# 18 · 빌드 · 테스트 · 점검 하네스 (SSOT) > 빌드·테스트·게이트·회귀 하네스·자가 점검 절차의 **단일 원천**. 절차를 바꾼 커밋에서 같이 고친다([16 §2-4](16-doc-git-conventions.md)). 설계 근거 = [port/44 §5](port/44-sql-ci-test-docs.md)(CI-101~120) · DR-10. ## 1. 전제 - 형제 저장소가 나란히: `../nexa-ui` · `../nexa-license`(path 의존) · 원본 `../nexa-dir2` · 기준 `../nexa-sql`(대조용). - 툴체인 = `rust-toolchain.toml`(stable · 5타깃 std). 교차 타깃 std가 없으면 `rustup target add x86_64-apple-darwin x86_64-unknown-linux-gnu`. - Windows MSVC 정적 CRT = `.cargo/config.toml`(`+crt-static` · `RUSTFLAGS` 환경 변수 금지). ## 2. 빌드 ```bash cargo build --workspace # Debug(의존 크레이트는 opt-level 2) cargo build --release -p nexa-dir # 배포 프로필(lto fat · panic abort · strip) cargo run -p nexa-dir -- --version # 버전 = 루트 Cargo.toml 하나 ``` ### 2-1. 동봉 플러그인(.wasm) 빌드 `plugins/sdk/plugins.list`(단일 출처)의 게스트 크레이트를 `scripts/plugin-build.sh`(mac/Linux) · `scripts/plugin-build.ps1`(Windows)이 `wasm32-unknown-unknown`으로 빌드해 `plugins/<이름>.wasm`(동봉본)에 복사한다. 사전 준비 `rustup target add wasm32-unknown-unknown`. 옵션 `--out-dir <폴더>`(스테이징) · `--skip-dist`(동봉본 유지). 동봉본은 dir2 dist **무수정**이 회귀 기준(DR-7)이므로 소스를 고친 뒤에만 갱신한다. 앱이 보는 폴더는 `NDIR_PLUGINS_DIR` → `<설정 폴더>/plugins` → `/plugins`(docs/port/20 §4-7). ## 3. push 전 게이트(단계형 · 빨강이면 push 금지 · DR-26) **기본 = `bash scripts/gate.sh`**(사용자 10-04 "모든 기능 수정에 전수 테스트를 수행할 필요는 없다 — 최근 수행 기록이 없거나 핵심 로직이 수정되어 영향도가 높은 경우가 아니라면 일정 기간 테스트가 되지 않은 경우에만 전수 · 배포 전 같은 중요 시점에 한 번 더"). 첫 줄에 고른 단계와 이유를 출력하고, 각 단계를 **종료 코드 · 결과 문자열로 판정**한 뒤에만 다음으로 간다(파이프 끝 명령의 종료 코드로 판정하지 않는다 — 10-03 §101 · 10-04 §1 교훈). **시험 단계는 cargo 종료 코드 + 실패 수로 판정**하고 출력의 `^error` 줄로 판정하지 않는다 — 런처 시험(`launch_runs_our_own_binary_and_reports_failure`)이 띄운 자식(시험 바이너리 `--version`)이 stderr에 늦게 찍는 `error: Unrecognized option: 'version'` 줄이 줄 맨 앞에 떨어지면 통과한 시험(448/0)을 실패로 읽었다(10-05 §2 · 094997f). 로그에 보이는 그 'version' 줄은 정상이다. | 단계 | 하는 일 | | --- | --- | | `quick` | fmt + 호스트 clippy(`check-3os.sh --quick` · `-D warnings`) + **마지막 전수 뒤 바뀐 크레이트만** `cargo test -p …`(ndir-i18n · ndir-settings가 바뀌면 nexa-dir 포함) + `--smoke` | | `full` | `check-3os.sh`(fmt + 3-OS clippy) + `cargo test --workspace` + `--smoke` + `--selfcheck --ci` + T4(`ndir-check --ci` — Windows 전제 3개 `ctx-menu` · `launcher` · `selfcheck-win`는 다른 OS에서 실패가 기준선 · T-117) → 통과하면 `target/gate/last-full`에 `시각 HEAD nexa-ui-HEAD nexa-license-HEAD` 기록(PC별 · git 미추적) | | `auto`(기본) | **full을 고르는 때**(하나라도): ① 이 PC에 전수 기록 없음 ② 마지막 전수가 `NDIR_GATE_FULL_HOURS`(기본 24)시간 경과 ③ 형제 저장소(nexa-ui · nexa-license) HEAD 변경 ④ 마지막 전수 뒤 **핵심 경로** 변경 — `crates/{ndir-core,ndir-vfs,ndir-tree,ndir-ops,ndir-term,ndir-license}/` · `crates/ndir-settings/src/lib.rs` · `crates/nexa-dir/src/platform/` · `crates/nexa-dir/Cargo.toml` · `crates/ndir-check/` · `Cargo.toml`/`Cargo.lock` · `.github/` · `scripts/` · `tests/scenarios/`. 그 밖 = quick | - **중요 시점 = `gate.sh full` 한 번 더**: 배포(패키징) · 버전 태그 · 마일스톤 마감 · 브랜치 병합 전. - **패키징 자원을 바꾼 커밋은 태그 전 로컬 패키징 1회**(10-08 v0.23.2 1차 실패 교훈): `packaging/windows/*.wxs` · `build-msi.ps1` · `packaging/lib.sh` 등은 `gate.sh`가 돌리지 않는다(WiX · deb/rpm/pkg 빌드는 release.yml에서만). Windows = `packaging/windows/build-msi.ps1` 로컬 1회(WiX 5 · XML 주석에 `--` 금지 = WIX0104) · 다른 OS 자원은 해당 OS 세션에서 — 그 OS가 없으면 **릴리스 선검증 `gh workflow run release.yml -f tag=vX.Y.Z`(초안)**로 대신(docs/16 §5-2 · 10-10 v0.24.0). 태그 뒤 실패하면 publish 전이라 외부 영향은 없지만 태그를 지우고 다시 찍어야 한다. - **quick으로 push했으면 CI(3-OS 전수)가 전수 역할** — push 뒤 CI 결과 확인이 필수다(협업 세션이 감시 · 빨강이면 즉시 보고). - `scripts/check-all.sh`는 **형제 저장소까지 도는 최대 전수**로 남긴다(nexa-ui → nexa-license → dir3 · 필요할 때 수동). - 아래는 각 단계를 손으로 돌릴 때의 명령(gate.sh가 내부에서 쓰는 것과 같다). ```bash scripts/check-all.sh [--quick] # ★ push 전 전체 게이트(T-05): 형제 저장소 → dir3 fmt·clippy·test → check-3os → smoke → selfcheck → 시나리오 → target/check-all/summary.txt scripts/check-3os.sh # T0: fmt + 호스트 clippy + 다른 두 OS clippy(-D warnings) cargo test --workspace # T1~T3 cargo run -q -p nexa-dir -- --smoke # 창 없음: 설정·i18n·자원·글꼴·플러그인 런타임·라이선스 루트 키 cargo run -q -p nexa-dir -- --selfcheck --ci # T5 부분집합(표시·사용자 자원 필요 항목 SKIP) ``` **Linux 개발 세션**(10-03 §101 · 이제 `gate.sh full`에 포함): `cargo build -p nexa-dir && cargo run -p ndir-check -- --ci`(T4) — CI는 T4를 Windows에서만 돌려, Linux에서 바꾼 기본값의 T4 기대값 누락이 main을 빨갛게 했다. Windows 전제 3개(`ctx-menu` · `launcher` · `selfcheck-win`)의 실패는 제외(T-117). 한 번에: `scripts/check-all.sh`(T-05에서 작성 · nexa-ui → nexa-license → dir3 순으로 fmt·clippy·test → 위 4단계 → `tests/out/summary.txt`). ## 4. 하네스 7층 | 층 | 무엇 | 도구 · 위치 | CI | 비고 | | --- | --- | --- | --- | --- | | T0 | 포맷 · 경고 0 · 3-OS 컴파일 | `cargo fmt` · `clippy -D warnings` · `scripts/check-3os.sh` | ✅ 3-OS | | | T1 | 순수 로직(정렬·필터·이름 규칙·경로·ops 계획·VT 파서·설정 레지스트리·i18n·라이선스 check·OS 분기 판정 MC/DC) | `#[cfg(test)]` · dir2 테스트 이식(DR-11) | ✅ | 실제 설정 폴더·사용자 파일 금지(`temp_dir()`/`from_text`) | | T2 | 컨트롤(nexa-ui 쪽): 히트 x·y · 포커스 링 ≤ 1 · 클립 · 스크롤 · 키 이동 | nexa-ctl `RecordCtx`(T-21에서 공용화) | ✅(nexa-ui CI) | 글자 폭 = 글자 × 7 고정 | | T3 | **앱 시나리오(창 없음)**: `AppCore::new(FakePlatform, 샌드박스)` → `route(InputEvent)`/`command(id)` → 상태·가짜 호출 기록·**레이아웃 덤프 골든**(영역 이름 + Rect 트리 · 픽셀 아님) | `crates/nexa-dir/tests/scenarios_*.rs` · 골든 `tests/golden/*.layout` | ✅ | "컨트롤 배치 유지" 자동 검증 수단 | | T4 | 실제 프로세스: 창 · present · 설정 영속 · 세션 복원 | `ndir-check`(T-06) + `NDIR_HOME` + `NDIR_STARTUP_CMD` + `*.dump:<파일>` + 검사식 `파일:정규식`/`!` · 시나리오 `tests/scenarios/*.scn` | ✅ Windows(CI 단계 `scenarios`) · ◐ Linux xvfb·macOS 후속 | 고정 sleep 대신 `@ready`/`@idle`/`quit` · 로컬 = `cargo build -p nexa-dir && cargo run -p ndir-check` | | T5 | 실제 OS 자원: 셸 · PTY · 휴지통 · 클립보드 · 컨텍스트 메뉴 · 플러그인 · 라이선스 | `nexa-dir --selfcheck [--ci] [--json] [--only 그룹] [--with-clipboard]` · Help ▸ 자가 점검 창(같은 함수) | ✅(`--ci`) | 격리 홈·임시 폴더 안에서만 · 네트워크 0 · 포트 공개 메서드를 그대로 호출 | | T6 | 성능·누수·용량·코드 건강 | `scripts/perf-baseline.sh`(기준선 저장/비교 · 회귀선) · `scripts/win-leak-cycle.ps1`(누수 주기) · `scripts/code-health.py` · `membench.ps1` — 절차 · 회귀선 = [25](25-perf-and-code-health.md)(T-176) | ✗(exe 예산만) | 마일스톤마다 · 배포 전 · Release | ## 5. 기동 명령 · 환경 변수(T4·T5 · 앱 훅) | 이름 | 뜻 | | --- | --- | | `NDIR_HOME` | 설정·세션·라이선스·플러그인 폴더 재지정(격리) | | `NDIR_STARTUP_CMD` | 쉼표 구분 명령: `<명령id>` · `@ready:<명령>`(첫 프레임 + 초기 열거 뒤) · `@idle:<명령>`(작업 큐 빈 뒤) · `@after::<명령>` · `quit[:코드]` · `assert.<대상>:<식>` · `ui.click:@<영역>`/`ui.key:<조합>` · `ui.type:`(`\b` = Backspace) · `ui.press:` · `list.select:` · `dock.kind:` · `term.focus` · `term.send:` · `ops.cancel` · `dlg.pick:`/`dlg.type:` · `ctx.pick:` · `ctx.wait`(우클릭 메뉴가 셸 항목과 함께 열릴 때까지 — §81부터 메뉴는 준비된 뒤 한 번만 열린다 — 뒤 `@ready` 명령 보류 · 대상 없으면 통과 · 20 s 상한 = 대기 명령만 건너뛰고 진행) · `ops.wait`(진행 중인 전송이 끝날 때까지 뒤 `@ready` 보류 · 같은 상한 · §81) · `prefs.search:`/`prefs.cat:<키>` · `dupes.wait` · `compare.wait` · `hash.wait` · `jobs.wait` · `dirsize.wait`(정보 도크 폴더 크기가 표시될 때까지) · `git.wait`(대기 명령 = `startup_cmd::is_wait_cmd` 단일 판정 · 대기 직전 예약 도크 갱신을 먼저 흘림 · 같은 상한 · 10-08 §18) · `<영역>.dump:<파일>`(layout·panel·list·tabs·status·menu·prefs·dock·preview·term·ops·dlg·pvwin·ctx·check·palette·favs·dupes·compare·hash·jobs·docktext·tabstatus — layout 덤프에 `log mem ` 줄 · ctx 덤프 kind `aux` = 즐겨찾기 팝업 · 상태줄 칸) | | `.scn` `tree:` 어휘 | `dir <경로>` · `file <경로> [<바이트>]` · `text <경로> <내용>`(`\n` = 줄바꿈) · `zip <경로> <이름=내용>;…`(Store zip 자체 생성 · CRC-32 · 10-08 §18) · `git <경로>`(init + 첫 커밋 + 수정 1 + 미추적 1 → 탭 상태바 `!1 ?1` · git 필요) — 원천 = `crates/ndir-check/src/main.rs` 머리 주석 | | `NDIR_NO_ACTIVATE` | 창을 앞으로 가져오지 않음(사용자 작업 방해 금지) | | `.scn` OS 꼬리표 | 시나리오의 **모든 키**에 `@`를 붙이면 그 OS에서만 쓴다 — `windows` · `linux` · `macos` · `unix`(= linux + macos) · 예: `settings@windows: launcher.items=Shell\|cmd.exe\|/c exit` / `settings@unix: launcher.items=Shell\|/bin/sh\|-c exit` · `check@windows: bg.txt: bg shell:` · 모르는 꼬리표 = 파싱 오류(10-05 §29 · 864a0de · `ndir-check::os_matches` · `parse_for`) | | `NDIR_TRACE_FRAMES` / `_IME` / `_CLIP` / `_WINDOW` | 추적 로그(stderr) — `_FRAMES` = 메인 창 프레임마다 `[frame] n= wait= paint= present=` µs(집계 `scripts/frame-stats.py` · [25 §5-4](25-perf-and-code-health.md)) | | `NDIR_TERM_TRACE` | `<파일>` — 터미널 PTY에서 읽은 **원시 바이트**를 그 파일에 덧붙인다(conhost가 보내는 이스케이프·커서 이동·글자 폭 진단 · §81). `term.dump` 끝에는 PTY에 마지막으로 준 크기 `pty CxR`가 찍힌다 | | `NDIR_FAKE_CLIPBOARD` | `1`(빈 값·`0` = 꺼짐) — 텍스트·파일 클립보드를 **프로세스 안 가짜**로 쓴다(OS 클립보드를 읽지도 쓰지도 않음 · §88). `ndir-check`가 모든 T4 시나리오 프로세스에 항상 넣는다 · 검토용 격리 캡처 실행에도 넣는다 · **사용자 인스턴스에는 넣지 않는다** | | 덤프 어휘 | `layout` · `panel` · `list` · `tree` · `tabs` · `status` · `menu` · `ops` · `term` · `preview` · `prefs` · `plugin` · `license` · `log` — 컨트롤·패널마다 `dump()` 하나가 구현 요건 | ## 6. 자가 점검(`--selfcheck`) 항목 > 창: **Help ▸ 자가 점검…**(`help.selfcheck` · T-54) — 같은 `selfcheck::run`을 작업 스레드에서 돌려 표로 보이고 [복사]는 CLI 표 텍스트와 동일. 기동 명령 `help.selfcheck` + `check.dump:<파일>`/`assert.check:<식>`으로 시나리오 검증(`selfcheck-win.scn`). | 그룹 | 점검 | `--ci` | | --- | --- | --- | | env | OS·아키·버전·빌드일 · 홈/임시 폴더 쓰기 | 실행 | | config | 레지스트리 로드 · 기본값 전수 유효 · 저장→재적재 왕복(격리) · 이주 표 | 실행 | | resources | 내장 아이콘 전수 디코드 · 언어 파일 3종 키 파리티 · 샘플 | 실행 | | fonts | UI·고정폭 글꼴 · 한글 지원 | 실행 | | fs | 샌드박스에서 만들기·복사·이동·이름 바꾸기·삭제 · 긴 경로 · 유니코드(NFD) · 대소문자 감지 · 감시 통지 | 실행 | | trash | 샌드박스 파일 휴지통 이동 | SKIP | | shell | 기본 셸 탐지 · 실행 파일 존재 | 실행 | | pty | PTY 생성 → `echo <표식>` → 수신 → 종료 | 실행(WARN으로 시작) | | ctxmenu | 백엔드 가용성 · 샘플 항목 수 | SKIP | | clipboard | 텍스트·파일 왕복(`--with-clipboard` opt-in) | SKIP | | dnd · open | 등록 가능 여부 · 열기 수단 존재 | SKIP / 실행 | | preview · plugin · archive | 내장 디코드 · WASM 런타임 · 동봉 플러그인 적재 · 고정물 기대 출력 · 연료/메모리 상한 | 실행 | | license | 루트 키 등재 · 시험 벡터 검증 · 기기 ID · 설치 상태 · 경로 쓰기 | 실행 | | window | (창 모드) 표면 생성 · 첫 프레임 · 배율 | SKIP | 출력 = 표(사람) 또는 `--json` · 항목 = PASS/FAIL/WARN/SKIP(사유) + ms · 종료 코드 = FAIL 수. 항목 하나의 실패가 다음을 막지 않는다. ## 7. 실행 중 진단(DR-10) - 기동 점검(수 ms): 설정 읽기 실패 · 자원 누락 · 플러그인 폴더 · 셸 탐지 실패 · 라이선스 손상 → 상태줄 배지 + 로그 + 토스트. - 패닉 훅 → `/crash/<시각>.txt`(메시지·위치·버전·OS·마지막 명령 id) · 다음 기동 때 안내. - 기능 실패(휴지통 불가 · PTY 실패 · 메뉴 백엔드 없음)는 사유 문구를 한 번만. ## 8. CI(`.github/workflows/ci.yml`) 3-OS 매트릭스 · 형제 2 체크아웃 · Linux 한글 글꼴 설치 · fmt → clippy → test → `--smoke` → `--selfcheck --ci` → `plugins` 잡(T-07 ✅: `plugin-build.sh` → `NDIR_PLUGINS_DIR` 자가 점검 `plugin` 로드 검증) → (Windows) 임포트 화이트리스트·용량 측정. 별도 `e2e.yml`(수동·main): Linux `xvfb-run` + `ndir-check`. 릴리스 = `release.yml`(M7). **릴리스 함정 체크리스트(10-05 §70 · v0.23.0 1·2차 실패)**: ① 셸 스크립트(`packaging/**/*.sh` · `scripts/*.sh`)는 git 모드 **100755** — Windows에서 새로 만들면 100644가 되므로 `git add --chmod=+x`(확인 = `git ls-files -s '*.sh'`) ② 앱은 창 서브시스템이라 PowerShell `& exe --version` 캡처 = 빈 값(기다리지 않음) → 스크립트에서 CLI 출력을 판정할 때는 `Start-Process -Wait -RedirectStandardOutput` 파일 · bash/cmd 리다이렉트 · CLI 출력은 `say!` 매크로(쓰기 실패 무시 · panic 금지) ③ 태그 이동은 **Release 공개 전**에만(`gh release view ` = not found 확인) ④ 게시 규칙 = [packaging/README.md](../packaging/README.md). ## 9. 검증 매트릭스 · 교차 검증 [port/90-verification-matrix.md](port/90-verification-matrix.md): 행 = 이식 원장 ID · 열 = 구현 위치 · 시험 층 · 시험 이름/시나리오 id · 상태(☐/🚧/✅/의도된 차이/실기 필요). 규칙 "기능 ID 1개 = 시험 1개 이상 또는 사유". 마일스톤 끝마다 빈칸을 센다. ## 10. 실기 시험 규칙 - 격리: `NDIR_HOME=<임시>`로 띄우고 **격리 폴더의 `settings.conf` 수정 시각**과 실제 설정 폴더 불변을 확인. - 입력 주입·포커스 탈취 금지(사용자가 자리에 있을 때) — 기동 명령으로 몰고 창 단위 캡처(Windows `PrintWindow` · macOS `screencapture -l`). - 프로세스는 내가 띄운 PID만 종료(예외: 이 저장소 `target/` 아래 `nexa-dir.exe`). **이름으로 죽이지 않는다**(`pkill -x nexa-dir` 금지) — 같은 PC에서 `ndir-check --ci`(T4)가 같은 이름의 시나리오 프로세스를 띄우므로 함께 죽어 시나리오가 exit로 실패한다(10-04 §10 · 협업 세션 재시작 사고). 띄운 PID를 파일에 적어 두고 그 PID만 `kill`. - 사용자 클립보드를 덮어쓰는 명령은 자동 시험에 넣지 않는다. - **텍스트 클립보드 = 시험 빌드에서 가짜**(§87): `clipboard::{read_text, write_text, write_rich}`는 `#[cfg(test)]`에서 스레드별 가짜(`thread_local FAKE`)만 쓴다 — T1~T3(`cargo test`)은 OS 클립보드를 읽지도 쓰지도 않는다(시험 `test_build_never_touches_the_os_clipboard`). 파일 클립보드는 `platform` 가짜 포트. - **T4(실제 exe) = env 가짜 클립보드**(§88 · §87 적발 해소): `ndir-check`가 시나리오 프로세스에 `NDIR_FAKE_CLIPBOARD=1`을 넣어, `copy-paste.scn` · `ctx-menu.scn`의 `edit.copy`도 프로세스 안 `MemoryFiles`/`MEMORY`로만 간다. 검증 = 게이트 전후 `GetClipboardSequenceNumber`(읽기만) 비교 — journal §88에 수치. - `--selfcheck --with-clipboard`(opt-in)만 사용자가 명시로 켤 때 실제 클립보드를 쓴다. - **키 주입 경로를 구분한다**(§93): 기동 명령 `ui.press:<키>`는 **키맵을 우회**해 컨트롤에 키 사건을 직접 넣는다 — 키맵이 가로채는 키(Enter · Alt+↓ 등)의 실제 동작은 검증되지 않는다. 키맵 경로는 `ui.key:<조합>`(키맵 조회 → 명령 · 타이핑/메뉴 통과 규칙은 미적용)으로, T3는 `App::key_chord`로 넣는다. 다음 하네스 = 실제 경로 전체(`key_chord`)를 타는 기동 명령. - **경로 비교 시험은 양쪽을 `fs::canonicalize` 뒤 비교**한다(§90): CI 러너의 `TEMP`는 8.3 짧은 이름(`C:\Users\RUNNER~1\…`)이라 OS API가 돌려주는 긴 이름과 문자열이 다르다 — 로컬(TEMP = 긴 이름)에서는 통과하고 CI에서만 깨진다. 지우기 전에 계산. - **OS 상태를 타는 값은 시험에서 먼저 고정**(§106 · §116 — 같은 유형 2번): 테마(`system`) · Windows Terminal 프로필(`app.wt_profile`) · 로캘 · 런처 시드 · 아이콘 테마처럼 OS 설정으로 풀리는 값에 기대는 단언은 시험 시작에서 그 값을 고정한다(예: `ui.theme=dark` · `wt_profile = None`) — 이 PC(Linux · dark · WT 없음)에서 통과해도 windows · macos 러너는 다르다. 기본값 자체는 설정 단위 시험이 본다. **Windows 분기/Windows 상태는 이 PC에서 검증되지 않으므로 push 뒤 CI windows 결과 확인을 게이트로 본다.** - **OS 분기 안의 단언은 이 PC에서 돌지 않는다**(§106): `platform::has_dotfile_toggle()` 같은 분기로 OS마다 기대값이 다른 시험을 건드리면 두 분기를 손으로 따져 보고, push 뒤 CI windows/macos 결과를 확인한다(e5790ca — Linux 게이트 녹색 · CI windows 빨강). - **프로세스 전역을 시험하지 않는다**(§90): nexa-grid/nexa-ctl 고속 스크롤처럼 프로세스 공유 전역은 병렬 시험(다른 시험의 `App` 생성)이 덮어쓴다 → 구성 계산을 순수 함수로 떼어 그것을 확인한다(`App::scroll_configs`).