From 83ae96e086dede0235aa80139d93249f40b215e4 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 06:23:49 -0800 Subject: [PATCH] v1.92.30.0 test(free): split files over the 60 s seed ceiling and the Windows doctor file, then reseed (GSTA-151 A) (#3585) * test(free): split files over the 60 s seed ceiling and the Windows doctor file Eight free test files the fresh recordings put over the seed ratchet's 60 s ceiling, and test/gstack-doctor.test.ts (343 s on Windows), are split by describe block or test with shared helpers moved to test/helpers/. Test bodies are unchanged. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(todos): note the GSTA-151 seed-ceiling splits Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(free): reseed both duration seeds at this head Recorded by free-durations-record.yml (open_pr=false) run 40818261447 at ec6faf1: 1,416 Linux files on ubicloud-standard-8 and the 795 curated Windows files on windows-latest. test/setup-codex-scope.test.ts fell to 49 s and leaves the seed allowlist. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: changelog entry for the GSTA-151 seed-ceiling splits (unreleased) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(free): split test/ubicloud-runner.test.ts over the 60 s seed ceiling; keep setup-codex-scope allowlisted The reseed at the main merge (free-durations-record run 41226501374, 221320e) put test/ubicloud-runner.test.ts, which main added after this branch's first reseed, at 127 s and test/setup-codex-scope.test.ts back at 66 s. The fake-API cases move into four parts by describe topic with their shared fake VM in test/helpers/ubi-runner-fake-vm.ts (46 tests before and after). The allowlist keeps main's setup-codex-scope entry. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(free): reseed both duration seeds at the main merge; changelog fragment Recorded by free-durations-record.yml (open_pr=false) run 41229924108 at 4f5bd2d: 1,470 Linux files on ubicloud-standard-8 and 822 curated Windows files. The Windows recorder was red on browse/test/cookie-import-native-job.test.ts (known Edge leak) and test/hook-scripts.test.ts, plus test/free-seed-ratchet.test.ts on both OSes against the pre-reseed seed; their durations are kept. The CHANGELOG entry moves to changelog.d/ now that main runs on fragments. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: correct the seed-ceiling TODO and the ubicloud-runner test paths after the split Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 14 + TODOS.md | 12 +- VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/TESTING_INTERNALS.md | 4 +- package.json | 2 +- scripts/free-test-durations-windows.json | 1550 +++++----- scripts/free-test-durations.json | 2706 +++++++++-------- scripts/lib/windows-curation.ts | 59 + scripts/typecheck-test-baseline.json | 2 +- test/claude-overlay-setup-installs.test.ts | 33 +- test/claude-overlay-setup-lifecycle.test.ts | 52 +- test/claude-overlay-setup-project.test.ts | 41 + test/claude-overlay-setup-recovery.test.ts | 60 + test/docsync-fault-interface-faults.test.ts | 32 + test/docsync-fault-interface-legacy.test.ts | 32 + test/docsync-fault-interface.test.ts | 380 +-- test/gstack-doctor-check.test.ts | 373 +++ test/gstack-doctor-launch.test.ts | 403 +++ test/gstack-doctor.test.ts | 882 +----- test/helpers/docsync-fault-callbacks.ts | 386 +++ test/helpers/doctor-fixture.ts | 131 + test/helpers/free-fixtures-data.ts | 4 +- test/helpers/host-renders-prune-fixture.ts | 31 + test/helpers/setup-codex-scope-cyclic.ts | 37 + test/helpers/shared-libs-checker-evidence.ts | 212 ++ test/helpers/ship-queue-train-cases.ts | 29 + test/helpers/ubi-runner-fake-vm.ts | 119 + test/host-renders-prune-migration.test.ts | 76 + test/host-renders-prune-shared-root.test.ts | 28 + test/host-renders-prune.test.ts | 105 +- test/setup-codex-scope-aliases.test.ts | 70 + test/setup-codex-scope-namespaces.test.ts | 60 +- ...etup-codex-scope-review-boundaries.test.ts | 40 +- test/setup-codex-scope-runtime-links.test.ts | 16 + ...hecker-interface-evidence-declared.test.ts | 178 ++ ...checker-interface-evidence-replays.test.ts | 156 + ...ed-libs-checker-interface-evidence.test.ts | 501 +-- test/ship-queue-train-bisect.test.ts | 201 ++ test/ship-queue-train-freeze.test.ts | 195 ++ test/ship-queue-train-verify.test.ts | 258 ++ test/ship-queue-train.test.ts | 627 +--- test/test-of-test-ratchet.test.ts | 9 + test/ubicloud-runner-reattach.test.ts | 157 + test/ubicloud-runner-run.test.ts | 139 + test/ubicloud-runner-tiers.test.ts | 182 ++ test/ubicloud-runner-verify-quota.test.ts | 184 ++ test/ubicloud-runner.test.ts | 732 +---- test/windows-native-workflows.test.ts | 2 +- 49 files changed, 6111 insertions(+), 5395 deletions(-) create mode 100644 test/claude-overlay-setup-project.test.ts create mode 100644 test/claude-overlay-setup-recovery.test.ts create mode 100644 test/docsync-fault-interface-faults.test.ts create mode 100644 test/docsync-fault-interface-legacy.test.ts create mode 100644 test/gstack-doctor-check.test.ts create mode 100644 test/gstack-doctor-launch.test.ts create mode 100644 test/helpers/docsync-fault-callbacks.ts create mode 100644 test/helpers/doctor-fixture.ts create mode 100644 test/helpers/host-renders-prune-fixture.ts create mode 100644 test/helpers/setup-codex-scope-cyclic.ts create mode 100644 test/helpers/shared-libs-checker-evidence.ts create mode 100644 test/helpers/ship-queue-train-cases.ts create mode 100644 test/helpers/ubi-runner-fake-vm.ts create mode 100644 test/host-renders-prune-migration.test.ts create mode 100644 test/host-renders-prune-shared-root.test.ts create mode 100644 test/setup-codex-scope-aliases.test.ts create mode 100644 test/setup-codex-scope-runtime-links.test.ts create mode 100644 test/shared-libs-checker-interface-evidence-declared.test.ts create mode 100644 test/shared-libs-checker-interface-evidence-replays.test.ts create mode 100644 test/ship-queue-train-bisect.test.ts create mode 100644 test/ship-queue-train-freeze.test.ts create mode 100644 test/ship-queue-train-verify.test.ts create mode 100644 test/ubicloud-runner-reattach.test.ts create mode 100644 test/ubicloud-runner-run.test.ts create mode 100644 test/ubicloud-runner-tiers.test.ts create mode 100644 test/ubicloud-runner-verify-quota.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aea2f170c..03bf564a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.92.30.0] - 2026-11-01 + +**The free suite's shards finish closer together: slow test files are split and the packer plans from fresh timings.** The committed duration seed was missing more than a hundred free test files, so CI guessed the same time for every shard and the slowest shard ran far past the mean. No product code changed. Every split file runs the same tests as before. + +### Changed +- **Split free test files.** Each of these went over the 60 s seed ceiling and is now two to five files, with test bodies moved unchanged and shared setup moved into `test/helpers/`: `test/host-renders-prune.test.ts`, `test/ship-queue-train.test.ts`, `test/claude-overlay-setup-lifecycle.test.ts`, `test/claude-overlay-setup-installs.test.ts`, `test/setup-codex-scope-namespaces.test.ts`, `test/setup-codex-scope-review-boundaries.test.ts`, `test/docsync-fault-interface.test.ts`, `test/shared-libs-checker-interface-evidence.test.ts` and `test/ubicloud-runner.test.ts`. The last one was 127 s and is now five files of 16 to 31 s, with its fake Ubicloud API and fake VM in `test/helpers/ubi-runner-fake-vm.ts`. +- **Windows lane.** `test/gstack-doctor.test.ts`, the Windows lane's longest file, is now three files. +- **Duration seeds.** `scripts/free-test-durations.json` (1,470 Linux files) and `scripts/free-test-durations-windows.json` (822 curated Windows files) were re-recorded at this branch by `free-durations-record.yml` run 41229924108. Linux files over 5 s recorded a median 1.26x slower than this branch's earlier recording on 2026-10-29 (run 40818261447). A second recording, run 41226501374, matched that at 1.29x, so the slowdown is the runner's current speed, not one noisy run. + +### Gate integrity +- `scripts/free-test-seed-allowlist.json` is unchanged from main. `test/setup-codex-scope.test.ts` stays on it at 77 s. No entry was added. +- `scripts/lib/windows-curation.ts` gives each new part the same Windows scope as the file it came from. `test/ubicloud-runner-tiers.test.ts` gets an explicit exclusion, because its shims now live in a helper the source scan doesn't read. +- `test/test-of-test-ratchet.test.ts` lists the split files whose product reach now lives in a shared helper, each with its reason. `scripts/typecheck-test-baseline.json` moves one known diagnostic, with its code, into `test/helpers/shared-libs-checker-evidence.ts`. + ## [1.92.29.0] - 2026-11-01 - `/ship`'s review collector no longer turns a severity word in a negation into a finding. A fix-check reader that closed with "found nothing CRITICAL" was recorded as a CRITICAL finding with no file or line, and the judge can't drop a finding, so the run carried a phantom blocker that could stop the review loop at its cap (GST-766, from GEVA-92 on gstack-evals #108). A label right after "no", "nothing", "not", "none", "zero", "without" or "never" (optionally followed by a word like "new" or "remaining", and through Markdown emphasis or brackets), and a label joined to it by a comma, a slash, "or", "nor" or "and", now counts as no finding. A label that opens a field ("High: …") is still collected, and a prose finding worded around a negated label still arrives through its Severity or Blocks field. diff --git a/TODOS.md b/TODOS.md index cdbabf43e..a95320ccd 100644 --- a/TODOS.md +++ b/TODOS.md @@ -5537,12 +5537,12 @@ Garry for each; revisit 2026-10-29 unless a date says otherwise. (`['paste', 'enter', 'end_turn']`). Wait for the CLI's own write instead of a timer (docs/TESTING_INTERNALS.md#flake-ledger). **Effort:** S. **Priority:** P2. - **Split the free files the weekly seed puts over 60 s** — - `test/setup-install-registry.test.ts` (84 s), `test/brain-sync.test.ts` (89 s) - and `test/setup-codex-scope.test.ts` (64 s) are allowlisted with this todo in - `scripts/free-test-seed-allowlist.json` (2026-10-19 recording); - `test/host-renders-prune.test.ts` (57 s) and - `test/setup-codex-scope-namespaces.test.ts` are next. Split - each by describe block, rerun `bun run test:ubicloud --record-durations` and + `test/setup-install-registry.test.ts` (117 s), `test/brain-sync.test.ts` (91 s) + and `test/setup-codex-scope.test.ts` (77 s) are allowlisted with this todo in + `scripts/free-test-seed-allowlist.json` (2026-10-31 recording, run 41229924108). + `test/host-renders-prune.test.ts`, `test/setup-codex-scope-namespaces.test.ts` + and seven other files the recordings put over 60 s were split in + GSTA-151. Split each remaining file by describe block, rerun `bun run test:ubicloud --record-durations` and delete the entries. **Effort:** S each. **Priority:** P2. ## /write-skill follow-ups (filed via /autoplan on GSTA-133, 2026-10-27) diff --git a/VERSION b/VERSION index 304ceb3b1..717977a22 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.29.0 +1.92.30.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 713bc8924..4b041fd78 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.29.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.30.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md index ef9fcbe25..634336631 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -1031,7 +1031,7 @@ repository content: its setup script and pull destinations must resolve inside `--src`, and a name it lists under `pass` is forwarded only when the command line grants it (`--pass NAME`, or all of them with `--trust-config`); `--pass` never forwards `UBICLOUD_*`. All of this is pinned offline in -`test/ubicloud-runner.test.ts` against a fake API and a fake `ssh`, including +`test/ubicloud-runner*.test.ts` against a fake API and a fake `ssh`, including a fixture whose fake API creates, so `run` and `verify` execute end to end. `run` and `verify` start their setup, command and tier phases detached on the @@ -1047,7 +1047,7 @@ start/pass/fail per tier under `UBI_CHECKPOINT_DIR//`, so a resume on a fresh VM reruns setup and only the tiers without a pass; `pregate-remote.sh` adds its tree and planned-set digest to the key, so a rerun of `gstack-pregate --stage tests --remote` reattaches and bills from the original -`created_at`. The fake VM in `test/ubicloud-runner.test.ts` runs these phases +`created_at`. The fake VM in `test/helpers/ubi-runner-fake-vm.ts` runs these phases for real in a directory. Under 8 cores (`gstack-doctor`'s `cores` row says so), run the full unit and diff --git a/package.json b/package.json index b928548a3..fe6b88f7a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.29", + "version": "1.92.30", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/scripts/free-test-durations-windows.json b/scripts/free-test-durations-windows.json index 8be52d252..e09083bad 100644 --- a/scripts/free-test-durations-windows.json +++ b/scripts/free-test-durations-windows.json @@ -1,750 +1,828 @@ { "version": 1, - "recordedAt": "2026-10-19T04:37:35.081Z", + "recordedAt": "2026-10-31T18:53:46.380Z", "durations": { - "browse/test/activity.test.ts": 126, - "browse/test/adversarial-security.test.ts": 99, - "browse/test/batch.test.ts": 6701, - "browse/test/bridge-chromium-e2e.test.ts": 4235, - "browse/test/browse-client.test.ts": 162, - "browse/test/browse-eval-wrapping.test.ts": 77, - "browse/test/browser-manager-custom-chromium.test.ts": 1019, - "browse/test/browser-skills-storage.test.ts": 210, - "browse/test/build-command-response.test.ts": 899, - "browse/test/build.test.ts": 173, - "browse/test/bun-child-config.test.ts": 109, - "browse/test/bun-polyfill.test.ts": 2432, - "browse/test/busy-daemon-iron-rule.test.ts": 7824, - "browse/test/busy-daemon-recovery.test.ts": 707, - "browse/test/cdp-allowlist.test.ts": 61, - "browse/test/cdp-e2e.test.ts": 1145, - "browse/test/cdp-inspector-history-cap.test.ts": 63, - "browse/test/cdp-mutex.test.ts": 1151, - "browse/test/cdp-session-cleanup.test.ts": 64, - "browse/test/claude-bin.test.ts": 56, - "browse/test/cli-chain-stdin.test.ts": 365, - "browse/test/cli-config-mismatch-message.test.ts": 215, - "browse/test/cli-lock.test.ts": 85, - "browse/test/cli-loopback-no-proxy.test.ts": 76, - "browse/test/cli-setsid-daemonize.test.ts": 114, - "browse/test/cli-start-final-healthcheck.test.ts": 139, - "browse/test/cli-supervisor.test.ts": 70, - "browse/test/content-security.test.ts": 1551, - "browse/test/cookie-auth-verification.test.ts": 18096, - "browse/test/cookie-credential-deadline.test.ts": 710, - "browse/test/cookie-database.test.ts": 735, - "browse/test/cookie-fixture-delete-lease.test.ts": 1273, - "browse/test/cookie-import-browser.test.ts": 98, - "browse/test/cookie-import-native-job.test.ts": 127403, - "browse/test/cookie-import-native.test.ts": 9044, - "browse/test/cookie-import-node.test.ts": 5402, - "browse/test/cookie-import-operation.test.ts": 2846, - "browse/test/cookie-import-reliability.test.ts": 1849, - "browse/test/cookie-import-transport.test.ts": 73, - "browse/test/cookie-picker-binding-browser.test.ts": 3657, - "browse/test/cookie-picker-binding.test.ts": 866, - "browse/test/cookie-picker-csrf-browser.test.ts": 1082, - "browse/test/cookie-picker-routes.test.ts": 570, - "browse/test/cookie-picker-ui.test.ts": 42854, - "browse/test/daemon-log-hygiene.test.ts": 85, - "browse/test/daemon-mismatch-refuse.test.ts": 259, - "browse/test/data-platform.test.ts": 110, - "browse/test/domain-skills-e2e.test.ts": 1046, - "browse/test/domain-skills-storage.test.ts": 159, - "browse/test/dotenv-isolation.test.ts": 483, - "browse/test/dual-listener.test.ts": 1209, - "browse/test/dx-polish.test.ts": 62, - "browse/test/error-handling.test.ts": 49, - "browse/test/extension-sender-auth.test.ts": 84, - "browse/test/extension-token.test.ts": 2607, - "browse/test/file-drop.test.ts": 70, - "browse/test/file-permissions.test.ts": 991, - "browse/test/fill-change-event.test.ts": 3014, - "browse/test/find-browse.test.ts": 205, - "browse/test/from-file-path-validation.test.ts": 102, - "browse/test/handoff.test.ts": 7216, - "browse/test/headless-gpu-and-reap.test.ts": 1282, - "browse/test/headless-launch-options.test.ts": 571, - "browse/test/launch-signal-flags.test.ts": 38, - "browse/test/learnings-injection.test.ts": 2242, - "browse/test/media-extract-unit.test.ts": 61, - "browse/test/memory-command.test.ts": 1103, - "browse/test/memory-leak-reproducer.test.ts": 577, - "browse/test/navigation-guard.test.ts": 3749, - "browse/test/output-path-symlinks.test.ts": 41, - "browse/test/pair-agent-optin-gate.test.ts": 788, - "browse/test/path-security-realpath.test.ts": 681, - "browse/test/pdf-flags.test.ts": 72, - "browse/test/playwright-core-patch.test.ts": 58, - "browse/test/poisoned-bundle-probe.test.ts": 443, - "browse/test/process-liveness-windows.test.ts": 243, - "browse/test/proxy-config.test.ts": 58, - "browse/test/proxy-env-daemon.test.ts": 144, - "browse/test/proxy-env.test.ts": 65, - "browse/test/proxy-redact.test.ts": 40, - "browse/test/pty-inject-scan.test.ts": 1533, - "browse/test/pty-session-lease.test.ts": 51, - "browse/test/rebrand-signed-bundle.test.ts": 55, - "browse/test/regression-pr1169-pdf-from-file-invalid-json.test.ts": 107, - "browse/test/restart-env.test.ts": 73, - "browse/test/runtime-root-server-bundle.test.ts": 96, - "browse/test/sanitize.test.ts": 56, - "browse/test/screenshot-size-guard.test.ts": 436, - "browse/test/security-adversarial-fixes.test.ts": 58, - "browse/test/security-adversarial.test.ts": 54, - "browse/test/security-bench.test.ts": 45, - "browse/test/security-classifier-download-cleanup.test.ts": 68, - "browse/test/security-classifier.test.ts": 46, - "browse/test/security-integration.test.ts": 67, - "browse/test/security-live-playwright.test.ts": 1859, - "browse/test/security-sidecar-client.test.ts": 85, - "browse/test/security.test.ts": 52, - "browse/test/server-auth.test.ts": 1043, - "browse/test/server-factory.test.ts": 3576, - "browse/test/server-flush-trackers.test.ts": 43, - "browse/test/server-launch-resolution.test.ts": 61, + "browse/test/activity.test.ts": 160, + "browse/test/adversarial-security.test.ts": 107, + "browse/test/batch.test.ts": 7076, + "browse/test/bridge-chromium-e2e.test.ts": 4156, + "browse/test/browse-client.test.ts": 206, + "browse/test/browse-eval-wrapping.test.ts": 68, + "browse/test/browser-manager-custom-chromium.test.ts": 1339, + "browse/test/browser-skills-storage.test.ts": 146, + "browse/test/build-command-response.test.ts": 1483, + "browse/test/build.test.ts": 175, + "browse/test/bun-child-config.test.ts": 204, + "browse/test/bun-polyfill.test.ts": 2872, + "browse/test/busy-active-tab.test.ts": 14656, + "browse/test/busy-daemon-iron-rule.test.ts": 8417, + "browse/test/busy-daemon-recovery.test.ts": 735, + "browse/test/cdp-allowlist.test.ts": 96, + "browse/test/cdp-e2e.test.ts": 1235, + "browse/test/cdp-inspector-history-cap.test.ts": 67, + "browse/test/cdp-mutex.test.ts": 894, + "browse/test/cdp-session-cleanup.test.ts": 79, + "browse/test/claude-bin.test.ts": 62, + "browse/test/cli-chain-stdin.test.ts": 301, + "browse/test/cli-config-mismatch-message.test.ts": 145, + "browse/test/cli-lock.test.ts": 59, + "browse/test/cli-loopback-no-proxy.test.ts": 65, + "browse/test/cli-setsid-daemonize.test.ts": 56, + "browse/test/cli-start-final-healthcheck.test.ts": 82, + "browse/test/cli-supervisor.test.ts": 87, + "browse/test/command-timeout-report.test.ts": 191, + "browse/test/content-security.test.ts": 3036, + "browse/test/cookie-auth-verification.test.ts": 20125, + "browse/test/cookie-credential-deadline.test.ts": 373, + "browse/test/cookie-database.test.ts": 802, + "browse/test/cookie-fixture-delete-lease.test.ts": 1189, + "browse/test/cookie-import-browser.test.ts": 109, + "browse/test/cookie-import-native-job.test.ts": 122469, + "browse/test/cookie-import-native.test.ts": 7715, + "browse/test/cookie-import-node.test.ts": 5035, + "browse/test/cookie-import-operation.test.ts": 767, + "browse/test/cookie-import-reliability.test.ts": 1141, + "browse/test/cookie-import-transport.test.ts": 61, + "browse/test/cookie-picker-binding-browser.test.ts": 3337, + "browse/test/cookie-picker-binding.test.ts": 658, + "browse/test/cookie-picker-csrf-browser.test.ts": 1143, + "browse/test/cookie-picker-routes.test.ts": 590, + "browse/test/cookie-picker-ui.test.ts": 41255, + "browse/test/daemon-log-hygiene.test.ts": 164, + "browse/test/daemon-mismatch-refuse.test.ts": 303, + "browse/test/data-platform.test.ts": 78, + "browse/test/domain-skills-e2e.test.ts": 903, + "browse/test/domain-skills-storage.test.ts": 126, + "browse/test/dotenv-isolation.test.ts": 880, + "browse/test/dual-listener.test.ts": 1383, + "browse/test/dx-polish.test.ts": 158, + "browse/test/error-handling.test.ts": 58, + "browse/test/extension-sender-auth.test.ts": 141, + "browse/test/extension-token.test.ts": 2610, + "browse/test/file-drop.test.ts": 65, + "browse/test/file-permissions.test.ts": 565, + "browse/test/fill-change-event.test.ts": 2154, + "browse/test/find-browse.test.ts": 140, + "browse/test/from-file-path-validation.test.ts": 67, + "browse/test/handoff.test.ts": 6209, + "browse/test/headless-gpu-and-reap.test.ts": 966, + "browse/test/headless-launch-options.test.ts": 763, + "browse/test/launch-signal-flags.test.ts": 241, + "browse/test/learnings-injection.test.ts": 2070, + "browse/test/media-extract-unit.test.ts": 51, + "browse/test/memory-command.test.ts": 920, + "browse/test/memory-leak-reproducer.test.ts": 816, + "browse/test/navigation-guard.test.ts": 7147, + "browse/test/output-path-symlinks.test.ts": 44, + "browse/test/pair-agent-optin-gate.test.ts": 863, + "browse/test/path-security-realpath.test.ts": 97, + "browse/test/pdf-flags.test.ts": 51, + "browse/test/playwright-core-patch.test.ts": 57, + "browse/test/poisoned-bundle-probe.test.ts": 378, + "browse/test/process-liveness-windows.test.ts": 2658, + "browse/test/proxy-config.test.ts": 57, + "browse/test/proxy-direct-launch-args.test.ts": 415, + "browse/test/proxy-direct-launch-paths.test.ts": 430, + "browse/test/proxy-direct-launch.test.ts": 60, + "browse/test/proxy-env-daemon.test.ts": 107, + "browse/test/proxy-env.test.ts": 94, + "browse/test/proxy-redact.test.ts": 57, + "browse/test/pty-inject-scan.test.ts": 1434, + "browse/test/pty-session-lease.test.ts": 57, + "browse/test/rebrand-signed-bundle.test.ts": 44, + "browse/test/regression-pr1169-pdf-from-file-invalid-json.test.ts": 76, + "browse/test/restart-env.test.ts": 65, + "browse/test/runtime-root-server-bundle.test.ts": 78, + "browse/test/sanitize.test.ts": 52, + "browse/test/screenshot-size-guard.test.ts": 386, + "browse/test/security-adversarial-fixes.test.ts": 65, + "browse/test/security-adversarial.test.ts": 63, + "browse/test/security-bench.test.ts": 93, + "browse/test/security-classifier-download-cleanup.test.ts": 92, + "browse/test/security-classifier.test.ts": 61, + "browse/test/security-integration.test.ts": 71, + "browse/test/security-live-playwright.test.ts": 1160, + "browse/test/security-sidecar-client.test.ts": 122, + "browse/test/security.test.ts": 74, + "browse/test/server-auth.test.ts": 1019, + "browse/test/server-factory.test.ts": 4248, + "browse/test/server-flush-trackers.test.ts": 50, + "browse/test/server-launch-resolution.test.ts": 67, "browse/test/server-lock-errors.test.ts": 121, - "browse/test/server-no-import-side-effects.test.ts": 729, - "browse/test/server-pty-lease-routes.test.ts": 765, - "browse/test/server-route-auth-blackbox.test.ts": 3016, - "browse/test/server-route-dispatch-ratchet.test.ts": 643, - "browse/test/server-sanitize-surrogates.test.ts": 780, - "browse/test/server-tmp-state-path.test.ts": 57, - "browse/test/session-cookie-store.test.ts": 138, - "browse/test/sidebar-tabs.test.ts": 842, - "browse/test/sidebar-ux.test.ts": 768, - "browse/test/sidepanel-patient-autoconnect.test.ts": 54, - "browse/test/sidepanel-reattach.test.ts": 57, - "browse/test/sidepanel-restart-dispose.test.ts": 54, - "browse/test/skill-token.test.ts": 66, - "browse/test/socks-bridge.test.ts": 605, - "browse/test/sse-helpers.test.ts": 205, - "browse/test/sse-session-cookie.test.ts": 63, - "browse/test/state-ttl.test.ts": 47, - "browse/test/stealth-extended.test.ts": 45, - "browse/test/stealth-layer-c.test.ts": 55, - "browse/test/stealth-webdriver.test.ts": 12124, - "browse/test/stop-ack-before-shutdown.test.ts": 174, - "browse/test/stop-dead-daemon.test.ts": 529, - "browse/test/tab-each.test.ts": 69, - "browse/test/tab-external-close-dispatch.test.ts": 1471, - "browse/test/tab-external-close.test.ts": 1241, - "browse/test/tab-guardrail.test.ts": 1097, - "browse/test/tab-isolation.test.ts": 800, - "browse/test/tab-session-frame-detach.test.ts": 55, - "browse/test/telemetry-optout.test.ts": 149, - "browse/test/telemetry.test.ts": 96, - "browse/test/temp-dirs.test.ts": 60, - "browse/test/terminal-agent-detach-reattach.test.ts": 50, - "browse/test/terminal-agent-import.test.ts": 4478, + "browse/test/server-no-import-side-effects.test.ts": 807, + "browse/test/server-pty-lease-routes.test.ts": 645, + "browse/test/server-route-auth-blackbox.test.ts": 2565, + "browse/test/server-route-dispatch-ratchet.test.ts": 542, + "browse/test/server-sanitize-surrogates.test.ts": 761, + "browse/test/server-tmp-state-path.test.ts": 53, + "browse/test/session-cookie-store.test.ts": 90, + "browse/test/sidebar-tabs.test.ts": 824, + "browse/test/sidebar-ux.test.ts": 655, + "browse/test/sidepanel-patient-autoconnect.test.ts": 47, + "browse/test/sidepanel-reattach.test.ts": 47, + "browse/test/sidepanel-restart-dispose.test.ts": 52, + "browse/test/skill-token.test.ts": 76, + "browse/test/socks-bridge.test.ts": 607, + "browse/test/sse-helpers.test.ts": 220, + "browse/test/sse-session-cookie.test.ts": 67, + "browse/test/state-ttl.test.ts": 55, + "browse/test/stealth-extended.test.ts": 52, + "browse/test/stealth-layer-c.test.ts": 63, + "browse/test/stealth-webdriver.test.ts": 9871, + "browse/test/stop-ack-before-shutdown.test.ts": 181, + "browse/test/stop-dead-daemon.test.ts": 623, + "browse/test/tab-each.test.ts": 343, + "browse/test/tab-external-close-dispatch.test.ts": 2123, + "browse/test/tab-external-close.test.ts": 1467, + "browse/test/tab-guardrail.test.ts": 681, + "browse/test/tab-isolation.test.ts": 1280, + "browse/test/tab-session-frame-detach.test.ts": 69, + "browse/test/telemetry-optout.test.ts": 105, + "browse/test/telemetry.test.ts": 78, + "browse/test/temp-dirs.test.ts": 66, + "browse/test/terminal-agent-bun-exe.test.ts": 53, + "browse/test/terminal-agent-detach-reattach.test.ts": 42, + "browse/test/terminal-agent-import.test.ts": 4455, "browse/test/terminal-agent-keepalive.test.ts": 56, - "browse/test/terminal-agent-native-observation.test.ts": 55, - "browse/test/terminal-agent-owner-watchdog.test.ts": 3577, - "browse/test/terminal-agent-pid-identity.test.ts": 137, - "browse/test/terminal-agent-port-range.test.ts": 52, - "browse/test/terminal-agent-ring-buffer-runtime.test.ts": 81, - "browse/test/terminal-agent-session-routing.test.ts": 41, - "browse/test/terminal-agent-signal-cleanup.test.ts": 59, - "browse/test/terminal-agent-watchdog.test.ts": 41, - "browse/test/terminal-agent.test.ts": 827, - "browse/test/terminal-pty-lifecycle.test.ts": 45, - "browse/test/token-registry.test.ts": 59, - "browse/test/tunnel-gate-unit.test.ts": 715, - "browse/test/watch.test.ts": 584, - "browse/test/welcome-page.test.ts": 49, - "browse/test/windows-spawn-hide.test.ts": 76, - "browse/test/xprotect-heal.test.ts": 590, - "browser-skills/hackernews-frontpage/script.test.ts": 62, - "design/test/brief.test.ts": 95, - "design/test/cli-persistence.test.ts": 7092, - "design/test/cli-version.test.ts": 103, - "design/test/feedback-roundtrip-daemon.test.ts": 404, - "design/test/feedback-roundtrip.test.ts": 3654, - "design/test/image-args.test.ts": 82, - "design/test/image-gen-pairing.test.ts": 118, - "design/test/receipted-fetch.test.ts": 53, + "browse/test/terminal-agent-native-observation.test.ts": 78, + "browse/test/terminal-agent-owner-watchdog.test.ts": 828, + "browse/test/terminal-agent-pid-identity.test.ts": 140, + "browse/test/terminal-agent-port-range.test.ts": 56, + "browse/test/terminal-agent-ring-buffer-runtime.test.ts": 62, + "browse/test/terminal-agent-session-routing.test.ts": 45, + "browse/test/terminal-agent-signal-cleanup.test.ts": 61, + "browse/test/terminal-agent-watchdog.test.ts": 46, + "browse/test/terminal-agent.test.ts": 783, + "browse/test/terminal-pty-lifecycle.test.ts": 54, + "browse/test/token-registry.test.ts": 68, + "browse/test/tunnel-gate-unit.test.ts": 738, + "browse/test/watch.test.ts": 570, + "browse/test/welcome-page.test.ts": 50, + "browse/test/windows-spawn-hide.test.ts": 84, + "browse/test/xprotect-heal.test.ts": 525, + "browser-skills/hackernews-frontpage/script.test.ts": 73, + "design/test/brief.test.ts": 59, + "design/test/cli-persistence.test.ts": 7082, + "design/test/cli-version.test.ts": 85, + "design/test/feedback-roundtrip-daemon.test.ts": 309, + "design/test/feedback-roundtrip.test.ts": 3629, + "design/test/image-args.test.ts": 102, + "design/test/image-gen-pairing.test.ts": 148, + "design/test/receipted-fetch.test.ts": 66, "ios-qa/daemon/test/allowlist.test.ts": 73, - "ios-qa/daemon/test/audit.test.ts": 50, - "ios-qa/daemon/test/auth-mint.test.ts": 60, - "ios-qa/daemon/test/daemon-integration.test.ts": 4014, - "ios-qa/daemon/test/proxy-classify.test.ts": 52, - "ios-qa/daemon/test/proxy-transport.test.ts": 1854, - "ios-qa/daemon/test/session-tokens.test.ts": 47, - "ios-qa/daemon/test/single-instance.test.ts": 50, - "ios-qa/daemon/test/tailscale-localapi.test.ts": 63, - "ios-qa/scripts/gen-accessors.test.ts": 142, - "make-pdf/test/asideClient.test.ts": 54, - "make-pdf/test/cli-args.test.ts": 40, - "make-pdf/test/cli-exit-codes.test.ts": 596, - "make-pdf/test/diagram-prepass.test.ts": 205, - "make-pdf/test/docx-image-size.test.ts": 1418, - "make-pdf/test/e2e/ci-prereqs.test.ts": 54, - "make-pdf/test/e2e/combined-gate.test.ts": 71, - "make-pdf/test/e2e/diagram-gate.test.ts": 67, - "make-pdf/test/e2e/emoji-gate.test.ts": 61, - "make-pdf/test/e2e/format-gate.test.ts": 63, - "make-pdf/test/e2e/landscape-gate.test.ts": 64, - "make-pdf/test/e2e/toc-gate.test.ts": 68, - "make-pdf/test/image-policy.test.ts": 55, + "ios-qa/daemon/test/audit.test.ts": 112, + "ios-qa/daemon/test/auth-mint.test.ts": 77, + "ios-qa/daemon/test/daemon-integration.test.ts": 4005, + "ios-qa/daemon/test/proxy-classify.test.ts": 56, + "ios-qa/daemon/test/proxy-transport.test.ts": 1886, + "ios-qa/daemon/test/session-tokens.test.ts": 60, + "ios-qa/daemon/test/single-instance.test.ts": 54, + "ios-qa/daemon/test/tailscale-localapi.test.ts": 65, + "ios-qa/scripts/gen-accessors.test.ts": 145, + "make-pdf/test/asideClient.test.ts": 60, + "make-pdf/test/cli-args.test.ts": 50, + "make-pdf/test/cli-exit-codes.test.ts": 522, + "make-pdf/test/diagram-prepass.test.ts": 153, + "make-pdf/test/docx-image-size.test.ts": 1363, + "make-pdf/test/e2e/ci-prereqs.test.ts": 49, + "make-pdf/test/e2e/combined-gate.test.ts": 54, + "make-pdf/test/e2e/diagram-gate.test.ts": 54, + "make-pdf/test/e2e/emoji-gate.test.ts": 62, + "make-pdf/test/e2e/format-gate.test.ts": 58, + "make-pdf/test/e2e/landscape-gate.test.ts": 60, + "make-pdf/test/e2e/toc-gate.test.ts": 94, + "make-pdf/test/image-policy.test.ts": 62, "make-pdf/test/render-offline-sanitize.test.ts": 86, - "make-pdf/test/render.test.ts": 108, - "make-pdf/test/setup-smoke.test.ts": 127, - "make-pdf/test/toc-anchors.test.ts": 76, - "make-pdf/test/toc-pages.test.ts": 130, - "test/agents-digest.test.ts": 93, - "test/analytics.test.ts": 72, - "test/anthropic-preflight.test.ts": 54, - "test/archaeology-lint.test.ts": 3760, - "test/arm-benchmark-selftest.test.ts": 4277, - "test/arm-setup-smoke-workflow.test.ts": 200, - "test/auq-format-always-loaded.test.ts": 105, - "test/auq-native-capture.test.ts": 588, - "test/auq-parallel.test.ts": 42691, - "test/auq-substance-panel.test.ts": 226, - "test/autoplan-admission.test.ts": 2528, - "test/autoplan-artifact-windows-argv.test.ts": 352, - "test/autoplan-bounded-journal.test.ts": 904, - "test/autoplan-budget.test.ts": 20449, - "test/autoplan-capy-e2e.test.ts": 19388, - "test/autoplan-compaction-copies.test.ts": 465, - "test/autoplan-compaction-preserved-segment.test.ts": 2089, - "test/autoplan-guard-log.test.ts": 132, - "test/autoplan-guard-pty-fixture.test.ts": 72, - "test/autoplan-guard-reasons.test.ts": 48, - "test/autoplan-init.test.ts": 2154, - "test/autoplan-long-session-observer.test.ts": 165, - "test/autoplan-method-read-audit.test.ts": 642, - "test/autoplan-owned-read.test.ts": 5990, - "test/autoplan-owned-state.test.ts": 913, - "test/autoplan-pairing.test.ts": 79, - "test/autoplan-pending-artifact.test.ts": 46, - "test/autoplan-pending-question.test.ts": 4700, - "test/autoplan-phase-handoff.test.ts": 244, - "test/autoplan-phase-observer.test.ts": 84, - "test/autoplan-phase-order.test.ts": 177, - "test/autoplan-public-narration.test.ts": 150, - "test/autoplan-publication-generation.test.ts": 263, - "test/autoplan-publication-guard.test.ts": 8675, - "test/autoplan-publication-hook.test.ts": 3772, - "test/autoplan-publication-payload.test.ts": 3045, - "test/autoplan-record.test.ts": 6031, - "test/autoplan-revise.test.ts": 18079, - "test/autoplan-schema-canary-compare.test.ts": 63, - "test/benchmark-runner.test.ts": 82, - "test/binary-version-probe.test.ts": 377, - "test/binding-template-drift.test.ts": 311, - "test/bootstrap-retention-shard.test.ts": 55, - "test/bootstrap-session-lifecycle.test.ts": 59, - "test/brain-cache-roundtrip.test.ts": 690, - "test/brain-cache-spec.test.ts": 78, - "test/brain-preflight.test.ts": 95, + "make-pdf/test/render.test.ts": 123, + "make-pdf/test/setup-smoke.test.ts": 139, + "make-pdf/test/toc-anchors.test.ts": 87, + "make-pdf/test/toc-pages.test.ts": 97, + "test/agents-digest.test.ts": 66, + "test/analytics.test.ts": 75, + "test/anthropic-preflight.test.ts": 59, + "test/archaeology-lint.test.ts": 4641, + "test/arm-benchmark-selftest.test.ts": 3993, + "test/arm-setup-smoke-workflow.test.ts": 190, + "test/auq-format-always-loaded.test.ts": 112, + "test/auq-native-capture.test.ts": 579, + "test/auq-parallel.test.ts": 37752, + "test/auq-substance-panel.test.ts": 236, + "test/autoplan-admission.test.ts": 2577, + "test/autoplan-artifact-windows-argv.test.ts": 306, + "test/autoplan-bounded-journal.test.ts": 854, + "test/autoplan-budget.test.ts": 19815, + "test/autoplan-capy-e2e.test.ts": 18831, + "test/autoplan-carry.test.ts": 60768, + "test/autoplan-compaction-copies.test.ts": 374, + "test/autoplan-compaction-preserved-segment.test.ts": 1977, + "test/autoplan-guard-log.test.ts": 124, + "test/autoplan-guard-pty-fixture.test.ts": 103, + "test/autoplan-guard-reasons.test.ts": 61, + "test/autoplan-init.test.ts": 2169, + "test/autoplan-long-session-observer.test.ts": 156, + "test/autoplan-method-read-audit.test.ts": 603, + "test/autoplan-owned-read.test.ts": 5951, + "test/autoplan-owned-state.test.ts": 1343, + "test/autoplan-pairing.test.ts": 107, + "test/autoplan-pending-artifact.test.ts": 68, + "test/autoplan-pending-question.test.ts": 4655, + "test/autoplan-phase-handoff.test.ts": 263, + "test/autoplan-phase-observer.test.ts": 74, + "test/autoplan-phase-order.test.ts": 150, + "test/autoplan-public-narration.test.ts": 113, + "test/autoplan-publication-generation.test.ts": 205, + "test/autoplan-publication-guard.test.ts": 5629, + "test/autoplan-publication-hook.test.ts": 3584, + "test/autoplan-publication-payload.test.ts": 11796, + "test/autoplan-record.test.ts": 5156, + "test/autoplan-revise.test.ts": 18974, + "test/autoplan-schema-canary-compare.test.ts": 72, + "test/benchmark-runner.test.ts": 80, + "test/binary-version-probe.test.ts": 371, + "test/binding-template-drift.test.ts": 296, + "test/bootstrap-retention-shard.test.ts": 58, + "test/bootstrap-session-lifecycle.test.ts": 52, + "test/brain-cache-roundtrip.test.ts": 525, + "test/brain-cache-spec.test.ts": 72, + "test/brain-preflight.test.ts": 112, "test/build-script-shell-compat.test.ts": 52, - "test/bun-subprocess-fd-lifetime.test.ts": 45, - "test/cache-concurrent-refresh.test.ts": 125, - "test/carve-guard-completeness.test.ts": 60, - "test/carve-guards-negative.test.ts": 68, - "test/carve-plan-fixture.test.ts": 769, - "test/carve-section-ordering.test.ts": 84, - "test/carve-section-sharding.test.ts": 3822, - "test/catalog-budget.test.ts": 121, - "test/catalog-mode-full.test.ts": 1860, - "test/catalog-trim.test.ts": 104, - "test/census-red-docs.test.ts": 98, - "test/ceo-barless-submit.test.ts": 168, - "test/ceo-count-ad-v2.test.ts": 77, - "test/ceo-expansion-auq.test.ts": 182, - "test/ceo-expansion-pacing-native.test.ts": 151, - "test/ceo-hold-posture-review.test.ts": 259, - "test/ceo-mode-expansion-disposition.test.ts": 146, - "test/ceo-mode-handoff-hook.test.ts": 318, - "test/ceo-mode-labels-native.test.ts": 115, - "test/ceo-mode-option.test.ts": 741, - "test/ceo-mode-pending-submit.test.ts": 114, - "test/ceo-mode-posture-native.test.ts": 120, - "test/ceo-mode-prerequisite.test.ts": 115, - "test/ceo-mode-routing-fixture.test.ts": 8146, - "test/ceo-plan-mode-fixture.test.ts": 6398, - "test/ceo-posture-packet.test.ts": 135, - "test/ceo-section-loading-fixture.test.ts": 727, - "test/ceo-split-collection.test.ts": 838, - "test/ceo-split-question-policy.test.ts": 712, - "test/ceo-stale-fill-decision.test.ts": 166, - "test/ceo-workflow-clarity.test.ts": 65, - "test/changed-files-union.test.ts": 5182, - "test/ci-image-cli-pin.test.ts": 78, - "test/ci-image-tag-binding.test.ts": 49, - "test/ci-paid-coordination.test.ts": 27144, - "test/claude-code-runner.test.ts": 5500, - "test/claude-code-windows-job.test.ts": 3345, + "test/bun-config-shell-bins.test.ts": 14148, + "test/bun-isolation-launch.test.ts": 1147, + "test/bun-subprocess-fd-lifetime.test.ts": 69, + "test/cache-concurrent-refresh.test.ts": 133, + "test/captain-skill.test.ts": 76, + "test/carve-guard-completeness.test.ts": 117, + "test/carve-guards-negative.test.ts": 117, + "test/carve-plan-fixture.test.ts": 992, + "test/carve-section-ordering.test.ts": 138, + "test/carve-section-sharding.test.ts": 4590, + "test/catalog-budget.test.ts": 124, + "test/catalog-mode-full.test.ts": 2918, + "test/catalog-trim.test.ts": 158, + "test/census-red-docs.test.ts": 115, + "test/ceo-barless-submit.test.ts": 219, + "test/ceo-count-ad-v2.test.ts": 98, + "test/ceo-expansion-auq.test.ts": 230, + "test/ceo-expansion-pacing-native.test.ts": 181, + "test/ceo-hold-posture-review.test.ts": 330, + "test/ceo-mode-expansion-disposition.test.ts": 198, + "test/ceo-mode-handoff-hook.test.ts": 348, + "test/ceo-mode-labels-native.test.ts": 140, + "test/ceo-mode-option.test.ts": 915, + "test/ceo-mode-pending-submit.test.ts": 128, + "test/ceo-mode-posture-native.test.ts": 145, + "test/ceo-mode-prerequisite.test.ts": 145, + "test/ceo-mode-routing-fixture.test.ts": 8553, + "test/ceo-plan-mode-fixture.test.ts": 6883, + "test/ceo-posture-packet.test.ts": 160, + "test/ceo-section-loading-fixture.test.ts": 935, + "test/ceo-split-collection.test.ts": 981, + "test/ceo-split-question-policy.test.ts": 881, + "test/ceo-stale-fill-decision.test.ts": 209, + "test/ceo-workflow-clarity.test.ts": 84, + "test/changed-files-union.test.ts": 4655, + "test/changelog-fragments.test.ts": 26676, + "test/changelog-merge-damage.test.ts": 150, + "test/ci-image-cli-pin.test.ts": 103, + "test/ci-image-tag-binding.test.ts": 66, + "test/ci-paid-coordination.test.ts": 27721, + "test/claude-code-runner.test.ts": 5627, + "test/claude-code-windows-job.test.ts": 3271, "test/claude-md-size.test.ts": 53, - "test/claude-overlay-models.test.ts": 173, - "test/claude-producer-stream.test.ts": 100, - "test/claude-provider-keychain.test.ts": 189, - "test/codex-carve-fixture.test.ts": 4731, - "test/codex-generation-model.test.ts": 370, - "test/codex-invocation-sites.test.ts": 783, - "test/codex-resume-flag-semantics.test.ts": 103, - "test/compare-pr-version.test.ts": 476, - "test/conductor-env-shim.test.ts": 62, - "test/context-budget-ratchet.test.ts": 415, - "test/context-remaining-work-provenance.test.ts": 59, - "test/cookie-workflow-judge-input.test.ts": 4451, - "test/cookie-workflow-manual-review.test.ts": 279, - "test/copilot-windows-bash.test.ts": 53, - "test/cost-known.test.ts": 172, - "test/coverage-audit.test.ts": 11104, - "test/cso-bounded-file.test.ts": 72, - "test/cso-docker-policy.test.ts": 114, - "test/cso-docker-socket-realpath.test.ts": 141, - "test/cso-git-global-config.test.ts": 176, - "test/cso-git-hardening.test.ts": 94, - "test/cso-history.test.ts": 56, - "test/cso-image-provisioning.test.ts": 333, - "test/cso-lease-identity.test.ts": 486, - "test/cso-native-evidence.test.ts": 129, - "test/cso-node-lifecycle-integration.test.ts": 134, - "test/cso-ntfs-fixture.test.ts": 824, - "test/cso-postgresql-policy.test.ts": 98, + "test/claude-overlay-models.test.ts": 126, + "test/claude-producer-stream.test.ts": 85, + "test/claude-provider-keychain.test.ts": 225, + "test/claude-usage.test.ts": 3864, + "test/codex-carve-fixture.test.ts": 4408, + "test/codex-generation-model.test.ts": 448, + "test/codex-invocation-sites.test.ts": 1091, + "test/codex-resume-flag-semantics.test.ts": 106, + "test/compare-pr-version.test.ts": 40386, + "test/conductor-env-shim.test.ts": 74, + "test/context-budget-ratchet.test.ts": 586, + "test/context-remaining-work-provenance.test.ts": 67, + "test/context-restore-fragment-verdict.test.ts": 109, + "test/cookie-workflow-judge-input.test.ts": 4466, + "test/cookie-workflow-manual-review.test.ts": 256, + "test/copilot-windows-bash.test.ts": 63, + "test/cost-known.test.ts": 201, + "test/coverage-audit.test.ts": 9537, + "test/cso-bounded-file.test.ts": 82, + "test/cso-docker-policy.test.ts": 112, + "test/cso-docker-socket-realpath.test.ts": 104, + "test/cso-git-global-config.test.ts": 150, + "test/cso-git-hardening.test.ts": 100, + "test/cso-history.test.ts": 76, + "test/cso-image-provisioning.test.ts": 319, + "test/cso-lease-identity.test.ts": 436, + "test/cso-native-evidence.test.ts": 114, + "test/cso-node-lifecycle-integration.test.ts": 118, + "test/cso-ntfs-fixture.test.ts": 594, + "test/cso-postgresql-policy.test.ts": 92, "test/cso-preparation-java.test.ts": 90, - "test/cso-preparation.test.ts": 244, - "test/cso-preserved.test.ts": 74, - "test/cso-public-ghcr.test.ts": 80, - "test/cso-requalification.test.ts": 231, - "test/cso-runtime-promotion.test.ts": 453, - "test/cso-scanner-docker-integration.test.ts": 155, - "test/cso-scanners.test.ts": 190, - "test/cso-snapshot-disappearance-race.test.ts": 96, - "test/cso-spec-taxonomy-alignment.test.ts": 58, - "test/cso-staging-run.test.ts": 271, - "test/cso-verifier.test.ts": 151, - "test/cso-windows-build-contract.test.ts": 86, - "test/cso-windows-docker.test.ts": 81, - "test/declared-annotation.test.ts": 81, - "test/defensive-review-framing.test.ts": 71, - "test/dependency-security.test.ts": 196, - "test/deps-smoke.test.ts": 202, - "test/design-board-reload.test.ts": 350, - "test/design-catalog.test.ts": 161, - "test/design-checklist-sync.test.ts": 2242, - "test/design-completion-handoff-scored.test.ts": 133, - "test/design-consultation-contract.test.ts": 173, - "test/design-daemon-windows-identity.test.ts": 188, - "test/design-detect-contract.test.ts": 320, - "test/design-doc-data-guard.test.ts": 64, - "test/design-flag-utils.test.ts": 138, - "test/design-html-section-completion.test.ts": 11306, - "test/design-md.test.ts": 1597, - "test/design-printed-paths.test.ts": 242, - "test/design-research-fixture.test.ts": 67, - "test/detector-corpora.test.ts": 1058, - "test/detector-corpus-auto-decide-preserved.test.ts": 128, - "test/detector-corpus-plan-ceo-section-loading.test.ts": 152, - "test/detector-corpus-plan-design-review-plan-mode.test.ts": 2707, - "test/detector-corpus-plan-eng-multi-finding-batching.test.ts": 687, - "test/detector-corpus-shared-libs-plan-callers.test.ts": 91, - "test/detector-corpus-ship-docsync-late-result.test.ts": 147, - "test/detector-corpus-ship-docsync-launch-failure.test.ts": 142, - "test/devex-finding-fixture.test.ts": 2989, - "test/devex-peer-comparison-calibration.test.ts": 924, - "test/diagram-render-drift.test.ts": 173, - "test/disabled-dated-record-at.test.ts": 194, - "test/discover-section-templates.test.ts": 66, - "test/dispatch-rules.test.ts": 109, - "test/docsync-authority.test.ts": 6355, - "test/docsync-child-marker.test.ts": 99, - "test/docsync-lifecycle-interface.test.ts": 1149, - "test/doctor-components.test.ts": 128, - "test/document-skills-redaction.test.ts": 130, - "test/dom-dump-hygiene.test.ts": 654, - "test/dx-error-contract-grader.test.ts": 77, - "test/dx-error-contract.test.ts": 102, - "test/dx-selected-navigation-ap.test.ts": 224, - "test/e2e-harness-audit.test.ts": 102, - "test/e2e-tier-alignment.test.ts": 516, - "test/eng-batching-current-ledger.test.ts": 607, - "test/eng-batching-native-replay.test.ts": 206, - "test/eng-batching-saved-ledger.test.ts": 1530, - "test/eng-count-question-policy.test.ts": 133, - "test/eng-devex-s-count.test.ts": 144, - "test/eng-finding-retry-budget.test.ts": 1916, - "test/eng-first-review.test.ts": 665, - "test/eng-published-navigation.test.ts": 179, - "test/eng-resolution-block-position.test.ts": 658, - "test/eng-review-routing.test.ts": 116, - "test/eng-seeded-completion-ai.test.ts": 15107, - "test/eng-seeded-coverage.test.ts": 4674, - "test/eng-semantic-terminal.test.ts": 272, - "test/env-host-runtime-assets.test.ts": 4927, - "test/eval-budgets-policy.test.ts": 471, - "test/eval-flake-rank.test.ts": 1938, - "test/eval-history.test.ts": 3522, - "test/eval-kinds.test.ts": 122, - "test/eval-model.test.ts": 47, - "test/eval-sweep-workflow.test.ts": 55, - "test/evals-workflow-wiring.test.ts": 694, - "test/exit-propagation.test.ts": 446, - "test/extension-pty-inject-invariant.test.ts": 52, - "test/external-host-carve.test.ts": 6155, - "test/flake-ledger.test.ts": 161, - "test/free-home-guard.test.ts": 88, - "test/free-seed-ratchet.test.ts": 788, - "test/free-tests-workflow-wiring.test.ts": 172, - "test/gate-cost.test.ts": 4110, - "test/gate-integrity-templates.test.ts": 97, - "test/gbrain-detection-override.test.ts": 2586, - "test/gbrain-exec-invariant.test.ts": 59, - "test/gbrain-guards.test.ts": 86, - "test/gbrain-source-gitignore.test.ts": 1151, - "test/gbrain-sources-parse.test.ts": 72, - "test/gbrain-spawn-windows-shell.test.ts": 211, - "test/gbrain-sync-voyage-code-3-integration.test.ts": 86, - "test/gen-skill-docs-checks.test.ts": 40966, - "test/gen-skill-docs-idempotency.test.ts": 6547, - "test/gen-skill-docs-import-purity.test.ts": 143, - "test/gen-skill-docs-prune-stale.test.ts": 1297, - "test/generated-docs-fences.test.ts": 1489, - "test/generator-eexist.test.ts": 954, - "test/git-ref-fixture-tripwire.test.ts": 367, - "test/gitattributes-eol.test.ts": 3037, - "test/global-discover.test.ts": 6207, - "test/greptile-early.test.ts": 110, - "test/gstack-ci-gate.test.ts": 99, - "test/gstack-config-gbrain-refresh.test.ts": 2123, - "test/gstack-design-detect-plugins.test.ts": 1090, - "test/gstack-doctor.test.ts": 647123, - "test/gstack-home-module-scope.test.ts": 318, - "test/gstack-launch-probe.test.ts": 62, - "test/gstack-post.test.ts": 128, - "test/gstack-regen.test.ts": 38964, - "test/gstack-render-cli.test.ts": 1029, - "test/gstack-schema-pack.test.ts": 64, - "test/gstack-upgrade-migration-v1_78_0_0.test.ts": 634, - "test/gstack-wtree-windows-paths.test.ts": 1325, - "test/harness-version.test.ts": 492, - "test/helpers-unit.test.ts": 124, - "test/helpers/budget-override.test.ts": 80, - "test/helpers/capture-parity-baseline.test.ts": 555, - "test/helpers/claude-pty-runner.auq.unit.test.ts": 165, - "test/helpers/claude-pty-runner.boundaries.unit.test.ts": 178, - "test/helpers/claude-pty-runner.classify.unit.test.ts": 154, - "test/helpers/claude-pty-runner.launch.unit.test.ts": 64, - "test/helpers/claude-pty-runner.plan-native.unit.test.ts": 124, - "test/helpers/claude-pty-runner.runners.unit.test.ts": 2609, - "test/helpers/claude-pty-runner.scope-gate-floor.unit.test.ts": 150, + "test/cso-preparation.test.ts": 299, + "test/cso-preserved.test.ts": 103, + "test/cso-public-ghcr.test.ts": 71, + "test/cso-requalification.test.ts": 180, + "test/cso-runtime-promotion.test.ts": 392, + "test/cso-scanner-docker-integration.test.ts": 104, + "test/cso-scanners.test.ts": 298, + "test/cso-snapshot-disappearance-race.test.ts": 70, + "test/cso-spec-taxonomy-alignment.test.ts": 66, + "test/cso-staging-run.test.ts": 227, + "test/cso-verifier.test.ts": 102, + "test/cso-windows-build-contract.test.ts": 73, + "test/cso-windows-docker.test.ts": 74, + "test/declared-annotation.test.ts": 70, + "test/defensive-review-framing.test.ts": 63, + "test/dependency-security.test.ts": 233, + "test/deps-smoke.test.ts": 139, + "test/design-board-reload.test.ts": 347, + "test/design-catalog.test.ts": 104, + "test/design-checklist-sync.test.ts": 2700, + "test/design-completion-handoff-scored.test.ts": 94, + "test/design-consultation-contract.test.ts": 93, + "test/design-daemon-windows-identity.test.ts": 145, + "test/design-detect-contract.test.ts": 305, + "test/design-doc-data-guard.test.ts": 58, + "test/design-flag-utils.test.ts": 118, + "test/design-html-section-completion.test.ts": 10775, + "test/design-lint-assets.test.ts": 301, + "test/design-lint-chrome.test.ts": 471, + "test/design-lint-color.test.ts": 298, + "test/design-lint-eighth-cycle.test.ts": 22196, + "test/design-lint-fifth-cycle.test.ts": 10167, + "test/design-lint-finish.test.ts": 449, + "test/design-lint-fourth-cycle.test.ts": 10243, + "test/design-lint-motion.test.ts": 417, + "test/design-lint-recorded.test.ts": 167, + "test/design-lint-render.test.ts": 183, + "test/design-lint-settle.test.ts": 204, + "test/design-lint-structure.test.ts": 909, + "test/design-lint-tailwind.test.ts": 374, + "test/design-lint-type.test.ts": 304, + "test/design-md.test.ts": 1673, + "test/design-printed-paths.test.ts": 249, + "test/design-research-fixture.test.ts": 68, + "test/detector-corpora.test.ts": 966, + "test/detector-corpus-auto-decide-preserved.test.ts": 157, + "test/detector-corpus-plan-ceo-section-loading.test.ts": 166, + "test/detector-corpus-plan-design-review-plan-mode.test.ts": 2027, + "test/detector-corpus-plan-eng-multi-finding-batching.test.ts": 507, + "test/detector-corpus-shared-libs-plan-callers.test.ts": 93, + "test/detector-corpus-ship-docsync-late-result.test.ts": 156, + "test/detector-corpus-ship-docsync-launch-failure.test.ts": 144, + "test/devex-finding-fixture.test.ts": 3925, + "test/devex-peer-comparison-calibration.test.ts": 875, + "test/diagram-render-drift.test.ts": 190, + "test/disabled-dated-record-at.test.ts": 195, + "test/discover-section-templates.test.ts": 73, + "test/dispatch-rules.test.ts": 118, + "test/docsync-authority.test.ts": 10290, + "test/docsync-child-marker.test.ts": 80, + "test/docsync-lifecycle-interface.test.ts": 1496, + "test/doctor-components.test.ts": 122, + "test/document-skills-redaction.test.ts": 131, + "test/dom-dump-hygiene.test.ts": 731, + "test/dx-error-contract-grader.test.ts": 83, + "test/dx-error-contract.test.ts": 110, + "test/dx-selected-navigation-ap.test.ts": 213, + "test/e2e-harness-audit.test.ts": 91, + "test/e2e-tier-alignment.test.ts": 453, + "test/eng-batching-current-ledger.test.ts": 667, + "test/eng-batching-native-replay.test.ts": 198, + "test/eng-batching-saved-ledger.test.ts": 1600, + "test/eng-count-question-policy.test.ts": 159, + "test/eng-devex-s-count.test.ts": 145, + "test/eng-finding-retry-budget.test.ts": 3132, + "test/eng-first-review.test.ts": 575, + "test/eng-published-navigation.test.ts": 226, + "test/eng-resolution-block-position.test.ts": 612, + "test/eng-review-routing.test.ts": 106, + "test/eng-seeded-completion-ai.test.ts": 16846, + "test/eng-seeded-coverage.test.ts": 4582, + "test/eng-semantic-terminal.test.ts": 310, + "test/env-host-runtime-assets.test.ts": 5833, + "test/eval-budgets-policy.test.ts": 205, + "test/eval-flake-rank.test.ts": 1031, + "test/eval-history.test.ts": 3501, + "test/eval-kinds.test.ts": 118, + "test/eval-model.test.ts": 57, + "test/eval-sweep-workflow.test.ts": 61, + "test/evals-workflow-wiring.test.ts": 2006, + "test/exit-propagation.test.ts": 447, + "test/extension-pty-inject-invariant.test.ts": 65, + "test/external-host-carve.test.ts": 7604, + "test/flake-ledger.test.ts": 135, + "test/free-home-guard.test.ts": 91, + "test/free-seed-ratchet.test.ts": 612, + "test/gate-cost.test.ts": 4457, + "test/gate-integrity-templates.test.ts": 133, + "test/gateway-provider.test.ts": 300, + "test/gbrain-detection-override.test.ts": 3050, + "test/gbrain-exec-invariant.test.ts": 60, + "test/gbrain-guards.test.ts": 176, + "test/gbrain-source-gitignore.test.ts": 982, + "test/gbrain-sources-parse.test.ts": 84, + "test/gbrain-spawn-windows-shell.test.ts": 222, + "test/gbrain-sync-voyage-code-3-integration.test.ts": 83, + "test/gen-skill-docs-checks.test.ts": 53939, + "test/gen-skill-docs-idempotency.test.ts": 9501, + "test/gen-skill-docs-import-purity.test.ts": 159, + "test/gen-skill-docs-prune-stale.test.ts": 1750, + "test/generated-docs-fences.test.ts": 1799, + "test/generator-eexist.test.ts": 1098, + "test/git-ref-fixture-tripwire.test.ts": 383, + "test/gitattributes-eol.test.ts": 3371, + "test/global-discover.test.ts": 7581, + "test/greptile-early.test.ts": 108, + "test/gstack-checks-go.test.ts": 14998, + "test/gstack-ci-gate.test.ts": 81, + "test/gstack-coauthor-trailers.test.ts": 5465, + "test/gstack-config-gbrain-refresh.test.ts": 2327, + "test/gstack-design-detect-plugins.test.ts": 862, + "test/gstack-doctor-check.test.ts": 186801, + "test/gstack-doctor-launch.test.ts": 162316, + "test/gstack-doctor.test.ts": 169652, + "test/gstack-home-module-scope.test.ts": 370, + "test/gstack-launch-probe.test.ts": 101, + "test/gstack-lease-git-bash.test.ts": 3733, + "test/gstack-post.test.ts": 297, + "test/gstack-regen.test.ts": 43012, + "test/gstack-render-cli.test.ts": 1110, + "test/gstack-schema-pack.test.ts": 136, + "test/gstack-ship-receipt-docs.test.ts": 47116, + "test/gstack-upgrade-migration-v1_78_0_0.test.ts": 533, + "test/gstack-wtree-windows-paths.test.ts": 1261, + "test/harness-version.test.ts": 550, + "test/helpers-unit.test.ts": 131, + "test/helpers/budget-override.test.ts": 85, + "test/helpers/capture-parity-baseline.test.ts": 669, + "test/helpers/claude-pty-runner.auq.unit.test.ts": 185, + "test/helpers/claude-pty-runner.boundaries.unit.test.ts": 187, + "test/helpers/claude-pty-runner.classify.unit.test.ts": 144, + "test/helpers/claude-pty-runner.launch.unit.test.ts": 80, + "test/helpers/claude-pty-runner.plan-native.unit.test.ts": 120, + "test/helpers/claude-pty-runner.runners.unit.test.ts": 2046, + "test/helpers/claude-pty-runner.scope-gate-floor.unit.test.ts": 128, "test/helpers/claude-pty-runner.screen.unit.test.ts": 149, - "test/helpers/e2e-gate.unit.test.ts": 83, - "test/helpers/eval-store.test.ts": 4830, - "test/helpers/hermetic-env.test.ts": 20821, - "test/helpers/observability.test.ts": 814, - "test/helpers/providers/gemini.test.ts": 90, - "test/helpers/run-bin.test.ts": 250, - "test/heredoc-pipe-deadlock.test.ts": 995, - "test/honesty-kernel.test.ts": 120, - "test/hook-scripts.test.ts": 233104, - "test/instruction-facts.test.ts": 178, - "test/investigate-freeze-path.test.ts": 33705, - "test/investigate-recipes.test.ts": 69, - "test/ios-debug-bridge-release-guard.test.ts": 56, - "test/ios-qa-daemon-cli.test.ts": 249, - "test/ios-qa-stateserver-hardening.test.ts": 61, - "test/ios-qa-swift-build.test.ts": 84, - "test/ios-qa.test.ts": 326, - "test/is-conductor.test.ts": 57, - "test/jargon-list.test.ts": 67, - "test/judge-calibration.test.ts": 164, - "test/judge-panel-median.test.ts": 115, - "test/land-and-deploy-command-trigger.test.ts": 53, - "test/land-and-deploy-flow.test.ts": 17992, - "test/land-and-deploy-postfail.test.ts": 71, - "test/llm-judge-abort.test.ts": 91, - "test/llm-judge-frontier.test.ts": 328, - "test/llm-judge-stream.test.ts": 128, - "test/llms-txt-shape.test.ts": 107, - "test/maintenance-cli-args.test.ts": 635, - "test/memory-ingest-timeout.test.ts": 101, - "test/migration-checkpoint-ownership.test.ts": 2348, - "test/mktemp-portability.test.ts": 987, - "test/model-catalog-isolation.test.ts": 289, - "test/model-overlays.test.ts": 94, - "test/model-policy-freshness.test.ts": 403, - "test/model-policy.test.ts": 241, - "test/module-size-ratchet.test.ts": 607, - "test/native-auto-decide-pty.test.ts": 13810, - "test/native-auto-decide.test.ts": 697, - "test/no-quoted-tilde-assignments.test.ts": 78, - "test/no-suicide-exit.test.ts": 371, - "test/office-hours-budget.test.ts": 120, - "test/office-hours-open-question-form.test.ts": 59, - "test/office-hours-phase4-caller.test.ts": 114, - "test/office-hours-review.test.ts": 3336, - "test/office-posture-recording.test.ts": 134, - "test/one-way-doors.test.ts": 56, - "test/open-gstack-browser-liveness.test.ts": 50, - "test/openclaw-native-skills.test.ts": 51, - "test/osv-config-wiring.test.ts": 64, - "test/osv-ignore-expiry.test.ts": 68, - "test/outside-review-severity.test.ts": 253, - "test/outside-review-unparsed-severity.test.ts": 506, - "test/outside-send-boundary.test.ts": 1049, - "test/outside-voice-evidence.test.ts": 360, - "test/outside-voice-failure-policy.test.ts": 470, - "test/outside-voice-fixture.test.ts": 2897, - "test/outside-voice-native-deadline.test.ts": 129, - "test/outside-voice-routing.test.ts": 3475, - "test/overlay-lifecycle.test.ts": 618, - "test/overlay-measurement.test.ts": 1097, - "test/overlay-recording-order.test.ts": 156, - "test/overlay-sdk-cancel-eof.test.ts": 233, - "test/paid-free-boundary.test.ts": 12473, - "test/paid-orphan-tripwire.test.ts": 386, - "test/paid-overlay-scheduling.test.ts": 2314, - "test/paid-pr-profile.test.ts": 7684, - "test/paid-report-census.test.ts": 494, - "test/paid-report-fail-open.test.ts": 3816, - "test/paid-retry-supervision.test.ts": 5816, - "test/paid-run-manifest.test.ts": 6396, - "test/paid-selection-propagation.test.ts": 184, - "test/paid-shard-settlement.test.ts": 1986, - "test/paid-shards.test.ts": 19222, - "test/paid-slice-deadline.test.ts": 1658, - "test/pair-agent-token-hygiene.test.ts": 57, - "test/parity-baseline-integrity.test.ts": 86, - "test/parity-sectioned.test.ts": 75, - "test/parity-suite.test.ts": 410, - "test/path-spelling.test.ts": 2158, - "test/pending-question-completion.test.ts": 500, - "test/periodic-exclude-policy.test.ts": 382, - "test/plan-count-checkbox.test.ts": 145, - "test/plan-count-completion.test.ts": 742, - "test/plan-count-cropped-wrap.test.ts": 280, - "test/plan-count-cross-cwd-ancestry.test.ts": 453, - "test/plan-count-design-ui-recovery.test.ts": 178, - "test/plan-count-dx-handoff.test.ts": 294, - "test/plan-count-empty-review.test.ts": 147, - "test/plan-count-history.test.ts": 86, - "test/plan-count-long-edit.test.ts": 152, - "test/plan-count-native-input.test.ts": 127, - "test/plan-count-owned-permission.test.ts": 121, - "test/plan-count-pending-exit.test.ts": 223, - "test/plan-count-prerequisite.test.ts": 161, - "test/plan-count-preview-footer.test.ts": 124, - "test/plan-count-session-cwd.test.ts": 313, - "test/plan-count-timeout.test.ts": 68, - "test/plan-count-transcript.test.ts": 256, - "test/plan-count-truncated-border.test.ts": 158, - "test/plan-count-truncated-question.test.ts": 140, - "test/plan-create-combined-permission.test.ts": 457, - "test/plan-create-permission.test.ts": 254, - "test/plan-design-floor-fixture.test.ts": 2448, - "test/plan-design-sdk-fixture.test.ts": 4508, - "test/plan-design-with-ui-fixture.test.ts": 10957, - "test/plan-edit-cropped-permission.test.ts": 501, - "test/plan-eng-resume.test.ts": 69, - "test/plan-floor-dx-actor.test.ts": 270, - "test/plan-floor-edit-wrap.test.ts": 198, - "test/plan-floor-review.test.ts": 114, - "test/plan-floor-target.test.ts": 15199, - "test/plan-format-approach-prompt.test.ts": 46, - "test/plan-format-kind-note.test.ts": 64, - "test/plan-pending-question-pty.test.ts": 125, - "test/plan-review-board-feedback.test.ts": 16253, - "test/plan-review-calibration.test.ts": 1141, - "test/plan-review-decisions.test.ts": 492, - "test/plan-review-report-recording.test.ts": 11843, - "test/plan-scope-selection.test.ts": 4032, - "test/plan-skill-question-hook-scope.test.ts": 11531, - "test/plan-skill-read-permission.test.ts": 174, - "test/plan-skill-webfetch-permission.test.ts": 6455, - "test/platform-qualification-workflow.test.ts": 57, - "test/post-rename-doc-regen.test.ts": 107, - "test/pr-direct-cases.test.ts": 8938, - "test/pr-shared-input-selection.test.ts": 4713, - "test/pr-title-sync-workflow-safety.test.ts": 61, - "test/pregate-import-graph-paths.test.ts": 13703, - "test/pregate-manifest-paths.test.ts": 8174, - "test/pregate-wtree-refusal.test.ts": 12209, - "test/prompt-audit-manifest.test.ts": 1048, - "test/prosons-neutral-posture.test.ts": 56, - "test/provider-model-defaults.test.ts": 105, - "test/pty-askuserquestion-single-line.test.ts": 116, - "test/pty-numbered-option-indent-native.test.ts": 150, - "test/pty-option-selection.test.ts": 70, - "test/pty-output-wake.test.ts": 3387, - "test/pty-screen-session.test.ts": 66, - "test/pty-screen-supervision.test.ts": 10816, + "test/helpers/e2e-gate.unit.test.ts": 81, + "test/helpers/eval-store.test.ts": 4095, + "test/helpers/hermetic-env.test.ts": 18718, + "test/helpers/observability.test.ts": 671, + "test/helpers/providers/gemini.test.ts": 80, + "test/helpers/run-bin.test.ts": 219, + "test/heredoc-pipe-deadlock.test.ts": 811, + "test/honesty-kernel.test.ts": 140, + "test/hook-scripts.test.ts": 252101, + "test/host-renders-openai-yaml-proof.test.ts": 141, + "test/instruction-facts.test.ts": 168, + "test/investigate-freeze-path.test.ts": 29609, + "test/investigate-recipes.test.ts": 74, + "test/ios-debug-bridge-release-guard.test.ts": 75, + "test/ios-qa-daemon-cli.test.ts": 196, + "test/ios-qa-stateserver-hardening.test.ts": 66, + "test/ios-qa-swift-build.test.ts": 82, + "test/ios-qa.test.ts": 378, + "test/is-conductor.test.ts": 66, + "test/jargon-list.test.ts": 61, + "test/judge-calibration.test.ts": 202, + "test/judge-panel-median.test.ts": 114, + "test/land-and-deploy-command-trigger.test.ts": 78, + "test/land-and-deploy-flow.test.ts": 15443, + "test/land-and-deploy-postfail.test.ts": 77, + "test/llm-judge-abort.test.ts": 111, + "test/llm-judge-frontier.test.ts": 293, + "test/llm-judge-stream.test.ts": 122, + "test/llms-txt-shape.test.ts": 112, + "test/maintenance-cli-args.test.ts": 496, + "test/memory-ingest-timeout.test.ts": 124, + "test/migration-checkpoint-ownership.test.ts": 1899, + "test/mktemp-portability.test.ts": 797, + "test/model-catalog-isolation.test.ts": 222, + "test/model-overlays.test.ts": 97, + "test/model-panel-selftest.test.ts": 5073, + "test/model-policy-freshness.test.ts": 433, + "test/model-policy.test.ts": 230, + "test/modernize-skill.test.ts": 79, + "test/module-size-ratchet.test.ts": 619, + "test/native-auto-decide-pty.test.ts": 14732, + "test/native-auto-decide.test.ts": 807, + "test/no-quoted-tilde-assignments.test.ts": 118, + "test/no-suicide-exit.test.ts": 340, + "test/office-hours-budget.test.ts": 136, + "test/office-hours-open-question-form.test.ts": 71, + "test/office-hours-phase4-caller.test.ts": 90, + "test/office-hours-review.test.ts": 2688, + "test/office-posture-recording.test.ts": 147, + "test/one-way-doors.test.ts": 62, + "test/open-gstack-browser-liveness.test.ts": 54, + "test/openclaw-native-skills.test.ts": 52, + "test/osv-config-wiring.test.ts": 49, + "test/osv-ignore-expiry.test.ts": 56, + "test/outside-review-severity.test.ts": 231, + "test/outside-review-unclosed-fence.test.ts": 139, + "test/outside-review-unparsed-severity.test.ts": 443, + "test/outside-send-boundary.test.ts": 1010, + "test/outside-voice-evidence.test.ts": 264, + "test/outside-voice-failure-policy.test.ts": 517, + "test/outside-voice-fixture.test.ts": 2398, + "test/outside-voice-native-deadline.test.ts": 157, + "test/outside-voice-routing.test.ts": 4217, + "test/overlay-lifecycle.test.ts": 659, + "test/overlay-measurement.test.ts": 941, + "test/overlay-recording-order.test.ts": 87, + "test/overlay-sdk-cancel-eof.test.ts": 256, + "test/paid-free-boundary.test.ts": 20803, + "test/paid-orphan-tripwire.test.ts": 272, + "test/paid-overlay-scheduling.test.ts": 2542, + "test/paid-pr-profile.test.ts": 9245, + "test/paid-report-census.test.ts": 586, + "test/paid-report-fail-open.test.ts": 3738, + "test/paid-run-manifest.test.ts": 9256, + "test/paid-selection-propagation.test.ts": 133, + "test/paid-shard-settlement.test.ts": 1846, + "test/paid-shards.test.ts": 16821, + "test/paid-slice-deadline.test.ts": 1673, + "test/pair-agent-token-hygiene.test.ts": 58, + "test/parity-baseline-integrity.test.ts": 128, + "test/parity-sectioned.test.ts": 65, + "test/parity-suite.test.ts": 333, + "test/path-spelling.test.ts": 1806, + "test/pending-question-completion.test.ts": 427, + "test/periodic-exclude-policy.test.ts": 411, + "test/plan-count-checkbox.test.ts": 196, + "test/plan-count-completion.test.ts": 683, + "test/plan-count-cropped-wrap.test.ts": 227, + "test/plan-count-cross-cwd-ancestry.test.ts": 616, + "test/plan-count-design-ui-recovery.test.ts": 179, + "test/plan-count-dx-handoff.test.ts": 283, + "test/plan-count-empty-review.test.ts": 180, + "test/plan-count-history.test.ts": 88, + "test/plan-count-long-edit.test.ts": 147, + "test/plan-count-native-input.test.ts": 150, + "test/plan-count-owned-permission.test.ts": 115, + "test/plan-count-pending-exit.test.ts": 262, + "test/plan-count-prerequisite.test.ts": 165, + "test/plan-count-preview-footer.test.ts": 117, + "test/plan-count-session-cwd.test.ts": 266, + "test/plan-count-timeout.test.ts": 81, + "test/plan-count-transcript.test.ts": 246, + "test/plan-count-truncated-border.test.ts": 138, + "test/plan-count-truncated-question.test.ts": 131, + "test/plan-create-combined-permission.test.ts": 398, + "test/plan-create-permission.test.ts": 249, + "test/plan-design-floor-fixture.test.ts": 1897, + "test/plan-design-sdk-fixture.test.ts": 3518, + "test/plan-design-with-ui-fixture.test.ts": 19046, + "test/plan-eng-resume.test.ts": 77, + "test/plan-floor-dx-actor.test.ts": 244, + "test/plan-floor-edit-wrap.test.ts": 186, + "test/plan-floor-review.test.ts": 162, + "test/plan-floor-target.test.ts": 15157, + "test/plan-format-approach-prompt.test.ts": 61, + "test/plan-format-kind-note.test.ts": 70, + "test/plan-mode-off-generation.test.ts": 1411, + "test/plan-mode-preamble-setup.test.ts": 195, + "test/plan-pending-question-pty.test.ts": 276, + "test/plan-review-board-feedback.test.ts": 12931, + "test/plan-review-calibration.test.ts": 3601, + "test/plan-review-decisions.test.ts": 1307, + "test/plan-scope-selection.test.ts": 4694, + "test/plan-skill-question-hook-scope.test.ts": 19669, + "test/plan-skill-read-permission.test.ts": 308, + "test/plan-skill-webfetch-permission.test.ts": 6623, + "test/platform-qualification-workflow.test.ts": 61, + "test/post-rename-doc-regen.test.ts": 79, + "test/pr-direct-cases.test.ts": 13544, + "test/pr-shared-input-selection.test.ts": 5390, + "test/pr-title-sync-workflow-safety.test.ts": 148, + "test/preamble-hoist.test.ts": 19194, + "test/pregate-always-run.test.ts": 19759, + "test/pregate-import-graph-paths.test.ts": 17850, + "test/pregate-manifest-paths.test.ts": 9330, + "test/pregate-rust.test.ts": 22406, + "test/pregate-user-policy.test.ts": 90329, + "test/pregate-wtree-refusal.test.ts": 16818, + "test/prompt-audit-manifest.test.ts": 1200, + "test/prose-voice-floor.test.ts": 738, + "test/prosons-neutral-posture.test.ts": 54, + "test/provider-model-defaults.test.ts": 232, + "test/pstack-sections.test.ts": 98, + "test/pty-askuserquestion-single-line.test.ts": 122, + "test/pty-numbered-option-indent-native.test.ts": 164, + "test/pty-option-selection.test.ts": 80, + "test/pty-output-wake.test.ts": 3335, + "test/pty-screen-session.test.ts": 70, + "test/pty-screen-supervision.test.ts": 11228, "test/pty-screen-unicode-ap.test.ts": 219, - "test/pty-screen.test.ts": 228, - "test/pty-skill-seeding-wiring.test.ts": 725, - "test/pty-trust-dialog.test.ts": 67, - "test/published-text.test.ts": 79, - "test/qa-browser-preservation.test.ts": 17642, - "test/qa-bugs-fixture.test.ts": 110, - "test/qa-caller-authority.test.ts": 356, - "test/qa-caller-freshness-order.test.ts": 192, - "test/qa-caller-report-observer.test.ts": 100, - "test/qa-checkpoint-evidence.test.ts": 426, - "test/qa-deadline-publication-observer.test.ts": 106, - "test/qa-deadline-selection.test.ts": 117, - "test/qa-deadline.test.ts": 24851, - "test/qa-evidence-selection.test.ts": 182, - "test/qa-evidence.test.ts": 21011, - "test/qa-fix-loop-fixture.test.ts": 9304, - "test/qa-health-rubric.test.ts": 69, - "test/qa-only-browser-probe.test.ts": 1717, - "test/qa-probe-gates.test.ts": 217, - "test/qa-report-fields.test.ts": 60, - "test/qa-supervision-selection.test.ts": 109, - "test/question-tuning-registry-path.test.ts": 62, - "test/read-only-command-grammar.test.ts": 103, - "test/readme-skill-lists.test.ts": 82, - "test/redact-context-exemptions.test.ts": 127, - "test/redact-doc-resolver.test.ts": 143, - "test/redact-dotenv-filename-false-positive.test.ts": 70, - "test/redact-engine-autoredact.test.ts": 73, - "test/redact-parcel-id-false-positive.test.ts": 81, - "test/redact-pattern-lint.test.ts": 94, - "test/redact-prepush-emails.test.ts": 70990, - "test/redact-prepush-target.test.ts": 82963, - "test/redact-span-binding.test.ts": 144, - "test/redact-unicode-offsets.test.ts": 230, - "test/regression-1539-review-self-verify.test.ts": 64, - "test/regression-1834-retro-recommendations.test.ts": 60, - "test/regression-issue2091-bsd-mktemp.test.ts": 960, - "test/regression-pr1169-mktemp-fallbacks.test.ts": 257, - "test/regression-transcript-frontmatter-fence.test.ts": 136, - "test/remote-identity-vectors.test.ts": 360, - "test/replay-pr-selection.test.ts": 4979, - "test/resolvers-gbrain-put-rewrite.test.ts": 134, - "test/resolvers-gbrain-save-results.test.ts": 70, - "test/retired-commands.test.ts": 141, - "test/review-army-budget.test.ts": 6890, - "test/review-base-refresh.test.ts": 2405, - "test/review-consensus-lifecycle.test.ts": 125, - "test/review-count-markdown.test.ts": 301, - "test/review-entry-and-design-clarity-au.test.ts": 176, - "test/review-enum-lifecycle.test.ts": 102, - "test/review-finalization-budget.test.ts": 8384, - "test/review-n-plus-one-contract.test.ts": 6711, - "test/review-quality-provenance.test.ts": 117, - "test/router-disabled-skills.test.ts": 1691, - "test/run-shard-child.test.ts": 1420, - "test/runtime-pins.test.ts": 276, - "test/safety-rule-registry.test.ts": 112, - "test/salience-allowlist.test.ts": 134, - "test/schema-version-migration.test.ts": 551, - "test/section-manifest-consistency.test.ts": 99, - "test/session-runner-browse-errors.test.ts": 4838, - "test/session-runner-stream-lifecycle.test.ts": 410, - "test/session-runner-timeout.test.ts": 88, - "test/session-runner-tools.test.ts": 267, - "test/setup-alias-name-uniqueness.test.ts": 30161, - "test/setup-browser-hint.test.ts": 764, - "test/setup-bun-cmd-and-pipe-bugs.test.ts": 443, - "test/setup-cleanup-orphans.test.ts": 75, - "test/setup-codex-design-pdf-runtime.test.ts": 90, - "test/setup-codex-model.test.ts": 67, - "test/setup-codex-scope-review-boundaries.test.ts": 206, - "test/setup-cso-windows-probe.test.ts": 65, - "test/setup-cursor-legacy-skills.test.ts": 171, - "test/setup-deploy-key-presence.test.ts": 153, - "test/setup-deploy-not-deployed.test.ts": 64, - "test/setup-deploy-vercel-trigger.test.ts": 66, - "test/setup-gbrain-bin-invocation-paths.test.ts": 84, - "test/setup-gbrain-path4-caller.test.ts": 8187, - "test/setup-gbrain-path4-structure.test.ts": 263, - "test/setup-gbrain-remote-caller.test.ts": 11356, - "test/setup-help.test.ts": 849, - "test/setup-hook-syntax-gate.test.ts": 166453, - "test/setup-playwright-platform.test.ts": 12030, - "test/setup-prune-stale-generated.test.ts": 3660, - "test/setup-sections-linking.test.ts": 82, - "test/setup-skill-copies.test.ts": 94, - "test/setup-staged-copy.test.ts": 604, - "test/setup-windows-fallback.test.ts": 72, - "test/setup-windows-rerun-refresh.test.ts": 2592, - "test/shard-cli-parity.test.ts": 377, - "test/shared-libs-cancellation.test.ts": 416, - "test/shared-libs-evidence.test.ts": 217, - "test/shared-libs-plan-actor.test.ts": 291, - "test/shared-libs-rendering.test.ts": 4300, - "test/shared-libs-source-reads.test.ts": 28516, - "test/ship-apple-gate.test.ts": 79, - "test/ship-control-flow.test.ts": 7090, - "test/ship-coverage-audit-af.test.ts": 278, - "test/ship-greptile-early.test.ts": 108, - "test/ship-greptile-pr-check.test.ts": 2116, - "test/ship-hook-windows-paths.test.ts": 4202, - "test/ship-measure-bar.test.ts": 264, - "test/ship-measure-loop-skill.test.ts": 131, - "test/ship-pr-liveness-policy.test.ts": 88, - "test/ship-publication-gates.test.ts": 19864, - "test/ship-reentry-gates.test.ts": 154, - "test/ship-review-loop.test.ts": 85, - "test/ship-section-fixture.test.ts": 2515, - "test/ship-skip-requeue.test.ts": 173, - "test/ship-template-redaction.test.ts": 76, - "test/ship-test-detection-markers.test.ts": 2597, - "test/ship-triage-labels.test.ts": 91, - "test/ship-workflow-clarity.test.ts": 168, - "test/shortcut-debt-ledger.test.ts": 80, - "test/sideways-review.test.ts": 59, - "test/skill-browse-state-extraction.test.ts": 430, - "test/skill-budget-regression.test.ts": 651, - "test/skill-check-driver.test.ts": 408, - "test/skill-collision-sentinel.test.ts": 79, - "test/skill-coverage-floor.test.ts": 163, - "test/skill-cross-model-recommendation-emit.test.ts": 85, - "test/skill-md-size-guard.test.ts": 4454, - "test/skill-parser.test.ts": 111, - "test/skill-positional-literals.test.ts": 9956, - "test/skill-preflight-budget.test.ts": 81, - "test/skillify-host-paths.test.ts": 3766, - "test/sol-skill-fixture.test.ts": 4905, - "test/spawnsync-timeout-tripwire.test.ts": 618, - "test/spec-template-invariants.test.ts": 78, - "test/spend-ledger.test.ts": 402, - "test/state-root-parity.test.ts": 3083, - "test/strict-output-capture.test.ts": 271, - "test/strict-output-formats.test.ts": 3508, - "test/strict-output-settlement.test.ts": 4344, - "test/strict-output.test.ts": 64, - "test/sync-gbrain-readiness-fixture.test.ts": 745, - "test/takes-fence-fallback.test.ts": 59, - "test/tasks-section-jq.test.ts": 259, - "test/team-init-hook-shell-neutral.test.ts": 1758, - "test/template-context-parity.test.ts": 92, - "test/terse-build.test.ts": 80, - "test/test-free-shards-files-from.test.ts": 400, - "test/test-free-shards-git-maintenance.test.ts": 1296, - "test/test-free-shards-sandbox-knobs.test.ts": 593, - "test/test-free-shards-settlement-lifecycle.test.ts": 111, - "test/test-free-shards-settlement-ownership.test.ts": 94, - "test/test-health-report.test.ts": 1977, - "test/test-health-workflow.test.ts": 70, - "test/test-of-test-ratchet.test.ts": 696, - "test/test-pr-profile.test.ts": 175, - "test/test-value-bar.test.ts": 889, - "test/third-party-actions-recording.test.ts": 909, - "test/third-party-actions.test.ts": 748, - "test/timeline-stop-hook-slug.test.ts": 5216, - "test/touchfiles-facade.test.ts": 89, - "test/touchfiles-map-diff.test.ts": 1942, - "test/tracker-guard-wiring.test.ts": 187, - "test/trial-cause.test.ts": 1253, - "test/typecheck-test-ratchet.test.ts": 139, - "test/uninstall-windows-copies.test.ts": 13420, - "test/v0-dormancy.test.ts": 139, - "test/version-source.test.ts": 69, - "test/voice-directive.test.ts": 160, - "test/windows-free-selection-autoplan-guard.test.ts": 103, - "test/windows-native-workflows.test.ts": 858, - "test/workflow-action-pins.test.ts": 109, - "test/workflow-boundaries-fixture.test.ts": 28350, - "test/workflow-concurrency.test.ts": 61, - "test/workflow-excerpt.test.ts": 350, - "test/workflow-judge-cache.test.ts": 25059, - "test/workflow-required-steps.test.ts": 128, - "test/worktree-isolated-shapes.test.ts": 127, - "test/worktree.test.ts": 9019 + "test/pty-screen.test.ts": 236, + "test/pty-skill-seeding-wiring.test.ts": 1200, + "test/pty-trust-dialog.test.ts": 625, + "test/published-text.test.ts": 95, + "test/qa-browser-preservation.test.ts": 16041, + "test/qa-bugs-fixture.test.ts": 91, + "test/qa-caller-authority.test.ts": 335, + "test/qa-caller-freshness-order.test.ts": 182, + "test/qa-caller-report-observer.test.ts": 109, + "test/qa-checkpoint-evidence.test.ts": 358, + "test/qa-deadline-publication-observer.test.ts": 89, + "test/qa-deadline-selection.test.ts": 112, + "test/qa-deadline.test.ts": 30097, + "test/qa-evidence-selection.test.ts": 177, + "test/qa-evidence.test.ts": 18281, + "test/qa-fix-loop-fixture.test.ts": 8900, + "test/qa-health-rubric.test.ts": 384, + "test/qa-only-browser-probe.test.ts": 2442, + "test/qa-probe-gates.test.ts": 228, + "test/qa-report-fields.test.ts": 63, + "test/qa-supervision-selection.test.ts": 110, + "test/question-tuning-registry-path.test.ts": 65, + "test/readme-skill-lists.test.ts": 72, + "test/redact-constant-and-at-filename.test.ts": 92, + "test/redact-context-exemptions.test.ts": 187, + "test/redact-doc-resolver.test.ts": 132, + "test/redact-dotenv-filename-false-positive.test.ts": 79, + "test/redact-engine-autoredact.test.ts": 66, + "test/redact-parcel-id-false-positive.test.ts": 79, + "test/redact-pattern-lint.test.ts": 107, + "test/redact-prepush-emails.test.ts": 52521, + "test/redact-prepush-target.test.ts": 61351, + "test/redact-span-binding.test.ts": 131, + "test/redact-unicode-offsets.test.ts": 234, + "test/regression-1539-review-self-verify.test.ts": 73, + "test/regression-1834-retro-recommendations.test.ts": 61, + "test/regression-issue2091-bsd-mktemp.test.ts": 755, + "test/regression-pr1169-mktemp-fallbacks.test.ts": 180, + "test/regression-transcript-frontmatter-fence.test.ts": 133, + "test/release-cut.test.ts": 138015, + "test/remote-identity-vectors.test.ts": 286, + "test/replay-pr-selection.test.ts": 4793, + "test/resolvers-gbrain-put-rewrite.test.ts": 107, + "test/resolvers-gbrain-save-results.test.ts": 72, + "test/restamp-base-merge.test.ts": 14319, + "test/restamp-four-part-manifest.test.ts": 22166, + "test/restamp-mentions.test.ts": 48900, + "test/retired-commands.test.ts": 129, + "test/review-army-budget.test.ts": 5686, + "test/review-base-refresh.test.ts": 2106, + "test/review-collect.test.ts": 18677, + "test/review-consensus-lifecycle.test.ts": 114, + "test/review-count-markdown.test.ts": 267, + "test/review-dispatch-compare.test.ts": 234, + "test/review-dispatch-counters.test.ts": 86, + "test/review-entry-and-design-clarity-au.test.ts": 174, + "test/review-enum-lifecycle.test.ts": 91, + "test/review-finalization-budget.test.ts": 7436, + "test/review-loop-budget-wording.test.ts": 74, + "test/review-n-plus-one-contract.test.ts": 6694, + "test/review-quality-provenance.test.ts": 126, + "test/review-scope-followups.test.ts": 96, + "test/router-disabled-skills.test.ts": 2292, + "test/run-shard-child.test.ts": 1389, + "test/runtime-pins.test.ts": 245, + "test/safety-rule-registry.test.ts": 106, + "test/salience-allowlist.test.ts": 103, + "test/schema-version-migration.test.ts": 412, + "test/section-manifest-consistency.test.ts": 98, + "test/session-runner-browse-errors.test.ts": 5462, + "test/session-runner-stream-lifecycle.test.ts": 411, + "test/session-runner-timeout.test.ts": 83, + "test/session-runner-tools.test.ts": 265, + "test/setup-alias-name-uniqueness.test.ts": 24711, + "test/setup-browser-hint.test.ts": 613, + "test/setup-bun-cmd-and-pipe-bugs.test.ts": 360, + "test/setup-cleanup-orphans.test.ts": 80, + "test/setup-codex-design-pdf-runtime.test.ts": 78, + "test/setup-codex-model.test.ts": 69, + "test/setup-codex-scope-review-boundaries.test.ts": 161, + "test/setup-cso-windows-probe.test.ts": 80, + "test/setup-cursor-legacy-skills.test.ts": 170, + "test/setup-deploy-key-presence.test.ts": 144, + "test/setup-deploy-not-deployed.test.ts": 83, + "test/setup-deploy-vercel-trigger.test.ts": 65, + "test/setup-gbrain-bin-invocation-paths.test.ts": 69, + "test/setup-gbrain-path4-caller.test.ts": 15185, + "test/setup-gbrain-path4-structure.test.ts": 66, + "test/setup-gbrain-remote-caller.test.ts": 20976, + "test/setup-help.test.ts": 373, + "test/setup-hook-syntax-gate.test.ts": 111941, + "test/setup-playwright-platform.test.ts": 7517, + "test/setup-prune-stale-generated.test.ts": 2303, + "test/setup-sections-linking.test.ts": 79, + "test/setup-skill-copies.test.ts": 88, + "test/setup-staged-copy.test.ts": 551, + "test/setup-windows-fallback.test.ts": 106, + "test/setup-windows-rerun-refresh.test.ts": 1717, + "test/shard-cli-parity.test.ts": 330, + "test/shared-libs-cancellation.test.ts": 334, + "test/shared-libs-evidence.test.ts": 95, + "test/shared-libs-plan-actor.test.ts": 187, + "test/shared-libs-rendering.test.ts": 4524, + "test/shared-libs-source-reads.test.ts": 19941, + "test/ship-apple-gate.test.ts": 115, + "test/ship-budget-batch.test.ts": 10536, + "test/ship-budget-calibration.test.ts": 113, + "test/ship-budget-chain.test.ts": 118227, + "test/ship-budget-meter-contract.test.ts": 20311, + "test/ship-budget-projection.test.ts": 107262, + "test/ship-budget-reader-drift.test.ts": 53273, + "test/ship-budget-review-log.test.ts": 131834, + "test/ship-budget-section.test.ts": 109, + "test/ship-budget-small-run.test.ts": 7146, + "test/ship-budget-step-prior.test.ts": 2101, + "test/ship-control-flow.test.ts": 3877, + "test/ship-coverage-audit-af.test.ts": 173, + "test/ship-frozen-scope-actor.test.ts": 578, + "test/ship-greptile-early.test.ts": 71, + "test/ship-greptile-pr-check.test.ts": 1153, + "test/ship-handoff-brief.test.ts": 20871, + "test/ship-hook-windows-paths.test.ts": 2319, + "test/ship-measure-bar.test.ts": 179, + "test/ship-measure-loop-skill.test.ts": 68, + "test/ship-pr-liveness-policy.test.ts": 66, + "test/ship-publication-gates.test.ts": 7889, + "test/ship-queue-train-stamp.test.ts": 78565, + "test/ship-receipt-carry-rewrite.test.ts": 75723, + "test/ship-receipt-carry-squash.test.ts": 76600, + "test/ship-receipt-depth.test.ts": 68005, + "test/ship-reentry-gates.test.ts": 84, + "test/ship-review-loop.test.ts": 84, + "test/ship-section-fixture.test.ts": 1966, + "test/ship-skip-requeue.test.ts": 141, + "test/ship-template-redaction.test.ts": 85, + "test/ship-test-detection-markers.test.ts": 3459, + "test/ship-triage-labels.test.ts": 73, + "test/ship-workflow-clarity.test.ts": 159, + "test/shortcut-debt-ledger.test.ts": 72, + "test/sideways-review.test.ts": 67, + "test/size-ledger.test.ts": 6713, + "test/skill-browse-state-extraction.test.ts": 240, + "test/skill-budget-regression.test.ts": 374, + "test/skill-check-driver.test.ts": 376, + "test/skill-collision-sentinel.test.ts": 95, + "test/skill-coverage-floor.test.ts": 151, + "test/skill-cross-model-recommendation-emit.test.ts": 92, + "test/skill-eval-tasks.test.ts": 329, + "test/skill-md-size-guard.test.ts": 4449, + "test/skill-parser.test.ts": 102, + "test/skill-positional-literals.test.ts": 6446, + "test/skill-preflight-budget.test.ts": 71, + "test/skill-triggers.test.ts": 75, + "test/skillify-host-paths.test.ts": 4396, + "test/sol-skill-fixture.test.ts": 5142, + "test/spawnsync-timeout-tripwire.test.ts": 644, + "test/spec-template-invariants.test.ts": 83, + "test/spend-ledger.test.ts": 345, + "test/state-root-parity.test.ts": 2114, + "test/strict-output-capture.test.ts": 213, + "test/strict-output-formats.test.ts": 2732, + "test/strict-output-settlement.test.ts": 4260, + "test/strict-output.test.ts": 56, + "test/sync-gbrain-readiness-fixture.test.ts": 606, + "test/takes-fence-fallback.test.ts": 60, + "test/tasks-section-jq.test.ts": 228, + "test/team-init-hook-shell-neutral.test.ts": 1385, + "test/template-context-parity.test.ts": 65, + "test/terse-build.test.ts": 67, + "test/test-free-shards-files-from.test.ts": 338, + "test/test-free-shards-git-maintenance.test.ts": 947, + "test/test-free-shards-sandbox-knobs.test.ts": 443, + "test/test-free-shards-settlement-lifecycle.test.ts": 73, + "test/test-free-shards-settlement-ownership.test.ts": 74, + "test/test-health-report.test.ts": 1544, + "test/test-health-workflow.test.ts": 87, + "test/test-of-test-ratchet.test.ts": 693, + "test/test-pr-profile.test.ts": 4087, + "test/test-value-bar.test.ts": 748, + "test/third-party-actions-recording.test.ts": 693, + "test/third-party-actions.test.ts": 409, + "test/timeline-stop-hook-slug.test.ts": 5032, + "test/touchfiles-facade.test.ts": 99, + "test/touchfiles-map-diff.test.ts": 2147, + "test/tracker-guard-wiring.test.ts": 164, + "test/train-backstop.test.ts": 52410, + "test/train-gate.test.ts": 12718, + "test/trial-cause.test.ts": 1190, + "test/typecheck-test-ratchet.test.ts": 126, + "test/unattended-grant.test.ts": 68, + "test/uninstall-windows-copies.test.ts": 10151, + "test/v0-dormancy.test.ts": 113, + "test/version-gate-fragments.test.ts": 16278, + "test/version-source.test.ts": 64, + "test/voice-directive.test.ts": 152, + "test/weekly-release-workflow.test.ts": 88, + "test/windows-free-selection-autoplan-guard.test.ts": 82, + "test/windows-native-workflows.test.ts": 696, + "test/workflow-action-pins.test.ts": 84, + "test/workflow-boundaries-fixture.test.ts": 25430, + "test/workflow-concurrency.test.ts": 72, + "test/workflow-excerpt.test.ts": 328, + "test/workflow-gh-token.test.ts": 73, + "test/workflow-judge-cache.test.ts": 17402, + "test/workflow-required-steps.test.ts": 101, + "test/workflow-shell-lint.test.ts": 107, + "test/worktree-isolated-shapes.test.ts": 116, + "test/worktree.test.ts": 6314 } } diff --git a/scripts/free-test-durations.json b/scripts/free-test-durations.json index de9bde403..0ef0e54ad 100644 --- a/scripts/free-test-durations.json +++ b/scripts/free-test-durations.json @@ -1,1290 +1,1476 @@ { "version": 1, - "recordedAt": "2026-10-19T04:31:41.807Z", + "recordedAt": "2026-10-31T18:44:17.070Z", "durations": { - "browse/test/activity.test.ts": 37, - "browse/test/adversarial-security.test.ts": 26, - "browse/test/batch.test.ts": 1675, - "browse/test/bridge-chromium-e2e.test.ts": 826, - "browse/test/browse-client.test.ts": 106, - "browse/test/browse-eval-wrapping.test.ts": 19, - "browse/test/browser-manager-custom-chromium.test.ts": 367, - "browse/test/browser-manager-unit.test.ts": 511, - "browse/test/browser-skill-commands.test.ts": 1162, - "browse/test/browser-skill-write.test.ts": 93, - "browse/test/browser-skills-e2e.test.ts": 355, - "browse/test/browser-skills-storage.test.ts": 273, - "browse/test/build-command-response.test.ts": 542, - "browse/test/build.test.ts": 107, - "browse/test/bun-child-config.test.ts": 98, - "browse/test/bun-polyfill.test.ts": 1428, - "browse/test/busy-daemon-iron-rule.test.ts": 15956, - "browse/test/busy-daemon-recovery.test.ts": 564, - "browse/test/cdp-allowlist.test.ts": 21, - "browse/test/cdp-e2e.test.ts": 520, - "browse/test/cdp-inspector-history-cap.test.ts": 24, - "browse/test/cdp-mutex.test.ts": 654, - "browse/test/cdp-session-cleanup.test.ts": 43, - "browse/test/chromium-profile-isolation.test.ts": 13717, - "browse/test/claude-bin.test.ts": 9, - "browse/test/cli-chain-stdin.test.ts": 147, - "browse/test/cli-config-mismatch-message.test.ts": 100, - "browse/test/cli-lock.test.ts": 29, - "browse/test/cli-loopback-no-proxy.test.ts": 205, - "browse/test/cli-setsid-daemonize.test.ts": 10, - "browse/test/cli-start-final-healthcheck.test.ts": 53, - "browse/test/cli-supervisor.test.ts": 39, - "browse/test/commands.test.ts": 31274, - "browse/test/compare-board.test.ts": 904, - "browse/test/config.test.ts": 163, - "browse/test/content-security.test.ts": 771, - "browse/test/cookie-auth-verification.test.ts": 7627, - "browse/test/cookie-credential-deadline.test.ts": 381, - "browse/test/cookie-database.test.ts": 465, - "browse/test/cookie-fixture-delete-lease.test.ts": 50, - "browse/test/cookie-import-browser.test.ts": 201, - "browse/test/cookie-import-native-job.test.ts": 461, - "browse/test/cookie-import-native.test.ts": 3243, - "browse/test/cookie-import-node.test.ts": 191, - "browse/test/cookie-import-operation.test.ts": 828, - "browse/test/cookie-import-reliability.test.ts": 1536, - "browse/test/cookie-import-transport.test.ts": 29, - "browse/test/cookie-picker-binding-browser.test.ts": 1812, - "browse/test/cookie-picker-binding.test.ts": 224, - "browse/test/cookie-picker-csrf-browser.test.ts": 424, - "browse/test/cookie-picker-routes.test.ts": 257, - "browse/test/cookie-picker-ui.test.ts": 12684, - "browse/test/daemon-log-hygiene.test.ts": 32, - "browse/test/daemon-mismatch-refuse.test.ts": 105, + "browse/test/activity.test.ts": 52, + "browse/test/adversarial-security.test.ts": 41, + "browse/test/batch.test.ts": 1440, + "browse/test/bridge-chromium-e2e.test.ts": 852, + "browse/test/browse-client.test.ts": 121, + "browse/test/browse-eval-wrapping.test.ts": 68, + "browse/test/browser-manager-custom-chromium.test.ts": 345, + "browse/test/browser-manager-unit.test.ts": 552, + "browse/test/browser-skill-commands.test.ts": 1115, + "browse/test/browser-skill-write.test.ts": 49, + "browse/test/browser-skills-e2e.test.ts": 43, + "browse/test/browser-skills-storage.test.ts": 42, + "browse/test/build-command-response.test.ts": 352, + "browse/test/build.test.ts": 77, + "browse/test/bun-child-config.test.ts": 36, + "browse/test/bun-polyfill.test.ts": 1287, + "browse/test/busy-active-tab.test.ts": 12510, + "browse/test/busy-daemon-iron-rule.test.ts": 16155, + "browse/test/busy-daemon-recovery.test.ts": 583, + "browse/test/cdp-allowlist.test.ts": 10, + "browse/test/cdp-e2e.test.ts": 446, + "browse/test/cdp-inspector-history-cap.test.ts": 32, + "browse/test/cdp-mutex.test.ts": 695, + "browse/test/cdp-session-cleanup.test.ts": 67, + "browse/test/chromium-profile-isolation.test.ts": 13296, + "browse/test/claude-bin.test.ts": 10, + "browse/test/cli-chain-stdin.test.ts": 140, + "browse/test/cli-config-mismatch-message.test.ts": 101, + "browse/test/cli-lock.test.ts": 16, + "browse/test/cli-loopback-no-proxy.test.ts": 62, + "browse/test/cli-setsid-daemonize.test.ts": 14, + "browse/test/cli-start-final-healthcheck.test.ts": 22, + "browse/test/cli-supervisor.test.ts": 23, + "browse/test/command-timeout-report.test.ts": 89, + "browse/test/commands.test.ts": 32608, + "browse/test/compare-board.test.ts": 1010, + "browse/test/config.test.ts": 301, + "browse/test/content-security.test.ts": 795, + "browse/test/cookie-auth-verification.test.ts": 7071, + "browse/test/cookie-credential-deadline.test.ts": 413, + "browse/test/cookie-database.test.ts": 522, + "browse/test/cookie-fixture-delete-lease.test.ts": 36, + "browse/test/cookie-import-browser.test.ts": 459, + "browse/test/cookie-import-native-job.test.ts": 629, + "browse/test/cookie-import-native.test.ts": 2703, + "browse/test/cookie-import-node.test.ts": 165, + "browse/test/cookie-import-operation.test.ts": 400, + "browse/test/cookie-import-reliability.test.ts": 1032, + "browse/test/cookie-import-transport.test.ts": 23, + "browse/test/cookie-picker-binding-browser.test.ts": 1859, + "browse/test/cookie-picker-binding.test.ts": 190, + "browse/test/cookie-picker-csrf-browser.test.ts": 440, + "browse/test/cookie-picker-routes.test.ts": 304, + "browse/test/cookie-picker-ui.test.ts": 13932, + "browse/test/daemon-log-hygiene.test.ts": 91, + "browse/test/daemon-mismatch-refuse.test.ts": 146, "browse/test/data-platform.test.ts": 21, - "browse/test/domain-skills-e2e.test.ts": 520, - "browse/test/domain-skills-storage.test.ts": 232, - "browse/test/dotenv-isolation.test.ts": 405, - "browse/test/dual-listener.test.ts": 470, + "browse/test/domain-skills-e2e.test.ts": 451, + "browse/test/domain-skills-storage.test.ts": 77, + "browse/test/dotenv-isolation.test.ts": 504, + "browse/test/dual-listener.test.ts": 400, "browse/test/dx-polish.test.ts": 25, - "browse/test/error-handling.test.ts": 21, - "browse/test/extension-sender-auth.test.ts": 52, - "browse/test/extension-token.test.ts": 460, - "browse/test/file-drop.test.ts": 35, - "browse/test/file-permissions.test.ts": 26, - "browse/test/fill-change-event.test.ts": 1533, - "browse/test/find-browse.test.ts": 27, - "browse/test/findport.test.ts": 419, - "browse/test/from-file-path-validation.test.ts": 27, - "browse/test/gstack-config.test.ts": 929, - "browse/test/gstack-update-check.test.ts": 3133, - "browse/test/handoff.test.ts": 10217, - "browse/test/headless-gpu-and-reap.test.ts": 1756, - "browse/test/headless-launch-options.test.ts": 3549, - "browse/test/launch-signal-flags.test.ts": 9, - "browse/test/learnings-injection.test.ts": 82, - "browse/test/media-extract-unit.test.ts": 11, - "browse/test/memory-command.test.ts": 319, - "browse/test/memory-leak-reproducer.test.ts": 330, - "browse/test/navigation-guard.test.ts": 6572, - "browse/test/output-path-symlinks.test.ts": 64, - "browse/test/pair-agent-e2e.test.ts": 1663, - "browse/test/pair-agent-optin-gate.test.ts": 291, - "browse/test/pair-agent-tunnel-eval.test.ts": 7572, - "browse/test/path-security-realpath.test.ts": 44, - "browse/test/path-validation.test.ts": 1580, - "browse/test/pdf-flags.test.ts": 24, - "browse/test/platform.test.ts": 11, - "browse/test/playwright-core-patch.test.ts": 12, - "browse/test/poisoned-bundle-probe.test.ts": 215, - "browse/test/process-liveness-windows.test.ts": 69, - "browse/test/proxy-config.test.ts": 19, - "browse/test/proxy-env-daemon.test.ts": 2566, - "browse/test/proxy-env.test.ts": 26, - "browse/test/proxy-redact.test.ts": 11, - "browse/test/pty-inject-scan.test.ts": 554, - "browse/test/pty-session-lease.test.ts": 23, - "browse/test/rebrand-signed-bundle.test.ts": 10, - "browse/test/regression-pr1169-pdf-from-file-invalid-json.test.ts": 26, - "browse/test/restart-env.test.ts": 23, - "browse/test/runtime-root-server-bundle.test.ts": 45, - "browse/test/sanitize.test.ts": 13, - "browse/test/screenshot-size-guard.test.ts": 426, - "browse/test/security-adversarial-fixes.test.ts": 27, - "browse/test/security-adversarial.test.ts": 17, - "browse/test/security-audit-r2.test.ts": 370, - "browse/test/security-bench.test.ts": 14, - "browse/test/security-classifier-download-cleanup.test.ts": 48, - "browse/test/security-classifier.test.ts": 13, + "browse/test/error-handling.test.ts": 30, + "browse/test/extension-sender-auth.test.ts": 57, + "browse/test/extension-token.test.ts": 328, + "browse/test/file-drop.test.ts": 17, + "browse/test/file-permissions.test.ts": 16, + "browse/test/fill-change-event.test.ts": 1554, + "browse/test/find-browse.test.ts": 17, + "browse/test/findport.test.ts": 366, + "browse/test/from-file-path-validation.test.ts": 10, + "browse/test/gstack-config.test.ts": 759, + "browse/test/gstack-update-check.test.ts": 2512, + "browse/test/handoff.test.ts": 13444, + "browse/test/headless-gpu-and-reap.test.ts": 1547, + "browse/test/headless-launch-options.test.ts": 2254, + "browse/test/launch-signal-flags.test.ts": 16, + "browse/test/learnings-injection.test.ts": 155, + "browse/test/media-extract-unit.test.ts": 17, + "browse/test/memory-command.test.ts": 392, + "browse/test/memory-leak-reproducer.test.ts": 371, + "browse/test/navigation-guard.test.ts": 6933, + "browse/test/output-path-symlinks.test.ts": 33, + "browse/test/pair-agent-e2e.test.ts": 1929, + "browse/test/pair-agent-optin-gate.test.ts": 319, + "browse/test/pair-agent-tunnel-eval.test.ts": 7753, + "browse/test/path-security-realpath.test.ts": 61, + "browse/test/path-validation.test.ts": 2273, + "browse/test/pdf-flags.test.ts": 29, + "browse/test/platform.test.ts": 16, + "browse/test/playwright-core-patch.test.ts": 29, + "browse/test/poisoned-bundle-probe.test.ts": 371, + "browse/test/process-liveness-windows.test.ts": 45, + "browse/test/proxy-config.test.ts": 111, + "browse/test/proxy-direct-launch-args.test.ts": 1268, + "browse/test/proxy-direct-launch-paths.test.ts": 4009, + "browse/test/proxy-direct-launch.test.ts": 5012, + "browse/test/proxy-env-daemon.test.ts": 5044, + "browse/test/proxy-env.test.ts": 36, + "browse/test/proxy-redact.test.ts": 22, + "browse/test/pty-inject-scan.test.ts": 635, + "browse/test/pty-session-lease.test.ts": 34, + "browse/test/rebrand-signed-bundle.test.ts": 50, + "browse/test/regression-pr1169-pdf-from-file-invalid-json.test.ts": 59, + "browse/test/restart-env.test.ts": 24, + "browse/test/runtime-root-server-bundle.test.ts": 75, + "browse/test/sanitize.test.ts": 21, + "browse/test/screenshot-size-guard.test.ts": 528, + "browse/test/security-adversarial-fixes.test.ts": 23, + "browse/test/security-adversarial.test.ts": 22, + "browse/test/security-audit-r2.test.ts": 390, + "browse/test/security-bench.test.ts": 18, + "browse/test/security-classifier-download-cleanup.test.ts": 57, + "browse/test/security-classifier.test.ts": 33, "browse/test/security-integration.test.ts": 19, - "browse/test/security-live-playwright.test.ts": 606, - "browse/test/security-sidecar-client.test.ts": 33, - "browse/test/security.test.ts": 28, - "browse/test/server-auth.test.ts": 354, - "browse/test/server-embedder-terminal-port.test.ts": 4027, - "browse/test/server-factory.test.ts": 926, - "browse/test/server-flush-trackers.test.ts": 10, - "browse/test/server-launch-resolution.test.ts": 22, - "browse/test/server-lock-errors.test.ts": 38, - "browse/test/server-no-import-side-effects.test.ts": 548, - "browse/test/server-proxy-fail-fast.test.ts": 1297, - "browse/test/server-pty-lease-routes.test.ts": 233, - "browse/test/server-route-auth-blackbox.test.ts": 328, - "browse/test/server-route-dispatch-ratchet.test.ts": 235, - "browse/test/server-route-table.test.ts": 296, - "browse/test/server-sanitize-surrogates.test.ts": 227, - "browse/test/server-tmp-state-path.test.ts": 9, - "browse/test/session-cookie-store.test.ts": 39, - "browse/test/session-persist.test.ts": 753, - "browse/test/sidebar-tabs.test.ts": 279, - "browse/test/sidebar-ux.test.ts": 304, - "browse/test/sidepanel-font-size.test.ts": 15, - "browse/test/sidepanel-patient-autoconnect.test.ts": 20, - "browse/test/sidepanel-reattach.test.ts": 18, - "browse/test/sidepanel-restart-dispose.test.ts": 20, - "browse/test/skill-token.test.ts": 20, - "browse/test/snapshot.test.ts": 7715, - "browse/test/socks-bridge.test.ts": 498, - "browse/test/sse-helpers.test.ts": 156, - "browse/test/sse-session-cookie.test.ts": 19, - "browse/test/state-ttl.test.ts": 10, - "browse/test/stealth-extended.test.ts": 7, - "browse/test/stealth-layer-c.test.ts": 10, - "browse/test/stealth-webdriver.test.ts": 1567, - "browse/test/stop-ack-before-shutdown.test.ts": 133, - "browse/test/stop-dead-daemon.test.ts": 229, - "browse/test/tab-each.test.ts": 25, - "browse/test/tab-external-close-dispatch.test.ts": 701, - "browse/test/tab-external-close.test.ts": 566, - "browse/test/tab-guardrail.test.ts": 334, - "browse/test/tab-isolation.test.ts": 316, - "browse/test/tab-session-frame-detach.test.ts": 9, - "browse/test/telemetry-optout.test.ts": 54, - "browse/test/telemetry.test.ts": 24, - "browse/test/temp-dirs.test.ts": 51, - "browse/test/terminal-agent-detach-reattach.test.ts": 10, - "browse/test/terminal-agent-import.test.ts": 4362, - "browse/test/terminal-agent-integration.test.ts": 777, - "browse/test/terminal-agent-keepalive.test.ts": 14, - "browse/test/terminal-agent-lifecycle.test.ts": 4251, - "browse/test/terminal-agent-native-observation.test.ts": 13, - "browse/test/terminal-agent-owner-watchdog.test.ts": 85, - "browse/test/terminal-agent-pid-identity.test.ts": 29, - "browse/test/terminal-agent-port-range.test.ts": 19, - "browse/test/terminal-agent-publication-lock.test.ts": 1147, - "browse/test/terminal-agent-ring-buffer-runtime.test.ts": 30, - "browse/test/terminal-agent-session-routing.test.ts": 9, - "browse/test/terminal-agent-signal-cleanup.test.ts": 15627, - "browse/test/terminal-agent-watchdog.test.ts": 10, - "browse/test/terminal-agent.test.ts": 230, - "browse/test/terminal-pty-lifecycle.test.ts": 13, - "browse/test/token-registry.test.ts": 24, - "browse/test/tunnel-gate-unit.test.ts": 254, - "browse/test/tunnel-revoke-cli.test.ts": 858, - "browse/test/url-validation.test.ts": 66, - "browse/test/watch.test.ts": 305, - "browse/test/watchdog.test.ts": 2841, - "browse/test/welcome-page.test.ts": 10, - "browse/test/windows-spawn-hide.test.ts": 20, - "browse/test/xprotect-heal.test.ts": 722, - "browse/test/xvfb.test.ts": 15970, + "browse/test/security-live-playwright.test.ts": 842, + "browse/test/security-sidecar-client.test.ts": 43, + "browse/test/security.test.ts": 30, + "browse/test/server-auth.test.ts": 399, + "browse/test/server-embedder-terminal-port.test.ts": 4109, + "browse/test/server-factory.test.ts": 1120, + "browse/test/server-flush-trackers.test.ts": 42, + "browse/test/server-launch-resolution.test.ts": 54, + "browse/test/server-lock-errors.test.ts": 67, + "browse/test/server-no-import-side-effects.test.ts": 689, + "browse/test/server-proxy-fail-fast.test.ts": 1452, + "browse/test/server-pty-lease-routes.test.ts": 348, + "browse/test/server-route-auth-blackbox.test.ts": 339, + "browse/test/server-route-dispatch-ratchet.test.ts": 260, + "browse/test/server-route-table.test.ts": 442, + "browse/test/server-sanitize-surrogates.test.ts": 316, + "browse/test/server-tmp-state-path.test.ts": 11, + "browse/test/session-cookie-store.test.ts": 43, + "browse/test/session-persist.test.ts": 899, + "browse/test/sidebar-tabs.test.ts": 315, + "browse/test/sidebar-ux.test.ts": 338, + "browse/test/sidepanel-font-size.test.ts": 29, + "browse/test/sidepanel-patient-autoconnect.test.ts": 15, + "browse/test/sidepanel-reattach.test.ts": 13, + "browse/test/sidepanel-restart-dispose.test.ts": 22, + "browse/test/skill-token.test.ts": 27, + "browse/test/snapshot.test.ts": 8528, + "browse/test/socks-bridge.test.ts": 542, + "browse/test/sse-helpers.test.ts": 166, + "browse/test/sse-session-cookie.test.ts": 36, + "browse/test/state-ttl.test.ts": 11, + "browse/test/stealth-extended.test.ts": 12, + "browse/test/stealth-layer-c.test.ts": 12, + "browse/test/stealth-webdriver.test.ts": 2129, + "browse/test/stop-ack-before-shutdown.test.ts": 156, + "browse/test/stop-dead-daemon.test.ts": 317, + "browse/test/tab-each.test.ts": 74, + "browse/test/tab-external-close-dispatch.test.ts": 1096, + "browse/test/tab-external-close.test.ts": 801, + "browse/test/tab-guardrail.test.ts": 362, + "browse/test/tab-isolation.test.ts": 317, + "browse/test/tab-session-frame-detach.test.ts": 77, + "browse/test/telemetry-optout.test.ts": 155, + "browse/test/telemetry.test.ts": 146, + "browse/test/temp-dirs.test.ts": 98, + "browse/test/terminal-agent-bun-exe.test.ts": 46, + "browse/test/terminal-agent-detach-reattach.test.ts": 49, + "browse/test/terminal-agent-import.test.ts": 4401, + "browse/test/terminal-agent-integration.test.ts": 803, + "browse/test/terminal-agent-keepalive.test.ts": 23, + "browse/test/terminal-agent-lifecycle.test.ts": 4324, + "browse/test/terminal-agent-native-observation.test.ts": 17, + "browse/test/terminal-agent-owner-watchdog.test.ts": 89, + "browse/test/terminal-agent-pid-identity.test.ts": 26, + "browse/test/terminal-agent-port-range.test.ts": 25, + "browse/test/terminal-agent-publication-lock.test.ts": 1230, + "browse/test/terminal-agent-ring-buffer-runtime.test.ts": 22, + "browse/test/terminal-agent-session-routing.test.ts": 16, + "browse/test/terminal-agent-signal-cleanup.test.ts": 15497, + "browse/test/terminal-agent-watchdog.test.ts": 13, + "browse/test/terminal-agent.test.ts": 298, + "browse/test/terminal-pty-lifecycle.test.ts": 11, + "browse/test/token-registry.test.ts": 20, + "browse/test/tunnel-gate-unit.test.ts": 241, + "browse/test/tunnel-revoke-cli.test.ts": 897, + "browse/test/url-validation.test.ts": 94, + "browse/test/watch.test.ts": 283, + "browse/test/watchdog.test.ts": 2567, + "browse/test/welcome-page.test.ts": 19, + "browse/test/windows-spawn-hide.test.ts": 22, + "browse/test/xprotect-heal.test.ts": 689, + "browse/test/xvfb.test.ts": 16338, "browser-skills/hackernews-frontpage/script.test.ts": 10, - "design/test/approval.test.ts": 31, - "design/test/auth.test.ts": 14, - "design/test/brief.test.ts": 8, - "design/test/cli-persistence.test.ts": 6474, - "design/test/cli-version.test.ts": 26, - "design/test/daemon-discovery.test.ts": 11208, - "design/test/daemon.test.ts": 155, - "design/test/feedback-roundtrip-daemon.test.ts": 288, - "design/test/feedback-roundtrip.test.ts": 2796, - "design/test/gallery.test.ts": 11, - "design/test/image-args.test.ts": 25, - "design/test/image-gen-pairing.test.ts": 34, - "design/test/models.test.ts": 62, - "design/test/persist.test.ts": 55, - "design/test/receipted-fetch.test.ts": 16, - "design/test/serve.test.ts": 138, - "design/test/variants-briefs.test.ts": 3520, - "design/test/variants-retry-after.test.ts": 8258, - "ios-qa/daemon/test/allowlist.test.ts": 12, - "ios-qa/daemon/test/audit.test.ts": 12, - "ios-qa/daemon/test/auth-mint.test.ts": 16, - "ios-qa/daemon/test/cli-mint.test.ts": 169, - "ios-qa/daemon/test/daemon-integration.test.ts": 3821, - "ios-qa/daemon/test/devicectl-launch-env.test.ts": 14, - "ios-qa/daemon/test/proxy-classify.test.ts": 14, - "ios-qa/daemon/test/proxy-transport.test.ts": 1728, - "ios-qa/daemon/test/session-tokens.test.ts": 35, - "ios-qa/daemon/test/single-instance.test.ts": 21, - "ios-qa/daemon/test/tailscale-localapi.test.ts": 28, - "ios-qa/daemon/test/tunnel-bootstrap.test.ts": 407, - "ios-qa/scripts/gen-accessors.test.ts": 2527, + "design/test/approval.test.ts": 23, + "design/test/auth.test.ts": 18, + "design/test/brief.test.ts": 11, + "design/test/cli-persistence.test.ts": 6664, + "design/test/cli-version.test.ts": 48, + "design/test/daemon-discovery.test.ts": 11554, + "design/test/daemon.test.ts": 162, + "design/test/feedback-roundtrip-daemon.test.ts": 295, + "design/test/feedback-roundtrip.test.ts": 2871, + "design/test/gallery.test.ts": 20, + "design/test/image-args.test.ts": 31, + "design/test/image-gen-pairing.test.ts": 42, + "design/test/models.test.ts": 36, + "design/test/persist.test.ts": 40, + "design/test/receipted-fetch.test.ts": 18, + "design/test/serve.test.ts": 137, + "design/test/variants-briefs.test.ts": 3813, + "design/test/variants-retry-after.test.ts": 8567, + "ios-qa/daemon/test/allowlist.test.ts": 22, + "ios-qa/daemon/test/audit.test.ts": 18, + "ios-qa/daemon/test/auth-mint.test.ts": 28, + "ios-qa/daemon/test/cli-mint.test.ts": 200, + "ios-qa/daemon/test/daemon-integration.test.ts": 3901, + "ios-qa/daemon/test/devicectl-launch-env.test.ts": 27, + "ios-qa/daemon/test/proxy-classify.test.ts": 46, + "ios-qa/daemon/test/proxy-transport.test.ts": 1762, + "ios-qa/daemon/test/session-cache.test.ts": 3154, + "ios-qa/daemon/test/session-tokens.test.ts": 14, + "ios-qa/daemon/test/single-instance.test.ts": 18, + "ios-qa/daemon/test/tailscale-localapi.test.ts": 25, + "ios-qa/daemon/test/tunnel-bootstrap.test.ts": 424, + "ios-qa/scripts/gen-accessors.test.ts": 2878, "make-pdf/test/asideClient.test.ts": 28, "make-pdf/test/cli-args.test.ts": 13, - "make-pdf/test/cli-exit-codes.test.ts": 361, - "make-pdf/test/diagram-prepass.test.ts": 52, - "make-pdf/test/docx-image-size.test.ts": 485, + "make-pdf/test/cli-exit-codes.test.ts": 348, + "make-pdf/test/diagram-prepass.test.ts": 63, + "make-pdf/test/docx-image-size.test.ts": 439, "make-pdf/test/e2e/ci-prereqs.test.ts": 22, - "make-pdf/test/e2e/combined-gate.test.ts": 933, - "make-pdf/test/e2e/diagram-gate.test.ts": 2783, - "make-pdf/test/e2e/emoji-gate.test.ts": 936, - "make-pdf/test/e2e/format-gate.test.ts": 4960, - "make-pdf/test/e2e/landscape-gate.test.ts": 3795, - "make-pdf/test/e2e/toc-gate.test.ts": 6374, - "make-pdf/test/image-policy.test.ts": 35, - "make-pdf/test/pdftotext.test.ts": 64, - "make-pdf/test/render-offline-sanitize.test.ts": 38, - "make-pdf/test/render.test.ts": 47, - "make-pdf/test/setup-smoke.test.ts": 209, - "make-pdf/test/toc-anchors.test.ts": 36, - "make-pdf/test/toc-pages.test.ts": 35, - "test/agent-sdk-runner.test.ts": 8308, - "test/agents-digest.test.ts": 40, - "test/analytics.test.ts": 50, - "test/anthropic-preflight.test.ts": 40, - "test/approval-classes.test.ts": 6647, - "test/archaeology-lint.test.ts": 1727, - "test/arm-benchmark-selftest.test.ts": 410, - "test/arm-setup-smoke-workflow.test.ts": 28, - "test/artifacts-allowlist-decisions.test.ts": 13, - "test/artifacts-init-migration.test.ts": 237, - "test/aside-driver.test.ts": 151, - "test/aside-probe-shell.test.ts": 9418, - "test/aside-render.test.ts": 10140, - "test/audit-compliance.test.ts": 24, - "test/auq-error-fallback-hook.test.ts": 203, - "test/auq-format-always-loaded.test.ts": 77, - "test/auq-mode-capture.test.ts": 126, - "test/auq-native-capture.test.ts": 2288, - "test/auq-parallel.test.ts": 10877, - "test/auq-substance-panel.test.ts": 119, - "test/auto-decide-fixture.test.ts": 2298, - "test/autoplan-admission.test.ts": 769, - "test/autoplan-amend-input.test.ts": 774, - "test/autoplan-answer-invalidation.test.ts": 6424, - "test/autoplan-artifact-recorder.test.ts": 5347, - "test/autoplan-artifact-windows-argv.test.ts": 62, - "test/autoplan-bounded-journal.test.ts": 307, - "test/autoplan-budget.test.ts": 7678, - "test/autoplan-capy-e2e.test.ts": 7548, - "test/autoplan-checkpoint.test.ts": 5891, - "test/autoplan-compaction-copies.test.ts": 138, - "test/autoplan-compaction-preserved-segment.test.ts": 1402, - "test/autoplan-driver.test.ts": 1904, - "test/autoplan-dual-voice-evidence.test.ts": 1131, - "test/autoplan-dual-voice-fixture.test.ts": 2757, - "test/autoplan-durability.test.ts": 1731, - "test/autoplan-edit-digests-al.test.ts": 268, - "test/autoplan-gate-artifacts.test.ts": 1954, - "test/autoplan-gate-judgment.test.ts": 14990, - "test/autoplan-guard-log.test.ts": 49, - "test/autoplan-guard-pty-fixture.test.ts": 18, - "test/autoplan-guard-reasons.test.ts": 13, - "test/autoplan-init.test.ts": 866, - "test/autoplan-long-session-observer.test.ts": 82, - "test/autoplan-method-read-audit.test.ts": 252, - "test/autoplan-methodology-names.test.ts": 897, - "test/autoplan-obligations.test.ts": 2163, - "test/autoplan-owned-read.test.ts": 2493, - "test/autoplan-owned-state.test.ts": 1471, - "test/autoplan-pairing.test.ts": 32, - "test/autoplan-pending-artifact.test.ts": 825, - "test/autoplan-pending-question.test.ts": 4151, - "test/autoplan-phase-handoff.test.ts": 94, - "test/autoplan-phase-observer.test.ts": 106, - "test/autoplan-phase-order.test.ts": 69, - "test/autoplan-preconfigured-onboarding-ar.test.ts": 804, - "test/autoplan-public-export.test.ts": 4410, + "make-pdf/test/e2e/combined-gate.test.ts": 816, + "make-pdf/test/e2e/diagram-gate.test.ts": 2843, + "make-pdf/test/e2e/emoji-gate.test.ts": 971, + "make-pdf/test/e2e/format-gate.test.ts": 5008, + "make-pdf/test/e2e/landscape-gate.test.ts": 3978, + "make-pdf/test/e2e/toc-gate.test.ts": 6107, + "make-pdf/test/image-policy.test.ts": 11, + "make-pdf/test/pdftotext.test.ts": 44, + "make-pdf/test/render-offline-sanitize.test.ts": 53, + "make-pdf/test/render.test.ts": 39, + "make-pdf/test/setup-smoke.test.ts": 228, + "make-pdf/test/toc-anchors.test.ts": 33, + "make-pdf/test/toc-pages.test.ts": 50, + "test/agent-model.test.ts": 616, + "test/agent-sdk-runner.test.ts": 8294, + "test/agents-digest.test.ts": 63, + "test/analytics.test.ts": 23, + "test/anthropic-preflight.test.ts": 32, + "test/approval-classes.test.ts": 10407, + "test/approval-record.test.ts": 1342, + "test/archaeology-lint.test.ts": 2359, + "test/arm-benchmark-selftest.test.ts": 446, + "test/arm-setup-smoke-workflow.test.ts": 31, + "test/artifacts-allowlist-decisions.test.ts": 20, + "test/artifacts-init-migration.test.ts": 230, + "test/aside-driver.test.ts": 193, + "test/aside-probe-shell.test.ts": 9480, + "test/aside-render.test.ts": 10258, + "test/audit-compliance.test.ts": 57, + "test/auq-error-fallback-hook.test.ts": 208, + "test/auq-format-always-loaded.test.ts": 59, + "test/auq-mode-capture.test.ts": 168, + "test/auq-native-capture.test.ts": 2307, + "test/auq-parallel.test.ts": 11193, + "test/auq-substance-panel.test.ts": 112, + "test/auto-decide-fixture.test.ts": 2565, + "test/autoplan-admission.test.ts": 912, + "test/autoplan-amend-input.test.ts": 748, + "test/autoplan-answer-invalidation.test.ts": 6964, + "test/autoplan-artifact-recorder.test.ts": 5387, + "test/autoplan-artifact-windows-argv.test.ts": 71, + "test/autoplan-bounded-journal.test.ts": 325, + "test/autoplan-budget.test.ts": 8913, + "test/autoplan-capy-e2e.test.ts": 9982, + "test/autoplan-carry.test.ts": 32957, + "test/autoplan-checkpoint.test.ts": 8025, + "test/autoplan-compaction-copies.test.ts": 118, + "test/autoplan-compaction-preserved-segment.test.ts": 1384, + "test/autoplan-driver.test.ts": 2159, + "test/autoplan-dual-voice-evidence.test.ts": 1221, + "test/autoplan-dual-voice-fixture.test.ts": 3591, + "test/autoplan-durability.test.ts": 2054, + "test/autoplan-edit-digests-al.test.ts": 284, + "test/autoplan-gate-artifacts.test.ts": 2447, + "test/autoplan-gate-cost.test.ts": 3493, + "test/autoplan-gate-judgment.test.ts": 17365, + "test/autoplan-guard-log.test.ts": 62, + "test/autoplan-guard-pty-fixture.test.ts": 27, + "test/autoplan-guard-reasons.test.ts": 19, + "test/autoplan-init.test.ts": 1044, + "test/autoplan-long-session-observer.test.ts": 111, + "test/autoplan-method-read-audit.test.ts": 279, + "test/autoplan-methodology-names.test.ts": 1090, + "test/autoplan-obligations.test.ts": 2431, + "test/autoplan-owned-read.test.ts": 2591, + "test/autoplan-owned-state.test.ts": 1582, + "test/autoplan-pairing.test.ts": 35, + "test/autoplan-pending-artifact.test.ts": 882, + "test/autoplan-pending-question.test.ts": 4158, + "test/autoplan-phase-handoff.test.ts": 152, + "test/autoplan-phase-observer.test.ts": 109, + "test/autoplan-phase-order.test.ts": 68, + "test/autoplan-preconfigured-onboarding-ar.test.ts": 994, + "test/autoplan-public-export.test.ts": 4980, "test/autoplan-public-narration.test.ts": 41, - "test/autoplan-publication-generation.test.ts": 23, - "test/autoplan-publication-guard.test.ts": 1451, - "test/autoplan-publication-hook.test.ts": 2530, - "test/autoplan-publication-payload.test.ts": 2219, - "test/autoplan-record.test.ts": 2230, - "test/autoplan-residuals.test.ts": 5130, - "test/autoplan-review-discovery.test.ts": 9403, - "test/autoplan-revise.test.ts": 8220, - "test/autoplan-run-concurrency.test.ts": 3115, - "test/autoplan-run-recovery.test.ts": 7433, - "test/autoplan-schema-canary-compare.test.ts": 18, - "test/autoplan-scope.test.ts": 739, - "test/autoplan-snapshot.test.ts": 1765, - "test/autoplan-timing.test.ts": 279, - "test/benchmark-cli.test.ts": 531, - "test/benchmark-provider-adapters.test.ts": 53, - "test/benchmark-runner.test.ts": 19, - "test/bin-context-windows-slug.test.ts": 1812, - "test/bin-windows-bun-import-paths.test.ts": 874, - "test/binary-version-probe.test.ts": 134, - "test/binding-template-drift.test.ts": 41, - "test/bootstrap-retention-shard.test.ts": 1876, - "test/bootstrap-retention.test.ts": 10115, - "test/bootstrap-session-lifecycle.test.ts": 5082, - "test/brain-cache-roundtrip.test.ts": 80, - "test/brain-cache-spec.test.ts": 22, - "test/brain-preflight.test.ts": 22, - "test/brain-sync-removals.test.ts": 11559, - "test/brain-sync-windows-paths.test.ts": 10, - "test/brain-sync.test.ts": 88583, - "test/branch-slug-hygiene.test.ts": 620, - "test/build-cso-nonfatal.test.ts": 42, - "test/build-cso-result-record.test.ts": 407, - "test/build-gbrain-env.test.ts": 65, - "test/build-script-shell-compat.test.ts": 15, - "test/builder-profile.test.ts": 3308, - "test/bun-resolve.test.ts": 421, - "test/bun-subprocess-fd-lifetime.test.ts": 58, - "test/bun-version-drift.test.ts": 447, - "test/bun-version-shells.test.ts": 354, - "test/cache-concurrent-refresh.test.ts": 44, - "test/capy-review-voice.test.ts": 9422, - "test/capy-skill-wrapper.test.ts": 7874, - "test/carve-guard-completeness.test.ts": 13, + "test/autoplan-publication-generation.test.ts": 20, + "test/autoplan-publication-guard.test.ts": 1590, + "test/autoplan-publication-hook.test.ts": 2606, + "test/autoplan-publication-payload.test.ts": 11279, + "test/autoplan-record.test.ts": 2556, + "test/autoplan-residuals.test.ts": 5895, + "test/autoplan-review-discovery.test.ts": 9949, + "test/autoplan-revise.test.ts": 9319, + "test/autoplan-run-concurrency.test.ts": 3588, + "test/autoplan-run-recovery.test.ts": 8557, + "test/autoplan-schema-canary-compare.test.ts": 21, + "test/autoplan-scope.test.ts": 765, + "test/autoplan-snapshot.test.ts": 1715, + "test/autoplan-timing.test.ts": 258, + "test/autoplan-token-efficiency.test.ts": 15310, + "test/base-red.test.ts": 1844, + "test/benchmark-cli.test.ts": 659, + "test/benchmark-provider-adapters.test.ts": 66, + "test/benchmark-runner.test.ts": 31, + "test/bin-context-windows-slug.test.ts": 2317, + "test/bin-windows-bun-import-paths.test.ts": 767, + "test/binary-version-probe.test.ts": 154, + "test/binding-template-drift.test.ts": 32, + "test/bootstrap-retention-shard.test.ts": 2091, + "test/bootstrap-retention.test.ts": 9374, + "test/bootstrap-session-lifecycle.test.ts": 5114, + "test/brain-cache-roundtrip.test.ts": 130, + "test/brain-cache-spec.test.ts": 20, + "test/brain-preflight.test.ts": 52, + "test/brain-sync-removals.test.ts": 14244, + "test/brain-sync-windows-paths.test.ts": 13, + "test/brain-sync.test.ts": 91338, + "test/branch-slug-hygiene.test.ts": 722, + "test/build-cso-nonfatal.test.ts": 48, + "test/build-cso-result-record.test.ts": 508, + "test/build-gbrain-env.test.ts": 53, + "test/build-script-shell-compat.test.ts": 13, + "test/builder-profile.test.ts": 3486, + "test/bun-config-shell-bins.test.ts": 2763, + "test/bun-dotenv-isolation.test.ts": 582, + "test/bun-entry-isolation.test.ts": 165, + "test/bun-isolation-launch.test.ts": 93, + "test/bun-resolve.test.ts": 441, + "test/bun-subprocess-fd-lifetime.test.ts": 59, + "test/bun-version-drift.test.ts": 437, + "test/bun-version-shells.test.ts": 427, + "test/cache-concurrent-refresh.test.ts": 51, + "test/captain-brief.test.ts": 2030, + "test/captain-skill.test.ts": 18, + "test/capy-captain-flow.test.ts": 2534, + "test/capy-review-voice.test.ts": 9889, + "test/capy-skill-wrapper.test.ts": 12874, + "test/carve-guard-completeness.test.ts": 18, "test/carve-guards-negative.test.ts": 16, - "test/carve-plan-fixture.test.ts": 167, - "test/carve-section-ordering.test.ts": 27, - "test/carve-section-sharding.test.ts": 1515, - "test/catalog-budget.test.ts": 48, - "test/catalog-mode-full.test.ts": 803, - "test/catalog-trim.test.ts": 105, - "test/census-red-docs.test.ts": 37, - "test/ceo-barless-submit.test.ts": 104, - "test/ceo-count-ad-v2.test.ts": 31, - "test/ceo-expansion-auq.test.ts": 79, - "test/ceo-expansion-pacing-native.test.ts": 71, - "test/ceo-finding-fixture.test.ts": 958, - "test/ceo-hold-posture-review.test.ts": 168, - "test/ceo-mode-expansion-disposition.test.ts": 80, - "test/ceo-mode-handoff-hook.test.ts": 64, - "test/ceo-mode-handoff.test.ts": 89, - "test/ceo-mode-labels-native.test.ts": 57, - "test/ceo-mode-option.test.ts": 1751, - "test/ceo-mode-pending-submit.test.ts": 45, - "test/ceo-mode-posture-native.test.ts": 83, - "test/ceo-mode-preference-al.test.ts": 2755, - "test/ceo-mode-prerequisite.test.ts": 1450, - "test/ceo-mode-routing-fixture.test.ts": 1181, - "test/ceo-plan-find-cross-branch.test.ts": 168, - "test/ceo-plan-mode-fixture.test.ts": 953, - "test/ceo-posture-packet.test.ts": 334, - "test/ceo-section-loading-fixture.test.ts": 414, - "test/ceo-split-collection.test.ts": 622, - "test/ceo-split-question-policy.test.ts": 486, - "test/ceo-stale-fill-decision.test.ts": 96, - "test/ceo-workflow-clarity.test.ts": 44, - "test/changed-files-union.test.ts": 468, - "test/chromium-sandbox-ci.test.ts": 233, - "test/ci-eval-cache.test.ts": 307, - "test/ci-image-cli-pin.test.ts": 26, - "test/ci-image-tag-binding.test.ts": 12, - "test/ci-native-evidence.test.ts": 838, - "test/ci-paid-coordination.test.ts": 7431, - "test/claude-code-migration.test.ts": 177, - "test/claude-code-runner.test.ts": 3111, - "test/claude-code-skill.test.ts": 1506, - "test/claude-code-windows-job.test.ts": 50, - "test/claude-md-size.test.ts": 11, - "test/claude-overlay-models.test.ts": 21, - "test/claude-overlay-setup-default.test.ts": 29082, - "test/claude-overlay-setup-installs.test.ts": 43402, - "test/claude-overlay-setup-lifecycle.test.ts": 50190, - "test/claude-producer-stream.test.ts": 3548, - "test/claude-provider-keychain.test.ts": 76, - "test/code-intelligence-cli.test.ts": 638, - "test/code-intelligence.test.ts": 4335, - "test/codex-boundary-evidence.test.ts": 10, + "test/carve-plan-fixture.test.ts": 234, + "test/carve-section-ordering.test.ts": 41, + "test/carve-section-sharding.test.ts": 2092, + "test/catalog-budget.test.ts": 88, + "test/catalog-mode-full.test.ts": 1537, + "test/catalog-trim.test.ts": 107, + "test/census-red-docs.test.ts": 59, + "test/ceo-barless-submit.test.ts": 92, + "test/ceo-count-ad-v2.test.ts": 58, + "test/ceo-expansion-auq.test.ts": 93, + "test/ceo-expansion-pacing-native.test.ts": 82, + "test/ceo-finding-fixture.test.ts": 1077, + "test/ceo-hold-posture-review.test.ts": 158, + "test/ceo-mode-expansion-disposition.test.ts": 60, + "test/ceo-mode-handoff-hook.test.ts": 55, + "test/ceo-mode-handoff.test.ts": 91, + "test/ceo-mode-labels-native.test.ts": 76, + "test/ceo-mode-option.test.ts": 1688, + "test/ceo-mode-pending-submit.test.ts": 46, + "test/ceo-mode-posture-native.test.ts": 34, + "test/ceo-mode-preference-al.test.ts": 2286, + "test/ceo-mode-prerequisite.test.ts": 1202, + "test/ceo-mode-routing-fixture.test.ts": 863, + "test/ceo-plan-find-cross-branch.test.ts": 150, + "test/ceo-plan-mode-fixture.test.ts": 881, + "test/ceo-posture-packet.test.ts": 392, + "test/ceo-section-loading-fixture.test.ts": 387, + "test/ceo-split-collection.test.ts": 518, + "test/ceo-split-question-policy.test.ts": 436, + "test/ceo-stale-fill-decision.test.ts": 91, + "test/ceo-workflow-clarity.test.ts": 32, + "test/changed-files-union.test.ts": 380, + "test/changelog-fragments.test.ts": 1832, + "test/changelog-merge-damage.test.ts": 28, + "test/chromium-sandbox-ci.test.ts": 291, + "test/ci-eval-cache.test.ts": 262, + "test/ci-image-cli-pin.test.ts": 37, + "test/ci-image-tag-binding.test.ts": 19, + "test/ci-native-evidence.test.ts": 3900, + "test/ci-paid-coordination.test.ts": 7861, + "test/claude-code-migration.test.ts": 187, + "test/claude-code-runner.test.ts": 3177, + "test/claude-code-skill.test.ts": 1582, + "test/claude-code-windows-job.test.ts": 56, + "test/claude-md-size.test.ts": 15, + "test/claude-overlay-models.test.ts": 20, + "test/claude-overlay-setup-default.test.ts": 36007, + "test/claude-overlay-setup-installs.test.ts": 26483, + "test/claude-overlay-setup-lifecycle.test.ts": 27027, + "test/claude-overlay-setup-project.test.ts": 25929, + "test/claude-overlay-setup-recovery.test.ts": 31654, + "test/claude-producer-stream.test.ts": 3550, + "test/claude-provider-keychain.test.ts": 79, + "test/claude-usage.test.ts": 498, + "test/code-intelligence-cli.test.ts": 797, + "test/code-intelligence.test.ts": 4586, + "test/codex-boundary-evidence.test.ts": 17, "test/codex-carve-fixture.test.ts": 895, - "test/codex-ci-access.test.ts": 226, - "test/codex-effort.test.ts": 1458, - "test/codex-eval-recording.test.ts": 301, - "test/codex-generation-model.test.ts": 111, - "test/codex-hardening.test.ts": 9961, - "test/codex-invocation-sites.test.ts": 311, - "test/codex-model-probe.test.ts": 4423, - "test/codex-multiblock.test.ts": 11, - "test/codex-probe-cache.test.ts": 11411, - "test/codex-probe-subcommands.test.ts": 4930, - "test/codex-resume-flag-semantics.test.ts": 13, - "test/codex-session-lifecycle.test.ts": 12022, - "test/codex-skill-isolation.test.ts": 135, - "test/codex-under-codex-detection.test.ts": 20, - "test/codex-web-search-flag.test.ts": 299, - "test/compare-pr-version.test.ts": 77, - "test/conductor-env-shim.test.ts": 9, - "test/context-bill.test.ts": 87, - "test/context-budget-ratchet.test.ts": 90, - "test/context-identity.test.ts": 24661, - "test/context-remaining-work-provenance.test.ts": 10, - "test/context-save-hardening.test.ts": 409, - "test/cookie-workflow-judge-input.test.ts": 1735, - "test/cookie-workflow-manual-review.test.ts": 93, - "test/copilot-windows-bash.test.ts": 11, - "test/cost-known.test.ts": 60, - "test/coverage-audit-evidence.test.ts": 197, - "test/coverage-audit.test.ts": 1061, - "test/cso-audit-paths.test.ts": 19830, + "test/codex-ci-access.test.ts": 282, + "test/codex-effort.test.ts": 1676, + "test/codex-eval-recording.test.ts": 298, + "test/codex-generation-model.test.ts": 110, + "test/codex-hardening.test.ts": 10091, + "test/codex-host.test.ts": 197, + "test/codex-invocation-sites.test.ts": 368, + "test/codex-model-probe.test.ts": 5335, + "test/codex-multiblock.test.ts": 14, + "test/codex-probe-cache.test.ts": 12478, + "test/codex-probe-subcommands.test.ts": 5182, + "test/codex-resume-flag-semantics.test.ts": 15, + "test/codex-session-lifecycle.test.ts": 11963, + "test/codex-skill-isolation.test.ts": 166, + "test/codex-under-codex-detection.test.ts": 26, + "test/codex-web-search-flag.test.ts": 336, + "test/compare-pr-version.test.ts": 5375, + "test/conductor-env-shim.test.ts": 13, + "test/context-bill.test.ts": 284, + "test/context-budget-ratchet.test.ts": 149, + "test/context-identity.test.ts": 26106, + "test/context-remaining-work-provenance.test.ts": 11, + "test/context-restore-fragment-verdict.test.ts": 28, + "test/context-restore-head.test.ts": 1363, + "test/context-restore-transcripts.test.ts": 409, + "test/context-save-hardening.test.ts": 410, + "test/cookie-workflow-judge-input.test.ts": 1960, + "test/cookie-workflow-manual-review.test.ts": 74, + "test/copilot-windows-bash.test.ts": 12, + "test/cost-known.test.ts": 62, + "test/coverage-audit-evidence.test.ts": 193, + "test/coverage-audit.test.ts": 1028, + "test/credential-rejection.test.ts": 290, + "test/cso-audit-paths.test.ts": 18155, "test/cso-bounded-file.test.ts": 22, - "test/cso-cache.test.ts": 226, - "test/cso-cli-lifecycle.test.ts": 6493, - "test/cso-cli-recheck.test.ts": 20426, - "test/cso-cli.test.ts": 35485, - "test/cso-contracts.test.ts": 327, - "test/cso-distribution.test.ts": 3215, - "test/cso-docker-argv0.test.ts": 1018, + "test/cso-cache.test.ts": 243, + "test/cso-cli-lifecycle.test.ts": 5556, + "test/cso-cli-recheck.test.ts": 17405, + "test/cso-cli.test.ts": 31867, + "test/cso-contracts.test.ts": 286, + "test/cso-distribution.test.ts": 2784, + "test/cso-docker-argv0.test.ts": 988, "test/cso-docker-integration.test.ts": 25, - "test/cso-docker-mounts.test.ts": 47, - "test/cso-docker-policy.test.ts": 23, - "test/cso-docker-socket-realpath.test.ts": 40, - "test/cso-eval.test.ts": 10845, - "test/cso-file-ceiling.test.ts": 31474, - "test/cso-git-global-config.test.ts": 38, - "test/cso-git-hardening.test.ts": 243, - "test/cso-history.test.ts": 18, - "test/cso-image-provisioning.test.ts": 317, - "test/cso-json-redaction.test.ts": 277, - "test/cso-launcher-generation.test.ts": 1825, - "test/cso-lease-identity.test.ts": 537, - "test/cso-macos-launcher.test.ts": 28, - "test/cso-native-evidence.test.ts": 122, - "test/cso-node-lifecycle-integration.test.ts": 49, - "test/cso-ntfs-fixture.test.ts": 209, + "test/cso-docker-mounts.test.ts": 53, + "test/cso-docker-policy.test.ts": 22, + "test/cso-docker-socket-realpath.test.ts": 30, + "test/cso-eval.test.ts": 9928, + "test/cso-file-ceiling.test.ts": 30373, + "test/cso-git-global-config.test.ts": 30, + "test/cso-git-hardening.test.ts": 460, + "test/cso-history.test.ts": 35, + "test/cso-image-provisioning.test.ts": 299, + "test/cso-json-redaction.test.ts": 288, + "test/cso-launcher-generation.test.ts": 1412, + "test/cso-lease-identity.test.ts": 653, + "test/cso-macos-launcher.test.ts": 25, + "test/cso-native-evidence.test.ts": 108, + "test/cso-node-lifecycle-integration.test.ts": 41, + "test/cso-ntfs-fixture.test.ts": 80, + "test/cso-ordinary-repos.test.ts": 4043, "test/cso-postgresql-policy.test.ts": 30, - "test/cso-preparation-adversarial.test.ts": 147, - "test/cso-preparation-container.test.ts": 105, - "test/cso-preparation-executor.test.ts": 914, - "test/cso-preparation-java.test.ts": 38, - "test/cso-preparation.test.ts": 115, - "test/cso-preserved.test.ts": 31, - "test/cso-public-ghcr.test.ts": 34, - "test/cso-python-runner-shadow.test.ts": 3438, - "test/cso-rails-verification.test.ts": 42, - "test/cso-registry-socket.test.ts": 1643, - "test/cso-requalification.test.ts": 2788, - "test/cso-runtime-promotion.test.ts": 146, - "test/cso-scanner-cli.test.ts": 8578, - "test/cso-scanner-docker-integration.test.ts": 41, - "test/cso-scanner-executor.test.ts": 171, - "test/cso-scanner-release.test.ts": 121, - "test/cso-scanners.test.ts": 83, - "test/cso-snapshot-disappearance-race.test.ts": 203, - "test/cso-snapshot-identity.test.ts": 634, - "test/cso-snapshot-state.test.ts": 4346, - "test/cso-spec-taxonomy-alignment.test.ts": 14, - "test/cso-stack-cold-integration.test.ts": 30, - "test/cso-staging-run.test.ts": 114, - "test/cso-verification-cleanup.test.ts": 1125, - "test/cso-verifier.test.ts": 42, - "test/cso-watchdog.test.ts": 8346, - "test/cso-windows-build-contract.test.ts": 14, - "test/cso-windows-docker.test.ts": 24, - "test/cso-windows-launcher.test.ts": 17, - "test/cso-witness.test.ts": 1151, - "test/declared-annotation.test.ts": 20, - "test/defensive-review-framing.test.ts": 19, - "test/dependency-security.test.ts": 70, - "test/deps-smoke.test.ts": 60, - "test/design-approval-flow.test.ts": 6280, - "test/design-board-reload.test.ts": 21, - "test/design-catalog.test.ts": 68, - "test/design-checklist-sync.test.ts": 994, - "test/design-completion-handoff-scored.test.ts": 42, - "test/design-consultation-command-contract.test.ts": 307, - "test/design-consultation-contract.test.ts": 30, - "test/design-daemon-windows-identity.test.ts": 55, - "test/design-detect-contract.test.ts": 152, - "test/design-detector-source-fixture.test.ts": 6738, - "test/design-doc-data-guard.test.ts": 12, - "test/design-flag-utils.test.ts": 26, - "test/design-html-section-completion.test.ts": 3508, - "test/design-md.test.ts": 639, - "test/design-printed-paths.test.ts": 99, - "test/design-research-fixture.test.ts": 12, - "test/design-setup-probe.test.ts": 11257, - "test/detector-corpora.test.ts": 571, - "test/detector-corpus-auto-decide-preserved.test.ts": 49, - "test/detector-corpus-plan-ceo-section-loading.test.ts": 51, - "test/detector-corpus-plan-design-review-plan-mode.test.ts": 246, - "test/detector-corpus-plan-eng-multi-finding-batching.test.ts": 255, - "test/detector-corpus-shared-libs-plan-callers.test.ts": 20, - "test/detector-corpus-ship-docsync-late-result.test.ts": 50, - "test/detector-corpus-ship-docsync-launch-failure.test.ts": 51, - "test/dev-setup-render-isolation.test.ts": 143, - "test/devex-finding-fixture.test.ts": 1032, - "test/devex-peer-comparison-calibration.test.ts": 378, - "test/diagram-render-drift.test.ts": 408, - "test/diff-scope.test.ts": 2326, - "test/disabled-dated-record-at.test.ts": 75, - "test/disabled-plan-review-evidence.test.ts": 1386, - "test/discover-section-templates.test.ts": 10, - "test/dispatch-rules.test.ts": 38, - "test/distill-apply.test.ts": 1091, - "test/distill-free-text.test.ts": 1114, - "test/docs-config-keys.test.ts": 143, - "test/docsync-atomic-writes.test.ts": 1725, - "test/docsync-authority.test.ts": 699, - "test/docsync-child-marker.test.ts": 18, - "test/docsync-command-grammar.test.ts": 714, - "test/docsync-fault-interface.test.ts": 37247, - "test/docsync-lifecycle-interface.test.ts": 155, - "test/docsync-nested-writes.test.ts": 856, - "test/docsync-report-interface.test.ts": 473, - "test/doctor-components.test.ts": 20, - "test/document-skills-redaction.test.ts": 26, - "test/dom-dump-hygiene.test.ts": 870, - "test/dx-error-capture.test.ts": 5039, - "test/dx-error-contract-grader.test.ts": 21, - "test/dx-error-contract.test.ts": 44, - "test/dx-selected-navigation-ap.test.ts": 151, - "test/e2e-harness-audit.test.ts": 20, - "test/e2e-shard-reuse.test.ts": 2497, - "test/e2e-tier-alignment.test.ts": 148, - "test/egress-lib.test.ts": 282, - "test/egress-receipt-wiring.test.ts": 193, - "test/egress-receipt.test.ts": 5414, - "test/empty-find-fallthrough.test.ts": 222, - "test/eng-batching-current-ledger.test.ts": 208, - "test/eng-batching-native-replay.test.ts": 59, - "test/eng-batching-saved-ledger.test.ts": 582, - "test/eng-count-question-policy.test.ts": 31, - "test/eng-devex-s-count.test.ts": 29, - "test/eng-finding-retry-budget.test.ts": 469, - "test/eng-first-review.test.ts": 295, - "test/eng-published-navigation.test.ts": 45, - "test/eng-resolution-block-position.test.ts": 242, - "test/eng-review-routing.test.ts": 21, - "test/eng-scope-entry-ap.test.ts": 79, - "test/eng-seeded-completion-ai.test.ts": 12139, - "test/eng-seeded-coverage.test.ts": 1676, - "test/eng-semantic-terminal.test.ts": 72, - "test/eng-test-plan-edit-approval.test.ts": 986, - "test/env-host-fence-conformance.test.ts": 8225, - "test/env-host-runtime-assets.test.ts": 1402, - "test/eval-budgets-policy.test.ts": 38, - "test/eval-cli-family.test.ts": 3123, - "test/eval-detach-timeout-floor.test.ts": 1199, - "test/eval-flake-rank.test.ts": 278, - "test/eval-history.test.ts": 1919, - "test/eval-input-cache.test.ts": 59, - "test/eval-kinds.test.ts": 67, - "test/eval-list-cli.test.ts": 284, - "test/eval-model.test.ts": 33, - "test/eval-models.test.ts": 1233, - "test/eval-plan-prereg-diagnostics.test.ts": 547, - "test/eval-readiness.test.ts": 66, - "test/eval-sweep-workflow.test.ts": 14, - "test/evals-pr-lane-workflow.test.ts": 29, - "test/evals-tracking-issue.test.ts": 327, - "test/evals-workflow-wiring.test.ts": 239, - "test/evidence-bundle-portability.test.ts": 5008, - "test/evidence.test.ts": 15876, - "test/exercised-map.test.ts": 14205, - "test/exit-propagation.test.ts": 351, - "test/explain-level-config.test.ts": 866, - "test/extension-pty-inject-invariant.test.ts": 18, - "test/external-host-carve.test.ts": 2256, - "test/flake-ledger.test.ts": 23, - "test/founder-resources-optout.test.ts": 164, - "test/free-durations-record-workflow.test.ts": 364, - "test/free-fixtures.test.ts": 2204, - "test/free-home-guard.test.ts": 92, - "test/free-seed-ratchet.test.ts": 92, - "test/free-tests-workflow-wiring.test.ts": 20, - "test/freeze-owned-lifecycle.test.ts": 3234, - "test/frontend-scope.test.ts": 1312, - "test/fs-atomic.test.ts": 39, - "test/fs-utils.test.ts": 265, - "test/gate-cost.test.ts": 2107, - "test/gate-diff-cli.test.ts": 3892, - "test/gate-diff.test.ts": 49, - "test/gate-integrity-templates.test.ts": 45, - "test/gate-list.test.ts": 312, - "test/gate-reuse.test.ts": 6384, - "test/gate-secret-scan.test.ts": 603, - "test/gate-tail-lint.test.ts": 1603, - "test/gbrain-cycle-completed.test.ts": 84, - "test/gbrain-detect-install.test.ts": 778, - "test/gbrain-detect-shape.test.ts": 552, - "test/gbrain-detection-override.test.ts": 1112, - "test/gbrain-dream-stage.test.ts": 309, - "test/gbrain-exec-invariant.test.ts": 20, - "test/gbrain-guards.test.ts": 33, - "test/gbrain-init-voyage-code-3.test.ts": 201, - "test/gbrain-lib-validate-varname.test.ts": 47, - "test/gbrain-lib-verify.test.ts": 168, - "test/gbrain-local-status.test.ts": 3942, - "test/gbrain-prune-gone-worktrees.test.ts": 310, - "test/gbrain-read-capability.test.ts": 1900, - "test/gbrain-refresh-install-render.test.ts": 21, - "test/gbrain-repo-policy-client.test.ts": 835, - "test/gbrain-repo-policy.test.ts": 1620, - "test/gbrain-source-gitignore.test.ts": 86, - "test/gbrain-source-worktree-advance.test.ts": 1072, - "test/gbrain-sources-parse.test.ts": 18, - "test/gbrain-sources.test.ts": 128, - "test/gbrain-spawn-windows-shell.test.ts": 13, - "test/gbrain-structured-busy.test.ts": 1773, - "test/gbrain-supabase-provision.test.ts": 126, - "test/gbrain-sync-no-bash-message.test.ts": 19, - "test/gbrain-sync-skip.test.ts": 10846, - "test/gbrain-sync-voyage-code-3-integration.test.ts": 15, - "test/gen-skill-docs-checks.test.ts": 12316, - "test/gen-skill-docs-idempotency.test.ts": 2543, - "test/gen-skill-docs-import-purity.test.ts": 47, - "test/gen-skill-docs-out-dir.test.ts": 2503, - "test/gen-skill-docs-prune-stale.test.ts": 893, - "test/gen-skill-docs.test.ts": 7015, - "test/generated-bash-placeholders.test.ts": 848, - "test/generated-docs-fences.test.ts": 331, - "test/generator-eexist.test.ts": 405, - "test/git-ref-fixture-tripwire.test.ts": 139, - "test/gitattributes-eol.test.ts": 822, - "test/global-discover.test.ts": 987, - "test/greptile-early-cli.test.ts": 288, - "test/greptile-early.test.ts": 36, - "test/gstack-analytics-avg-duration.test.ts": 249, - "test/gstack-artifacts-init.test.ts": 6375, - "test/gstack-artifacts-url.test.ts": 195, - "test/gstack-brain-context-load.test.ts": 1007, - "test/gstack-browser-ensure.test.ts": 5528, - "test/gstack-capy-install.test.ts": 26225, - "test/gstack-ceo-plan-find.test.ts": 227, - "test/gstack-ci-gate.test.ts": 29, - "test/gstack-codex-session-import.test.ts": 613, - "test/gstack-config-browse-extension-id.test.ts": 361, - "test/gstack-config-cross-project.test.ts": 240, - "test/gstack-config-defaults.test.ts": 5462, - "test/gstack-config-gbrain-refresh.test.ts": 149, - "test/gstack-config-key-locale.test.ts": 128, - "test/gstack-config-memorable-key.test.ts": 342, - "test/gstack-config-redact-keys.test.ts": 214, - "test/gstack-config-transcript-scope.test.ts": 2451, - "test/gstack-context-recovery.test.ts": 123, - "test/gstack-decision-bins.test.ts": 6341, - "test/gstack-decision-semantic.test.ts": 44, - "test/gstack-decision.test.ts": 41, - "test/gstack-design-claim.test.ts": 852, - "test/gstack-design-detect-plugins.test.ts": 427, - "test/gstack-design-detect.test.ts": 15747, - "test/gstack-design-doc-find.test.ts": 464, - "test/gstack-detach.test.ts": 23575, - "test/gstack-developer-profile.test.ts": 12010, - "test/gstack-docs-candidate.test.ts": 1064, - "test/gstack-doctor.test.ts": 26676, - "test/gstack-egress-cli.test.ts": 628, - "test/gstack-ff-update.test.ts": 14842, - "test/gstack-gbrain-detect-mcp-mode.test.ts": 1037, - "test/gstack-gbrain-mcp-verify.test.ts": 1126, - "test/gstack-gbrain-source-wireup.test.ts": 2813, - "test/gstack-gbrain-sync-transcript-mode.test.ts": 2607, - "test/gstack-gbrain-sync.test.ts": 1740, - "test/gstack-home-module-scope.test.ts": 64, - "test/gstack-launch-probe.test.ts": 74, - "test/gstack-learnings-search.test.ts": 1364, - "test/gstack-lease.test.ts": 3330, - "test/gstack-memorable.test.ts": 7491, - "test/gstack-memory-helpers.test.ts": 11108, - "test/gstack-memory-ingest.test.ts": 10632, - "test/gstack-next-version.test.ts": 18668, - "test/gstack-paths.test.ts": 724, - "test/gstack-post-cli.test.ts": 379, - "test/gstack-post.test.ts": 20, - "test/gstack-question-log.test.ts": 2265, - "test/gstack-question-preference.test.ts": 7053, - "test/gstack-redact-cli.test.ts": 3269, - "test/gstack-regen.test.ts": 2361, - "test/gstack-render-cli.test.ts": 371, - "test/gstack-repo-mode.test.ts": 1034, - "test/gstack-restamp-queue.test.ts": 9311, - "test/gstack-retro-metrics.test.ts": 727, - "test/gstack-safe-git.test.ts": 1146, - "test/gstack-schema-pack.test.ts": 8, - "test/gstack-session-kind.test.ts": 104, - "test/gstack-settings-hook-schema-aware.test.ts": 5006, - "test/gstack-settings-hook-symlink.test.ts": 1301, - "test/gstack-ship-policy.test.ts": 1407, - "test/gstack-ship-queue.test.ts": 12022, - "test/gstack-ship-receipt.test.ts": 1772, - "test/gstack-skill-start.test.ts": 26358, - "test/gstack-slug-cwd-walk-up.test.ts": 907, - "test/gstack-slug-get.test.ts": 512, - "test/gstack-slug-parity.test.ts": 4206, - "test/gstack-slug-sanitize.test.ts": 137, - "test/gstack-state-root-override.test.ts": 606, - "test/gstack-team-init-hook-schema.test.ts": 156, - "test/gstack-upgrade-bin.test.ts": 11332, - "test/gstack-upgrade-fresh-shell.test.ts": 5572, - "test/gstack-upgrade-migration-v1_17_0_0.test.ts": 56, - "test/gstack-upgrade-migration-v1_37_0_0.test.ts": 103, - "test/gstack-upgrade-migration-v1_40_0_0.test.ts": 182, - "test/gstack-upgrade-migration-v1_78_0_0.test.ts": 37, - "test/gstack-upgrade-migration-v1_91_19_0.test.ts": 135, - "test/gstack-upgrade-path-spelling.test.ts": 595, - "test/gstack-version-bump.test.ts": 2219, - "test/gstack-wtree-windows-paths.test.ts": 229, - "test/harness-version.test.ts": 127, - "test/headless-artifacts.test.ts": 911, - "test/health-capture.test.ts": 154, - "test/health-eval-fixture.test.ts": 332, - "test/held-install-recovery.test.ts": 30520, - "test/helpers-unit.test.ts": 66, - "test/helpers/budget-override.test.ts": 20, - "test/helpers/capture-parity-baseline.test.ts": 152, - "test/helpers/claude-pty-runner.auq.unit.test.ts": 60, - "test/helpers/claude-pty-runner.boundaries.unit.test.ts": 67, - "test/helpers/claude-pty-runner.classify.unit.test.ts": 50, - "test/helpers/claude-pty-runner.launch.unit.test.ts": 336, - "test/helpers/claude-pty-runner.plan-native.unit.test.ts": 30, - "test/helpers/claude-pty-runner.runners.unit.test.ts": 426, - "test/helpers/claude-pty-runner.scope-gate-floor.unit.test.ts": 40, - "test/helpers/claude-pty-runner.screen.unit.test.ts": 32, - "test/helpers/e2e-gate.unit.test.ts": 11, - "test/helpers/eval-store.test.ts": 261, - "test/helpers/hermetic-env.test.ts": 951, - "test/helpers/observability.test.ts": 74, - "test/helpers/providers/gemini.test.ts": 25, - "test/helpers/resolve-repo-path.test.ts": 11, - "test/helpers/run-bin.test.ts": 21, - "test/helpers/session-runner.test.ts": 114, - "test/helpers/sync-command-capture.test.ts": 1056, - "test/heredoc-pipe-deadlock.test.ts": 109, - "test/hermetic-skill-runtime.test.ts": 2211, - "test/hermetic-skills-seeding.test.ts": 138, - "test/hermetic-wiring.test.ts": 169, - "test/honesty-kernel.test.ts": 28, - "test/hook-log.test.ts": 232, - "test/hook-scripts.test.ts": 28307, - "test/hooks-windows-paths.test.ts": 192, - "test/host-config.test.ts": 2874, - "test/host-conformance.test.ts": 73521, - "test/host-renders-prune.test.ts": 57494, - "test/host-renders.test.ts": 26926, - "test/hostile-path-writers.test.ts": 1951, - "test/impeccable-fixtures.test.ts": 14, - "test/install-fixture.test.ts": 903, - "test/install-verdict.test.ts": 1435, - "test/instruction-facts.test.ts": 68, - "test/investigate-freeze-path.test.ts": 5568, - "test/investigate-recipes.test.ts": 15, - "test/ios-debug-bridge-release-guard.test.ts": 13, + "test/cso-preparation-adversarial.test.ts": 105, + "test/cso-preparation-container.test.ts": 73, + "test/cso-preparation-executor.test.ts": 782, + "test/cso-preparation-java.test.ts": 20, + "test/cso-preparation.test.ts": 84, + "test/cso-preserved.test.ts": 17, + "test/cso-public-ghcr.test.ts": 43, + "test/cso-python-runner-shadow.test.ts": 3091, + "test/cso-rails-verification.test.ts": 34, + "test/cso-registry-socket.test.ts": 1794, + "test/cso-requalification.test.ts": 2950, + "test/cso-runtime-promotion.test.ts": 3055, + "test/cso-scanner-cli.test.ts": 7570, + "test/cso-scanner-docker-integration.test.ts": 73, + "test/cso-scanner-executor.test.ts": 139, + "test/cso-scanner-release.test.ts": 88, + "test/cso-scanners.test.ts": 125, + "test/cso-snapshot-disappearance-race.test.ts": 314, + "test/cso-snapshot-identity.test.ts": 953, + "test/cso-snapshot-state.test.ts": 4982, + "test/cso-spec-taxonomy-alignment.test.ts": 20, + "test/cso-stack-cold-integration.test.ts": 75, + "test/cso-staging-run.test.ts": 116, + "test/cso-verification-cleanup.test.ts": 1183, + "test/cso-verifier.test.ts": 87, + "test/cso-watchdog.test.ts": 8155, + "test/cso-windows-build-contract.test.ts": 24, + "test/cso-windows-docker.test.ts": 16, + "test/cso-windows-launcher.test.ts": 20, + "test/cso-witness.test.ts": 1101, + "test/decision-trail.test.ts": 835, + "test/declared-annotation.test.ts": 24, + "test/defensive-review-framing.test.ts": 32, + "test/dependency-security.test.ts": 78, + "test/deps-smoke.test.ts": 59, + "test/design-approval-flow.test.ts": 6263, + "test/design-board-reload.test.ts": 20, + "test/design-catalog.test.ts": 23, + "test/design-checklist-sync.test.ts": 857, + "test/design-completion-handoff-scored.test.ts": 39, + "test/design-consultation-command-contract.test.ts": 358, + "test/design-consultation-contract.test.ts": 27, + "test/design-craft-resolvers.test.ts": 54, + "test/design-daemon-windows-identity.test.ts": 33, + "test/design-detect-contract.test.ts": 128, + "test/design-detector-source-fixture.test.ts": 6851, + "test/design-doc-data-guard.test.ts": 11, + "test/design-doc-plan-copy.test.ts": 177, + "test/design-flag-utils.test.ts": 71, + "test/design-html-section-completion.test.ts": 3991, + "test/design-lint-assets.test.ts": 176, + "test/design-lint-chrome.test.ts": 281, + "test/design-lint-color.test.ts": 155, + "test/design-lint-eighth-cycle.test.ts": 11995, + "test/design-lint-fifth-cycle.test.ts": 5394, + "test/design-lint-finish.test.ts": 202, + "test/design-lint-fourth-cycle.test.ts": 5292, + "test/design-lint-motion.test.ts": 257, + "test/design-lint-recorded.test.ts": 79, + "test/design-lint-render.test.ts": 26091, + "test/design-lint-settle.test.ts": 105, + "test/design-lint-seventh-cycle.test.ts": 428, + "test/design-lint-sixth-cycle.test.ts": 1345, + "test/design-lint-structure.test.ts": 612, + "test/design-lint-tailwind.test.ts": 116, + "test/design-lint-type.test.ts": 282, + "test/design-lint.test.ts": 1672, + "test/design-md.test.ts": 657, + "test/design-printed-paths.test.ts": 141, + "test/design-research-fixture.test.ts": 34, + "test/design-setup-probe.test.ts": 11984, + "test/detector-corpora.test.ts": 482, + "test/detector-corpus-auto-decide-preserved.test.ts": 59, + "test/detector-corpus-plan-ceo-section-loading.test.ts": 75, + "test/detector-corpus-plan-design-review-plan-mode.test.ts": 316, + "test/detector-corpus-plan-eng-multi-finding-batching.test.ts": 353, + "test/detector-corpus-shared-libs-plan-callers.test.ts": 35, + "test/detector-corpus-ship-docsync-late-result.test.ts": 62, + "test/detector-corpus-ship-docsync-launch-failure.test.ts": 62, + "test/dev-setup-render-isolation.test.ts": 254, + "test/devex-finding-fixture.test.ts": 1480, + "test/devex-peer-comparison-calibration.test.ts": 498, + "test/diagram-render-drift.test.ts": 474, + "test/diff-scope.test.ts": 2956, + "test/disabled-dated-record-at.test.ts": 108, + "test/disabled-plan-review-evidence.test.ts": 1609, + "test/discover-section-templates.test.ts": 23, + "test/dispatch-rules.test.ts": 49, + "test/distill-apply.test.ts": 992, + "test/distill-free-text.test.ts": 979, + "test/docs-config-keys.test.ts": 179, + "test/docsync-atomic-writes.test.ts": 11507, + "test/docsync-authority.test.ts": 4530, + "test/docsync-child-marker.test.ts": 26, + "test/docsync-command-grammar.test.ts": 6527, + "test/docsync-fault-interface-faults.test.ts": 15350, + "test/docsync-fault-interface-legacy.test.ts": 16465, + "test/docsync-fault-interface.test.ts": 27220, + "test/docsync-lifecycle-interface.test.ts": 1414, + "test/docsync-nested-writes.test.ts": 6130, + "test/docsync-report-interface.test.ts": 3100, + "test/doctor-components.test.ts": 35, + "test/document-skills-redaction.test.ts": 15, + "test/dom-dump-hygiene.test.ts": 1037, + "test/dx-error-capture.test.ts": 5105, + "test/dx-error-contract-grader.test.ts": 70, + "test/dx-error-contract.test.ts": 25, + "test/dx-selected-navigation-ap.test.ts": 71, + "test/e2e-harness-audit.test.ts": 17, + "test/e2e-shard-reuse.test.ts": 3181, + "test/e2e-tier-alignment.test.ts": 133, + "test/egress-lib.test.ts": 288, + "test/egress-receipt-wiring.test.ts": 207, + "test/egress-receipt.test.ts": 5417, + "test/empty-find-fallthrough.test.ts": 264, + "test/eng-batching-current-ledger.test.ts": 279, + "test/eng-batching-native-replay.test.ts": 74, + "test/eng-batching-saved-ledger.test.ts": 606, + "test/eng-count-question-policy.test.ts": 59, + "test/eng-devex-s-count.test.ts": 40, + "test/eng-finding-retry-budget.test.ts": 694, + "test/eng-first-review.test.ts": 252, + "test/eng-published-navigation.test.ts": 67, + "test/eng-resolution-block-position.test.ts": 300, + "test/eng-review-routing.test.ts": 60, + "test/eng-scope-entry-ap.test.ts": 105, + "test/eng-seeded-completion-ai.test.ts": 12223, + "test/eng-seeded-coverage.test.ts": 1519, + "test/eng-semantic-terminal.test.ts": 152, + "test/eng-test-plan-edit-approval.test.ts": 1131, + "test/env-host-fence-conformance.test.ts": 9217, + "test/env-host-runtime-assets.test.ts": 1839, + "test/eval-budgets-policy.test.ts": 47, + "test/eval-cli-family.test.ts": 3007, + "test/eval-detach-timeout-floor.test.ts": 1501, + "test/eval-flake-rank.test.ts": 286, + "test/eval-history.test.ts": 2417, + "test/eval-input-cache.test.ts": 131, + "test/eval-kinds.test.ts": 61, + "test/eval-list-cli.test.ts": 319, + "test/eval-model.test.ts": 16, + "test/eval-models.test.ts": 1228, + "test/eval-plan-no-gstack-arm.test.ts": 220, + "test/eval-plan-prereg-diagnostics.test.ts": 537, + "test/eval-readiness.test.ts": 50, + "test/eval-sweep-workflow.test.ts": 25, + "test/evals-pr-lane-workflow.test.ts": 35, + "test/evals-tracking-issue.test.ts": 349, + "test/evals-train-workflow.test.ts": 426, + "test/evals-workflow-wiring.test.ts": 585, + "test/evidence-bundle-portability.test.ts": 4869, + "test/evidence.test.ts": 23773, + "test/exercised-map.test.ts": 13378, + "test/exit-propagation.test.ts": 349, + "test/explain-level-config.test.ts": 834, + "test/extension-pty-inject-invariant.test.ts": 27, + "test/external-host-carve.test.ts": 2992, + "test/factory-scorecard.test.ts": 487, + "test/flake-ledger.test.ts": 41, + "test/flaky-run.test.ts": 782, + "test/founder-resources-optout.test.ts": 165, + "test/free-durations-record-workflow.test.ts": 379, + "test/free-fixtures.test.ts": 2691, + "test/free-home-guard.test.ts": 66, + "test/free-seed-ratchet.test.ts": 58, + "test/free-tests-workflow-wiring.test.ts": 59, + "test/freeze-owned-lifecycle.test.ts": 2869, + "test/frontend-scope.test.ts": 1183, + "test/fs-atomic.test.ts": 29, + "test/fs-utils.test.ts": 252, + "test/gate-cost.test.ts": 1922, + "test/gate-diff-cli.test.ts": 5825, + "test/gate-diff.test.ts": 29, + "test/gate-integrity-templates.test.ts": 43, + "test/gate-list.test.ts": 273, + "test/gate-reuse.test.ts": 7026, + "test/gate-secret-scan.test.ts": 579, + "test/gate-tail-lint.test.ts": 1303, + "test/gateway-provider.test.ts": 95, + "test/gbrain-cycle-completed.test.ts": 63, + "test/gbrain-detect-install.test.ts": 847, + "test/gbrain-detect-shape.test.ts": 667, + "test/gbrain-detection-override.test.ts": 1352, + "test/gbrain-dream-stage.test.ts": 271, + "test/gbrain-exec-invariant.test.ts": 12, + "test/gbrain-guards.test.ts": 60, + "test/gbrain-init-voyage-code-3.test.ts": 237, + "test/gbrain-lib-validate-varname.test.ts": 87, + "test/gbrain-lib-verify.test.ts": 198, + "test/gbrain-local-status.test.ts": 3979, + "test/gbrain-prune-gone-worktrees.test.ts": 291, + "test/gbrain-read-capability.test.ts": 1797, + "test/gbrain-refresh-install-render.test.ts": 13, + "test/gbrain-repo-policy-client.test.ts": 1255, + "test/gbrain-repo-policy.test.ts": 3848, + "test/gbrain-source-gitignore.test.ts": 90, + "test/gbrain-source-worktree-advance.test.ts": 831, + "test/gbrain-sources-parse.test.ts": 16, + "test/gbrain-sources.test.ts": 111, + "test/gbrain-spawn-windows-shell.test.ts": 15, + "test/gbrain-structured-busy.test.ts": 2035, + "test/gbrain-supabase-provision.test.ts": 101, + "test/gbrain-sync-no-bash-message.test.ts": 23, + "test/gbrain-sync-skip.test.ts": 10847, + "test/gbrain-sync-voyage-code-3-integration.test.ts": 19, + "test/gen-skill-docs-checks.test.ts": 15852, + "test/gen-skill-docs-idempotency.test.ts": 3457, + "test/gen-skill-docs-import-purity.test.ts": 60, + "test/gen-skill-docs-out-dir.test.ts": 3371, + "test/gen-skill-docs-prune-stale.test.ts": 879, + "test/gen-skill-docs.test.ts": 8778, + "test/generated-bash-placeholders.test.ts": 783, + "test/generated-docs-fences.test.ts": 277, + "test/generator-eexist.test.ts": 485, + "test/git-ref-fixture-tripwire.test.ts": 152, + "test/gitattributes-eol.test.ts": 913, + "test/global-discover.test.ts": 1040, + "test/greptile-early-cli.test.ts": 336, + "test/greptile-early.test.ts": 23, + "test/gstack-analytics-avg-duration.test.ts": 235, + "test/gstack-artifacts-init.test.ts": 6473, + "test/gstack-artifacts-url.test.ts": 150, + "test/gstack-brain-context-load.test.ts": 1082, + "test/gstack-browser-ensure.test.ts": 5747, + "test/gstack-capy-install.test.ts": 38237, + "test/gstack-ceo-plan-find.test.ts": 259, + "test/gstack-checks-go.test.ts": 2899, + "test/gstack-checks.test.ts": 5618, + "test/gstack-ci-gate.test.ts": 21, + "test/gstack-coauthor-trailers.test.ts": 818, + "test/gstack-codex-session-import.test.ts": 750, + "test/gstack-config-browse-extension-id.test.ts": 373, + "test/gstack-config-cross-project.test.ts": 230, + "test/gstack-config-defaults.test.ts": 5624, + "test/gstack-config-gbrain-refresh.test.ts": 211, + "test/gstack-config-key-locale.test.ts": 142, + "test/gstack-config-memorable-key.test.ts": 352, + "test/gstack-config-redact-keys.test.ts": 219, + "test/gstack-config-transcript-scope.test.ts": 2367, + "test/gstack-context-recovery.test.ts": 107, + "test/gstack-decision-bins.test.ts": 5993, + "test/gstack-decision-semantic.test.ts": 41, + "test/gstack-decision.test.ts": 31, + "test/gstack-design-claim.test.ts": 662, + "test/gstack-design-detect-plugins.test.ts": 320, + "test/gstack-design-detect.test.ts": 15569, + "test/gstack-design-doc-find.test.ts": 451, + "test/gstack-detach.test.ts": 23591, + "test/gstack-developer-profile.test.ts": 18398, + "test/gstack-docs-candidate.test.ts": 987, + "test/gstack-doctor-check.test.ts": 7529, + "test/gstack-doctor-launch.test.ts": 7846, + "test/gstack-doctor.test.ts": 5652, + "test/gstack-egress-cli.test.ts": 480, + "test/gstack-ff-update.test.ts": 12950, + "test/gstack-gbrain-detect-mcp-mode.test.ts": 850, + "test/gstack-gbrain-mcp-verify.test.ts": 966, + "test/gstack-gbrain-source-wireup.test.ts": 2301, + "test/gstack-gbrain-sync-transcript-mode.test.ts": 2094, + "test/gstack-gbrain-sync.test.ts": 1748, + "test/gstack-home-module-scope.test.ts": 91, + "test/gstack-launch-probe.test.ts": 62, + "test/gstack-learnings-search.test.ts": 1281, + "test/gstack-lease-git-bash.test.ts": 319, + "test/gstack-lease.test.ts": 10669, + "test/gstack-memorable.test.ts": 7406, + "test/gstack-memory-helpers.test.ts": 11077, + "test/gstack-memory-ingest.test.ts": 10862, + "test/gstack-next-version.test.ts": 18742, + "test/gstack-paths.test.ts": 689, + "test/gstack-post-cli.test.ts": 386, + "test/gstack-post.test.ts": 44, + "test/gstack-question-log.test.ts": 2092, + "test/gstack-question-preference.test.ts": 8729, + "test/gstack-redact-cli.test.ts": 3336, + "test/gstack-regen.test.ts": 3204, + "test/gstack-render-cli.test.ts": 350, + "test/gstack-repo-mode.test.ts": 1088, + "test/gstack-restamp-queue.test.ts": 13994, + "test/gstack-retro-metrics.test.ts": 800, + "test/gstack-safe-git.test.ts": 1167, + "test/gstack-schema-pack.test.ts": 17, + "test/gstack-session-kind.test.ts": 132, + "test/gstack-settings-hook-schema-aware.test.ts": 5381, + "test/gstack-settings-hook-symlink.test.ts": 1339, + "test/gstack-ship-policy.test.ts": 1656, + "test/gstack-ship-queue.test.ts": 28840, + "test/gstack-ship-receipt-docs.test.ts": 4591, + "test/gstack-ship-receipt.test.ts": 10276, + "test/gstack-skill-start.test.ts": 36234, + "test/gstack-slug-cwd-walk-up.test.ts": 871, + "test/gstack-slug-get.test.ts": 454, + "test/gstack-slug-parity.test.ts": 5315, + "test/gstack-slug-sanitize.test.ts": 142, + "test/gstack-state-root-override.test.ts": 657, + "test/gstack-team-init-hook-schema.test.ts": 452, + "test/gstack-upgrade-bin.test.ts": 13579, + "test/gstack-upgrade-fresh-shell.test.ts": 6485, + "test/gstack-upgrade-migration-v1_17_0_0.test.ts": 96, + "test/gstack-upgrade-migration-v1_37_0_0.test.ts": 134, + "test/gstack-upgrade-migration-v1_40_0_0.test.ts": 257, + "test/gstack-upgrade-migration-v1_78_0_0.test.ts": 57, + "test/gstack-upgrade-migration-v1_91_19_0.test.ts": 263, + "test/gstack-upgrade-path-spelling.test.ts": 630, + "test/gstack-version-bump.test.ts": 2978, + "test/gstack-wtree-windows-paths.test.ts": 160, + "test/harness-register.test.ts": 57374, + "test/harness-version.test.ts": 183, + "test/headless-artifacts.test.ts": 800, + "test/health-capture.test.ts": 177, + "test/health-eval-fixture.test.ts": 252, + "test/held-install-recovery.test.ts": 28928, + "test/helpers-unit.test.ts": 40, + "test/helpers/budget-override.test.ts": 12, + "test/helpers/capture-parity-baseline.test.ts": 159, + "test/helpers/claude-pty-runner.auq.unit.test.ts": 39, + "test/helpers/claude-pty-runner.boundaries.unit.test.ts": 56, + "test/helpers/claude-pty-runner.classify.unit.test.ts": 39, + "test/helpers/claude-pty-runner.launch.unit.test.ts": 366, + "test/helpers/claude-pty-runner.plan-native.unit.test.ts": 39, + "test/helpers/claude-pty-runner.runners.unit.test.ts": 305, + "test/helpers/claude-pty-runner.scope-gate-floor.unit.test.ts": 57, + "test/helpers/claude-pty-runner.screen.unit.test.ts": 48, + "test/helpers/e2e-gate.unit.test.ts": 29, + "test/helpers/eval-store.test.ts": 285, + "test/helpers/hermetic-env.test.ts": 1237, + "test/helpers/observability.test.ts": 94, + "test/helpers/providers/gemini.test.ts": 18, + "test/helpers/resolve-repo-path.test.ts": 29, + "test/helpers/run-bin.test.ts": 35, + "test/helpers/session-runner.test.ts": 100, + "test/helpers/sync-command-capture.test.ts": 1102, + "test/heredoc-pipe-deadlock.test.ts": 122, + "test/hermetic-skill-runtime.test.ts": 2693, + "test/hermetic-skills-seeding.test.ts": 186, + "test/hermetic-wiring.test.ts": 263, + "test/honesty-kernel.test.ts": 35, + "test/hook-log.test.ts": 326, + "test/hook-scripts.test.ts": 32246, + "test/hooks-windows-paths.test.ts": 209, + "test/host-config.test.ts": 3392, + "test/host-conformance.test.ts": 88509, + "test/host-renders-openai-yaml-proof.test.ts": 152, + "test/host-renders-prune-migration.test.ts": 36941, + "test/host-renders-prune-shared-root.test.ts": 35572, + "test/host-renders-prune.test.ts": 25588, + "test/host-renders.test.ts": 27249, + "test/hostile-path-writers.test.ts": 1461, + "test/impeccable-fixtures.test.ts": 42, + "test/install-fixture.test.ts": 796, + "test/install-verdict.test.ts": 1471, + "test/instruction-facts.test.ts": 67, + "test/investigate-freeze-path.test.ts": 4902, + "test/investigate-recipes.test.ts": 22, + "test/ios-debug-bridge-release-guard.test.ts": 11, "test/ios-qa-daemon-cli.test.ts": 18, - "test/ios-qa-regen.test.ts": 2085, - "test/ios-qa-stateserver-hardening.test.ts": 15, - "test/ios-qa-swift-build.test.ts": 15, - "test/ios-qa.test.ts": 192, - "test/is-conductor.test.ts": 13, - "test/jargon-list.test.ts": 17, - "test/jsonl-merge.test.ts": 425, - "test/jsonl-store.test.ts": 26, - "test/judge-calibration.test.ts": 43, - "test/judge-panel-median.test.ts": 36, - "test/land-and-deploy-ci-gate.test.ts": 3252, + "test/ios-qa-regen.test.ts": 1730, + "test/ios-qa-stateserver-hardening.test.ts": 17, + "test/ios-qa-swift-build.test.ts": 28, + "test/ios-qa.test.ts": 204, + "test/is-conductor.test.ts": 22, + "test/jargon-list.test.ts": 15, + "test/jsonl-merge.test.ts": 372, + "test/jsonl-store.test.ts": 19, + "test/judge-calibration.test.ts": 78, + "test/judge-panel-median.test.ts": 30, + "test/land-and-deploy-ci-gate.test.ts": 3154, "test/land-and-deploy-command-trigger.test.ts": 14, - "test/land-and-deploy-flow.test.ts": 696, - "test/land-and-deploy-postfail.test.ts": 16, - "test/lane-coordination.test.ts": 2106, - "test/learnings-callers-unavailable.test.ts": 1491, - "test/learnings-injection.test.ts": 10, - "test/learnings.test.ts": 4875, - "test/llm-judge-abort.test.ts": 30, - "test/llm-judge-frontier.test.ts": 82, - "test/llm-judge-stream.test.ts": 32, - "test/llms-txt-shape.test.ts": 32, - "test/maintenance-cli-args.test.ts": 151, - "test/memorable-user-prompt-hook.test.ts": 12006, - "test/memory-cache-injection.test.ts": 272, - "test/memory-ingest-include-gitignored.test.ts": 41, - "test/memory-ingest-landing.test.ts": 1418, - "test/memory-ingest-sources.test.ts": 2812, - "test/memory-ingest-timeout.test.ts": 28, - "test/memory-pipeline.test.ts": 618, - "test/migration-checkpoint-ownership.test.ts": 144, - "test/migrations-v1.27.0.0.test.ts": 693, - "test/migrations-v1.65.0.0.test.ts": 218, - "test/mktemp-portability.test.ts": 79, - "test/model-catalog-isolation.test.ts": 79, - "test/model-overlays.test.ts": 22, - "test/model-policy-cli.test.ts": 3631, - "test/model-policy-freshness.test.ts": 243, - "test/model-policy-workflow.test.ts": 386, - "test/model-policy.test.ts": 84, - "test/model-role-invocation.test.ts": 35850, - "test/module-size-ratchet.test.ts": 261, - "test/native-auto-decide-pty.test.ts": 10224, - "test/native-auto-decide.test.ts": 279, - "test/no-quoted-tilde-assignments.test.ts": 26, - "test/no-stale-gstack-brain-refs.test.ts": 323, - "test/no-suicide-exit.test.ts": 105, - "test/office-hours-attempt.test.ts": 38967, - "test/office-hours-budget.test.ts": 41, - "test/office-hours-completion.test.ts": 146, - "test/office-hours-open-question-form.test.ts": 16, - "test/office-hours-phase4-caller.test.ts": 34, - "test/office-hours-review.test.ts": 951, - "test/office-hours-writeback-env.test.ts": 385, + "test/land-and-deploy-flow.test.ts": 547, + "test/land-and-deploy-postfail.test.ts": 19, + "test/land-and-deploy-version-check.test.ts": 5169, + "test/lane-coordination.test.ts": 1991, + "test/launch-env-echo-ratchet.test.ts": 1572, + "test/learnings-bin-cli.test.ts": 376, + "test/learnings-callers-unavailable.test.ts": 1814, + "test/learnings-injection.test.ts": 17, + "test/learnings.test.ts": 5256, + "test/llm-judge-abort.test.ts": 48, + "test/llm-judge-frontier.test.ts": 108, + "test/llm-judge-stream.test.ts": 54, + "test/llms-txt-shape.test.ts": 36, + "test/maintenance-cli-args.test.ts": 213, + "test/memorable-user-prompt-hook.test.ts": 12926, + "test/memory-cache-injection.test.ts": 374, + "test/memory-ingest-include-gitignored.test.ts": 54, + "test/memory-ingest-landing.test.ts": 1635, + "test/memory-ingest-sources.test.ts": 2533, + "test/memory-ingest-timeout.test.ts": 22, + "test/memory-pipeline.test.ts": 659, + "test/migration-checkpoint-ownership.test.ts": 179, + "test/migrations-v1.27.0.0.test.ts": 762, + "test/migrations-v1.65.0.0.test.ts": 195, + "test/mktemp-portability.test.ts": 80, + "test/model-catalog-isolation.test.ts": 64, + "test/model-overlays.test.ts": 31, + "test/model-panel-selftest.test.ts": 597, + "test/model-policy-cli.test.ts": 3252, + "test/model-policy-freshness.test.ts": 181, + "test/model-policy-workflow.test.ts": 278, + "test/model-policy.test.ts": 73, + "test/model-role-invocation.test.ts": 34685, + "test/modernize-skill.test.ts": 14, + "test/module-loadouts.test.ts": 1631, + "test/module-size-ratchet.test.ts": 281, + "test/motion-capture.test.ts": 53, + "test/motion-encode.test.ts": 150, + "test/motion-pipeline.test.ts": 284, + "test/native-auto-decide-pty.test.ts": 10100, + "test/native-auto-decide.test.ts": 273, + "test/no-quoted-tilde-assignments.test.ts": 35, + "test/no-stale-gstack-brain-refs.test.ts": 322, + "test/no-suicide-exit.test.ts": 108, + "test/office-hours-attempt.test.ts": 29861, + "test/office-hours-budget.test.ts": 67, + "test/office-hours-completion.test.ts": 144, + "test/office-hours-open-question-form.test.ts": 17, + "test/office-hours-phase4-caller.test.ts": 31, + "test/office-hours-review.test.ts": 855, + "test/office-hours-writeback-env.test.ts": 885, "test/office-posture-recording.test.ts": 37, - "test/onboarding-moved-literals.test.ts": 32, - "test/one-way-doors.test.ts": 10, - "test/open-gstack-browser-liveness.test.ts": 10, - "test/openclaw-native-skills.test.ts": 8, - "test/osv-config-wiring.test.ts": 7, - "test/osv-ignore-expiry.test.ts": 9, - "test/outside-consent.test.ts": 985, - "test/outside-review-result.test.ts": 608, - "test/outside-review-severity.test.ts": 67, - "test/outside-review-unparsed-severity.test.ts": 108, - "test/outside-runner-contract.test.ts": 781, - "test/outside-send-boundary.test.ts": 342, - "test/outside-voice-evidence.test.ts": 67, - "test/outside-voice-failure-policy.test.ts": 119, - "test/outside-voice-fixture.test.ts": 211, - "test/outside-voice-invocation.test.ts": 10044, - "test/outside-voice-native-deadline.test.ts": 56, - "test/outside-voice-preflight.test.ts": 352, - "test/outside-voice-provenance.test.ts": 869, - "test/outside-voice-receipt.test.ts": 362, - "test/outside-voice-routing.test.ts": 1066, - "test/overlay-lifecycle.test.ts": 331, - "test/overlay-measurement.test.ts": 230, - "test/overlay-recording-order.test.ts": 22, - "test/overlay-sdk-cancel-eof.test.ts": 138, - "test/owner-brief-contributor-banned.test.ts": 445, - "test/paid-free-boundary.test.ts": 3912, - "test/paid-orphan-tripwire.test.ts": 72, - "test/paid-overlay-scheduling.test.ts": 557, - "test/paid-pr-profile.test.ts": 3056, - "test/paid-report-census.test.ts": 141, - "test/paid-report-fail-open.test.ts": 1680, - "test/paid-retry-supervision.test.ts": 2379, - "test/paid-run-manifest.test.ts": 2183, - "test/paid-selection-propagation.test.ts": 45, - "test/paid-shard-settlement.test.ts": 1372, - "test/paid-shards.test.ts": 6919, - "test/paid-slice-deadline.test.ts": 1548, - "test/pair-agent-token-hygiene.test.ts": 25, - "test/parity-baseline-integrity.test.ts": 17, + "test/onboarding-moved-literals.test.ts": 33, + "test/one-way-doors.test.ts": 13, + "test/open-gstack-browser-liveness.test.ts": 11, + "test/openclaw-native-skills.test.ts": 10, + "test/osv-config-wiring.test.ts": 11, + "test/osv-ignore-expiry.test.ts": 14, + "test/outside-consent.test.ts": 940, + "test/outside-review-result.test.ts": 620, + "test/outside-review-severity.test.ts": 65, + "test/outside-review-unclosed-fence.test.ts": 57, + "test/outside-review-unparsed-severity.test.ts": 160, + "test/outside-runner-contract.test.ts": 687, + "test/outside-send-boundary.test.ts": 384, + "test/outside-voice-evidence.test.ts": 35, + "test/outside-voice-failure-policy.test.ts": 126, + "test/outside-voice-fixture.test.ts": 122, + "test/outside-voice-invocation.test.ts": 8528, + "test/outside-voice-native-deadline.test.ts": 45, + "test/outside-voice-preflight.test.ts": 340, + "test/outside-voice-provenance.test.ts": 766, + "test/outside-voice-receipt.test.ts": 301, + "test/outside-voice-routing.test.ts": 1289, + "test/overlay-lifecycle.test.ts": 336, + "test/overlay-measurement.test.ts": 288, + "test/overlay-recording-order.test.ts": 75, + "test/overlay-sdk-cancel-eof.test.ts": 134, + "test/owner-brief-contributor-banned.test.ts": 670, + "test/paid-free-boundary.test.ts": 3841, + "test/paid-orphan-tripwire.test.ts": 73, + "test/paid-overlay-scheduling.test.ts": 390, + "test/paid-pr-profile.test.ts": 2524, + "test/paid-report-census.test.ts": 158, + "test/paid-report-fail-open.test.ts": 1335, + "test/paid-rerun.test.ts": 1914, + "test/paid-retry-supervision.test.ts": 1950, + "test/paid-run-manifest.test.ts": 2053, + "test/paid-selection-propagation.test.ts": 30, + "test/paid-shard-settlement.test.ts": 1227, + "test/paid-shards.test.ts": 6153, + "test/paid-slice-deadline.test.ts": 1534, + "test/pair-agent-token-hygiene.test.ts": 11, + "test/parity-baseline-integrity.test.ts": 20, "test/parity-sectioned.test.ts": 13, - "test/parity-suite.test.ts": 100, - "test/path-spelling.test.ts": 308, - "test/pending-question-completion.test.ts": 133, - "test/periodic-exclude-policy.test.ts": 129, - "test/plan-ceo-floor-interactive-boundary.test.ts": 1489, - "test/plan-count-artifacts.test.ts": 31, - "test/plan-count-checkbox.test.ts": 15372, + "test/parity-suite.test.ts": 88, + "test/path-spelling.test.ts": 288, + "test/pending-question-completion.test.ts": 88, + "test/periodic-exclude-policy.test.ts": 79, + "test/plan-ceo-floor-interactive-boundary.test.ts": 1030, + "test/plan-count-artifacts.test.ts": 17, + "test/plan-count-checkbox.test.ts": 15354, "test/plan-count-clipped-elision.test.ts": 61, - "test/plan-count-collection-completion.test.ts": 30036, - "test/plan-count-completion.test.ts": 14184, - "test/plan-count-cropped-wrap.test.ts": 64, - "test/plan-count-cross-cwd-ancestry.test.ts": 88, - "test/plan-count-design-ui-recovery.test.ts": 15758, - "test/plan-count-dx-handoff.test.ts": 88, - "test/plan-count-empty-review.test.ts": 1523, - "test/plan-count-file-permission.test.ts": 44425, - "test/plan-count-fixture.test.ts": 15542, - "test/plan-count-history.test.ts": 718, - "test/plan-count-long-edit.test.ts": 49, - "test/plan-count-native-input.test.ts": 11550, - "test/plan-count-owned-permission.test.ts": 11376, - "test/plan-count-pending-exit.test.ts": 248, - "test/plan-count-prerequisite.test.ts": 41, - "test/plan-count-preview-footer.test.ts": 3446, - "test/plan-count-session-cwd.test.ts": 55, - "test/plan-count-timeout.test.ts": 9490, - "test/plan-count-transcript.test.ts": 139, - "test/plan-count-truncated-border.test.ts": 46, + "test/plan-count-collection-completion.test.ts": 29865, + "test/plan-count-completion.test.ts": 14090, + "test/plan-count-cropped-wrap.test.ts": 58, + "test/plan-count-cross-cwd-ancestry.test.ts": 152, + "test/plan-count-design-ui-recovery.test.ts": 15807, + "test/plan-count-dx-handoff.test.ts": 59, + "test/plan-count-empty-review.test.ts": 1469, + "test/plan-count-file-permission.test.ts": 45171, + "test/plan-count-fixture.test.ts": 15322, + "test/plan-count-history.test.ts": 865, + "test/plan-count-long-edit.test.ts": 41, + "test/plan-count-native-input.test.ts": 11595, + "test/plan-count-owned-permission.test.ts": 11461, + "test/plan-count-pending-exit.test.ts": 355, + "test/plan-count-prerequisite.test.ts": 62, + "test/plan-count-preview-footer.test.ts": 3489, + "test/plan-count-session-cwd.test.ts": 49, + "test/plan-count-timeout.test.ts": 9455, + "test/plan-count-transcript.test.ts": 47, + "test/plan-count-truncated-border.test.ts": 29, "test/plan-count-truncated-question.test.ts": 29, - "test/plan-create-combined-permission.test.ts": 110, - "test/plan-create-permission.test.ts": 52, - "test/plan-create-prepublication.test.ts": 94, - "test/plan-design-floor-fixture.test.ts": 196, - "test/plan-design-sdk-fixture.test.ts": 393, - "test/plan-design-with-ui-fixture.test.ts": 1411, - "test/plan-edit-cropped-permission.test.ts": 90, - "test/plan-eng-resume.test.ts": 13, - "test/plan-floor-dx-actor.test.ts": 94, - "test/plan-floor-edit-wrap.test.ts": 46, - "test/plan-floor-permission.test.ts": 1415, - "test/plan-floor-review.test.ts": 49, - "test/plan-floor-target.test.ts": 12547, - "test/plan-format-approach-prompt.test.ts": 11, - "test/plan-format-kind-note.test.ts": 14, - "test/plan-mode-evidence.test.ts": 992, - "test/plan-pending-question-pty.test.ts": 455, - "test/plan-reality.test.ts": 573, - "test/plan-review-board-feedback.test.ts": 4413, - "test/plan-review-calibration.test.ts": 476, - "test/plan-review-cases.test.ts": 1120, - "test/plan-review-decisions.test.ts": 305, - "test/plan-review-report-recording.test.ts": 5473, - "test/plan-scope-selection.test.ts": 2767, - "test/plan-seed-submission.test.ts": 54200, - "test/plan-skill-question-events.test.ts": 2124, - "test/plan-skill-question-hook-scope.test.ts": 2601, - "test/plan-skill-questions.test.ts": 17186, - "test/plan-skill-read-permission.test.ts": 76, - "test/plan-skill-webfetch-permission.test.ts": 1111, - "test/plan-tune-cathedral.test.ts": 577, - "test/plan-tune-gates.test.ts": 1150, - "test/plan-tune.test.ts": 1276, - "test/platform-qualification-workflow.test.ts": 8, + "test/plan-create-combined-permission.test.ts": 68, + "test/plan-create-permission.test.ts": 41, + "test/plan-create-prepublication.test.ts": 79, + "test/plan-design-floor-fixture.test.ts": 174, + "test/plan-design-sdk-fixture.test.ts": 357, + "test/plan-design-with-ui-fixture.test.ts": 1982, + "test/plan-edit-cropped-permission.test.ts": 201, + "test/plan-eng-resume.test.ts": 11, + "test/plan-floor-dx-actor.test.ts": 58, + "test/plan-floor-edit-wrap.test.ts": 40, + "test/plan-floor-permission.test.ts": 991, + "test/plan-floor-review.test.ts": 25, + "test/plan-floor-target.test.ts": 12494, + "test/plan-format-approach-prompt.test.ts": 27, + "test/plan-format-kind-note.test.ts": 15, + "test/plan-mode-evidence.test.ts": 1131, + "test/plan-mode-off-build.test.ts": 23, + "test/plan-mode-off-generation.test.ts": 588, + "test/plan-mode-preamble-config.test.ts": 582, + "test/plan-mode-preamble-render.test.ts": 1097, + "test/plan-mode-preamble-setup.test.ts": 25586, + "test/plan-pending-question-pty.test.ts": 432, + "test/plan-reality.test.ts": 775, + "test/plan-review-board-feedback.test.ts": 4599, + "test/plan-review-calibration.test.ts": 409, + "test/plan-review-cases.test.ts": 1711, + "test/plan-review-decisions.test.ts": 338, + "test/plan-review-report-recording.test.ts": 6233, + "test/plan-scope-selection.test.ts": 2823, + "test/plan-seed-submission.test.ts": 54165, + "test/plan-skill-question-events.test.ts": 2387, + "test/plan-skill-question-hook-scope.test.ts": 3963, + "test/plan-skill-questions.test.ts": 22133, + "test/plan-skill-read-permission.test.ts": 36, + "test/plan-skill-webfetch-permission.test.ts": 1649, + "test/plan-tune-cathedral.test.ts": 1104, + "test/plan-tune-gates.test.ts": 1490, + "test/plan-tune.test.ts": 3304, + "test/platform-qualification-workflow.test.ts": 13, "test/post-rename-doc-regen.test.ts": 20, - "test/pr-dependencies.test.ts": 3629, - "test/pr-direct-cases.test.ts": 3220, - "test/pr-shared-input-selection.test.ts": 1818, - "test/pr-title-rewrite.test.ts": 181, - "test/pr-title-sync-workflow-safety.test.ts": 16, - "test/preamble-compose.test.ts": 15, - "test/preamble-first-task-scaffold.test.ts": 869, - "test/pregate-bats-nested.test.ts": 1307, - "test/pregate-docs-lane-review.test.ts": 1103, - "test/pregate-guards.test.ts": 6109, - "test/pregate-import-graph-paths.test.ts": 1012, - "test/pregate-invalidation.test.ts": 3781, - "test/pregate-manifest-paths.test.ts": 410, - "test/pregate-regate.test.ts": 9722, - "test/pregate-remote-gbrain.test.ts": 701, - "test/pregate-remote.test.ts": 3677, - "test/pregate-secrets-source-context.test.ts": 923, - "test/pregate-selection.test.ts": 9239, - "test/pregate-supervise.test.ts": 29677, + "test/pr-dependencies.test.ts": 5665, + "test/pr-direct-cases.test.ts": 4914, + "test/pr-shared-input-selection.test.ts": 2705, + "test/pr-shots.test.ts": 2622, + "test/pr-title-rewrite.test.ts": 190, + "test/pr-title-sync-workflow-safety.test.ts": 26, + "test/pr-wait.test.ts": 790, + "test/preamble-compose.test.ts": 101, + "test/preamble-first-task-scaffold.test.ts": 1481, + "test/preamble-hoist.test.ts": 8019, + "test/pregate-allowlist.test.ts": 2523, + "test/pregate-always-run.test.ts": 2296, + "test/pregate-bats-nested.test.ts": 1880, + "test/pregate-dart.test.ts": 8793, + "test/pregate-docs-lane-review.test.ts": 1484, + "test/pregate-environment.test.ts": 4542, + "test/pregate-guards.test.ts": 7404, + "test/pregate-head-policy.test.ts": 11256, + "test/pregate-import-graph-paths.test.ts": 1679, + "test/pregate-invalidation.test.ts": 6802, + "test/pregate-manifest-paths.test.ts": 590, + "test/pregate-non-js.test.ts": 3802, + "test/pregate-regate.test.ts": 10630, + "test/pregate-remote-gbrain.test.ts": 941, + "test/pregate-remote-stamp-only.test.ts": 5032, + "test/pregate-remote.test.ts": 10087, + "test/pregate-removed-paths.test.ts": 17469, + "test/pregate-rust.test.ts": 2540, + "test/pregate-scratch.test.ts": 1847, + "test/pregate-secrets-source-context.test.ts": 946, + "test/pregate-selection.test.ts": 11718, + "test/pregate-stamp-only.test.ts": 6308, + "test/pregate-starter.test.ts": 2520, + "test/pregate-supervise.test.ts": 33207, "test/pregate-timing.test.ts": 1929, - "test/pregate-wtree-refusal.test.ts": 982, - "test/pricing-eval-plan.test.ts": 1443, - "test/prompt-audit-manifest.test.ts": 329, - "test/prosons-neutral-posture.test.ts": 15, - "test/provider-model-defaults.test.ts": 406, - "test/pty-askuserquestion-single-line.test.ts": 34, - "test/pty-numbered-option-indent-native.test.ts": 55, - "test/pty-option-selection.test.ts": 644, - "test/pty-output-wake.test.ts": 10958, - "test/pty-screen-session.test.ts": 9363, - "test/pty-screen-supervision.test.ts": 10484, - "test/pty-screen-unicode-ap.test.ts": 131, - "test/pty-screen.test.ts": 110, - "test/pty-skill-seeding-wiring.test.ts": 479, - "test/pty-trust-dialog.test.ts": 3061, - "test/pty-upgrade-isolation.test.ts": 637, - "test/pty-workspace-trust.test.ts": 371, + "test/pregate-user-policy.test.ts": 8525, + "test/pregate-wtree-refusal.test.ts": 1403, + "test/pricing-eval-plan.test.ts": 1440, + "test/proactive-config.test.ts": 1583, + "test/proactive-core-set.test.ts": 19, + "test/proactive-skill-start.test.ts": 8581, + "test/prompt-audit-manifest.test.ts": 432, + "test/prose-lint.test.ts": 727, + "test/prose-voice-floor.test.ts": 30, + "test/prosons-neutral-posture.test.ts": 11, + "test/provider-model-defaults.test.ts": 281, + "test/pstack-sections.test.ts": 14, + "test/pty-askuserquestion-single-line.test.ts": 27, + "test/pty-numbered-option-indent-native.test.ts": 41, + "test/pty-option-selection.test.ts": 607, + "test/pty-output-wake.test.ts": 10794, + "test/pty-screen-session.test.ts": 9228, + "test/pty-screen-supervision.test.ts": 10506, + "test/pty-screen-unicode-ap.test.ts": 103, + "test/pty-screen.test.ts": 98, + "test/pty-skill-seeding-wiring.test.ts": 459, + "test/pty-trust-dialog.test.ts": 3051, + "test/pty-upgrade-isolation.test.ts": 628, + "test/pty-workspace-trust.test.ts": 344, "test/published-text.test.ts": 19, - "test/qa-browser-deadline-evidence.test.ts": 3691, - "test/qa-browser-preservation.test.ts": 992, - "test/qa-bugs-fixture.test.ts": 19, - "test/qa-caller-authority.test.ts": 88, - "test/qa-caller-freshness-order.test.ts": 65, - "test/qa-caller-report-observer.test.ts": 142, - "test/qa-checkpoint-evidence.test.ts": 68, - "test/qa-deadline-publication-observer.test.ts": 158, - "test/qa-deadline-selection.test.ts": 24, - "test/qa-deadline.test.ts": 19552, - "test/qa-evidence-producer.test.ts": 8179, - "test/qa-evidence-selection.test.ts": 64, - "test/qa-evidence.test.ts": 10945, - "test/qa-exploratory-callers.test.ts": 13065, - "test/qa-fix-loop-fixture.test.ts": 2088, - "test/qa-functional-evidence.test.ts": 6146, - "test/qa-functional-fixture.test.ts": 873, - "test/qa-functional-observer-atomic.test.ts": 226, - "test/qa-functional-observer.test.ts": 1108, - "test/qa-functional-prompt.test.ts": 1918, - "test/qa-health-rubric.test.ts": 30, - "test/qa-lazy-sections.test.ts": 8813, - "test/qa-only-browser-probe.test.ts": 704, - "test/qa-only-capability.test.ts": 2014, - "test/qa-only-cleanup.test.ts": 7703, - "test/qa-only-fixture.test.ts": 12325, - "test/qa-probe-gates.test.ts": 83, - "test/qa-report-fields.test.ts": 10, - "test/qa-supervision-selection.test.ts": 27, - "test/question-log-hook.test.ts": 1615, - "test/question-preference-hook.test.ts": 2366, - "test/question-tuning-registry-path.test.ts": 11, - "test/read-only-command-grammar.test.ts": 454, - "test/readme-skill-lists.test.ts": 16, - "test/recover-receipts.test.ts": 902, - "test/redact-audit-log.test.ts": 60, - "test/redact-context-exemptions.test.ts": 35, - "test/redact-doc-resolver.test.ts": 17, - "test/redact-dotenv-filename-false-positive.test.ts": 18, - "test/redact-engine-autoredact.test.ts": 42, - "test/redact-engine.test.ts": 113, - "test/redact-parcel-id-false-positive.test.ts": 18, - "test/redact-pattern-lint.test.ts": 24, - "test/redact-prepush-emails.test.ts": 15127, - "test/redact-prepush-hook.test.ts": 4128, - "test/redact-prepush-rebase-force-push.test.ts": 1078, - "test/redact-prepush-scan-range.test.ts": 1821, - "test/redact-prepush-target.test.ts": 10881, - "test/redact-span-binding.test.ts": 29, - "test/redact-unicode-offsets.test.ts": 82, - "test/regression-1539-review-self-verify.test.ts": 12, - "test/regression-1611-gbrain-sync-resume.test.ts": 46, - "test/regression-1624-retro-stale-base.test.ts": 25, - "test/regression-1834-retro-recommendations.test.ts": 12, - "test/regression-2922-memory-ingest-sources.test.ts": 515, - "test/regression-dot-leading-slug.test.ts": 72, - "test/regression-issue2091-bsd-mktemp.test.ts": 127, - "test/regression-issue3024-zsh-sourced-helpers.test.ts": 1120, - "test/regression-pr1169-build-app-sed.test.ts": 66, - "test/regression-pr1169-mktemp-fallbacks.test.ts": 34, - "test/regression-transcript-frontmatter-fence.test.ts": 28, - "test/regression-transcript-slug-collision.test.ts": 24, - "test/relink-disabled-router.test.ts": 1219, - "test/relink.test.ts": 11775, - "test/remote-identity-vectors.test.ts": 817, - "test/render-sections-provenance.test.ts": 308, - "test/replay-pr-selection.test.ts": 1871, - "test/resolver-ask-user-format.test.ts": 43, - "test/resolvers-gbrain-put-rewrite.test.ts": 19, - "test/resolvers-gbrain-save-results.test.ts": 23, - "test/restamp-command-boundary.test.ts": 5076, - "test/restamp-gate-ahead-changelog.test.ts": 3035, - "test/retired-commands.test.ts": 18, - "test/review-army-budget.test.ts": 2175, - "test/review-base-refresh.test.ts": 212, - "test/review-checklists.test.ts": 26, - "test/review-consensus-lifecycle.test.ts": 32, - "test/review-count-markdown.test.ts": 133, - "test/review-entry-and-design-clarity-au.test.ts": 54, + "test/qa-browser-deadline-evidence.test.ts": 5222, + "test/qa-browser-preservation.test.ts": 913, + "test/qa-bugs-fixture.test.ts": 13, + "test/qa-caller-authority.test.ts": 63, + "test/qa-caller-freshness-order.test.ts": 55, + "test/qa-caller-report-observer.test.ts": 95, + "test/qa-caller-review-plan.test.ts": 3283, + "test/qa-checkpoint-evidence.test.ts": 55, + "test/qa-deadline-publication-observer.test.ts": 150, + "test/qa-deadline-selection.test.ts": 26, + "test/qa-deadline.test.ts": 24414, + "test/qa-evidence-producer.test.ts": 8570, + "test/qa-evidence-selection.test.ts": 102, + "test/qa-evidence.test.ts": 10251, + "test/qa-exploratory-callers.test.ts": 16490, + "test/qa-fix-loop-fixture.test.ts": 2360, + "test/qa-functional-evidence.test.ts": 6404, + "test/qa-functional-fixture.test.ts": 1028, + "test/qa-functional-observer-atomic.test.ts": 251, + "test/qa-functional-observer.test.ts": 1064, + "test/qa-functional-prompt.test.ts": 1780, + "test/qa-health-rubric.test.ts": 81, + "test/qa-lazy-sections.test.ts": 9708, + "test/qa-only-browser-probe.test.ts": 618, + "test/qa-only-capability.test.ts": 1395, + "test/qa-only-cleanup.test.ts": 7935, + "test/qa-only-fixture.test.ts": 16226, + "test/qa-probe-gates.test.ts": 50, + "test/qa-report-fields.test.ts": 15, + "test/qa-supervision-selection.test.ts": 69, + "test/question-log-hook.test.ts": 1628, + "test/question-preference-hook.test.ts": 2814, + "test/question-tuning-registry-path.test.ts": 13, + "test/read-only-command-grammar.test.ts": 2428, + "test/readme-skill-lists.test.ts": 15, + "test/recover-receipts.test.ts": 834, + "test/redact-audit-log.test.ts": 33, + "test/redact-constant-and-at-filename.test.ts": 46, + "test/redact-context-exemptions.test.ts": 38, + "test/redact-doc-resolver.test.ts": 24, + "test/redact-dotenv-filename-false-positive.test.ts": 30, + "test/redact-engine-autoredact.test.ts": 14, + "test/redact-engine.test.ts": 106, + "test/redact-env-value.test.ts": 38, + "test/redact-parcel-id-false-positive.test.ts": 49, + "test/redact-pattern-lint.test.ts": 61, + "test/redact-prepush-emails.test.ts": 14605, + "test/redact-prepush-hook.test.ts": 4223, + "test/redact-prepush-rebase-force-push.test.ts": 866, + "test/redact-prepush-scan-range.test.ts": 1880, + "test/redact-prepush-target.test.ts": 10306, + "test/redact-span-binding.test.ts": 35, + "test/redact-unicode-offsets.test.ts": 94, + "test/regression-1539-review-self-verify.test.ts": 16, + "test/regression-1611-gbrain-sync-resume.test.ts": 42, + "test/regression-1624-retro-stale-base.test.ts": 11, + "test/regression-1834-retro-recommendations.test.ts": 16, + "test/regression-2922-memory-ingest-sources.test.ts": 634, + "test/regression-dot-leading-slug.test.ts": 103, + "test/regression-issue2091-bsd-mktemp.test.ts": 176, + "test/regression-issue3024-zsh-sourced-helpers.test.ts": 1422, + "test/regression-pr1169-build-app-sed.test.ts": 81, + "test/regression-pr1169-mktemp-fallbacks.test.ts": 58, + "test/regression-transcript-frontmatter-fence.test.ts": 29, + "test/regression-transcript-slug-collision.test.ts": 40, + "test/release-cut.test.ts": 11938, + "test/relink-disabled-router.test.ts": 1333, + "test/relink.test.ts": 14088, + "test/remote-identity-vectors.test.ts": 721, + "test/render-sections-provenance.test.ts": 297, + "test/replay-pr-selection.test.ts": 1680, + "test/resolver-ask-user-format.test.ts": 36, + "test/resolvers-gbrain-put-rewrite.test.ts": 26, + "test/resolvers-gbrain-save-results.test.ts": 17, + "test/restamp-base-merge.test.ts": 1349, + "test/restamp-command-boundary.test.ts": 5342, + "test/restamp-four-part-manifest.test.ts": 2361, + "test/restamp-gate-ahead-changelog.test.ts": 2988, + "test/restamp-mentions.test.ts": 4419, + "test/retired-commands.test.ts": 20, + "test/review-army-budget.test.ts": 1913, + "test/review-base-refresh.test.ts": 126, + "test/review-checklists.test.ts": 32, + "test/review-collect.test.ts": 1198, + "test/review-consensus-lifecycle.test.ts": 39, + "test/review-count-markdown.test.ts": 85, + "test/review-depth.test.ts": 86, + "test/review-dispatch-compare.test.ts": 80, + "test/review-dispatch-counters.test.ts": 29, + "test/review-dispatch.test.ts": 3090, + "test/review-entry-and-design-clarity-au.test.ts": 42, "test/review-enum-lifecycle.test.ts": 23, - "test/review-finalization-budget.test.ts": 2709, - "test/review-log-findings.test.ts": 5531, - "test/review-log.test.ts": 2612, - "test/review-n-plus-one-contract.test.ts": 733, - "test/review-quality-provenance.test.ts": 36, - "test/review-start-evidence.test.ts": 22384, - "test/review-workflow-clarity.test.ts": 204, + "test/review-finalization-budget.test.ts": 2720, + "test/review-fix-range.test.ts": 13775, + "test/review-log-findings.test.ts": 5965, + "test/review-log.test.ts": 2961, + "test/review-loop-budget-wording.test.ts": 19, + "test/review-n-plus-one-contract.test.ts": 811, + "test/review-packets-removed.test.ts": 213, + "test/review-pass.test.ts": 23996, + "test/review-plan-cli.test.ts": 6872, + "test/review-quality-provenance.test.ts": 78, + "test/review-scope-followups.test.ts": 518, + "test/review-scope.test.ts": 2351, + "test/review-start-evidence.test.ts": 18339, + "test/review-targeted.test.ts": 4747, + "test/review-workflow-clarity.test.ts": 303, "test/review-workflow-fixture.test.ts": 27, - "test/router-disabled-skills.test.ts": 511, - "test/routing-probe.test.ts": 36, - "test/run-in-background-guidance.test.ts": 2144, - "test/run-shard-child.test.ts": 1243, - "test/runtime-pins.test.ts": 60, - "test/runtime-root-assets.test.ts": 1631, - "test/safety-rule-registry.test.ts": 44, - "test/salience-allowlist.test.ts": 23, - "test/sandbox-doctor-shell.test.ts": 12, - "test/sanitize-echo.test.ts": 378, - "test/schema-version-migration.test.ts": 74, - "test/script-invocation-tripwire.test.ts": 1339, - "test/script-invocation.test.ts": 224, - "test/sealed-guard.test.ts": 2663, - "test/secret-sink-harness.test.ts": 55, - "test/section-capture-native-tools.test.ts": 4167, - "test/section-manifest-consistency.test.ts": 28, - "test/security-dashboard-fallback.test.ts": 1700, - "test/session-runner-browse-errors.test.ts": 1991, - "test/session-runner-groupkill.test.ts": 4073, - "test/session-runner-startup-grace.test.ts": 13088, - "test/session-runner-stream-lifecycle.test.ts": 303, - "test/session-runner-timeout.test.ts": 3031, - "test/session-runner-tools.test.ts": 5188, - "test/session-update-autostash.test.ts": 11360, - "test/session-update-heartbeat.test.ts": 208, - "test/session-update-held-banner.test.ts": 1462, - "test/session-update-hook-hold.test.ts": 5312, - "test/session-update-stages.test.ts": 5546, - "test/setup-alias-name-uniqueness.test.ts": 1347, - "test/setup-browser-hint.test.ts": 39, - "test/setup-bun-cmd-and-pipe-bugs.test.ts": 26, - "test/setup-bun-floor.test.ts": 198, - "test/setup-claude-code-migration.test.ts": 15393, - "test/setup-claude-skill-assets.test.ts": 1217, - "test/setup-cleanup-orphans.test.ts": 214, - "test/setup-codesign-restore.test.ts": 389, - "test/setup-codesign.test.ts": 22, - "test/setup-codex-design-pdf-runtime.test.ts": 121, - "test/setup-codex-model.test.ts": 50, - "test/setup-codex-scope-migrations.test.ts": 32108, - "test/setup-codex-scope-namespaces.test.ts": 59048, - "test/setup-codex-scope-review-boundaries.test.ts": 52917, - "test/setup-codex-scope.test.ts": 64112, - "test/setup-conductor-auq-hook.test.ts": 22140, + "test/router-disabled-skills.test.ts": 687, + "test/routing-probe.test.ts": 34, + "test/run-in-background-guidance.test.ts": 1859, + "test/run-shard-child.test.ts": 1233, + "test/runtime-pins.test.ts": 57, + "test/runtime-root-assets.test.ts": 1781, + "test/safety-rule-registry.test.ts": 28, + "test/salience-allowlist.test.ts": 30, + "test/sandbox-doctor-shell.test.ts": 21, + "test/sanitize-echo.test.ts": 422, + "test/schema-version-migration.test.ts": 67, + "test/script-invocation-tripwire.test.ts": 1150, + "test/script-invocation.test.ts": 186, + "test/sealed-guard.test.ts": 6420, + "test/secret-sink-harness.test.ts": 48, + "test/section-capture-native-tools.test.ts": 3798, + "test/section-manifest-consistency.test.ts": 24, + "test/security-dashboard-fallback.test.ts": 1284, + "test/session-kind-marker.test.ts": 632, + "test/session-runner-browse-errors.test.ts": 1496, + "test/session-runner-groupkill.test.ts": 4051, + "test/session-runner-startup-grace.test.ts": 13060, + "test/session-runner-stream-lifecycle.test.ts": 253, + "test/session-runner-timeout.test.ts": 3021, + "test/session-runner-tools.test.ts": 5072, + "test/session-update-autostash.test.ts": 14056, + "test/session-update-heartbeat.test.ts": 201, + "test/session-update-held-banner.test.ts": 1697, + "test/session-update-hook-hold.test.ts": 4910, + "test/session-update-stages.test.ts": 9817, + "test/setup-alias-name-uniqueness.test.ts": 1335, + "test/setup-browser-hint.test.ts": 35, + "test/setup-bun-cmd-and-pipe-bugs.test.ts": 24, + "test/setup-bun-floor.test.ts": 160, + "test/setup-claude-code-migration.test.ts": 15324, + "test/setup-claude-skill-assets.test.ts": 889, + "test/setup-cleanup-orphans.test.ts": 128, + "test/setup-codesign-restore.test.ts": 248, + "test/setup-codesign.test.ts": 14, + "test/setup-codex-design-pdf-runtime.test.ts": 99, + "test/setup-codex-model.test.ts": 11, + "test/setup-codex-scope-aliases.test.ts": 32932, + "test/setup-codex-scope-migrations.test.ts": 34740, + "test/setup-codex-scope-namespaces.test.ts": 26757, + "test/setup-codex-scope-review-boundaries.test.ts": 32005, + "test/setup-codex-scope-runtime-links.test.ts": 29164, + "test/setup-codex-scope.test.ts": 77321, + "test/setup-conductor-auq-hook.test.ts": 26542, "test/setup-conductor-worktree.test.ts": 33, - "test/setup-crlf-repair.test.ts": 367, - "test/setup-cso-degrade.test.ts": 246, - "test/setup-cso-windows-probe.test.ts": 45, - "test/setup-cursor-legacy-skills.test.ts": 4552, - "test/setup-deploy-key-presence.test.ts": 21, - "test/setup-deploy-not-deployed.test.ts": 9, - "test/setup-deploy-vercel-trigger.test.ts": 14, - "test/setup-emoji-font.test.ts": 57, - "test/setup-gbrain-bin-invocation-paths.test.ts": 12, - "test/setup-gbrain-fixture.test.ts": 2784, - "test/setup-gbrain-path4-caller.test.ts": 2931, - "test/setup-gbrain-path4-structure.test.ts": 11, - "test/setup-gbrain-remote-caller.test.ts": 9141, - "test/setup-gbrain-windows-mcp.test.ts": 84, - "test/setup-help.test.ts": 50, - "test/setup-hook-canonical-paths.test.ts": 67, - "test/setup-hook-syntax-gate.test.ts": 9913, - "test/setup-install-registry.test.ts": 84467, - "test/setup-kiro-native.test.ts": 789, - "test/setup-link-ownership.test.ts": 1061, - "test/setup-needs-build.test.ts": 259, - "test/setup-plan-tune-hooks-noninteractive.test.ts": 520, - "test/setup-playwright-best-effort.test.ts": 20960, - "test/setup-playwright-platform.test.ts": 5339, - "test/setup-prune-stale-generated.test.ts": 162, - "test/setup-ps1.test.ts": 37512, - "test/setup-runtime-lib-command.test.ts": 12800, - "test/setup-runtime-root-guard.test.ts": 16309, - "test/setup-sections-linking.test.ts": 15, - "test/setup-skill-copies.test.ts": 350, - "test/setup-staged-copy.test.ts": 52, - "test/setup-timeline-hook-gate.test.ts": 308, - "test/setup-windows-fallback.test.ts": 56, - "test/setup-windows-launch-hint.test.ts": 245, - "test/setup-windows-rerun-refresh.test.ts": 167, - "test/shard-cli-parity.test.ts": 119, - "test/shard-engine-equivalence.test.ts": 3227, - "test/shard-engine.test.ts": 3783, - "test/shared-libs-cancellation.test.ts": 292, - "test/shared-libs-checker-interface-evidence.test.ts": 55934, - "test/shared-libs-evidence.test.ts": 40, - "test/shared-libs-fixture.test.ts": 9847, - "test/shared-libs-plan-actor.test.ts": 67, - "test/shared-libs-rendering.test.ts": 1590, - "test/shared-libs-revalidation-prompt.test.ts": 13573, - "test/shared-libs-review-start-evidence.test.ts": 3344, - "test/shared-libs-snapshot-check.test.ts": 34909, - "test/shared-libs-source-reads.test.ts": 1468, - "test/shared-libs-stage-actor.test.ts": 83451, - "test/ship-apple-gate.test.ts": 31, - "test/ship-control-flow.test.ts": 314, - "test/ship-coverage-audit-af.test.ts": 76, - "test/ship-document-release-dispatch.test.ts": 6173, - "test/ship-greptile-early.test.ts": 55, - "test/ship-greptile-pr-check.test.ts": 111, - "test/ship-hook-actor.test.ts": 2196, - "test/ship-hook-refresh.test.ts": 3122, - "test/ship-hook-windows-paths.test.ts": 445, - "test/ship-issue-intent.test.ts": 4171, - "test/ship-measure-bar.test.ts": 147, - "test/ship-measure-loop-skill.test.ts": 11, - "test/ship-measure-seeded-fixture.test.ts": 1617, - "test/ship-measure-sweep.test.ts": 1205, - "test/ship-measure.test.ts": 2585, - "test/ship-plan-completion-invariants.test.ts": 1371, - "test/ship-policy-compat.test.ts": 551, - "test/ship-pr-liveness-policy.test.ts": 15, - "test/ship-publication-gates.test.ts": 809, - "test/ship-reentry-gates.test.ts": 30, - "test/ship-review-loop.test.ts": 16, - "test/ship-section-fixture.test.ts": 547, - "test/ship-skip-actor.test.ts": 34256, - "test/ship-skip-requeue.test.ts": 75, - "test/ship-skip-selection.test.ts": 2052, - "test/ship-template-redaction.test.ts": 16, - "test/ship-test-detection-markers.test.ts": 169, - "test/ship-triage-labels.test.ts": 16, - "test/ship-version-sync.test.ts": 863, - "test/ship-workflow-clarity.test.ts": 35, - "test/shortcut-debt-ledger.test.ts": 14, - "test/sideways-review.test.ts": 13, - "test/skill-browse-state-extraction.test.ts": 43, - "test/skill-budget-regression.test.ts": 43, - "test/skill-census.test.ts": 43, - "test/skill-ceo-section-ordering.test.ts": 2342, - "test/skill-check-driver.test.ts": 199, - "test/skill-collision-sentinel.test.ts": 18, - "test/skill-coverage-floor.test.ts": 80, - "test/skill-cross-model-recommendation-emit.test.ts": 21, - "test/skill-end-completion-durability.test.ts": 10111, - "test/skill-fixture.test.ts": 69, - "test/skill-md-size-guard.test.ts": 1474, - "test/skill-parser.test.ts": 26, - "test/skill-positional-literals.test.ts": 1004, - "test/skill-preflight-budget.test.ts": 11, - "test/skill-size-budget.test.ts": 520, - "test/skill-validation.test.ts": 1049, - "test/skillify-host-paths.test.ts": 1232, - "test/slop-diff-cli.test.ts": 318, - "test/sol-skill-fixture.test.ts": 884, - "test/spawned-consent-rule.test.ts": 29, - "test/spawnsync-timeout-tripwire.test.ts": 175, - "test/spec-quality-gate-secret-sink.test.ts": 1578, - "test/spec-template-invariants.test.ts": 18, - "test/spend-ledger.test.ts": 98, - "test/stamp-templates.test.ts": 405, - "test/state-matrix.test.ts": 1065, - "test/state-root-config-merge.test.ts": 1020, - "test/state-root-one-variable.test.ts": 5011, - "test/state-root-parity.test.ts": 134, - "test/state-root-ratchet.test.ts": 337, - "test/state-root-resolver-failure.test.ts": 28, - "test/static-no-legacy-writes.test.ts": 971, - "test/strict-output-capture.test.ts": 83, - "test/strict-output-formats.test.ts": 804, - "test/strict-output-settlement.test.ts": 4301, - "test/strict-output.test.ts": 13, - "test/sync-gbrain-code-scope.test.ts": 143, - "test/sync-gbrain-cycle-freshness.test.ts": 73, - "test/sync-gbrain-readiness-fixture.test.ts": 122, - "test/sync-gbrain-source-probe.test.ts": 649, - "test/takes-fence-fallback.test.ts": 41, - "test/tasks-section-jq.test.ts": 27, - "test/taste-engine.test.ts": 830, - "test/team-init-hook-shell-neutral.test.ts": 935, - "test/team-mode.test.ts": 1926, - "test/telemetry-repo-strip.test.ts": 23, - "test/telemetry.test.ts": 7424, - "test/template-context-parity.test.ts": 22, - "test/terse-build.test.ts": 28, - "test/test-free-shards-capture.test.ts": 1513, - "test/test-free-shards-files-from.test.ts": 254, - "test/test-free-shards-git-maintenance.test.ts": 277, - "test/test-free-shards-sandbox-knobs.test.ts": 136, - "test/test-free-shards-settlement-lifecycle.test.ts": 46142, - "test/test-free-shards-settlement-ownership.test.ts": 37520, - "test/test-free-shards.test.ts": 9740, - "test/test-health-report.test.ts": 252, - "test/test-health-workflow.test.ts": 14, - "test/test-of-test-ratchet.test.ts": 185, - "test/test-pr-profile.test.ts": 41, - "test/test-value-bar.test.ts": 71, - "test/testing-internals-anchors.test.ts": 671, - "test/third-party-actions-recording.test.ts": 83, - "test/third-party-actions.test.ts": 275, - "test/timeline-stop-hook-slug.test.ts": 4368, - "test/timeline-stop-hook.test.ts": 482, - "test/timeline.test.ts": 9809, - "test/touchfiles-facade.test.ts": 23, - "test/touchfiles-map-diff.test.ts": 207, - "test/touchfiles.test.ts": 1323, - "test/tracker-guard-wiring.test.ts": 77, - "test/tracker-guard.test.ts": 161, - "test/transcript-consent.test.ts": 865, - "test/transcript-gate-values.test.ts": 1386, - "test/transcript-scope-brain-sync.test.ts": 5566, - "test/transcript-scope-ingest.test.ts": 855, - "test/tree-receipt-gbrain-pair.test.ts": 673, - "test/tree-receipt-stamp-sensitive.test.ts": 8817, - "test/trial-cause.test.ts": 978, - "test/troubleshooting-anchors.test.ts": 1475, - "test/troubleshooting-windows-recovery.test.ts": 17, - "test/typecheck-test-ratchet.test.ts": 36, - "test/ubicloud-pregate-remote.test.ts": 32047, - "test/ubicloud-runner.test.ts": 2498, - "test/uninstall-windows-copies.test.ts": 1562, - "test/uninstall.test.ts": 5274, - "test/update-check-crash-sentinel.test.ts": 644, - "test/update-check-main-moved.test.ts": 3233, - "test/upgrade-migration-v1.test.ts": 36, - "test/upgrade-setup-recovery.test.ts": 424, - "test/upgrade-template-pins.test.ts": 9, - "test/user-render-out-dir-install.test.ts": 126, - "test/user-slug-fallback.test.ts": 572, - "test/v0-dormancy.test.ts": 29, - "test/verify-gate.test.ts": 1793, - "test/version-source.test.ts": 13, - "test/voice-directive.test.ts": 65, - "test/windows-free-selection-autoplan-guard.test.ts": 23, - "test/windows-native-workflows.test.ts": 206, - "test/workflow-action-pins.test.ts": 16, - "test/workflow-boundaries-fixture.test.ts": 3086, - "test/workflow-concurrency.test.ts": 9, - "test/workflow-excerpt.test.ts": 118, - "test/workflow-judge-cache.test.ts": 1467, - "test/workflow-judge-input.test.ts": 45, - "test/workflow-required-steps.test.ts": 33, - "test/worktree-isolated-shapes.test.ts": 26, - "test/worktree.test.ts": 364, - "test/writing-style-resolver.test.ts": 14, - "test/wtree-consumers.test.ts": 790 + "test/setup-crlf-repair.test.ts": 241, + "test/setup-cso-degrade.test.ts": 202, + "test/setup-cso-windows-probe.test.ts": 48, + "test/setup-cursor-legacy-skills.test.ts": 4653, + "test/setup-deploy-key-presence.test.ts": 22, + "test/setup-deploy-not-deployed.test.ts": 12, + "test/setup-deploy-vercel-trigger.test.ts": 12, + "test/setup-emoji-font.test.ts": 78, + "test/setup-gbrain-bin-invocation-paths.test.ts": 17, + "test/setup-gbrain-fixture.test.ts": 2556, + "test/setup-gbrain-path4-caller.test.ts": 3904, + "test/setup-gbrain-path4-structure.test.ts": 16, + "test/setup-gbrain-remote-caller.test.ts": 12003, + "test/setup-gbrain-windows-mcp.test.ts": 47, + "test/setup-help.test.ts": 45, + "test/setup-hook-canonical-paths.test.ts": 26, + "test/setup-hook-syntax-gate.test.ts": 9181, + "test/setup-install-registry.test.ts": 117380, + "test/setup-kiro-native.test.ts": 943, + "test/setup-link-ownership.test.ts": 842, + "test/setup-needs-build.test.ts": 271, + "test/setup-plan-tune-hooks-noninteractive.test.ts": 588, + "test/setup-playwright-best-effort.test.ts": 18972, + "test/setup-playwright-platform.test.ts": 5760, + "test/setup-production-deps.test.ts": 440, + "test/setup-prune-stale-generated.test.ts": 159, + "test/setup-ps1.test.ts": 34647, + "test/setup-runtime-lib-command.test.ts": 11870, + "test/setup-runtime-root-guard.test.ts": 15723, + "test/setup-sections-linking.test.ts": 13, + "test/setup-skill-copies.test.ts": 330, + "test/setup-staged-copy.test.ts": 79, + "test/setup-timeline-hook-gate.test.ts": 317, + "test/setup-windows-fallback.test.ts": 52, + "test/setup-windows-launch-hint.test.ts": 93, + "test/setup-windows-rerun-refresh.test.ts": 119, + "test/shard-cli-parity.test.ts": 183, + "test/shard-engine-equivalence.test.ts": 3121, + "test/shard-engine.test.ts": 3732, + "test/shared-libs-cancellation.test.ts": 177, + "test/shared-libs-checker-interface-evidence-declared.test.ts": 22855, + "test/shared-libs-checker-interface-evidence-replays.test.ts": 12742, + "test/shared-libs-checker-interface-evidence.test.ts": 32749, + "test/shared-libs-evidence.test.ts": 113, + "test/shared-libs-fixture.test.ts": 9673, + "test/shared-libs-index-flags-readonly.test.ts": 2050, + "test/shared-libs-plan-actor.test.ts": 80, + "test/shared-libs-rendering.test.ts": 1567, + "test/shared-libs-revalidation-prompt.test.ts": 16888, + "test/shared-libs-review-start-evidence.test.ts": 4614, + "test/shared-libs-snapshot-check.test.ts": 49252, + "test/shared-libs-source-reads.test.ts": 1628, + "test/shared-libs-stage-actor.test.ts": 117793, + "test/ship-apple-gate.test.ts": 46, + "test/ship-budget-batch.test.ts": 1425, + "test/ship-budget-calibration.test.ts": 25, + "test/ship-budget-chain.test.ts": 14693, + "test/ship-budget-meter-contract.test.ts": 2262, + "test/ship-budget-projection.test.ts": 10915, + "test/ship-budget-reader-drift.test.ts": 6384, + "test/ship-budget-review-log.test.ts": 13631, + "test/ship-budget-section.test.ts": 43, + "test/ship-budget-small-run.test.ts": 949, + "test/ship-budget-step-prior.test.ts": 526, + "test/ship-budget.test.ts": 9418, + "test/ship-calls.test.ts": 105, + "test/ship-control-flow.test.ts": 278, + "test/ship-coverage-audit-af.test.ts": 58, + "test/ship-document-release-dispatch.test.ts": 27741, + "test/ship-driver.test.ts": 219, + "test/ship-frozen-scope-actor.test.ts": 6154, + "test/ship-greptile-early.test.ts": 15, + "test/ship-greptile-pr-check.test.ts": 92, + "test/ship-handoff-brief.test.ts": 2914, + "test/ship-hook-actor.test.ts": 3107, + "test/ship-hook-refresh.test.ts": 4055, + "test/ship-hook-windows-paths.test.ts": 577, + "test/ship-issue-intent.test.ts": 6872, + "test/ship-measure-bar.test.ts": 60, + "test/ship-measure-loop-skill.test.ts": 21, + "test/ship-measure-seeded-fixture.test.ts": 2239, + "test/ship-measure-sweep.test.ts": 1267, + "test/ship-measure.test.ts": 3231, + "test/ship-plan-completion-invariants.test.ts": 2229, + "test/ship-policy-compat.test.ts": 709, + "test/ship-pr-liveness-policy.test.ts": 53, + "test/ship-preflight.test.ts": 19210, + "test/ship-publication-gates.test.ts": 1344, + "test/ship-queue-land.test.ts": 59194, + "test/ship-queue-train-bisect.test.ts": 29658, + "test/ship-queue-train-cost.test.ts": 6030, + "test/ship-queue-train-freeze.test.ts": 27317, + "test/ship-queue-train-land.test.ts": 54777, + "test/ship-queue-train-logic.test.ts": 2396, + "test/ship-queue-train-ops.test.ts": 40836, + "test/ship-queue-train-stamp.test.ts": 6806, + "test/ship-queue-train-verify.test.ts": 24377, + "test/ship-queue-train.test.ts": 31436, + "test/ship-receipt-carry-pregate.test.ts": 6120, + "test/ship-receipt-carry-rewrite.test.ts": 5942, + "test/ship-receipt-carry-squash.test.ts": 5502, + "test/ship-receipt-depth.test.ts": 4716, + "test/ship-receipt-prose.test.ts": 106, + "test/ship-reentry-gates.test.ts": 15, + "test/ship-release.test.ts": 10489, + "test/ship-review-loop.test.ts": 30, + "test/ship-section-fixture.test.ts": 380, + "test/ship-skip-actor.test.ts": 28501, + "test/ship-skip-requeue.test.ts": 37, + "test/ship-skip-selection.test.ts": 1656, + "test/ship-template-redaction.test.ts": 22, + "test/ship-test-detection-markers.test.ts": 250, + "test/ship-triage-labels.test.ts": 13, + "test/ship-version-sync.test.ts": 621, + "test/ship-workflow-clarity.test.ts": 48, + "test/shortcut-debt-ledger.test.ts": 19, + "test/sideways-review.test.ts": 11, + "test/size-ledger.test.ts": 624, + "test/skill-bill.test.ts": 386, + "test/skill-bin-drift.test.ts": 202, + "test/skill-browse-state-extraction.test.ts": 23, + "test/skill-budget-regression.test.ts": 42, + "test/skill-census.test.ts": 16, + "test/skill-ceo-section-ordering.test.ts": 2148, + "test/skill-check-driver.test.ts": 105, + "test/skill-collision-sentinel.test.ts": 20, + "test/skill-coverage-floor.test.ts": 42, + "test/skill-cross-model-recommendation-emit.test.ts": 27, + "test/skill-end-completion-durability.test.ts": 8952, + "test/skill-eval-checks.test.ts": 778, + "test/skill-eval-tasks.test.ts": 41, + "test/skill-fixture.test.ts": 51, + "test/skill-md-size-guard.test.ts": 1353, + "test/skill-parser.test.ts": 29, + "test/skill-positional-literals.test.ts": 822, + "test/skill-preflight-budget.test.ts": 23, + "test/skill-size-budget.test.ts": 410, + "test/skill-triggers.test.ts": 11, + "test/skill-validation.test.ts": 953, + "test/skillify-host-paths.test.ts": 1220, + "test/slop-diff-cli.test.ts": 285, + "test/sol-skill-fixture.test.ts": 811, + "test/spawned-consent-rule.test.ts": 22, + "test/spawnsync-timeout-tripwire.test.ts": 211, + "test/spec-quality-gate-secret-sink.test.ts": 1884, + "test/spec-template-invariants.test.ts": 20, + "test/spend-ledger.test.ts": 81, + "test/stamp-templates.test.ts": 457, + "test/state-matrix.test.ts": 1040, + "test/state-root-config-merge.test.ts": 1014, + "test/state-root-one-variable.test.ts": 4040, + "test/state-root-parity.test.ts": 136, + "test/state-root-ratchet.test.ts": 290, + "test/state-root-resolver-failure.test.ts": 44, + "test/static-no-legacy-writes.test.ts": 931, + "test/strict-output-capture.test.ts": 80, + "test/strict-output-formats.test.ts": 765, + "test/strict-output-settlement.test.ts": 4269, + "test/strict-output.test.ts": 10, + "test/sync-gbrain-code-scope.test.ts": 66, + "test/sync-gbrain-cycle-freshness.test.ts": 60, + "test/sync-gbrain-readiness-fixture.test.ts": 103, + "test/sync-gbrain-source-probe.test.ts": 405, + "test/takes-fence-fallback.test.ts": 12, + "test/tasks-section-jq.test.ts": 26, + "test/taste-engine.test.ts": 605, + "test/team-init-hook-shell-neutral.test.ts": 722, + "test/team-mode.test.ts": 1502, + "test/telemetry-repo-strip.test.ts": 24, + "test/telemetry.test.ts": 4826, + "test/template-context-parity.test.ts": 15, + "test/terse-build.test.ts": 15, + "test/test-free-shards-capture.test.ts": 1197, + "test/test-free-shards-files-from.test.ts": 97, + "test/test-free-shards-git-maintenance.test.ts": 106, + "test/test-free-shards-sandbox-knobs.test.ts": 93, + "test/test-free-shards-settlement-lifecycle.test.ts": 45388, + "test/test-free-shards-settlement-ownership.test.ts": 36977, + "test/test-free-shards.test.ts": 9604, + "test/test-health-report.test.ts": 247, + "test/test-health-workflow.test.ts": 12, + "test/test-of-test-ratchet.test.ts": 173, + "test/test-pr-profile.test.ts": 1360, + "test/test-value-bar.test.ts": 81, + "test/testing-internals-anchors.test.ts": 555, + "test/third-party-actions-recording.test.ts": 70, + "test/third-party-actions.test.ts": 128, + "test/timeline-stop-hook-slug.test.ts": 4330, + "test/timeline-stop-hook.test.ts": 387, + "test/timeline.test.ts": 7261, + "test/touchfiles-facade.test.ts": 18, + "test/touchfiles-map-diff.test.ts": 177, + "test/touchfiles.test.ts": 879, + "test/tracker-guard-wiring.test.ts": 34, + "test/tracker-guard.test.ts": 107, + "test/train-backstop.test.ts": 2313, + "test/train-gate.test.ts": 561, + "test/transcript-consent.test.ts": 706, + "test/transcript-gate-values.test.ts": 1043, + "test/transcript-scope-brain-sync.test.ts": 3956, + "test/transcript-scope-ingest.test.ts": 646, + "test/tree-receipt-gbrain-pair.test.ts": 508, + "test/tree-receipt-stamp-sensitive.test.ts": 7123, + "test/trial-cause.test.ts": 951, + "test/troubleshooting-anchors.test.ts": 974, + "test/troubleshooting-windows-recovery.test.ts": 11, + "test/typecheck-test-ratchet.test.ts": 26, + "test/ubicloud-pregate-remote.test.ts": 42386, + "test/ubicloud-runner-reattach.test.ts": 27043, + "test/ubicloud-runner-run.test.ts": 29149, + "test/ubicloud-runner-tiers.test.ts": 30774, + "test/ubicloud-runner-verify-quota.test.ts": 24864, + "test/ubicloud-runner.test.ts": 15798, + "test/unattended-grant.test.ts": 9, + "test/uninstall-windows-copies.test.ts": 1131, + "test/uninstall.test.ts": 4140, + "test/update-channel.test.ts": 5290, + "test/update-check-crash-sentinel.test.ts": 409, + "test/update-check-main-moved.test.ts": 2176, + "test/upgrade-migration-v1.test.ts": 27, + "test/upgrade-setup-recovery.test.ts": 286, + "test/upgrade-template-pins.test.ts": 8, + "test/user-render-out-dir-install.test.ts": 78, + "test/user-slug-fallback.test.ts": 409, + "test/v0-dormancy.test.ts": 16, + "test/verify-gate.test.ts": 1160, + "test/verify-skill-eval.test.ts": 3526, + "test/version-gate-fragments.test.ts": 1089, + "test/version-source.test.ts": 11, + "test/voice-directive.test.ts": 34, + "test/weekly-release-workflow.test.ts": 18, + "test/windows-free-selection-autoplan-guard.test.ts": 22, + "test/windows-native-workflows.test.ts": 233, + "test/workflow-action-pins.test.ts": 29, + "test/workflow-boundaries-fixture.test.ts": 3119, + "test/workflow-concurrency.test.ts": 17, + "test/workflow-excerpt.test.ts": 88, + "test/workflow-gh-token.test.ts": 22, + "test/workflow-judge-cache.test.ts": 1128, + "test/workflow-judge-input.test.ts": 34, + "test/workflow-required-steps.test.ts": 17, + "test/workflow-shell-lint.test.ts": 26, + "test/worktree-audit.test.ts": 1798, + "test/worktree-isolated-shapes.test.ts": 22, + "test/worktree.test.ts": 321, + "test/writing-style-resolver.test.ts": 15, + "test/wtree-consumers.test.ts": 636 } } diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index efff8bb22..04e37cac3 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -258,6 +258,26 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> file: 'test/claude-overlay-setup-installs.test.ts', reason: 'every case runs the real ./setup under describe.skipIf(win32); its bin/ spawns live in test/helpers/claude-overlay-fixture.ts, so the source scan misses them', }, + { + file: 'test/claude-overlay-setup-lifecycle.test.ts', + reason: 'every case runs the real ./setup under describe.skipIf(win32); its bin/ spawns live in test/helpers/claude-overlay-fixture.ts, so the source scan misses them', + }, + { + file: 'test/claude-overlay-setup-project.test.ts', + reason: 'every case runs the real ./setup under describe.skipIf(win32); its bin/ spawns live in test/helpers/claude-overlay-fixture.ts, so the source scan misses them', + }, + { + file: 'test/host-renders-prune-shared-root.test.ts', + reason: 'every case runs the real ./setup under describe.skipIf(win32); its bin/ spawns live in test/helpers/install-fixture.ts, so the source scan misses them', + }, + { + file: 'test/host-renders-prune-migration.test.ts', + reason: 'every case runs the real ./setup under describe.skipIf(win32); its bin/ spawns live in test/helpers/install-fixture.ts, so the source scan misses them', + }, + { + file: 'test/setup-codex-scope-runtime-links.test.ts', + reason: 'every case runs the real ./setup under describe.skipIf(win32); its bin/ spawns live in test/helpers/install-fixture.ts and test/helpers/setup-codex-scope-cyclic.ts, so the source scan misses them', + }, { file: 'test/cso-scanner-cli.test.ts', reason: 'drives the prebuilt POSIX CSO launcher with /usr/bin/git and a POSIX-only PATH; native Windows launcher behavior is covered by the dedicated cso-windows-launcher gate', @@ -299,10 +319,47 @@ export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> file: 'test/ship-queue-train.test.ts', reason: 'drives bin/gstack-ship-queue against the stateful #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead of the shim (windows-free-shard run 39152702056)', }, + { + file: 'test/ship-queue-train-bisect.test.ts', + reason: 'split from test/ship-queue-train.test.ts (GSTA-151): drives bin/gstack-ship-queue against the same #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts', + }, + { + file: 'test/ship-queue-train-verify.test.ts', + reason: 'split from test/ship-queue-train.test.ts (GSTA-151): drives bin/gstack-ship-queue against the same #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts', + }, + { + file: 'test/ubicloud-runner-tiers.test.ts', + reason: 'split from test/ubicloud-runner.test.ts, excluded as a whole: runs scripts/ubicloud/ubi-runner.sh through bash with the #!/usr/bin/env bash curl, ssh and make shims test/helpers/ubi-runner-fake-vm.ts puts on a colon-joined PATH, like test/ubicloud-runner-run.test.ts', + }, + { + file: 'test/ship-queue-train-freeze.test.ts', + reason: 'split from test/ship-queue-train.test.ts (GSTA-151): drives bin/gstack-ship-queue against the same #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts', + }, { file: 'test/ship-queue-train-cost.test.ts', reason: 'drives bin/gstack-ship-queue train verdict against the same #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts; on windows-latest spawnSync(\'gh\') resolves the runner\'s gh.exe instead of the shim (windows-free-shard 2 on #3478 at d5e6a7d)', }, + // GSTA-151 splits: parts of files the curation excluded as a whole keep that platform scope. + { + file: 'test/docsync-fault-interface-legacy.test.ts', + reason: 'split from test/docsync-fault-interface.test.ts, excluded as a whole by its POSIX mode-bit checks; replays the same docsync callbacks through the generated bun-test child', + }, + { + file: 'test/docsync-fault-interface-faults.test.ts', + reason: 'split from test/docsync-fault-interface.test.ts, excluded as a whole by its POSIX mode-bit checks; replays the same docsync callbacks through the generated bun-test child', + }, + { + file: 'test/shared-libs-checker-interface-evidence.test.ts', + reason: 'runs bin/gstack-review-log (shebang) through test/helpers/shared-libs-checker-evidence.ts captures (Windows CreateProcess does not parse shebangs)', + }, + { + file: 'test/shared-libs-checker-interface-evidence-declared.test.ts', + reason: 'split from test/shared-libs-checker-interface-evidence.test.ts: runs bin/gstack-review-log (shebang) through test/helpers/shared-libs-checker-evidence.ts captures (Windows CreateProcess does not parse shebangs)', + }, + { + file: 'test/shared-libs-checker-interface-evidence-replays.test.ts', + reason: 'split from test/shared-libs-checker-interface-evidence.test.ts, which spawned bin/gstack-review-log (shebang); its prerequisite checks run the same review-log commands through bash', + }, { file: 'test/ship-queue-train-ops.test.ts', reason: 'drives bin/gstack-ship-queue (screen verdicts, scratch-merge screens, prune --stale, watch, forget, verify --post-merge, ready --dispatch-ci, --stack-tests) against the same #!/bin/sh gh shim in test/helpers/ship-queue-train-fixture.ts', @@ -355,6 +412,8 @@ export const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [ { file: 'test/gstack-config-gbrain-refresh.test.ts', reason: 'E6: runs bin/gstack-config through explicit bash; its gbrain/python3 shims are found by bash PATH lookup, never launched by CreateProcess' }, { file: 'test/hook-scripts.test.ts', reason: '#3067 PowerShell and NotebookEdit hook coverage: every hook runs through spawnSync("bash", [script]) with the payload on stdin, scratch state lives under os.tmpdir(); its /tmp/ literals are payload strings, never opened; symlink, newline-path and POSIX-PATH cases are skipIf win32' }, { file: 'test/gstack-doctor.test.ts', reason: 'runs bin/gstack-doctor and setup --status through explicit bash argv in an os.tmpdir fixture; its bun/codex/claude/probe stubs are found by bash PATH lookup, and expected paths use bash spellings' }, + { file: 'test/gstack-doctor-check.test.ts', reason: 'split from test/gstack-doctor.test.ts: runs bin/gstack-doctor --check through explicit bash argv in the test/helpers/doctor-fixture.ts os.tmpdir fixture; its stubs are found by bash PATH lookup, and expected paths use bash spellings' }, + { file: 'test/gstack-doctor-launch.test.ts', reason: 'split from test/gstack-doctor.test.ts: runs bin/gstack-doctor through explicit bash argv in the test/helpers/doctor-fixture.ts os.tmpdir fixture; Windows bash and platform rows are injected through GSTACK_DOCTOR_PLATFORM, and POSIX-path cases skip on win32' }, // Fix wave PR 5 (item 10b, #3197): the fixture runBin launches bins through lib/script-invocation.ts // (bun bins as Bun argv, bash bins through Git Bash); passed on windows-latest in the fix wave PR 5 windows-free-tests run. { file: 'test/gstack-regen.test.ts', reason: 'runs bin/gstack-regen through the fixture runBin (lib/script-invocation.ts) and its regen commands through the resolved Git Bash' }, diff --git a/scripts/typecheck-test-baseline.json b/scripts/typecheck-test-baseline.json index fafa2959a..4d1c9dc9b 100644 --- a/scripts/typecheck-test-baseline.json +++ b/scripts/typecheck-test-baseline.json @@ -241,6 +241,7 @@ "test/helpers/qa-functional-evidence.ts\tTS7006\tParameter 'row' implicitly has an 'any' type.": 2, "test/helpers/session-runner.ts\tTS2352\tConversion of type 'ReadableStream' to type 'ReadableStream>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Types of property 'getReader' are incompatible. Type '{ (): ReadableStreamDefaultReader; (options: { mode: \"byob\"; }): ReadableStreamBYOBReader; (options?: ReadableStreamGetReaderOptions | undefined): ReadableStreamReader<...>; }' is not comparable to type '{ (): ReadableStreamDefaultReader>; (options: { mode: \"byob\"; }): ReadableStreamBYOBReader; (options?: ReadableStreamGetReaderOptions | undefined): ReadableStreamReader<...>; }'. Target signature provides too few arguments. Expected 1 or more, but got 0.": 1, "test/helpers/setup-gbrain-sandbox.ts\tTS2345\tArgument of type '(event: any) => { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }[] | { message: { content: any; id: any; role: any; }; parent_tool_use_id: any; session_id: any; type: any; }[]' is not assignable to parameter of type '(this: undefined, value: unknown, index: number, array: unknown[]) => readonly { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }[] | { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }'. Type '{ claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }[] | { message: { content: any; id: any; role: any; }; parent_tool_use_id: any; session_id: any; type: any; }[]' is not assignable to type 'readonly { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }[] | { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }'. Type '{ message: { content: any; id: any; role: any; }; parent_tool_use_id: any; session_id: any; type: any; }[]' is not assignable to type 'readonly { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }[] | { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }'. Type '{ message: { content: any; id: any; role: any; }; parent_tool_use_id: any; session_id: any; type: any; }[]' is not assignable to type 'readonly { claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }[]'. Type '{ message: { content: any; id: any; role: any; }; parent_tool_use_id: any; session_id: any; type: any; }' is missing the following properties from type '{ claude_code_version: any; cwd: any; model: any; session_id: any; subtype: any; tools: any; type: any; }': subtype, cwd, model, tools, claude_code_version": 1, + "test/helpers/shared-libs-checker-evidence.ts\tTS18048\t'current' is possibly 'undefined'.": 1, "test/helpers/shared-libs-eval-fixture.ts\tTS7006\tParameter 'candidate' implicitly has an 'any' type.": 2, "test/helpers/shared-libs-path-fixture.ts\tTS2352\tConversion of type '{ env: { GSTACK_HOME: string; }; repo: string; root: string; state: string; }' to type 'SharedLibsFixture' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. Type '{ env: { GSTACK_HOME: string; }; repo: string; root: string; state: string; }' is missing the following properties from type 'SharedLibsFixture': bin, trace, hookTrace, tip": 1, "test/helpers/shared-libs-plan-actor.ts\tTS18046\t'questions' is of type 'unknown'.": 1, @@ -431,7 +432,6 @@ "test/setup-gbrain-path4-caller.test.ts\tTS2339\tProperty 'unregistered' does not exist on type '{ 'no-init': number; 'no-install': number; 'no-registration': number; 'no-verifier': number; }'.": 1, "test/setup-gbrain-path4-caller.test.ts\tTS2339\tProperty 'wrong-engine' does not exist on type '{ 'no-init': number; 'no-install': number; 'no-registration': number; 'no-verifier': number; }'.": 1, "test/setup-gbrain-remote-caller.test.ts\tTS18048\t'lateDecision' is possibly 'undefined'.": 1, - "test/shared-libs-checker-interface-evidence.test.ts\tTS18048\t'current' is possibly 'undefined'.": 1, "test/shared-libs-fixture.test.ts\tTS18046\t'packet' is of type 'unknown'.": 4, "test/shared-libs-fixture.test.ts\tTS2345\tArgument of type '(options: { label: string; } | { description: string; label: string; } | { description: string; label: string; }) => Promise' is not assignable to parameter of type '(...args: [{ label: string; } | { description: string; label: string; } | { description: string; label: string; }, done: (err?: unknown) => void] | [{ label: string; } | { description: string; label: string; } | { ...; }, { ...; } | undefined, done: (err?: unknown) => void]) => void | Promise<...>'. Types of parameters 'options' and 'args' are incompatible. Type '[{ label: string; } | { description: string; label: string; } | { description: string; label: string; }, done: (err?: unknown) => void] | [{ label: string; } | { description: string; label: string; } | { description: string; label: string; }, { ...; } | undefined, done: (err?: unknown) => void]' is not assignable to type '[options: { label: string; } | { description: string; label: string; } | { description: string; label: string; }]'. Type '[{ label: string; } | { description: string; label: string; } | { description: string; label: string; }, done: (err?: unknown) => void]' is not assignable to type '[options: { label: string; } | { description: string; label: string; } | { description: string; label: string; }]'. Source has 2 element(s) but target allows only 1.": 1, "test/shared-libs-fixture.test.ts\tTS2345\tArgument of type '(options: { label: string; } | { description: string; label: string; } | { label: string; preview: string; } | { description: string; label: string; preview: string; } | { label: string; } | { description: string; label: string; } | { ...; }) => Promise<...>' is not assignable to parameter of type '(...args: [{ label: string; } | { description: string; label: string; } | { label: string; preview: string; } | { description: string; label: string; preview: string; } | { label: string; } | { description: string; label: string; } | { ...; }, done: (err?: unknown) => void] | [...]) => void | Promise<...>'. Types of parameters 'options' and 'args' are incompatible. Type '[{ label: string; } | { description: string; label: string; } | { label: string; preview: string; } | { description: string; label: string; preview: string; } | { label: string; } | { description: string; label: string; } | { ...; }, done: (err?: unknown) => void] | [...]' is not assignable to type '[options: { label: string; } | { description: string; label: string; } | { label: string; preview: string; } | { description: string; label: string; preview: string; } | { label: string; } | { description: string; label: string; } | { ...; }]'. Type '[{ label: string; } | { description: string; label: string; } | { label: string; preview: string; } | { description: string; label: string; preview: string; } | { label: string; } | { description: string; label: string; } | { ...; }, done: (err?: unknown) => void]' is not assignable to type '[options: { label: string; } | { description: string; label: string; } | { label: string; preview: string; } | { description: string; label: string; preview: string; } | { label: string; } | { description: string; label: string; } | { ...; }]'. Source has 2 element(s) but target allows only 1.": 1, diff --git a/test/claude-overlay-setup-installs.test.ts b/test/claude-overlay-setup-installs.test.ts index e5c4f43a8..697d53b5c 100644 --- a/test/claude-overlay-setup-installs.test.ts +++ b/test/claude-overlay-setup-installs.test.ts @@ -5,42 +5,13 @@ import { afterAll, afterEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; -import { MARKER, failableBun, setup, config, defaultRender, record, served } from './helpers/claude-overlay-fixture'; -import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, put, registryRows, runSetup } from './helpers/install-fixture'; +import { MARKER, setup, config, defaultRender, record, served } from './helpers/claude-overlay-fixture'; +import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, runSetup } from './helpers/install-fixture'; afterEach(cleanupFixtures); afterAll(cleanupSeed); describe.skipIf(process.platform === 'win32')('nondefault installs, refresh and status', () => { - test('a project install keeps its own per-install render: pinned, back to claude, failure keeps the previous render', () => { - const f = makeFixture(); - failableBun(f); - const project = join(f.home, 'proj'); - const vendored = makeSource(f, join(project, '.claude/skills/gstack')); - const skills = join(project, '.claude/skills'); - const renderOf = () => registryRows(f).find(row => row[0] === 'claude')?.[8] ?? ''; - - expect(setup(f, vendored, ['--claude-model', 'claude-opus-4-7'], project).status).toBe(0); - const render = renderOf(); - expect(render).toContain('/render/installs/claude-'); - expect(record(render)).toMatchObject({ overlay: 'opus-4-7', source: 'explicit' }); - expect(served(skills)).toContain(MARKER); - expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`); - - expect(setup(f, vendored, ['--claude-model', 'claude'], project).status).toBe(0); - expect(renderOf()).toBe(render); - expect(record(render).overlay).toBe('claude'); - expect(served(skills)).not.toContain(MARKER); - expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`); - - put(join(f.home, 'fail-render'), '1'); - const failed = setup(f, vendored, ['--claude-model', 'claude-opus-4-8'], project); - expect(failed.status, failed.out).toBe(0); - expect(failed.out).toContain('the previous render stays in use'); - expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`); - expect(served(skills)).not.toContain(MARKER); - }, 240_000); - test('--refresh-registered persists the overlay before refreshing; later refreshes honor it with no flag', () => { const f = makeFixture(); const src = makeSource(f, join(f.home, '.claude/skills/gstack')); diff --git a/test/claude-overlay-setup-lifecycle.test.ts b/test/claude-overlay-setup-lifecycle.test.ts index bfbe81beb..0a9e67992 100644 --- a/test/claude-overlay-setup-lifecycle.test.ts +++ b/test/claude-overlay-setup-lifecycle.test.ts @@ -3,11 +3,10 @@ * Shared fixture and overlay helpers: test/helpers/claude-overlay-fixture.ts. */ import { afterAll, afterEach, describe, expect, test } from 'bun:test'; -import { spawn } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { MARKER, failableBun, fakeDetect, setup, config, defaultRender, record, served } from './helpers/claude-overlay-fixture'; -import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, put, setVersion } from './helpers/install-fixture'; +import { MARKER, fakeDetect, setup, config, defaultRender, record, served } from './helpers/claude-overlay-fixture'; +import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, put } from './helpers/install-fixture'; import { readSkillStartup } from './helpers/skill-startup'; afterEach(cleanupFixtures); @@ -57,49 +56,4 @@ describe.skipIf(process.platform === 'win32')('overlay render lifecycle with gbr expect(existsSync(`${defaultRender(f)}.overlay`)).toBe(false); expect(served(join(f.home, '.claude/skills'))).toBe(readSkillStartup(join(src, 'autoplan/SKILL.md'))); }, 180_000); - - test('a failed render keeps the previous render; after an upgrade it serves the committed files', () => { - const f = makeFixture(); - failableBun(f); - const src = makeSource(f, join(f.home, '.claude/skills/gstack')); - const skills = join(f.home, '.claude/skills'); - expect(setup(f, src, ['--claude-model', 'claude-sonnet-5']).status).toBe(0); - expect(record(defaultRender(f)).overlay).toBe('sonnet-5'); - - put(join(f.home, 'fail-render'), '1'); - const kept = setup(f, src); - expect(kept.status, kept.out).toBe(0); - expect(kept.out).toContain('the previous render (sonnet-5 overlay) stays in use'); - expect(existsSync(join(defaultRender(f), 'autoplan/SKILL.md'))).toBe(true); - - setVersion(src, '9.9.9.9'); - const upgraded = setup(f, src); - expect(upgraded.status, upgraded.out).toBe(0); - expect(upgraded.out).toContain('the committed claude render is served'); - expect(upgraded.out).toContain('Claude skill overlay: claude (was sonnet-5).'); - expect(existsSync(defaultRender(f))).toBe(false); - expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', source: 'explicit', render: 'committed', version: '9.9.9.9' }); - expect(served(skills)).toBe(readSkillStartup(join(src, 'autoplan/SKILL.md'))); - }, 240_000); - - test('concurrent gbrain-refresh runs leave one valid render and no temp, old or lock dirs', async () => { - const f = makeFixture(); - const src = makeSource(f, join(f.home, '.claude/skills/gstack')); - fakeDetect(src); - expect(setup(f, src, ['--claude-model', 'claude-opus-4-7']).status).toBe(0); - put(join(f.home, 'gbrain-mode'), 'ok'); - const run = () => new Promise<{ code: number | null; err: string }>(resolve => { - const child = spawn('bash', [join(src, 'bin/gstack-config'), 'gbrain-refresh'], { env: f.env, stdio: ['ignore', 'ignore', 'pipe'] }); - let err = ''; - child.stderr.on('data', chunk => { err += chunk; }); - child.on('exit', code => resolve({ code, err })); - }); - const runs = await Promise.all([run(), run(), run()]); - expect(runs).toEqual([{ code: 0, err: '' }, { code: 0, err: '' }, { code: 0, err: '' }]); - const renderParent = join(f.home, '.gstack/render'); - expect(readdirSync(renderParent).sort()).toEqual(['claude', 'claude.overlay']); - expect(readdirSync(defaultRender(f)).filter(name => name.startsWith('claude'))).toEqual([]); - expect(readSkillStartup(join(defaultRender(f), 'autoplan/SKILL.md'))).toContain(MARKER); - expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-7', gbrain: 'ok' }); - }, 240_000); }); diff --git a/test/claude-overlay-setup-project.test.ts b/test/claude-overlay-setup-project.test.ts new file mode 100644 index 000000000..5441d9cc6 --- /dev/null +++ b/test/claude-overlay-setup-project.test.ts @@ -0,0 +1,41 @@ +/** + * W7 Claude overlay opt-in (`./setup --claude-model `): a project install keeps its own per-install render. Split from test/claude-overlay-setup-installs.test.ts so the free-suite packer can balance it (60 s seed ceiling). Shared fixture and overlay helpers: test/helpers/claude-overlay-fixture.ts. + */ +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { MARKER, failableBun, setup, record, served } from './helpers/claude-overlay-fixture'; +import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, put, registryRows } from './helpers/install-fixture'; + +afterEach(cleanupFixtures); +afterAll(cleanupSeed); + +describe.skipIf(process.platform === 'win32')('nondefault installs, refresh and status', () => { + test('a project install keeps its own per-install render: pinned, back to claude, failure keeps the previous render', () => { + const f = makeFixture(); + failableBun(f); + const project = join(f.home, 'proj'); + const vendored = makeSource(f, join(project, '.claude/skills/gstack')); + const skills = join(project, '.claude/skills'); + const renderOf = () => registryRows(f).find(row => row[0] === 'claude')?.[8] ?? ''; + + expect(setup(f, vendored, ['--claude-model', 'claude-opus-4-7'], project).status).toBe(0); + const render = renderOf(); + expect(render).toContain('/render/installs/claude-'); + expect(record(render)).toMatchObject({ overlay: 'opus-4-7', source: 'explicit' }); + expect(served(skills)).toContain(MARKER); + expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`); + + expect(setup(f, vendored, ['--claude-model', 'claude'], project).status).toBe(0); + expect(renderOf()).toBe(render); + expect(record(render).overlay).toBe('claude'); + expect(served(skills)).not.toContain(MARKER); + expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`); + + put(join(f.home, 'fail-render'), '1'); + const failed = setup(f, vendored, ['--claude-model', 'claude-opus-4-8'], project); + expect(failed.status, failed.out).toBe(0); + expect(failed.out).toContain('the previous render stays in use'); + expect(served(skills)).toContain(`${vendored}/bin/gstack-skill-start`); + expect(served(skills)).not.toContain(MARKER); + }, 240_000); +}); diff --git a/test/claude-overlay-setup-recovery.test.ts b/test/claude-overlay-setup-recovery.test.ts new file mode 100644 index 000000000..72e4f84ce --- /dev/null +++ b/test/claude-overlay-setup-recovery.test.ts @@ -0,0 +1,60 @@ +/** + * W7 Claude overlay opt-in (`./setup --claude-model `): a failed render and an upgrade keep a valid render, and concurrent refreshes leave one. Split from test/claude-overlay-setup-lifecycle.test.ts so the free-suite packer can balance it (60 s seed ceiling). Shared fixture and overlay helpers: test/helpers/claude-overlay-fixture.ts. + */ +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import { spawn } from 'node:child_process'; +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { MARKER, failableBun, fakeDetect, setup, config, defaultRender, record, served } from './helpers/claude-overlay-fixture'; +import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, put, setVersion } from './helpers/install-fixture'; +import { readSkillStartup } from './helpers/skill-startup'; + +afterEach(cleanupFixtures); +afterAll(cleanupSeed); + +describe.skipIf(process.platform === 'win32')('overlay render lifecycle with gbrain and failures', () => { + test('a failed render keeps the previous render; after an upgrade it serves the committed files', () => { + const f = makeFixture(); + failableBun(f); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + const skills = join(f.home, '.claude/skills'); + expect(setup(f, src, ['--claude-model', 'claude-sonnet-5']).status).toBe(0); + expect(record(defaultRender(f)).overlay).toBe('sonnet-5'); + + put(join(f.home, 'fail-render'), '1'); + const kept = setup(f, src); + expect(kept.status, kept.out).toBe(0); + expect(kept.out).toContain('the previous render (sonnet-5 overlay) stays in use'); + expect(existsSync(join(defaultRender(f), 'autoplan/SKILL.md'))).toBe(true); + + setVersion(src, '9.9.9.9'); + const upgraded = setup(f, src); + expect(upgraded.status, upgraded.out).toBe(0); + expect(upgraded.out).toContain('the committed claude render is served'); + expect(upgraded.out).toContain('Claude skill overlay: claude (was sonnet-5).'); + expect(existsSync(defaultRender(f))).toBe(false); + expect(record(defaultRender(f))).toMatchObject({ overlay: 'claude', source: 'explicit', render: 'committed', version: '9.9.9.9' }); + expect(served(skills)).toBe(readSkillStartup(join(src, 'autoplan/SKILL.md'))); + }, 240_000); + + test('concurrent gbrain-refresh runs leave one valid render and no temp, old or lock dirs', async () => { + const f = makeFixture(); + const src = makeSource(f, join(f.home, '.claude/skills/gstack')); + fakeDetect(src); + expect(setup(f, src, ['--claude-model', 'claude-opus-4-7']).status).toBe(0); + put(join(f.home, 'gbrain-mode'), 'ok'); + const run = () => new Promise<{ code: number | null; err: string }>(resolve => { + const child = spawn('bash', [join(src, 'bin/gstack-config'), 'gbrain-refresh'], { env: f.env, stdio: ['ignore', 'ignore', 'pipe'] }); + let err = ''; + child.stderr.on('data', chunk => { err += chunk; }); + child.on('exit', code => resolve({ code, err })); + }); + const runs = await Promise.all([run(), run(), run()]); + expect(runs).toEqual([{ code: 0, err: '' }, { code: 0, err: '' }, { code: 0, err: '' }]); + const renderParent = join(f.home, '.gstack/render'); + expect(readdirSync(renderParent).sort()).toEqual(['claude', 'claude.overlay']); + expect(readdirSync(defaultRender(f)).filter(name => name.startsWith('claude'))).toEqual([]); + expect(readSkillStartup(join(defaultRender(f), 'autoplan/SKILL.md'))).toContain(MARKER); + expect(record(defaultRender(f))).toMatchObject({ overlay: 'opus-4-7', gbrain: 'ok' }); + }, 240_000); +}); diff --git a/test/docsync-fault-interface-faults.test.ts b/test/docsync-fault-interface-faults.test.ts new file mode 100644 index 000000000..8c4bd4844 --- /dev/null +++ b/test/docsync-fault-interface-faults.test.ts @@ -0,0 +1,32 @@ +// Split from test/docsync-fault-interface.test.ts (GSTA-151): the fault-control callback replays. +import { expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { docsFaultCallbackScript } from './helpers/docsync-fault-callbacks'; + +const CALLBACK_SPLITS = [ + ['fault controls', '^ship-docsync-(?!failure )[a-z-]+ rejects ', 14], +] as const; + +for (const [split, pattern, count] of CALLBACK_SPLITS) test(`registered fault callbacks execute actual transport and hook commands, consume results, and reject controls without model calls (${split})`, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-fault-callback-')); + const root = path.resolve(import.meta.dir, '..'); + try { + const script = path.join(dir, 'callbacks.test.ts'); + fs.writeFileSync(script, docsFaultCallbackScript(root)); + const home = path.join(dir, 'home'); + fs.mkdirSync(home); + const result = Bun.spawnSync([process.execPath, 'test', script, '--test-name-pattern', pattern], { + env: { ...process.env, EVALS: '', EVALS_TIER: '', EVALS_ALL: '', EVALS_RUN_ID: 'free-docsync-faults', + HOME: home, USERPROFILE: home, + GSTACK_EVAL_DIR: path.join(dir, 'evidence'), GSTACK_HOME: path.join(dir, 'state') }, + stdout: 'pipe', stderr: 'pipe', timeout: 120_000, + }); + const output = result.stdout.toString() + result.stderr.toString(); + expect(result.exitCode, output).toBe(0); + expect(output).toContain(`${count} pass`); + expect(output).toContain(`${36 - count} filtered out`); + expect(output).toContain('0 fail'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}, 120_000); diff --git a/test/docsync-fault-interface-legacy.test.ts b/test/docsync-fault-interface-legacy.test.ts new file mode 100644 index 000000000..d7173a477 --- /dev/null +++ b/test/docsync-fault-interface-legacy.test.ts @@ -0,0 +1,32 @@ +// Split from test/docsync-fault-interface.test.ts (GSTA-151): the legacy-control callback replays. +import { expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { docsFaultCallbackScript } from './helpers/docsync-fault-callbacks'; + +const CALLBACK_SPLITS = [ + ['legacy controls', '^ship-docsync-failure rejects ', 12], +] as const; + +for (const [split, pattern, count] of CALLBACK_SPLITS) test(`registered fault callbacks execute actual transport and hook commands, consume results, and reject controls without model calls (${split})`, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'docs-fault-callback-')); + const root = path.resolve(import.meta.dir, '..'); + try { + const script = path.join(dir, 'callbacks.test.ts'); + fs.writeFileSync(script, docsFaultCallbackScript(root)); + const home = path.join(dir, 'home'); + fs.mkdirSync(home); + const result = Bun.spawnSync([process.execPath, 'test', script, '--test-name-pattern', pattern], { + env: { ...process.env, EVALS: '', EVALS_TIER: '', EVALS_ALL: '', EVALS_RUN_ID: 'free-docsync-faults', + HOME: home, USERPROFILE: home, + GSTACK_EVAL_DIR: path.join(dir, 'evidence'), GSTACK_HOME: path.join(dir, 'state') }, + stdout: 'pipe', stderr: 'pipe', timeout: 120_000, + }); + const output = result.stdout.toString() + result.stderr.toString(); + expect(result.exitCode, output).toBe(0); + expect(output).toContain(`${count} pass`); + expect(output).toContain(`${36 - count} filtered out`); + expect(output).toContain('0 fail'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}, 120_000); diff --git a/test/docsync-fault-interface.test.ts b/test/docsync-fault-interface.test.ts index 525df1500..5755cb9ec 100644 --- a/test/docsync-fault-interface.test.ts +++ b/test/docsync-fault-interface.test.ts @@ -8,6 +8,7 @@ import { DOC_PATH, docsSnapshotArgv, fixtureDocs, gitAt, gitBlobId, repoSnapshot import { DOCS_CHECKPOINT_MARKER, DOCS_SEEDED_AUDIT_ID, docsActorCommand, docsActorHook, docsActorSeeded, installDocsActor, seedDocsFirstAttempt, type DocsActorState } from './helpers/docsync-fault-actor'; import { docsActorVerdict } from './helpers/docsync-fault-eval'; import { extractDocsDispatch, parseDocsCompletion } from './helpers/docsync-contract'; +import { docsFaultCallbackScript } from './helpers/docsync-fault-callbacks'; import { docsNativeInterface, docsWriteFailures, observeDocsWrites } from './helpers/docsync-observer'; test.skipIf(process.platform !== 'linux')('actor CLI validates owned state before creating even transient locks', async () => { @@ -470,8 +471,6 @@ test('legacy verdict permits early blocking or one evidenced changed-input re-au const CALLBACK_SPLITS = [ ['adapter results', 'consumes actual adapter results|accepts its remaining changed-input audit', 10], - ['legacy controls', '^ship-docsync-failure rejects ', 12], - ['fault controls', '^ship-docsync-(?!failure )[a-z-]+ rejects ', 14], ] as const; for (const [split, pattern, count] of CALLBACK_SPLITS) test(`registered fault callbacks execute actual transport and hook commands, consume results, and reject controls without model calls (${split})`, () => { @@ -479,382 +478,7 @@ for (const [split, pattern, count] of CALLBACK_SPLITS) test(`registered fault ca const root = path.resolve(import.meta.dir, '..'); try { const script = path.join(dir, 'callbacks.test.ts'); - fs.writeFileSync(script, ` -import { expect, mock, test } from 'bun:test'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import type { SkillTestResult } from ${JSON.stringify(path.join(root, 'test/helpers/session-runner'))}; -const root = ${JSON.stringify(root)}; -const fixtureModule = path.join(root, 'test/helpers/docsync-fixture.ts'); -const observerModule = path.join(root, 'test/helpers/docsync-observer.ts'); -const fixtures = { ...await import(fixtureModule) }; -const observers = { ...await import(observerModule) }; -const { CAPTURE_MS, CAPTURE_LONG_MS } = await import(path.join(root, 'test/helpers/eval-budgets.ts')); -const { parseDocsCompletion } = await import(path.join(root, 'test/helpers/docsync-contract.ts')); -const { docsActorSeeded, DOCS_SEEDED_AUDIT_ID } = await import(path.join(root, 'test/helpers/docsync-fault-actor.ts')); -const callbacks = new Map(); -let fixture, control = '', launches = 0, recorded, legacyReaudit = false; -let returnedResult: SkillTestResult | undefined; -let consumers: string[] = []; -function expectResult(result: SkillTestResult) { - expect(result).toBe(returnedResult); - expect(result).toEqual({ - toolCalls: expect.any(Array), browseErrors: [], exitReason: 'success', duration: 0, - output: 'Bounded callback replay complete', transcript: [], model: 'free-callback-replay', - firstResponseMs: 0, maxInterTurnMs: 0, - costEstimate: { inputChars: 0, outputChars: 0, estimatedTokens: 0, estimatedCost: 0, turnsUsed: 1 }, - }); - expect(result.toolCalls.length).toBeGreaterThan(0); -} -mock.module(fixtureModule, () => ({ ...fixtures, - fixtureDocs(...args) { fixture = fixtures.fixtureDocs(...args); return fixture; }, -})); -mock.module(observerModule, () => ({ ...observers, - docsBoundedStageInterface(...args) { return observers.docsBoundedStageInterface(...args) + '\\nBOUNDED_CALLBACK_USED'; }, -})); -mock.module(path.join(root, 'test/helpers/e2e-gate.ts'), () => ({ describeE2ETier: () => (_name, body) => body() })); -mock.module(path.join(root, 'test/helpers/e2e-helpers.ts'), () => ({ - runId: 'free-docsync-faults', describeIfSelected: (_name, _names, body) => body(), - testConcurrentIfSelected: (name, body, budget) => callbacks.set(name, { body, budget }), - createEvalCollector: () => ({}), finalizeEvalCollector() {}, - logCost(_name, result) { - expectResult(result); - consumers.push('logCost'); - }, - recordE2E(_collector, _name, _label, result, verdict) { - expectResult(result); - expect(verdict).toEqual({ passed: control === '' }); - consumers.push('recordE2E'); - recorded = verdict; - }, -})); -mock.module(path.join(root, 'test/helpers/session-runner.ts'), () => ({ async runSkillTest(options) { - launches++; - const legacy = options.testName === 'ship-docsync-failure'; - const scenario = legacy ? 'legacy-completion' : options.testName.slice('ship-docsync-'.length); - expect(options.maxTurns).toBe(legacy ? 30 : 24); - expect(options.timeout).toBeGreaterThan((legacy ? CAPTURE_LONG_MS : CAPTURE_MS) - 20_000); - expect(options.timeout).toBeLessThanOrEqual((legacy ? CAPTURE_LONG_MS : CAPTURE_MS) - 15_000); - expect(options.tools).toEqual(['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep']); - expect(options.allowedTools).toEqual(options.tools); - expect(options.prompt).toContain('BOUNDED_CALLBACK_USED'); - expect(options.prompt).toContain(fixture.invocation); - expect(options.prompt).toContain('only you apply the loaded workflow'); - expect(options.prompt).toContain('do not probe invented ids'); - expect(options.prompt).toContain('Save each actual completion/rejected output once'); - expect(options.prompt).toContain('Private artifact filenames must end in .json, .md or .markdown'); - expect(options.prompt).toContain('.txt and .log filenames are not supported'); - expect(options.prompt).toContain('in a .md file without changing its bytes or reconstructing JSON'); - expect(options.prompt).toContain('the parent must not manufacture it'); - const repairable = ['recovery', 'late-result'].includes(scenario); - const actor = path.join(root, 'test/helpers/docsync-fault-actor.ts'); - const stateFile = path.join(fixture.home, 'actor-state.json'); - expect(options.prompt.includes('bun ' + actor + ' repair ' + stateFile)).toBe(repairable); - expect(options.prompt.includes('returns no result within the Collect deadline')).toBe(repairable); - expect(options.prompt.includes('This launcher has no repair operation')).toBe(!repairable); - expect(options.prompt.includes('bun ' + actor + ' inspect ' + stateFile)).toBe(true); - expect(options.prompt).toContain('batched read-only observation'); - expect(options.prompt).toContain('Parent output handling'); - expect(options.prompt).toContain('as its own standalone Bash call with no redirect, pipe, wrapper, substitution or other composition'); - expect(options.prompt).toContain('Native Read, Glob and Grep stay available for file reads and are not Bash commands'); - expect(options.prompt).toContain('Never redirect a command into a file and never re-run a command merely to save its output'); - expect(options.prompt).toContain('Keep inspect observations in their original tool results in context and compare those returned values directly'); - expect(options.prompt).toContain('Do not transcribe or reserialize inspect JSON into duplicate snapshot files; prepare already saves the required candidate and prompt'); - expect(options.prompt).toContain('preserve each actual child completion/rejected output once in Markdown'); - expect(options.prompt).toContain('Persist each required checkpoint as one short appended journal entry'); - expect(options.prompt).toContain('use native Edit with old_string exactly ' + ${JSON.stringify(JSON.stringify(DOCS_CHECKPOINT_MARKER))}); - expect(options.prompt).toContain('new_string containing only the new entry followed by that same marker, and replace_all=false'); - expect(options.prompt).toContain('if missing or duplicated, stop rather than guessing an edit'); - expect(options.prompt).toContain('Preserve unrelated sections and every earlier entry byte-for-byte'); - expect(options.prompt).toContain("retaining each earlier attempt's id, count, evidence paths and outcome"); - expect(options.prompt).toContain('The latest stated value is current; do not recopy previous entries'); - expect(options.prompt).toContain('Reference saved candidate/prompt/completion artifacts instead of repeating their contents or prior narration'); - expect(options.prompt).toContain('Before dispatch, save the incremented attempt count, fresh audit id and candidate/prompt paths together'); - expect(options.prompt).toContain('Save the returned child handle before polling'); - expect(options.prompt).toContain('consolidation must never postpone the pre-launch count or child-settlement checks'); - expect(options.prompt).toContain('If recovery is authorized, save the intermediate result in one checkpoint before continuing it'); - expect(options.prompt).toContain('After the loaded Continue or recover / Blocked recovery steps reach a final outcome'); - expect(options.prompt).toContain('Append the finishing checkpoint and Write the complete report in the same response using separate native file calls'); - expect(options.prompt).toContain('Never omit the final report or final response, even when publication is blocked'); - expect(options.prompt).toContain('when it requires stopping, write the required invocation state and report, then stop rather than continuing later preparation to fill optional artifacts'); - expect(options.prompt).toContain('do not introduce any undeclared comparison or processing program to compare or transform observations'); - expect(options.prompt).toContain('Use only the transport commands above and the commands permitted by the Fixture observation interface below'); - expect(options.prompt).toContain('inspect takes no arguments beyond the state path shown above'); - expect(options.prompt).not.toContain('inspect call below'); - expect(options.prompt).not.toContain('inspect receipt'); - const calls = []; - const config = JSON.parse(fs.readFileSync(path.join(fixture.env.CLAUDE_CONFIG_DIR, 'settings.json'), 'utf8')); - const hook = config.hooks.PreToolUse.flatMap(entry => entry.hooks).find(entry => entry.command.includes('docsync-fault-actor.ts')); - expect(hook).toBeDefined(); - const record = (tool, input, output) => calls.push({ tool, input, output }); - const pretool = (tool, input) => { - const result = Bun.spawnSync(['bash', '-c', hook.command], { cwd: fixture.repo, - stdin: Buffer.from(JSON.stringify({ hook_event_name: 'PreToolUse', cwd: fixture.repo, tool_name: tool, tool_input: input })), - stdout: 'pipe', stderr: 'pipe', timeout: 10_000 }); - expect(result.exitCode, result.stderr.toString()).toBe(0); - }; - const read = file_path => { - pretool('Read', { file_path }); - const output = fs.readFileSync(file_path, 'utf8'); - record('Read', { file_path }, output); - return output; - }; - const write = (file_path, content) => { - pretool('Write', { file_path, content }); - fs.writeFileSync(file_path, content); - record('Write', { file_path, content }, 'File written'); - }; - const invoke = (action, args = {}) => { - const argv = [actor, action, stateFile, ...Object.entries(args).map(([key, value]) => key + '=' + value)]; - const command = 'bun ' + argv.join(' '); - pretool('Bash', { command }); - const result = Bun.spawnSync([process.execPath, ...argv], { cwd: fixture.repo, stdout: 'pipe', stderr: 'pipe', timeout: 10_000 }); - const text = result.stdout.toString().trimEnd(); - const output = (result.exitCode ? 'Exit code ' + result.exitCode + '\\n' : '') + text; - record('Bash', { command }, output); - return { text, output, exit: result.exitCode }; - }; - read(path.join(fixture.home, 'phase.md')); - read(path.join(fixture.skills, 'ship/sections/documentation.md')); - const initialRecord = read(fixture.invocation); - expect(initialRecord).toContain('Attempts used: 0'); - expect(initialRecord).toContain('synthetic prior-stage state'); - const marker = ${JSON.stringify(DOCS_CHECKPOINT_MARKER)}; - expect(initialRecord.split(marker)).toHaveLength(2); - const recordPrefix = initialRecord.split(marker)[0]; - let checkpointCount = 0; - const seeded = docsActorSeeded(scenario); - let attemptsUsed = seeded ? 1 : 0; - let seedOutput = ''; - if (seeded) { - expect(initialRecord).toContain('Attempts used: 1'); - read(path.join(fixture.home, 'candidate-' + DOCS_SEEDED_AUDIT_ID + '.json')); - const saved = path.join(fixture.home, 'completion-' + DOCS_SEEDED_AUDIT_ID + '.md'); - seedOutput = control === 'skip-seeded-output' ? fs.readFileSync(saved, 'utf8') : read(saved); - } - const checkpoint = (details, finished = false) => { - const before = fs.readFileSync(fixture.invocation, 'utf8'); - expect(before.split(marker)).toHaveLength(2); - const entry = '### Checkpoint ' + (++checkpointCount) + '\\nAttempts used: ' + attemptsUsed + '.\\n' - + details + '\\nNext: ' + (finished ? 'Documentation gate finished; see ship-report.md. STOP before Step 15.' : 'Continue the actual documentation gate; STOP before Step 15.') + '\\n'; - const input = { file_path: fixture.invocation, old_string: marker, new_string: entry + marker, replace_all: false }; - pretool('Edit', input); - const after = before.replace(input.old_string, input.new_string); - fs.writeFileSync(fixture.invocation, after); - record('Edit', input, 'File edited'); - expect(after.startsWith(recordPrefix)).toBe(true); - expect(after.replace(input.new_string, marker)).toBe(before); - expect(after.split(marker)).toHaveLength(2); - expect(input.new_string).not.toContain(recordPrefix); - }; - const inspectResult = invoke('inspect'); - const observed = JSON.parse(inspectResult.text); - expect(observed.operation).toBe('inspect'); - expect(observed.files[fixtures.DOC_PATH].exists).toBe(true); - expect(observed.pre_existing_dirty).toContain(String.fromCharCode(0)); - expect(inspectResult.text).not.toContain(String.fromCharCode(0)); - expect(calls.at(-1).output).toBe(inspectResult.text); - if (control === 'tampered-inspect') calls[calls.length - 1].output = inspectResult.text.replace('"operation":"inspect"', '"operation":"tampered"'); - const prepare = id => { - const result = invoke('prepare', { audit_id: id }); - expect(result.exit).toBe(0); - const prepared = JSON.parse(result.text); - read(prepared.candidate); - read(prepared.prompt); - return prepared; - }; - const dispatch = prepared => { - attemptsUsed++; - checkpoint('Audit: ' + prepared.audit_id + '; candidate: ' + prepared.candidate + '; prompt: ' + prepared.prompt); - const result = invoke('dispatch', { audit_id: prepared.audit_id, candidate: prepared.candidate, prompt: prepared.prompt, run_in_background: 'false' }); - const saved = calls.at(-2); - expect(saved.tool).toBe('Edit'); - expect(saved.input.file_path).toBe(fixture.invocation); - expect(saved.input.old_string).toBe(marker); - expect(saved.input.new_string).toContain('Attempts used: ' + attemptsUsed); - expect(saved.input.new_string).toContain(prepared.audit_id); - expect(saved.input.new_string).toContain(prepared.candidate); - expect(saved.input.new_string).toContain(prepared.prompt); - return result; - }; - let output = '', accepted = null, first; - if (scenario !== 'missing-asset') { - first = seeded ? { audit_id: DOCS_SEEDED_AUDIT_ID, candidate: path.join(fixture.home, 'candidate-' + DOCS_SEEDED_AUDIT_ID + '.json'), - prompt: path.join(fixture.home, 'prompt-' + DOCS_SEEDED_AUDIT_ID + '.md') } : prepare('ship-docs-20260926-a1'); - const initial = seeded ? { text: seedOutput, output: seedOutput } : dispatch(first); - output = initial.text; - if (scenario === 'missing-marker') { - expect(initial.output).toBe('SESSION_KIND: interactive\\n{"schema_version":1,"audit_id":"' + first.audit_id + '","status":"blocked","files_updated":[],"files_reviewed":[],"documentation_section":"blocked — fixture child audit ' + first.audit_id + '; Missing spawned marker.","blockers":["Missing spawned marker"],"decisions":[]}'); - } - if (scenario === 'launch-failure') { - expect(initial.text).toBe('Child launch failed: injected unavailable worker. No child was started.'); - expect(seeded ? initialRecord.includes('dispatch exit code 23') : initial.output.startsWith('Exit code 23\\n')).toBe(true); - const attempts = JSON.parse(fs.readFileSync(stateFile, 'utf8')).tasks; - expect(attempts).toHaveLength(1); - expect(attempts[0].id).toBeNull(); - expect(attempts[0].candidate).toBe(fs.readFileSync(first.candidate, 'utf8')); - } - if (['timeout-unsettled', 'late-result'].includes(scenario)) { - expect(initial.output).toBe('{"task_id":"fixture-child-1","status":"running","elapsed_ms":0,"virtual_clock":true}'); - const task_id = JSON.parse(output).task_id; - if (seeded) expect(initialRecord).toContain('Returned child handle: ' + task_id + '.'); - else checkpoint('Running child handle: ' + task_id); - expect(invoke('status', { task_id }).output).toBe('{"task_id":"fixture-child-1","status":"running","settled":false,"elapsed_ms":600001,"virtual_clock":true}'); - const stop = invoke('stop', { task_id }); - expect(JSON.parse(stop.text).settled).toBe(scenario === 'late-result'); - if (control !== 'skip-post-stop-status') { - const status = invoke('status', { task_id }); - if (scenario === 'timeout-unsettled') expect(status.output).toBe('{"task_id":"fixture-child-1","status":"running","settled":false,"elapsed_ms":900002,"virtual_clock":true}'); - } - } - if (repairable) expect(invoke('repair').exit).toBe(0); - if (repairable || scenario.startsWith('stale-') || legacy && legacyReaudit) { - read(path.join(fixture.repo, 'app.ts')); - const second = prepare('ship-docs-20260926-a2'); - if (control === 'stale-candidate' || control === 'legacy-stale') { - const old = JSON.parse(fs.readFileSync(first.candidate, 'utf8')); - old.audit_id = second.audit_id; - write(second.candidate, JSON.stringify(old)); - } - output = dispatch(second).text; - if (scenario === 'late-result') expect(() => parseDocsCompletion(output, second.audit_id)).toThrow('completion identity'); - else if (legacy) expect(() => parseDocsCompletion(output, second.audit_id)).toThrow('completion fields'); - else accepted = parseDocsCompletion(output, second.audit_id); - if (control === 'legacy-third') { - const third = prepare('ship-docs-20260926-a3'); - output = dispatch(third).text; - } - } - } else { - expect(fs.existsSync(path.join(fixture.skills, 'document-release/sections/audit-scope.md'))).toBe(false); - } - if (control === 'invalid-repair') expect(invoke('repair').output).toBe('Exit code 24\\nError: repair not available'); - if (control === 'invented-handle') expect(invoke('status', { task_id: 'ship-docs-20260926-a1' }).output).toBe('Exit code 24\\nError: unknown fixture child'); - if (control === 'restore-partial') write(path.join(fixture.repo, fixtures.DOC_PATH), Buffer.from(fixture.before.contents[fixtures.DOC_PATH], 'base64').toString()); - if (control === 'user-content') write(path.join(fixture.repo, 'personal-note.txt'), 'clobbered'); - if (control === 'private-state') read(stateFile); - if (control === 'git-mutation') record('Bash', { command: 'git add ' + fixtures.DOC_PATH }, ''); - const runShell = (command) => { - pretool('Bash', { command }); - const r = Bun.spawnSync(['bash', '-c', command], { cwd: fixture.repo, stdout: 'pipe', stderr: 'pipe', timeout: 10_000 }); - record('Bash', { command }, (r.exitCode ? 'Exit code ' + r.exitCode + '\\n' : '') + r.stdout.toString().trimEnd()); - return r.exitCode; - }; - if (control === 'redirect-transport') { - const snapshot = path.join(fixture.home, 'snapshot.json'); - expect(runShell('bun ' + actor + ' inspect ' + stateFile + ' > ' + snapshot)).toBe(0); - expect(calls.at(-1).output).toBe(''); - expect(JSON.parse(fs.readFileSync(snapshot, 'utf8')).head).toBe(observed.head); - } - if (control === 'compare-program') expect(runShell('diff ' + path.join(fixture.repo, fixtures.DOC_PATH) + ' ' + path.join(fixture.repo, fixtures.DOC_PATH))).toBe(0); - if (control === 'mutate-index') { - const staged = Bun.spawnSync(['git', 'add', fixtures.DOC_PATH], { cwd: fixture.repo, stdout: 'pipe', stderr: 'pipe', timeout: 10_000 }); - expect(staged.exitCode).toBe(0); - } - if (['legacy-unchanged', 'legacy-unsettled', 'legacy-fake-repair'].includes(control)) { - const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); - if (control === 'legacy-unchanged') { - state.tasks[1].observed_candidate.hashes = { ...state.tasks[0].observed_candidate.hashes }; - state.tasks[1].candidate = JSON.stringify(state.tasks[1].observed_candidate); - } - if (control === 'legacy-unsettled') state.tasks[0].settled = false; - if (control === 'legacy-fake-repair') { state.repaired = true; state.events.push({ action: 'repair' }); } - fs.writeFileSync(stateFile, JSON.stringify(state)); - } - const outside = path.join(path.dirname(import.meta.path), 'outside.md'); - if (control === 'outside-artifact') record('Write', { file_path: outside, content: output }, 'attempted write'); - if (control === 'protected-artifact') record('Write', { file_path: path.join(fixture.skills, 'private.md'), content: output }, 'attempted write'); - if (control === 'script-artifact') record('Write', { file_path: actor, content: output }, 'attempted write'); - if (control === 'symlink-artifact') { - fs.writeFileSync(outside, 'outside fixture control'); - const link = path.join(fixture.home, 'linked.md'); - fs.symlinkSync(outside, link); - record('Write', { file_path: link, content: output }, 'attempted write'); - } - write(path.join(fixture.home, control === 'raw-text-artifact' ? 'completion.txt' : 'completion.md'), output); - expect(fs.readFileSync(path.join(fixture.home, control === 'raw-text-artifact' ? 'completion.txt' : 'completion.md'), 'utf8')).toBe(output); - if (control === '') expect(fs.readdirSync(fixture.home).filter(name => /inspect|snapshot/.test(name))).toEqual([]); - const report = path.join(fixture.home, 'ship-report.md'); - const finalReport = control === 'false-current' ? 'Documentation: current' : accepted - ? 'Documentation: ' + accepted.status + '\\n' + accepted.documentation_section - : 'Documentation: blocked\\n' + (legacy ? 'Invalid legacy completion; partial edit retained: ' + fixtures.DOC_PATH : 'Actual child result did not clear the gate') + '\\nEvidence: completion.md'; - checkpoint(finalReport, true); - if (control !== 'missing-report') write(report, finalReport); - expect(fs.readFileSync(fixture.invocation, 'utf8')).toContain(finalReport); - expect(fs.readFileSync(fixture.invocation, 'utf8')).toContain('Attempts used: ' + attemptsUsed); - if (attemptsUsed > 1) expect(fs.readFileSync(fixture.invocation, 'utf8')).toContain(first.audit_id); - if (control === '') { - expect(calls.slice(-2).map(call => [call.tool, call.input.file_path])).toEqual([ - ['Edit', fixture.invocation], ['Write', report], - ]); - expect(fs.readFileSync(report, 'utf8')).toBe(finalReport); - } - if (accepted) expect(invoke('publish', { audit_id: accepted.audit_id, report }).exit).toBe(0); - if (control === 'invalid-publication') expect(invoke('publish', { audit_id: 'ship-docs-20260926-a1', report }).exit).toBe(24); - returnedResult = { exitReason: 'success', output: 'Bounded callback replay complete', toolCalls: calls, transcript: [], - browseErrors: [], duration: 0, model: 'free-callback-replay', firstResponseMs: 0, maxInterTurnMs: 0, - costEstimate: { inputChars: 0, outputChars: 0, estimatedTokens: 0, estimatedCost: 0, turnsUsed: 1 } } satisfies SkillTestResult; - return returnedResult; -} })); -await import(path.join(root, 'test/skill-e2e-ship-docsync.test.ts')); -expect(callbacks.size).toBe(12); -const names = ['ship-docsync-failure', 'ship-docsync-missing-marker', 'ship-docsync-missing-asset', - 'ship-docsync-launch-failure', 'ship-docsync-timeout-unsettled', 'ship-docsync-late-result', - 'ship-docsync-stale-before', 'ship-docsync-stale-after', 'ship-docsync-recovery']; -for (const name of names) test(name + ' consumes actual adapter results', async () => { - control = ''; - legacyReaudit = false; - recorded = undefined; - returnedResult = undefined; - consumers = []; - const before = launches; - expect(callbacks.get(name).budget).toBe(name === 'ship-docsync-failure' ? CAPTURE_LONG_MS : CAPTURE_MS); - await callbacks.get(name).body(); - expect(launches).toBe(before + 1); - expect(recorded).toEqual({ passed: true }); - expect(consumers).toEqual(['logCost', 'recordE2E']); - expect(fs.existsSync(fixture.home)).toBe(false); -}); -test('ship-docsync-failure accepts its remaining changed-input audit and preserves raw output in Markdown', async () => { - control = ''; - legacyReaudit = true; - recorded = undefined; - returnedResult = undefined; - consumers = []; - await callbacks.get('ship-docsync-failure').body(); - expect(recorded).toEqual({ passed: true }); - expect(consumers).toEqual(['logCost', 'recordE2E']); - expect(fs.existsSync(fixture.home)).toBe(false); -}); -for (const [name, mutation] of [ - ['ship-docsync-failure', 'false-current'], ['ship-docsync-failure', 'invalid-publication'], - ['ship-docsync-failure', 'restore-partial'], ['ship-docsync-failure', 'user-content'], - ['ship-docsync-failure', 'private-state'], ['ship-docsync-failure', 'git-mutation'], - ['ship-docsync-failure', 'mutate-index'], ['ship-docsync-missing-marker', 'invalid-repair'], - ['ship-docsync-missing-marker', 'redirect-transport'], ['ship-docsync-missing-marker', 'compare-program'], - ['ship-docsync-launch-failure', 'invented-handle'], ['ship-docsync-timeout-unsettled', 'skip-post-stop-status'], - ['ship-docsync-late-result', 'missing-report'], - ['ship-docsync-stale-before', 'stale-candidate'], - ['ship-docsync-recovery', 'skip-seeded-output'], - ['ship-docsync-failure', 'legacy-unchanged'], ['ship-docsync-failure', 'legacy-unsettled'], - ['ship-docsync-failure', 'legacy-stale'], ['ship-docsync-failure', 'legacy-third'], - ['ship-docsync-failure', 'legacy-fake-repair'], - ['ship-docsync-missing-marker', 'tampered-inspect'], - ['ship-docsync-missing-marker', 'raw-text-artifact'], ['ship-docsync-missing-marker', 'outside-artifact'], - ['ship-docsync-missing-marker', 'protected-artifact'], ['ship-docsync-missing-marker', 'symlink-artifact'], - ['ship-docsync-missing-marker', 'script-artifact'], -]) test(name + ' rejects ' + mutation, async () => { - control = mutation; - legacyReaudit = name === 'ship-docsync-failure'; - recorded = undefined; - returnedResult = undefined; - consumers = []; - await expect(callbacks.get(name).body()).rejects.toThrow(); - expect(recorded).toEqual({ passed: false }); - expect(consumers).toEqual(['logCost', 'recordE2E']); - expect(fs.existsSync(fixture.home)).toBe(false); -}); -`); + fs.writeFileSync(script, docsFaultCallbackScript(root)); const home = path.join(dir, 'home'); fs.mkdirSync(home); const result = Bun.spawnSync([process.execPath, 'test', script, '--test-name-pattern', pattern], { diff --git a/test/gstack-doctor-check.test.ts b/test/gstack-doctor-check.test.ts new file mode 100644 index 000000000..790eebc87 --- /dev/null +++ b/test/gstack-doctor-check.test.ts @@ -0,0 +1,373 @@ +// Split from test/gstack-doctor.test.ts: the `gstack-doctor --check` verdict rows, trailer and JSON. +import { describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { useDoctorCleanup, REPO, BROWSE_BIN, EXE, write, script, bashPath, probeCalls, makeFixture, doctor, type Fixture } from './helpers/doctor-fixture'; + +useDoctorCleanup(); + +// A1: `--check` is the one-line verdict a parent agent greps. Rows are +// `PASS | FAIL | SKIP ` from lib/doctor-components.ts; the trailer is +// `gstack: ok for=` (exit 0) or `gstack: fail ` (exit 1). +// Pinned on tokens, ids, exit codes and the JSON shape, not prose. +describe('gstack-doctor --check', () => { + const VERSION = fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim(); + const row = (f: Fixture, args: string[], extra: Record = {}) => { + const r = doctor(f, ['--check', ...args], extra); + const lines = r.out.split('\n'); + const rows = Object.fromEntries(lines.filter(l => /^(PASS|FAIL|SKIP|WARN) /.test(l)).map(l => { + const m = l.match(/^(PASS|FAIL|SKIP|WARN) ([a-z-]+)(?:\s(.*))?$/)!; + return [m[2], { status: m[1], rest: m[3] ?? '' }]; + })); + const trailer = lines.filter(l => l.startsWith('gstack: ')).at(-1) ?? ''; + const required = lines.find(l => l.startsWith('required: '))?.slice(10).split(' ') ?? []; + return { status: r.status, out: r.out, rows, trailer, required, trailers: lines.filter(l => l.startsWith('gstack: ')).length }; + }; + /** Register a Codex install from this checkout and render a review skill, with or without the Astra patch. */ + const installCodex = (f: Fixture, patch = true) => { + const dest = path.join(f.home, '.codex/skills'); + fs.appendFileSync(path.join(f.state, 'installs.tsv'), + ['codex', 'global', '-', dest, path.join(dest, 'gstack'), f.root, VERSION, 'true', 'committed', '2026-10-07T00:00:00Z'].join('\t') + '\n'); + fs.mkdirSync(path.join(dest, 'gstack'), { recursive: true }); + write(path.join(dest, 'gstack-review/SKILL.md'), `---\nname: gstack-review\n---\n# Review\n${patch ? '\n## Model-Specific Behavioral Patch (gpt-6-astra)\n' : ''}`); + return dest; + }; + const installPlaywrightStub = (f: Fixture, launches: boolean) => write(path.join(f.root, 'node_modules/playwright/index.js'), + launches + ? 'module.exports.chromium = { launch: async () => ({ version: () => "141.0.1", close: async () => {}, newPage: async () => ({ setContent: async () => {}, title: async () => "gstack smoke test" }) }) };' + : 'module.exports.chromium = { launch: async () => { throw new Error("browserType.launch: Executable doesn\'t exist"); } };'); + + test('healthy Claude + Codex installs: every required row PASS, exactly one trailer, exit 0, no paid probe', () => { + const f = makeFixture(); + installCodex(f); + const r = row(f, []); + expect(r.status, r.out).toBe(0); + expect(r.required).toEqual(['claude', 'codex', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'floor', 'wrapper']); + for (const id of r.required.filter(x => x !== 'pins' && x !== 'floor' && x !== 'wrapper')) expect(r.rows[id]?.status, `${id}\n${r.out}`).toBe('PASS'); + // The work dir is not a git repository, so there is no project to read pins or a floor from: SKIP with the reason, never silent. + expect(r.rows.pins).toEqual({ status: 'SKIP', rest: expect.stringContaining('no project') }); + expect(r.rows.floor).toEqual({ status: 'SKIP', rest: expect.stringContaining('no project to read .gstack/ship-policy.json from') }); + // No ~/.capy/drive skill copy and no active wrapper: not a Capy machine. + expect(r.rows.wrapper).toEqual({ status: 'SKIP', rest: expect.stringContaining('not a Capy machine') }); + expect(r.rows.patch.status).toBe('PASS'); + expect(r.rows['codex-cli'].status).toBe('PASS'); + expect(r.rows.browser).toEqual({ status: 'SKIP', rest: '(lazy; gstack-browser-ensure installs on first use)' }); + expect(r.rows.cso.status).toBe('SKIP'); + expect(r.trailer).toBe(`gstack: ok ${VERSION} for=installed-hosts:claude,codex`); + expect(r.trailers).toBe(1); + expect(r.rows.runtime.rest).toContain('floor 1.3.3, supported minimum 1.4.2'); + expect(r.rows.privacy.rest).toBe('telemetry=off artifacts_sync=off codex_reviews=enabled update_check=true'); + expect(r.rows['state-root'].rest).toContain('durable=yes'); + expect(probeCalls(f)).not.toContain('probe-model'); + }); + + // Value: protects=the --check state-root row names the variable that selected the root, after earlier rows read config; + // fails_when=an in-process config read reselects the root from the doctor's own GSTACK_STATE_ROOT; + // why_new=every other fixture sets GSTACK_STATE_ROOT itself, so a wrong "selected by" still reads GSTACK_STATE_ROOT; seam=none; + // no_claim=CLAUDE_PLUGIN_DATA and default selection (GSTACK_HOME stands in for every selector other than GSTACK_STATE_ROOT) + test('the state-root row names GSTACK_HOME when GSTACK_HOME selected the root, in text and JSON', () => { + const f = makeFixture(); + installCodex(f); + const byHome = { GSTACK_STATE_ROOT: '', GSTACK_HOME: f.state }; + const r = row(f, [], byHome); + expect(r.rows['state-root'].rest, r.out).toContain('(selected by GSTACK_HOME;'); + const json = JSON.parse(doctor(f, ['--json'], byHome).out); + expect(json.rows.find((x: { id: string }) => x.id === 'state-root').detail).toContain('(selected by GSTACK_HOME;'); + }); + + test('--for autoplan: healthy Capy machine with Claude subagents as the outside voice and no Codex CLI passes', () => { + const f = makeFixture(); + installCodex(f); + fs.rmSync(path.join(f.stub, 'codex')); + const r = row(f, ['--for', 'autoplan']); + expect(r.status, r.out).toBe(0); + expect(r.required).toEqual(['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'floor', 'wrapper']); + expect(r.rows.patch).toEqual({ status: 'PASS', rest: 'gpt-6-astra (gstack-review/SKILL.md)' }); + expect(r.rows['codex-cli'].status).toBe('SKIP'); + expect(r.rows['codex-cli'].rest).toContain('optional'); + expect(r.trailer).toBe(`gstack: ok ${VERSION} for=autoplan`); + // The same machine with a stale Codex render fails the patch row. + installCodex(f, false); + const noPatch = row(f, ['--for', 'autoplan']); + expect(noPatch.status).toBe(1); + expect(noPatch.rows.patch.status).toBe('FAIL'); + expect(noPatch.rows.patch.rest).toContain('fix: '); + expect(noPatch.rows.patch.rest).toContain('(COMPONENT_MISSING; '); + expect(noPatch.rows.patch.rest).toContain('--host codex --model'); + expect(noPatch.trailer).toBe('gstack: fail patch'); + }); + + test('--for autoplan: a hoisted Codex render carries the patch in its shared sections/preamble.md', () => { + const f = makeFixture(); + const dest = installCodex(f, false); + write(path.join(dest, 'gstack-review/sections/preamble.md'), '# gstack shared preamble 0123456789ab\n\n## Model-Specific Behavioral Patch (gpt-6-astra)\n'); + const r = row(f, ['--for', 'autoplan']); + expect(r.rows.patch, r.out).toEqual({ status: 'PASS', rest: 'gpt-6-astra (gstack-review/sections/preamble.md)' }); + }); + + test('an empty installed-host set is never ok: claude is required and FAILs', () => { + const f = makeFixture(); + write(path.join(f.state, 'installs.tsv'), ''); + const r = row(f, []); + expect(r.status).toBe(1); + expect(r.required).toContain('claude'); + expect(r.rows.claude.status).toBe('FAIL'); + expect(r.rows.claude.rest).toContain(`fix: cd ${bashPath(f.root)} && ./setup --host claude --no-prefix`); + expect(r.trailer).toBe('gstack: fail claude'); + expect(JSON.parse(doctor(f, ['--json']).out).for).toBe('installed-hosts:none(claude required)'); + }); + + test('wrong host: an install registered from another checkout does not count; --require names what is missing', () => { + const f = makeFixture(); + const other = path.join(f.base, 'other'); + write(path.join(f.state, 'installs.tsv'), ['codex', 'global', '-', path.join(f.home, '.codex/skills'), other, other, VERSION, 'false', 'committed', 'x'].join('\t') + '\n'); + const r = row(f, ['--require', 'codex,patch']); + expect(r.status).toBe(1); + expect(r.rows.codex.status).toBe('FAIL'); + expect(r.rows.patch.status).toBe('FAIL'); + expect(r.rows.claude.status).toBe('SKIP'); + expect(r.trailer).toBe('gstack: fail codex,patch'); + expect(JSON.parse(doctor(f, ['--json', '--require', 'codex,patch']).out).for).toBe('codex,patch'); + }); + + test('missing authentication: --for codex needs the CLI; absent, unauthenticated and present are told apart, --live probes once', () => { + const f = makeFixture(); + installCodex(f); + const unauthed = row(f, ['--for', 'codex'], { STUB_AUTH_FAIL: '1' }); + expect(unauthed.status).toBe(1); + expect(unauthed.rows['codex-cli'].status).toBe('FAIL'); + expect(unauthed.rows['codex-cli'].rest).toContain('not authenticated'); + expect(unauthed.rows['codex-cli'].rest).toContain('codex login --with-api-key'); + expect(unauthed.trailer).toBe('gstack: fail codex-cli'); + + const authed = row(f, ['--for', 'codex']); + expect(authed.status, authed.out).toBe(0); + expect(authed.rows['codex-cli'].rest).toContain('paid probe not run; add --live'); + expect(probeCalls(f)).not.toContain('probe-model'); + + const live = row(f, ['--for', 'codex', '--live']); + expect(live.status, live.out).toBe(0); + expect(live.rows['codex-cli'].rest).toContain('live probe ok'); + expect(probeCalls(f).match(/probe-model exec/g)?.length).toBe(1); + + fs.rmSync(path.join(f.stub, 'codex')); + const absent = row(f, ['--for', 'codex']); + expect(absent.rows['codex-cli'].status).toBe('FAIL'); + expect(absent.rows['codex-cli'].rest).toContain('npm install -g @openai/codex'); + }); + + test('runtime mismatch: the supported minimum (1.4.2) and the floor (1.3.3) both FAIL in --check', () => { + const f = makeFixture(); + const untested = row(f, [], { STUB_BUN_VERSION: '1.3.14' }); + expect(untested.status).toBe(1); + expect(untested.rows.runtime.status).toBe('FAIL'); + expect(untested.rows.runtime.rest).toContain('below the supported minimum 1.4.2'); + expect(untested.rows.runtime.rest).toContain('(RUNTIME_BELOW_MINIMUM; '); + expect(untested.trailer).toBe('gstack: fail runtime'); + const old = row(f, [], { STUB_BUN_VERSION: '1.3.2' }); + expect(old.rows.runtime.rest).toContain('below the security floor 1.3.3'); + expect(old.trailer).toBe('gstack: fail runtime'); + }); + + test('bun removed from PATH: the doctor still reports, runtime FAILs, pins SKIPs with a reason, one trailer', () => { + const f = makeFixture(); + fs.rmSync(path.join(f.stub, 'bun')); + const r = row(f, []); + expect(r.status).toBe(1); + expect(r.rows.runtime.status).toBe('FAIL'); + expect(r.rows.runtime.rest).toContain('bun not found on PATH'); + expect(r.rows.pins).toEqual({ status: 'SKIP', rest: expect.stringContaining('no bun to evaluate the project pins') }); + expect(r.rows.claude.status).toBe('PASS'); + expect(r.trailer).toBe('gstack: fail runtime'); + expect(r.trailers).toBe(1); + }); + + test('project pins: --project reads the target repository, a range mismatch FAILs with its source, a match PASSes', () => { + const f = makeFixture(); + const project = path.join(f.base, 'project'); + write(path.join(project, 'package.json'), '{\n "engines": {\n "bun": ">=1.4.2"\n }\n}\n'); + const bad = row(f, ['--project', project], { STUB_BUN_VERSION: '1.3.14' }); + expect(bad.rows.pins.status).toBe('FAIL'); + expect(bad.rows.pins.rest).toContain('bun 1.3.14 outside engines.bun >=1.4.2 (package.json:3)'); + expect(bad.rows.pins.rest).toContain('(PROJECT_PIN_MISMATCH; '); + expect(bad.trailer).toBe('gstack: fail runtime,pins'); + const good = row(f, ['--project', project]); + expect(good.rows.pins).toEqual({ status: 'PASS', rest: `${project}: bun >=1.4.2 (engines.bun, package.json:3)` }); + const none = row(f, ['--project', f.work]); + expect(none.rows.pins.status).toBe('PASS'); + expect(none.rows.pins.rest).toContain('pins no runtime'); + const missing = row(f, ['--project', path.join(f.base, 'nope')]); + expect(missing.rows.pins.status).toBe('SKIP'); + }); + + test('project pins on Capy: the PROJECT_PIN_MISMATCH fix is the exact bootstrapper command, which installs a verified pinned Bun', () => { + const f = makeFixture(); + const project = path.join(f.base, 'project'); + write(path.join(project, 'package.json'), '{\n "engines": {\n "bun": ">=1.4.2"\n }\n}\n'); + const wrapper = '/drive/skills/gstack/scripts/install.sh'; + const capy = row(f, ['--project', project], { STUB_BUN_VERSION: '1.3.14', GSTACK_CAPY_WRAPPER_PATH: wrapper }); + expect(capy.rows.pins.status).toBe('FAIL'); + expect(capy.rows.pins.rest).toContain(`fix: bash ${wrapper} (installs the project's pinned Bun when it is a verified version: 1.4.2 1.4.3 1.4.4; otherwise it names the conflict), or change the pin in ${project}`); + expect(capy.rows.pins.rest).toContain('(PROJECT_PIN_MISMATCH; '); + const forSkill = row(f, ['--project', project, '--for', 'review'], { STUB_BUN_VERSION: '1.3.14', GSTACK_CAPY_WRAPPER_PATH: wrapper }); + expect(forSkill.rows.pins.rest).toContain(`fix: bash ${wrapper} --for review (installs`); + const plain = row(f, ['--project', project], { STUB_BUN_VERSION: '1.3.14' }); + expect(plain.rows.pins.rest).toContain('fix: install the pinned version for this platform'); + expect(plain.rows.pins.rest).not.toContain(wrapper); + }); + + test('project pins: a Node-only pin mismatch is a WARN (the trailer stays ok) that names the node it read and a fix that installs the pinned Node', () => { + const f = makeFixture(); + const project = path.join(f.base, 'project'); + write(path.join(project, '.github/workflows/native-locks.yml'), 'jobs:\n locks:\n steps:\n - uses: actions/setup-node@v4\n with:\n node-version: 24.18.0\n'); + script(path.join(f.stub, 'node'), ['echo v24.21.0']); + const wrapper = '/drive/skills/gstack/scripts/install.sh'; + const capy = row(f, ['--project', project, '--for', 'health'], { GSTACK_CAPY_WRAPPER_PATH: wrapper }); + expect(capy.rows.pins.status).toBe('WARN'); + expect(capy.rows.pins.rest).toContain(`node 24.21.0 outside CI 24.18.0 (.github/workflows/native-locks.yml:6); node read from ${bashPath(f.stub)}/node (node pin only; bun pins pass)`); + expect(capy.rows.pins.rest).toContain(`fix: bash ${wrapper} --for health (installs the project's pinned Node 24.18.0 into gstack's runtime store and launches it through the GSTACK_LAUNCH PATH), or change the pin in ${project}`); + expect(capy.rows.pins.rest).toContain('(PROJECT_NODE_PIN_UNMET; '); + expect(capy.trailer).toBe(`gstack: ok ${VERSION} for=health`); + const plain = row(f, ['--project', project, '--for', 'health']); + expect(plain.rows.pins.rest).toContain(`fix: nvm install 24.18.0 and put its bin directory first on PATH`); + // A Bun pin that also fails keeps the FAIL and its code: only a Node-only gap is advisory. + write(path.join(project, 'package.json'), '{\n "engines": {\n "bun": ">=1.4.2"\n }\n}\n'); + const both = row(f, ['--project', project, '--for', 'health'], { STUB_BUN_VERSION: '1.3.14' }); + expect(both.rows.pins.status).toBe('FAIL'); + expect(both.rows.pins.rest).toContain('(PROJECT_PIN_MISMATCH; '); + fs.rmSync(path.join(project, 'package.json')); + script(path.join(f.stub, 'node'), ['echo v24.18.0']); + expect(row(f, ['--project', project, '--for', 'health']).rows.pins.status).toBe('PASS'); + }); + + test('floor: min_gstack_version in the project\'s .gstack/ship-policy.json PASSes at or below the installed version, WARNs above it with the upgrade as the fix, and SKIPs when absent or null', () => { + const f = makeFixture(); + const project = path.join(f.base, 'project'); + fs.mkdirSync(project, { recursive: true }); + const none = row(f, ['--project', project]); + expect(none.rows.floor).toEqual({ status: 'SKIP', rest: `(${project} declares none (.gstack/ship-policy.json absent; set min_gstack_version there to require a gstack version))` }); + write(path.join(project, '.gstack/ship-policy.json'), '{\n "bump": "patch",\n "min_gstack_version": null\n}\n'); + expect(row(f, ['--project', project]).rows.floor).toEqual({ status: 'SKIP', rest: `(${project}/.gstack/ship-policy.json sets no min_gstack_version (null or absent))` }); + // A present value that is not a dotted version is named as such, never read as "absent"; the shell never evaluates it. + const marker = path.join(project, 'evaluated-marker'); + write(path.join(project, '.gstack/ship-policy.json'), `{\n "min_gstack_version": "1.2.3-rc1; $(touch ${marker})"\n}\n`); + expect(row(f, ['--project', project]).rows.floor).toEqual({ status: 'SKIP', rest: `(${project}/.gstack/ship-policy.json sets min_gstack_version to something other than a plain dotted version)` }); + expect(fs.existsSync(marker)).toBe(false); + write(path.join(project, '.gstack/ship-policy.json'), `{\n "min_gstack_version": "${VERSION}"\n}\n`); + const met = row(f, ['--project', project]); + expect(met.rows.floor).toEqual({ status: 'PASS', rest: `${VERSION} declared by ${project}/.gstack/ship-policy.json (installed ${VERSION})` }); + // Valid JSON may put the value on the line after its field (found by Codex in /ship's adversarial review). + write(path.join(project, '.gstack/ship-policy.json'), `{\n "bump": "patch",\n "min_gstack_version":\n "${VERSION}"\n}\n`); + expect(row(f, ['--project', project]).rows.floor.status).toBe('PASS'); + // The first occurrence is the top-level field: a later nested one never lowers the floor (found by Codex). + write(path.join(project, '.gstack/ship-policy.json'), '{\n "min_gstack_version": "999.0.0.0",\n "$schema": {"min_gstack_version": "0.0.0"}\n}\n'); + expect(row(f, ['--project', project]).rows.floor.status).toBe('WARN'); + write(path.join(project, '.gstack/ship-policy.json'), '{\n "min_gstack_version": "999.0.0.0"\n}\n'); + const wrapper = '/drive/skills/gstack/scripts/install.sh'; + const unmet = row(f, ['--project', project, '--for', 'health'], { GSTACK_CAPY_WRAPPER_PATH: wrapper }); + expect(unmet.status, unmet.out).toBe(0); + expect(unmet.rows.floor.status).toBe('WARN'); + expect(unmet.rows.floor.rest).toContain(`installed ${VERSION} is below the floor 999.0.0.0 that ${project}/.gstack/ship-policy.json declares (min_gstack_version); fix: bash ${wrapper} --for health (fast-forwards a clean checkout to origin and reinstalls), or `); + expect(unmet.rows.floor.rest).toContain('(PROJECT_FLOOR_UNMET; '); + expect(unmet.trailer).toBe(`gstack: ok ${VERSION} for=health`); + const plain = row(f, ['--project', project]); + expect(plain.rows.floor.rest).toContain('fix: '); + expect(plain.rows.floor.rest).toContain('bin/gstack-upgrade (fast-forwards to the remote default branch'); + expect(plain.rows.floor.rest).not.toContain(wrapper); + }); + + test('--revision: a matching VERSION passes, an unmet revision is never ok', () => { + const f = makeFixture(); + const ok = row(f, ['--revision', VERSION]); + expect(ok.status, ok.out).toBe(0); + expect(ok.rows.revision.status).toBe('PASS'); + const unmet = row(f, ['--revision', 'v9.9.9.9']); + expect(unmet.status).toBe(1); + expect(unmet.rows.revision.status).toBe('FAIL'); + expect(unmet.rows.revision.rest).toContain(`installed ${VERSION}`); + expect(unmet.rows.revision.rest).toContain('(requested v9.9.9.9)'); + expect(unmet.rows.revision.rest).toContain('(REVISION_UNMET; '); + expect(unmet.trailer).toBe('gstack: fail revision'); + }); + + test('browser skills: the bundle and a Chromium launch plus render are required; planning skills never launch it', () => { + const f = makeFixture(); + installCodex(f); + installPlaywrightStub(f, false); + const broken = row(f, ['--for', 'qa']); + expect(broken.status).toBe(1); + expect(broken.rows['browse-bundle'].status).toBe('PASS'); + expect(broken.rows.browser.status).toBe('FAIL'); + expect(broken.rows.browser.rest).toContain("Executable doesn't exist"); + expect(broken.rows.browser.rest).toContain(`fix: ${bashPath(f.root)}/bin/gstack-browser-ensure (BROWSER_UNAVAILABLE; `); + expect(broken.trailer).toBe('gstack: fail browser'); + installPlaywrightStub(f, true); + const ok = row(f, ['--for', 'qa']); + expect(ok.status, ok.out).toBe(0); + expect(ok.rows.browser).toEqual({ status: 'PASS', rest: 'Chromium 141.0.1 launched and rendered (node)' }); + fs.rmSync(path.join(f.root, BROWSE_BIN)); + const noBundle = row(f, ['--for', 'browse']); + expect(noBundle.rows['browse-bundle'].status).toBe('FAIL'); + expect(noBundle.rows['browse-bundle'].rest).toContain('fix: cd '); + installPlaywrightStub(f, false); + expect(row(f, ['--for', 'autoplan']).rows.browser.status).toBe('SKIP'); + }); + + test('cso: optional by default (SKIP, never silent), required by --for cso', () => { + const f = makeFixture(); + expect(row(f, []).rows.cso).toEqual({ status: 'SKIP', rest: '(optional; toolchain absent or helper not built)' }); + const r = row(f, ['--for', 'cso']); + expect(r.rows.cso.status).toBe('FAIL'); + expect(r.trailer).toBe('gstack: fail cso'); + for (const name of ['gstack-cso-launcher', 'gstack-cso-core']) write(path.join(f.root, 'bin', name + EXE), '#!/bin/sh\necho cso\n', 0o755); + expect(row(f, ['--for', 'cso']).rows.cso.status).toBe('PASS'); + }); + + test('state root: GSTACK_EPHEMERAL=1 reports durable=no in --check (PASS) and warns in the dashboard', () => { + const f = makeFixture(); + const r = row(f, [], { GSTACK_EPHEMERAL: '1' }); + expect(r.rows['state-root'].status).toBe('PASS'); + expect(r.rows['state-root'].rest).toContain(`${f.state} durable=no`); + expect(r.rows['state-root'].rest).toContain('GSTACK_EPHEMERAL=1'); + expect(r.status, r.out).toBe(0); + const dash = doctor(f, [], { GSTACK_EPHEMERAL: '1' }); + expect(dash.row('state root').state).toBe('warn'); + expect(dash.row('state root').detail).toContain('durable=no'); + expect(dash.row('state root').fix).toContain('GSTACK_STATE_ROOT'); + expect(doctor(f).row('state root').detail).toContain('durable=yes'); + }); + + test('--json: the same rows as objects keyed by the component ids, ok/failed/required and the trailer', () => { + const f = makeFixture(); + installCodex(f); + const r = doctor(f, ['--json', '--for', 'autoplan'], { STUB_BUN_VERSION: '1.3.14' }); + expect(r.status).toBe(1); + const json = JSON.parse(r.out.trim().split('\n').at(-1)!); + expect(json.schema_version).toBe(1); + expect(json.revision).toBe(VERSION); + expect(json.for).toBe('autoplan'); + expect(json.ok).toBe(false); + expect(json.failed).toEqual(['runtime']); + expect(json.required).toEqual(['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'floor', 'wrapper']); + expect(json.rows.map((x: { id: string }) => x.id)).toEqual(['claude', 'codex', 'patch', 'codex-cli', 'browse-bundle', 'browser', 'cso', 'helpers', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'floor', 'wrapper']); + const runtime = json.rows.find((x: { id: string }) => x.id === 'runtime'); + expect(runtime.status).toBe('FAIL'); + expect(runtime.code).toBe('RUNTIME_BELOW_MINIMUM'); + expect(runtime.anchor).toBe('doctor-runtime-below-minimum'); + expect(runtime.fix).toContain('gstack-capy-install'); + expect(json.trailer).toBe('gstack: fail runtime'); + expect(r.out.trim().split('\n')).toHaveLength(1); + const ok = JSON.parse(doctor(f, ['--json']).out.trim()); + expect(ok.ok).toBe(true); + expect(ok.trailer).toBe(`gstack: ok ${VERSION} for=installed-hosts:claude,codex`); + }); + + test('usage: --for with --require, an unknown skill or component, and a flag without a value exit 2', () => { + const f = makeFixture(); + expect(doctor(f, ['--check', '--for', 'autoplan', '--require', 'claude']).status).toBe(2); + expect(doctor(f, ['--for', 'no-such-skill']).status).toBe(2); + expect(doctor(f, ['--require', 'claude,bogus']).status).toBe(2); + expect(doctor(f, ['--check', '--project']).status).toBe(2); + expect(doctor(f, ['--help']).out).toContain('--check'); + }); +}); diff --git a/test/gstack-doctor-launch.test.ts b/test/gstack-doctor-launch.test.ts new file mode 100644 index 000000000..747745832 --- /dev/null +++ b/test/gstack-doctor-launch.test.ts @@ -0,0 +1,403 @@ +// Split from test/gstack-doctor.test.ts: launch/CSO rows, launch Bun, wrapper drift, rescue refs and Windows bash. +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { useDoctorCleanup, REPO, BROWSE_BIN, EXE, OTHER_BINS, write, script, bashPath, makeFixture, doctor, type Fixture } from './helpers/doctor-fixture'; + +useDoctorCleanup(); + +// #2595/#2124 and #3071: compiled binaries by launch state, and CSO from the +// build-result record. A blocked binary is simulated by the application-control +// message reporters captured (#2595), which works on every OS. +describe('gstack-doctor launch and CSO rows', () => { + const BLOCKED = `#!/bin/sh\necho "Program 'x.exe' failed to run: An Application Control policy has blocked this file" >&2\nexit 1\n`; + const record = (f: Fixture, text: string) => write(path.join(f.root, 'bin/.gstack-cso-build-result'), text); + const cso = (f: Fixture) => { for (const name of ['gstack-cso-launcher', 'gstack-cso-core']) write(path.join(f.root, 'bin', name + EXE), '#!/bin/sh\necho cso\n', 0o755); }; + + test('healthy binaries are ok; a blocked browse says blocked, not "not built"', () => { + const f = makeFixture(); + let r = doctor(f); + expect(r.row('binaries').state).toBe('ok'); + expect(r.row('binaries').detail).toContain('(4 of 4)'); + write(path.join(f.root, BROWSE_BIN), BLOCKED, 0o755); + r = doctor(f); + expect(r.row('browse bundle').state).toBe('warn'); + expect(r.row('browse bundle').detail).toContain('built, but blocked at launch (Program'); + expect(r.row('browse bundle').detail).not.toContain('not built'); + expect(r.row('browse bundle').fix).toContain(process.platform === 'win32' ? 'Smart App Control' : 'the OS refused to execute it'); + expect(r.status).toBe(0); + }); + + test('blocked, broken and missing binaries are told apart', () => { + const f = makeFixture(); + write(path.join(f.root, OTHER_BINS[1]), BLOCKED, 0o755); + let r = doctor(f); + expect(r.row('binaries').detail).toContain('blocked at launch: design'); + expect(r.row('binaries').detail).toContain('unavailable: /design-consultation'); + write(path.join(f.root, OTHER_BINS[1]), '#!/bin/sh\necho ok\n', 0o755); + write(path.join(f.root, OTHER_BINS[2]), '#!/bin/sh\necho "usage" >&2\nexit 2\n', 0o755); + r = doctor(f); + expect(r.row('binaries').detail).toBe('launched but failed: pdf (exit 2: usage)'); + expect(r.row('binaries').fix).toBe(`cd ${bashPath(f.root)} && bun run build`); + fs.rmSync(path.join(f.root, OTHER_BINS[2])); + r = doctor(f); + expect(r.row('binaries').detail).toBe('not built: pdf'); + }); + + test('the CSO row reads the build-result record', () => { + const f = makeFixture(); + const none = doctor(f).row('cso'); + expect(none.state).toBe('not configured'); + expect(none.detail).toBe('native helper not built; /cso reports not assessed'); + expect(none.fix).toContain('./setup (it names any missing build prerequisite)'); + record(f, 'result=failed\nstage=build\nreason=exit 42\nrevision=1.2 (abc)\ninstalled=\nlauncher=no\ndiagnostic=/x/cso.log\n'); + let r = doctor(f); + expect(r.row('cso').state).toBe('warn'); + expect(r.row('cso').detail).toBe('unavailable: the build step failed (exit 42); /cso reports not assessed'); + expect(r.row('cso').fix).toContain('bun run build:cso && ./setup (log: /x/cso.log;'); + cso(f); + record(f, 'result=failed\nstage=publish\nreason=exit 1\nrevision=1.2 (abc)\ninstalled=1.1 (def)\nlauncher=yes\ndiagnostic=/x/cso.log\n'); + expect(doctor(f).row('cso').detail).toBe('publish step failed (exit 1) for 1.2 (abc); previous CSO kept: 1.1 (def)'); + record(f, 'result=ok\nstage=publish\nreason=committed\nrevision=1.2 (abc)\ninstalled=1.2 (abc)\nlauncher=yes\ndiagnostic=x\n'); + r = doctor(f); + expect(r.row('cso')).toEqual({ state: 'ok', detail: 'native helper 1.2 (abc)', fix: '' }); + expect(r.status).toBe(0); + }); +}); + +// Field wave lane A: the trailer agrees with the Bun workflows run (A3), a +// remote minimum fails the revision row (A2), and planning-only workflows do +// not fail on a repo test-pin mismatch (A3 amendment). +describe('gstack-doctor --check: launch Bun, supported minimum, planning-only pins', () => { + const VERSION = fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim(); + const check = (f: Fixture, args: string[], extra: Record = {}) => { + const r = doctor(f, ['--check', ...args], extra); + const lines = r.out.split('\n'); + const rows = Object.fromEntries(lines.filter(l => /^(PASS|FAIL|SKIP|WARN) /.test(l)).map(l => { + const m = l.match(/^(PASS|FAIL|SKIP|WARN) ([a-z-]+)(?:\s(.*))?$/)!; + return [m[2], { status: m[1], rest: m[3] ?? '' }]; + })); + return { status: r.status, out: r.out, rows, trailer: lines.filter(l => l.startsWith('gstack: ')).at(-1) ?? '' }; + }; + /** A verified Bun in $HOME/.bun/bin (off PATH) that answers --version and runs TypeScript. */ + const verifiedBun = (f: Fixture, version = '1.4.2') => + script(path.join(f.home, '.bun/bin/bun'), [`[ "$1" = --version ] && { echo "${version}"; exit 0; }`, `exec "${process.execPath}" "$@"`]); + + test('PATH Bun 1.3.14 with a verified 1.4.2 off PATH: runtime FAILs naming where it was found; with the launch PATH it PASSes', () => { + const f = makeFixture(); + verifiedBun(f); + const fresh = check(f, [], { STUB_BUN_VERSION: '1.3.14' }); + expect(fresh.rows.runtime.status).toBe('FAIL'); + const home = bashPath(f.home); + expect(fresh.rows.runtime.rest).toContain(`bun 1.4.2 found at ${home}/.bun/bin (not on PATH)`); + expect(fresh.rows.runtime.rest).toContain(`fix: export PATH="${home}/.bun/bin:$PATH" or prefix commands with the GSTACK_LAUNCH line`); + expect(fresh.rows.runtime.rest).toContain('(RUNTIME_BELOW_MINIMUM; '); + const launched = check(f, [], { STUB_BUN_VERSION: '1.3.14', PATH: `${f.home}/.bun/bin${path.delimiter}${f.env.PATH}` }); + expect(launched.rows.runtime.status, launched.out).toBe('PASS'); + expect(launched.rows.runtime.rest).toContain(`at ${home}/.bun/bin/bun`); + }); + + test('--min-revision: below FAILs with REVISION_BELOW_MINIMUM naming the upgrade command; at or above PASSes; unknown says so and never claims the minimum', () => { + const f = makeFixture(); + const below = check(f, ['--min-revision', '99.0.0.0']); + expect(below.status).toBe(1); + expect(below.rows.revision.status).toBe('FAIL'); + expect(below.rows.revision.rest).toContain(`installed ${VERSION}`); + expect(below.rows.revision.rest).toContain('below the supported minimum 99.0.0.0'); + expect(below.rows.revision.rest).toContain(`${bashPath(f.root)}/bin/gstack-upgrade`); + expect(below.rows.revision.rest).toContain('(REVISION_BELOW_MINIMUM; '); + expect(below.trailer).toBe('gstack: fail revision'); + const wrapped = check(f, ['--min-revision', '99.0.0.0'], { GSTACK_CAPY_WRAPPER_PATH: '/drive/skills/gstack/scripts/install.sh' }); + expect(wrapped.rows.revision.rest).toContain('fix: bash /drive/skills/gstack/scripts/install.sh (fast-forwards)'); + const at = check(f, ['--min-revision', VERSION]); + expect(at.rows.revision.status, at.out).toBe('PASS'); + expect(at.rows.revision.rest).toContain(`>= supported minimum ${VERSION}`); + const unknown = check(f, ['--min-revision', 'unknown']); + expect(unknown.rows.revision.status).toBe('PASS'); + expect(unknown.rows.revision.rest).toContain('remote minimum unknown (fetch failed)'); + expect(unknown.rows.revision.rest).not.toContain('>= supported minimum'); + }); + + test('planning-only workflows SKIP a repo test-pin mismatch; test-running workflows and --require keep pins required', () => { + const f = makeFixture(); + const project = path.join(f.base, 'project'); + write(path.join(project, '.bun-version'), '1.4.2\n'); + const planning = check(f, ['--for', 'autoplan', '--project', project], { STUB_BUN_VERSION: '1.4.3' }); + expect(planning.rows.pins.status, planning.out).toBe('SKIP'); + expect(planning.rows.pins.rest).toMatch(/^\(planning-only; repo pins .*1\.4\.2.*, launch bun 1\.4\.3\)$/); + for (const args of [['--for', 'review'], ['--for', 'ship'], ['--require', 'claude']]) { + const tests = check(f, [...args, '--project', project], { STUB_BUN_VERSION: '1.4.3' }); + expect(tests.rows.pins.status, args.join(' ')).toBe('FAIL'); + } + }); +}); + +// Plan A8/A-h: the Capy skill copy. Active-wrapper provenance decides; without +// it the Drive copies do, failing only when every copy is stale. The printed +// workaround (the checkout's own wrapper) certifies with the stale Drive copy +// left unchanged. Plan A5: a drifted resolution is a runtime FAIL. +describe('gstack-doctor --check: wrapper and runtime drift', () => { + const posixPathTest = process.platform === 'win32' ? test.skip : test; + const PRE_BOOTSTRAPPER = '#!/usr/bin/env bash\n# gstack Capy skill installer (pre-bootstrapper)\nset -u\nroot="${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}"\nexec "$root/bin/gstack-capy-install" "$@"\n'; + const parse = (f: Fixture, args: string[], extra: Record = {}) => { + const r = doctor(f, ['--check', ...args], extra); + const line = (id: string) => r.out.split('\n').find(l => new RegExp(`^(PASS|FAIL|SKIP|WARN) ${id}\\b`).test(l)) ?? ''; + return { status: r.status, out: r.out, line, trailer: r.out.split('\n').filter(l => l.startsWith('gstack: ')).at(-1) ?? '' }; + }; + const drive = (f: Fixture, scope: string, text: string) => { const file = path.join(f.home, '.capy/drive', scope, 'skills/gstack/scripts/install.sh'); write(file, text); return file; }; + const withSupport = (f: Fixture, min = 1) => write(path.join(f.root, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.0.0\nGSTACK_CAPY_MIN_WRAPPER=${min}\n`); + const current = fs.readFileSync(path.join(REPO, 'capy/skill/gstack/scripts/install.sh'), 'utf8'); + const currentRev = current.match(/^GSTACK_CAPY_WRAPPER=(\d+)$/m)![1]; + + posixPathTest('direct doctor run: a pre-bootstrapper Drive copy (no revision line) fails wrapper with both fixes; a current copy is not flagged', () => { + const f = makeFixture(); + withSupport(f); + const stale = drive(f, 'user-garry-tan', PRE_BOOTSTRAPPER); + const r = parse(f, []); + expect(r.status).toBe(1); + expect(r.trailer).toBe('gstack: fail wrapper'); + const row = r.line('wrapper'); + expect(row).toContain(`FAIL wrapper — every Capy skill copy is stale (minimum wrapper 1): ${stale} (wrapper 0); fix: bash ${f.root}/capy/skill/gstack/scripts/install.sh --check (this session); owner: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes`); + expect(row).toContain('(CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)'); + write(stale, current); + const fresh = parse(f, []); + expect(fresh.line('wrapper')).toBe(`PASS wrapper Drive copy ${stale} (wrapper ${currentRev}) (minimum 1)`); + expect(fresh.status, fresh.out).toBe(0); + }); + + posixPathTest('direct doctor run: a bootstrapper Drive copy below the minimum still works, so it is an owner-action WARN with an ok trailer (only a pre-bootstrapper copy fails)', () => { + const f = makeFixture(); + withSupport(f, Number(currentRev) + 1); + const old = drive(f, 'user-garry-tan', current); + const r = parse(f, []); + expect(r.status, r.out).toBe(0); + expect(r.trailer).toMatch(/^gstack: ok /); + expect(r.line('wrapper')).toBe(`WARN wrapper — every Capy skill copy is below the minimum wrapper ${Number(currentRev) + 1} (it still works): ${old} (wrapper ${currentRev}); owner action: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes (CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)`); + const json = JSON.parse(doctor(f, ['--check', '--json']).out); + expect(json.ok).toBe(true); + expect(json.rows.find((x: { id: string }) => x.id === 'wrapper')).toMatchObject({ status: 'WARN', code: 'CAPY_WRAPPER_STALE' }); + // A pre-bootstrapper copy beside it keeps the FAIL: that copy cannot hand over at all. + drive(f, 'org', PRE_BOOTSTRAPPER); + expect(parse(f, []).trailer).toBe('gstack: fail wrapper'); + }); + + posixPathTest('two Drive copies, one current: PASS naming the stale one; GSTACK_CAPY_WRAPPER_PATH alone decides when set without a revision', () => { + const f = makeFixture(); + withSupport(f); + const stale = drive(f, 'org', PRE_BOOTSTRAPPER); + const good = drive(f, 'user', current); + const r = parse(f, []); + expect(r.line('wrapper')).toBe(`PASS wrapper Drive copy ${good} (wrapper ${currentRev}) (minimum 1); stale copies: ${stale} (wrapper 0)`); + expect(parse(f, [], { GSTACK_CAPY_WRAPPER_PATH: stale }).trailer).toBe('gstack: fail wrapper'); + }); + + posixPathTest('the printed workaround certifies: a current active wrapper passes with the stale Drive copy unchanged (WARN, owner action); a stale active wrapper is a WARN too', () => { + const f = makeFixture(); + withSupport(f); + const stale = drive(f, 'user-garry-tan', PRE_BOOTSTRAPPER); + const checkoutCopy = path.join(f.root, 'capy/skill/gstack/scripts/install.sh'); + const r = parse(f, [], { GSTACK_CAPY_WRAPPER_REV: '1', GSTACK_CAPY_WRAPPER_PATH: checkoutCopy }); + expect(r.status, r.out).toBe(0); + expect(r.trailer).toMatch(/^gstack: ok /); + expect(r.line('wrapper')).toBe(`WARN wrapper — active wrapper 1 (${checkoutCopy}) is current; stale Drive copies (minimum 1): ${stale} (wrapper 0); owner action: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes (CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)`); + expect(fs.readFileSync(stale, 'utf8')).toBe(PRE_BOOTSTRAPPER); + const json = JSON.parse(doctor(f, ['--check', '--json'], { GSTACK_CAPY_WRAPPER_REV: '1', GSTACK_CAPY_WRAPPER_PATH: checkoutCopy }).out); + expect(json.ok).toBe(true); + expect(json.rows.find((x: { id: string }) => x.id === 'wrapper')).toMatchObject({ status: 'WARN', code: 'CAPY_WRAPPER_STALE' }); + withSupport(f, 2); + const old = parse(f, [], { GSTACK_CAPY_WRAPPER_REV: '1', GSTACK_CAPY_WRAPPER_PATH: stale }); + // Integration item 2: an active copy below the minimum still works, so it + // is an owner-action WARN and the trailer stays ok. + expect(old.status, old.out).toBe(0); + expect(old.trailer).toMatch(/^gstack: ok /); + expect(old.line('wrapper')).toBe(`WARN wrapper — the active Capy skill copy ${stale} is wrapper 1, minimum 2 (it still works); owner action: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes (CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)`); + }); + + test('a drifted Bun resolution is a runtime FAIL carrying the installer\'s detail, fix and BUN_RESOLUTION_DRIFT', () => { + const f = makeFixture(); + const r = parse(f, [], { GSTACK_DOCTOR_RUNTIME_DRIFT: 'recorded bun 1.4.3 at /s/bun (home), now no eligible bun; changed: the recorded binary is gone', GSTACK_DOCTOR_RUNTIME_DRIFT_FIX: 'bash /d/install.sh --for review' }); + expect(r.trailer).toBe('gstack: fail runtime'); + expect(r.line('runtime')).toBe('FAIL runtime — bun resolution drifted: recorded bun 1.4.3 at /s/bun (home), now no eligible bun; changed: the recorded binary is gone; fix: bash /d/install.sh --for review (BUN_RESOLUTION_DRIFT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#bun-resolution-drift)'); + }); + + posixPathTest('the runtime row shows gstack\'s bun shim and where it resolves from here, or names a foreign ~/.local/bin/bun it left alone', () => { + const f = makeFixture(); + const shim = path.join(f.home, '.local/bin/bun'); + write(shim, '#!/bin/sh\n# gstack-bun-shim: gstack-owned; delete this file to remove it\n[ "$GSTACK_BUN_SHIM_EXPLAIN" = 1 ] && echo "gstack bun shim: project=none record=none chose=/store/1.4.3/bun rule=default" >&2\necho 1.4.3\n', 0o755); + expect(parse(f, []).line('runtime')).toContain(`; shim ${shim} -> /store/1.4.3/bun`); + write(shim, '#!/bin/sh\necho mine\n', 0o755); + expect(parse(f, []).line('runtime')).toContain(`; ${shim} is not gstack's shim (no marker), left untouched: launch with the GSTACK_LAUNCH line`); + expect(parse(f, [], { GSTACK_BUN_INSTALL_VERSION: '1.4.2' }).line('runtime')).toContain('; install version 1.4.2 from GSTACK_BUN_INSTALL_VERSION'); + }); + + test('A-a: the Capy skill\'s dispatch examples (fail revision, fail runtime,pins, fail pins) match the real failure heads, unchanged', () => { + const skill = fs.readFileSync(path.join(REPO, 'capy/skill/gstack/SKILL.md'), 'utf8'); + const step = skill.split('\n').find(l => l.includes('If the trailer names `revision`, `runtime` or `pins`'))!; + const examples = [...step.matchAll(/`(gstack: fail [^`]+)`/g)].map(m => m[1]); + expect(examples).toEqual(['gstack: fail revision …', 'gstack: fail runtime,pins', 'gstack: fail pins']); + const f = makeFixture(); + const project = path.join(f.base, 'project'); + write(path.join(project, '.bun-version'), '1.4.2\n'); + const real: Record = { + revision: parse(f, ['--require', 'claude', '--min-revision', '99.0.0.0']).trailer, + 'runtime,pins': parse(f, ['--require', 'claude', '--project', project], { STUB_BUN_VERSION: '1.3.14' }).trailer, + pins: parse(f, ['--require', 'claude', '--project', project], { STUB_BUN_VERSION: '1.4.3' }).trailer, + }; + for (const example of examples) { + const ids = example.slice('gstack: fail '.length).replace(/ …$/, ''); + const head = real[ids]; + expect(head === `gstack: fail ${ids}` || head.startsWith(`gstack: fail ${ids} — `), `${example} vs ${head}`).toBe(true); + expect(head.match(/^gstack: fail ([a-z,-]+)/)?.[1].split(',')).toEqual(ids.split(',')); + } + }); +}); + +// Lane F item D: the plain report lists rescue refs with their commands, warns +// about a CLAUDE.md gstack section that enumerates skills (#3169), and on +// Windows names the bash gstack's TypeScript hands its scripts to. GSTACK_DOCTOR_PLATFORM +// injects the platform and GSTACK_DOCTOR_BASH_PROBE the discovery inputs, so +// every case runs on every OS. +describe('gstack-doctor: rescue refs, CLAUDE.md skill lists, Windows bash', () => { + const git = (f: Fixture, args: string[], extra: Record = {}) => { + const r = spawnSync('git', args, { + cwd: f.root, encoding: 'utf8', timeout: 20_000, + env: { ...process.env, HOME: f.home, GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com', GIT_COMMITTER_NAME: 'fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com', ...extra }, + }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return r.stdout.trim(); + }; + /** A git install with one rescue commit per entry: a user edit on top of HEAD, committed on the given date. */ + const withRescues = (f: Fixture, rescues: Array<{ date: string; unmerged?: boolean }>) => { + git(f, ['init', '-q']); + git(f, ['add', '-A']); + git(f, ['commit', '-qm', 'release']); + const index = path.join(f.base, 'rescue.idx'); + return rescues.map(({ date, unmerged }, i) => { + const blob = spawnSync('git', ['hash-object', '-w', '--stdin'], { cwd: f.root, input: `edit ${i}\n`, encoding: 'utf8', timeout: 20_000 }).stdout.trim(); + git(f, ['read-tree', 'HEAD'], { GIT_INDEX_FILE: index }); + git(f, ['update-index', '--add', '--cacheinfo', `100644,${blob},local-edit-${i}.txt`], { GIT_INDEX_FILE: index }); + const tree = git(f, ['write-tree'], { GIT_INDEX_FILE: index }); + const message = `gstack rescue: local edits that conflicted with abc\n${unmerged ? '\nNeeds manual resolution: the install had unmerged files; their captured\ncontent may contain conflict markers.\n' : ''}`; + const oid = spawnSync('git', ['commit-tree', tree, '-p', 'HEAD'], { + cwd: f.root, input: message, encoding: 'utf8', timeout: 20_000, + env: { ...process.env, HOME: f.home, GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'gstack rescue', GIT_AUTHOR_EMAIL: 'gstack@localhost', GIT_COMMITTER_NAME: 'gstack rescue', GIT_COMMITTER_EMAIL: 'gstack@localhost', GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }, + }).stdout.trim(); + git(f, ['update-ref', `refs/gstack/rescue/${oid}`, oid]); + return `refs/gstack/rescue/${oid}`; + }); + }; + const rescueRefs = (f: Fixture) => git(f, ['for-each-ref', '--format=%(refname)', 'refs/gstack/rescue/']).split('\n').filter(Boolean); + const commandFor = (out: string, label: 'inspect' | 'delete', ref: string) => + out.split('\n').map(l => l.trim()).find(l => l.startsWith(`${label}:`) && l.includes(`'${ref}'`))?.replace(/^[a-z]+:\s+/, '') ?? ''; + + test('rescue refs: two refs are listed newest first with their dates; the printed inspect command shows the edit and the printed delete command removes the ref', () => { + const f = makeFixture(); + const [older, newer] = withRescues(f, [{ date: '2026-10-01T12:00:00Z' }, { date: '2026-10-14T08:30:00Z', unmerged: true }]); + const r = doctor(f); + const row = r.row('rescue refs'); + expect(row.state, r.out).toBe('warn'); + expect(row.detail).toContain(`2 in ${bashPath(f.root)}`); + expect(row.fix).toContain('#auto-update-rescued-edits'); + const listing = r.out.split('\n').map(l => l.trim()); + expect(listing.findIndex(l => l.startsWith(newer))).toBeLessThan(listing.findIndex(l => l.startsWith(older))); + expect(listing).toContain(`${older} (2026-10-01)`); + expect(listing).toContain(`${newer} (2026-10-14, needs manual resolution)`); + const q = `'${bashPath(f.root)}'`; + expect(commandFor(r.out, 'inspect', older)).toBe(`git -C ${q} diff '${older}^' '${older}'`); + expect(commandFor(r.out, 'delete', older)).toBe(`git -C ${q} update-ref -d '${older}'`); + const shown = spawnSync('bash', ['-c', commandFor(r.out, 'inspect', older)], { cwd: f.work, encoding: 'utf8', timeout: 20_000 }); + expect(shown.status, shown.stderr).toBe(0); + expect(shown.stdout).toContain('+edit 0'); + const deleted = spawnSync('bash', ['-c', commandFor(r.out, 'delete', older)], { cwd: f.work, encoding: 'utf8', timeout: 20_000 }); + expect(deleted.status, deleted.stderr).toBe(0); + expect(rescueRefs(f)).toEqual([newer]); + expect(doctor(f).row('rescue refs').detail).toContain(`1 in ${bashPath(f.root)}`); + spawnSync('bash', ['-c', commandFor(r.out, 'delete', newer)], { cwd: f.work, encoding: 'utf8', timeout: 20_000 }); + const none = doctor(f); + expect(none.row('rescue refs')).toEqual({ state: 'ok', detail: `none in ${bashPath(f.root)}`, fix: '' }); + expect(none.status, none.out).toBe(0); + }); + + const LISTING = '## gstack\nUse /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools.\nAvailable skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review,\n/design-consultation, /design-shotgun, /design-html, /review, /ship, /qa, /browse, /retro, /investigate.\n'; + const SHORT = '## gstack\nUse /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools.\n'; + const replacement = (out: string) => out.split('\n').filter(l => l.startsWith(' '.repeat(34))).map(l => l.slice(34)); + + test('CLAUDE.md: a gstack section that enumerates skills warns with the short replacement block, for ~/.claude/CLAUDE.md and the project CLAUDE.md', () => { + const f = makeFixture(); + const user = path.join(f.home, '.claude/CLAUDE.md'); + write(user, `# Me\n\n${LISTING}\n## Other\ntext\n`); + const r = doctor(f); + const row = r.row('CLAUDE.md'); + expect(row.state, r.out).toBe('warn'); + expect(row.detail).toContain(`${bashPath(f.home)}/.claude/CLAUDE.md lists 13 skills`); + expect(row.fix).toContain('#claude-md-skill-list'); + expect(fs.readFileSync(path.join(REPO, 'docs/troubleshooting.md'), 'utf8')).toContain(''); + expect(replacement(r.out)).toEqual(['## gstack', 'Use /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools.']); + expect(r.status, r.out).toBe(0); + write(user, SHORT); + write(path.join(f.work, 'CLAUDE.md'), `# Project\n\n## gstack (recommended)\nskills: /office-hours /plan-ceo-review /plan-eng-review /review /ship /qa /browse /retro /investigate\n`); + const project = doctor(f).row('CLAUDE.md'); + expect(project.state).toBe('warn'); + expect(project.detail).toContain(`${bashPath(f.work)}/CLAUDE.md lists 9 skills`); + expect(project.detail).not.toContain('.claude/CLAUDE.md'); + }); + + test('CLAUDE.md: the short block, team-init\'s block, a separate Skill routing section and paths do not warn; no CLAUDE.md does not warn', () => { + const f = makeFixture(); + const none = doctor(f).row('CLAUDE.md'); + expect(none.state).toBe('ok'); + expect(none.fix).toBe(''); + write(path.join(f.home, '.claude/CLAUDE.md'), SHORT); + write(path.join(f.work, 'CLAUDE.md'), [ + '## gstack (recommended)', + 'Using gstack skills: After install, skills like /qa, /ship, /review, /investigate,', + 'and /browse are available. Use the install path ~/.claude/skills/gstack/bin/gstack-config /usr/local/bin/bun /opt/a/b /x/y /z/w.', + '', + '## Skill routing', + '- Product ideas → invoke /office-hours', + '- Strategy → invoke /plan-ceo-review', + '- Architecture → invoke /plan-eng-review', + '- Design → invoke /design-consultation or /plan-design-review', + '- Review → invoke /autoplan, /investigate, /qa, /review, /ship', + '', + ].join('\n')); + const r = doctor(f); + expect(r.row('CLAUDE.md').state, r.out).toBe('ok'); + expect(replacement(r.out)).toEqual([]); + }); + + /** A JSON probe file: the discovery inputs resolveWindowsBash takes, in test/fixtures/windows-bash-resolution.json's shape. */ + const probe = (f: Fixture, inputs: Record) => { const file = path.join(f.base, 'bash-probe.json'); write(file, JSON.stringify(inputs)); return file; }; + const GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'; + + test('windows bash: on Windows the row names the resolved bash and what chose it', () => { + const f = makeFixture(); + const found = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: [GIT_BASH], gitExecPath: 'C:/Program Files/Git/mingw64/libexec/git-core' }) }); + expect(found.row('windows bash'), found.out).toEqual({ state: 'ok', detail: `${GIT_BASH} (chosen by git --exec-path)`, fix: '' }); + const override = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', GSTACK_GIT_BASH: 'D:\\tools\\bash.exe', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: ['D:/tools/bash.exe', GIT_BASH] }) }); + expect(override.row('windows bash').detail).toBe('D:/tools/bash.exe (chosen by GSTACK_GIT_BASH)'); + }); + + test('windows bash: GSTACK_GIT_BASH and a different deprecated GSTACK_BASH warn naming both', () => { + const f = makeFixture(); + const differ = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', GSTACK_GIT_BASH: 'D:/a/bash.exe', GSTACK_BASH: 'D:/b/bash.exe', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: ['D:/a/bash.exe', 'D:/b/bash.exe'] }) }); + expect(differ.row('windows bash').state).toBe('warn'); + expect(differ.row('windows bash').detail).toContain('D:/a/bash.exe (chosen by GSTACK_GIT_BASH)'); + expect(differ.row('windows bash').detail).toContain('GSTACK_GIT_BASH (D:/a/bash.exe) and the deprecated GSTACK_BASH (D:/b/bash.exe) differ'); + }); + + test('windows bash: a refusal fails with BASH_UNAVAILABLE naming GSTACK_GIT_BASH; off Windows there is no row', () => { + const f = makeFixture(); + const refused = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: [], gitExecPath: null, which: { bash: 'C:/Windows/System32/bash.exe' } }) }); + const row = refused.row('windows bash'); + expect(row.state, refused.out).toBe('fail'); + expect(row.detail).toContain('(BASH_UNAVAILABLE)'); + expect(row.detail).toContain('PATH C:/Windows/System32/bash.exe: WSL launcher, not Git Bash'); + expect(row.detail).toContain(`install path ${GIT_BASH}: not found`); + expect(row.fix).toContain('GSTACK_GIT_BASH'); + expect(row.fix).toContain('#bash-unavailable'); + expect(refused.status).toBe(1); + expect(doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'posix' }).out).not.toContain('windows bash'); + }); +}); diff --git a/test/gstack-doctor.test.ts b/test/gstack-doctor.test.ts index 3d53a67ce..61a680b40 100644 --- a/test/gstack-doctor.test.ts +++ b/test/gstack-doctor.test.ts @@ -8,128 +8,13 @@ * bun/codex/claude on PATH and an explicit HOME. Scripts run through explicit * bash argv, so the file is in the curated Windows lane. */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import { spawnSync } from 'child_process'; import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; +import { useDoctorCleanup, REPO, BROWSE_BIN, write, bashPath, probeCalls, iso, syncOn, makeFixture, doctor, type Fixture } from './helpers/doctor-fixture'; -// setup builds browse/dist/browse.exe on Windows (Git Bash), browse/dist/browse elsewhere. -const BROWSE_BIN = process.platform === 'win32' ? 'browse/dist/browse.exe' : 'browse/dist/browse'; -const EXE = process.platform === 'win32' ? '.exe' : ''; -const OTHER_BINS = ['browse/dist/find-browse', 'design/dist/design', 'make-pdf/dist/pdf', 'bin/gstack-global-discover'].map(rel => rel + EXE); - -const REPO = path.resolve(import.meta.dir, '..'); -const COPIED = [ - 'setup', 'VERSION', - 'bin/gstack-doctor', 'bin/gstack-codex-status.sh', 'bin/gstack-state-root.sh', 'bin/gstack-install-registry.sh', - 'bin/gstack-render-claude.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-hook-check', 'bin/gstack-config', - 'autoplan/SKILL.md', // its frontmatter registers the autoplan hook the hooks-row test breaks - 'bin/gstack-launch-probe.sh', - 'bin/gstack-doctor-check.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts', - 'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', // the windows bash row -]; -const bases: string[] = []; -afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); - -const write = (file: string, text: string, mode = 0o644) => { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.writeFileSync(file, text, { mode }); -}; -const script = (file: string, lines: string[]) => write(file, ['#!/usr/bin/env bash', ...lines].join('\n') + '\n', 0o755); - -/** The fixture's PATH entries minus every directory holding a real bun, codex or claude. */ -function hostPath(base: string): string { - const names = ['bun', 'codex', 'claude']; - const exts = process.platform === 'win32' ? ['', '.exe', '.cmd', '.ps1'] : ['']; - return (process.env.PATH ?? process.env.Path ?? '').split(path.delimiter).filter(Boolean).map((dir, i) => { - if (!names.some(n => exts.some(e => fs.existsSync(path.join(dir, n + e))))) return dir; - const shim = path.join(base, `host-${i}`); - fs.mkdirSync(shim, { recursive: true }); - for (const name of fs.readdirSync(dir)) { - if (names.some(n => exts.some(e => name === n + e))) continue; - try { fs.symlinkSync(path.join(dir, name), path.join(shim, name)); } catch { /* unreadable entry */ } - } - return shim; - }).join(path.delimiter); -} - -interface Fixture { base: string; root: string; state: string; home: string; work: string; stub: string; probeLog: string; env: Record } - -function makeFixture(): Fixture { - const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-doctor-'))); - bases.push(base); - const root = path.join(base, 'gstack'); - const state = path.join(base, 'state'); - const home = path.join(base, 'home'); - const work = path.join(base, 'work'); - const stub = path.join(base, 'stub'); - const probeLog = path.join(base, 'probe-calls'); - for (const dir of [state, home, work, stub]) fs.mkdirSync(dir, { recursive: true }); - for (const rel of COPIED) { - fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); - fs.copyFileSync(path.join(REPO, rel), path.join(root, rel)); - fs.chmodSync(path.join(root, rel), fs.statSync(path.join(REPO, rel)).mode); - } - script(path.join(root, 'bin/gstack-codex-probe'), [ - 'echo "$*" >> "$DOCTOR_PROBE_LOG"', - 'case "$1" in', - ' select-model) [ "${STUB_SELECT_FAIL:-}" = 1 ] && { echo "gstack-codex-probe: cannot find its scripts dir" >&2; exit 1; }', - ' echo "CODEX_SEL: gpt-stub-5"; echo "CODEX_SEL_KIND: $2"; echo "CODEX_MODEL: gpt-stub-5 ($2; source: codex config.toml)" >&2 ;;', - ' check-auth) [ "${STUB_AUTH_FAIL:-}" = 1 ] && { echo AUTH_FAILED; exit 1; }; echo AUTH_OK ;;', - ' show-sandbox) echo "CODEX_SANDBOX: read-only" ;;', - ' check-sandbox|check-version) ;;', - ' probe-model) echo "CODEX_PROBE_STATE: ${STUB_PROBE_STATE:-ok}"; exit "${STUB_PROBE_EXIT:-0}" ;;', - ' *) exit 64 ;;', - 'esac', - ]); - const listed = spawnSync('bash', [path.join(root, 'bin/gstack-hook-check'), '--list', root], { encoding: 'utf8', timeout: 20_000 }); - const hooks = listed.stdout.split('\n').map(line => line.trim()).filter(Boolean); - if (!hooks.includes('autoplan/bin/phase-publication-hook')) throw new Error(`gstack-hook-check --list missed the autoplan hook: ${listed.stdout}${listed.stderr}`); - for (const hook of hooks) write(path.join(root, hook), '#!/bin/sh\nexit 0\n', 0o755); - // The doctor launches each compiled binary's --version (#2595), so the - // stand-ins answer it like the real binaries do. - for (const rel of [BROWSE_BIN, ...OTHER_BINS]) write(path.join(root, rel), '#!/bin/sh\necho built\n', 0o755); - for (const rel of ['browse/dist/server-node.mjs', 'browse/dist/.build-complete']) write(path.join(root, rel), 'built\n', 0o755); - const version = fs.readFileSync(path.join(root, 'VERSION'), 'utf8').trim(); - write(path.join(state, 'installs.tsv'), - ['claude', 'global', '-', path.join(home, '.claude/skills'), root, root, version, 'false', 'committed', '2026-10-07T00:00:00Z'].join('\t') + '\n'); - // The stub answers --version; anything else (the pins row runs bin/gstack-runtime-pins.ts) goes to the real bun. - script(path.join(stub, 'bun'), ['[ "$1" = --version ] && { echo "${STUB_BUN_VERSION:-1.4.2}"; exit 0; }', `exec "${process.execPath}" "$@"`]); - script(path.join(stub, 'codex'), ['[ "$1" = --version ] && { echo "codex-cli 0.130.0"; exit 0; }', 'echo "unexpected codex call: $*" >> "$DOCTOR_PROBE_LOG"', 'exit 9']); - script(path.join(stub, 'claude'), ['[ "$1" = --version ] && { echo "2.1.292 (Claude Code)"; exit 0; }', 'exit 9']); - const env: Record = { - PATH: [stub, hostPath(base)].join(path.delimiter), - HOME: home, GSTACK_STATE_ROOT: state, TMPDIR: base, DOCTOR_PROBE_LOG: probeLog, - }; - if (process.env.SYSTEMROOT) env.SYSTEMROOT = process.env.SYSTEMROOT; - return { base, root, state, home, work, stub, probeLog, env }; -} - -function doctor(f: Fixture, args: string[] = [], extra: Record = {}) { - const r = spawnSync('bash', [path.join(f.root, 'bin/gstack-doctor'), ...args], { cwd: f.work, encoding: 'utf8', env: { ...f.env, ...extra }, timeout: 60_000 }); - const out = `${r.stdout}${r.stderr}`; - return { status: r.status, out, row: (name: string) => rowOf(r.stdout, name) }; -} - -/** The named row and its fix line: { state, detail, fix }. */ -function rowOf(stdout: string, name: string) { - const lines = stdout.split('\n'); - const i = lines.findIndex(l => l.slice(16, 32).trim() === name); - if (i < 0) throw new Error(`no row "${name}" in:\n${stdout}`); - const fix = lines[i + 1]?.startsWith(' '.repeat(32) + 'fix: ') ? lines[i + 1].slice(37) : ''; - return { state: lines[i].slice(0, 16).trim(), detail: lines[i].slice(32), fix }; -} - -/** How bash spells a directory (Git Bash on Windows prints /c/...). */ -const bashPath = (dir: string) => spawnSync('bash', ['-c', 'cd "$1" && pwd', '_', dir], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); -const probeCalls = (f: Fixture) => fs.existsSync(f.probeLog) ? fs.readFileSync(f.probeLog, 'utf8') : ''; -const iso = (secondsAgo: number) => new Date(Date.now() - secondsAgo * 1000).toISOString().replace(/\.\d+Z$/, 'Z'); -const syncOn = (f: Fixture, status: Record) => { - fs.mkdirSync(path.join(f.state, '.git'), { recursive: true }); - write(path.join(f.state, 'config.yaml'), 'artifacts_sync_mode: artifacts-only\n'); - write(path.join(f.state, '.brain-sync-status.json'), JSON.stringify(status).replace(/":/g, '": ').replace(/,"/g, ', "') + '\n'); -}; +useDoctorCleanup(); describe('gstack-doctor', () => { test('a healthy fixture: every core row ok, exit 0, and no paid probe', () => { @@ -392,764 +277,3 @@ describe('bug-report template and README (DX-2, DX-12)', () => { expect(section).toContain('`~/.claude/skills/gstack/bin/gstack-doctor`'); }); }); - -// #2595/#2124 and #3071: compiled binaries by launch state, and CSO from the -// build-result record. A blocked binary is simulated by the application-control -// message reporters captured (#2595), which works on every OS. -describe('gstack-doctor launch and CSO rows', () => { - const BLOCKED = `#!/bin/sh\necho "Program 'x.exe' failed to run: An Application Control policy has blocked this file" >&2\nexit 1\n`; - const record = (f: Fixture, text: string) => write(path.join(f.root, 'bin/.gstack-cso-build-result'), text); - const cso = (f: Fixture) => { for (const name of ['gstack-cso-launcher', 'gstack-cso-core']) write(path.join(f.root, 'bin', name + EXE), '#!/bin/sh\necho cso\n', 0o755); }; - - test('healthy binaries are ok; a blocked browse says blocked, not "not built"', () => { - const f = makeFixture(); - let r = doctor(f); - expect(r.row('binaries').state).toBe('ok'); - expect(r.row('binaries').detail).toContain('(4 of 4)'); - write(path.join(f.root, BROWSE_BIN), BLOCKED, 0o755); - r = doctor(f); - expect(r.row('browse bundle').state).toBe('warn'); - expect(r.row('browse bundle').detail).toContain('built, but blocked at launch (Program'); - expect(r.row('browse bundle').detail).not.toContain('not built'); - expect(r.row('browse bundle').fix).toContain(process.platform === 'win32' ? 'Smart App Control' : 'the OS refused to execute it'); - expect(r.status).toBe(0); - }); - - test('blocked, broken and missing binaries are told apart', () => { - const f = makeFixture(); - write(path.join(f.root, OTHER_BINS[1]), BLOCKED, 0o755); - let r = doctor(f); - expect(r.row('binaries').detail).toContain('blocked at launch: design'); - expect(r.row('binaries').detail).toContain('unavailable: /design-consultation'); - write(path.join(f.root, OTHER_BINS[1]), '#!/bin/sh\necho ok\n', 0o755); - write(path.join(f.root, OTHER_BINS[2]), '#!/bin/sh\necho "usage" >&2\nexit 2\n', 0o755); - r = doctor(f); - expect(r.row('binaries').detail).toBe('launched but failed: pdf (exit 2: usage)'); - expect(r.row('binaries').fix).toBe(`cd ${bashPath(f.root)} && bun run build`); - fs.rmSync(path.join(f.root, OTHER_BINS[2])); - r = doctor(f); - expect(r.row('binaries').detail).toBe('not built: pdf'); - }); - - test('the CSO row reads the build-result record', () => { - const f = makeFixture(); - const none = doctor(f).row('cso'); - expect(none.state).toBe('not configured'); - expect(none.detail).toBe('native helper not built; /cso reports not assessed'); - expect(none.fix).toContain('./setup (it names any missing build prerequisite)'); - record(f, 'result=failed\nstage=build\nreason=exit 42\nrevision=1.2 (abc)\ninstalled=\nlauncher=no\ndiagnostic=/x/cso.log\n'); - let r = doctor(f); - expect(r.row('cso').state).toBe('warn'); - expect(r.row('cso').detail).toBe('unavailable: the build step failed (exit 42); /cso reports not assessed'); - expect(r.row('cso').fix).toContain('bun run build:cso && ./setup (log: /x/cso.log;'); - cso(f); - record(f, 'result=failed\nstage=publish\nreason=exit 1\nrevision=1.2 (abc)\ninstalled=1.1 (def)\nlauncher=yes\ndiagnostic=/x/cso.log\n'); - expect(doctor(f).row('cso').detail).toBe('publish step failed (exit 1) for 1.2 (abc); previous CSO kept: 1.1 (def)'); - record(f, 'result=ok\nstage=publish\nreason=committed\nrevision=1.2 (abc)\ninstalled=1.2 (abc)\nlauncher=yes\ndiagnostic=x\n'); - r = doctor(f); - expect(r.row('cso')).toEqual({ state: 'ok', detail: 'native helper 1.2 (abc)', fix: '' }); - expect(r.status).toBe(0); - }); -}); - -// A1: `--check` is the one-line verdict a parent agent greps. Rows are -// `PASS | FAIL | SKIP ` from lib/doctor-components.ts; the trailer is -// `gstack: ok for=` (exit 0) or `gstack: fail ` (exit 1). -// Pinned on tokens, ids, exit codes and the JSON shape, not prose. -describe('gstack-doctor --check', () => { - const VERSION = fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim(); - const row = (f: Fixture, args: string[], extra: Record = {}) => { - const r = doctor(f, ['--check', ...args], extra); - const lines = r.out.split('\n'); - const rows = Object.fromEntries(lines.filter(l => /^(PASS|FAIL|SKIP|WARN) /.test(l)).map(l => { - const m = l.match(/^(PASS|FAIL|SKIP|WARN) ([a-z-]+)(?:\s(.*))?$/)!; - return [m[2], { status: m[1], rest: m[3] ?? '' }]; - })); - const trailer = lines.filter(l => l.startsWith('gstack: ')).at(-1) ?? ''; - const required = lines.find(l => l.startsWith('required: '))?.slice(10).split(' ') ?? []; - return { status: r.status, out: r.out, rows, trailer, required, trailers: lines.filter(l => l.startsWith('gstack: ')).length }; - }; - /** Register a Codex install from this checkout and render a review skill, with or without the Astra patch. */ - const installCodex = (f: Fixture, patch = true) => { - const dest = path.join(f.home, '.codex/skills'); - fs.appendFileSync(path.join(f.state, 'installs.tsv'), - ['codex', 'global', '-', dest, path.join(dest, 'gstack'), f.root, VERSION, 'true', 'committed', '2026-10-07T00:00:00Z'].join('\t') + '\n'); - fs.mkdirSync(path.join(dest, 'gstack'), { recursive: true }); - write(path.join(dest, 'gstack-review/SKILL.md'), `---\nname: gstack-review\n---\n# Review\n${patch ? '\n## Model-Specific Behavioral Patch (gpt-6-astra)\n' : ''}`); - return dest; - }; - const installPlaywrightStub = (f: Fixture, launches: boolean) => write(path.join(f.root, 'node_modules/playwright/index.js'), - launches - ? 'module.exports.chromium = { launch: async () => ({ version: () => "141.0.1", close: async () => {}, newPage: async () => ({ setContent: async () => {}, title: async () => "gstack smoke test" }) }) };' - : 'module.exports.chromium = { launch: async () => { throw new Error("browserType.launch: Executable doesn\'t exist"); } };'); - - test('healthy Claude + Codex installs: every required row PASS, exactly one trailer, exit 0, no paid probe', () => { - const f = makeFixture(); - installCodex(f); - const r = row(f, []); - expect(r.status, r.out).toBe(0); - expect(r.required).toEqual(['claude', 'codex', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'floor', 'wrapper']); - for (const id of r.required.filter(x => x !== 'pins' && x !== 'floor' && x !== 'wrapper')) expect(r.rows[id]?.status, `${id}\n${r.out}`).toBe('PASS'); - // The work dir is not a git repository, so there is no project to read pins or a floor from: SKIP with the reason, never silent. - expect(r.rows.pins).toEqual({ status: 'SKIP', rest: expect.stringContaining('no project') }); - expect(r.rows.floor).toEqual({ status: 'SKIP', rest: expect.stringContaining('no project to read .gstack/ship-policy.json from') }); - // No ~/.capy/drive skill copy and no active wrapper: not a Capy machine. - expect(r.rows.wrapper).toEqual({ status: 'SKIP', rest: expect.stringContaining('not a Capy machine') }); - expect(r.rows.patch.status).toBe('PASS'); - expect(r.rows['codex-cli'].status).toBe('PASS'); - expect(r.rows.browser).toEqual({ status: 'SKIP', rest: '(lazy; gstack-browser-ensure installs on first use)' }); - expect(r.rows.cso.status).toBe('SKIP'); - expect(r.trailer).toBe(`gstack: ok ${VERSION} for=installed-hosts:claude,codex`); - expect(r.trailers).toBe(1); - expect(r.rows.runtime.rest).toContain('floor 1.3.3, supported minimum 1.4.2'); - expect(r.rows.privacy.rest).toBe('telemetry=off artifacts_sync=off codex_reviews=enabled update_check=true'); - expect(r.rows['state-root'].rest).toContain('durable=yes'); - expect(probeCalls(f)).not.toContain('probe-model'); - }); - - // Value: protects=the --check state-root row names the variable that selected the root, after earlier rows read config; - // fails_when=an in-process config read reselects the root from the doctor's own GSTACK_STATE_ROOT; - // why_new=every other fixture sets GSTACK_STATE_ROOT itself, so a wrong "selected by" still reads GSTACK_STATE_ROOT; seam=none; - // no_claim=CLAUDE_PLUGIN_DATA and default selection (GSTACK_HOME stands in for every selector other than GSTACK_STATE_ROOT) - test('the state-root row names GSTACK_HOME when GSTACK_HOME selected the root, in text and JSON', () => { - const f = makeFixture(); - installCodex(f); - const byHome = { GSTACK_STATE_ROOT: '', GSTACK_HOME: f.state }; - const r = row(f, [], byHome); - expect(r.rows['state-root'].rest, r.out).toContain('(selected by GSTACK_HOME;'); - const json = JSON.parse(doctor(f, ['--json'], byHome).out); - expect(json.rows.find((x: { id: string }) => x.id === 'state-root').detail).toContain('(selected by GSTACK_HOME;'); - }); - - test('--for autoplan: healthy Capy machine with Claude subagents as the outside voice and no Codex CLI passes', () => { - const f = makeFixture(); - installCodex(f); - fs.rmSync(path.join(f.stub, 'codex')); - const r = row(f, ['--for', 'autoplan']); - expect(r.status, r.out).toBe(0); - expect(r.required).toEqual(['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'floor', 'wrapper']); - expect(r.rows.patch).toEqual({ status: 'PASS', rest: 'gpt-6-astra (gstack-review/SKILL.md)' }); - expect(r.rows['codex-cli'].status).toBe('SKIP'); - expect(r.rows['codex-cli'].rest).toContain('optional'); - expect(r.trailer).toBe(`gstack: ok ${VERSION} for=autoplan`); - // The same machine with a stale Codex render fails the patch row. - installCodex(f, false); - const noPatch = row(f, ['--for', 'autoplan']); - expect(noPatch.status).toBe(1); - expect(noPatch.rows.patch.status).toBe('FAIL'); - expect(noPatch.rows.patch.rest).toContain('fix: '); - expect(noPatch.rows.patch.rest).toContain('(COMPONENT_MISSING; '); - expect(noPatch.rows.patch.rest).toContain('--host codex --model'); - expect(noPatch.trailer).toBe('gstack: fail patch'); - }); - - test('--for autoplan: a hoisted Codex render carries the patch in its shared sections/preamble.md', () => { - const f = makeFixture(); - const dest = installCodex(f, false); - write(path.join(dest, 'gstack-review/sections/preamble.md'), '# gstack shared preamble 0123456789ab\n\n## Model-Specific Behavioral Patch (gpt-6-astra)\n'); - const r = row(f, ['--for', 'autoplan']); - expect(r.rows.patch, r.out).toEqual({ status: 'PASS', rest: 'gpt-6-astra (gstack-review/sections/preamble.md)' }); - }); - - test('an empty installed-host set is never ok: claude is required and FAILs', () => { - const f = makeFixture(); - write(path.join(f.state, 'installs.tsv'), ''); - const r = row(f, []); - expect(r.status).toBe(1); - expect(r.required).toContain('claude'); - expect(r.rows.claude.status).toBe('FAIL'); - expect(r.rows.claude.rest).toContain(`fix: cd ${bashPath(f.root)} && ./setup --host claude --no-prefix`); - expect(r.trailer).toBe('gstack: fail claude'); - expect(JSON.parse(doctor(f, ['--json']).out).for).toBe('installed-hosts:none(claude required)'); - }); - - test('wrong host: an install registered from another checkout does not count; --require names what is missing', () => { - const f = makeFixture(); - const other = path.join(f.base, 'other'); - write(path.join(f.state, 'installs.tsv'), ['codex', 'global', '-', path.join(f.home, '.codex/skills'), other, other, VERSION, 'false', 'committed', 'x'].join('\t') + '\n'); - const r = row(f, ['--require', 'codex,patch']); - expect(r.status).toBe(1); - expect(r.rows.codex.status).toBe('FAIL'); - expect(r.rows.patch.status).toBe('FAIL'); - expect(r.rows.claude.status).toBe('SKIP'); - expect(r.trailer).toBe('gstack: fail codex,patch'); - expect(JSON.parse(doctor(f, ['--json', '--require', 'codex,patch']).out).for).toBe('codex,patch'); - }); - - test('missing authentication: --for codex needs the CLI; absent, unauthenticated and present are told apart, --live probes once', () => { - const f = makeFixture(); - installCodex(f); - const unauthed = row(f, ['--for', 'codex'], { STUB_AUTH_FAIL: '1' }); - expect(unauthed.status).toBe(1); - expect(unauthed.rows['codex-cli'].status).toBe('FAIL'); - expect(unauthed.rows['codex-cli'].rest).toContain('not authenticated'); - expect(unauthed.rows['codex-cli'].rest).toContain('codex login --with-api-key'); - expect(unauthed.trailer).toBe('gstack: fail codex-cli'); - - const authed = row(f, ['--for', 'codex']); - expect(authed.status, authed.out).toBe(0); - expect(authed.rows['codex-cli'].rest).toContain('paid probe not run; add --live'); - expect(probeCalls(f)).not.toContain('probe-model'); - - const live = row(f, ['--for', 'codex', '--live']); - expect(live.status, live.out).toBe(0); - expect(live.rows['codex-cli'].rest).toContain('live probe ok'); - expect(probeCalls(f).match(/probe-model exec/g)?.length).toBe(1); - - fs.rmSync(path.join(f.stub, 'codex')); - const absent = row(f, ['--for', 'codex']); - expect(absent.rows['codex-cli'].status).toBe('FAIL'); - expect(absent.rows['codex-cli'].rest).toContain('npm install -g @openai/codex'); - }); - - test('runtime mismatch: the supported minimum (1.4.2) and the floor (1.3.3) both FAIL in --check', () => { - const f = makeFixture(); - const untested = row(f, [], { STUB_BUN_VERSION: '1.3.14' }); - expect(untested.status).toBe(1); - expect(untested.rows.runtime.status).toBe('FAIL'); - expect(untested.rows.runtime.rest).toContain('below the supported minimum 1.4.2'); - expect(untested.rows.runtime.rest).toContain('(RUNTIME_BELOW_MINIMUM; '); - expect(untested.trailer).toBe('gstack: fail runtime'); - const old = row(f, [], { STUB_BUN_VERSION: '1.3.2' }); - expect(old.rows.runtime.rest).toContain('below the security floor 1.3.3'); - expect(old.trailer).toBe('gstack: fail runtime'); - }); - - test('bun removed from PATH: the doctor still reports, runtime FAILs, pins SKIPs with a reason, one trailer', () => { - const f = makeFixture(); - fs.rmSync(path.join(f.stub, 'bun')); - const r = row(f, []); - expect(r.status).toBe(1); - expect(r.rows.runtime.status).toBe('FAIL'); - expect(r.rows.runtime.rest).toContain('bun not found on PATH'); - expect(r.rows.pins).toEqual({ status: 'SKIP', rest: expect.stringContaining('no bun to evaluate the project pins') }); - expect(r.rows.claude.status).toBe('PASS'); - expect(r.trailer).toBe('gstack: fail runtime'); - expect(r.trailers).toBe(1); - }); - - test('project pins: --project reads the target repository, a range mismatch FAILs with its source, a match PASSes', () => { - const f = makeFixture(); - const project = path.join(f.base, 'project'); - write(path.join(project, 'package.json'), '{\n "engines": {\n "bun": ">=1.4.2"\n }\n}\n'); - const bad = row(f, ['--project', project], { STUB_BUN_VERSION: '1.3.14' }); - expect(bad.rows.pins.status).toBe('FAIL'); - expect(bad.rows.pins.rest).toContain('bun 1.3.14 outside engines.bun >=1.4.2 (package.json:3)'); - expect(bad.rows.pins.rest).toContain('(PROJECT_PIN_MISMATCH; '); - expect(bad.trailer).toBe('gstack: fail runtime,pins'); - const good = row(f, ['--project', project]); - expect(good.rows.pins).toEqual({ status: 'PASS', rest: `${project}: bun >=1.4.2 (engines.bun, package.json:3)` }); - const none = row(f, ['--project', f.work]); - expect(none.rows.pins.status).toBe('PASS'); - expect(none.rows.pins.rest).toContain('pins no runtime'); - const missing = row(f, ['--project', path.join(f.base, 'nope')]); - expect(missing.rows.pins.status).toBe('SKIP'); - }); - - test('project pins on Capy: the PROJECT_PIN_MISMATCH fix is the exact bootstrapper command, which installs a verified pinned Bun', () => { - const f = makeFixture(); - const project = path.join(f.base, 'project'); - write(path.join(project, 'package.json'), '{\n "engines": {\n "bun": ">=1.4.2"\n }\n}\n'); - const wrapper = '/drive/skills/gstack/scripts/install.sh'; - const capy = row(f, ['--project', project], { STUB_BUN_VERSION: '1.3.14', GSTACK_CAPY_WRAPPER_PATH: wrapper }); - expect(capy.rows.pins.status).toBe('FAIL'); - expect(capy.rows.pins.rest).toContain(`fix: bash ${wrapper} (installs the project's pinned Bun when it is a verified version: 1.4.2 1.4.3 1.4.4; otherwise it names the conflict), or change the pin in ${project}`); - expect(capy.rows.pins.rest).toContain('(PROJECT_PIN_MISMATCH; '); - const forSkill = row(f, ['--project', project, '--for', 'review'], { STUB_BUN_VERSION: '1.3.14', GSTACK_CAPY_WRAPPER_PATH: wrapper }); - expect(forSkill.rows.pins.rest).toContain(`fix: bash ${wrapper} --for review (installs`); - const plain = row(f, ['--project', project], { STUB_BUN_VERSION: '1.3.14' }); - expect(plain.rows.pins.rest).toContain('fix: install the pinned version for this platform'); - expect(plain.rows.pins.rest).not.toContain(wrapper); - }); - - test('project pins: a Node-only pin mismatch is a WARN (the trailer stays ok) that names the node it read and a fix that installs the pinned Node', () => { - const f = makeFixture(); - const project = path.join(f.base, 'project'); - write(path.join(project, '.github/workflows/native-locks.yml'), 'jobs:\n locks:\n steps:\n - uses: actions/setup-node@v4\n with:\n node-version: 24.18.0\n'); - script(path.join(f.stub, 'node'), ['echo v24.21.0']); - const wrapper = '/drive/skills/gstack/scripts/install.sh'; - const capy = row(f, ['--project', project, '--for', 'health'], { GSTACK_CAPY_WRAPPER_PATH: wrapper }); - expect(capy.rows.pins.status).toBe('WARN'); - expect(capy.rows.pins.rest).toContain(`node 24.21.0 outside CI 24.18.0 (.github/workflows/native-locks.yml:6); node read from ${bashPath(f.stub)}/node (node pin only; bun pins pass)`); - expect(capy.rows.pins.rest).toContain(`fix: bash ${wrapper} --for health (installs the project's pinned Node 24.18.0 into gstack's runtime store and launches it through the GSTACK_LAUNCH PATH), or change the pin in ${project}`); - expect(capy.rows.pins.rest).toContain('(PROJECT_NODE_PIN_UNMET; '); - expect(capy.trailer).toBe(`gstack: ok ${VERSION} for=health`); - const plain = row(f, ['--project', project, '--for', 'health']); - expect(plain.rows.pins.rest).toContain(`fix: nvm install 24.18.0 and put its bin directory first on PATH`); - // A Bun pin that also fails keeps the FAIL and its code: only a Node-only gap is advisory. - write(path.join(project, 'package.json'), '{\n "engines": {\n "bun": ">=1.4.2"\n }\n}\n'); - const both = row(f, ['--project', project, '--for', 'health'], { STUB_BUN_VERSION: '1.3.14' }); - expect(both.rows.pins.status).toBe('FAIL'); - expect(both.rows.pins.rest).toContain('(PROJECT_PIN_MISMATCH; '); - fs.rmSync(path.join(project, 'package.json')); - script(path.join(f.stub, 'node'), ['echo v24.18.0']); - expect(row(f, ['--project', project, '--for', 'health']).rows.pins.status).toBe('PASS'); - }); - - test('floor: min_gstack_version in the project\'s .gstack/ship-policy.json PASSes at or below the installed version, WARNs above it with the upgrade as the fix, and SKIPs when absent or null', () => { - const f = makeFixture(); - const project = path.join(f.base, 'project'); - fs.mkdirSync(project, { recursive: true }); - const none = row(f, ['--project', project]); - expect(none.rows.floor).toEqual({ status: 'SKIP', rest: `(${project} declares none (.gstack/ship-policy.json absent; set min_gstack_version there to require a gstack version))` }); - write(path.join(project, '.gstack/ship-policy.json'), '{\n "bump": "patch",\n "min_gstack_version": null\n}\n'); - expect(row(f, ['--project', project]).rows.floor).toEqual({ status: 'SKIP', rest: `(${project}/.gstack/ship-policy.json sets no min_gstack_version (null or absent))` }); - // A present value that is not a dotted version is named as such, never read as "absent"; the shell never evaluates it. - const marker = path.join(project, 'evaluated-marker'); - write(path.join(project, '.gstack/ship-policy.json'), `{\n "min_gstack_version": "1.2.3-rc1; $(touch ${marker})"\n}\n`); - expect(row(f, ['--project', project]).rows.floor).toEqual({ status: 'SKIP', rest: `(${project}/.gstack/ship-policy.json sets min_gstack_version to something other than a plain dotted version)` }); - expect(fs.existsSync(marker)).toBe(false); - write(path.join(project, '.gstack/ship-policy.json'), `{\n "min_gstack_version": "${VERSION}"\n}\n`); - const met = row(f, ['--project', project]); - expect(met.rows.floor).toEqual({ status: 'PASS', rest: `${VERSION} declared by ${project}/.gstack/ship-policy.json (installed ${VERSION})` }); - // Valid JSON may put the value on the line after its field (found by Codex in /ship's adversarial review). - write(path.join(project, '.gstack/ship-policy.json'), `{\n "bump": "patch",\n "min_gstack_version":\n "${VERSION}"\n}\n`); - expect(row(f, ['--project', project]).rows.floor.status).toBe('PASS'); - // The first occurrence is the top-level field: a later nested one never lowers the floor (found by Codex). - write(path.join(project, '.gstack/ship-policy.json'), '{\n "min_gstack_version": "999.0.0.0",\n "$schema": {"min_gstack_version": "0.0.0"}\n}\n'); - expect(row(f, ['--project', project]).rows.floor.status).toBe('WARN'); - write(path.join(project, '.gstack/ship-policy.json'), '{\n "min_gstack_version": "999.0.0.0"\n}\n'); - const wrapper = '/drive/skills/gstack/scripts/install.sh'; - const unmet = row(f, ['--project', project, '--for', 'health'], { GSTACK_CAPY_WRAPPER_PATH: wrapper }); - expect(unmet.status, unmet.out).toBe(0); - expect(unmet.rows.floor.status).toBe('WARN'); - expect(unmet.rows.floor.rest).toContain(`installed ${VERSION} is below the floor 999.0.0.0 that ${project}/.gstack/ship-policy.json declares (min_gstack_version); fix: bash ${wrapper} --for health (fast-forwards a clean checkout to origin and reinstalls), or `); - expect(unmet.rows.floor.rest).toContain('(PROJECT_FLOOR_UNMET; '); - expect(unmet.trailer).toBe(`gstack: ok ${VERSION} for=health`); - const plain = row(f, ['--project', project]); - expect(plain.rows.floor.rest).toContain('fix: '); - expect(plain.rows.floor.rest).toContain('bin/gstack-upgrade (fast-forwards to the remote default branch'); - expect(plain.rows.floor.rest).not.toContain(wrapper); - }); - - test('--revision: a matching VERSION passes, an unmet revision is never ok', () => { - const f = makeFixture(); - const ok = row(f, ['--revision', VERSION]); - expect(ok.status, ok.out).toBe(0); - expect(ok.rows.revision.status).toBe('PASS'); - const unmet = row(f, ['--revision', 'v9.9.9.9']); - expect(unmet.status).toBe(1); - expect(unmet.rows.revision.status).toBe('FAIL'); - expect(unmet.rows.revision.rest).toContain(`installed ${VERSION}`); - expect(unmet.rows.revision.rest).toContain('(requested v9.9.9.9)'); - expect(unmet.rows.revision.rest).toContain('(REVISION_UNMET; '); - expect(unmet.trailer).toBe('gstack: fail revision'); - }); - - test('browser skills: the bundle and a Chromium launch plus render are required; planning skills never launch it', () => { - const f = makeFixture(); - installCodex(f); - installPlaywrightStub(f, false); - const broken = row(f, ['--for', 'qa']); - expect(broken.status).toBe(1); - expect(broken.rows['browse-bundle'].status).toBe('PASS'); - expect(broken.rows.browser.status).toBe('FAIL'); - expect(broken.rows.browser.rest).toContain("Executable doesn't exist"); - expect(broken.rows.browser.rest).toContain(`fix: ${bashPath(f.root)}/bin/gstack-browser-ensure (BROWSER_UNAVAILABLE; `); - expect(broken.trailer).toBe('gstack: fail browser'); - installPlaywrightStub(f, true); - const ok = row(f, ['--for', 'qa']); - expect(ok.status, ok.out).toBe(0); - expect(ok.rows.browser).toEqual({ status: 'PASS', rest: 'Chromium 141.0.1 launched and rendered (node)' }); - fs.rmSync(path.join(f.root, BROWSE_BIN)); - const noBundle = row(f, ['--for', 'browse']); - expect(noBundle.rows['browse-bundle'].status).toBe('FAIL'); - expect(noBundle.rows['browse-bundle'].rest).toContain('fix: cd '); - installPlaywrightStub(f, false); - expect(row(f, ['--for', 'autoplan']).rows.browser.status).toBe('SKIP'); - }); - - test('cso: optional by default (SKIP, never silent), required by --for cso', () => { - const f = makeFixture(); - expect(row(f, []).rows.cso).toEqual({ status: 'SKIP', rest: '(optional; toolchain absent or helper not built)' }); - const r = row(f, ['--for', 'cso']); - expect(r.rows.cso.status).toBe('FAIL'); - expect(r.trailer).toBe('gstack: fail cso'); - for (const name of ['gstack-cso-launcher', 'gstack-cso-core']) write(path.join(f.root, 'bin', name + EXE), '#!/bin/sh\necho cso\n', 0o755); - expect(row(f, ['--for', 'cso']).rows.cso.status).toBe('PASS'); - }); - - test('state root: GSTACK_EPHEMERAL=1 reports durable=no in --check (PASS) and warns in the dashboard', () => { - const f = makeFixture(); - const r = row(f, [], { GSTACK_EPHEMERAL: '1' }); - expect(r.rows['state-root'].status).toBe('PASS'); - expect(r.rows['state-root'].rest).toContain(`${f.state} durable=no`); - expect(r.rows['state-root'].rest).toContain('GSTACK_EPHEMERAL=1'); - expect(r.status, r.out).toBe(0); - const dash = doctor(f, [], { GSTACK_EPHEMERAL: '1' }); - expect(dash.row('state root').state).toBe('warn'); - expect(dash.row('state root').detail).toContain('durable=no'); - expect(dash.row('state root').fix).toContain('GSTACK_STATE_ROOT'); - expect(doctor(f).row('state root').detail).toContain('durable=yes'); - }); - - test('--json: the same rows as objects keyed by the component ids, ok/failed/required and the trailer', () => { - const f = makeFixture(); - installCodex(f); - const r = doctor(f, ['--json', '--for', 'autoplan'], { STUB_BUN_VERSION: '1.3.14' }); - expect(r.status).toBe(1); - const json = JSON.parse(r.out.trim().split('\n').at(-1)!); - expect(json.schema_version).toBe(1); - expect(json.revision).toBe(VERSION); - expect(json.for).toBe('autoplan'); - expect(json.ok).toBe(false); - expect(json.failed).toEqual(['runtime']); - expect(json.required).toEqual(['claude', 'codex', 'patch', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'floor', 'wrapper']); - expect(json.rows.map((x: { id: string }) => x.id)).toEqual(['claude', 'codex', 'patch', 'codex-cli', 'browse-bundle', 'browser', 'cso', 'helpers', 'runtime', 'pins', 'state-root', 'privacy', 'cores', 'revision', 'floor', 'wrapper']); - const runtime = json.rows.find((x: { id: string }) => x.id === 'runtime'); - expect(runtime.status).toBe('FAIL'); - expect(runtime.code).toBe('RUNTIME_BELOW_MINIMUM'); - expect(runtime.anchor).toBe('doctor-runtime-below-minimum'); - expect(runtime.fix).toContain('gstack-capy-install'); - expect(json.trailer).toBe('gstack: fail runtime'); - expect(r.out.trim().split('\n')).toHaveLength(1); - const ok = JSON.parse(doctor(f, ['--json']).out.trim()); - expect(ok.ok).toBe(true); - expect(ok.trailer).toBe(`gstack: ok ${VERSION} for=installed-hosts:claude,codex`); - }); - - test('usage: --for with --require, an unknown skill or component, and a flag without a value exit 2', () => { - const f = makeFixture(); - expect(doctor(f, ['--check', '--for', 'autoplan', '--require', 'claude']).status).toBe(2); - expect(doctor(f, ['--for', 'no-such-skill']).status).toBe(2); - expect(doctor(f, ['--require', 'claude,bogus']).status).toBe(2); - expect(doctor(f, ['--check', '--project']).status).toBe(2); - expect(doctor(f, ['--help']).out).toContain('--check'); - }); -}); - -// Field wave lane A: the trailer agrees with the Bun workflows run (A3), a -// remote minimum fails the revision row (A2), and planning-only workflows do -// not fail on a repo test-pin mismatch (A3 amendment). -describe('gstack-doctor --check: launch Bun, supported minimum, planning-only pins', () => { - const VERSION = fs.readFileSync(path.join(REPO, 'VERSION'), 'utf8').trim(); - const check = (f: Fixture, args: string[], extra: Record = {}) => { - const r = doctor(f, ['--check', ...args], extra); - const lines = r.out.split('\n'); - const rows = Object.fromEntries(lines.filter(l => /^(PASS|FAIL|SKIP|WARN) /.test(l)).map(l => { - const m = l.match(/^(PASS|FAIL|SKIP|WARN) ([a-z-]+)(?:\s(.*))?$/)!; - return [m[2], { status: m[1], rest: m[3] ?? '' }]; - })); - return { status: r.status, out: r.out, rows, trailer: lines.filter(l => l.startsWith('gstack: ')).at(-1) ?? '' }; - }; - /** A verified Bun in $HOME/.bun/bin (off PATH) that answers --version and runs TypeScript. */ - const verifiedBun = (f: Fixture, version = '1.4.2') => - script(path.join(f.home, '.bun/bin/bun'), [`[ "$1" = --version ] && { echo "${version}"; exit 0; }`, `exec "${process.execPath}" "$@"`]); - - test('PATH Bun 1.3.14 with a verified 1.4.2 off PATH: runtime FAILs naming where it was found; with the launch PATH it PASSes', () => { - const f = makeFixture(); - verifiedBun(f); - const fresh = check(f, [], { STUB_BUN_VERSION: '1.3.14' }); - expect(fresh.rows.runtime.status).toBe('FAIL'); - const home = bashPath(f.home); - expect(fresh.rows.runtime.rest).toContain(`bun 1.4.2 found at ${home}/.bun/bin (not on PATH)`); - expect(fresh.rows.runtime.rest).toContain(`fix: export PATH="${home}/.bun/bin:$PATH" or prefix commands with the GSTACK_LAUNCH line`); - expect(fresh.rows.runtime.rest).toContain('(RUNTIME_BELOW_MINIMUM; '); - const launched = check(f, [], { STUB_BUN_VERSION: '1.3.14', PATH: `${f.home}/.bun/bin${path.delimiter}${f.env.PATH}` }); - expect(launched.rows.runtime.status, launched.out).toBe('PASS'); - expect(launched.rows.runtime.rest).toContain(`at ${home}/.bun/bin/bun`); - }); - - test('--min-revision: below FAILs with REVISION_BELOW_MINIMUM naming the upgrade command; at or above PASSes; unknown says so and never claims the minimum', () => { - const f = makeFixture(); - const below = check(f, ['--min-revision', '99.0.0.0']); - expect(below.status).toBe(1); - expect(below.rows.revision.status).toBe('FAIL'); - expect(below.rows.revision.rest).toContain(`installed ${VERSION}`); - expect(below.rows.revision.rest).toContain('below the supported minimum 99.0.0.0'); - expect(below.rows.revision.rest).toContain(`${bashPath(f.root)}/bin/gstack-upgrade`); - expect(below.rows.revision.rest).toContain('(REVISION_BELOW_MINIMUM; '); - expect(below.trailer).toBe('gstack: fail revision'); - const wrapped = check(f, ['--min-revision', '99.0.0.0'], { GSTACK_CAPY_WRAPPER_PATH: '/drive/skills/gstack/scripts/install.sh' }); - expect(wrapped.rows.revision.rest).toContain('fix: bash /drive/skills/gstack/scripts/install.sh (fast-forwards)'); - const at = check(f, ['--min-revision', VERSION]); - expect(at.rows.revision.status, at.out).toBe('PASS'); - expect(at.rows.revision.rest).toContain(`>= supported minimum ${VERSION}`); - const unknown = check(f, ['--min-revision', 'unknown']); - expect(unknown.rows.revision.status).toBe('PASS'); - expect(unknown.rows.revision.rest).toContain('remote minimum unknown (fetch failed)'); - expect(unknown.rows.revision.rest).not.toContain('>= supported minimum'); - }); - - test('planning-only workflows SKIP a repo test-pin mismatch; test-running workflows and --require keep pins required', () => { - const f = makeFixture(); - const project = path.join(f.base, 'project'); - write(path.join(project, '.bun-version'), '1.4.2\n'); - const planning = check(f, ['--for', 'autoplan', '--project', project], { STUB_BUN_VERSION: '1.4.3' }); - expect(planning.rows.pins.status, planning.out).toBe('SKIP'); - expect(planning.rows.pins.rest).toMatch(/^\(planning-only; repo pins .*1\.4\.2.*, launch bun 1\.4\.3\)$/); - for (const args of [['--for', 'review'], ['--for', 'ship'], ['--require', 'claude']]) { - const tests = check(f, [...args, '--project', project], { STUB_BUN_VERSION: '1.4.3' }); - expect(tests.rows.pins.status, args.join(' ')).toBe('FAIL'); - } - }); -}); - -// Plan A8/A-h: the Capy skill copy. Active-wrapper provenance decides; without -// it the Drive copies do, failing only when every copy is stale. The printed -// workaround (the checkout's own wrapper) certifies with the stale Drive copy -// left unchanged. Plan A5: a drifted resolution is a runtime FAIL. -describe('gstack-doctor --check: wrapper and runtime drift', () => { - const posixPathTest = process.platform === 'win32' ? test.skip : test; - const PRE_BOOTSTRAPPER = '#!/usr/bin/env bash\n# gstack Capy skill installer (pre-bootstrapper)\nset -u\nroot="${GSTACK_CAPY_ROOT:-$HOME/.local/share/gstack}"\nexec "$root/bin/gstack-capy-install" "$@"\n'; - const parse = (f: Fixture, args: string[], extra: Record = {}) => { - const r = doctor(f, ['--check', ...args], extra); - const line = (id: string) => r.out.split('\n').find(l => new RegExp(`^(PASS|FAIL|SKIP|WARN) ${id}\\b`).test(l)) ?? ''; - return { status: r.status, out: r.out, line, trailer: r.out.split('\n').filter(l => l.startsWith('gstack: ')).at(-1) ?? '' }; - }; - const drive = (f: Fixture, scope: string, text: string) => { const file = path.join(f.home, '.capy/drive', scope, 'skills/gstack/scripts/install.sh'); write(file, text); return file; }; - const withSupport = (f: Fixture, min = 1) => write(path.join(f.root, 'capy/support.env'), `GSTACK_MIN_REVISION=1.0.0.0\nGSTACK_CAPY_MIN_WRAPPER=${min}\n`); - const current = fs.readFileSync(path.join(REPO, 'capy/skill/gstack/scripts/install.sh'), 'utf8'); - const currentRev = current.match(/^GSTACK_CAPY_WRAPPER=(\d+)$/m)![1]; - - posixPathTest('direct doctor run: a pre-bootstrapper Drive copy (no revision line) fails wrapper with both fixes; a current copy is not flagged', () => { - const f = makeFixture(); - withSupport(f); - const stale = drive(f, 'user-garry-tan', PRE_BOOTSTRAPPER); - const r = parse(f, []); - expect(r.status).toBe(1); - expect(r.trailer).toBe('gstack: fail wrapper'); - const row = r.line('wrapper'); - expect(row).toContain(`FAIL wrapper — every Capy skill copy is stale (minimum wrapper 1): ${stale} (wrapper 0); fix: bash ${f.root}/capy/skill/gstack/scripts/install.sh --check (this session); owner: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes`); - expect(row).toContain('(CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)'); - write(stale, current); - const fresh = parse(f, []); - expect(fresh.line('wrapper')).toBe(`PASS wrapper Drive copy ${stale} (wrapper ${currentRev}) (minimum 1)`); - expect(fresh.status, fresh.out).toBe(0); - }); - - posixPathTest('direct doctor run: a bootstrapper Drive copy below the minimum still works, so it is an owner-action WARN with an ok trailer (only a pre-bootstrapper copy fails)', () => { - const f = makeFixture(); - withSupport(f, Number(currentRev) + 1); - const old = drive(f, 'user-garry-tan', current); - const r = parse(f, []); - expect(r.status, r.out).toBe(0); - expect(r.trailer).toMatch(/^gstack: ok /); - expect(r.line('wrapper')).toBe(`WARN wrapper — every Capy skill copy is below the minimum wrapper ${Number(currentRev) + 1} (it still works): ${old} (wrapper ${currentRev}); owner action: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes (CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)`); - const json = JSON.parse(doctor(f, ['--check', '--json']).out); - expect(json.ok).toBe(true); - expect(json.rows.find((x: { id: string }) => x.id === 'wrapper')).toMatchObject({ status: 'WARN', code: 'CAPY_WRAPPER_STALE' }); - // A pre-bootstrapper copy beside it keeps the FAIL: that copy cannot hand over at all. - drive(f, 'org', PRE_BOOTSTRAPPER); - expect(parse(f, []).trailer).toBe('gstack: fail wrapper'); - }); - - posixPathTest('two Drive copies, one current: PASS naming the stale one; GSTACK_CAPY_WRAPPER_PATH alone decides when set without a revision', () => { - const f = makeFixture(); - withSupport(f); - const stale = drive(f, 'org', PRE_BOOTSTRAPPER); - const good = drive(f, 'user', current); - const r = parse(f, []); - expect(r.line('wrapper')).toBe(`PASS wrapper Drive copy ${good} (wrapper ${currentRev}) (minimum 1); stale copies: ${stale} (wrapper 0)`); - expect(parse(f, [], { GSTACK_CAPY_WRAPPER_PATH: stale }).trailer).toBe('gstack: fail wrapper'); - }); - - posixPathTest('the printed workaround certifies: a current active wrapper passes with the stale Drive copy unchanged (WARN, owner action); a stale active wrapper is a WARN too', () => { - const f = makeFixture(); - withSupport(f); - const stale = drive(f, 'user-garry-tan', PRE_BOOTSTRAPPER); - const checkoutCopy = path.join(f.root, 'capy/skill/gstack/scripts/install.sh'); - const r = parse(f, [], { GSTACK_CAPY_WRAPPER_REV: '1', GSTACK_CAPY_WRAPPER_PATH: checkoutCopy }); - expect(r.status, r.out).toBe(0); - expect(r.trailer).toMatch(/^gstack: ok /); - expect(r.line('wrapper')).toBe(`WARN wrapper — active wrapper 1 (${checkoutCopy}) is current; stale Drive copies (minimum 1): ${stale} (wrapper 0); owner action: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes (CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)`); - expect(fs.readFileSync(stale, 'utf8')).toBe(PRE_BOOTSTRAPPER); - const json = JSON.parse(doctor(f, ['--check', '--json'], { GSTACK_CAPY_WRAPPER_REV: '1', GSTACK_CAPY_WRAPPER_PATH: checkoutCopy }).out); - expect(json.ok).toBe(true); - expect(json.rows.find((x: { id: string }) => x.id === 'wrapper')).toMatchObject({ status: 'WARN', code: 'CAPY_WRAPPER_STALE' }); - withSupport(f, 2); - const old = parse(f, [], { GSTACK_CAPY_WRAPPER_REV: '1', GSTACK_CAPY_WRAPPER_PATH: stale }); - // Integration item 2: an active copy below the minimum still works, so it - // is an owner-action WARN and the trailer stays ok. - expect(old.status, old.out).toBe(0); - expect(old.trailer).toMatch(/^gstack: ok /); - expect(old.line('wrapper')).toBe(`WARN wrapper — the active Capy skill copy ${stale} is wrapper 1, minimum 2 (it still works); owner action: copy ${f.root}/capy/skill/gstack/ over the Drive skill folder, or have Capy re-sync it; this recurs every session until the Drive copy changes (CAPY_WRAPPER_STALE; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#capy-wrapper-stale)`); - }); - - test('a drifted Bun resolution is a runtime FAIL carrying the installer\'s detail, fix and BUN_RESOLUTION_DRIFT', () => { - const f = makeFixture(); - const r = parse(f, [], { GSTACK_DOCTOR_RUNTIME_DRIFT: 'recorded bun 1.4.3 at /s/bun (home), now no eligible bun; changed: the recorded binary is gone', GSTACK_DOCTOR_RUNTIME_DRIFT_FIX: 'bash /d/install.sh --for review' }); - expect(r.trailer).toBe('gstack: fail runtime'); - expect(r.line('runtime')).toBe('FAIL runtime — bun resolution drifted: recorded bun 1.4.3 at /s/bun (home), now no eligible bun; changed: the recorded binary is gone; fix: bash /d/install.sh --for review (BUN_RESOLUTION_DRIFT; https://github.com/garrytan/gstack/blob/main/docs/troubleshooting.md#bun-resolution-drift)'); - }); - - posixPathTest('the runtime row shows gstack\'s bun shim and where it resolves from here, or names a foreign ~/.local/bin/bun it left alone', () => { - const f = makeFixture(); - const shim = path.join(f.home, '.local/bin/bun'); - write(shim, '#!/bin/sh\n# gstack-bun-shim: gstack-owned; delete this file to remove it\n[ "$GSTACK_BUN_SHIM_EXPLAIN" = 1 ] && echo "gstack bun shim: project=none record=none chose=/store/1.4.3/bun rule=default" >&2\necho 1.4.3\n', 0o755); - expect(parse(f, []).line('runtime')).toContain(`; shim ${shim} -> /store/1.4.3/bun`); - write(shim, '#!/bin/sh\necho mine\n', 0o755); - expect(parse(f, []).line('runtime')).toContain(`; ${shim} is not gstack's shim (no marker), left untouched: launch with the GSTACK_LAUNCH line`); - expect(parse(f, [], { GSTACK_BUN_INSTALL_VERSION: '1.4.2' }).line('runtime')).toContain('; install version 1.4.2 from GSTACK_BUN_INSTALL_VERSION'); - }); - - test('A-a: the Capy skill\'s dispatch examples (fail revision, fail runtime,pins, fail pins) match the real failure heads, unchanged', () => { - const skill = fs.readFileSync(path.join(REPO, 'capy/skill/gstack/SKILL.md'), 'utf8'); - const step = skill.split('\n').find(l => l.includes('If the trailer names `revision`, `runtime` or `pins`'))!; - const examples = [...step.matchAll(/`(gstack: fail [^`]+)`/g)].map(m => m[1]); - expect(examples).toEqual(['gstack: fail revision …', 'gstack: fail runtime,pins', 'gstack: fail pins']); - const f = makeFixture(); - const project = path.join(f.base, 'project'); - write(path.join(project, '.bun-version'), '1.4.2\n'); - const real: Record = { - revision: parse(f, ['--require', 'claude', '--min-revision', '99.0.0.0']).trailer, - 'runtime,pins': parse(f, ['--require', 'claude', '--project', project], { STUB_BUN_VERSION: '1.3.14' }).trailer, - pins: parse(f, ['--require', 'claude', '--project', project], { STUB_BUN_VERSION: '1.4.3' }).trailer, - }; - for (const example of examples) { - const ids = example.slice('gstack: fail '.length).replace(/ …$/, ''); - const head = real[ids]; - expect(head === `gstack: fail ${ids}` || head.startsWith(`gstack: fail ${ids} — `), `${example} vs ${head}`).toBe(true); - expect(head.match(/^gstack: fail ([a-z,-]+)/)?.[1].split(',')).toEqual(ids.split(',')); - } - }); -}); - -// Lane F item D: the plain report lists rescue refs with their commands, warns -// about a CLAUDE.md gstack section that enumerates skills (#3169), and on -// Windows names the bash gstack's TypeScript hands its scripts to. GSTACK_DOCTOR_PLATFORM -// injects the platform and GSTACK_DOCTOR_BASH_PROBE the discovery inputs, so -// every case runs on every OS. -describe('gstack-doctor: rescue refs, CLAUDE.md skill lists, Windows bash', () => { - const git = (f: Fixture, args: string[], extra: Record = {}) => { - const r = spawnSync('git', args, { - cwd: f.root, encoding: 'utf8', timeout: 20_000, - env: { ...process.env, HOME: f.home, GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'fixture', GIT_AUTHOR_EMAIL: 'fixture@example.com', GIT_COMMITTER_NAME: 'fixture', GIT_COMMITTER_EMAIL: 'fixture@example.com', ...extra }, - }); - if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); - return r.stdout.trim(); - }; - /** A git install with one rescue commit per entry: a user edit on top of HEAD, committed on the given date. */ - const withRescues = (f: Fixture, rescues: Array<{ date: string; unmerged?: boolean }>) => { - git(f, ['init', '-q']); - git(f, ['add', '-A']); - git(f, ['commit', '-qm', 'release']); - const index = path.join(f.base, 'rescue.idx'); - return rescues.map(({ date, unmerged }, i) => { - const blob = spawnSync('git', ['hash-object', '-w', '--stdin'], { cwd: f.root, input: `edit ${i}\n`, encoding: 'utf8', timeout: 20_000 }).stdout.trim(); - git(f, ['read-tree', 'HEAD'], { GIT_INDEX_FILE: index }); - git(f, ['update-index', '--add', '--cacheinfo', `100644,${blob},local-edit-${i}.txt`], { GIT_INDEX_FILE: index }); - const tree = git(f, ['write-tree'], { GIT_INDEX_FILE: index }); - const message = `gstack rescue: local edits that conflicted with abc\n${unmerged ? '\nNeeds manual resolution: the install had unmerged files; their captured\ncontent may contain conflict markers.\n' : ''}`; - const oid = spawnSync('git', ['commit-tree', tree, '-p', 'HEAD'], { - cwd: f.root, input: message, encoding: 'utf8', timeout: 20_000, - env: { ...process.env, HOME: f.home, GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'gstack rescue', GIT_AUTHOR_EMAIL: 'gstack@localhost', GIT_COMMITTER_NAME: 'gstack rescue', GIT_COMMITTER_EMAIL: 'gstack@localhost', GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date }, - }).stdout.trim(); - git(f, ['update-ref', `refs/gstack/rescue/${oid}`, oid]); - return `refs/gstack/rescue/${oid}`; - }); - }; - const rescueRefs = (f: Fixture) => git(f, ['for-each-ref', '--format=%(refname)', 'refs/gstack/rescue/']).split('\n').filter(Boolean); - const commandFor = (out: string, label: 'inspect' | 'delete', ref: string) => - out.split('\n').map(l => l.trim()).find(l => l.startsWith(`${label}:`) && l.includes(`'${ref}'`))?.replace(/^[a-z]+:\s+/, '') ?? ''; - - test('rescue refs: two refs are listed newest first with their dates; the printed inspect command shows the edit and the printed delete command removes the ref', () => { - const f = makeFixture(); - const [older, newer] = withRescues(f, [{ date: '2026-10-01T12:00:00Z' }, { date: '2026-10-14T08:30:00Z', unmerged: true }]); - const r = doctor(f); - const row = r.row('rescue refs'); - expect(row.state, r.out).toBe('warn'); - expect(row.detail).toContain(`2 in ${bashPath(f.root)}`); - expect(row.fix).toContain('#auto-update-rescued-edits'); - const listing = r.out.split('\n').map(l => l.trim()); - expect(listing.findIndex(l => l.startsWith(newer))).toBeLessThan(listing.findIndex(l => l.startsWith(older))); - expect(listing).toContain(`${older} (2026-10-01)`); - expect(listing).toContain(`${newer} (2026-10-14, needs manual resolution)`); - const q = `'${bashPath(f.root)}'`; - expect(commandFor(r.out, 'inspect', older)).toBe(`git -C ${q} diff '${older}^' '${older}'`); - expect(commandFor(r.out, 'delete', older)).toBe(`git -C ${q} update-ref -d '${older}'`); - const shown = spawnSync('bash', ['-c', commandFor(r.out, 'inspect', older)], { cwd: f.work, encoding: 'utf8', timeout: 20_000 }); - expect(shown.status, shown.stderr).toBe(0); - expect(shown.stdout).toContain('+edit 0'); - const deleted = spawnSync('bash', ['-c', commandFor(r.out, 'delete', older)], { cwd: f.work, encoding: 'utf8', timeout: 20_000 }); - expect(deleted.status, deleted.stderr).toBe(0); - expect(rescueRefs(f)).toEqual([newer]); - expect(doctor(f).row('rescue refs').detail).toContain(`1 in ${bashPath(f.root)}`); - spawnSync('bash', ['-c', commandFor(r.out, 'delete', newer)], { cwd: f.work, encoding: 'utf8', timeout: 20_000 }); - const none = doctor(f); - expect(none.row('rescue refs')).toEqual({ state: 'ok', detail: `none in ${bashPath(f.root)}`, fix: '' }); - expect(none.status, none.out).toBe(0); - }); - - const LISTING = '## gstack\nUse /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools.\nAvailable skills: /office-hours, /plan-ceo-review, /plan-eng-review, /plan-design-review,\n/design-consultation, /design-shotgun, /design-html, /review, /ship, /qa, /browse, /retro, /investigate.\n'; - const SHORT = '## gstack\nUse /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools.\n'; - const replacement = (out: string) => out.split('\n').filter(l => l.startsWith(' '.repeat(34))).map(l => l.slice(34)); - - test('CLAUDE.md: a gstack section that enumerates skills warns with the short replacement block, for ~/.claude/CLAUDE.md and the project CLAUDE.md', () => { - const f = makeFixture(); - const user = path.join(f.home, '.claude/CLAUDE.md'); - write(user, `# Me\n\n${LISTING}\n## Other\ntext\n`); - const r = doctor(f); - const row = r.row('CLAUDE.md'); - expect(row.state, r.out).toBe('warn'); - expect(row.detail).toContain(`${bashPath(f.home)}/.claude/CLAUDE.md lists 13 skills`); - expect(row.fix).toContain('#claude-md-skill-list'); - expect(fs.readFileSync(path.join(REPO, 'docs/troubleshooting.md'), 'utf8')).toContain(''); - expect(replacement(r.out)).toEqual(['## gstack', 'Use /browse from gstack for all web browsing. Never use mcp__claude-in-chrome__* tools.']); - expect(r.status, r.out).toBe(0); - write(user, SHORT); - write(path.join(f.work, 'CLAUDE.md'), `# Project\n\n## gstack (recommended)\nskills: /office-hours /plan-ceo-review /plan-eng-review /review /ship /qa /browse /retro /investigate\n`); - const project = doctor(f).row('CLAUDE.md'); - expect(project.state).toBe('warn'); - expect(project.detail).toContain(`${bashPath(f.work)}/CLAUDE.md lists 9 skills`); - expect(project.detail).not.toContain('.claude/CLAUDE.md'); - }); - - test('CLAUDE.md: the short block, team-init\'s block, a separate Skill routing section and paths do not warn; no CLAUDE.md does not warn', () => { - const f = makeFixture(); - const none = doctor(f).row('CLAUDE.md'); - expect(none.state).toBe('ok'); - expect(none.fix).toBe(''); - write(path.join(f.home, '.claude/CLAUDE.md'), SHORT); - write(path.join(f.work, 'CLAUDE.md'), [ - '## gstack (recommended)', - 'Using gstack skills: After install, skills like /qa, /ship, /review, /investigate,', - 'and /browse are available. Use the install path ~/.claude/skills/gstack/bin/gstack-config /usr/local/bin/bun /opt/a/b /x/y /z/w.', - '', - '## Skill routing', - '- Product ideas → invoke /office-hours', - '- Strategy → invoke /plan-ceo-review', - '- Architecture → invoke /plan-eng-review', - '- Design → invoke /design-consultation or /plan-design-review', - '- Review → invoke /autoplan, /investigate, /qa, /review, /ship', - '', - ].join('\n')); - const r = doctor(f); - expect(r.row('CLAUDE.md').state, r.out).toBe('ok'); - expect(replacement(r.out)).toEqual([]); - }); - - /** A JSON probe file: the discovery inputs resolveWindowsBash takes, in test/fixtures/windows-bash-resolution.json's shape. */ - const probe = (f: Fixture, inputs: Record) => { const file = path.join(f.base, 'bash-probe.json'); write(file, JSON.stringify(inputs)); return file; }; - const GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'; - - test('windows bash: on Windows the row names the resolved bash and what chose it', () => { - const f = makeFixture(); - const found = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: [GIT_BASH], gitExecPath: 'C:/Program Files/Git/mingw64/libexec/git-core' }) }); - expect(found.row('windows bash'), found.out).toEqual({ state: 'ok', detail: `${GIT_BASH} (chosen by git --exec-path)`, fix: '' }); - const override = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', GSTACK_GIT_BASH: 'D:\\tools\\bash.exe', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: ['D:/tools/bash.exe', GIT_BASH] }) }); - expect(override.row('windows bash').detail).toBe('D:/tools/bash.exe (chosen by GSTACK_GIT_BASH)'); - }); - - test('windows bash: GSTACK_GIT_BASH and a different deprecated GSTACK_BASH warn naming both', () => { - const f = makeFixture(); - const differ = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', GSTACK_GIT_BASH: 'D:/a/bash.exe', GSTACK_BASH: 'D:/b/bash.exe', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: ['D:/a/bash.exe', 'D:/b/bash.exe'] }) }); - expect(differ.row('windows bash').state).toBe('warn'); - expect(differ.row('windows bash').detail).toContain('D:/a/bash.exe (chosen by GSTACK_GIT_BASH)'); - expect(differ.row('windows bash').detail).toContain('GSTACK_GIT_BASH (D:/a/bash.exe) and the deprecated GSTACK_BASH (D:/b/bash.exe) differ'); - }); - - test('windows bash: a refusal fails with BASH_UNAVAILABLE naming GSTACK_GIT_BASH; off Windows there is no row', () => { - const f = makeFixture(); - const refused = doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'windows', GSTACK_DOCTOR_BASH_PROBE: probe(f, { exists: [], gitExecPath: null, which: { bash: 'C:/Windows/System32/bash.exe' } }) }); - const row = refused.row('windows bash'); - expect(row.state, refused.out).toBe('fail'); - expect(row.detail).toContain('(BASH_UNAVAILABLE)'); - expect(row.detail).toContain('PATH C:/Windows/System32/bash.exe: WSL launcher, not Git Bash'); - expect(row.detail).toContain(`install path ${GIT_BASH}: not found`); - expect(row.fix).toContain('GSTACK_GIT_BASH'); - expect(row.fix).toContain('#bash-unavailable'); - expect(refused.status).toBe(1); - expect(doctor(f, [], { GSTACK_DOCTOR_PLATFORM: 'posix' }).out).not.toContain('windows bash'); - }); -}); diff --git a/test/helpers/docsync-fault-callbacks.ts b/test/helpers/docsync-fault-callbacks.ts new file mode 100644 index 000000000..94804e754 --- /dev/null +++ b/test/helpers/docsync-fault-callbacks.ts @@ -0,0 +1,386 @@ +/** + * The generated bun-test child that test/docsync-fault-interface*.test.ts run: it mocks the docsync + * fixture, observer, e2e gate and session runner, imports test/skill-e2e-ship-docsync.test.ts and replays + * its registered callbacks (moved verbatim from test/docsync-fault-interface.test.ts, GSTA-151). + */ +import * as path from 'node:path'; +import { DOCS_CHECKPOINT_MARKER } from './docsync-fault-actor'; + +export function docsFaultCallbackScript(root: string): string { + return ` +import { expect, mock, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { SkillTestResult } from ${JSON.stringify(path.join(root, 'test/helpers/session-runner'))}; +const root = ${JSON.stringify(root)}; +const fixtureModule = path.join(root, 'test/helpers/docsync-fixture.ts'); +const observerModule = path.join(root, 'test/helpers/docsync-observer.ts'); +const fixtures = { ...await import(fixtureModule) }; +const observers = { ...await import(observerModule) }; +const { CAPTURE_MS, CAPTURE_LONG_MS } = await import(path.join(root, 'test/helpers/eval-budgets.ts')); +const { parseDocsCompletion } = await import(path.join(root, 'test/helpers/docsync-contract.ts')); +const { docsActorSeeded, DOCS_SEEDED_AUDIT_ID } = await import(path.join(root, 'test/helpers/docsync-fault-actor.ts')); +const callbacks = new Map(); +let fixture, control = '', launches = 0, recorded, legacyReaudit = false; +let returnedResult: SkillTestResult | undefined; +let consumers: string[] = []; +function expectResult(result: SkillTestResult) { + expect(result).toBe(returnedResult); + expect(result).toEqual({ + toolCalls: expect.any(Array), browseErrors: [], exitReason: 'success', duration: 0, + output: 'Bounded callback replay complete', transcript: [], model: 'free-callback-replay', + firstResponseMs: 0, maxInterTurnMs: 0, + costEstimate: { inputChars: 0, outputChars: 0, estimatedTokens: 0, estimatedCost: 0, turnsUsed: 1 }, + }); + expect(result.toolCalls.length).toBeGreaterThan(0); +} +mock.module(fixtureModule, () => ({ ...fixtures, + fixtureDocs(...args) { fixture = fixtures.fixtureDocs(...args); return fixture; }, +})); +mock.module(observerModule, () => ({ ...observers, + docsBoundedStageInterface(...args) { return observers.docsBoundedStageInterface(...args) + '\\nBOUNDED_CALLBACK_USED'; }, +})); +mock.module(path.join(root, 'test/helpers/e2e-gate.ts'), () => ({ describeE2ETier: () => (_name, body) => body() })); +mock.module(path.join(root, 'test/helpers/e2e-helpers.ts'), () => ({ + runId: 'free-docsync-faults', describeIfSelected: (_name, _names, body) => body(), + testConcurrentIfSelected: (name, body, budget) => callbacks.set(name, { body, budget }), + createEvalCollector: () => ({}), finalizeEvalCollector() {}, + logCost(_name, result) { + expectResult(result); + consumers.push('logCost'); + }, + recordE2E(_collector, _name, _label, result, verdict) { + expectResult(result); + expect(verdict).toEqual({ passed: control === '' }); + consumers.push('recordE2E'); + recorded = verdict; + }, +})); +mock.module(path.join(root, 'test/helpers/session-runner.ts'), () => ({ async runSkillTest(options) { + launches++; + const legacy = options.testName === 'ship-docsync-failure'; + const scenario = legacy ? 'legacy-completion' : options.testName.slice('ship-docsync-'.length); + expect(options.maxTurns).toBe(legacy ? 30 : 24); + expect(options.timeout).toBeGreaterThan((legacy ? CAPTURE_LONG_MS : CAPTURE_MS) - 20_000); + expect(options.timeout).toBeLessThanOrEqual((legacy ? CAPTURE_LONG_MS : CAPTURE_MS) - 15_000); + expect(options.tools).toEqual(['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep']); + expect(options.allowedTools).toEqual(options.tools); + expect(options.prompt).toContain('BOUNDED_CALLBACK_USED'); + expect(options.prompt).toContain(fixture.invocation); + expect(options.prompt).toContain('only you apply the loaded workflow'); + expect(options.prompt).toContain('do not probe invented ids'); + expect(options.prompt).toContain('Save each actual completion/rejected output once'); + expect(options.prompt).toContain('Private artifact filenames must end in .json, .md or .markdown'); + expect(options.prompt).toContain('.txt and .log filenames are not supported'); + expect(options.prompt).toContain('in a .md file without changing its bytes or reconstructing JSON'); + expect(options.prompt).toContain('the parent must not manufacture it'); + const repairable = ['recovery', 'late-result'].includes(scenario); + const actor = path.join(root, 'test/helpers/docsync-fault-actor.ts'); + const stateFile = path.join(fixture.home, 'actor-state.json'); + expect(options.prompt.includes('bun ' + actor + ' repair ' + stateFile)).toBe(repairable); + expect(options.prompt.includes('returns no result within the Collect deadline')).toBe(repairable); + expect(options.prompt.includes('This launcher has no repair operation')).toBe(!repairable); + expect(options.prompt.includes('bun ' + actor + ' inspect ' + stateFile)).toBe(true); + expect(options.prompt).toContain('batched read-only observation'); + expect(options.prompt).toContain('Parent output handling'); + expect(options.prompt).toContain('as its own standalone Bash call with no redirect, pipe, wrapper, substitution or other composition'); + expect(options.prompt).toContain('Native Read, Glob and Grep stay available for file reads and are not Bash commands'); + expect(options.prompt).toContain('Never redirect a command into a file and never re-run a command merely to save its output'); + expect(options.prompt).toContain('Keep inspect observations in their original tool results in context and compare those returned values directly'); + expect(options.prompt).toContain('Do not transcribe or reserialize inspect JSON into duplicate snapshot files; prepare already saves the required candidate and prompt'); + expect(options.prompt).toContain('preserve each actual child completion/rejected output once in Markdown'); + expect(options.prompt).toContain('Persist each required checkpoint as one short appended journal entry'); + expect(options.prompt).toContain('use native Edit with old_string exactly ' + ${JSON.stringify(JSON.stringify(DOCS_CHECKPOINT_MARKER))}); + expect(options.prompt).toContain('new_string containing only the new entry followed by that same marker, and replace_all=false'); + expect(options.prompt).toContain('if missing or duplicated, stop rather than guessing an edit'); + expect(options.prompt).toContain('Preserve unrelated sections and every earlier entry byte-for-byte'); + expect(options.prompt).toContain("retaining each earlier attempt's id, count, evidence paths and outcome"); + expect(options.prompt).toContain('The latest stated value is current; do not recopy previous entries'); + expect(options.prompt).toContain('Reference saved candidate/prompt/completion artifacts instead of repeating their contents or prior narration'); + expect(options.prompt).toContain('Before dispatch, save the incremented attempt count, fresh audit id and candidate/prompt paths together'); + expect(options.prompt).toContain('Save the returned child handle before polling'); + expect(options.prompt).toContain('consolidation must never postpone the pre-launch count or child-settlement checks'); + expect(options.prompt).toContain('If recovery is authorized, save the intermediate result in one checkpoint before continuing it'); + expect(options.prompt).toContain('After the loaded Continue or recover / Blocked recovery steps reach a final outcome'); + expect(options.prompt).toContain('Append the finishing checkpoint and Write the complete report in the same response using separate native file calls'); + expect(options.prompt).toContain('Never omit the final report or final response, even when publication is blocked'); + expect(options.prompt).toContain('when it requires stopping, write the required invocation state and report, then stop rather than continuing later preparation to fill optional artifacts'); + expect(options.prompt).toContain('do not introduce any undeclared comparison or processing program to compare or transform observations'); + expect(options.prompt).toContain('Use only the transport commands above and the commands permitted by the Fixture observation interface below'); + expect(options.prompt).toContain('inspect takes no arguments beyond the state path shown above'); + expect(options.prompt).not.toContain('inspect call below'); + expect(options.prompt).not.toContain('inspect receipt'); + const calls = []; + const config = JSON.parse(fs.readFileSync(path.join(fixture.env.CLAUDE_CONFIG_DIR, 'settings.json'), 'utf8')); + const hook = config.hooks.PreToolUse.flatMap(entry => entry.hooks).find(entry => entry.command.includes('docsync-fault-actor.ts')); + expect(hook).toBeDefined(); + const record = (tool, input, output) => calls.push({ tool, input, output }); + const pretool = (tool, input) => { + const result = Bun.spawnSync(['bash', '-c', hook.command], { cwd: fixture.repo, + stdin: Buffer.from(JSON.stringify({ hook_event_name: 'PreToolUse', cwd: fixture.repo, tool_name: tool, tool_input: input })), + stdout: 'pipe', stderr: 'pipe', timeout: 10_000 }); + expect(result.exitCode, result.stderr.toString()).toBe(0); + }; + const read = file_path => { + pretool('Read', { file_path }); + const output = fs.readFileSync(file_path, 'utf8'); + record('Read', { file_path }, output); + return output; + }; + const write = (file_path, content) => { + pretool('Write', { file_path, content }); + fs.writeFileSync(file_path, content); + record('Write', { file_path, content }, 'File written'); + }; + const invoke = (action, args = {}) => { + const argv = [actor, action, stateFile, ...Object.entries(args).map(([key, value]) => key + '=' + value)]; + const command = 'bun ' + argv.join(' '); + pretool('Bash', { command }); + const result = Bun.spawnSync([process.execPath, ...argv], { cwd: fixture.repo, stdout: 'pipe', stderr: 'pipe', timeout: 10_000 }); + const text = result.stdout.toString().trimEnd(); + const output = (result.exitCode ? 'Exit code ' + result.exitCode + '\\n' : '') + text; + record('Bash', { command }, output); + return { text, output, exit: result.exitCode }; + }; + read(path.join(fixture.home, 'phase.md')); + read(path.join(fixture.skills, 'ship/sections/documentation.md')); + const initialRecord = read(fixture.invocation); + expect(initialRecord).toContain('Attempts used: 0'); + expect(initialRecord).toContain('synthetic prior-stage state'); + const marker = ${JSON.stringify(DOCS_CHECKPOINT_MARKER)}; + expect(initialRecord.split(marker)).toHaveLength(2); + const recordPrefix = initialRecord.split(marker)[0]; + let checkpointCount = 0; + const seeded = docsActorSeeded(scenario); + let attemptsUsed = seeded ? 1 : 0; + let seedOutput = ''; + if (seeded) { + expect(initialRecord).toContain('Attempts used: 1'); + read(path.join(fixture.home, 'candidate-' + DOCS_SEEDED_AUDIT_ID + '.json')); + const saved = path.join(fixture.home, 'completion-' + DOCS_SEEDED_AUDIT_ID + '.md'); + seedOutput = control === 'skip-seeded-output' ? fs.readFileSync(saved, 'utf8') : read(saved); + } + const checkpoint = (details, finished = false) => { + const before = fs.readFileSync(fixture.invocation, 'utf8'); + expect(before.split(marker)).toHaveLength(2); + const entry = '### Checkpoint ' + (++checkpointCount) + '\\nAttempts used: ' + attemptsUsed + '.\\n' + + details + '\\nNext: ' + (finished ? 'Documentation gate finished; see ship-report.md. STOP before Step 15.' : 'Continue the actual documentation gate; STOP before Step 15.') + '\\n'; + const input = { file_path: fixture.invocation, old_string: marker, new_string: entry + marker, replace_all: false }; + pretool('Edit', input); + const after = before.replace(input.old_string, input.new_string); + fs.writeFileSync(fixture.invocation, after); + record('Edit', input, 'File edited'); + expect(after.startsWith(recordPrefix)).toBe(true); + expect(after.replace(input.new_string, marker)).toBe(before); + expect(after.split(marker)).toHaveLength(2); + expect(input.new_string).not.toContain(recordPrefix); + }; + const inspectResult = invoke('inspect'); + const observed = JSON.parse(inspectResult.text); + expect(observed.operation).toBe('inspect'); + expect(observed.files[fixtures.DOC_PATH].exists).toBe(true); + expect(observed.pre_existing_dirty).toContain(String.fromCharCode(0)); + expect(inspectResult.text).not.toContain(String.fromCharCode(0)); + expect(calls.at(-1).output).toBe(inspectResult.text); + if (control === 'tampered-inspect') calls[calls.length - 1].output = inspectResult.text.replace('"operation":"inspect"', '"operation":"tampered"'); + const prepare = id => { + const result = invoke('prepare', { audit_id: id }); + expect(result.exit).toBe(0); + const prepared = JSON.parse(result.text); + read(prepared.candidate); + read(prepared.prompt); + return prepared; + }; + const dispatch = prepared => { + attemptsUsed++; + checkpoint('Audit: ' + prepared.audit_id + '; candidate: ' + prepared.candidate + '; prompt: ' + prepared.prompt); + const result = invoke('dispatch', { audit_id: prepared.audit_id, candidate: prepared.candidate, prompt: prepared.prompt, run_in_background: 'false' }); + const saved = calls.at(-2); + expect(saved.tool).toBe('Edit'); + expect(saved.input.file_path).toBe(fixture.invocation); + expect(saved.input.old_string).toBe(marker); + expect(saved.input.new_string).toContain('Attempts used: ' + attemptsUsed); + expect(saved.input.new_string).toContain(prepared.audit_id); + expect(saved.input.new_string).toContain(prepared.candidate); + expect(saved.input.new_string).toContain(prepared.prompt); + return result; + }; + let output = '', accepted = null, first; + if (scenario !== 'missing-asset') { + first = seeded ? { audit_id: DOCS_SEEDED_AUDIT_ID, candidate: path.join(fixture.home, 'candidate-' + DOCS_SEEDED_AUDIT_ID + '.json'), + prompt: path.join(fixture.home, 'prompt-' + DOCS_SEEDED_AUDIT_ID + '.md') } : prepare('ship-docs-20260926-a1'); + const initial = seeded ? { text: seedOutput, output: seedOutput } : dispatch(first); + output = initial.text; + if (scenario === 'missing-marker') { + expect(initial.output).toBe('SESSION_KIND: interactive\\n{"schema_version":1,"audit_id":"' + first.audit_id + '","status":"blocked","files_updated":[],"files_reviewed":[],"documentation_section":"blocked — fixture child audit ' + first.audit_id + '; Missing spawned marker.","blockers":["Missing spawned marker"],"decisions":[]}'); + } + if (scenario === 'launch-failure') { + expect(initial.text).toBe('Child launch failed: injected unavailable worker. No child was started.'); + expect(seeded ? initialRecord.includes('dispatch exit code 23') : initial.output.startsWith('Exit code 23\\n')).toBe(true); + const attempts = JSON.parse(fs.readFileSync(stateFile, 'utf8')).tasks; + expect(attempts).toHaveLength(1); + expect(attempts[0].id).toBeNull(); + expect(attempts[0].candidate).toBe(fs.readFileSync(first.candidate, 'utf8')); + } + if (['timeout-unsettled', 'late-result'].includes(scenario)) { + expect(initial.output).toBe('{"task_id":"fixture-child-1","status":"running","elapsed_ms":0,"virtual_clock":true}'); + const task_id = JSON.parse(output).task_id; + if (seeded) expect(initialRecord).toContain('Returned child handle: ' + task_id + '.'); + else checkpoint('Running child handle: ' + task_id); + expect(invoke('status', { task_id }).output).toBe('{"task_id":"fixture-child-1","status":"running","settled":false,"elapsed_ms":600001,"virtual_clock":true}'); + const stop = invoke('stop', { task_id }); + expect(JSON.parse(stop.text).settled).toBe(scenario === 'late-result'); + if (control !== 'skip-post-stop-status') { + const status = invoke('status', { task_id }); + if (scenario === 'timeout-unsettled') expect(status.output).toBe('{"task_id":"fixture-child-1","status":"running","settled":false,"elapsed_ms":900002,"virtual_clock":true}'); + } + } + if (repairable) expect(invoke('repair').exit).toBe(0); + if (repairable || scenario.startsWith('stale-') || legacy && legacyReaudit) { + read(path.join(fixture.repo, 'app.ts')); + const second = prepare('ship-docs-20260926-a2'); + if (control === 'stale-candidate' || control === 'legacy-stale') { + const old = JSON.parse(fs.readFileSync(first.candidate, 'utf8')); + old.audit_id = second.audit_id; + write(second.candidate, JSON.stringify(old)); + } + output = dispatch(second).text; + if (scenario === 'late-result') expect(() => parseDocsCompletion(output, second.audit_id)).toThrow('completion identity'); + else if (legacy) expect(() => parseDocsCompletion(output, second.audit_id)).toThrow('completion fields'); + else accepted = parseDocsCompletion(output, second.audit_id); + if (control === 'legacy-third') { + const third = prepare('ship-docs-20260926-a3'); + output = dispatch(third).text; + } + } + } else { + expect(fs.existsSync(path.join(fixture.skills, 'document-release/sections/audit-scope.md'))).toBe(false); + } + if (control === 'invalid-repair') expect(invoke('repair').output).toBe('Exit code 24\\nError: repair not available'); + if (control === 'invented-handle') expect(invoke('status', { task_id: 'ship-docs-20260926-a1' }).output).toBe('Exit code 24\\nError: unknown fixture child'); + if (control === 'restore-partial') write(path.join(fixture.repo, fixtures.DOC_PATH), Buffer.from(fixture.before.contents[fixtures.DOC_PATH], 'base64').toString()); + if (control === 'user-content') write(path.join(fixture.repo, 'personal-note.txt'), 'clobbered'); + if (control === 'private-state') read(stateFile); + if (control === 'git-mutation') record('Bash', { command: 'git add ' + fixtures.DOC_PATH }, ''); + const runShell = (command) => { + pretool('Bash', { command }); + const r = Bun.spawnSync(['bash', '-c', command], { cwd: fixture.repo, stdout: 'pipe', stderr: 'pipe', timeout: 10_000 }); + record('Bash', { command }, (r.exitCode ? 'Exit code ' + r.exitCode + '\\n' : '') + r.stdout.toString().trimEnd()); + return r.exitCode; + }; + if (control === 'redirect-transport') { + const snapshot = path.join(fixture.home, 'snapshot.json'); + expect(runShell('bun ' + actor + ' inspect ' + stateFile + ' > ' + snapshot)).toBe(0); + expect(calls.at(-1).output).toBe(''); + expect(JSON.parse(fs.readFileSync(snapshot, 'utf8')).head).toBe(observed.head); + } + if (control === 'compare-program') expect(runShell('diff ' + path.join(fixture.repo, fixtures.DOC_PATH) + ' ' + path.join(fixture.repo, fixtures.DOC_PATH))).toBe(0); + if (control === 'mutate-index') { + const staged = Bun.spawnSync(['git', 'add', fixtures.DOC_PATH], { cwd: fixture.repo, stdout: 'pipe', stderr: 'pipe', timeout: 10_000 }); + expect(staged.exitCode).toBe(0); + } + if (['legacy-unchanged', 'legacy-unsettled', 'legacy-fake-repair'].includes(control)) { + const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + if (control === 'legacy-unchanged') { + state.tasks[1].observed_candidate.hashes = { ...state.tasks[0].observed_candidate.hashes }; + state.tasks[1].candidate = JSON.stringify(state.tasks[1].observed_candidate); + } + if (control === 'legacy-unsettled') state.tasks[0].settled = false; + if (control === 'legacy-fake-repair') { state.repaired = true; state.events.push({ action: 'repair' }); } + fs.writeFileSync(stateFile, JSON.stringify(state)); + } + const outside = path.join(path.dirname(import.meta.path), 'outside.md'); + if (control === 'outside-artifact') record('Write', { file_path: outside, content: output }, 'attempted write'); + if (control === 'protected-artifact') record('Write', { file_path: path.join(fixture.skills, 'private.md'), content: output }, 'attempted write'); + if (control === 'script-artifact') record('Write', { file_path: actor, content: output }, 'attempted write'); + if (control === 'symlink-artifact') { + fs.writeFileSync(outside, 'outside fixture control'); + const link = path.join(fixture.home, 'linked.md'); + fs.symlinkSync(outside, link); + record('Write', { file_path: link, content: output }, 'attempted write'); + } + write(path.join(fixture.home, control === 'raw-text-artifact' ? 'completion.txt' : 'completion.md'), output); + expect(fs.readFileSync(path.join(fixture.home, control === 'raw-text-artifact' ? 'completion.txt' : 'completion.md'), 'utf8')).toBe(output); + if (control === '') expect(fs.readdirSync(fixture.home).filter(name => /inspect|snapshot/.test(name))).toEqual([]); + const report = path.join(fixture.home, 'ship-report.md'); + const finalReport = control === 'false-current' ? 'Documentation: current' : accepted + ? 'Documentation: ' + accepted.status + '\\n' + accepted.documentation_section + : 'Documentation: blocked\\n' + (legacy ? 'Invalid legacy completion; partial edit retained: ' + fixtures.DOC_PATH : 'Actual child result did not clear the gate') + '\\nEvidence: completion.md'; + checkpoint(finalReport, true); + if (control !== 'missing-report') write(report, finalReport); + expect(fs.readFileSync(fixture.invocation, 'utf8')).toContain(finalReport); + expect(fs.readFileSync(fixture.invocation, 'utf8')).toContain('Attempts used: ' + attemptsUsed); + if (attemptsUsed > 1) expect(fs.readFileSync(fixture.invocation, 'utf8')).toContain(first.audit_id); + if (control === '') { + expect(calls.slice(-2).map(call => [call.tool, call.input.file_path])).toEqual([ + ['Edit', fixture.invocation], ['Write', report], + ]); + expect(fs.readFileSync(report, 'utf8')).toBe(finalReport); + } + if (accepted) expect(invoke('publish', { audit_id: accepted.audit_id, report }).exit).toBe(0); + if (control === 'invalid-publication') expect(invoke('publish', { audit_id: 'ship-docs-20260926-a1', report }).exit).toBe(24); + returnedResult = { exitReason: 'success', output: 'Bounded callback replay complete', toolCalls: calls, transcript: [], + browseErrors: [], duration: 0, model: 'free-callback-replay', firstResponseMs: 0, maxInterTurnMs: 0, + costEstimate: { inputChars: 0, outputChars: 0, estimatedTokens: 0, estimatedCost: 0, turnsUsed: 1 } } satisfies SkillTestResult; + return returnedResult; +} })); +await import(path.join(root, 'test/skill-e2e-ship-docsync.test.ts')); +expect(callbacks.size).toBe(12); +const names = ['ship-docsync-failure', 'ship-docsync-missing-marker', 'ship-docsync-missing-asset', + 'ship-docsync-launch-failure', 'ship-docsync-timeout-unsettled', 'ship-docsync-late-result', + 'ship-docsync-stale-before', 'ship-docsync-stale-after', 'ship-docsync-recovery']; +for (const name of names) test(name + ' consumes actual adapter results', async () => { + control = ''; + legacyReaudit = false; + recorded = undefined; + returnedResult = undefined; + consumers = []; + const before = launches; + expect(callbacks.get(name).budget).toBe(name === 'ship-docsync-failure' ? CAPTURE_LONG_MS : CAPTURE_MS); + await callbacks.get(name).body(); + expect(launches).toBe(before + 1); + expect(recorded).toEqual({ passed: true }); + expect(consumers).toEqual(['logCost', 'recordE2E']); + expect(fs.existsSync(fixture.home)).toBe(false); +}); +test('ship-docsync-failure accepts its remaining changed-input audit and preserves raw output in Markdown', async () => { + control = ''; + legacyReaudit = true; + recorded = undefined; + returnedResult = undefined; + consumers = []; + await callbacks.get('ship-docsync-failure').body(); + expect(recorded).toEqual({ passed: true }); + expect(consumers).toEqual(['logCost', 'recordE2E']); + expect(fs.existsSync(fixture.home)).toBe(false); +}); +for (const [name, mutation] of [ + ['ship-docsync-failure', 'false-current'], ['ship-docsync-failure', 'invalid-publication'], + ['ship-docsync-failure', 'restore-partial'], ['ship-docsync-failure', 'user-content'], + ['ship-docsync-failure', 'private-state'], ['ship-docsync-failure', 'git-mutation'], + ['ship-docsync-failure', 'mutate-index'], ['ship-docsync-missing-marker', 'invalid-repair'], + ['ship-docsync-missing-marker', 'redirect-transport'], ['ship-docsync-missing-marker', 'compare-program'], + ['ship-docsync-launch-failure', 'invented-handle'], ['ship-docsync-timeout-unsettled', 'skip-post-stop-status'], + ['ship-docsync-late-result', 'missing-report'], + ['ship-docsync-stale-before', 'stale-candidate'], + ['ship-docsync-recovery', 'skip-seeded-output'], + ['ship-docsync-failure', 'legacy-unchanged'], ['ship-docsync-failure', 'legacy-unsettled'], + ['ship-docsync-failure', 'legacy-stale'], ['ship-docsync-failure', 'legacy-third'], + ['ship-docsync-failure', 'legacy-fake-repair'], + ['ship-docsync-missing-marker', 'tampered-inspect'], + ['ship-docsync-missing-marker', 'raw-text-artifact'], ['ship-docsync-missing-marker', 'outside-artifact'], + ['ship-docsync-missing-marker', 'protected-artifact'], ['ship-docsync-missing-marker', 'symlink-artifact'], + ['ship-docsync-missing-marker', 'script-artifact'], +]) test(name + ' rejects ' + mutation, async () => { + control = mutation; + legacyReaudit = name === 'ship-docsync-failure'; + recorded = undefined; + returnedResult = undefined; + consumers = []; + await expect(callbacks.get(name).body()).rejects.toThrow(); + expect(recorded).toEqual({ passed: false }); + expect(consumers).toEqual(['logCost', 'recordE2E']); + expect(fs.existsSync(fixture.home)).toBe(false); +}); +`; +} diff --git a/test/helpers/doctor-fixture.ts b/test/helpers/doctor-fixture.ts new file mode 100644 index 000000000..24d7b00b2 --- /dev/null +++ b/test/helpers/doctor-fixture.ts @@ -0,0 +1,131 @@ +/** + * Shared fixture for the gstack-doctor tests (test/gstack-doctor.test.ts, + * test/gstack-doctor-check.test.ts, test/gstack-doctor-launch.test.ts): a + * fixture install root, state root, stub bun/codex/claude on PATH, and the + * doctor() and rowOf() readers. Each test file calls useDoctorCleanup(). + */ +import { afterEach } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// setup builds browse/dist/browse.exe on Windows (Git Bash), browse/dist/browse elsewhere. +export const BROWSE_BIN = process.platform === 'win32' ? 'browse/dist/browse.exe' : 'browse/dist/browse'; +export const EXE = process.platform === 'win32' ? '.exe' : ''; +export const OTHER_BINS = ['browse/dist/find-browse', 'design/dist/design', 'make-pdf/dist/pdf', 'bin/gstack-global-discover'].map(rel => rel + EXE); + +export const REPO = path.resolve(import.meta.dir, '../..'); +const COPIED = [ + 'setup', 'VERSION', + 'bin/gstack-doctor', 'bin/gstack-codex-status.sh', 'bin/gstack-state-root.sh', 'bin/gstack-install-registry.sh', + 'bin/gstack-render-claude.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-hook-check', 'bin/gstack-config', + 'autoplan/SKILL.md', // its frontmatter registers the autoplan hook the hooks-row test breaks + 'bin/gstack-launch-probe.sh', + 'bin/gstack-doctor-check.sh', 'bin/gstack-doctor-components.sh', 'bin/gstack-runtime-pins.ts', 'lib/runtime-pins.ts', + 'bin/gstack-windows-bash.ts', 'lib/script-invocation.ts', 'lib/bun-isolation.ts', 'lib/result-codes.ts', 'lib/result-codes-release.ts', 'lib/result-codes-capy.ts', 'lib/result-codes-budget.ts', 'lib/result-codes-eval.ts', 'lib/result-codes-plan.ts', // the windows bash row +]; +const bases: string[] = []; +/** Registers the per-test fixture cleanup; each test file calls it at top level (a hook in this module would bind to the first importing file only). */ +export function useDoctorCleanup() { + afterEach(() => { for (const b of bases.splice(0)) fs.rmSync(b, { recursive: true, force: true }); }); +} + +export const write = (file: string, text: string, mode = 0o644) => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text, { mode }); +}; +export const script = (file: string, lines: string[]) => write(file, ['#!/usr/bin/env bash', ...lines].join('\n') + '\n', 0o755); + +/** The fixture's PATH entries minus every directory holding a real bun, codex or claude. */ +function hostPath(base: string): string { + const names = ['bun', 'codex', 'claude']; + const exts = process.platform === 'win32' ? ['', '.exe', '.cmd', '.ps1'] : ['']; + return (process.env.PATH ?? process.env.Path ?? '').split(path.delimiter).filter(Boolean).map((dir, i) => { + if (!names.some(n => exts.some(e => fs.existsSync(path.join(dir, n + e))))) return dir; + const shim = path.join(base, `host-${i}`); + fs.mkdirSync(shim, { recursive: true }); + for (const name of fs.readdirSync(dir)) { + if (names.some(n => exts.some(e => name === n + e))) continue; + try { fs.symlinkSync(path.join(dir, name), path.join(shim, name)); } catch { /* unreadable entry */ } + } + return shim; + }).join(path.delimiter); +} + +export interface Fixture { base: string; root: string; state: string; home: string; work: string; stub: string; probeLog: string; env: Record } + +export function makeFixture(): Fixture { + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-doctor-'))); + bases.push(base); + const root = path.join(base, 'gstack'); + const state = path.join(base, 'state'); + const home = path.join(base, 'home'); + const work = path.join(base, 'work'); + const stub = path.join(base, 'stub'); + const probeLog = path.join(base, 'probe-calls'); + for (const dir of [state, home, work, stub]) fs.mkdirSync(dir, { recursive: true }); + for (const rel of COPIED) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.copyFileSync(path.join(REPO, rel), path.join(root, rel)); + fs.chmodSync(path.join(root, rel), fs.statSync(path.join(REPO, rel)).mode); + } + script(path.join(root, 'bin/gstack-codex-probe'), [ + 'echo "$*" >> "$DOCTOR_PROBE_LOG"', + 'case "$1" in', + ' select-model) [ "${STUB_SELECT_FAIL:-}" = 1 ] && { echo "gstack-codex-probe: cannot find its scripts dir" >&2; exit 1; }', + ' echo "CODEX_SEL: gpt-stub-5"; echo "CODEX_SEL_KIND: $2"; echo "CODEX_MODEL: gpt-stub-5 ($2; source: codex config.toml)" >&2 ;;', + ' check-auth) [ "${STUB_AUTH_FAIL:-}" = 1 ] && { echo AUTH_FAILED; exit 1; }; echo AUTH_OK ;;', + ' show-sandbox) echo "CODEX_SANDBOX: read-only" ;;', + ' check-sandbox|check-version) ;;', + ' probe-model) echo "CODEX_PROBE_STATE: ${STUB_PROBE_STATE:-ok}"; exit "${STUB_PROBE_EXIT:-0}" ;;', + ' *) exit 64 ;;', + 'esac', + ]); + const listed = spawnSync('bash', [path.join(root, 'bin/gstack-hook-check'), '--list', root], { encoding: 'utf8', timeout: 20_000 }); + const hooks = listed.stdout.split('\n').map(line => line.trim()).filter(Boolean); + if (!hooks.includes('autoplan/bin/phase-publication-hook')) throw new Error(`gstack-hook-check --list missed the autoplan hook: ${listed.stdout}${listed.stderr}`); + for (const hook of hooks) write(path.join(root, hook), '#!/bin/sh\nexit 0\n', 0o755); + // The doctor launches each compiled binary's --version (#2595), so the + // stand-ins answer it like the real binaries do. + for (const rel of [BROWSE_BIN, ...OTHER_BINS]) write(path.join(root, rel), '#!/bin/sh\necho built\n', 0o755); + for (const rel of ['browse/dist/server-node.mjs', 'browse/dist/.build-complete']) write(path.join(root, rel), 'built\n', 0o755); + const version = fs.readFileSync(path.join(root, 'VERSION'), 'utf8').trim(); + write(path.join(state, 'installs.tsv'), + ['claude', 'global', '-', path.join(home, '.claude/skills'), root, root, version, 'false', 'committed', '2026-10-07T00:00:00Z'].join('\t') + '\n'); + // The stub answers --version; anything else (the pins row runs bin/gstack-runtime-pins.ts) goes to the real bun. + script(path.join(stub, 'bun'), ['[ "$1" = --version ] && { echo "${STUB_BUN_VERSION:-1.4.2}"; exit 0; }', `exec "${process.execPath}" "$@"`]); + script(path.join(stub, 'codex'), ['[ "$1" = --version ] && { echo "codex-cli 0.130.0"; exit 0; }', 'echo "unexpected codex call: $*" >> "$DOCTOR_PROBE_LOG"', 'exit 9']); + script(path.join(stub, 'claude'), ['[ "$1" = --version ] && { echo "2.1.292 (Claude Code)"; exit 0; }', 'exit 9']); + const env: Record = { + PATH: [stub, hostPath(base)].join(path.delimiter), + HOME: home, GSTACK_STATE_ROOT: state, TMPDIR: base, DOCTOR_PROBE_LOG: probeLog, + }; + if (process.env.SYSTEMROOT) env.SYSTEMROOT = process.env.SYSTEMROOT; + return { base, root, state, home, work, stub, probeLog, env }; +} + +export function doctor(f: Fixture, args: string[] = [], extra: Record = {}) { + const r = spawnSync('bash', [path.join(f.root, 'bin/gstack-doctor'), ...args], { cwd: f.work, encoding: 'utf8', env: { ...f.env, ...extra }, timeout: 60_000 }); + const out = `${r.stdout}${r.stderr}`; + return { status: r.status, out, row: (name: string) => rowOf(r.stdout, name) }; +} + +/** The named row and its fix line: { state, detail, fix }. */ +export function rowOf(stdout: string, name: string) { + const lines = stdout.split('\n'); + const i = lines.findIndex(l => l.slice(16, 32).trim() === name); + if (i < 0) throw new Error(`no row "${name}" in:\n${stdout}`); + const fix = lines[i + 1]?.startsWith(' '.repeat(32) + 'fix: ') ? lines[i + 1].slice(37) : ''; + return { state: lines[i].slice(0, 16).trim(), detail: lines[i].slice(32), fix }; +} + +/** How bash spells a directory (Git Bash on Windows prints /c/...). */ +export const bashPath = (dir: string) => spawnSync('bash', ['-c', 'cd "$1" && pwd', '_', dir], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); +export const probeCalls = (f: Fixture) => fs.existsSync(f.probeLog) ? fs.readFileSync(f.probeLog, 'utf8') : ''; +export const iso = (secondsAgo: number) => new Date(Date.now() - secondsAgo * 1000).toISOString().replace(/\.\d+Z$/, 'Z'); +export const syncOn = (f: Fixture, status: Record) => { + fs.mkdirSync(path.join(f.state, '.git'), { recursive: true }); + write(path.join(f.state, 'config.yaml'), 'artifacts_sync_mode: artifacts-only\n'); + write(path.join(f.state, '.brain-sync-status.json'), JSON.stringify(status).replace(/":/g, '": ').replace(/,"/g, ', "') + '\n'); +}; diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index bca79b7a2..043f2ca2c 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -203,9 +203,9 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/shard-cli-parity/**': ['test/shard-cli-parity.test.ts'], 'test/fixtures/shard-equivalence/**': ['test/shard-engine-equivalence.test.ts'], 'test/fixtures/shared-libs-claude-version-probe.json': ['test/shared-libs-fixture.test.ts'], - 'test/fixtures/shared-libs-index-flags-loop-read-39173752116.json': ['test/shared-libs-checker-interface-evidence.test.ts'], + 'test/fixtures/shared-libs-index-flags-loop-read-39173752116.json': ['test/shared-libs-checker-interface-evidence-replays.test.ts'], 'test/fixtures/shared-libs-no-change-ci-public.json': ['test/shared-libs-fixture.test.ts'], - 'test/fixtures/shared-libs-path-ignored-errored-read-40617200857.json': ['test/shared-libs-checker-interface-evidence.test.ts'], + 'test/fixtures/shared-libs-path-ignored-errored-read-40617200857.json': ['test/shared-libs-checker-interface-evidence-replays.test.ts'], 'test/fixtures/shared-libs-plan-callers-fixes-behavior-local.json': ['test/shared-libs-plan-actor.test.ts'], 'test/fixtures/shared-libs-plan-callers-future-risk-37195203538.json': ['test/shared-libs-plan-actor.test.ts'], 'test/fixtures/shared-libs-plan-callers-no-hardening-36633323521.json': ['test/shared-libs-plan-actor.test.ts'], diff --git a/test/helpers/host-renders-prune-fixture.ts b/test/helpers/host-renders-prune-fixture.ts new file mode 100644 index 000000000..65363383b --- /dev/null +++ b/test/helpers/host-renders-prune-fixture.ts @@ -0,0 +1,31 @@ +/** + * Shared fixture for the #1694 host-render prune tests (test/host-renders-prune*.test.ts): + * a real `--host all` legacy render, the backup listing and a setup run that must pass. + */ +import { expect } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { runGeneration } from '../../scripts/gen-skill-docs'; +import { runSetup, type Fixture } from './install-fixture'; + +export const ROOT = path.resolve(import.meta.dir, '../..'); +export const OTHER_DIRS = ['.agents', '.factory', '.kiro', '.opencode', '.cursor', '.copilot', '.agy', '.gemini-cli', '.slate', '.openclaw', '.hermes', '.gbrain']; + +export const skillFiles = (dir: string): string[] => !fs.existsSync(dir) ? [] + : (fs.readdirSync(dir, { recursive: true }) as string[]).filter(rel => path.basename(rel) === 'SKILL.md' && fs.statSync(path.join(dir, rel)).isFile()); + +export async function legacyRender(src: string): Promise { + const result = await runGeneration({ host: 'all', outputRoot: src }); + if (result.exitCode !== 0) throw new Error('legacy render failed'); +} + +export function backups(f: Fixture): string[] { + const dir = path.join(f.home, '.gstack/backups/host-renders'); + return fs.existsSync(dir) ? fs.readdirSync(dir).map(d => path.join(dir, d)) : []; +} + +export function setupOk(f: Fixture, src: string, args: string[] = []) { + const r = runSetup(f, path.join(src, 'setup'), args); + expect(r.status, r.stdout + r.stderr).toBe(0); + return r; +} diff --git a/test/helpers/setup-codex-scope-cyclic.ts b/test/helpers/setup-codex-scope-cyclic.ts new file mode 100644 index 000000000..2a4b14ab7 --- /dev/null +++ b/test/helpers/setup-codex-scope-cyclic.ts @@ -0,0 +1,37 @@ +import { existsSync, lstatSync, rmSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect } from 'bun:test'; +import { fixtureWriteFileSync, fixtureMkdirSync, tree, fixture, install } from './install-fixture'; + +/** F13: a Claude-only setup run twice leaves a cyclic foreign ownership link at `target` and the rest of the global tree untouched. */ +export function claudeOnlySetupPreservesCyclicLink(target: string): void { + const f = fixture('ordinary'); + rmSync(join(f.global, 'gstack-retired')); + for (const rel of ['bin', 'lib']) { + const asset = join(f.global, 'gstack', rel); + rmSync(asset); + symlinkSync(join(f.source, rel), asset, 'dir'); + } + const old = join(f.global, 'gstack-claude'); + rmSync(old, { recursive: true }); + const oldRender = join(f.source, '.agents/skills/gstack-claude'); + fixtureMkdirSync(oldRender, { recursive: true }); + fixtureWriteFileSync(join(oldRender, 'SKILL.md'), '\n\nPrior legacy render.\n'); + symlinkSync(oldRender, old, 'dir'); + const link = target === 'legacy' ? old + : target === 'replacement' ? join(f.global, 'gstack-claude-code') + : target === 'legacy-skill' ? join(old, 'SKILL.md') + : target === 'runtime' ? join(f.global, 'gstack') + : join(f.global, 'gstack', target === 'runtime-bin' ? 'bin' : 'lib'); + if (target === 'legacy-skill') { + rmSync(old); + fixtureMkdirSync(old); + } + if (existsSync(link) || lstatSync(link, { throwIfNoEntry: false })) rmSync(link, { recursive: true }); + symlinkSync(link, link); + const before = tree(f.global); + for (let run = 0; run < 2; run++) { + install(f, '--host claude'); + expect(tree(f.global)).toEqual(before); + } +} diff --git a/test/helpers/shared-libs-checker-evidence.ts b/test/helpers/shared-libs-checker-evidence.ts new file mode 100644 index 000000000..b20dab453 --- /dev/null +++ b/test/helpers/shared-libs-checker-evidence.ts @@ -0,0 +1,212 @@ +/** + * Captures and replay adapters shared by test/shared-libs-checker-interface-evidence*.test.ts + * (moved from test/shared-libs-checker-interface-evidence.test.ts, GSTA-151). Each test file + * runs its own beforeAll over the captures it replays and its own afterAll cleanup. + */ +import { expect } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { hasTrustedSharedLibsCheck } from './shared-libs-review-start-evidence'; +import { + createSharedInteractiveToolHandler, createSharedLibsFixture, fixtureGit, fixtureWorkingTree, fixtureWrite, + installNormalizingFilter, reviewLifecycleInstructions, reviewRevalidationPrompt, reviewRecords, + seedReviewSources, seedSkippedAdvisory, SHARED_LIBS_ROOT, shellQuote, toolCommandTrace, + type SharedLibsFixture, +} from './shared-libs-eval-fixture'; +import { + seedPathReviewPrerequisites, checkPathReviewPrerequisites, + hasPathReviewPrerequisiteReceipt, type PathEligibilityFixture, +} from './shared-libs-path-fixture'; +import { CAPTURE_LONG_MS } from './eval-budgets'; +import capturedChecker from '../fixtures/shared-libs-index-flags-r59-checker-public.json'; + +export const helper = path.join(SHARED_LIBS_ROOT, 'bin/gstack-review-log'); +export const fixtures: SharedLibsFixture[] = []; +export const captures = new Map(); +export const pathSource = fs.readFileSync(path.join(import.meta.dir, '..', 'skill-e2e-shared-libs-paths.test.ts'), 'utf8'); +export const pathKinds = ['symlinks', 'submodule', 'ignored', 'legacy', 'assume-unchanged', 'skip-worktree', 'removed-filter'] as const; + +export async function capture(change: string, prepared?: PathEligibilityFixture, declared = false) { + const f = prepared?.fixture ?? createSharedLibsFixture(`checker-${change}`); + fixtures.push(f); + let current = prepared?.current; + if (!current) { + seedReviewSources(f); + fixtureWrite(f, 'src/retry-worker.ts', fs.readFileSync(path.join(f.repo, 'src/retry-worker.ts'), 'utf8') + .replace('const unusedRetryDiagnostic = "unused";\n', '')); + if (change === 'filtered') installNormalizingFilter(f); + const { action: _action, ...finding } = await seedSkippedAdvisory(f); + current = finding; + if (change === 'branch') fixtureGit(f, 'checkout', '-b', 'feature-a'); + if (change === 'secondary') fixtureWrite(f, 'src/retry-route.ts', + fs.readFileSync(path.join(f.repo, 'src/retry-route.ts'), 'utf8') + '// Changed caller\n'); + if (change === 'filtered') fixtureWrite(f, 'src/retry-route.ts', + fs.readFileSync(path.join(f.repo, 'src/retry-route.ts'), 'utf8') + '// RAW-ONLY changed caller\n'); + } + const events: any[] = []; + const invoke = (command: string) => { + const id = `call-${events.length}`; + events.push({ type: 'assistant', message: { content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] } }); + const output = execFileSync('bash', ['-c', command], { cwd: f.repo, env: { ...process.env, ...f.env }, + encoding: 'utf8', timeout: 30_000 }); + events.push({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content: output }] } }); + return output.trim(); + }; + const instructionFile = reviewLifecycleInstructions(f); + const instructions = fs.readFileSync(instructionFile, 'utf8'); + const resumed = prepared?.resumed ?? seedPathReviewPrerequisites(f); + const prerequisites = checkPathReviewPrerequisites(f, resumed.input); + const protocol = declared ? [...reviewRevalidationPrompt(f, instructionFile, path.join(f.root, 'current-advisory.jsonl'), resumed) + .matchAll(/```bash\n([\s\S]*?)\n```/g)].map(match => match[1]) : []; + if (declared) expect(protocol).toHaveLength(3); + const startCommand = instructions.match(/```bash\n(DIFF_BASE=\$[\s\S]*?)\n```/)?.[1]; + expect(startCommand).toBeDefined(); + const token = invoke(declared ? protocol[0] : startCommand!).split('\n')[0]; + if (declared) invoke('git diff origin/main'); + for (const file of current.evidence_paths) invoke(`cat ${shellQuote(file)}`); + const checkAt = events.length; + const receipt = JSON.parse(invoke(declared ? protocol[1].replace('REVIEW_START', token).replace('CURRENT_FINDING_JSON', JSON.stringify(current)) + : `${shellQuote(helper)} --check-shared-libs ${token} <<'FINDING'\n${JSON.stringify(current)}\nFINDING`)); + const questions: any[] = []; + if (declared && !receipt.reusable) { + const input = { questions: [{ question: 'Revalidate this supplied authored-source advisory?', + options: [{ label: 'Fix', description: 'Apply the extraction.' }, + { label: 'Skip', description: 'Keep the source and index unchanged; record this advisory decision.' }] }] }; + const callback = createSharedInteractiveToolHandler('skip', { + nonQuestion: () => { throw new Error('No non-question tool is allowed by this free actor'); }, + onQuestion: value => { questions.push(value); }, onAnswer: () => {}, + }); + const id = `decision-${events.length}`; + events.push({ type: 'assistant', message: { content: [{ type: 'tool_use', id, name: 'AskUserQuestion', input }] } }); + const answer = await callback('AskUserQuestion', input); + expect(answer.updatedInput.answers).toEqual({ [input.questions[0].question]: 'Skip' }); + events.push({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, + content: JSON.stringify(answer.updatedInput.answers) }] } }); + } + { + const settled = JSON.parse(invoke(resumed.checkCommand)); + expect(settled).toEqual(checkPathReviewPrerequisites(f, resumed.input)); + expect(settled).toMatchObject({ synthetic: true, native_coverage: false, settled: true, current: true }); + } + const finishAt = events.length; + const record = JSON.stringify({ skill: 'review', status: 'clean', issues_found: 0, + completed: true, converged: true, findings: change === 'unchanged' ? [] : [{ ...current, action: 'skipped' }] }); + invoke(declared ? protocol[2].replace("'FINAL_REVIEW_JSON'", shellQuote(record)).replace('REVIEW_START', token) + : `${shellQuote(helper)} ${shellQuote(record)} --finish ${token} && ${shellQuote(path.join(SHARED_LIBS_ROOT, 'bin/gstack-review-read'))}`); + const expected = { helper, repo: f.repo, state: f.state, slug: 'fixture-shared-libs', + directory: path.join(f.state, 'projects/fixture-shared-libs/.review-starts'), + branch: fixtureGit(f, 'symbolic-ref', '--quiet', '--short', 'HEAD'), wtree: fixtureWorkingTree(f), + startedAt: receipt.review_start.started_at, finding: current, reusable: change === 'unchanged', + coveredPaths: receipt.snapshot.covered_paths, + ...(prepared ? { sources: Object.fromEntries(current!.evidence_paths.map((file: string) => [file, fs.readFileSync(path.join(f.repo, file), 'utf8')])) } : {}) }; + return { f, current, token, receipt, events, checkAt, finishAt, expected, prepared, resumed, prerequisites, ...(declared ? { questions } : {}) }; +} + +export function replay(change = 'unchanged') { return structuredClone(captures.get(change)); } +export function call(run: any, at = run.checkAt) { return run.events[at].message.content[0]; } +export function result(run: any, at = run.checkAt) { return run.events[at + 1].message.content[0]; } +export function alterReceipt(run: any, change: (receipt: any) => void) { + const receipt = JSON.parse(result(run).content); + change(receipt); + result(run).content = JSON.stringify(receipt); +} + +export function pathCallback(run: any, overrides: Record = {}) { + const start = pathSource.indexOf('async function exerciseEligibility('); + const end = pathSource.indexOf('\ndescribeE2E(', start); + const readStart = pathSource.indexOf('function sourceReadTrace('); + expect(start).toBeGreaterThan(readStart); + expect(end).toBeGreaterThan(start); + const rows: any[] = []; + const native = { events: run.events, exitReason: 'success', output: '', toolCalls: run.events + .filter((event: any) => event.type === 'assistant') + .flatMap((event: any) => event.message.content.filter((block: any) => block.type === 'tool_use') + .map((block: any) => ({ tool: block.name, input: block.input }))) }; + const exercise = new Function('deps', new Bun.Transpiler({ loader: 'ts' }).transformSync(`const { + captures, preparePathEligibilityFixture, fs, path, reviewLifecycleInstructions, reviewRevalidationPrompt, + runSharedInteractive, readRequests, toolCommandTrace, fixtureGit, fixtureWorkingTree, reviewRecords, expect, + CAPTURE_LONG_MS, hasTrustedSharedLibsCheck, SHARED_LIBS_ROOT, + checkPathReviewPrerequisites, hasPathReviewPrerequisiteReceipt } = deps; + ${pathSource.slice(readStart, end)} return exerciseEligibility;`))({ + captures: { runAttempt: async (_name: string, _kinds: string[], _timeout: number, work: any) => + work({ add: (scenario: string, row: any) => rows.push({ scenario, row }) }) }, + preparePathEligibilityFixture: () => run.prepared, + fs: { ...fs, rmSync: (directory: string) => { expect(directory).toBe(run.f.root); } }, path, + reviewLifecycleInstructions, reviewRevalidationPrompt, + runSharedInteractive: async (fixture: any, _name: string, prompt: string, choice: string) => { + expect(choice).toBe('skip'); + expect(fixture).toBe(run.prepared.fixture); + expect(prompt).toContain(reviewRevalidationPrompt(fixture, path.join(fixture.root, 'review-lifecycle.md'), + path.join(fixture.root, 'current-advisory.jsonl'), run.prepared.resumed)); + return { result: native, questions: run.questions ?? [{}] }; + }, + readRequests: () => [], toolCommandTrace, fixtureGit, fixtureWorkingTree, reviewRecords, expect, + CAPTURE_LONG_MS, hasTrustedSharedLibsCheck, SHARED_LIBS_ROOT, + checkPathReviewPrerequisites, hasPathReviewPrerequisiteReceipt, ...overrides, + }); + return { rows, invoke: () => exercise('shared-libs-review-path-eligibility', [run.kind]) }; +} + +export function capturedPathRun(index: number) { + const captured = structuredClone(capturedChecker.attempts[index]); + const events: any[] = captured.events; + const blocks = events.flatMap(event => event.message.content); + const calls = blocks.filter(block => block.type === 'tool_use'); + const returned = (id: string) => { + const result = blocks.find(block => block.type === 'tool_result' && block.tool_use_id === id); + expect(result).toBeDefined(); + expect(result.is_error).not.toBe(true); + expect(typeof result.content).toBe('string'); + return result.content as string; + }; + const files = new Map(calls.filter(call => call.name === 'Read') + .map(call => [call.input.file_path, returned(call.id).replace(/^\d+\t/gm, '')])); + const repo = captured.repo, root = path.posix.dirname(repo), state = path.posix.join(root, 'state'); + const input = path.posix.join(root, 'resumed-review-prerequisites.json'); + const supplied = path.posix.join(root, 'current-advisory.jsonl'); + const current = JSON.parse(files.get(supplied)!); + const prerequisiteCall = calls.find(call => call.input.command?.includes('--check-review-prerequisites')); + const prerequisites = JSON.parse(returned(prerequisiteCall.id)); + expect(prerequisites).toMatchObject({ settled: true, current: true, context: { binding: { root, repo, state } } }); + expect(prerequisites.context).toEqual(JSON.parse(files.get(input)!)); + const finish = calls.find(call => call.input.command?.includes('--finish')); + const records = returned(finish.id).split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)); + const f = { root, repo, state } as SharedLibsFixture; + const run = { f, kind: 'assume-unchanged', events, questions: calls.filter(call => call.name === 'AskUserQuestion').map(call => call.input), + prepared: { fixture: f, current, sourcePaths: ['src/retry-route.ts'], beforeTree: prerequisites.context.binding.wtree, + resumed: { input, checkCommand: prerequisiteCall.input.command } } }; + expect(captured.exit_reason).toBe('success'); + expect(run.questions).toHaveLength(1); + for (const call of calls.filter(call => call.name === 'AskUserQuestion')) { + for (const question of call.input.questions) { + expect(returned(call.id)).toContain(`"${question.question}"="Skip"`); + expect(question.options.some((option: any) => option.label === 'Skip')).toBe(true); + } + } + const overrides = { + path: path.posix, SHARED_LIBS_ROOT: '/workspace/gstack', + fs: { + readFileSync: (file: string) => { expect(files.has(file)).toBe(true); return files.get(file); }, + realpathSync: (file: string) => file, + writeFileSync: (file: string, contents: string) => { + expect(file).toBe(supplied); + expect(JSON.parse(contents)).toEqual(current); + }, + rmSync: (directory: string) => { expect(directory).toBe(root); }, + }, + reviewLifecycleInstructions: () => path.posix.join(root, 'review-lifecycle.md'), + checkPathReviewPrerequisites: (fixture: SharedLibsFixture, file: string) => { + expect(fixture).toBe(f); expect(file).toBe(input); return prerequisites; + }, + fixtureGit: (fixture: SharedLibsFixture, ...args: string[]) => { + expect(fixture).toBe(f); expect(args).toEqual(['symbolic-ref', '--quiet', '--short', 'HEAD']); + return prerequisites.context.binding.branch; + }, + fixtureWorkingTree: (fixture: SharedLibsFixture) => { + expect(fixture).toBe(f); return prerequisites.context.binding.wtree; + }, + reviewRecords: (fixture: SharedLibsFixture) => { expect(fixture).toBe(f); return structuredClone(records); }, + }; + return { run, overrides }; +} diff --git a/test/helpers/ship-queue-train-cases.ts b/test/helpers/ship-queue-train-cases.ts new file mode 100644 index 000000000..5e265932b --- /dev/null +++ b/test/helpers/ship-queue-train-cases.ts @@ -0,0 +1,29 @@ +/** Train setups shared by the test/ship-queue-train*.test.ts files (moved from test/ship-queue-train.test.ts, GSTA-151). */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { GREEN_GATE, nonceOf, openPr, queue, readyReceipt, type Rule, type TrainFixture } from './ship-queue-train-fixture'; + +export function twoPrTrain(fx: TrainFixture, gate: Rule = GREEN_GATE) { + const h1 = openPr(fx, '1'); + const h2 = openPr(fx, '2'); + readyReceipt(fx, '1', h1); + readyReceipt(fx, '2', h2); + fx.write(d => { d.rules['evals.yml'] = gate; }); + const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]); + return { h1, h2, r, nonce: nonceOf(r.out) }; +} + +/** A main commit with a prefix's tree plus a VERSION stamp, the way stamp-at-merge publishes it. */ +export function publishMain(fx: TrainFixture, prefixCommit: string, version: string, extra?: { file: string; text: string }): string { + const { repo } = fx; + repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main'); + repo.git('rm', '-rq', '.'); + repo.git('checkout', prefixCommit, '--', '.'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), version + '\n'); + if (extra) fs.writeFileSync(path.join(repo.work, extra.file), extra.text); + repo.git('add', '-A'); + repo.git('commit', '-q', '-m', `merge to ${version}`); + repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main'); + repo.git('fetch', '-q', 'origin'); + return repo.git('rev-parse', 'HEAD'); +} diff --git a/test/helpers/ubi-runner-fake-vm.ts b/test/helpers/ubi-runner-fake-vm.ts new file mode 100644 index 000000000..20a6a8514 --- /dev/null +++ b/test/helpers/ubi-runner-fake-vm.ts @@ -0,0 +1,119 @@ +/** + * Fake Ubicloud API and fake VM for scripts/ubicloud/ubi-runner.sh, shared by the + * test/ubicloud-runner-*.test.ts parts split from test/ubicloud-runner.test.ts (GSTA-151). + */ +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +export const ROOT = resolve(import.meta.dir, '../..'); +export const DIR = join(ROOT, 'scripts/ubicloud'); +export const read = (rel: string) => readFileSync(join(ROOT, rel), 'utf8'); + +/** + * Fake Ubicloud API that can create: create adds the name to the listed file + * (or answers the quota refusal in refuse with HTTP 400), show is running at + * 192.0.2.1 while listed, destroy unlists. The fake VM is the directory vm/: + * the fake ssh records every remote command in ssh.log, keeps copies of + * ~/.ubirun-env and ~/.ubirun-setup.sh under the fixture root, serves pulls + * from remote-fs/, and runs every other command there for real (HOME=vm/, + * vmbin/ first on PATH), so uploads unpack, detached phases write their pid, + * output and exit files, tail --pid follows them and pkill ends them. The stub + * make in vmbin/ echoes `ran: make ARGS`, or runs command-mode or + * make-.sh from the fixture root. No network, no VM. + */ +export function fakeVm(opts: { listed?: string; refuse?: string } = {}) { + const root = mkdtempSync(join(tmpdir(), 'ubi-run-')); + const bin = join(root, 'bin'), state = join(root, 'state'), log = join(root, 'calls.log'), sshLog = join(root, 'ssh.log'), record = join(root, 'vms.tsv'); + const vm = join(root, 'vm'), vmbin = join(root, 'vmbin'), srcDir = join(root, 'src'), checkpoints = join(root, 'checkpoints'); + mkdirSync(vm); mkdirSync(vmbin); mkdirSync(srcDir); + writeFileSync(join(srcDir, 'README'), 'fixture checkout\n'); + mkdirSync(bin); mkdirSync(state, { recursive: true }); + writeFileSync(join(root, 'listed'), opts.listed ?? ''); + writeFileSync(join(bin, 'curl'), '#!/usr/bin/env bash\n' + + 'out=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\n' + + 'body=$(cat); echo "$body" >> ' + JSON.stringify(log) + '\n' + + 'listed=' + JSON.stringify(join(root, 'listed')) + '\n' + + 'loc=$(printf \'%s\' "$body" | sed -n \'s/.*"vm", *"\\([^/"]*\\)\\/[^"]*".*/\\1/p\')\n' + + 'name=$(printf \'%s\' "$body" | sed -n \'s/.*"vm", *"[^/"]*\\/\\([^"]*\\)".*/\\1/p\')\n' + + 'case "$body" in\n' + + ' *\'"create"\'*) if [ -n ' + JSON.stringify(opts.refuse ?? '') + ' ]; then printf \'%s\' ' + JSON.stringify(opts.refuse ?? '') + ' > "$out"; printf 400; else echo "$loc $name" >> "$listed"; : > "$out"; printf 200; fi ;;\n' + + ' *destroy*) : > "$out"; if [ -f "$listed.destroy-fails" ]; then printf 500; else grep -v " $name$" "$listed" > "$listed.tmp"; mv "$listed.tmp" "$listed"; printf 200; fi ;;\n' + + ' *\'"list"\'*) if [ -f "$listed.list-fails" ]; then echo down > "$out"; printf 503; else cat "$listed" > "$out"; printf 200; fi ;;\n' + + ' *\'"show"\'*) [ -z "${FAKE_SHOW_DELAY:-}" ] || sleep "$FAKE_SHOW_DELAY"; if grep -q " $name$" "$listed"; then printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200; else : > "$out"; printf 404; fi ;;\n' + + ' *) : > "$out"; printf 200 ;;\n' + + 'esac\n'); + writeFileSync(join(bin, 'ssh'), '#!/usr/bin/env bash\n' + + 'root=' + JSON.stringify(root) + '; vm="$root/vm"\n' + + 'cmd=${*: -1}\n' + + 'printf \'%s\\n\' "$cmd" >> "$root/ssh.log"\n' + + 'export HOME="$vm" PATH="$root/vmbin:$PATH"\n' + + 'cd "$vm" || exit 255\n' + + 'case "$cmd" in\n' + + ' *pkill*) echo "$cmd" >> "$root/cancel.log"; exec bash -c "$cmd" ;;\n' + + ' *captured*) cat > "$root/captured" ;;\n' + + ' *"cat > ~/.ubirun-env"*) tee "$root/ubirun-env" > "$vm/.ubirun-env" ;;\n' + + ' *"cat > ~/.ubirun-setup.sh"*) tee "$root/ubirun-setup.sh" > "$vm/.ubirun-setup.sh" ;;\n' + + ' *"tar -czf -"*) base=${cmd##* }; tar -czf - -C "$root/remote-fs" "$base" ;;\n' + + ' *"ls -d -- "*) : ;;\n' + + ' *) exec bash -c "$cmd" ;;\n' + + 'esac\n'); + writeFileSync(join(vmbin, 'make'), '#!/usr/bin/env bash\n' + + 'root=' + JSON.stringify(root) + '\n' + + 'if [ -f "$root/command-mode" ]; then exec bash -c "$(cat "$root/command-mode")"; fi\n' + + 'echo "ran: make $*"\n' + + '[ ! -f "$root/make-$1.sh" ] || exec bash "$root/make-$1.sh"\n'); + chmodSync(join(bin, 'curl'), 0o755); chmodSync(join(bin, 'ssh'), 0o755); chmodSync(join(vmbin, 'make'), 0o755); + const env: Record = { ...(process.env as Record), PATH: `${bin}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: state, UBI_POLL_SECONDS: '1', UBI_VM_RECORD: record, UBI_OWNER: 'thrd1', UBI_HEARTBEAT: '0', UBI_CHECKPOINT_DIR: checkpoints }; + const run = (args: string[], extra: Record = {}, stdin?: string) => + Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), ...args], { env: { ...env, ...extra }, stdin: stdin === undefined ? undefined : Buffer.from(stdin), timeout: 90_000 }); + const spawn = (args: string[], extra: Record = {}) => + Bun.spawn(['bash', join(DIR, 'ubi-runner.sh'), ...args], { env: { ...env, ...extra }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', timeout: 120_000 }); + return { + root, state, record, run, spawn, vm, src: srcDir, checkpoints, + calls: () => (existsSync(log) ? readFileSync(log, 'utf8') : ''), + ssh: () => (existsSync(sshLog) ? readFileSync(sshLog, 'utf8') : ''), + listed: () => readFileSync(join(root, 'listed'), 'utf8'), + }; +} + +export const src = (f: { root: string }, cfg: unknown) => { + const dir = join(f.root, 'repo'); + mkdirSync(join(dir, '.gstack'), { recursive: true }); + mkdirSync(join(dir, 'scripts'), { recursive: true }); + writeFileSync(join(dir, 'scripts/boot.sh'), 'echo boot\n'); + writeFileSync(join(dir, '.gstack/ubicloud.json'), JSON.stringify(cfg)); + return dir; +}; + +export const run = (f: ReturnType, args: string[], extra: Record = {}, stdin?: string) => f.run(args, extra, stdin); + +// GSTA-158 P3: detached phases, reattach and tier checkpoints. +export const until = async (cond: () => boolean, ms: number) => { + const deadline = Date.now() + ms; + while (!cond()) { if (Date.now() > deadline) throw new Error('condition not met in time'); await Bun.sleep(100); } +}; +export const vmDirs = (f: ReturnType) => (existsSync(f.state) ? readdirSync(f.state).filter(d => d.startsWith('ubirun-')) : []); +export const phaseStarted = (f: ReturnType, phase: string) => vmDirs(f).some(d => existsSync(join(f.state, d, 'phase.' + phase + '.start'))); +export const creates = (f: ReturnType) => f.calls().split('\n').filter(l => l.includes('"create"')).length; +export const killSession = async (f: ReturnType, phase: string) => { + const pidFile = join(f.vm, '.ubirun-' + phase + '.pid'); + await until(() => existsSync(pidFile) && readFileSync(pidFile, 'utf8').trim() !== '', 10_000); + const pid = Number(readFileSync(pidFile, 'utf8').trim()); + try { process.kill(-pid, 'SIGKILL'); } catch { /* already gone */ } + await until(() => { try { process.kill(pid, 0); return false; } catch { return true; } }, 10_000); +}; +export const gitSrc = (f: ReturnType, cfg?: unknown) => { + const dir = cfg === undefined ? join(f.root, 'repo') : src(f, cfg); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'README'), 'fixture repo\n'); + for (const args of [['init', '-q', '-b', 'main'], ['add', '-A'], ['commit', '-qm', 'fixture']]) { + const r = Bun.spawnSync(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { cwd: dir, timeout: 30_000 }); + if (r.exitCode !== 0) throw new Error('git ' + args.join(' ') + ': ' + r.stderr.toString()); + } + return dir; +}; +export const TIERS = { setup: 'scripts/boot.sh', tiers: [{ name: 't1', command: 'make t1' }, { name: 't2', command: 'make t2' }, { name: 't3', command: 'make t3' }] }; +export const bootCounts = (f: ReturnType, dir: string) => writeFileSync(join(dir, 'scripts/boot.sh'), 'echo boot >> ~/setup-count\n'); +export const verifyLine = (out: string) => out.split('\n').find(l => l.startsWith('UBI_VERIFY: ')) ?? ''; +export type FakeVm = ReturnType; diff --git a/test/host-renders-prune-migration.test.ts b/test/host-renders-prune-migration.test.ts new file mode 100644 index 000000000..265164ef4 --- /dev/null +++ b/test/host-renders-prune-migration.test.ts @@ -0,0 +1,76 @@ +/** + * #1694 host-render prune: a host linked into the render is kept, and the gstack-upgrade migration prunes existing installs. + * Split from test/host-renders-prune.test.ts so the free-suite packer can balance it (60 s seed ceiling). + */ +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, put, type Fixture } from './helpers/install-fixture'; +import { ROOT, backups, legacyRender, setupOk, skillFiles } from './helpers/host-renders-prune-fixture'; + +afterEach(cleanupFixtures); +afterAll(cleanupSeed); + +describe.skipIf(process.platform === 'win32')('setup prunes unrecorded host renders without losing a file (#1694)', () => { + test('a host whose skills link into the render is kept even with no registry row', async () => { + const f = makeFixture(); + const src = makeSource(f, path.join(f.home, '.claude/skills/gstack')); + await legacyRender(src); + fs.mkdirSync(path.join(f.home, '.factory/skills'), { recursive: true }); + fs.symlinkSync(path.join(src, '.factory/skills/gstack-qa'), path.join(f.home, '.factory/skills/gstack-qa')); + setupOk(f, src); + expect(fs.readFileSync(path.join(src, '.gstack-installed-hosts'), 'utf8')).toEndWith('\nclaude\nfactory\n'); + expect(skillFiles(path.join(src, '.factory')).length).toBeGreaterThan(40); + expect(fs.existsSync(path.join(f.home, '.factory/skills/gstack-qa/SKILL.md'))).toBe(true); + }, 240_000); +}); + +describe.skipIf(process.platform === 'win32')('gstack-upgrade migration prunes existing installs (#1694)', () => { + const migrations = fs.readdirSync(path.join(ROOT, 'gstack-upgrade/migrations')) + .filter(n => fs.readFileSync(path.join(ROOT, 'gstack-upgrade/migrations', n), 'utf8').includes('gstack-host-renders.sh')); + + test('exactly one migration owns the prune, and setup runs it (not newer than VERSION)', () => { + expect(migrations).toEqual([expect.stringMatching(/^v\d+\.\d+\.\d+\.\d+\.sh$/)]); + const parts = (v: string) => v.split('.').map(Number); + const mine = parts(migrations[0]!.slice(1, -3)); + const version = parts(fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim()); + const cmp = mine.map((n, i) => n - version[i]!).find(d => d !== 0) ?? 0; + expect(cmp).toBeLessThanOrEqual(0); + }); + + function migrate(f: Fixture, install: string) { + return spawnSync('bash', [path.join(install, 'gstack-upgrade/migrations', migrations[0]!)], { + cwd: f.home, encoding: 'utf8', timeout: 60_000, env: { ...f.env, GSTACK_INSTALL_DIR: install }, + }); + } + + test('a pre-record Claude install: renders of other hosts are backed up and removed, a look-alike survives, rerun is a no-op', async () => { + const f = makeFixture(); + const src = makeSource(f, path.join(f.home, '.claude/skills/gstack')); + await legacyRender(src); + put(path.join(src, '.opencode/skills/mine/SKILL.md'), 'no banner\n'); + const r = migrate(f, src); + expect(r.status, r.stdout + r.stderr).toBe(0); + expect(fs.readFileSync(path.join(src, '.gstack-installed-hosts'), 'utf8')).toEndWith('\nclaude\n'); + expect(skillFiles(src).filter(rel => rel.startsWith('.'))).toEqual(['.opencode/skills/mine/SKILL.md']); + expect(r.stdout).toContain('kept .opencode/skills/mine/SKILL.md: not proven generated'); + expect(backups(f)).toHaveLength(1); + const again = migrate(f, src); + expect(again.status).toBe(0); + expect(again.stdout).not.toContain('pruned '); + expect(backups(f)).toHaveLength(1); + }, 240_000); + + test('a checkout no install points at is left alone', async () => { + const f = makeFixture(); + const src = makeSource(f, path.join(f.home, 'elsewhere/gstack')); + await legacyRender(src); + const before = skillFiles(src).length; + const r = migrate(f, src); + expect(r.status).toBe(0); + expect(r.stdout).toContain(`host renders: no install from ${src} was found, so its renders were left alone`); + expect(skillFiles(src).length).toBe(before); + expect(fs.existsSync(path.join(src, '.gstack-installed-hosts'))).toBe(false); + }, 240_000); +}); diff --git a/test/host-renders-prune-shared-root.test.ts b/test/host-renders-prune-shared-root.test.ts new file mode 100644 index 000000000..7b21a1390 --- /dev/null +++ b/test/host-renders-prune-shared-root.test.ts @@ -0,0 +1,28 @@ +/** + * #1694 host-render prune: two installs sharing one state root each keep only their own hosts. + * Split from test/host-renders-prune.test.ts so the free-suite packer can balance it (60 s seed ceiling). + */ +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { cleanupFixtures, cleanupSeed, makeFixture, makeSource } from './helpers/install-fixture'; +import { OTHER_DIRS, backups, legacyRender, setupOk, skillFiles } from './helpers/host-renders-prune-fixture'; + +afterEach(cleanupFixtures); +afterAll(cleanupSeed); + +describe.skipIf(process.platform === 'win32')('setup prunes unrecorded host renders without losing a file (#1694)', () => { + test('two installs sharing one state root each end with only their own hosts', async () => { + const f = makeFixture(); + const a = makeSource(f, path.join(f.home, '.claude/skills/gstack')); + const b = makeSource(f, path.join(f.home, 'src-b/gstack')); + await legacyRender(a); + await legacyRender(b); + setupOk(f, a); + setupOk(f, b, ['--host', 'codex']); + for (const dir of OTHER_DIRS) expect(fs.existsSync(path.join(a, dir)), `a ${dir}`).toBe(false); + expect(skillFiles(path.join(b, '.agents')).length).toBeGreaterThan(40); + for (const dir of OTHER_DIRS.filter(d => d !== '.agents')) expect(fs.existsSync(path.join(b, dir)), `b ${dir}`).toBe(false); + expect(backups(f)).toHaveLength(2); + }, 240_000); +}); diff --git a/test/host-renders-prune.test.ts b/test/host-renders-prune.test.ts index 0cbe6995b..fae80feb4 100644 --- a/test/host-renders-prune.test.ts +++ b/test/host-renders-prune.test.ts @@ -8,39 +8,19 @@ * identity) is moved to $GSTACK_STATE_ROOT/backups/host-renders/; anything * else stays; only emptied directories are removed. The legacy layout here is * a real `--host all` render. + * + * Split by test so the free-suite packer can balance the suite (60 s seed ceiling): + * test/host-renders-prune-shared-root.test.ts and test/host-renders-prune-migration.test.ts hold the rest. */ import { afterAll, afterEach, describe, expect, test } from 'bun:test'; -import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { runGeneration } from '../scripts/gen-skill-docs'; -import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, put, runSetup, type Fixture } from './helpers/install-fixture'; - -const ROOT = path.resolve(import.meta.dir, '..'); -const OTHER_DIRS = ['.agents', '.factory', '.kiro', '.opencode', '.cursor', '.copilot', '.agy', '.gemini-cli', '.slate', '.openclaw', '.hermes', '.gbrain']; +import { cleanupFixtures, cleanupSeed, makeFixture, makeSource, put } from './helpers/install-fixture'; +import { backups, legacyRender, setupOk, skillFiles } from './helpers/host-renders-prune-fixture'; afterEach(cleanupFixtures); afterAll(cleanupSeed); -const skillFiles = (dir: string): string[] => !fs.existsSync(dir) ? [] - : (fs.readdirSync(dir, { recursive: true }) as string[]).filter(rel => path.basename(rel) === 'SKILL.md' && fs.statSync(path.join(dir, rel)).isFile()); - -async function legacyRender(src: string): Promise { - const result = await runGeneration({ host: 'all', outputRoot: src }); - if (result.exitCode !== 0) throw new Error('legacy render failed'); -} - -function backups(f: Fixture): string[] { - const dir = path.join(f.home, '.gstack/backups/host-renders'); - return fs.existsSync(dir) ? fs.readdirSync(dir).map(d => path.join(dir, d)) : []; -} - -function setupOk(f: Fixture, src: string, args: string[] = []) { - const r = runSetup(f, path.join(src, 'setup'), args); - expect(r.status, r.stdout + r.stderr).toBe(0); - return r; -} - describe.skipIf(process.platform === 'win32')('setup prunes unrecorded host renders without losing a file (#1694)', () => { test('a legacy all-host render inside a Claude install: generated files backed up, user files kept, links handled', async () => { const f = makeFixture(); @@ -90,79 +70,4 @@ describe.skipIf(process.platform === 'win32')('setup prunes unrecorded host rend expect(again.stdout).not.toContain('host-render backup'); expect(backups(f)).toHaveLength(1); }, 240_000); - - test('two installs sharing one state root each end with only their own hosts', async () => { - const f = makeFixture(); - const a = makeSource(f, path.join(f.home, '.claude/skills/gstack')); - const b = makeSource(f, path.join(f.home, 'src-b/gstack')); - await legacyRender(a); - await legacyRender(b); - setupOk(f, a); - setupOk(f, b, ['--host', 'codex']); - for (const dir of OTHER_DIRS) expect(fs.existsSync(path.join(a, dir)), `a ${dir}`).toBe(false); - expect(skillFiles(path.join(b, '.agents')).length).toBeGreaterThan(40); - for (const dir of OTHER_DIRS.filter(d => d !== '.agents')) expect(fs.existsSync(path.join(b, dir)), `b ${dir}`).toBe(false); - expect(backups(f)).toHaveLength(2); - }, 240_000); - - test('a host whose skills link into the render is kept even with no registry row', async () => { - const f = makeFixture(); - const src = makeSource(f, path.join(f.home, '.claude/skills/gstack')); - await legacyRender(src); - fs.mkdirSync(path.join(f.home, '.factory/skills'), { recursive: true }); - fs.symlinkSync(path.join(src, '.factory/skills/gstack-qa'), path.join(f.home, '.factory/skills/gstack-qa')); - setupOk(f, src); - expect(fs.readFileSync(path.join(src, '.gstack-installed-hosts'), 'utf8')).toEndWith('\nclaude\nfactory\n'); - expect(skillFiles(path.join(src, '.factory')).length).toBeGreaterThan(40); - expect(fs.existsSync(path.join(f.home, '.factory/skills/gstack-qa/SKILL.md'))).toBe(true); - }, 240_000); -}); - -describe.skipIf(process.platform === 'win32')('gstack-upgrade migration prunes existing installs (#1694)', () => { - const migrations = fs.readdirSync(path.join(ROOT, 'gstack-upgrade/migrations')) - .filter(n => fs.readFileSync(path.join(ROOT, 'gstack-upgrade/migrations', n), 'utf8').includes('gstack-host-renders.sh')); - - test('exactly one migration owns the prune, and setup runs it (not newer than VERSION)', () => { - expect(migrations).toEqual([expect.stringMatching(/^v\d+\.\d+\.\d+\.\d+\.sh$/)]); - const parts = (v: string) => v.split('.').map(Number); - const mine = parts(migrations[0]!.slice(1, -3)); - const version = parts(fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim()); - const cmp = mine.map((n, i) => n - version[i]!).find(d => d !== 0) ?? 0; - expect(cmp).toBeLessThanOrEqual(0); - }); - - function migrate(f: Fixture, install: string) { - return spawnSync('bash', [path.join(install, 'gstack-upgrade/migrations', migrations[0]!)], { - cwd: f.home, encoding: 'utf8', timeout: 60_000, env: { ...f.env, GSTACK_INSTALL_DIR: install }, - }); - } - - test('a pre-record Claude install: renders of other hosts are backed up and removed, a look-alike survives, rerun is a no-op', async () => { - const f = makeFixture(); - const src = makeSource(f, path.join(f.home, '.claude/skills/gstack')); - await legacyRender(src); - put(path.join(src, '.opencode/skills/mine/SKILL.md'), 'no banner\n'); - const r = migrate(f, src); - expect(r.status, r.stdout + r.stderr).toBe(0); - expect(fs.readFileSync(path.join(src, '.gstack-installed-hosts'), 'utf8')).toEndWith('\nclaude\n'); - expect(skillFiles(src).filter(rel => rel.startsWith('.'))).toEqual(['.opencode/skills/mine/SKILL.md']); - expect(r.stdout).toContain('kept .opencode/skills/mine/SKILL.md: not proven generated'); - expect(backups(f)).toHaveLength(1); - const again = migrate(f, src); - expect(again.status).toBe(0); - expect(again.stdout).not.toContain('pruned '); - expect(backups(f)).toHaveLength(1); - }, 240_000); - - test('a checkout no install points at is left alone', async () => { - const f = makeFixture(); - const src = makeSource(f, path.join(f.home, 'elsewhere/gstack')); - await legacyRender(src); - const before = skillFiles(src).length; - const r = migrate(f, src); - expect(r.status).toBe(0); - expect(r.stdout).toContain(`host renders: no install from ${src} was found, so its renders were left alone`); - expect(skillFiles(src).length).toBe(before); - expect(fs.existsSync(path.join(src, '.gstack-installed-hosts'))).toBe(false); - }, 240_000); }); diff --git a/test/setup-codex-scope-aliases.test.ts b/test/setup-codex-scope-aliases.test.ts new file mode 100644 index 000000000..e602f22c3 --- /dev/null +++ b/test/setup-codex-scope-aliases.test.ts @@ -0,0 +1,70 @@ +/** + * Split from test/setup-codex-scope-namespaces.test.ts so the free-suite packer can balance it (60 s seed ceiling): supported generation-namespace and generated-skill leaf aliases. + */ +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import { existsSync, readFileSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { cleanupFixtures, cleanupSeed, fixtureWriteFileSync, fixtureMkdirSync, fixtureUtimesSync, tree, fixture, install } from './helpers/install-fixture'; + +afterEach(cleanupFixtures); +afterAll(cleanupSeed); + +describe.skipIf(process.platform === 'win32')('setup Codex destination follows recognized source scope', () => { + for (const level of ['agents', 'skills']) for (const preexisting of [false, true]) for (const windows of [false, true]) test(`internal generation namespace alias remains supported: ${level}, preexisting=${preexisting}, Windows=${windows}`, () => { + const f = fixture('.claude'); + const internal = join(f.source, 'generated-codex'); + fixtureMkdirSync(internal); + if (level === 'agents') symlinkSync(internal, join(f.source, '.agents'), 'dir'); + else { + fixtureMkdirSync(join(f.source, '.agents')); + symlinkSync(internal, join(f.source, '.agents/skills'), 'dir'); + } + const generated = join(f.source, '.agents/skills'); + if (preexisting) { + fixtureMkdirSync(join(generated, 'gstack-review'), { recursive: true }); + fixtureWriteFileSync(join(generated, 'gstack-review/SKILL.md'), 'Prior internal render.\n'); + } + if (windows) { + fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 }); + for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) { + fixtureWriteFileSync(join(f.source, rel), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01')); + } + } + fixtureWriteFileSync(join(f.source, 'uncommitted-work'), 'Preserve source edits.\n'); + const excluded = tree(f.global), sibling = tree(f.other); + const sourceBefore = Object.fromEntries(['SKILL.md', 'bin', 'lib', 'uncommitted-work'].map(rel => [rel, tree(join(f.source, rel))])); + for (let run = 0; run < 2; run++) { + install(f); + expect(tree(f.global)).toEqual(excluded); + expect(tree(f.other)).toEqual(sibling); + for (const [rel, before] of Object.entries(sourceBefore)) expect(tree(join(f.source, rel))).toEqual(before); + expect(readFileSync(join(f.project, '.agents/skills/gstack-review/SKILL.md'))).toEqual(readFileSync(join(generated, 'gstack-review/SKILL.md'))); + expect(existsSync(join(f.source, 'bin/bin'))).toBe(false); + } + }, 90_000); + + for (const windows of [false, true]) test(`individual generated skill leaf alias remains supported, Windows=${windows}`, () => { + const f = fixture('.claude'); + const rendered = join(f.source, '.agents/skills/gstack-review'); + const local = join(f.project, '.agents/skills'); + fixtureMkdirSync(rendered, { recursive: true }); + fixtureMkdirSync(local, { recursive: true }); + fixtureWriteFileSync(join(rendered, 'user-notes'), 'Keep notes beside the source render.\n'); + symlinkSync(rendered, join(local, 'gstack-review'), 'dir'); + if (windows) { + fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 }); + for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) { + fixtureWriteFileSync(join(f.source, rel), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01')); + } + } + const excluded = tree(f.global); + for (let run = 0; run < 2; run++) { + install(f); + expect(tree(f.global)).toEqual(excluded); + expect(readFileSync(join(rendered, 'user-notes'), 'utf8')).toBe('Keep notes beside the source render.\n'); + expect(readFileSync(join(local, 'gstack-review/SKILL.md'))).toEqual(readFileSync(join(rendered, 'SKILL.md'))); + } + }, 90_000); +}); diff --git a/test/setup-codex-scope-namespaces.test.ts b/test/setup-codex-scope-namespaces.test.ts index 765217a29..00a96feff 100644 --- a/test/setup-codex-scope-namespaces.test.ts +++ b/test/setup-codex-scope-namespaces.test.ts @@ -2,7 +2,7 @@ import { afterAll, afterEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import { cpSync, existsSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { cleanupFixtures, cleanupSeed, fixtureWriteFileSync, fixtureCopyFileSync, fixtureMkdirSync, fixtureUtimesSync, tree, fixture, install } from './helpers/install-fixture'; +import { cleanupFixtures, cleanupSeed, fixtureWriteFileSync, fixtureCopyFileSync, fixtureMkdirSync, tree, fixture, install } from './helpers/install-fixture'; afterEach(cleanupFixtures); afterAll(cleanupSeed); @@ -195,64 +195,6 @@ describe.skipIf(process.platform === 'win32')('setup Codex destination follows r } }, 90_000); - for (const level of ['agents', 'skills']) for (const preexisting of [false, true]) for (const windows of [false, true]) test(`internal generation namespace alias remains supported: ${level}, preexisting=${preexisting}, Windows=${windows}`, () => { - const f = fixture('.claude'); - const internal = join(f.source, 'generated-codex'); - fixtureMkdirSync(internal); - if (level === 'agents') symlinkSync(internal, join(f.source, '.agents'), 'dir'); - else { - fixtureMkdirSync(join(f.source, '.agents')); - symlinkSync(internal, join(f.source, '.agents/skills'), 'dir'); - } - const generated = join(f.source, '.agents/skills'); - if (preexisting) { - fixtureMkdirSync(join(generated, 'gstack-review'), { recursive: true }); - fixtureWriteFileSync(join(generated, 'gstack-review/SKILL.md'), 'Prior internal render.\n'); - } - if (windows) { - fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 }); - for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) { - fixtureWriteFileSync(join(f.source, rel), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); - fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01')); - } - } - fixtureWriteFileSync(join(f.source, 'uncommitted-work'), 'Preserve source edits.\n'); - const excluded = tree(f.global), sibling = tree(f.other); - const sourceBefore = Object.fromEntries(['SKILL.md', 'bin', 'lib', 'uncommitted-work'].map(rel => [rel, tree(join(f.source, rel))])); - for (let run = 0; run < 2; run++) { - install(f); - expect(tree(f.global)).toEqual(excluded); - expect(tree(f.other)).toEqual(sibling); - for (const [rel, before] of Object.entries(sourceBefore)) expect(tree(join(f.source, rel))).toEqual(before); - expect(readFileSync(join(f.project, '.agents/skills/gstack-review/SKILL.md'))).toEqual(readFileSync(join(generated, 'gstack-review/SKILL.md'))); - expect(existsSync(join(f.source, 'bin/bin'))).toBe(false); - } - }, 90_000); - - for (const windows of [false, true]) test(`individual generated skill leaf alias remains supported, Windows=${windows}`, () => { - const f = fixture('.claude'); - const rendered = join(f.source, '.agents/skills/gstack-review'); - const local = join(f.project, '.agents/skills'); - fixtureMkdirSync(rendered, { recursive: true }); - fixtureMkdirSync(local, { recursive: true }); - fixtureWriteFileSync(join(rendered, 'user-notes'), 'Keep notes beside the source render.\n'); - symlinkSync(rendered, join(local, 'gstack-review'), 'dir'); - if (windows) { - fixtureWriteFileSync(join(f.dir, 'commands/uname'), '#!/bin/sh\nprintf "MINGW64_NT-10.0\\n"\n', { mode: 0o755 }); - for (const rel of ['browse/dist/browse.exe', 'design/dist/design.exe', 'make-pdf/dist/pdf.exe']) { - fixtureWriteFileSync(join(f.source, rel), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); - fixtureUtimesSync(join(f.source, rel), new Date('2040-01-01'), new Date('2040-01-01')); - } - } - const excluded = tree(f.global); - for (let run = 0; run < 2; run++) { - install(f); - expect(tree(f.global)).toEqual(excluded); - expect(readFileSync(join(rendered, 'user-notes'), 'utf8')).toBe('Keep notes beside the source render.\n'); - expect(readFileSync(join(local, 'gstack-review/SKILL.md'))).toEqual(readFileSync(join(rendered, 'SKILL.md'))); - } - }, 90_000); - for (const target of ['dangling-skills', 'dangling-agents', 'cyclic-skills', 'file-skills']) test(`unresolvable namespace is refused without mutation: ${target}`, () => { const f = fixture('.claude'); const agents = join(f.project, '.agents'); diff --git a/test/setup-codex-scope-review-boundaries.test.ts b/test/setup-codex-scope-review-boundaries.test.ts index 02c2da50d..51ff302fd 100644 --- a/test/setup-codex-scope-review-boundaries.test.ts +++ b/test/setup-codex-scope-review-boundaries.test.ts @@ -1,45 +1,16 @@ import { afterAll, afterEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; -import { existsSync, lstatSync, rmSync, symlinkSync } from 'node:fs'; +import { symlinkSync } from 'node:fs'; import { join } from 'node:path'; -import { cleanupFixtures, cleanupSeed, fixtureWriteFileSync, fixtureCopyFileSync, fixtureMkdirSync, fixtureUtimesSync, tree, fixture, install } from './helpers/install-fixture'; +import { cleanupFixtures, cleanupSeed, fixtureWriteFileSync, fixtureCopyFileSync, fixtureMkdirSync, fixtureUtimesSync, tree, fixture } from './helpers/install-fixture'; +import { claudeOnlySetupPreservesCyclicLink } from './helpers/setup-codex-scope-cyclic'; afterEach(cleanupFixtures); afterAll(cleanupSeed); describe.skipIf(process.platform === 'win32')('F13 independent review boundaries', () => { - for (const target of ['legacy', 'replacement', 'legacy-skill', 'runtime', 'runtime-bin', 'runtime-lib']) { - test(`Claude-only setup preserves cyclic foreign ownership link: ${target}`, () => { - const f = fixture('ordinary'); - rmSync(join(f.global, 'gstack-retired')); - for (const rel of ['bin', 'lib']) { - const asset = join(f.global, 'gstack', rel); - rmSync(asset); - symlinkSync(join(f.source, rel), asset, 'dir'); - } - const old = join(f.global, 'gstack-claude'); - rmSync(old, { recursive: true }); - const oldRender = join(f.source, '.agents/skills/gstack-claude'); - fixtureMkdirSync(oldRender, { recursive: true }); - fixtureWriteFileSync(join(oldRender, 'SKILL.md'), '\n\nPrior legacy render.\n'); - symlinkSync(oldRender, old, 'dir'); - const link = target === 'legacy' ? old - : target === 'replacement' ? join(f.global, 'gstack-claude-code') - : target === 'legacy-skill' ? join(old, 'SKILL.md') - : target === 'runtime' ? join(f.global, 'gstack') - : join(f.global, 'gstack', target === 'runtime-bin' ? 'bin' : 'lib'); - if (target === 'legacy-skill') { - rmSync(old); - fixtureMkdirSync(old); - } - if (existsSync(link) || lstatSync(link, { throwIfNoEntry: false })) rmSync(link, { recursive: true }); - symlinkSync(link, link); - const before = tree(f.global); - for (let run = 0; run < 2; run++) { - install(f, '--host claude'); - expect(tree(f.global)).toEqual(before); - } - }, 90_000); + for (const target of ['legacy', 'replacement', 'legacy-skill']) { + test(`Claude-only setup preserves cyclic foreign ownership link: ${target}`, () => claudeOnlySetupPreservesCyclicLink(target), 90_000); } for (const alias of [false, true]) for (const windows of [false, true]) { @@ -86,5 +57,4 @@ describe.skipIf(process.platform === 'win32')('F13 independent review boundaries } }, 90_000); } - }); diff --git a/test/setup-codex-scope-runtime-links.test.ts b/test/setup-codex-scope-runtime-links.test.ts new file mode 100644 index 000000000..3980e059d --- /dev/null +++ b/test/setup-codex-scope-runtime-links.test.ts @@ -0,0 +1,16 @@ +/** + * F13 independent review boundaries: Claude-only setup preserves cyclic foreign ownership links inside the runtime. + * Split from test/setup-codex-scope-review-boundaries.test.ts so the free-suite packer can balance it (60 s seed ceiling). + */ +import { afterAll, afterEach, describe, test } from 'bun:test'; +import { cleanupFixtures, cleanupSeed } from './helpers/install-fixture'; +import { claudeOnlySetupPreservesCyclicLink } from './helpers/setup-codex-scope-cyclic'; + +afterEach(cleanupFixtures); +afterAll(cleanupSeed); + +describe.skipIf(process.platform === 'win32')('F13 independent review boundaries', () => { + for (const target of ['runtime', 'runtime-bin', 'runtime-lib']) { + test(`Claude-only setup preserves cyclic foreign ownership link: ${target}`, () => claudeOnlySetupPreservesCyclicLink(target), 90_000); + } +}); diff --git a/test/shared-libs-checker-interface-evidence-declared.test.ts b/test/shared-libs-checker-interface-evidence-declared.test.ts new file mode 100644 index 000000000..2682870cb --- /dev/null +++ b/test/shared-libs-checker-interface-evidence-declared.test.ts @@ -0,0 +1,178 @@ +// Split from test/shared-libs-checker-interface-evidence.test.ts (GSTA-151): the cases that replay the declared-protocol captures. +import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { createHash } from 'node:crypto'; +import { sharedLibsFingerprint } from '../lib/review-evidence'; +import { hasTrustedSharedLibsCheck, hasTrustedReviewStartRead } from './helpers/shared-libs-review-start-evidence'; +import { + fixtureGit, fixtureWorkingTree, reviewRecords, SHARED_LIBS_ROOT, toolCommandTrace, type SharedLibsFixture, +} from './helpers/shared-libs-eval-fixture'; +import { + preparePathEligibilityFixture, checkPathReviewPrerequisites, hasPathReviewPrerequisiteReceipt, +} from './helpers/shared-libs-path-fixture'; +import { fixtures, captures, pathKinds, capture, replay, call, result, pathCallback } from './helpers/shared-libs-checker-evidence'; + +const source = fs.readFileSync(path.join(import.meta.dir, 'skill-e2e-shared-libs.test.ts'), 'utf8'); + +beforeAll(async () => { + for (const change of ['unchanged', 'secondary', 'branch', 'filtered']) captures.set(`declared-${change}`, await capture(change, undefined, true)); + for (const kind of pathKinds) captures.set(`declared-path-${kind}`, await capture(`path-${kind}`, preparePathEligibilityFixture(kind), true)); +}, 120_000); +afterAll(() => { for (const fixture of fixtures) fs.rmSync(fixture.root, { recursive: true, force: true }); }); + +describe('native executable shared-code checker evidence', () => { + test('the actual path callback consumes the current synthetic prerequisite result without claiming native coverage', async () => { + const run = replay('declared-path-assume-unchanged'); + run.kind = 'assume-unchanged'; + let verified = 0; + const adapter = pathCallback(run, { hasPathReviewPrerequisiteReceipt: (events: any[], command: string, expected: any) => { + verified++; + expect(command).toBe(run.prepared.resumed.checkCommand); + expect(expected).toMatchObject({ synthetic: true, native_coverage: false, settled: true, current: true }); + expect(expected.context.binding.repo).toBe(run.f.repo); + expect(expected.context.binding.state).toBe(run.f.state); + expect(expected.context.binding.index).toMatch(/^h /m); + return hasPathReviewPrerequisiteReceipt(events, command, expected); + } }); + await adapter.invoke(); + expect(verified).toBe(1); + expect(adapter.rows[0].row.passed).toBe(true); + expect(adapter.rows[0].row.transcript[0]).toMatchObject({ + prerequisite_source: 'synthetic-fixture-input', prerequisite_native_coverage: false, + }); + const refused = pathCallback(run, { hasPathReviewPrerequisiteReceipt: () => false }); + await expect(refused.invoke()).rejects.toThrow('consume current synthetic prerequisites'); + expect(refused.rows[0].row.passed).toBe(false); + }); + + test.each(['missing', 'failed', 'unpaired', 'assistant-only', 'caption', 'false', 'foreign state', 'after finish'])( + 'the registered path callback rejects %s prerequisite receipts even with a completed record', async invalid => { + const run = replay('declared-path-ignored'); + run.kind = 'ignored'; + const at = run.events.findIndex((event: any) => event.type === 'assistant' + && event.message.content[0].input?.command === run.prepared.resumed.checkCommand); + expect(at).toBeGreaterThan(run.checkAt); + if (invalid === 'missing') run.events.splice(at, 2); + if (invalid === 'failed') result(run, at).is_error = true; + if (invalid === 'unpaired') result(run, at).tool_use_id = 'another-call'; + if (invalid === 'assistant-only') run.events[at + 1].type = 'assistant'; + if (invalid === 'caption') call(run, at).input = { command: 'true', description: run.prepared.resumed.checkCommand }; + if (invalid === 'false' || invalid === 'foreign state') { + const receipt = JSON.parse(result(run, at).content); + if (invalid === 'false') receipt.settled = false; + else receipt.context.binding.state = '/another-fixture/state'; + result(run, at).content = JSON.stringify(receipt); + } + if (invalid === 'after finish') run.events.push(...run.events.splice(at, 2)); + const adapter = pathCallback(run); + await expect(adapter.invoke()).rejects.toThrow('consume current synthetic prerequisites'); + expect(adapter.rows[0].row.passed).toBe(false); + }); + + test.each(['raw source', 'supplied context'])('the registered callback rejects late changes to %s', async changed => { + const run = replay('declared-path-assume-unchanged'); + run.kind = 'assume-unchanged'; + const file = changed === 'raw source' ? path.join(run.f.repo, 'src/retry-route.ts') : run.prepared.resumed.input; + const before = fs.readFileSync(file, 'utf8'); + const adapter = pathCallback(run, { runSharedInteractive: async () => { + fs.writeFileSync(file, before + '\n'); + return { questions: run.questions, result: { exitReason: 'success', output: '', events: run.events, + toolCalls: run.events.filter((event: any) => event.type === 'assistant') + .map((event: any) => ({ tool: event.message.content[0].name, input: event.message.content[0].input })) } }; + } }); + try { + await expect(adapter.invoke()).rejects.toThrow('prerequisite state must remain unchanged'); + expect(adapter.rows[0].row.passed).toBe(false); + } finally { fs.writeFileSync(file, before); } + }); + + test.each(['unchanged', 'secondary', 'branch', 'filtered'])('declared receipt commands satisfy the real %s revalidation callback', change => { + const run = replay(`declared-${change}`); + expect(result(run, 0).content.trim()).toBe(run.token); + expect(JSON.parse(result(run).content)).toEqual(run.receipt); + expect(call(run, run.finishAt).input.command).toContain(`--finish ${run.token} && '${SHARED_LIBS_ROOT}/bin/gstack-review-read'`); + expect(run.questions).toHaveLength(change === 'unchanged' ? 0 : 1); + const scenario = source.slice(source.indexOf("test('shared-libs-review-revalidation'")); + const marker = '}, result => {'; + const start = scenario.indexOf(marker) + marker.length; + const body = scenario.slice(start, scenario.indexOf('\n });', start)); + const verify = new Function('deps', 'result', new Bun.Transpiler({ loader: 'ts' }).transformSync(`const { + change, questions, expect, toolCommandTrace, reviewRecords, createHash, path, f, current, + fixtureGit, fixtureWorkingTree, hasTrustedReviewStartRead, hasTrustedSharedLibsCheck, SHARED_LIBS_ROOT, + resumed, prerequisites, checkPathReviewPrerequisites, hasPathReviewPrerequisiteReceipt + } = deps; ${body}`)); + const native = { events: run.events, toolCalls: run.events.filter((event: any) => event.type === 'assistant') + .map((event: any) => ({ tool: event.message.content[0].name, input: event.message.content[0].input })) }; + let checks = 0; + verify({ change, questions: run.questions, expect, toolCommandTrace, reviewRecords, createHash, path, + f: run.f, current: run.current, fixtureGit, fixtureWorkingTree, hasTrustedReviewStartRead, SHARED_LIBS_ROOT, + resumed: run.resumed, prerequisites: run.prerequisites, checkPathReviewPrerequisites, hasPathReviewPrerequisiteReceipt, + hasTrustedSharedLibsCheck: (events: unknown[], expected: any) => { + checks++; + expect(events).toBe(run.events); + expect(expected).toEqual(run.expected); + return hasTrustedSharedLibsCheck(events, expected); + } }, native); + expect(checks).toBe(1); + }); + + test.each(pathKinds)('declared receipt commands satisfy the real %s path callback after an actual actor decision', async kind => { + const run = replay(`declared-path-${kind}`); + run.kind = kind; + expect(result(run, 0).content.trim()).toBe(run.token); + expect(JSON.parse(result(run).content)).toEqual(run.receipt); + expect(run.receipt.reusable).toBe(false); + expect(run.questions).toHaveLength(1); + let checks = 0; + const adapter = pathCallback(run, { hasTrustedSharedLibsCheck: (events: unknown[], expected: any) => { + checks++; + expect(expected).toEqual(run.expected); + return hasTrustedSharedLibsCheck(events, expected); + } }); + await adapter.invoke(); + expect(checks).toBe(1); + expect(adapter.rows).toMatchObject([{ scenario: kind, row: { passed: true } }]); + }); + + test.each(['revised-only identity', 'unsupported finding', 'unfinished review', 'missing explicit decision'])( + 'canonical transport cannot waive %s in the real ignored-path callback', async failure => { + const run = replay('declared-path-ignored'); + run.kind = 'ignored'; + if (failure === 'missing explicit decision') run.questions = []; + const adapter = pathCallback(run, { reviewRecords: (fixture: SharedLibsFixture) => { + const records = reviewRecords(fixture); + const final = records.at(-1); + if (failure === 'revised-only identity') { + const revised = { ...final.findings[0], evidence_paths: [ + 'src/retry-worker.ts', 'src/scheduler.ts', 'src/retry-route.ts', 'lib/retry-after.ts', + ] }; + revised.fingerprint = sharedLibsFingerprint(revised); + revised.snapshot_covered_paths = [...revised.evidence_paths]; + expect(revised.fingerprint).not.toBe(run.current.fingerprint); + final.findings = [revised]; + } + if (failure === 'unsupported finding') final.findings = []; + if (failure === 'unfinished review') final.completed = false; + return records; + } }); + await expect(adapter.invoke()).rejects.toThrow(); + expect(adapter.rows).toMatchObject([{ scenario: 'ignored', row: { passed: false } }]); + }); + + test.each(['start batching', 'mixed checker stdout', 'finish metadata prelude', 'loop-only caller reads'])( + 'the unchanged detector still rejects %s after protocol declaration', shape => { + const run = replay('declared-unchanged'); + if (shape === 'start batching') call(run, 0).input.command = `echo start; ${call(run, 0).input.command}; git diff origin/main`; + if (shape === 'mixed checker stdout') result(run).content = `checker receipt:\n${result(run).content}\nexit=0`; + if (shape === 'finish metadata prelude') call(run, run.finishAt).input.command = `TS=$(date -u); ${call(run, run.finishAt).input.command}`; + if (shape === 'loop-only caller reads') { + for (const event of run.events) for (const block of event.message.content) { + if (block.type === 'tool_use' && block.name === 'Bash' && block.input.command.startsWith('cat ')) { + block.input.command = `for f in ${block.input.command.slice(4)}; do cat "$f"; done`; + } + } + } + expect(hasTrustedSharedLibsCheck(run.events, run.expected)).toBe(false); + }); +}); diff --git a/test/shared-libs-checker-interface-evidence-replays.test.ts b/test/shared-libs-checker-interface-evidence-replays.test.ts new file mode 100644 index 000000000..01359776a --- /dev/null +++ b/test/shared-libs-checker-interface-evidence-replays.test.ts @@ -0,0 +1,156 @@ +// Split from test/shared-libs-checker-interface-evidence.test.ts (GSTA-151): the cases that need no beforeAll capture (R59 public attempts, synthetic prerequisites, captured gate reads). +import { describe, expect, test } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { hasTrustedSharedLibsCheck } from './helpers/shared-libs-review-start-evidence'; +import { fixtureGit, fixtureWorkingTree } from './helpers/shared-libs-eval-fixture'; +import { preparePathEligibilityFixture, checkPathReviewPrerequisites } from './helpers/shared-libs-path-fixture'; +import { pathCallback, capturedPathRun } from './helpers/shared-libs-checker-evidence'; +import loopRead from './fixtures/shared-libs-index-flags-loop-read-39173752116.json'; +import erroredRead from './fixtures/shared-libs-path-ignored-errored-read-40617200857.json'; + +describe('native executable shared-code checker evidence', () => { + test.each([0, 1])('the complete R59 public attempt %i satisfies the actual callback without a manual fingerprint call', async index => { + const { run, overrides } = capturedPathRun(index); + const commands = run.events.flatMap(event => event.message.content) + .filter(block => block.type === 'tool_use' && block.name === 'Bash').map(block => block.input.command).join('\n'); + expect(commands).not.toContain('sharedLibsFingerprint'); + let checks = 0; + const adapter = pathCallback(run, { ...overrides, hasTrustedSharedLibsCheck: (events: unknown[], expected: any) => { + checks++; + expect(events).toBe(run.events); + expect(expected.finding).toEqual(run.prepared.current); + expect(expected.reusable).toBe(false); + expect(expected.coveredPaths).toEqual(['src/retry-worker.ts', 'lib/retry-after.ts']); + return hasTrustedSharedLibsCheck(events, expected); + } }); + await adapter.invoke(); + expect(checks).toBe(1); + expect(adapter.rows).toMatchObject([{ scenario: 'assume-unchanged', row: { passed: true } }]); + const refused = pathCallback(run, { ...overrides, hasTrustedSharedLibsCheck: () => false }); + await expect(refused.invoke()).rejects.toThrow(); + expect(refused.rows[0].row.passed).toBe(false); + }); + + test.each([0, 1].flatMap(index => ['missing', 'failed', 'unpaired', 'fingerprint', 'binding', 'extra coverage'].map(invalid => [index, invalid] as const)))( + 'the R59 public attempt %i callback rejects a %s checker receipt', async (index, invalid) => { + const { run, overrides } = capturedPathRun(index); + const blocks = run.events.flatMap(event => event.message.content); + const check = blocks.find(block => block.type === 'tool_use' && block.input.command?.includes('--check-shared-libs')); + const receipt = blocks.find(block => block.type === 'tool_result' && block.tool_use_id === check.id); + if (invalid === 'missing') receipt.content = ''; + else if (invalid === 'failed') receipt.is_error = true; + else if (invalid === 'unpaired') receipt.tool_use_id = 'unpaired-checker'; + else { + const value = JSON.parse(receipt.content); + if (invalid === 'fingerprint') value.fingerprint = `shared-libs:${'a'.repeat(64)}`; + if (invalid === 'binding') value.review_start.started_at = 'foreign'; + if (invalid === 'extra coverage') value.snapshot.covered_paths.push('src/retry-route.ts'); + receipt.content = JSON.stringify(value); + } + let checks = 0; + const adapter = pathCallback(run, { ...overrides, hasTrustedSharedLibsCheck: (events: unknown[], expected: any) => { + checks++; + return hasTrustedSharedLibsCheck(events, expected); + } }); + await expect(adapter.invoke()).rejects.toThrow(); + expect(checks).toBe(1); + expect(adapter.rows[0].row.passed).toBe(false); + }); + + test.each(['missing file', 'missing QA', 'empty probes', 'failed probe', 'missing native', 'failed native', + 'blocked native', 'foreign state', 'branch', 'base', 'index flag', 'raw hidden source', 'configuration', 'structured required'])( + 'synthetic prerequisites cannot settle with %s', async invalid => { + const prepared = preparePathEligibilityFixture('assume-unchanged'), f = prepared.fixture; + try { + const original = checkPathReviewPrerequisites(f, prepared.resumed.input); + expect(original.settled).toBe(true); + const context = structuredClone(original.context); + if (invalid === 'missing QA') delete context.qa; + if (invalid === 'empty probes') context.qa.required_probes = []; + if (invalid === 'failed probe') context.qa.required_probes[0].status = 'failed'; + if (invalid === 'missing native') delete context.native_adversarial; + if (invalid === 'failed native' || invalid === 'blocked native') context.native_adversarial.status = invalid.split(' ')[0]; + if (invalid === 'foreign state') context.binding.state = '/another-fixture/state'; + if (invalid === 'structured required') context.structured_review.required = true; + fs.writeFileSync(prepared.resumed.input, JSON.stringify(context)); + if (invalid === 'missing file') fs.unlinkSync(prepared.resumed.input); + if (invalid === 'branch') fixtureGit(f, 'checkout', '-b', 'another-branch'); + if (invalid === 'base') fixtureGit(f, 'update-ref', 'refs/remotes/origin/main', 'HEAD~1'); + if (invalid === 'index flag') fixtureGit(f, 'update-index', '--no-assume-unchanged', 'src/retry-route.ts'); + if (invalid === 'raw hidden source') { + fs.appendFileSync(path.join(f.repo, 'src/retry-route.ts'), '\n// Changed after the synthetic QA result\n'); + expect(fixtureWorkingTree(f)).toBe(original.context.binding.wtree); + } + if (invalid === 'configuration') fixtureGit(f, 'config', 'core.ignorecase', 'true'); + const checked = checkPathReviewPrerequisites(f, prepared.resumed.input); + expect(checked.settled).toBe(false); + expect(JSON.parse(execFileSync('bash', ['-c', prepared.resumed.checkCommand], { + cwd: f.repo, env: { ...process.env, ...f.env }, encoding: 'utf8', timeout: 30_000, + }))).toEqual(checked); + const run = { f, prepared, kind: 'assume-unchanged', events: [] }; + const adapter = pathCallback(run); + await expect(adapter.invoke()).rejects.toThrow('fixture prerequisites must be settled before capture'); + } finally { fs.rmSync(f.root, { recursive: true, force: true }); } + }); +}); + +// Gate 39173752116 slice 12: the skip-worktree review read every evidence file in one +// literal for-loop that printed each path before its contents. +describe('literal loop reads from the captured skip-worktree review', () => { + const loop = (events: any[]) => events.flatMap(event => event.message.content) + .find((block: any) => block.type === 'tool_use' && block.input?.command?.startsWith('for f in ')); + const output = (events: any[], id: string) => events.flatMap(event => event.message.content) + .find((block: any) => block.type === 'tool_result' && block.tool_use_id === id); + + test('a literal loop whose output names each evidence file counts as reading it', () => { + expect(hasTrustedSharedLibsCheck(structuredClone(loopRead.events), loopRead.expected)).toBe(true); + }); + + test.each(['variable list', 'file missing from output', 'path outside the repo'])('%s is still rejected', shape => { + const events = structuredClone(loopRead.events), call = loop(events), files = 'src/retry-worker.ts src/retry-route.ts lib/retry-after.ts'; + expect(call.input.command).toContain(`for f in ${files} `); + if (shape === 'variable list') call.input.command = `FILES="${files}"; ${call.input.command.replace(files, '$FILES')}`; + if (shape === 'file missing from output') { + const result = output(events, call.id); + result.content = JSON.parse(JSON.stringify(result.content).replaceAll('src/retry-route.ts', 'src/omitted.ts')); + } + if (shape === 'path outside the repo') call.input.command = call.input.command.replace(' src/retry-route.ts ', ' ../outside/src/retry-route.ts '); + expect(hasTrustedSharedLibsCheck(events, loopRead.expected)).toBe(false); + }); +}); + +// Gate 40617200857 slice 8: the ignored-path review returned all of src/retry-worker.ts through +// cat -n, then a later `echo ===` in the same call failed, so the result carried is_error. +describe('errored Bash reads from the captured ignored-path review', () => { + const blocks = (events: any[]) => events.flatMap(event => event.message.content); + const read = (events: any[]) => blocks(events).find((block: any) => block.type === 'tool_use' + && block.input?.command?.startsWith('cat -n src/retry-worker.ts; echo ===;')); + const output = (events: any[], id: string) => blocks(events).find((block: any) => block.type === 'tool_result' && block.tool_use_id === id); + + test('a whole evidence source returned before a later failure counts as reading it', () => { + const events = structuredClone(erroredRead.events); + expect(output(events, read(events).id).is_error).toBe(true); + expect(hasTrustedSharedLibsCheck(events, erroredRead.expected)).toBe(true); + }); + + test.each(['no expected sources', 'empty expected source', 'last source line missing', 'missing file', 'errored single read', 'errored native Read', 'errored checker'])('%s is still rejected', shape => { + const events = structuredClone(erroredRead.events), call = read(events), result = output(events, call.id); + const expected: any = structuredClone(erroredRead.expected); + if (shape === 'no expected sources') delete expected.sources; + if (shape === 'empty expected source') expected.sources['src/retry-worker.ts'] = ''; + if (shape === 'last source line missing') result.content = result.content.replace(/\n +15\t\}\n/, '\n'); + if (shape === 'missing file') result.content = 'Exit code 1\ncat: src/retry-worker.ts: No such file or directory'; + if (shape === 'errored single read') call.input.command = 'cat -n src/retry-worker.ts'; + if (shape === 'errored native Read') { + call.name = 'Read'; + call.input = { file_path: 'src/retry-worker.ts' }; + } + if (shape === 'errored checker') { + const checker = blocks(events).find((block: any) => block.type === 'tool_use' && block.input?.command?.includes('--check-shared-libs')); + output(events, checker.id).is_error = true; + } + expect(hasTrustedSharedLibsCheck(events, expected)).toBe(false); + }); +}); diff --git a/test/shared-libs-checker-interface-evidence.test.ts b/test/shared-libs-checker-interface-evidence.test.ts index 084677cc4..4159746c2 100644 --- a/test/shared-libs-checker-interface-evidence.test.ts +++ b/test/shared-libs-checker-interface-evidence.test.ts @@ -1,373 +1,26 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'; -import { execFileSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { createHash } from 'node:crypto'; -import { sharedLibsFingerprint } from '../lib/review-evidence'; import { hasTrustedSharedLibsCheck, hasTrustedReviewStartRead } from './helpers/shared-libs-review-start-evidence'; import { - createSharedInteractiveToolHandler, createSharedLibsFixture, fixtureGit, fixtureWorkingTree, fixtureWrite, installNormalizingFilter, - reviewLifecycleInstructions, reviewRevalidationPrompt, reviewRecords, seedReviewSources, seedSkippedAdvisory, - SHARED_LIBS_ROOT, shellQuote, toolCommandTrace, + fixtureGit, fixtureWorkingTree, reviewRecords, SHARED_LIBS_ROOT, shellQuote, toolCommandTrace, type SharedLibsFixture, } from './helpers/shared-libs-eval-fixture'; import { - preparePathEligibilityFixture, seedPathReviewPrerequisites, checkPathReviewPrerequisites, hasPathReviewPrerequisiteReceipt, type PathEligibilityFixture, + preparePathEligibilityFixture, checkPathReviewPrerequisites, hasPathReviewPrerequisiteReceipt, } from './helpers/shared-libs-path-fixture'; -import { CAPTURE_LONG_MS } from './helpers/eval-budgets'; -import capturedChecker from './fixtures/shared-libs-index-flags-r59-checker-public.json'; -import loopRead from './fixtures/shared-libs-index-flags-loop-read-39173752116.json'; -import erroredRead from './fixtures/shared-libs-path-ignored-errored-read-40617200857.json'; +import { helper, fixtures, captures, pathKinds, capture, replay, call, result, alterReceipt, pathCallback } from './helpers/shared-libs-checker-evidence'; -const helper = path.join(SHARED_LIBS_ROOT, 'bin/gstack-review-log'); -const fixtures: SharedLibsFixture[] = []; -const captures = new Map(); const source = fs.readFileSync(path.join(import.meta.dir, 'skill-e2e-shared-libs.test.ts'), 'utf8'); -const pathSource = fs.readFileSync(path.join(import.meta.dir, 'skill-e2e-shared-libs-paths.test.ts'), 'utf8'); -const pathKinds = ['symlinks', 'submodule', 'ignored', 'legacy', 'assume-unchanged', 'skip-worktree', 'removed-filter'] as const; - -async function capture(change: string, prepared?: PathEligibilityFixture, declared = false) { - const f = prepared?.fixture ?? createSharedLibsFixture(`checker-${change}`); - fixtures.push(f); - let current = prepared?.current; - if (!current) { - seedReviewSources(f); - fixtureWrite(f, 'src/retry-worker.ts', fs.readFileSync(path.join(f.repo, 'src/retry-worker.ts'), 'utf8') - .replace('const unusedRetryDiagnostic = "unused";\n', '')); - if (change === 'filtered') installNormalizingFilter(f); - const { action: _action, ...finding } = await seedSkippedAdvisory(f); - current = finding; - if (change === 'branch') fixtureGit(f, 'checkout', '-b', 'feature-a'); - if (change === 'secondary') fixtureWrite(f, 'src/retry-route.ts', - fs.readFileSync(path.join(f.repo, 'src/retry-route.ts'), 'utf8') + '// Changed caller\n'); - if (change === 'filtered') fixtureWrite(f, 'src/retry-route.ts', - fs.readFileSync(path.join(f.repo, 'src/retry-route.ts'), 'utf8') + '// RAW-ONLY changed caller\n'); - } - const events: any[] = []; - const invoke = (command: string) => { - const id = `call-${events.length}`; - events.push({ type: 'assistant', message: { content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] } }); - const output = execFileSync('bash', ['-c', command], { cwd: f.repo, env: { ...process.env, ...f.env }, - encoding: 'utf8', timeout: 30_000 }); - events.push({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, content: output }] } }); - return output.trim(); - }; - const instructionFile = reviewLifecycleInstructions(f); - const instructions = fs.readFileSync(instructionFile, 'utf8'); - const resumed = prepared?.resumed ?? seedPathReviewPrerequisites(f); - const prerequisites = checkPathReviewPrerequisites(f, resumed.input); - const protocol = declared ? [...reviewRevalidationPrompt(f, instructionFile, path.join(f.root, 'current-advisory.jsonl'), resumed) - .matchAll(/```bash\n([\s\S]*?)\n```/g)].map(match => match[1]) : []; - if (declared) expect(protocol).toHaveLength(3); - const startCommand = instructions.match(/```bash\n(DIFF_BASE=\$[\s\S]*?)\n```/)?.[1]; - expect(startCommand).toBeDefined(); - const token = invoke(declared ? protocol[0] : startCommand!).split('\n')[0]; - if (declared) invoke('git diff origin/main'); - for (const file of current.evidence_paths) invoke(`cat ${shellQuote(file)}`); - const checkAt = events.length; - const receipt = JSON.parse(invoke(declared ? protocol[1].replace('REVIEW_START', token).replace('CURRENT_FINDING_JSON', JSON.stringify(current)) - : `${shellQuote(helper)} --check-shared-libs ${token} <<'FINDING'\n${JSON.stringify(current)}\nFINDING`)); - const questions: any[] = []; - if (declared && !receipt.reusable) { - const input = { questions: [{ question: 'Revalidate this supplied authored-source advisory?', - options: [{ label: 'Fix', description: 'Apply the extraction.' }, - { label: 'Skip', description: 'Keep the source and index unchanged; record this advisory decision.' }] }] }; - const callback = createSharedInteractiveToolHandler('skip', { - nonQuestion: () => { throw new Error('No non-question tool is allowed by this free actor'); }, - onQuestion: value => { questions.push(value); }, onAnswer: () => {}, - }); - const id = `decision-${events.length}`; - events.push({ type: 'assistant', message: { content: [{ type: 'tool_use', id, name: 'AskUserQuestion', input }] } }); - const answer = await callback('AskUserQuestion', input); - expect(answer.updatedInput.answers).toEqual({ [input.questions[0].question]: 'Skip' }); - events.push({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: id, - content: JSON.stringify(answer.updatedInput.answers) }] } }); - } - { - const settled = JSON.parse(invoke(resumed.checkCommand)); - expect(settled).toEqual(checkPathReviewPrerequisites(f, resumed.input)); - expect(settled).toMatchObject({ synthetic: true, native_coverage: false, settled: true, current: true }); - } - const finishAt = events.length; - const record = JSON.stringify({ skill: 'review', status: 'clean', issues_found: 0, - completed: true, converged: true, findings: change === 'unchanged' ? [] : [{ ...current, action: 'skipped' }] }); - invoke(declared ? protocol[2].replace("'FINAL_REVIEW_JSON'", shellQuote(record)).replace('REVIEW_START', token) - : `${shellQuote(helper)} ${shellQuote(record)} --finish ${token} && ${shellQuote(path.join(SHARED_LIBS_ROOT, 'bin/gstack-review-read'))}`); - const expected = { helper, repo: f.repo, state: f.state, slug: 'fixture-shared-libs', - directory: path.join(f.state, 'projects/fixture-shared-libs/.review-starts'), - branch: fixtureGit(f, 'symbolic-ref', '--quiet', '--short', 'HEAD'), wtree: fixtureWorkingTree(f), - startedAt: receipt.review_start.started_at, finding: current, reusable: change === 'unchanged', - coveredPaths: receipt.snapshot.covered_paths, - ...(prepared ? { sources: Object.fromEntries(current!.evidence_paths.map((file: string) => [file, fs.readFileSync(path.join(f.repo, file), 'utf8')])) } : {}) }; - return { f, current, token, receipt, events, checkAt, finishAt, expected, prepared, resumed, prerequisites, ...(declared ? { questions } : {}) }; -} beforeAll(async () => { for (const change of ['unchanged', 'secondary', 'branch', 'filtered']) captures.set(change, await capture(change)); for (const kind of pathKinds) captures.set(`path-${kind}`, await capture(`path-${kind}`, preparePathEligibilityFixture(kind))); - for (const change of ['unchanged', 'secondary', 'branch', 'filtered']) captures.set(`declared-${change}`, await capture(change, undefined, true)); - for (const kind of pathKinds) captures.set(`declared-path-${kind}`, await capture(`path-${kind}`, preparePathEligibilityFixture(kind), true)); }, 120_000); afterAll(() => { for (const fixture of fixtures) fs.rmSync(fixture.root, { recursive: true, force: true }); }); -function replay(change = 'unchanged') { return structuredClone(captures.get(change)); } -function call(run: any, at = run.checkAt) { return run.events[at].message.content[0]; } -function result(run: any, at = run.checkAt) { return run.events[at + 1].message.content[0]; } -function alterReceipt(run: any, change: (receipt: any) => void) { - const receipt = JSON.parse(result(run).content); - change(receipt); - result(run).content = JSON.stringify(receipt); -} - -function pathCallback(run: any, overrides: Record = {}) { - const start = pathSource.indexOf('async function exerciseEligibility('); - const end = pathSource.indexOf('\ndescribeE2E(', start); - const readStart = pathSource.indexOf('function sourceReadTrace('); - expect(start).toBeGreaterThan(readStart); - expect(end).toBeGreaterThan(start); - const rows: any[] = []; - const native = { events: run.events, exitReason: 'success', output: '', toolCalls: run.events - .filter((event: any) => event.type === 'assistant') - .flatMap((event: any) => event.message.content.filter((block: any) => block.type === 'tool_use') - .map((block: any) => ({ tool: block.name, input: block.input }))) }; - const exercise = new Function('deps', new Bun.Transpiler({ loader: 'ts' }).transformSync(`const { - captures, preparePathEligibilityFixture, fs, path, reviewLifecycleInstructions, reviewRevalidationPrompt, - runSharedInteractive, readRequests, toolCommandTrace, fixtureGit, fixtureWorkingTree, reviewRecords, expect, - CAPTURE_LONG_MS, hasTrustedSharedLibsCheck, SHARED_LIBS_ROOT, - checkPathReviewPrerequisites, hasPathReviewPrerequisiteReceipt } = deps; - ${pathSource.slice(readStart, end)} return exerciseEligibility;`))({ - captures: { runAttempt: async (_name: string, _kinds: string[], _timeout: number, work: any) => - work({ add: (scenario: string, row: any) => rows.push({ scenario, row }) }) }, - preparePathEligibilityFixture: () => run.prepared, - fs: { ...fs, rmSync: (directory: string) => { expect(directory).toBe(run.f.root); } }, path, - reviewLifecycleInstructions, reviewRevalidationPrompt, - runSharedInteractive: async (fixture: any, _name: string, prompt: string, choice: string) => { - expect(choice).toBe('skip'); - expect(fixture).toBe(run.prepared.fixture); - expect(prompt).toContain(reviewRevalidationPrompt(fixture, path.join(fixture.root, 'review-lifecycle.md'), - path.join(fixture.root, 'current-advisory.jsonl'), run.prepared.resumed)); - return { result: native, questions: run.questions ?? [{}] }; - }, - readRequests: () => [], toolCommandTrace, fixtureGit, fixtureWorkingTree, reviewRecords, expect, - CAPTURE_LONG_MS, hasTrustedSharedLibsCheck, SHARED_LIBS_ROOT, - checkPathReviewPrerequisites, hasPathReviewPrerequisiteReceipt, ...overrides, - }); - return { rows, invoke: () => exercise('shared-libs-review-path-eligibility', [run.kind]) }; -} - -function capturedPathRun(index: number) { - const captured = structuredClone(capturedChecker.attempts[index]); - const events: any[] = captured.events; - const blocks = events.flatMap(event => event.message.content); - const calls = blocks.filter(block => block.type === 'tool_use'); - const returned = (id: string) => { - const result = blocks.find(block => block.type === 'tool_result' && block.tool_use_id === id); - expect(result).toBeDefined(); - expect(result.is_error).not.toBe(true); - expect(typeof result.content).toBe('string'); - return result.content as string; - }; - const files = new Map(calls.filter(call => call.name === 'Read') - .map(call => [call.input.file_path, returned(call.id).replace(/^\d+\t/gm, '')])); - const repo = captured.repo, root = path.posix.dirname(repo), state = path.posix.join(root, 'state'); - const input = path.posix.join(root, 'resumed-review-prerequisites.json'); - const supplied = path.posix.join(root, 'current-advisory.jsonl'); - const current = JSON.parse(files.get(supplied)!); - const prerequisiteCall = calls.find(call => call.input.command?.includes('--check-review-prerequisites')); - const prerequisites = JSON.parse(returned(prerequisiteCall.id)); - expect(prerequisites).toMatchObject({ settled: true, current: true, context: { binding: { root, repo, state } } }); - expect(prerequisites.context).toEqual(JSON.parse(files.get(input)!)); - const finish = calls.find(call => call.input.command?.includes('--finish')); - const records = returned(finish.id).split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)); - const f = { root, repo, state } as SharedLibsFixture; - const run = { f, kind: 'assume-unchanged', events, questions: calls.filter(call => call.name === 'AskUserQuestion').map(call => call.input), - prepared: { fixture: f, current, sourcePaths: ['src/retry-route.ts'], beforeTree: prerequisites.context.binding.wtree, - resumed: { input, checkCommand: prerequisiteCall.input.command } } }; - expect(captured.exit_reason).toBe('success'); - expect(run.questions).toHaveLength(1); - for (const call of calls.filter(call => call.name === 'AskUserQuestion')) { - for (const question of call.input.questions) { - expect(returned(call.id)).toContain(`"${question.question}"="Skip"`); - expect(question.options.some((option: any) => option.label === 'Skip')).toBe(true); - } - } - const overrides = { - path: path.posix, SHARED_LIBS_ROOT: '/workspace/gstack', - fs: { - readFileSync: (file: string) => { expect(files.has(file)).toBe(true); return files.get(file); }, - realpathSync: (file: string) => file, - writeFileSync: (file: string, contents: string) => { - expect(file).toBe(supplied); - expect(JSON.parse(contents)).toEqual(current); - }, - rmSync: (directory: string) => { expect(directory).toBe(root); }, - }, - reviewLifecycleInstructions: () => path.posix.join(root, 'review-lifecycle.md'), - checkPathReviewPrerequisites: (fixture: SharedLibsFixture, file: string) => { - expect(fixture).toBe(f); expect(file).toBe(input); return prerequisites; - }, - fixtureGit: (fixture: SharedLibsFixture, ...args: string[]) => { - expect(fixture).toBe(f); expect(args).toEqual(['symbolic-ref', '--quiet', '--short', 'HEAD']); - return prerequisites.context.binding.branch; - }, - fixtureWorkingTree: (fixture: SharedLibsFixture) => { - expect(fixture).toBe(f); return prerequisites.context.binding.wtree; - }, - reviewRecords: (fixture: SharedLibsFixture) => { expect(fixture).toBe(f); return structuredClone(records); }, - }; - return { run, overrides }; -} - describe('native executable shared-code checker evidence', () => { - test.each([0, 1])('the complete R59 public attempt %i satisfies the actual callback without a manual fingerprint call', async index => { - const { run, overrides } = capturedPathRun(index); - const commands = run.events.flatMap(event => event.message.content) - .filter(block => block.type === 'tool_use' && block.name === 'Bash').map(block => block.input.command).join('\n'); - expect(commands).not.toContain('sharedLibsFingerprint'); - let checks = 0; - const adapter = pathCallback(run, { ...overrides, hasTrustedSharedLibsCheck: (events: unknown[], expected: any) => { - checks++; - expect(events).toBe(run.events); - expect(expected.finding).toEqual(run.prepared.current); - expect(expected.reusable).toBe(false); - expect(expected.coveredPaths).toEqual(['src/retry-worker.ts', 'lib/retry-after.ts']); - return hasTrustedSharedLibsCheck(events, expected); - } }); - await adapter.invoke(); - expect(checks).toBe(1); - expect(adapter.rows).toMatchObject([{ scenario: 'assume-unchanged', row: { passed: true } }]); - const refused = pathCallback(run, { ...overrides, hasTrustedSharedLibsCheck: () => false }); - await expect(refused.invoke()).rejects.toThrow(); - expect(refused.rows[0].row.passed).toBe(false); - }); - - test.each([0, 1].flatMap(index => ['missing', 'failed', 'unpaired', 'fingerprint', 'binding', 'extra coverage'].map(invalid => [index, invalid] as const)))( - 'the R59 public attempt %i callback rejects a %s checker receipt', async (index, invalid) => { - const { run, overrides } = capturedPathRun(index); - const blocks = run.events.flatMap(event => event.message.content); - const check = blocks.find(block => block.type === 'tool_use' && block.input.command?.includes('--check-shared-libs')); - const receipt = blocks.find(block => block.type === 'tool_result' && block.tool_use_id === check.id); - if (invalid === 'missing') receipt.content = ''; - else if (invalid === 'failed') receipt.is_error = true; - else if (invalid === 'unpaired') receipt.tool_use_id = 'unpaired-checker'; - else { - const value = JSON.parse(receipt.content); - if (invalid === 'fingerprint') value.fingerprint = `shared-libs:${'a'.repeat(64)}`; - if (invalid === 'binding') value.review_start.started_at = 'foreign'; - if (invalid === 'extra coverage') value.snapshot.covered_paths.push('src/retry-route.ts'); - receipt.content = JSON.stringify(value); - } - let checks = 0; - const adapter = pathCallback(run, { ...overrides, hasTrustedSharedLibsCheck: (events: unknown[], expected: any) => { - checks++; - return hasTrustedSharedLibsCheck(events, expected); - } }); - await expect(adapter.invoke()).rejects.toThrow(); - expect(checks).toBe(1); - expect(adapter.rows[0].row.passed).toBe(false); - }); - - test('the actual path callback consumes the current synthetic prerequisite result without claiming native coverage', async () => { - const run = replay('declared-path-assume-unchanged'); - run.kind = 'assume-unchanged'; - let verified = 0; - const adapter = pathCallback(run, { hasPathReviewPrerequisiteReceipt: (events: any[], command: string, expected: any) => { - verified++; - expect(command).toBe(run.prepared.resumed.checkCommand); - expect(expected).toMatchObject({ synthetic: true, native_coverage: false, settled: true, current: true }); - expect(expected.context.binding.repo).toBe(run.f.repo); - expect(expected.context.binding.state).toBe(run.f.state); - expect(expected.context.binding.index).toMatch(/^h /m); - return hasPathReviewPrerequisiteReceipt(events, command, expected); - } }); - await adapter.invoke(); - expect(verified).toBe(1); - expect(adapter.rows[0].row.passed).toBe(true); - expect(adapter.rows[0].row.transcript[0]).toMatchObject({ - prerequisite_source: 'synthetic-fixture-input', prerequisite_native_coverage: false, - }); - const refused = pathCallback(run, { hasPathReviewPrerequisiteReceipt: () => false }); - await expect(refused.invoke()).rejects.toThrow('consume current synthetic prerequisites'); - expect(refused.rows[0].row.passed).toBe(false); - }); - - test.each(['missing', 'failed', 'unpaired', 'assistant-only', 'caption', 'false', 'foreign state', 'after finish'])( - 'the registered path callback rejects %s prerequisite receipts even with a completed record', async invalid => { - const run = replay('declared-path-ignored'); - run.kind = 'ignored'; - const at = run.events.findIndex((event: any) => event.type === 'assistant' - && event.message.content[0].input?.command === run.prepared.resumed.checkCommand); - expect(at).toBeGreaterThan(run.checkAt); - if (invalid === 'missing') run.events.splice(at, 2); - if (invalid === 'failed') result(run, at).is_error = true; - if (invalid === 'unpaired') result(run, at).tool_use_id = 'another-call'; - if (invalid === 'assistant-only') run.events[at + 1].type = 'assistant'; - if (invalid === 'caption') call(run, at).input = { command: 'true', description: run.prepared.resumed.checkCommand }; - if (invalid === 'false' || invalid === 'foreign state') { - const receipt = JSON.parse(result(run, at).content); - if (invalid === 'false') receipt.settled = false; - else receipt.context.binding.state = '/another-fixture/state'; - result(run, at).content = JSON.stringify(receipt); - } - if (invalid === 'after finish') run.events.push(...run.events.splice(at, 2)); - const adapter = pathCallback(run); - await expect(adapter.invoke()).rejects.toThrow('consume current synthetic prerequisites'); - expect(adapter.rows[0].row.passed).toBe(false); - }); - - test.each(['missing file', 'missing QA', 'empty probes', 'failed probe', 'missing native', 'failed native', - 'blocked native', 'foreign state', 'branch', 'base', 'index flag', 'raw hidden source', 'configuration', 'structured required'])( - 'synthetic prerequisites cannot settle with %s', async invalid => { - const prepared = preparePathEligibilityFixture('assume-unchanged'), f = prepared.fixture; - try { - const original = checkPathReviewPrerequisites(f, prepared.resumed.input); - expect(original.settled).toBe(true); - const context = structuredClone(original.context); - if (invalid === 'missing QA') delete context.qa; - if (invalid === 'empty probes') context.qa.required_probes = []; - if (invalid === 'failed probe') context.qa.required_probes[0].status = 'failed'; - if (invalid === 'missing native') delete context.native_adversarial; - if (invalid === 'failed native' || invalid === 'blocked native') context.native_adversarial.status = invalid.split(' ')[0]; - if (invalid === 'foreign state') context.binding.state = '/another-fixture/state'; - if (invalid === 'structured required') context.structured_review.required = true; - fs.writeFileSync(prepared.resumed.input, JSON.stringify(context)); - if (invalid === 'missing file') fs.unlinkSync(prepared.resumed.input); - if (invalid === 'branch') fixtureGit(f, 'checkout', '-b', 'another-branch'); - if (invalid === 'base') fixtureGit(f, 'update-ref', 'refs/remotes/origin/main', 'HEAD~1'); - if (invalid === 'index flag') fixtureGit(f, 'update-index', '--no-assume-unchanged', 'src/retry-route.ts'); - if (invalid === 'raw hidden source') { - fs.appendFileSync(path.join(f.repo, 'src/retry-route.ts'), '\n// Changed after the synthetic QA result\n'); - expect(fixtureWorkingTree(f)).toBe(original.context.binding.wtree); - } - if (invalid === 'configuration') fixtureGit(f, 'config', 'core.ignorecase', 'true'); - const checked = checkPathReviewPrerequisites(f, prepared.resumed.input); - expect(checked.settled).toBe(false); - expect(JSON.parse(execFileSync('bash', ['-c', prepared.resumed.checkCommand], { - cwd: f.repo, env: { ...process.env, ...f.env }, encoding: 'utf8', timeout: 30_000, - }))).toEqual(checked); - const run = { f, prepared, kind: 'assume-unchanged', events: [] }; - const adapter = pathCallback(run); - await expect(adapter.invoke()).rejects.toThrow('fixture prerequisites must be settled before capture'); - } finally { fs.rmSync(f.root, { recursive: true, force: true }); } - }); - - test.each(['raw source', 'supplied context'])('the registered callback rejects late changes to %s', async changed => { - const run = replay('declared-path-assume-unchanged'); - run.kind = 'assume-unchanged'; - const file = changed === 'raw source' ? path.join(run.f.repo, 'src/retry-route.ts') : run.prepared.resumed.input; - const before = fs.readFileSync(file, 'utf8'); - const adapter = pathCallback(run, { runSharedInteractive: async () => { - fs.writeFileSync(file, before + '\n'); - return { questions: run.questions, result: { exitReason: 'success', output: '', events: run.events, - toolCalls: run.events.filter((event: any) => event.type === 'assistant') - .map((event: any) => ({ tool: event.message.content[0].name, input: event.message.content[0].input })) } }; - } }); - try { - await expect(adapter.invoke()).rejects.toThrow('prerequisite state must remain unchanged'); - expect(adapter.rows[0].row.passed).toBe(false); - } finally { fs.writeFileSync(file, before); } - }); - test.each(['unchanged', 'secondary', 'branch', 'filtered'])('real %s checker receipt supplies the mechanical proof without manual trace words', change => { const run = replay(change); expect(run.receipt.reusable).toBe(change === 'unchanged'); @@ -574,152 +227,4 @@ describe('native executable shared-code checker evidence', () => { await expect(adapter.invoke()).rejects.toThrow(); expect(adapter.rows).toMatchObject([{ scenario: 'symlinks', row: { passed: false } }]); }); - - test.each(['unchanged', 'secondary', 'branch', 'filtered'])('declared receipt commands satisfy the real %s revalidation callback', change => { - const run = replay(`declared-${change}`); - expect(result(run, 0).content.trim()).toBe(run.token); - expect(JSON.parse(result(run).content)).toEqual(run.receipt); - expect(call(run, run.finishAt).input.command).toContain(`--finish ${run.token} && '${SHARED_LIBS_ROOT}/bin/gstack-review-read'`); - expect(run.questions).toHaveLength(change === 'unchanged' ? 0 : 1); - const scenario = source.slice(source.indexOf("test('shared-libs-review-revalidation'")); - const marker = '}, result => {'; - const start = scenario.indexOf(marker) + marker.length; - const body = scenario.slice(start, scenario.indexOf('\n });', start)); - const verify = new Function('deps', 'result', new Bun.Transpiler({ loader: 'ts' }).transformSync(`const { - change, questions, expect, toolCommandTrace, reviewRecords, createHash, path, f, current, - fixtureGit, fixtureWorkingTree, hasTrustedReviewStartRead, hasTrustedSharedLibsCheck, SHARED_LIBS_ROOT, - resumed, prerequisites, checkPathReviewPrerequisites, hasPathReviewPrerequisiteReceipt - } = deps; ${body}`)); - const native = { events: run.events, toolCalls: run.events.filter((event: any) => event.type === 'assistant') - .map((event: any) => ({ tool: event.message.content[0].name, input: event.message.content[0].input })) }; - let checks = 0; - verify({ change, questions: run.questions, expect, toolCommandTrace, reviewRecords, createHash, path, - f: run.f, current: run.current, fixtureGit, fixtureWorkingTree, hasTrustedReviewStartRead, SHARED_LIBS_ROOT, - resumed: run.resumed, prerequisites: run.prerequisites, checkPathReviewPrerequisites, hasPathReviewPrerequisiteReceipt, - hasTrustedSharedLibsCheck: (events: unknown[], expected: any) => { - checks++; - expect(events).toBe(run.events); - expect(expected).toEqual(run.expected); - return hasTrustedSharedLibsCheck(events, expected); - } }, native); - expect(checks).toBe(1); - }); - - test.each(pathKinds)('declared receipt commands satisfy the real %s path callback after an actual actor decision', async kind => { - const run = replay(`declared-path-${kind}`); - run.kind = kind; - expect(result(run, 0).content.trim()).toBe(run.token); - expect(JSON.parse(result(run).content)).toEqual(run.receipt); - expect(run.receipt.reusable).toBe(false); - expect(run.questions).toHaveLength(1); - let checks = 0; - const adapter = pathCallback(run, { hasTrustedSharedLibsCheck: (events: unknown[], expected: any) => { - checks++; - expect(expected).toEqual(run.expected); - return hasTrustedSharedLibsCheck(events, expected); - } }); - await adapter.invoke(); - expect(checks).toBe(1); - expect(adapter.rows).toMatchObject([{ scenario: kind, row: { passed: true } }]); - }); - - test.each(['revised-only identity', 'unsupported finding', 'unfinished review', 'missing explicit decision'])( - 'canonical transport cannot waive %s in the real ignored-path callback', async failure => { - const run = replay('declared-path-ignored'); - run.kind = 'ignored'; - if (failure === 'missing explicit decision') run.questions = []; - const adapter = pathCallback(run, { reviewRecords: (fixture: SharedLibsFixture) => { - const records = reviewRecords(fixture); - const final = records.at(-1); - if (failure === 'revised-only identity') { - const revised = { ...final.findings[0], evidence_paths: [ - 'src/retry-worker.ts', 'src/scheduler.ts', 'src/retry-route.ts', 'lib/retry-after.ts', - ] }; - revised.fingerprint = sharedLibsFingerprint(revised); - revised.snapshot_covered_paths = [...revised.evidence_paths]; - expect(revised.fingerprint).not.toBe(run.current.fingerprint); - final.findings = [revised]; - } - if (failure === 'unsupported finding') final.findings = []; - if (failure === 'unfinished review') final.completed = false; - return records; - } }); - await expect(adapter.invoke()).rejects.toThrow(); - expect(adapter.rows).toMatchObject([{ scenario: 'ignored', row: { passed: false } }]); - }); - - test.each(['start batching', 'mixed checker stdout', 'finish metadata prelude', 'loop-only caller reads'])( - 'the unchanged detector still rejects %s after protocol declaration', shape => { - const run = replay('declared-unchanged'); - if (shape === 'start batching') call(run, 0).input.command = `echo start; ${call(run, 0).input.command}; git diff origin/main`; - if (shape === 'mixed checker stdout') result(run).content = `checker receipt:\n${result(run).content}\nexit=0`; - if (shape === 'finish metadata prelude') call(run, run.finishAt).input.command = `TS=$(date -u); ${call(run, run.finishAt).input.command}`; - if (shape === 'loop-only caller reads') { - for (const event of run.events) for (const block of event.message.content) { - if (block.type === 'tool_use' && block.name === 'Bash' && block.input.command.startsWith('cat ')) { - block.input.command = `for f in ${block.input.command.slice(4)}; do cat "$f"; done`; - } - } - } - expect(hasTrustedSharedLibsCheck(run.events, run.expected)).toBe(false); - }); -}); - -// Gate 39173752116 slice 12: the skip-worktree review read every evidence file in one -// literal for-loop that printed each path before its contents. -describe('literal loop reads from the captured skip-worktree review', () => { - const loop = (events: any[]) => events.flatMap(event => event.message.content) - .find((block: any) => block.type === 'tool_use' && block.input?.command?.startsWith('for f in ')); - const output = (events: any[], id: string) => events.flatMap(event => event.message.content) - .find((block: any) => block.type === 'tool_result' && block.tool_use_id === id); - - test('a literal loop whose output names each evidence file counts as reading it', () => { - expect(hasTrustedSharedLibsCheck(structuredClone(loopRead.events), loopRead.expected)).toBe(true); - }); - - test.each(['variable list', 'file missing from output', 'path outside the repo'])('%s is still rejected', shape => { - const events = structuredClone(loopRead.events), call = loop(events), files = 'src/retry-worker.ts src/retry-route.ts lib/retry-after.ts'; - expect(call.input.command).toContain(`for f in ${files} `); - if (shape === 'variable list') call.input.command = `FILES="${files}"; ${call.input.command.replace(files, '$FILES')}`; - if (shape === 'file missing from output') { - const result = output(events, call.id); - result.content = JSON.parse(JSON.stringify(result.content).replaceAll('src/retry-route.ts', 'src/omitted.ts')); - } - if (shape === 'path outside the repo') call.input.command = call.input.command.replace(' src/retry-route.ts ', ' ../outside/src/retry-route.ts '); - expect(hasTrustedSharedLibsCheck(events, loopRead.expected)).toBe(false); - }); -}); - -// Gate 40617200857 slice 8: the ignored-path review returned all of src/retry-worker.ts through -// cat -n, then a later `echo ===` in the same call failed, so the result carried is_error. -describe('errored Bash reads from the captured ignored-path review', () => { - const blocks = (events: any[]) => events.flatMap(event => event.message.content); - const read = (events: any[]) => blocks(events).find((block: any) => block.type === 'tool_use' - && block.input?.command?.startsWith('cat -n src/retry-worker.ts; echo ===;')); - const output = (events: any[], id: string) => blocks(events).find((block: any) => block.type === 'tool_result' && block.tool_use_id === id); - - test('a whole evidence source returned before a later failure counts as reading it', () => { - const events = structuredClone(erroredRead.events); - expect(output(events, read(events).id).is_error).toBe(true); - expect(hasTrustedSharedLibsCheck(events, erroredRead.expected)).toBe(true); - }); - - test.each(['no expected sources', 'empty expected source', 'last source line missing', 'missing file', 'errored single read', 'errored native Read', 'errored checker'])('%s is still rejected', shape => { - const events = structuredClone(erroredRead.events), call = read(events), result = output(events, call.id); - const expected: any = structuredClone(erroredRead.expected); - if (shape === 'no expected sources') delete expected.sources; - if (shape === 'empty expected source') expected.sources['src/retry-worker.ts'] = ''; - if (shape === 'last source line missing') result.content = result.content.replace(/\n +15\t\}\n/, '\n'); - if (shape === 'missing file') result.content = 'Exit code 1\ncat: src/retry-worker.ts: No such file or directory'; - if (shape === 'errored single read') call.input.command = 'cat -n src/retry-worker.ts'; - if (shape === 'errored native Read') { - call.name = 'Read'; - call.input = { file_path: 'src/retry-worker.ts' }; - } - if (shape === 'errored checker') { - const checker = blocks(events).find((block: any) => block.type === 'tool_use' && block.input?.command?.includes('--check-shared-libs')); - output(events, checker.id).is_error = true; - } - expect(hasTrustedSharedLibsCheck(events, expected)).toBe(false); - }); }); diff --git a/test/ship-queue-train-bisect.test.ts b/test/ship-queue-train-bisect.test.ts new file mode 100644 index 000000000..f6439594b --- /dev/null +++ b/test/ship-queue-train-bisect.test.ts @@ -0,0 +1,201 @@ +// Split from test/ship-queue-train.test.ts (GSTA-151): dispatch recovery, train status, and the verdict, bisect and rebuild cases. +import { afterEach, describe, expect, test } from 'bun:test'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { cleanup } from './helpers/restamp-fixture'; +import { coordinatorClone } from './helpers/ship-queue-fixture'; +import { twoPrTrain } from './helpers/ship-queue-train-cases'; +import { + COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_POLICY, dispatches, line, queue, trainFixture, trainJson, + type Rule, type TrainFixture, +} from './helpers/ship-queue-train-fixture'; + +const fixtures: TrainFixture[] = []; +const make = (policy: Record = TRAIN_POLICY) => { const f = trainFixture(policy); fixtures.push(f); return f; }; +afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); }); + +const next = (out: string) => line(out, 'NEXT:'); +const remoteRefs = (fx: TrainFixture) => fx.repo.git('ls-remote', 'origin'); + +describe('dispatch recovery and status', () => { + test('a partial dispatch (gh failed after GitHub accepted it) is unknown, never re-dispatched, and train status finds the run by nonce', () => { + const fx = make(); + const { r, nonce } = twoPrTrain(fx, { ...GREEN_GATE, failButCreate: true }); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toContain('(QUEUE_TRAIN_DISPATCH_UNKNOWN)'); + expect(next(r.out)).toBe(`NEXT: gstack-ship-queue train status --train ${nonce}`); + expect(trainJson(fx, nonce).state).toBe('dispatch-unknown'); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).err).toContain('(QUEUE_TRAIN_DISPATCH_UNKNOWN)'); + expect(queue(fx, ['train', 'build', '--train', nonce]).err).toContain('(QUEUE_TRAIN_DISPATCH_UNKNOWN)'); + const status = queue(fx, ['train', 'status', '--train', nonce]); + expect(status.status).toBe(EXIT.ok); + expect(status.out).toMatch(new RegExp(`^ATTEMPT: a1 gate state=dispatched nonce=${nonce.replace(/\./g, '\\.')} run=101 `, 'm')); + expect(trainJson(fx, nonce).state).toBe('gate-pending'); + expect(next(status.out)).toBe(`NEXT: gstack-ship-queue train verdict --train ${nonce}`); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + expect(dispatches(fx, 'evals.yml').length).toBe(1); + }, 180_000); + + test('a dispatch that never started is marked absent by status; only then does build dispatch it again, under the same nonce', () => { + const fx = make(); + const { r, nonce } = twoPrTrain(fx, { silent: true }); + expect(r.err).toContain('(QUEUE_TRAIN_DISPATCH_UNKNOWN)'); + const status = queue(fx, ['train', 'status', '--train', nonce]); + expect(status.out).toContain('ATTEMPT: a1 gate state=absent'); + expect(next(status.out)).toBe(`NEXT: gstack-ship-queue train build --train ${nonce}`); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const again = queue(fx, ['train', 'build', '--train', nonce]); + expect(again.status).toBe(EXIT.ok); + expect(dispatches(fx, 'evals.yml').map(d => (d.inputs as any).nonce)).toEqual([nonce, nonce]); + expect(trainJson(fx, nonce).attempts).toEqual(['a1', 'a2']); + }, 180_000); + + test('train status on a clean state root rebuilds the records from refs/notes/gstack-trains and spends nothing', () => { + const fx = make(); + const { nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const before = dispatches(fx).length; + const clone = coordinatorClone(fx.repo); + const fresh = path.join(fx.repo.root, 'fresh-state'); + const s = queue(fx, ['train', 'status', '--train', nonce], { GSTACK_STATE_ROOT: fresh }, clone); + expect(s.status).toBe(EXIT.ok); + expect(s.out).toMatch(/^NOTES: read 2 record\(s\), restored 2 /m); + expect(s.out).toContain(`TRAIN: ${nonce} state=green`); + expect(s.out).toContain('CHECK: gstack/full-gate on'); + expect(trainJson(fx, nonce, fresh)).toEqual(trainJson(fx, nonce)); + expect(dispatches(fx).length).toBe(before); + const all = queue(fx, ['train', 'status'], { GSTACK_STATE_ROOT: fresh }, clone); + expect(all.out).toContain(`NEXT(${nonce}): gstack-ship-queue verify --pr 1 --train ${nonce}`); + }, 180_000); +}); + +describe('verdict, bisect, rebuild', () => { + function redTrain(fx: TrainFixture, pattern: string) { + const t = twoPrTrain(fx, RED_GATE); + const v = queue(fx, ['train', 'verdict', '--train', t.nonce]); + const prefixes = trainJson(fx, t.nonce).prefixes.map((p: any) => p.commit); + fx.write(d => { d.rules['evals.yml'] = { bisect: { prefixes, patterns: { c1: pattern } } }; }); + return { ...t, v, prefixes }; + } + + test('a red train names its cases and a capped bisect; the bisect runs only the red cases on every prefix and attributes a monotone pattern; prune deletes the dropped train\'s branch', () => { + const fx = make(); + const { h1, nonce, v, prefixes } = redTrain(fx, 'GGR'); + expect(v.status).toBe(EXIT.fail); + expect(v.err).toContain(`train ${nonce} is red on c1`); + expect(v.out).toContain('ATTEMPT: a1 gate run 101 RED red=c1 cost=$1.70'); + expect(next(v.out)).toBe(`NEXT: gstack-ship-queue train bisect --train ${nonce} --spend-cap 2.70`); + const dry = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '2.70', '--dry-run']); + expect(dry.out).toContain('BISECT: cases c1 prefixes T0,T1,T2 need results; $0.90 reserved per prefix; spent $0.00, cap $2.70'); + expect(dry.out).toContain('DRY-RUN: nothing dispatched'); + expect(dispatches(fx, 'evals.yml').length).toBe(1); + const go = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '2.70']); + expect(go.status).toBe(EXIT.ok); + const sent = dispatches(fx, 'evals.yml').at(-1)!.inputs as any; + expect(sent.cases).toBe('c1'); + expect(sent.prefix_shas).toBe(prefixes.join(',')); + expect(sent.expected_sha).toBe(prefixes[2]); + expect(sent.nonce).toMatch(/^gate\.bisect-/); + const done = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '2.70']); + expect(done.status).toBe(EXIT.ok); + expect(done.out).toContain('PATTERN: c1 T0=G T1=G T2=R'); + expect(done.out).toContain('BISECT: ATTRIBUTED #2'); + expect(next(done.out)).toBe(`NEXT: gstack-ship-queue train build --prs 1@${h1}`); + expect(trainJson(fx, nonce).state).toBe('attributed'); + expect(queue(fx, ['train', 'prune', '--dry-run']).out).toContain(`WOULD-DELETE: train/${nonce} (attributed)`); + expect(queue(fx, ['train', 'prune']).out).toContain(`DELETED: train/${nonce} (attributed)`); + expect(remoteRefs(fx)).not.toContain(`refs/heads/train/${nonce}`); + expect(queue(fx, ['train', 'prune']).out).toContain(`GONE: train/${nonce}`); + }, 180_000); + + test('red on every prefix, T0 included, is RED_ON_MAIN; a non-monotone pattern is unattributed and earns no rebuild', () => { + const main = make(); + const m = redTrain(main, 'RRR'); + queue(main, ['train', 'bisect', '--train', m.nonce, '--spend-cap', '5']); + const onMain = queue(main, ['train', 'bisect', '--train', m.nonce, '--spend-cap', '5']); + expect(onMain.status).toBe(EXIT.fail); + expect(onMain.err).toContain('(QUEUE_TRAIN_RED_ON_MAIN)'); + expect(next(onMain.out)).toMatch(/^NEXT: fix forward on main first, then gstack-ship-queue train build --prs /); + const fx = make(); + const n = redTrain(fx, 'GRG'); + queue(fx, ['train', 'bisect', '--train', n.nonce, '--spend-cap', '5']); + const mixed = queue(fx, ['train', 'bisect', '--train', n.nonce, '--spend-cap', '5']); + expect(mixed.status).toBe(EXIT.fail); + expect(mixed.err).toContain('(QUEUE_TRAIN_UNATTRIBUTED)'); + expect(mixed.out).toContain('BISECT: UNATTRIBUTED (not monotone)'); + expect(next(mixed.out)).toMatch(/^NEXT: route the red cases to a repair lane \(GStack Minder\); once the repair merges: /); + const refused = queue(fx, ['train', 'rebuild', '--train', n.nonce]); + expect(refused.status).toBe(EXIT.refused); + expect(refused.err).toMatch(/rebuild refused: the bisect was not all green \(c1=GRG\).*\(QUEUE_TRAIN_REBUILD_REFUSED\)/); + }, 240_000); + + test('rebuild-once: refused without a bisect; an all-green bisect earns exactly one unchanged rebuild, both runs reported; a second red stays red', () => { + const fx = make(); + const { nonce } = redTrain(fx, 'GGG'); + expect(queue(fx, ['train', 'rebuild', '--train', nonce]).err).toMatch(/rebuild refused: no bisect record.*\(QUEUE_TRAIN_REBUILD_REFUSED\)/); + queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5']); + const green = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5']); + expect(green.out).toContain('BISECT: UNATTRIBUTED (all green)'); + expect(next(green.out)).toBe(`NEXT: gstack-ship-queue train rebuild --train ${nonce}`); + fx.write(d => { d.rules['evals.yml'] = RED_GATE; }); + const rebuild = queue(fx, ['train', 'rebuild', '--train', nonce]); + expect(rebuild.status).toBe(EXIT.ok); + expect(dispatches(fx, 'evals.yml').at(-1)!.inputs).toMatchObject({ evals_all: 'true', nonce: `${nonce}.2` }); + const second = queue(fx, ['train', 'verdict', '--train', nonce]); + expect(second.status).toBe(EXIT.fail); + expect(second.out).toContain('ATTEMPT: a1 gate run 101 RED red=c1'); + expect(second.out).toMatch(/^ATTEMPT: a2 rebuild run \d+ RED red=c1/m); + expect(next(second.out)).toMatch(/^NEXT: second red stays red: route c1 to a repair lane/); + expect(queue(fx, ['train', 'rebuild', '--train', nonce]).err).toMatch(/rebuild refused: already rebuilt once/); + expect(queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5']).err).toContain('second red is not bisected again'); + }, 240_000); + + test('a capped bisect launches only what fits, keeps completed prefixes, and resumes without repeating one', () => { + const fx = make(); + const { nonce, prefixes } = redTrain(fx, 'GGR'); + const none = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '0.5']); + expect(none.status).toBe(EXIT.refused); + expect(none.err).toContain('(QUEUE_TRAIN_BISECT_CAPPED)'); + expect(next(none.out)).toBe(`NEXT: gstack-ship-queue train bisect --train ${nonce} --spend-cap 2.70`); + expect(dispatches(fx, 'evals.yml').length).toBe(1); + const part = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '1.9']); + expect(part.status).toBe(EXIT.refused); + expect(part.err).toContain('(QUEUE_TRAIN_BISECT_CAPPED)'); + expect(part.out).toContain('BISECT: launch T0,T1; 1 prefix(es) wait for a higher cap'); + expect((dispatches(fx, 'evals.yml').at(-1)!.inputs as any).prefix_shas).toBe(prefixes.slice(0, 2).join(',')); + const resume = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5']); + expect(resume.status).toBe(EXIT.ok); + expect((dispatches(fx, 'evals.yml').at(-1)!.inputs as any).prefix_shas).toBe(prefixes[2]); + const done = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5']); + expect(done.out).toContain('BISECT: ATTRIBUTED #2'); + expect(done.out).toContain('BISECT: spent $1.50 of cap $5.00'); + expect(dispatches(fx, 'evals.yml').length).toBe(3); + }, 240_000); + + test('a bisect refuses prefixes that are not ancestors of train/', () => { + const fx = make(); + const { nonce } = redTrain(fx, 'GGR'); + fx.repo.git('push', '-q', '-f', 'origin', `origin/main:refs/heads/train/${nonce}`); + const r = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5']); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toMatch(/train\/\S+ is at [0-9a-f]{12}, not the recorded train head .*\(QUEUE_TRAIN_PREFIX_INVALID\)/); + expect(dispatches(fx, 'evals.yml').length).toBe(1); + }, 180_000); + + test('an INFRA/INCOMPLETE-only census is never green and earns one re-dispatch, reported beside the first run', () => { + const fx = make(); + const infra: Rule = { conclusion: 'failure', record: { panels: [PASS('c1'), FAIL('c2', 'INFRA'), PASS('j1', 'judge')], census: { ...COMPLETE, killed_before_upload: 1 }, verdict: 'RED' } }; + const { nonce } = twoPrTrain(fx, infra); + const v = queue(fx, ['train', 'verdict', '--train', nonce]); + expect(v.status).toBe(EXIT.fail); + expect(v.err).toMatch(/gate is incomplete: 1 slice killed before upload; c2 FAIL \(INFRA\).*\(QUEUE_TRAIN_INCOMPLETE\)/); + expect(next(v.out)).toBe(`NEXT: gstack-ship-queue train rebuild --train ${nonce} --infra (the single re-dispatch EVAL_POLICY.infraRedispatch allows; report both runs)`); + expect(queue(fx, ['train', 'rebuild', '--train', nonce]).err).toContain('rebuild refused: no bisect record'); + const again = queue(fx, ['train', 'rebuild', '--train', nonce, '--infra']); + expect(again.status).toBe(EXIT.ok); + const second = queue(fx, ['train', 'verdict', '--train', nonce]); + expect(second.out).toMatch(/^ATTEMPT: a2 infra-redispatch run \d+ INCOMPLETE/m); + expect(next(second.out)).toMatch(/^NEXT: diagnose run /); + expect(queue(fx, ['train', 'rebuild', '--train', nonce, '--infra']).err).toMatch(/already re-dispatched once.*\(QUEUE_TRAIN_REBUILD_REFUSED\)/); + }, 180_000); +}); diff --git a/test/ship-queue-train-freeze.test.ts b/test/ship-queue-train-freeze.test.ts new file mode 100644 index 000000000..13c09b2d4 --- /dev/null +++ b/test/ship-queue-train-freeze.test.ts @@ -0,0 +1,195 @@ +// Split from test/ship-queue-train.test.ts (GSTA-151): verify freezes, GitHub merge-state reads and stale-train refusals. +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { cleanup } from './helpers/restamp-fixture'; +import { coordinatorClone } from './helpers/ship-queue-fixture'; +import { publishMain, twoPrTrain } from './helpers/ship-queue-train-cases'; +import { + TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, trainFixture, trainJson, + type TrainFixture, +} from './helpers/ship-queue-train-fixture'; + +const fixtures: TrainFixture[] = []; +const make = (policy: Record = TRAIN_POLICY) => { const f = trainFixture(policy); fixtures.push(f); return f; }; +afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); }); + +const next = (out: string) => line(out, 'NEXT:'); + +describe('verify, freeze, repair and clear', () => { + // fails_when=a freeze lists a PR GitHub reports MERGED as unmerged (train 24's freeze named #3453), or a post-merge CHANGELOG with a line the pin never wrote confirms; why_new=the freeze test above merges nothing of the slot it freezes on; seam=the fixture's gh pr view state; no_claim=the backstop's outcome + test('a post-merge CHANGELOG beyond the rule still freezes, and the freeze lists the slot GitHub reports MERGED as merged, not unmerged', () => { + const fx = make(); + const { h2, nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + const coord = coordinatorClone(fx.repo); + const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); + const released = (prefix: string, version: string) => fx.repo.git('show', `${prefix}:CHANGELOG.md`).replace('## [Unreleased]', `## [${version}] - 2026-10-18`) + '\n'; + const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released(t.prefixes[1].commit, '1.2.4.0') }); + expect(v('1').status).toBe(EXIT.ok); + expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`); + // Main's CHANGELOG plus #2's stamped entry, with one line the pin never wrote. + const smuggled = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n'; + const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text: smuggled }); + fx.write(d => { (d.prs['2'] as any).state = 'MERGED'; }); + const frozen = v('2'); + expect(frozen.status).toBe(EXIT.fail); + expect(frozen.err).toContain(`main ${broken.slice(0, 12)} is neither T1 nor T2 modulo stamps (vs T2: differs: CHANGELOG.md, which is not main ${main1.slice(0, 12)}'s plus #2's own entry: the PR's entry holds a line its pin never added: "- added after the gate")`); + expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1,#2; main /); + expect(line(frozen.out, 'freeze:')).toMatch(/; #2 merged on GitHub without a verified tree; unmerged \(none\); repair none admitted$/); + const freezeFile = path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`); + expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8'))).toMatchObject({ merged: ['1', '2'], unmerged: [] }); + }, 300_000); + + // Value: protects=a first slot GitHub reports MERGED whose squash fails the post-merge check freezes the queue, and a GitHub state that cannot be read refuses instead of reading as not merged; fails_when=stopped() reads an empty merged list, or an unreadable gh answer, as "nothing merged" while main already holds the unverified squash (review of GSTA-129, passes 1 and 2); why_new=the test above freezes on slot 2 with slot 1 verified; seam=the fixture's gh pr view state; no_claim=the backstop's outcome + test("slot 1 merged on GitHub but not confirmed: an unreadable GitHub state refuses, and a MERGED one freezes with #1 merged and #2 unmerged instead of saying nothing merged", () => { + const fx = make(); + const { h2, nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + const coord = coordinatorClone(fx.repo); + const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); + const smuggled = fx.repo.git('show', `${t.prefixes[0].commit}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n- added after the gate\n') + '\n'; + const broken = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: smuggled }); + // While GitHub's answer for #1 cannot be read, the verify neither freezes nor says "nothing merged": it refuses and asks to be rerun. + const row = fx.read().prs['1'] as any; + fx.write(d => { delete d.prs['1']; }); + const unreadable = v('1'); + expect(unreadable.status).toBe(EXIT.refused); + expect(unreadable.err).toContain(`main ${broken.slice(0, 12)} is neither T0 nor T1 modulo stamps`); + expect(unreadable.err).toMatch(/; whether #1 merged on GitHub could not be read, so whether the queue must freeze is unknown; .*\(QUEUE_PROOF_MISSING\)/); + expect(unreadable.out).toContain(`NEXT: gstack-ship-queue verify --pr 1 --train ${nonce} (once gh pr view answers)`); + expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`))).toBe(false); + fx.write(d => { d.prs['1'] = { ...row, state: 'MERGED' }; }); + const frozen = v('1'); + expect(frozen.status).toBe(EXIT.fail); + expect(frozen.err).toContain(`main ${broken.slice(0, 12)} is neither T0 nor T1 modulo stamps`); + expect(frozen.err).toContain('"- added after the gate"); the queue is frozen;'); + expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /); + expect(line(frozen.out, 'freeze:')).toMatch(/; #1 merged on GitHub without a verified tree; unmerged #2; repair none admitted$/); + expect(JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`), 'utf8'))).toMatchObject({ merged: ['1'], unmerged: [{ pr: '2', head: h2 }] }); + }, 300_000); + + // Value: protects=the freeze record is built from the same GitHub read that decided to freeze, so a slot that read MERGED stays merged even when GitHub stops answering a moment later; fails_when=freezeQueue reads GitHub again and files anything but an exact MERGED under unmerged (the misfile that listed train 24's merged #3453 as unmerged, GSTA-129 defect 1); why_new=the test above holds GitHub's answer fixed across the verify; seam=the fixture's gh pr view vanishing after one answer; no_claim=the backstop's outcome + test("a slot that read MERGED when the verify decided to freeze is recorded as merged even when GitHub stops answering before the record is written", () => { + const fx = make(); + const { h2, nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + const coord = coordinatorClone(fx.repo); + const smuggled = fx.repo.git('show', `${t.prefixes[0].commit}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n- added after the gate\n') + '\n'; + publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: smuggled }); + fx.write(d => { (d.prs['1'] as any).state = 'MERGED'; d.vanish = { '1': 1 }; }); + const frozen = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord); + expect(frozen.status).toBe(EXIT.fail); + expect(frozen.err).toContain('"- added after the gate"); the queue is frozen;'); + expect(fx.read().vanish).toEqual({ '1': 0 }); + expect(line(frozen.out, 'freeze:')).toMatch(/; #1 merged on GitHub without a verified tree; unmerged #2; repair none admitted$/); + expect(JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`), 'utf8'))).toMatchObject({ merged: ['1'], unmerged: [{ pr: '2', head: h2 }] }); + }, 300_000); + + // Value: protects=a slot unreadable on GitHub beside a merged one is left out of unmerged and named in the reason, so the rebuild after the repair never seats a PR that may already be in; fails_when=freezeQueue files an unreadable slot under unmerged, or drops it without naming it; why_new=the test above keeps slot 2 readable; seam=the fixture's gh pr view vanishing for slot 2; no_claim=the backstop's outcome + test("a slot unreadable on GitHub beside a merged one is left out of unmerged, recorded as unreadable and named by the NEXT after the repair", () => { + const fx = make(); + const { h2, nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + const coord = coordinatorClone(fx.repo); + const smuggled = fx.repo.git('show', `${t.prefixes[0].commit}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n- added after the gate\n') + '\n'; + publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: smuggled }); + fx.write(d => { (d.prs['1'] as any).state = 'MERGED'; d.vanish = { '2': 0 }; }); + const frozen = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord); + expect(frozen.status).toBe(EXIT.fail); + expect(line(frozen.out, 'freeze:')).toMatch(/; #1 merged on GitHub without a verified tree; #2 unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers; unmerged \(none\); repair none admitted$/); + expect(JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`), 'utf8'))).toMatchObject({ merged: ['1'], unmerged: [], unreadable: [{ pr: '2', head: h2 }] }); + expect(line(frozen.out, 'unreadable:')).toBe(`unreadable: #2@${h2.slice(0, 12)} (GitHub did not answer at the freeze; neither merged nor unmerged)`); + // The repair clears the freeze, and the NEXT names the unreadable slot instead of omitting it (#3519 b). + const h3 = openPr(fx, '3'); + readyReceipt(fx, '3', h3); + const rn = nonceOf(queue(fx, ['train', 'build', '--repair', '--prs', `3@${h3}`]).out); + expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok); + publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0'); + const cleared = queue(fx, ['verify', '--pr', '3', '--train', rn], {}, coord); + expect(cleared.out).toMatch(/^FREEZE: cleared /m); + expect(next(cleared.out)).toBe(`NEXT: gh pr view 2 --json state,headRefOid (unreadable at the freeze: #2@${h2.slice(0, 12)}); seat each that is OPEN in a train`); + }, 300_000); + + // Value: protects=verify on a train whose every slot already merged, against a main another train verified, refuses as stale instead of freezing the queue and dispatching a backstop (#3519 c: train 24's record froze the queue at 9ccb5233, T1 of verified train 28, run 40586582766); fails_when=stopped() freezes when every slot merged and main's sha is another train's verified prefix, or refuses without that proof; why_new=every freeze test above has an unmerged or unverified main; seam=a second train record written into the state root; no_claim=what prune --stale then does (covered by the prune tests) + test("verify on a train whose every slot merged, against a main another train verified, refuses as stale and freezes nothing", () => { + const fx = make(); + const { nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + const coord = coordinatorClone(fx.repo); + const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); + publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); + expect(v('1').status).toBe(EXIT.ok); + const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' }); + fx.write(d => { (d.prs['2'] as any).state = 'MERGED'; }); + const freezeFile = path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`); + const tree = fx.repo.git('rev-parse', `${later}^{tree}`); + const otherNonce = `${nonce}-later`; + const other = { ...t, nonce: otherNonce, state: 'merging', prefixes: [t.prefixes[0], { index: 1, commit: later, tree, pr: '9' }], merged: [{ pr: '9', sha: later, tree, at: '2026-10-18T13:00:00.000Z' }] }; + fs.mkdirSync(path.join(fx.state, 'ship-queue', 'trains', otherNonce), { recursive: true }); + fs.writeFileSync(path.join(fx.state, 'ship-queue', 'trains', otherNonce, 'train.json'), JSON.stringify(other)); + const stale = v('2'); + expect(stale.status).toBe(EXIT.refused); + expect(stale.err).toContain(`every slot of train ${nonce} merged on GitHub and main ${later.slice(0, 12)} is T1 of train ${otherNonce} (#9, verified at 2026-10-18T13:00:00.000Z), so this record is stale and the queue is not frozen`); + expect(stale.err).toContain('(QUEUE_TRAIN_STALE)'); + expect(next(stale.out)).toBe(`NEXT: gstack-ship-queue train prune --stale --train ${nonce}`); + expect(fs.existsSync(freezeFile)).toBe(false); + expect(dispatches(fx, 'evals.yml').filter(d => d.ref === 'main').length).toBe(0); + // Without another train's verified record for this main, the same mismatch still freezes. + fs.rmSync(path.join(fx.state, 'ship-queue', 'trains', otherNonce), { recursive: true }); + const frozen = v('2'); + expect(frozen.status).toBe(EXIT.fail); + expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1,#2; main /); + }, 300_000); + + // Value: protects=a train with a slot still OPEN on GitHub freezes on a mismatch even when main's sha is another train's verified prefix; fails_when=the every-slot-merged guard before verifiedElsewhere is dropped or weakened, so a live train refuses as stale and skips the freeze; why_new=the test above only covers an all-merged train; seam=none; no_claim=prune behavior + test("a train with a slot still open freezes on the mismatch even when another train verified main's sha", () => { + const fx = make(); + const { nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + const coord = coordinatorClone(fx.repo); + const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); + publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); + expect(v('1').status).toBe(EXIT.ok); + const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' }); + const tree = fx.repo.git('rev-parse', `${later}^{tree}`); + const otherNonce = `${nonce}-later`; + const other = { ...t, nonce: otherNonce, state: 'merging', prefixes: [t.prefixes[0], { index: 1, commit: later, tree, pr: '9' }], merged: [{ pr: '9', sha: later, tree, at: '2026-10-18T13:00:00.000Z' }] }; + fs.mkdirSync(path.join(fx.state, 'ship-queue', 'trains', otherNonce), { recursive: true }); + fs.writeFileSync(path.join(fx.state, 'ship-queue', 'trains', otherNonce, 'train.json'), JSON.stringify(other)); + const partial = v('2'); + expect(partial.status).toBe(EXIT.fail); + expect(partial.err).not.toContain('(QUEUE_TRAIN_STALE)'); + expect(line(partial.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /); + expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`))).toBe(true); + }, 300_000); + + // Value: protects=a post-merge CHANGELOG difference is excused only when main's parent is T modulo stamps, so a foreign commit between the verified head and the squash freezes; fails_when=published() skips or inverts the parent check; why_new=the two tests above reach the CHANGELOG rule only with a parent that is T1 modulo stamps; seam=none; no_claim=a parent differing from T1 in the CHANGELOG alone + test("a post-merge CHANGELOG difference is not excused when main's parent is not the verified prefix modulo stamps: the queue freezes naming the parent", () => { + const fx = make(); + const { nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + const coord = coordinatorClone(fx.repo); + const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); + const released = fx.repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n'; + const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released }); + expect(v('1').status).toBe(EXIT.ok); + const foreign = publishMain(fx, t.prefixes[1].commit, '1.2.4.1', { file: 'stray.txt', text: 'landed between the two slots\n' }); + const text = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n'; + const main2 = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text }); + expect(fx.repo.git('rev-parse', `${main2}^`)).toBe(foreign); + const frozen = v('2'); + expect(frozen.status).toBe(EXIT.fail); + expect(frozen.err).toContain(`main ${main2.slice(0, 12)} is neither T1 nor T2 modulo stamps`); + expect(frozen.err).toContain(`main's parent ${foreign.slice(0, 12)} is not T1 modulo stamps`); + expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /); + }, 300_000); +}); diff --git a/test/ship-queue-train-verify.test.ts b/test/ship-queue-train-verify.test.ts new file mode 100644 index 000000000..5c8551d0b --- /dev/null +++ b/test/ship-queue-train-verify.test.ts @@ -0,0 +1,258 @@ +// Split from test/ship-queue-train.test.ts (GSTA-151): verify --train acceptance, prefix equality and the CHANGELOG rule before and after merge. +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { mergeChangelogs, parseChangelog } from '../lib/changelog-check'; +import { EXIT } from '../lib/headless-artifacts'; +import { cleanup } from './helpers/restamp-fixture'; +import { coordinatorClone } from './helpers/ship-queue-fixture'; +import { publishMain, twoPrTrain } from './helpers/ship-queue-train-cases'; +import { + GREEN_GATE, TRAIN_POLICY, dispatches, line, nonceOf, openPr, queue, readyReceipt, repoKey, setPrBody, shippedBody, trainFixture, trainJson, + type TrainFixture, +} from './helpers/ship-queue-train-fixture'; + +const fixtures: TrainFixture[] = []; +const make = (policy: Record = TRAIN_POLICY) => { const f = trainFixture(policy); fixtures.push(f); return f; }; +afterEach(() => { while (fixtures.length) cleanup(fixtures.pop()!.repo); }); + +const next = (out: string) => line(out, 'NEXT:'); + +describe('verify, freeze, repair and clear', () => { + test('verify accepts each merge only at prefix equality modulo stamps; an interrupted publication freezes the queue until one repair train lands', () => { + const fx = make(); + const { h1, h2, nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + const coord = coordinatorClone(fx.repo); + const v = (pr: string, train = nonce) => queue(fx, ['verify', '--pr', pr, '--train', train], {}, coord); + + const pre1 = v('1'); + expect(pre1.status).toBe(EXIT.ok); + expect(pre1.out).toContain(`VERIFY_TRAIN: #1 may merge as slot 1 of train ${nonce}`); + expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`); + const early = v('2'); + expect(early.status).toBe(EXIT.fail); + expect(early.err).toMatch(/is neither T1 nor T2 modulo stamps.*nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); + + publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); + const post1 = v('1'); + expect(post1.status).toBe(EXIT.ok); + expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(1 stamp file\(s\)\)\)$/m); + expect(post1.out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`); + expect(next(post1.out)).toBe(`NEXT: gstack-ship-queue verify --pr 2 --train ${nonce}`); + expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`); + + const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'not in the train\n' }); + const mismatch = v('2'); + expect(mismatch.status).toBe(EXIT.fail); + expect(mismatch.err).toContain('(QUEUE_TRAIN_TREE_MISMATCH)'); + expect(line(mismatch.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(new RegExp(`^QUEUE_TRAIN_FROZEN: train \\S+ merged #1; main ${broken.slice(0, 12)} tree [0-9a-f]{12}; backstop dispatched run \\d+ \\(nonce gate\\.backstop-`)); + const backstop = dispatches(fx, 'evals.yml').at(-1)!; + expect([backstop.ref, (backstop.inputs as any).expected_sha, (backstop.inputs as any).evals_all]).toEqual(['main', broken, 'true']); + const freezeFile = path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`); + expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8'))).toMatchObject({ kind: 'freeze', merged: ['1'], unmerged: [{ pr: '2', head: h2 }], cleared_at: null }); + + const h3 = openPr(fx, '3'); + readyReceipt(fx, '3', h3); + const ordinary = queue(fx, ['train', 'build', '--prs', `3@${h3}`, '--urgent']); + expect(ordinary.status).toBe(EXIT.refused); + expect(ordinary.err).toContain('(QUEUE_TRAIN_FROZEN)'); + expect(next(ordinary.out)).toBe('NEXT: gstack-ship-queue train build --repair --prs @'); + const fresh = path.join(fx.repo.root, 'fresh-state'); + expect(queue(fx, ['train', 'status'], { GSTACK_STATE_ROOT: fresh }, coord).out).toMatch(/^QUEUE_TRAIN_FROZEN: train /m); + + const repair = queue(fx, ['train', 'build', '--repair', '--prs', `3@${h3}`]); + expect(repair.status).toBe(EXIT.ok); + const rn = nonceOf(repair.out); + expect(trainJson(fx, rn)).toMatchObject({ repair: true, base: { sha: broken } }); + expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok); + expect(v('2').err).toContain('(QUEUE_TRAIN_FROZEN)'); + expect(dispatches(fx, 'evals.yml').filter(d => d.ref === 'main').length).toBe(1); + expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h3}`); + publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0'); + const cleared = v('3', rn); + expect(cleared.status).toBe(EXIT.ok); + expect(cleared.out).toMatch(/^FREEZE: cleared \(repair #3 via train \S+; main [0-9a-f]{12} carries a green record\)$/m); + expect(next(cleared.out)).toBe(`NEXT: gstack-ship-queue train build --prs 2@${h2}`); + expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8')).cleared_at).toBe('2026-10-18T12:00:00.000Z'); + expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN'); + }, 300_000); + test("rule 22: verify --train binds the receipt to the pinned head when the current head is its stamp commit; a receipt for another head refuses the slot and merges nothing", () => { + const fx = make(); + const { h1, nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const coord = coordinatorClone(fx.repo); + const v = () => queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord); + const { repo } = fx; + repo.git('checkout', '-q', '-B', 'lane1', h1); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n'); + repo.git('commit', '-qam', 'v1.2.4.0 stamp'); + const stamped = repo.git('rev-parse', 'HEAD'); + repo.git('push', '-q', '-f', 'origin', `${stamped}:refs/pull/1/head`); + const ok = v(); + expect(ok.status).toBe(EXIT.ok); + expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (1 stamp file(s)))`); + expect(ok.out).toContain(`SHIP_RECEIPT: pr=1 head=${h1.slice(0, 12)} version=- base=main (binds the pinned head)`); + expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${stamped}`); + setPrBody(fx, '1', shippedBody('1', 'b'.repeat(40))); + const stale = v(); + expect(stale.status).toBe(EXIT.refused); + expect(stale.err).toMatch(new RegExp(`PR #1's ship receipt is for bbbbbbbbbbbb, not the head ${h1.slice(0, 12)} or ${stamped.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE\\)`)); + expect(stale.out).not.toContain('MERGE_CONDITION:'); + expect(next(stale.out)).toBe("NEXT: #1's lane runs /ship and READYs again; then gstack-ship-queue train build --prs "); + expect(trainJson(fx, nonce).merged).toEqual([]); + setPrBody(fx, '1', ''); + expect(v().err).toContain('(QUEUE_RECEIPT_MISSING)'); + setPrBody(fx, '1', '```gstack-ship-receipt\n{"schema_version":1}\n```\n'); + const invalid = v(); + expect(invalid.err).toMatch(/PR #1's ship receipt is invalid \(.*\).*\(QUEUE_RECEIPT_MISSING\)/); + expect(invalid.out).not.toContain('MERGE_CONDITION:'); + setPrBody(fx, '1', shippedBody('2', h1)); + const otherPr = v(); + expect(otherPr.err).toMatch(/PR #1's ship receipt names pr=2, not this PR.*\(QUEUE_RECEIPT_STALE\)/); + expect(otherPr.out).not.toContain('MERGE_CONDITION:'); + expect(trainJson(fx, nonce).merged).toEqual([]); + setPrBody(fx, '1', shippedBody('1', stamped)); + expect(v().out).toContain(`SHIP_RECEIPT: pr=1 head=${stamped.slice(0, 12)} version=- base=main (binds the current head)`); + const saved = fx.read().prs['1']; + fx.write(d => { delete d.prs['1']; }); + const unreadable = v(); + expect(unreadable.status).toBe(EXIT.refused); + expect(unreadable.err).toContain('(QUEUE_PROOF_MISSING)'); + expect(unreadable.out).not.toContain('MERGE_CONDITION:'); + fx.write(d => { d.prs['1'] = saved; }); + expect(trainJson(fx, nonce).merged).toEqual([]); + }, 300_000); + + test('from slot 2 on, a head that merges main into the pin verifies when its tree is the slot\'s prefix modulo stamps', () => { + const fx = make(); + const { h2, nonce } = twoPrTrain(fx); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + const coord = coordinatorClone(fx.repo); + const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); + const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); + expect(v('1').status).toBe(EXIT.ok); + + const { repo } = fx; + const laneHead = (extra?: { file: string; text: string }) => { + repo.git('reset', '-q', '--hard'); + repo.git('checkout', '-q', '-B', 'lane2', h2); + repo.git('rm', '-rq', '.'); + repo.git('checkout', t.prefixes[2].commit, '--', '.'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n'); + if (extra) fs.writeFileSync(path.join(repo.work, extra.file), extra.text); + repo.git('add', '-A'); + const tree = repo.git('write-tree'); + const head = repo.git('commit-tree', tree, '-p', h2, '-p', main1, '-m', 'merge main into #2, stamp 1.2.5.0'); + repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/2/head`); + return head; + }; + + const merged = laneHead(); + const ok = v('2'); + expect(ok.status).toBe(EXIT.ok); + expect(ok.out).toMatch(new RegExp(`^head: ${merged} \\(T2 with main merged in, same modulo stamps`, 'm')); + expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${merged}`); + + const stray = laneHead({ file: 'stray.txt', text: 'not in the train\n' }); + const moved = v('2'); + expect(moved.status).toBe(EXIT.fail); + expect(moved.err).toMatch(new RegExp(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps \\(${stray.slice(0, 12)}: .*\\(QUEUE_TRAIN_MOVED\\)`)); + }, 300_000); + + test("GSTA-67 (#3345 at 593e61c): a merged-in head whose CHANGELOG was resolved by hand verifies even where T's mechanical CHANGELOG differs; an invented line or a head without main is still refused", () => { + const fx = make(); + const h1 = openPr(fx, '1', { changelog: false }); + const h2 = openPr(fx, '2', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- #2 change\n').replace('## [1.2.2.0] - 2026-09-01\n\n', '') }); + readyReceipt(fx, '1', h1); + readyReceipt(fx, '2', h2); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]).out); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + expect(fx.repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`)).not.toContain('## [1.2.2.0]'); + const coord = coordinatorClone(fx.repo); + const v = () => queue(fx, ['verify', '--pr', '2', '--train', nonce], {}, coord); + const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); + const { repo } = fx; + const laneHead = (entry: string, parents: string[]) => { + repo.git('reset', '-q', '--hard'); + repo.git('checkout', '-q', '-B', 'lane2', h2); + repo.git('rm', '-rq', '.'); + repo.git('checkout', t.prefixes[2].commit, '--', '.'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n'); + fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', `# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n${entry}\n`) + '\n'); + repo.git('add', '-A'); + const head = repo.git('commit-tree', repo.git('write-tree'), ...parents.flatMap(p => ['-p', p]), '-m', 'merge main into #2 (CHANGELOG by hand), stamp 1.2.5.0'); + repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/2/head`); + return head; + }; + + const byHand = laneHead('- #2 change', [h2, main1]); + const ok = v(); + expect(ok.status).toBe(EXIT.ok); + expect(ok.out).toContain(`head: ${byHand} (T2 with main merged in, same modulo stamps (1 stamp file(s)) except CHANGELOG.md, resolved as main's CHANGELOG.md plus #2's own entry)`); + expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${byHand}`); + + const invented = laneHead('- #2 change\n- added after the gate', [h2, main1]); + const refused = v(); + expect(refused.status).toBe(EXIT.fail); + expect(refused.err).toContain(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps (${invented.slice(0, 12)}: differs from T2 in CHANGELOG.md, which is not main's plus #2's own entry: the PR's entry holds a line its pin never added: "- added after the gate"; against the pin it differs: `); + expect(refused.err).toContain('(QUEUE_TRAIN_MOVED)'); + + const orphan = laneHead('- #2 change', [h2]); + const noMain = v(); + expect(noMain.status).toBe(EXIT.fail); + expect(noMain.err).toContain(`(${orphan.slice(0, 12)}: does not contain main ${main1.slice(0, 12)}; against the pin it differs: `); + }, 300_000); + // fails_when=the post-merge verify of a merged-in head the pre-merge verify accepted freezes the queue because T's single [Unreleased] became two released sections, or the pre-merge verify refuses the correction bullet mergeChangelogs itself wrote; why_new=the GSTA-67 test above never publishes the accepted head and never stacks a child on a released parent; seam=none (real git, the fixture's gh); no_claim=what the squash commit's message says + test("train 24 (GSTA-96): a stacked child's head the pre-merge verify accepted confirms after its squash merge under the same CHANGELOG rule, corrections to the released parent included", () => { + const fx = make(); + const { repo } = fx; + const h1 = openPr(fx, '1', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n**Parent entry of #1.**\n\n### Changed\n- #1 change, the first wording\n- #1 second bullet, unchanged\n') }); + const h2 = openPr(fx, '2', { from: 'pr-1', changelog: cl => cl.replace('## [Unreleased]\n\n**Parent entry of #1.**', '## [Unreleased]\n\n**Child entry of #2.**\n\n### Changed\n- #2 change\n\n**Parent entry of #1.**').replace('- #1 change, the first wording', '- #1 change, the better wording') }); + readyReceipt(fx, '1', h1); + readyReceipt(fx, '2', h2); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]).out); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + expect(parseChangelog(repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`)).sections.map(s => s.version ?? 'Unreleased')).toEqual(['Unreleased', '1.2.3.0', '1.2.2.0']); + const coord = coordinatorClone(fx.repo); + const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); + + // Slot 1: #1 squash-merges and is released as 1.2.4.0 (its [Unreleased] re-headed). + expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`); + const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n' }); + expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`); + + // Slot 2: the lane merges main into its pin; mergeChangelogs carries the child past its released parent. + const merged = mergeChangelogs(repo.git('show', `${h2}:CHANGELOG.md`) + '\n', repo.git('show', `${main1}:CHANGELOG.md`) + '\n', repo.git('show', `${h1}:CHANGELOG.md`) + '\n')!; + expect(merged).toContain('- Correction to the 1.2.4.0 entry below: "first" should read "better".'); + repo.git('reset', '-q', '--hard'); + repo.git('checkout', '-q', '-B', 'lane2', h2); + repo.git('rm', '-rq', '.'); + repo.git('checkout', t.prefixes[2].commit, '--', '.'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n'); + fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), merged.replace('## [Unreleased]', '## [1.2.5.0] - 2026-10-18')); + repo.git('add', '-A'); + const laneHead = repo.git('commit-tree', repo.git('write-tree'), '-p', h2, '-p', main1, '-m', 'merge main into #2, stamp 1.2.5.0'); + repo.git('push', '-q', '-f', 'origin', `${laneHead}:refs/pull/2/head`); + repo.git('reset', '-q', '--hard'); + const pre = v('2'); + expect([pre.status, pre.err]).toEqual([EXIT.ok, '']); + expect(pre.out).toContain(`head: ${laneHead} (T2 with main merged in, same modulo stamps (1 stamp file(s)) except CHANGELOG.md, resolved as main's CHANGELOG.md plus #2's own entry)`); + expect(line(pre.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${laneHead}`); + + // The squash merge publishes exactly that tree; the post-merge verify confirms it instead of freezing the queue. + const main2 = publishMain(fx, laneHead, '1.2.5.0'); + expect(repo.git('rev-parse', `${main2}^{tree}`)).toBe(repo.git('rev-parse', `${laneHead}^{tree}`)); + const post = v('2'); + expect([post.status, post.err]).toEqual([EXIT.ok, '']); + expect(line(post.out, 'main:')).toBe(`main: ${main2} = T2 (same modulo stamps (1 stamp file(s)) except CHANGELOG.md, main ${main1.slice(0, 12)}'s CHANGELOG.md plus #2's own entry)`); + expect(post.out).toContain(`VERIFY_TRAIN: #2 merged; main's tree is T2 of train ${nonce} (2/2 merged)`); + expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN'); + }, 300_000); +}); diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index 969b3596d..ad5c056a3 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -15,14 +15,15 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { mergeChangelogs, parseChangelog } from '../lib/changelog-check'; +import { parseChangelog } from '../lib/changelog-check'; import { EXIT } from '../lib/headless-artifacts'; import { baseOverlap } from '../lib/ship-queue-train'; import { TRAIN_HELP } from '../lib/ship-queue-train-cli'; import { BASE_POLICY, cleanup, runBin } from './helpers/restamp-fixture'; import { coordinatorClone } from './helpers/ship-queue-fixture'; +import { publishMain, twoPrTrain } from './helpers/ship-queue-train-cases'; import { - COMPLETE, FAIL, GREEN_GATE, PASS, RED_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, githubOrigin, line, mergeRefCommit, nonceOf, openPr, queue, readyReceipt, repoKey, setPrBody, shippedBody, trainFixture, trainJson, urlKey, + GREEN_GATE, TRAIN_BLOCK, TRAIN_POLICY, dispatches, githubOrigin, line, mergeRefCommit, nonceOf, openPr, queue, readyReceipt, repoKey, setPrBody, shippedBody, trainFixture, trainJson, urlKey, type Rule, type TrainFixture, } from './helpers/ship-queue-train-fixture'; @@ -34,31 +35,6 @@ const result = (out: string) => line(out, 'GSTACK_RESULT:'); const next = (out: string) => line(out, 'NEXT:'); const remoteRefs = (fx: TrainFixture) => fx.repo.git('ls-remote', 'origin'); -function twoPrTrain(fx: TrainFixture, gate: Rule = GREEN_GATE) { - const h1 = openPr(fx, '1'); - const h2 = openPr(fx, '2'); - readyReceipt(fx, '1', h1); - readyReceipt(fx, '2', h2); - fx.write(d => { d.rules['evals.yml'] = gate; }); - const r = queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]); - return { h1, h2, r, nonce: nonceOf(r.out) }; -} - -/** A main commit with a prefix's tree plus a VERSION stamp, the way stamp-at-merge publishes it. */ -function publishMain(fx: TrainFixture, prefixCommit: string, version: string, extra?: { file: string; text: string }): string { - const { repo } = fx; - repo.git('checkout', '-q', '-B', 'mainsim', 'origin/main'); - repo.git('rm', '-rq', '.'); - repo.git('checkout', prefixCommit, '--', '.'); - fs.writeFileSync(path.join(repo.work, 'VERSION'), version + '\n'); - if (extra) fs.writeFileSync(path.join(repo.work, extra.file), extra.text); - repo.git('add', '-A'); - repo.git('commit', '-q', '-m', `merge to ${version}`); - repo.git('push', '-q', 'origin', 'HEAD:refs/heads/main'); - repo.git('fetch', '-q', 'origin'); - return repo.git('rev-parse', 'HEAD'); -} - // fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/, which a local copy refuses on every run; no_claim=which process removed the loose object those runs named test('the coordinator clone reads origin through git, never by copying its objects directory (GSTA-139)', () => { const fx = make(); @@ -601,600 +577,3 @@ describe('train build', () => { expect(all.out).toMatch(/^SLOT: 1 #3 head=[0-9a-f]{40} screened \(narrow\) ready_at=2026-10-18T09:00:00\.000Z$/m); }, 180_000); }); - -describe('dispatch recovery and status', () => { - test('a partial dispatch (gh failed after GitHub accepted it) is unknown, never re-dispatched, and train status finds the run by nonce', () => { - const fx = make(); - const { r, nonce } = twoPrTrain(fx, { ...GREEN_GATE, failButCreate: true }); - expect(r.status).toBe(EXIT.refused); - expect(r.err).toContain('(QUEUE_TRAIN_DISPATCH_UNKNOWN)'); - expect(next(r.out)).toBe(`NEXT: gstack-ship-queue train status --train ${nonce}`); - expect(trainJson(fx, nonce).state).toBe('dispatch-unknown'); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).err).toContain('(QUEUE_TRAIN_DISPATCH_UNKNOWN)'); - expect(queue(fx, ['train', 'build', '--train', nonce]).err).toContain('(QUEUE_TRAIN_DISPATCH_UNKNOWN)'); - const status = queue(fx, ['train', 'status', '--train', nonce]); - expect(status.status).toBe(EXIT.ok); - expect(status.out).toMatch(new RegExp(`^ATTEMPT: a1 gate state=dispatched nonce=${nonce.replace(/\./g, '\\.')} run=101 `, 'm')); - expect(trainJson(fx, nonce).state).toBe('gate-pending'); - expect(next(status.out)).toBe(`NEXT: gstack-ship-queue train verdict --train ${nonce}`); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - expect(dispatches(fx, 'evals.yml').length).toBe(1); - }, 180_000); - - test('a dispatch that never started is marked absent by status; only then does build dispatch it again, under the same nonce', () => { - const fx = make(); - const { r, nonce } = twoPrTrain(fx, { silent: true }); - expect(r.err).toContain('(QUEUE_TRAIN_DISPATCH_UNKNOWN)'); - const status = queue(fx, ['train', 'status', '--train', nonce]); - expect(status.out).toContain('ATTEMPT: a1 gate state=absent'); - expect(next(status.out)).toBe(`NEXT: gstack-ship-queue train build --train ${nonce}`); - fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); - const again = queue(fx, ['train', 'build', '--train', nonce]); - expect(again.status).toBe(EXIT.ok); - expect(dispatches(fx, 'evals.yml').map(d => (d.inputs as any).nonce)).toEqual([nonce, nonce]); - expect(trainJson(fx, nonce).attempts).toEqual(['a1', 'a2']); - }, 180_000); - - test('train status on a clean state root rebuilds the records from refs/notes/gstack-trains and spends nothing', () => { - const fx = make(); - const { nonce } = twoPrTrain(fx); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const before = dispatches(fx).length; - const clone = coordinatorClone(fx.repo); - const fresh = path.join(fx.repo.root, 'fresh-state'); - const s = queue(fx, ['train', 'status', '--train', nonce], { GSTACK_STATE_ROOT: fresh }, clone); - expect(s.status).toBe(EXIT.ok); - expect(s.out).toMatch(/^NOTES: read 2 record\(s\), restored 2 /m); - expect(s.out).toContain(`TRAIN: ${nonce} state=green`); - expect(s.out).toContain('CHECK: gstack/full-gate on'); - expect(trainJson(fx, nonce, fresh)).toEqual(trainJson(fx, nonce)); - expect(dispatches(fx).length).toBe(before); - const all = queue(fx, ['train', 'status'], { GSTACK_STATE_ROOT: fresh }, clone); - expect(all.out).toContain(`NEXT(${nonce}): gstack-ship-queue verify --pr 1 --train ${nonce}`); - }, 180_000); -}); - -describe('verdict, bisect, rebuild', () => { - function redTrain(fx: TrainFixture, pattern: string) { - const t = twoPrTrain(fx, RED_GATE); - const v = queue(fx, ['train', 'verdict', '--train', t.nonce]); - const prefixes = trainJson(fx, t.nonce).prefixes.map((p: any) => p.commit); - fx.write(d => { d.rules['evals.yml'] = { bisect: { prefixes, patterns: { c1: pattern } } }; }); - return { ...t, v, prefixes }; - } - - test('a red train names its cases and a capped bisect; the bisect runs only the red cases on every prefix and attributes a monotone pattern; prune deletes the dropped train\'s branch', () => { - const fx = make(); - const { h1, nonce, v, prefixes } = redTrain(fx, 'GGR'); - expect(v.status).toBe(EXIT.fail); - expect(v.err).toContain(`train ${nonce} is red on c1`); - expect(v.out).toContain('ATTEMPT: a1 gate run 101 RED red=c1 cost=$1.70'); - expect(next(v.out)).toBe(`NEXT: gstack-ship-queue train bisect --train ${nonce} --spend-cap 2.70`); - const dry = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '2.70', '--dry-run']); - expect(dry.out).toContain('BISECT: cases c1 prefixes T0,T1,T2 need results; $0.90 reserved per prefix; spent $0.00, cap $2.70'); - expect(dry.out).toContain('DRY-RUN: nothing dispatched'); - expect(dispatches(fx, 'evals.yml').length).toBe(1); - const go = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '2.70']); - expect(go.status).toBe(EXIT.ok); - const sent = dispatches(fx, 'evals.yml').at(-1)!.inputs as any; - expect(sent.cases).toBe('c1'); - expect(sent.prefix_shas).toBe(prefixes.join(',')); - expect(sent.expected_sha).toBe(prefixes[2]); - expect(sent.nonce).toMatch(/^gate\.bisect-/); - const done = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '2.70']); - expect(done.status).toBe(EXIT.ok); - expect(done.out).toContain('PATTERN: c1 T0=G T1=G T2=R'); - expect(done.out).toContain('BISECT: ATTRIBUTED #2'); - expect(next(done.out)).toBe(`NEXT: gstack-ship-queue train build --prs 1@${h1}`); - expect(trainJson(fx, nonce).state).toBe('attributed'); - expect(queue(fx, ['train', 'prune', '--dry-run']).out).toContain(`WOULD-DELETE: train/${nonce} (attributed)`); - expect(queue(fx, ['train', 'prune']).out).toContain(`DELETED: train/${nonce} (attributed)`); - expect(remoteRefs(fx)).not.toContain(`refs/heads/train/${nonce}`); - expect(queue(fx, ['train', 'prune']).out).toContain(`GONE: train/${nonce}`); - }, 180_000); - - test('red on every prefix, T0 included, is RED_ON_MAIN; a non-monotone pattern is unattributed and earns no rebuild', () => { - const main = make(); - const m = redTrain(main, 'RRR'); - queue(main, ['train', 'bisect', '--train', m.nonce, '--spend-cap', '5']); - const onMain = queue(main, ['train', 'bisect', '--train', m.nonce, '--spend-cap', '5']); - expect(onMain.status).toBe(EXIT.fail); - expect(onMain.err).toContain('(QUEUE_TRAIN_RED_ON_MAIN)'); - expect(next(onMain.out)).toMatch(/^NEXT: fix forward on main first, then gstack-ship-queue train build --prs /); - const fx = make(); - const n = redTrain(fx, 'GRG'); - queue(fx, ['train', 'bisect', '--train', n.nonce, '--spend-cap', '5']); - const mixed = queue(fx, ['train', 'bisect', '--train', n.nonce, '--spend-cap', '5']); - expect(mixed.status).toBe(EXIT.fail); - expect(mixed.err).toContain('(QUEUE_TRAIN_UNATTRIBUTED)'); - expect(mixed.out).toContain('BISECT: UNATTRIBUTED (not monotone)'); - expect(next(mixed.out)).toMatch(/^NEXT: route the red cases to a repair lane \(GStack Minder\); once the repair merges: /); - const refused = queue(fx, ['train', 'rebuild', '--train', n.nonce]); - expect(refused.status).toBe(EXIT.refused); - expect(refused.err).toMatch(/rebuild refused: the bisect was not all green \(c1=GRG\).*\(QUEUE_TRAIN_REBUILD_REFUSED\)/); - }, 240_000); - - test('rebuild-once: refused without a bisect; an all-green bisect earns exactly one unchanged rebuild, both runs reported; a second red stays red', () => { - const fx = make(); - const { nonce } = redTrain(fx, 'GGG'); - expect(queue(fx, ['train', 'rebuild', '--train', nonce]).err).toMatch(/rebuild refused: no bisect record.*\(QUEUE_TRAIN_REBUILD_REFUSED\)/); - queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5']); - const green = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5']); - expect(green.out).toContain('BISECT: UNATTRIBUTED (all green)'); - expect(next(green.out)).toBe(`NEXT: gstack-ship-queue train rebuild --train ${nonce}`); - fx.write(d => { d.rules['evals.yml'] = RED_GATE; }); - const rebuild = queue(fx, ['train', 'rebuild', '--train', nonce]); - expect(rebuild.status).toBe(EXIT.ok); - expect(dispatches(fx, 'evals.yml').at(-1)!.inputs).toMatchObject({ evals_all: 'true', nonce: `${nonce}.2` }); - const second = queue(fx, ['train', 'verdict', '--train', nonce]); - expect(second.status).toBe(EXIT.fail); - expect(second.out).toContain('ATTEMPT: a1 gate run 101 RED red=c1'); - expect(second.out).toMatch(/^ATTEMPT: a2 rebuild run \d+ RED red=c1/m); - expect(next(second.out)).toMatch(/^NEXT: second red stays red: route c1 to a repair lane/); - expect(queue(fx, ['train', 'rebuild', '--train', nonce]).err).toMatch(/rebuild refused: already rebuilt once/); - expect(queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5']).err).toContain('second red is not bisected again'); - }, 240_000); - - test('a capped bisect launches only what fits, keeps completed prefixes, and resumes without repeating one', () => { - const fx = make(); - const { nonce, prefixes } = redTrain(fx, 'GGR'); - const none = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '0.5']); - expect(none.status).toBe(EXIT.refused); - expect(none.err).toContain('(QUEUE_TRAIN_BISECT_CAPPED)'); - expect(next(none.out)).toBe(`NEXT: gstack-ship-queue train bisect --train ${nonce} --spend-cap 2.70`); - expect(dispatches(fx, 'evals.yml').length).toBe(1); - const part = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '1.9']); - expect(part.status).toBe(EXIT.refused); - expect(part.err).toContain('(QUEUE_TRAIN_BISECT_CAPPED)'); - expect(part.out).toContain('BISECT: launch T0,T1; 1 prefix(es) wait for a higher cap'); - expect((dispatches(fx, 'evals.yml').at(-1)!.inputs as any).prefix_shas).toBe(prefixes.slice(0, 2).join(',')); - const resume = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5']); - expect(resume.status).toBe(EXIT.ok); - expect((dispatches(fx, 'evals.yml').at(-1)!.inputs as any).prefix_shas).toBe(prefixes[2]); - const done = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5']); - expect(done.out).toContain('BISECT: ATTRIBUTED #2'); - expect(done.out).toContain('BISECT: spent $1.50 of cap $5.00'); - expect(dispatches(fx, 'evals.yml').length).toBe(3); - }, 240_000); - - test('a bisect refuses prefixes that are not ancestors of train/', () => { - const fx = make(); - const { nonce } = redTrain(fx, 'GGR'); - fx.repo.git('push', '-q', '-f', 'origin', `origin/main:refs/heads/train/${nonce}`); - const r = queue(fx, ['train', 'bisect', '--train', nonce, '--spend-cap', '5']); - expect(r.status).toBe(EXIT.refused); - expect(r.err).toMatch(/train\/\S+ is at [0-9a-f]{12}, not the recorded train head .*\(QUEUE_TRAIN_PREFIX_INVALID\)/); - expect(dispatches(fx, 'evals.yml').length).toBe(1); - }, 180_000); - - test('an INFRA/INCOMPLETE-only census is never green and earns one re-dispatch, reported beside the first run', () => { - const fx = make(); - const infra: Rule = { conclusion: 'failure', record: { panels: [PASS('c1'), FAIL('c2', 'INFRA'), PASS('j1', 'judge')], census: { ...COMPLETE, killed_before_upload: 1 }, verdict: 'RED' } }; - const { nonce } = twoPrTrain(fx, infra); - const v = queue(fx, ['train', 'verdict', '--train', nonce]); - expect(v.status).toBe(EXIT.fail); - expect(v.err).toMatch(/gate is incomplete: 1 slice killed before upload; c2 FAIL \(INFRA\).*\(QUEUE_TRAIN_INCOMPLETE\)/); - expect(next(v.out)).toBe(`NEXT: gstack-ship-queue train rebuild --train ${nonce} --infra (the single re-dispatch EVAL_POLICY.infraRedispatch allows; report both runs)`); - expect(queue(fx, ['train', 'rebuild', '--train', nonce]).err).toContain('rebuild refused: no bisect record'); - const again = queue(fx, ['train', 'rebuild', '--train', nonce, '--infra']); - expect(again.status).toBe(EXIT.ok); - const second = queue(fx, ['train', 'verdict', '--train', nonce]); - expect(second.out).toMatch(/^ATTEMPT: a2 infra-redispatch run \d+ INCOMPLETE/m); - expect(next(second.out)).toMatch(/^NEXT: diagnose run /); - expect(queue(fx, ['train', 'rebuild', '--train', nonce, '--infra']).err).toMatch(/already re-dispatched once.*\(QUEUE_TRAIN_REBUILD_REFUSED\)/); - }, 180_000); -}); - -describe('verify, freeze, repair and clear', () => { - test('verify accepts each merge only at prefix equality modulo stamps; an interrupted publication freezes the queue until one repair train lands', () => { - const fx = make(); - const { h1, h2, nonce } = twoPrTrain(fx); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const t = trainJson(fx, nonce); - const coord = coordinatorClone(fx.repo); - const v = (pr: string, train = nonce) => queue(fx, ['verify', '--pr', pr, '--train', train], {}, coord); - - const pre1 = v('1'); - expect(pre1.status).toBe(EXIT.ok); - expect(pre1.out).toContain(`VERIFY_TRAIN: #1 may merge as slot 1 of train ${nonce}`); - expect(line(pre1.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`); - const early = v('2'); - expect(early.status).toBe(EXIT.fail); - expect(early.err).toMatch(/is neither T1 nor T2 modulo stamps.*nothing of train \S+ merged, so the queue is not frozen.*\(QUEUE_TRAIN_TREE_MISMATCH\)/); - - publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); - const post1 = v('1'); - expect(post1.status).toBe(EXIT.ok); - expect(post1.out).toMatch(/^main: [0-9a-f]{40} = T1 \(same modulo stamps \(1 stamp file\(s\)\)\)$/m); - expect(post1.out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`); - expect(next(post1.out)).toBe(`NEXT: gstack-ship-queue verify --pr 2 --train ${nonce}`); - expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`); - - const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'not in the train\n' }); - const mismatch = v('2'); - expect(mismatch.status).toBe(EXIT.fail); - expect(mismatch.err).toContain('(QUEUE_TRAIN_TREE_MISMATCH)'); - expect(line(mismatch.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(new RegExp(`^QUEUE_TRAIN_FROZEN: train \\S+ merged #1; main ${broken.slice(0, 12)} tree [0-9a-f]{12}; backstop dispatched run \\d+ \\(nonce gate\\.backstop-`)); - const backstop = dispatches(fx, 'evals.yml').at(-1)!; - expect([backstop.ref, (backstop.inputs as any).expected_sha, (backstop.inputs as any).evals_all]).toEqual(['main', broken, 'true']); - const freezeFile = path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`); - expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8'))).toMatchObject({ kind: 'freeze', merged: ['1'], unmerged: [{ pr: '2', head: h2 }], cleared_at: null }); - - const h3 = openPr(fx, '3'); - readyReceipt(fx, '3', h3); - const ordinary = queue(fx, ['train', 'build', '--prs', `3@${h3}`, '--urgent']); - expect(ordinary.status).toBe(EXIT.refused); - expect(ordinary.err).toContain('(QUEUE_TRAIN_FROZEN)'); - expect(next(ordinary.out)).toBe('NEXT: gstack-ship-queue train build --repair --prs @'); - const fresh = path.join(fx.repo.root, 'fresh-state'); - expect(queue(fx, ['train', 'status'], { GSTACK_STATE_ROOT: fresh }, coord).out).toMatch(/^QUEUE_TRAIN_FROZEN: train /m); - - const repair = queue(fx, ['train', 'build', '--repair', '--prs', `3@${h3}`]); - expect(repair.status).toBe(EXIT.ok); - const rn = nonceOf(repair.out); - expect(trainJson(fx, rn)).toMatchObject({ repair: true, base: { sha: broken } }); - expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok); - expect(v('2').err).toContain('(QUEUE_TRAIN_FROZEN)'); - expect(dispatches(fx, 'evals.yml').filter(d => d.ref === 'main').length).toBe(1); - expect(line(v('3', rn).out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h3}`); - publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0'); - const cleared = v('3', rn); - expect(cleared.status).toBe(EXIT.ok); - expect(cleared.out).toMatch(/^FREEZE: cleared \(repair #3 via train \S+; main [0-9a-f]{12} carries a green record\)$/m); - expect(next(cleared.out)).toBe(`NEXT: gstack-ship-queue train build --prs 2@${h2}`); - expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8')).cleared_at).toBe('2026-10-18T12:00:00.000Z'); - expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN'); - }, 300_000); - test("rule 22: verify --train binds the receipt to the pinned head when the current head is its stamp commit; a receipt for another head refuses the slot and merges nothing", () => { - const fx = make(); - const { h1, nonce } = twoPrTrain(fx); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const coord = coordinatorClone(fx.repo); - const v = () => queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord); - const { repo } = fx; - repo.git('checkout', '-q', '-B', 'lane1', h1); - fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.4.0\n'); - repo.git('commit', '-qam', 'v1.2.4.0 stamp'); - const stamped = repo.git('rev-parse', 'HEAD'); - repo.git('push', '-q', '-f', 'origin', `${stamped}:refs/pull/1/head`); - const ok = v(); - expect(ok.status).toBe(EXIT.ok); - expect(ok.out).toContain(`head: ${stamped} (same modulo stamps (1 stamp file(s)))`); - expect(ok.out).toContain(`SHIP_RECEIPT: pr=1 head=${h1.slice(0, 12)} version=- base=main (binds the pinned head)`); - expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${stamped}`); - setPrBody(fx, '1', shippedBody('1', 'b'.repeat(40))); - const stale = v(); - expect(stale.status).toBe(EXIT.refused); - expect(stale.err).toMatch(new RegExp(`PR #1's ship receipt is for bbbbbbbbbbbb, not the head ${h1.slice(0, 12)} or ${stamped.slice(0, 12)} being merged.*\\(QUEUE_RECEIPT_STALE\\)`)); - expect(stale.out).not.toContain('MERGE_CONDITION:'); - expect(next(stale.out)).toBe("NEXT: #1's lane runs /ship and READYs again; then gstack-ship-queue train build --prs "); - expect(trainJson(fx, nonce).merged).toEqual([]); - setPrBody(fx, '1', ''); - expect(v().err).toContain('(QUEUE_RECEIPT_MISSING)'); - setPrBody(fx, '1', '```gstack-ship-receipt\n{"schema_version":1}\n```\n'); - const invalid = v(); - expect(invalid.err).toMatch(/PR #1's ship receipt is invalid \(.*\).*\(QUEUE_RECEIPT_MISSING\)/); - expect(invalid.out).not.toContain('MERGE_CONDITION:'); - setPrBody(fx, '1', shippedBody('2', h1)); - const otherPr = v(); - expect(otherPr.err).toMatch(/PR #1's ship receipt names pr=2, not this PR.*\(QUEUE_RECEIPT_STALE\)/); - expect(otherPr.out).not.toContain('MERGE_CONDITION:'); - expect(trainJson(fx, nonce).merged).toEqual([]); - setPrBody(fx, '1', shippedBody('1', stamped)); - expect(v().out).toContain(`SHIP_RECEIPT: pr=1 head=${stamped.slice(0, 12)} version=- base=main (binds the current head)`); - const saved = fx.read().prs['1']; - fx.write(d => { delete d.prs['1']; }); - const unreadable = v(); - expect(unreadable.status).toBe(EXIT.refused); - expect(unreadable.err).toContain('(QUEUE_PROOF_MISSING)'); - expect(unreadable.out).not.toContain('MERGE_CONDITION:'); - fx.write(d => { d.prs['1'] = saved; }); - expect(trainJson(fx, nonce).merged).toEqual([]); - }, 300_000); - - test('from slot 2 on, a head that merges main into the pin verifies when its tree is the slot\'s prefix modulo stamps', () => { - const fx = make(); - const { h2, nonce } = twoPrTrain(fx); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const t = trainJson(fx, nonce); - const coord = coordinatorClone(fx.repo); - const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); - const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); - expect(v('1').status).toBe(EXIT.ok); - - const { repo } = fx; - const laneHead = (extra?: { file: string; text: string }) => { - repo.git('reset', '-q', '--hard'); - repo.git('checkout', '-q', '-B', 'lane2', h2); - repo.git('rm', '-rq', '.'); - repo.git('checkout', t.prefixes[2].commit, '--', '.'); - fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n'); - if (extra) fs.writeFileSync(path.join(repo.work, extra.file), extra.text); - repo.git('add', '-A'); - const tree = repo.git('write-tree'); - const head = repo.git('commit-tree', tree, '-p', h2, '-p', main1, '-m', 'merge main into #2, stamp 1.2.5.0'); - repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/2/head`); - return head; - }; - - const merged = laneHead(); - const ok = v('2'); - expect(ok.status).toBe(EXIT.ok); - expect(ok.out).toMatch(new RegExp(`^head: ${merged} \\(T2 with main merged in, same modulo stamps`, 'm')); - expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${merged}`); - - const stray = laneHead({ file: 'stray.txt', text: 'not in the train\n' }); - const moved = v('2'); - expect(moved.status).toBe(EXIT.fail); - expect(moved.err).toMatch(new RegExp(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps \\(${stray.slice(0, 12)}: .*\\(QUEUE_TRAIN_MOVED\\)`)); - }, 300_000); - - test("GSTA-67 (#3345 at 593e61c): a merged-in head whose CHANGELOG was resolved by hand verifies even where T's mechanical CHANGELOG differs; an invented line or a head without main is still refused", () => { - const fx = make(); - const h1 = openPr(fx, '1', { changelog: false }); - const h2 = openPr(fx, '2', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- #2 change\n').replace('## [1.2.2.0] - 2026-09-01\n\n', '') }); - readyReceipt(fx, '1', h1); - readyReceipt(fx, '2', h2); - fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); - const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]).out); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const t = trainJson(fx, nonce); - expect(fx.repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`)).not.toContain('## [1.2.2.0]'); - const coord = coordinatorClone(fx.repo); - const v = () => queue(fx, ['verify', '--pr', '2', '--train', nonce], {}, coord); - const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); - const { repo } = fx; - const laneHead = (entry: string, parents: string[]) => { - repo.git('reset', '-q', '--hard'); - repo.git('checkout', '-q', '-B', 'lane2', h2); - repo.git('rm', '-rq', '.'); - repo.git('checkout', t.prefixes[2].commit, '--', '.'); - fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n'); - fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', `# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n${entry}\n`) + '\n'); - repo.git('add', '-A'); - const head = repo.git('commit-tree', repo.git('write-tree'), ...parents.flatMap(p => ['-p', p]), '-m', 'merge main into #2 (CHANGELOG by hand), stamp 1.2.5.0'); - repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/2/head`); - return head; - }; - - const byHand = laneHead('- #2 change', [h2, main1]); - const ok = v(); - expect(ok.status).toBe(EXIT.ok); - expect(ok.out).toContain(`head: ${byHand} (T2 with main merged in, same modulo stamps (1 stamp file(s)) except CHANGELOG.md, resolved as main's CHANGELOG.md plus #2's own entry)`); - expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${byHand}`); - - const invented = laneHead('- #2 change\n- added after the gate', [h2, main1]); - const refused = v(); - expect(refused.status).toBe(EXIT.fail); - expect(refused.err).toContain(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps (${invented.slice(0, 12)}: differs from T2 in CHANGELOG.md, which is not main's plus #2's own entry: the PR's entry holds a line its pin never added: "- added after the gate"; against the pin it differs: `); - expect(refused.err).toContain('(QUEUE_TRAIN_MOVED)'); - - const orphan = laneHead('- #2 change', [h2]); - const noMain = v(); - expect(noMain.status).toBe(EXIT.fail); - expect(noMain.err).toContain(`(${orphan.slice(0, 12)}: does not contain main ${main1.slice(0, 12)}; against the pin it differs: `); - }, 300_000); - // fails_when=the post-merge verify of a merged-in head the pre-merge verify accepted freezes the queue because T's single [Unreleased] became two released sections, or the pre-merge verify refuses the correction bullet mergeChangelogs itself wrote; why_new=the GSTA-67 test above never publishes the accepted head and never stacks a child on a released parent; seam=none (real git, the fixture's gh); no_claim=what the squash commit's message says - test("train 24 (GSTA-96): a stacked child's head the pre-merge verify accepted confirms after its squash merge under the same CHANGELOG rule, corrections to the released parent included", () => { - const fx = make(); - const { repo } = fx; - const h1 = openPr(fx, '1', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n**Parent entry of #1.**\n\n### Changed\n- #1 change, the first wording\n- #1 second bullet, unchanged\n') }); - const h2 = openPr(fx, '2', { from: 'pr-1', changelog: cl => cl.replace('## [Unreleased]\n\n**Parent entry of #1.**', '## [Unreleased]\n\n**Child entry of #2.**\n\n### Changed\n- #2 change\n\n**Parent entry of #1.**').replace('- #1 change, the first wording', '- #1 change, the better wording') }); - readyReceipt(fx, '1', h1); - readyReceipt(fx, '2', h2); - fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); - const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]).out); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const t = trainJson(fx, nonce); - expect(parseChangelog(repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`)).sections.map(s => s.version ?? 'Unreleased')).toEqual(['Unreleased', '1.2.3.0', '1.2.2.0']); - const coord = coordinatorClone(fx.repo); - const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); - - // Slot 1: #1 squash-merges and is released as 1.2.4.0 (its [Unreleased] re-headed). - expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`); - const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n' }); - expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`); - - // Slot 2: the lane merges main into its pin; mergeChangelogs carries the child past its released parent. - const merged = mergeChangelogs(repo.git('show', `${h2}:CHANGELOG.md`) + '\n', repo.git('show', `${main1}:CHANGELOG.md`) + '\n', repo.git('show', `${h1}:CHANGELOG.md`) + '\n')!; - expect(merged).toContain('- Correction to the 1.2.4.0 entry below: "first" should read "better".'); - repo.git('reset', '-q', '--hard'); - repo.git('checkout', '-q', '-B', 'lane2', h2); - repo.git('rm', '-rq', '.'); - repo.git('checkout', t.prefixes[2].commit, '--', '.'); - fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n'); - fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), merged.replace('## [Unreleased]', '## [1.2.5.0] - 2026-10-18')); - repo.git('add', '-A'); - const laneHead = repo.git('commit-tree', repo.git('write-tree'), '-p', h2, '-p', main1, '-m', 'merge main into #2, stamp 1.2.5.0'); - repo.git('push', '-q', '-f', 'origin', `${laneHead}:refs/pull/2/head`); - repo.git('reset', '-q', '--hard'); - const pre = v('2'); - expect([pre.status, pre.err]).toEqual([EXIT.ok, '']); - expect(pre.out).toContain(`head: ${laneHead} (T2 with main merged in, same modulo stamps (1 stamp file(s)) except CHANGELOG.md, resolved as main's CHANGELOG.md plus #2's own entry)`); - expect(line(pre.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${laneHead}`); - - // The squash merge publishes exactly that tree; the post-merge verify confirms it instead of freezing the queue. - const main2 = publishMain(fx, laneHead, '1.2.5.0'); - expect(repo.git('rev-parse', `${main2}^{tree}`)).toBe(repo.git('rev-parse', `${laneHead}^{tree}`)); - const post = v('2'); - expect([post.status, post.err]).toEqual([EXIT.ok, '']); - expect(line(post.out, 'main:')).toBe(`main: ${main2} = T2 (same modulo stamps (1 stamp file(s)) except CHANGELOG.md, main ${main1.slice(0, 12)}'s CHANGELOG.md plus #2's own entry)`); - expect(post.out).toContain(`VERIFY_TRAIN: #2 merged; main's tree is T2 of train ${nonce} (2/2 merged)`); - expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN'); - }, 300_000); - - // fails_when=a freeze lists a PR GitHub reports MERGED as unmerged (train 24's freeze named #3453), or a post-merge CHANGELOG with a line the pin never wrote confirms; why_new=the freeze test above merges nothing of the slot it freezes on; seam=the fixture's gh pr view state; no_claim=the backstop's outcome - test('a post-merge CHANGELOG beyond the rule still freezes, and the freeze lists the slot GitHub reports MERGED as merged, not unmerged', () => { - const fx = make(); - const { h2, nonce } = twoPrTrain(fx); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const t = trainJson(fx, nonce); - const coord = coordinatorClone(fx.repo); - const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); - const released = (prefix: string, version: string) => fx.repo.git('show', `${prefix}:CHANGELOG.md`).replace('## [Unreleased]', `## [${version}] - 2026-10-18`) + '\n'; - const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released(t.prefixes[1].commit, '1.2.4.0') }); - expect(v('1').status).toBe(EXIT.ok); - expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`); - // Main's CHANGELOG plus #2's stamped entry, with one line the pin never wrote. - const smuggled = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n'; - const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text: smuggled }); - fx.write(d => { (d.prs['2'] as any).state = 'MERGED'; }); - const frozen = v('2'); - expect(frozen.status).toBe(EXIT.fail); - expect(frozen.err).toContain(`main ${broken.slice(0, 12)} is neither T1 nor T2 modulo stamps (vs T2: differs: CHANGELOG.md, which is not main ${main1.slice(0, 12)}'s plus #2's own entry: the PR's entry holds a line its pin never added: "- added after the gate")`); - expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1,#2; main /); - expect(line(frozen.out, 'freeze:')).toMatch(/; #2 merged on GitHub without a verified tree; unmerged \(none\); repair none admitted$/); - const freezeFile = path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`); - expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8'))).toMatchObject({ merged: ['1', '2'], unmerged: [] }); - }, 300_000); - - // Value: protects=a first slot GitHub reports MERGED whose squash fails the post-merge check freezes the queue, and a GitHub state that cannot be read refuses instead of reading as not merged; fails_when=stopped() reads an empty merged list, or an unreadable gh answer, as "nothing merged" while main already holds the unverified squash (review of GSTA-129, passes 1 and 2); why_new=the test above freezes on slot 2 with slot 1 verified; seam=the fixture's gh pr view state; no_claim=the backstop's outcome - test("slot 1 merged on GitHub but not confirmed: an unreadable GitHub state refuses, and a MERGED one freezes with #1 merged and #2 unmerged instead of saying nothing merged", () => { - const fx = make(); - const { h2, nonce } = twoPrTrain(fx); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const t = trainJson(fx, nonce); - const coord = coordinatorClone(fx.repo); - const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); - const smuggled = fx.repo.git('show', `${t.prefixes[0].commit}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n- added after the gate\n') + '\n'; - const broken = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: smuggled }); - // While GitHub's answer for #1 cannot be read, the verify neither freezes nor says "nothing merged": it refuses and asks to be rerun. - const row = fx.read().prs['1'] as any; - fx.write(d => { delete d.prs['1']; }); - const unreadable = v('1'); - expect(unreadable.status).toBe(EXIT.refused); - expect(unreadable.err).toContain(`main ${broken.slice(0, 12)} is neither T0 nor T1 modulo stamps`); - expect(unreadable.err).toMatch(/; whether #1 merged on GitHub could not be read, so whether the queue must freeze is unknown; .*\(QUEUE_PROOF_MISSING\)/); - expect(unreadable.out).toContain(`NEXT: gstack-ship-queue verify --pr 1 --train ${nonce} (once gh pr view answers)`); - expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`))).toBe(false); - fx.write(d => { d.prs['1'] = { ...row, state: 'MERGED' }; }); - const frozen = v('1'); - expect(frozen.status).toBe(EXIT.fail); - expect(frozen.err).toContain(`main ${broken.slice(0, 12)} is neither T0 nor T1 modulo stamps`); - expect(frozen.err).toContain('"- added after the gate"); the queue is frozen;'); - expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /); - expect(line(frozen.out, 'freeze:')).toMatch(/; #1 merged on GitHub without a verified tree; unmerged #2; repair none admitted$/); - expect(JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`), 'utf8'))).toMatchObject({ merged: ['1'], unmerged: [{ pr: '2', head: h2 }] }); - }, 300_000); - - // Value: protects=the freeze record is built from the same GitHub read that decided to freeze, so a slot that read MERGED stays merged even when GitHub stops answering a moment later; fails_when=freezeQueue reads GitHub again and files anything but an exact MERGED under unmerged (the misfile that listed train 24's merged #3453 as unmerged, GSTA-129 defect 1); why_new=the test above holds GitHub's answer fixed across the verify; seam=the fixture's gh pr view vanishing after one answer; no_claim=the backstop's outcome - test("a slot that read MERGED when the verify decided to freeze is recorded as merged even when GitHub stops answering before the record is written", () => { - const fx = make(); - const { h2, nonce } = twoPrTrain(fx); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const t = trainJson(fx, nonce); - const coord = coordinatorClone(fx.repo); - const smuggled = fx.repo.git('show', `${t.prefixes[0].commit}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n- added after the gate\n') + '\n'; - publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: smuggled }); - fx.write(d => { (d.prs['1'] as any).state = 'MERGED'; d.vanish = { '1': 1 }; }); - const frozen = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord); - expect(frozen.status).toBe(EXIT.fail); - expect(frozen.err).toContain('"- added after the gate"); the queue is frozen;'); - expect(fx.read().vanish).toEqual({ '1': 0 }); - expect(line(frozen.out, 'freeze:')).toMatch(/; #1 merged on GitHub without a verified tree; unmerged #2; repair none admitted$/); - expect(JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`), 'utf8'))).toMatchObject({ merged: ['1'], unmerged: [{ pr: '2', head: h2 }] }); - }, 300_000); - - // Value: protects=a slot unreadable on GitHub beside a merged one is left out of unmerged and named in the reason, so the rebuild after the repair never seats a PR that may already be in; fails_when=freezeQueue files an unreadable slot under unmerged, or drops it without naming it; why_new=the test above keeps slot 2 readable; seam=the fixture's gh pr view vanishing for slot 2; no_claim=the backstop's outcome - test("a slot unreadable on GitHub beside a merged one is left out of unmerged, recorded as unreadable and named by the NEXT after the repair", () => { - const fx = make(); - const { h2, nonce } = twoPrTrain(fx); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const t = trainJson(fx, nonce); - const coord = coordinatorClone(fx.repo); - const smuggled = fx.repo.git('show', `${t.prefixes[0].commit}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n- added after the gate\n') + '\n'; - publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: smuggled }); - fx.write(d => { (d.prs['1'] as any).state = 'MERGED'; d.vanish = { '2': 0 }; }); - const frozen = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord); - expect(frozen.status).toBe(EXIT.fail); - expect(line(frozen.out, 'freeze:')).toMatch(/; #1 merged on GitHub without a verified tree; #2 unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers; unmerged \(none\); repair none admitted$/); - expect(JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`), 'utf8'))).toMatchObject({ merged: ['1'], unmerged: [], unreadable: [{ pr: '2', head: h2 }] }); - expect(line(frozen.out, 'unreadable:')).toBe(`unreadable: #2@${h2.slice(0, 12)} (GitHub did not answer at the freeze; neither merged nor unmerged)`); - // The repair clears the freeze, and the NEXT names the unreadable slot instead of omitting it (#3519 b). - const h3 = openPr(fx, '3'); - readyReceipt(fx, '3', h3); - const rn = nonceOf(queue(fx, ['train', 'build', '--repair', '--prs', `3@${h3}`]).out); - expect(queue(fx, ['train', 'verdict', '--train', rn]).status).toBe(EXIT.ok); - publishMain(fx, trainJson(fx, rn).prefixes[1].commit, '1.2.6.0'); - const cleared = queue(fx, ['verify', '--pr', '3', '--train', rn], {}, coord); - expect(cleared.out).toMatch(/^FREEZE: cleared /m); - expect(next(cleared.out)).toBe(`NEXT: gh pr view 2 --json state,headRefOid (unreadable at the freeze: #2@${h2.slice(0, 12)}); seat each that is OPEN in a train`); - }, 300_000); - - // Value: protects=verify on a train whose every slot already merged, against a main another train verified, refuses as stale instead of freezing the queue and dispatching a backstop (#3519 c: train 24's record froze the queue at 9ccb5233, T1 of verified train 28, run 40586582766); fails_when=stopped() freezes when every slot merged and main's sha is another train's verified prefix, or refuses without that proof; why_new=every freeze test above has an unmerged or unverified main; seam=a second train record written into the state root; no_claim=what prune --stale then does (covered by the prune tests) - test("verify on a train whose every slot merged, against a main another train verified, refuses as stale and freezes nothing", () => { - const fx = make(); - const { nonce } = twoPrTrain(fx); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const t = trainJson(fx, nonce); - const coord = coordinatorClone(fx.repo); - const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); - publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); - expect(v('1').status).toBe(EXIT.ok); - const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' }); - fx.write(d => { (d.prs['2'] as any).state = 'MERGED'; }); - const freezeFile = path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`); - const tree = fx.repo.git('rev-parse', `${later}^{tree}`); - const otherNonce = `${nonce}-later`; - const other = { ...t, nonce: otherNonce, state: 'merging', prefixes: [t.prefixes[0], { index: 1, commit: later, tree, pr: '9' }], merged: [{ pr: '9', sha: later, tree, at: '2026-10-18T13:00:00.000Z' }] }; - fs.mkdirSync(path.join(fx.state, 'ship-queue', 'trains', otherNonce), { recursive: true }); - fs.writeFileSync(path.join(fx.state, 'ship-queue', 'trains', otherNonce, 'train.json'), JSON.stringify(other)); - const stale = v('2'); - expect(stale.status).toBe(EXIT.refused); - expect(stale.err).toContain(`every slot of train ${nonce} merged on GitHub and main ${later.slice(0, 12)} is T1 of train ${otherNonce} (#9, verified at 2026-10-18T13:00:00.000Z), so this record is stale and the queue is not frozen`); - expect(stale.err).toContain('(QUEUE_TRAIN_STALE)'); - expect(next(stale.out)).toBe(`NEXT: gstack-ship-queue train prune --stale --train ${nonce}`); - expect(fs.existsSync(freezeFile)).toBe(false); - expect(dispatches(fx, 'evals.yml').filter(d => d.ref === 'main').length).toBe(0); - // Without another train's verified record for this main, the same mismatch still freezes. - fs.rmSync(path.join(fx.state, 'ship-queue', 'trains', otherNonce), { recursive: true }); - const frozen = v('2'); - expect(frozen.status).toBe(EXIT.fail); - expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1,#2; main /); - }, 300_000); - - // Value: protects=a train with a slot still OPEN on GitHub freezes on a mismatch even when main's sha is another train's verified prefix; fails_when=the every-slot-merged guard before verifiedElsewhere is dropped or weakened, so a live train refuses as stale and skips the freeze; why_new=the test above only covers an all-merged train; seam=none; no_claim=prune behavior - test("a train with a slot still open freezes on the mismatch even when another train verified main's sha", () => { - const fx = make(); - const { nonce } = twoPrTrain(fx); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const t = trainJson(fx, nonce); - const coord = coordinatorClone(fx.repo); - const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); - publishMain(fx, t.prefixes[1].commit, '1.2.4.0'); - expect(v('1').status).toBe(EXIT.ok); - const later = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'stray.txt', text: 'a later train\n' }); - const tree = fx.repo.git('rev-parse', `${later}^{tree}`); - const otherNonce = `${nonce}-later`; - const other = { ...t, nonce: otherNonce, state: 'merging', prefixes: [t.prefixes[0], { index: 1, commit: later, tree, pr: '9' }], merged: [{ pr: '9', sha: later, tree, at: '2026-10-18T13:00:00.000Z' }] }; - fs.mkdirSync(path.join(fx.state, 'ship-queue', 'trains', otherNonce), { recursive: true }); - fs.writeFileSync(path.join(fx.state, 'ship-queue', 'trains', otherNonce, 'train.json'), JSON.stringify(other)); - const partial = v('2'); - expect(partial.status).toBe(EXIT.fail); - expect(partial.err).not.toContain('(QUEUE_TRAIN_STALE)'); - expect(line(partial.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /); - expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`))).toBe(true); - }, 300_000); - - // Value: protects=a post-merge CHANGELOG difference is excused only when main's parent is T modulo stamps, so a foreign commit between the verified head and the squash freezes; fails_when=published() skips or inverts the parent check; why_new=the two tests above reach the CHANGELOG rule only with a parent that is T1 modulo stamps; seam=none; no_claim=a parent differing from T1 in the CHANGELOG alone - test("a post-merge CHANGELOG difference is not excused when main's parent is not the verified prefix modulo stamps: the queue freezes naming the parent", () => { - const fx = make(); - const { nonce } = twoPrTrain(fx); - expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); - const t = trainJson(fx, nonce); - const coord = coordinatorClone(fx.repo); - const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); - const released = fx.repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n'; - const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released }); - expect(v('1').status).toBe(EXIT.ok); - const foreign = publishMain(fx, t.prefixes[1].commit, '1.2.4.1', { file: 'stray.txt', text: 'landed between the two slots\n' }); - const text = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n'; - const main2 = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text }); - expect(fx.repo.git('rev-parse', `${main2}^`)).toBe(foreign); - const frozen = v('2'); - expect(frozen.status).toBe(EXIT.fail); - expect(frozen.err).toContain(`main ${main2.slice(0, 12)} is neither T1 nor T2 modulo stamps`); - expect(frozen.err).toContain(`main's parent ${foreign.slice(0, 12)} is not T1 modulo stamps`); - expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /); - }, 300_000); -}); diff --git a/test/test-of-test-ratchet.test.ts b/test/test-of-test-ratchet.test.ts index 2aed79521..cad251bda 100644 --- a/test/test-of-test-ratchet.test.ts +++ b/test/test-of-test-ratchet.test.ts @@ -56,6 +56,7 @@ const BASELINE = [ 'test/ceo-split-question-policy.test.ts', 'test/ceo-stale-fill-decision.test.ts', // replays census reports against the paid CEO section-loading case's structured checker 'test/changed-files-union.test.ts', + 'test/claude-overlay-setup-installs.test.ts', // GSTA-151: its bin/gstack-skill-start assertion moved to test/claude-overlay-setup-project.test.ts in the shard-time split; the remaining cases still run the real ./setup through test/helpers/install-fixture.ts 'test/claude-provider-keychain.test.ts', 'test/codex-carve-fixture.test.ts', 'test/codex-eval-recording.test.ts', @@ -83,6 +84,8 @@ const BASELINE = [ 'test/docsync-atomic-writes.test.ts', // landed in v1.91.7.0 before this ratchet: harness owner for its functional-QA/docsync paid evals 'test/docsync-child-marker.test.ts', // replays a PR-lane capture against the paid ship-docsync case's child-marker check 'test/docsync-command-grammar.test.ts', // landed in v1.91.7.0 before this ratchet: harness owner for its functional-QA/docsync paid evals + 'test/docsync-fault-interface-faults.test.ts', // GSTA-151: split from test/docsync-fault-interface.test.ts for shard time; replays the fault-control callbacks through the generated child + 'test/docsync-fault-interface-legacy.test.ts', // GSTA-151: split from test/docsync-fault-interface.test.ts for shard time; replays the legacy-control callbacks through the generated child 'test/docsync-nested-writes.test.ts', // landed in v1.91.7.0 before this ratchet: harness owner for its functional-QA/docsync paid evals 'test/dx-selected-navigation-ap.test.ts', 'test/eng-batching-current-ledger.test.ts', @@ -124,6 +127,7 @@ const BASELINE = [ 'test/helpers/session-runner.test.ts', 'test/helpers/sync-command-capture.test.ts', 'test/hooks-windows-paths.test.ts', + 'test/host-renders-prune-shared-root.test.ts', // GSTA-151: split from test/host-renders-prune.test.ts for shard time; runs the real ./setup prune through test/helpers/install-fixture.ts 'test/ios-debug-bridge-release-guard.test.ts', 'test/ios-qa-stateserver-hardening.test.ts', 'test/ios-qa-swift-build.test.ts', @@ -209,10 +213,13 @@ const BASELINE = [ 'test/session-runner-timeout.test.ts', 'test/setup-browser-hint.test.ts', 'test/setup-bun-cmd-and-pipe-bugs.test.ts', + 'test/setup-codex-scope-runtime-links.test.ts', // GSTA-151: split from test/setup-codex-scope-review-boundaries.test.ts for shard time; runs the real ./setup through test/helpers/install-fixture.ts 'test/setup-conductor-worktree.test.ts', 'test/setup-playwright-platform.test.ts', 'test/setup-sections-linking.test.ts', 'test/shared-libs-cancellation.test.ts', // landed in v1.91.7.0 before this ratchet: harness owner for its functional-QA/docsync paid evals + 'test/shared-libs-checker-interface-evidence-declared.test.ts', // GSTA-151: split from test/shared-libs-checker-interface-evidence.test.ts for shard time; same cases, slices the same paid source + 'test/shared-libs-checker-interface-evidence-replays.test.ts', // GSTA-151: split from test/shared-libs-checker-interface-evidence.test.ts for shard time; same cases 'test/shared-libs-checker-interface-evidence.test.ts', // slices test/skill-e2e-shared-libs.test.ts source (recorded 2026-10 when the ratchet learned paid-source slicing) 'test/shared-libs-fixture.test.ts', // slices test/skill-e2e-shared-libs-periodic.test.ts source (recorded 2026-10 when the ratchet learned paid-source slicing) 'test/shared-libs-plan-actor.test.ts', @@ -231,6 +238,8 @@ const BASELINE = [ 'test/tasks-section-jq.test.ts', 'test/test-free-shards-capture.test.ts', 'test/timeline.test.ts', + 'test/ubicloud-runner-reattach.test.ts', // GSTA-151: split from test/ubicloud-runner.test.ts for shard time; runs scripts/ubicloud/ubi-runner.sh through test/helpers/ubi-runner-fake-vm.ts + 'test/ubicloud-runner-tiers.test.ts', // GSTA-151: split from test/ubicloud-runner.test.ts for shard time; runs scripts/ubicloud/ubi-runner.sh through test/helpers/ubi-runner-fake-vm.ts 'test/workflow-judge-cache.test.ts', // slices test/skill-llm-eval.test.ts source (recorded 2026-10 when the ratchet learned paid-source slicing) 'test/workflow-judge-input.test.ts', // slices test/skill-llm-eval.test.ts source (recorded 2026-10 when the ratchet learned paid-source slicing) ]; diff --git a/test/ubicloud-runner-reattach.test.ts b/test/ubicloud-runner-reattach.test.ts new file mode 100644 index 000000000..1f9de77a9 --- /dev/null +++ b/test/ubicloud-runner-reattach.test.ts @@ -0,0 +1,157 @@ +/** + * ubi-runner detached phases, reattach, signals and the run key (GSTA-158 P3), against the fake Ubicloud API and fake ssh in + * test/helpers/ubi-runner-fake-vm.ts. Split from test/ubicloud-runner.test.ts (GSTA-151). + */ +import { describe, expect, test } from 'bun:test'; +import { existsSync, lstatSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { DIR, fakeVm, src, run, until, vmDirs, phaseStarted, creates, killSession, gitSrc } from './helpers/ubi-runner-fake-vm'; + +describe('ubi-runner run, verify and quota against a fake API and a fake ssh', () => { + test('run: a SIGKILLed runner leaves its VM; the same command reattaches, skips the finished setup and reports the command\'s exit', async () => { + const f = fakeVm(); + try { + const repo = gitSrc(f); + const setup = join(f.root, 'setup.sh'); writeFileSync(setup, 'echo setup >> ~/setup-count\n'); + writeFileSync(join(f.root, 'make-test.sh'), 'echo begin; sleep 4; echo end; exit 3\n'); + const args = ['run', '--src', repo, '--setup', setup, '--', 'make test']; + const a = f.spawn(args); + await until(() => vmDirs(f).some(d => existsSync(join(f.state, d, 'command.out')) && readFileSync(join(f.state, d, 'command.out'), 'utf8').includes('begin')), 30_000); + process.kill(a.pid, 'SIGKILL'); + await a.exited; + const name = vmDirs(f)[0]!; + expect(f.listed()).toContain(name); + const b = f.run(args); + const err = b.stderr.toString(); + expect(b.exitCode, err).toBe(3); + expect(err).toMatch(new RegExp('ubi-runner: reattached to ' + name + ' created_at=\\d{10} key=[0-9a-f]{12}\n')); + expect(b.stdout.toString().split('\n').filter(l => l === 'begin' || l === 'end')).toEqual(['begin', 'end']); + expect(err).toMatch(/command exited 3 after \d+s/); + expect(creates(f)).toBe(1); + expect(readFileSync(join(f.vm, 'setup-count'), 'utf8')).toBe('setup\n'); + expect(f.listed()).toBe(''); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + + test('run: a second live runner of the same command refuses with UBI_ATTACH_BUSY and creates nothing', async () => { + const f = fakeVm(); + try { + const repo = gitSrc(f); + writeFileSync(join(f.root, 'make-test.sh'), 'sleep 4; echo done\n'); + const args = ['run', '--src', repo, '--', 'make test']; + const a = f.spawn(args); + await until(() => phaseStarted(f, 'command'), 30_000); + const b = f.run(args); + expect(b.exitCode).toBe(1); + expect(b.stderr.toString()).toMatch(new RegExp('UBI_ATTACH_BUSY: vm=' + vmDirs(f)[0] + ' key=[0-9a-f]{12}: runner pid ' + a.pid + ' ')); + expect(await a.exited).toBe(0); + expect(await new Response(a.stdout).text()).toContain('done'); + expect(creates(f)).toBe(1); + expect(f.listed()).toBe(''); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + + test('run: HUP once the command is live detaches (UBI_DETACHED, exit 129, VM kept, no pass value on disk); a rerun reattaches and finishes', async () => { + const f = fakeVm(); + try { + const repo = gitSrc(f); + writeFileSync(join(f.root, 'make-test.sh'), 'sleep 3; echo finished\n'); + const args = ['run', '--src', repo, '--pass', 'UBI_TEST_TOKEN', '--', 'make test']; + const a = f.spawn(args, { UBI_TEST_TOKEN: 'hup-secret-value' }); + await until(() => phaseStarted(f, 'command'), 30_000); + await Bun.sleep(500); + process.kill(a.pid, 'SIGHUP'); + expect(await a.exited).toBe(129); + const name = vmDirs(f)[0]!; + const err = await new Response(a.stderr).text(); + expect(err).toMatch(new RegExp('UBI_DETACHED: vm=' + name + ' key=[0-9a-f]{12} reattach: rerun the same command')); + expect(f.listed()).toContain(name); + expect(f.calls()).not.toContain('destroy'); + for (const file of readdirSync(join(f.state, name))) { + const p = join(f.state, name, file); + if (lstatSync(p).isFile()) expect(readFileSync(p, 'utf8')).not.toContain('hup-secret-value'); + } + const b = f.run(args, { UBI_TEST_TOKEN: 'hup-secret-value' }); + expect(b.exitCode, b.stderr.toString()).toBe(0); + expect(b.stderr.toString()).toContain('reattached to ' + name); + expect(b.stdout.toString()).toContain('finished'); + expect(creates(f)).toBe(1); + expect(f.listed()).toBe(''); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + + test('run: INT and TERM during a live command still tear the VM down', async () => { + for (const sig of ['SIGTERM', 'SIGINT'] as const) { + const f = fakeVm(); + try { + const repo = gitSrc(f); + writeFileSync(join(f.root, 'make-test.sh'), 'sleep 3\n'); + const a = f.spawn(['run', '--src', repo, '--', 'make test']); + await until(() => phaseStarted(f, 'command'), 30_000); + await Bun.sleep(500); + process.kill(a.pid, sig); + expect(await a.exited).toBe(130); + expect(await new Response(a.stderr).text()).toMatch(/destroyed eu-central-h1\/ubirun-thrd1-/); + expect(f.listed()).toBe(''); + } finally { rmSync(f.root, { recursive: true, force: true }); } + } + }, 90_000); + + test('run: a remote session gone without an exit status exits 125 with UBI_ATTACH_LOST and the VM name', async () => { + const f = fakeVm(); + try { + const repo = gitSrc(f); + writeFileSync(join(f.root, 'make-test.sh'), 'sleep 30\n'); + const args = ['run', '--src', repo, '--', 'make test']; + const a = f.spawn(args); + await until(() => existsSync(join(f.vm, '.ubirun-command.pid')) && readFileSync(join(f.vm, '.ubirun-command.pid'), 'utf8').trim() !== '', 30_000); + process.kill(a.pid, 'SIGKILL'); + await a.exited; + await killSession(f, 'command'); + const name = vmDirs(f)[0]!; + const b = f.run(args); + expect(b.exitCode).toBe(125); + expect(b.stderr.toString()).toContain('UBI_ATTACH_LOST: vm=' + name + ' phase=command'); + expect(f.listed()).toBe(''); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + + test('run: the run key covers tree, command, setup content, env, pass value, image, origin/HEAD and the runner itself; a pass value never appears in it', () => { + const f = fakeVm(); + try { + const repo = gitSrc(f); + const setup = join(f.root, 'setup.sh'); writeFileSync(setup, 'echo one\n'); + const runner = join(f.root, 'runner-copy.sh'); writeFileSync(runner, readFileSync(join(DIR, 'ubi-runner.sh'), 'utf8')); + const key = (opts: { args?: string[]; env?: Record; cmd?: string; script?: string } = {}) => { + const r = Bun.spawnSync(['bash', opts.script ?? join(DIR, 'ubi-runner.sh'), 'run', '--probe', '--src', repo, '--setup', setup, '--env', 'MODE=a', '--pass', 'UBI_TEST_TOKEN', ...(opts.args ?? []), '--', opts.cmd ?? 'make test'], + { env: { ...process.env, PATH: join(f.root, 'bin') + ':' + process.env.PATH, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: f.state, UBI_OWNER: 'thrd1', UBI_TEST_TOKEN: 'v1', ...(opts.env ?? {}) }, timeout: 30_000 }); + const out = r.stdout.toString() + r.stderr.toString(); + expect(out).not.toContain('v1'); + const m = /^UBI_ATTACH_INFO: none key=([0-9a-f]{12})$/m.exec(out); + if (!m) throw new Error(out); + return m[1]!; + }; + const base = key(); + expect(key()).toBe(base); + const variants: Record = {}; + variants.command = key({ cmd: 'make check' }); + variants.env = key({ args: ['--env', 'MODE=b'] }); + variants.pass = key({ env: { UBI_TEST_TOKEN: 'v2' } }); + variants.image = key({ args: ['-b', 'ubuntu-jammy'] }); + variants.size = key({ args: ['-s', 'standard-8'] }); + writeFileSync(runner, readFileSync(runner, 'utf8') + '# edited\n'); + variants.runner = key({ script: runner }); + writeFileSync(setup, 'echo two\n'); + variants.setup = key(); + writeFileSync(setup, 'echo one\n'); + expect(key()).toBe(base); + Bun.spawnSync(['git', 'update-ref', 'refs/remotes/origin/HEAD', 'HEAD'], { cwd: repo, timeout: 10_000 }); + variants.base = key(); + writeFileSync(join(repo, 'new-file.txt'), 'untracked source\n'); + variants.tree = key(); + for (const [what, k] of Object.entries(variants)) expect({ what, same: k === base }).toEqual({ what, same: false }); + expect(new Set(Object.values(variants)).size).toBe(Object.keys(variants).length); + expect(f.calls()).not.toContain('"create"'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 60_000); +}); diff --git a/test/ubicloud-runner-run.test.ts b/test/ubicloud-runner-run.test.ts new file mode 100644 index 000000000..81890df77 --- /dev/null +++ b/test/ubicloud-runner-run.test.ts @@ -0,0 +1,139 @@ +/** + * ubi-runner run: watchdog, timeouts, teardown accounting, ssh dispatch and --pass, against the fake Ubicloud API and fake ssh in + * test/helpers/ubi-runner-fake-vm.ts. Split from test/ubicloud-runner.test.ts (GSTA-151). + */ +import { describe, expect, test } from 'bun:test'; +import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { read, fakeVm, src, run } from './helpers/ubi-runner-fake-vm'; + +describe('ubi-runner run, verify and quota against a fake API and a fake ssh', () => { + test('run: a hung setup is killed by the watchdog, exits 124, never reaches the command, and the VM is destroyed', () => { + const f = fakeVm(); + try { + const setup = join(f.root, 'setup.sh'); writeFileSync(setup, 'echo "setup start"; sleep 20\n'); + const r = run(f, ['run', '--src', f.src, '--setup', setup, '--', 'make test'], { UBI_IDLE_TIMEOUT: '2' }); + const err = r.stderr.toString(); + expect(r.exitCode, err).toBe(124); + expect(err).toMatch(/watchdog: setup printed nothing for \d+s \(UBI_IDLE_TIMEOUT=2\)/); + expect(err).toContain('setup failed (exit 124)'); + expect(f.ssh()).not.toContain('bash -lc'); + expect(err).toContain('setup start'); + // The setup runs detached in its own session: its leader's pid, both streams and the exit status are files on the VM. + expect(f.ssh()).toMatch(/setsid -f bash -c 'echo \$\$ >~\/\.ubirun-setup\.pid\.tmp && mv ~\/\.ubirun-setup\.pid\.tmp ~\/\.ubirun-setup\.pid; \( cd work\/[^ ]+ && \. ~\/\.ubirun-env && bash -l ~\/\.ubirun-setup\.sh \) >~\/\.ubirun-setup\.out 2>~\/\.ubirun-setup\.err <\/dev\/null; echo \$\? >~\/\.ubirun-setup\.exit\.tmp && mv ~\/\.ubirun-setup\.exit\.tmp ~\/\.ubirun-setup\.exit' <\/dev\/null/m); + expect(readFileSync(join(f.root, 'cancel.log'), 'utf8')).toMatch(/sid=\$\(cat ~\/\.ubirun-setup\.pid 2>\/dev\/null\); rm -f ~\/\.ubirun-setup\.pid; \[ -z "\$sid" \] \|\| \{ pkill -TERM -s "\$sid"; sleep 2; pkill -KILL -s "\$sid"; \}/); + expect(existsSync(join(f.vm, '.ubirun-setup.exit'))).toBe(false); + expect(f.calls()).toContain('destroy'); + expect(err).toMatch(/destroyed eu-central-h1\/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3/); + expect(f.listed()).toBe(''); + expect(readFileSync(f.record, 'utf8')).toBe(''); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 60_000); + + test('run: UBI_RUN_TIMEOUT kills a command that keeps printing, with exit 124 and its reason', () => { + const f = fakeVm(); + try { + writeFileSync(join(f.root, 'command-mode'), 'for i in 1 2 3 4 5 6 7 8 9 10; do echo "tick $i"; sleep 1; done'); + const r = run(f, ['run', '--src', f.src, '--', 'make test'], { UBI_RUN_TIMEOUT: '3' }); + expect(r.exitCode).toBe(124); + expect(r.stderr.toString()).toMatch(/watchdog: command ran \d+s \(UBI_RUN_TIMEOUT=3\); killing it/); + expect(r.stderr.toString()).toContain('command exited 124 after'); + expect(f.calls()).toContain('destroy'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 60_000); + + test('run: ends with UBICLOUD: left=0 for its own VM; a same-owner sibling is counted as information and does not fail the run', () => { + const f = fakeVm({ listed: 'eu-central-h1 ubirun-thrd1-9999999999-sib1-t3\n' }); + try { + const leftFile = join(f.root, 'left'); + const r = f.run(['run', '--src', f.src, '--', 'true'], { UBI_REAP: '0', UBI_LEFT_FILE: leftFile }); + const err = r.stderr.toString(); + expect(r.exitCode, err).toBe(0); + expect(err).toMatch(/UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n/); + expect(f.listed()).toBe('eu-central-h1 ubirun-thrd1-9999999999-sib1-t3\n'); + // Value: protects=UBI_LEFT_FILE carries the same leftover line pregate-remote.sh parses, apart from the command's output; fails_when=the file is not written or its format drifts from pregate-remote.sh's parser; why_new=the pregate stub hand-copies the format; seam=none; no_claim=real VM teardown (fake API and ssh) + expect(readFileSync(leftFile, 'utf8')).toMatch(/^UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n$/); + const parser = read('scripts/ubicloud/pregate-remote.sh').match(/left=\$\(sed -n '([^']+)' "\$log\.left"/)![1]; + expect(Bun.spawnSync(['sed', '-n', parser, leftFile], { timeout: 30_000 }).stdout.toString()).toBe('0\n'); + // Value: protects=run --keep reports its live VM as UBICLOUD: left=1 ... kept=1 and never a clean left=0; fails_when=the kept line is dropped or --keep still prints the teardown line; why_new=no test runs run --keep; seam=none; no_claim=real VM lifetime (fake API and ssh) + const kept = f.run(['run', '--src', f.src, '--keep', '--', 'true'], { UBI_REAP: '0' }); + const keptErr = kept.stderr.toString(); + expect(kept.exitCode, keptErr).toBe(0); + expect(keptErr).toMatch(/UBICLOUD: left=1 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 kept=1\n/); + expect(keptErr).not.toContain('left=0'); + expect(f.listed()).toMatch(/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3\n/); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 30_000); + + test('run: a passing command whose teardown fails exits 1 with UBICLOUD: left=1 and the down command; an unreadable VM list fails closed', () => { + const f = fakeVm(); + try { + writeFileSync(join(f.root, 'listed.destroy-fails'), ''); + const leaked = f.run(['run', '--src', f.src, '--', 'true'], { UBI_REAP: '0', UBI_DOWN_TIMEOUT: '3' }); + const err = leaked.stderr.toString(); + expect(leaked.exitCode, err).toBe(1); + expect(err).toMatch(/UBICLOUD: left=1 owner=thrd1 vm=(ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3) others=0\n[^]*VM cleanup not confirmed for \1 \(left=1\); destroy it with: \S+ down \1/); + expect(f.listed()).toContain('ubirun-thrd1-'); + // Value: protects=a failing command whose VM also leaks keeps its own exit status, not 1; fails_when=run_teardown exits 1 whenever left is not 0; why_new=the leak case above only runs a passing command; seam=none; no_claim=real destroy failure modes (fake API returns 500) + writeFileSync(join(f.root, 'command-mode'), 'exit 7'); + const failedLeak = f.run(['run', '--src', f.src, '--', 'make test'], { UBI_REAP: '0', UBI_DOWN_TIMEOUT: '3' }); + expect(failedLeak.exitCode, failedLeak.stderr.toString()).toBe(7); + expect(failedLeak.stderr.toString()).toMatch(/UBICLOUD: left=1 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n/); + const blind = fakeVm(); + try { + writeFileSync(join(blind.root, 'bin/curl.real'), readFileSync(join(blind.root, 'bin/curl'))); + chmodSync(join(blind.root, 'bin/curl.real'), 0o755); + writeFileSync(join(blind.root, 'bin/curl'), '#!/usr/bin/env bash\nif [ -f ' + JSON.stringify(join(blind.root, 'armed')) + ' ]; then touch ' + JSON.stringify(join(blind.root, 'listed.list-fails')) + '; fi\nexec ' + JSON.stringify(join(blind.root, 'bin/curl.real')) + ' "$@"\n'); + chmodSync(join(blind.root, 'bin/curl'), 0o755); + writeFileSync(join(blind.root, 'command-mode'), 'touch ' + JSON.stringify(join(blind.root, 'armed'))); + const r = blind.run(['run', '--src', blind.src, '--', 'make test'], { UBI_REAP: '0' }); + expect(r.exitCode, r.stderr.toString()).toBe(1); + expect(r.stderr.toString()).toMatch(/UBICLOUD: left=unknown owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=unknown\n/); + } finally { rmSync(blind.root, { recursive: true, force: true }); } + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 60_000); + + test('run: the fake ssh dispatches on the remote command, so a checkout whose name holds -O still syncs and runs (GSTA-139)', () => { + const f = fakeVm(); + try { + const checkout = join(f.root, 'ubi-run-O1x2y3'); + mkdirSync(checkout); + const r = run(f, ['run', '--src', checkout, '--', 'make test']); + expect(r.exitCode, r.stderr.toString()).toBe(0); + expect(r.stdout.toString()).toContain('ran: '); + expect(f.ssh()).toContain('tar -xzf - -C work/ubi-run-O1x2y3'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 60_000); + + test('run: --pass forwards a named local variable only; UBICLOUD_* and non-names are refused before any API call', () => { + const f = fakeVm(); + try { + const ok = run(f, ['run', '--src', f.src, '--pass', 'UBI_TEST_TOKEN', '--env', 'CI=1', '--', 'make test'], { UBI_TEST_TOKEN: 'sekret value' }); + expect(ok.exitCode).toBe(0); + expect(ok.stderr.toString()).toContain('command exited 0 after'); + const envFile = readFileSync(join(f.root, 'ubirun-env'), 'utf8'); + expect(envFile).toContain('export UBI_TEST_TOKEN=sekret\\ value'); + expect(envFile).toContain('export CI=1'); + expect(envFile).not.toContain('UBICLOUD'); + expect(ok.stdout.toString() + ok.stderr.toString()).not.toContain('sekret'); + for (const bad of ['UBICLOUD_API_KEY', 'UBICLOUD_API_TOKEN', 'X;Y', 'HOME/..']) { + const before = f.calls(); + const r = run(f, ['run', '--src', f.src, '--pass', bad, '--', 'true']); + expect(r.exitCode).toBe(1); + expect(r.stderr.toString()).toMatch(bad.startsWith('UBICLOUD_') ? /never forwards its own Ubicloud credentials/ : /not a variable name/); + expect(f.calls()).toBe(before); + } + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 60_000); + + test('ssh NAME COMMAND hands the caller’s stdin to the remote command', () => { + const f = fakeVm({ listed: 'eu-central-h1 vm-a\n' }); + try { + mkdirSync(join(f.state, 'vm-a'), { recursive: true }); + writeFileSync(join(f.state, 'vm-a/env'), 'NAME=vm-a\nLOCATION=eu-central-h1\nSIZE=standard-16\nIP=192.0.2.1\nCREATED=yes\nCREATE_AT=0\nCREATE_PID=1\n'); + const r = run(f, ['ssh', 'vm-a', 'cat > captured'], { UBI_IDLE_TIMEOUT: '0' }, 'payload line\n'); + expect(r.exitCode).toBe(0); + expect(readFileSync(join(f.root, 'captured'), 'utf8')).toBe('payload line\n'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 30_000); +}); diff --git a/test/ubicloud-runner-tiers.test.ts b/test/ubicloud-runner-tiers.test.ts new file mode 100644 index 000000000..17019e59f --- /dev/null +++ b/test/ubicloud-runner-tiers.test.ts @@ -0,0 +1,182 @@ +/** + * ubi-runner verify tiers and their checkpoints (GSTA-158 P3), against the fake Ubicloud API and fake ssh in + * test/helpers/ubi-runner-fake-vm.ts. Split from test/ubicloud-runner.test.ts (GSTA-151). + */ +import { describe, expect, test } from 'bun:test'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DIR, fakeVm, src, run, until, vmDirs, creates, killSession, gitSrc, TIERS, bootCounts, verifyLine } from './helpers/ubi-runner-fake-vm'; + +describe('ubi-runner run, verify and quota against a fake API and a fake ssh', () => { + test('verify tiers: config refuses command with tiers, bad and duplicate tier names, before any API call', () => { + const f = fakeVm(); + try { + const cases: Array<[unknown, RegExp]> = [ + [{ command: 'make', tiers: [{ name: 'a', command: 'x' }] }, /UBI_CONFIG_TIERS_AND_COMMAND/], + [{ tiers: [{ name: 'Unit', command: 'x' }] }, /UBI_CONFIG_TIER_NAME: tier name 'Unit' must match/], + [{ tiers: [{ name: '-x', command: 'x' }] }, /UBI_CONFIG_TIER_NAME: tier name '-x'/], + [{ tiers: [{ name: 'a'.repeat(33), command: 'x' }] }, /UBI_CONFIG_TIER_NAME/], + [{ tiers: [{ name: 'a.b', command: 'x' }] }, /UBI_CONFIG_TIER_NAME/], + [{ tiers: [{ name: 'a', command: 'x' }, { name: 'a', command: 'y' }] }, /UBI_CONFIG_TIER_NAME: tier name 'a' appears twice/], + [{ tiers: [{ name: 'a' }] }, /UBI_CONFIG_TIER_NAME: tier 'a' needs a command/], + [{ tiers: [] }, /UBI_CONFIG_TIER_NAME: tiers must be a non-empty list/], + ]; + for (const [cfg, want] of cases) { + const dir = src(f, cfg); + const r = run(f, ['verify', '--src', dir]); + expect(r.exitCode).toBe(1); + expect(r.stderr.toString()).toMatch(want); + } + expect(f.calls()).toBe(''); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 30_000); + + test('verify tiers: a run killed during tier 3 with its VM gone resumes on a new VM: setup reruns, t1 and t2 are reused, t3 reruns', async () => { + const f = fakeVm(); + try { + const dir = gitSrc(f, TIERS); + bootCounts(f, dir); + Bun.spawnSync(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'boot'], { cwd: dir, timeout: 10_000 }); + writeFileSync(join(f.root, 'make-t3.sh'), 'touch ' + JSON.stringify(join(f.root, 't3-started')) + '; sleep 30\n'); + const a = f.spawn(['verify', '--src', dir]); + await until(() => existsSync(join(f.root, 't3-started')), 60_000); + process.kill(a.pid, 'SIGKILL'); + await a.exited; + await killSession(f, 'tier-t3'); + const first = vmDirs(f)[0]!; + writeFileSync(join(f.root, 'listed'), ''); + writeFileSync(join(f.root, 'make-t3.sh'), 'echo t3 ok\n'); + const b = f.run(['verify', '--src', dir]); + const err = b.stderr.toString(), out = b.stdout.toString(); + expect(b.exitCode, err).toBe(0); + expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t1 reuse t1\.\d{10}\.\d+\.json \(\d+s\)\n/); + expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t2 reuse t2\.\d{10}\.\d+\.json \(\d+s\)\n/); + expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t3 run: interrupted \(only a start record, t3\.\d{10}\.\d+\.json\)\n/); + expect(err).toContain('run record for ' + first + ': the VM is gone; starting a new one'); + expect(err).not.toContain('reattached to'); + expect(creates(f)).toBe(2); + expect(readFileSync(join(f.vm, 'setup-count'), 'utf8')).toBe('boot\nboot\n'); + expect(out).not.toContain('ran: make t1'); + expect(out).toContain('t3 ok'); + expect(verifyLine(out)).toMatch(/^UBI_VERIFY: tree=[0-9a-f]{12} key=[0-9a-f]{12} tiers=3 passed=3 reused=t1,t2 failed=none prior_failures=none saved_s=\d+$/); + expect(f.listed()).toBe(''); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 120_000); + + test('verify tiers: a fail record blocks a rerun until --rerun-failed (then prior_failures); passes are reused unless stale, tampered, --no-resume or the tree changed', () => { + const f = fakeVm(); + try { + const dir = gitSrc(f, TIERS); + writeFileSync(join(f.root, 'make-t2.sh'), 'echo t2 broke; exit 7\n'); + const first = run(f, ['verify', '--src', dir]); + expect(first.exitCode, first.stderr.toString()).toBe(7); + expect(first.stdout.toString()).not.toContain('ran: make t3'); + expect(verifyLine(first.stdout.toString())).toMatch(/ tiers=3 passed=1 reused=none failed=t2 prior_failures=none saved_s=0$/); + expect(creates(f)).toBe(1); + + const blocked = run(f, ['verify', '--src', dir]); + expect(blocked.exitCode).toBe(7); + expect(blocked.stderr.toString()).toMatch(/UBI_VERIFY_RESUME: tier=t2 fail t2\.\d{10}\.\d+\.json \(\d+s, exit 7\): not rerun without --rerun-failed/); + expect(verifyLine(blocked.stdout.toString())).toMatch(/ failed=t2 prior_failures=none /); + expect(creates(f)).toBe(1); + + writeFileSync(join(f.root, 'make-t2.sh'), 'echo t2 fixed\n'); + const rerun = run(f, ['verify', '--src', dir, '--rerun-failed']); + expect(rerun.exitCode, rerun.stderr.toString()).toBe(0); + expect(rerun.stderr.toString()).toMatch(/UBI_VERIFY_RESUME: tier=t1 reuse /); + expect(rerun.stderr.toString()).toMatch(/UBI_VERIFY_RESUME: tier=t2 run: prior fail t2\.\d{10}\.\d+\.json \(exit 7; --rerun-failed\)/); + expect(verifyLine(rerun.stdout.toString())).toMatch(/ tiers=3 passed=3 reused=t1 failed=none prior_failures=t2:7 saved_s=\d+$/); + expect(creates(f)).toBe(2); + + const all = run(f, ['verify', '--src', dir]); + expect(all.exitCode).toBe(0); + expect(verifyLine(all.stdout.toString())).toMatch(/ passed=3 reused=t1,t2,t3 failed=none prior_failures=none saved_s=\d+$/); + expect(all.stderr.toString()).toContain('every tier passed under this key; no VM needed'); + expect(creates(f)).toBe(2); + + const probe = (args: string[] = [], extra: Record = {}) => run(f, ['verify', '--src', dir, '--probe', ...args], extra).stderr.toString(); + expect(probe(['--no-resume'])).toMatch(/tier=t1 run: --no-resume\n[^]*tier=t2 run: --no-resume\n[^]*tier=t3 run: --no-resume\n/); + expect(probe([], { UBI_CHECKPOINT_MAX_AGE: '0' })).toMatch(/tier=t1 run: pass t1\.\d{10}\.\d+\.json is \d+s old \(UBI_CHECKPOINT_MAX_AGE=0\)/); + const keyDir = join(f.checkpoints, readdirSync(f.checkpoints)[0]!); + const t1Log = readdirSync(keyDir).filter(n => n.startsWith('t1.') && n.endsWith('.log')).sort().at(-1)!; + writeFileSync(join(keyDir, t1Log), 'tampered\n'); + const tampered = probe(); + expect(tampered).toMatch(/tier=t1 run: pass t1\.\d{10}\.\d+\.json: its log does not re-hash/); + expect(tampered).toMatch(/tier=t2 reuse /); + writeFileSync(join(dir, 'new-source.ts'), 'export {}\n'); + const moved = probe(); + for (const t of ['t1', 't2', 't3']) expect(moved).toContain('UBI_VERIFY_RESUME: tier=' + t + ' run: no record'); + expect(creates(f)).toBe(2); + // Records are write-once JSON; every one carries its generation. + const gens = readdirSync(keyDir).filter(n => n.endsWith('.json')).map(n => JSON.parse(readFileSync(join(keyDir, n), 'utf8')).generation as number).sort((x, y) => x - y); + expect(gens).toEqual(gens.map((_, i) => i + 1)); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 120_000); + + test('verify tiers: a reattach whose VM TTL no longer covers the run left plus UBI_REATTACH_MARGIN starts a fresh VM, destroys the old one and reuses the passed tiers', async () => { + const f = fakeVm(); + try { + const dir = gitSrc(f, TIERS); + bootCounts(f, dir); + Bun.spawnSync(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'boot'], { cwd: dir, timeout: 10_000 }); + writeFileSync(join(f.root, 'make-t3.sh'), 'touch ' + JSON.stringify(join(f.root, 't3-started')) + '; sleep 30\n'); + const env = { UBI_REAP: '0', UBI_TTL_HOURS: '1' }; + const a = f.spawn(['verify', '--src', dir], env); + await until(() => existsSync(join(f.root, 't3-started')), 60_000); + process.kill(a.pid, 'SIGKILL'); + await a.exited; + const first = vmDirs(f)[0]!; + expect(first).toMatch(/-t1$/); + const probe = (extra: Record) => run(f, ['verify', '--src', dir, '--probe'], { ...env, ...extra }).stdout.toString(); + expect(probe({})).toMatch(new RegExp('UBI_ATTACH_INFO: vm=' + first + ' created_at=\\d{10} ')); + // A 1 h TTL leaves about 3600 s: t3's 1200 s run timeout plus a 2700 s margin does not fit. + const short = { UBI_RUN_TIMEOUT: '1200', UBI_REATTACH_MARGIN: '2700' }; + expect(probe(short)).toMatch(new RegExp('UBI_ATTACH_INFO: none key=[0-9a-f]{12} ttl_short vm=' + first + ' ttl_left=\\d+s needs=\\d+s \\(run still to do \\d+s \\+ UBI_REATTACH_MARGIN=2700s\\)')); + expect(creates(f)).toBe(1); + writeFileSync(join(f.root, 'make-t3.sh'), 'echo t3 ok\n'); + const b = run(f, ['verify', '--src', dir], { ...env, ...short }); + const err = b.stderr.toString(); + expect(b.exitCode, err).toBe(0); + expect(err).toMatch(new RegExp('UBI_REATTACH_TTL_SHORT: vm=' + first + ' key=[0-9a-f]{12} ttl_left=\\d+s needs=\\d+s \\(run still to do \\d+s \\+ UBI_REATTACH_MARGIN=2700s\\): starting a fresh VM \\(tier checkpoints carry over\\) and destroying ' + first)); + expect(err).not.toContain('reattached to'); + expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t1 reuse /); + expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t2 reuse /); + expect(verifyLine(b.stdout.toString())).toMatch(/ tiers=3 passed=3 reused=t1,t2 failed=none /); + expect(creates(f)).toBe(2); + expect(f.listed()).toBe(''); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 120_000); + + test('verify tiers: a writer whose start record is no longer the newest publishes nothing (stale writer)', () => { + const py = /\nCKPT_PY='\n([^']*)'\n/.exec(readFileSync(join(DIR, 'ubi-runner.sh'), 'utf8'))![1]!; + const root = mkdtempSync(join(tmpdir(), 'ubi-ckpt-')); + try { + const d = join(root, 'key'); + const log = join(root, 'tier.log'); writeFileSync(log, 'ok\n'); + const publish = (status: string, startgen: string, pid: string) => Bun.spawnSync(['python3', '-c', py, 'publish', d, 't1', status, 'vm', '100', '200', '0', status === 'start' ? '' : log, startgen, 'k', 'tree', pid], { timeout: 30_000 }); + expect(publish('start', '0', '1').stdout.toString().trim()).toBe('1'); + expect(publish('start', '0', '2').stdout.toString().trim()).toBe('2'); + const stale = publish('pass', '1', '1'); + expect(stale.exitCode).toBe(2); + expect(stale.stderr.toString()).toContain('stale writer: its start record is generation 1, the newest for t1 is 2'); + expect(readdirSync(d).filter(n => n.endsWith('.json')).length).toBe(2); + expect(publish('pass', '2', '2').stdout.toString().trim()).toBe('3'); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + + test('verify tiers: a tier that edits the source gets no pass record (UBI_VERIFY_TREE_MOVED) and reruns next time', () => { + const f = fakeVm(); + try { + const dir = gitSrc(f, { tiers: [{ name: 'mover', command: 'make mover' }, { name: 'clean', command: 'make clean' }] }); + writeFileSync(join(f.root, 'make-mover.sh'), 'echo edited > moved.txt\n'); + const r = run(f, ['verify', '--src', dir]); + expect(r.exitCode, r.stderr.toString()).toBe(0); + expect(r.stderr.toString()).toMatch(/UBI_VERIFY_TREE_MOVED: tier=mover before=[0-9a-f]{12} after=[0-9a-f]{12}: a tier must leave the source tree as it found it; no pass record/); + expect(verifyLine(r.stdout.toString())).toMatch(/ tiers=2 passed=2 reused=none failed=none /); + const next = run(f, ['verify', '--src', dir, '--probe']).stderr.toString(); + expect(next).toMatch(/tier=mover run: interrupted \(only a start record/); + expect(next).toMatch(/tier=clean reuse /); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); +}); diff --git a/test/ubicloud-runner-verify-quota.test.ts b/test/ubicloud-runner-verify-quota.test.ts new file mode 100644 index 000000000..92966c925 --- /dev/null +++ b/test/ubicloud-runner-verify-quota.test.ts @@ -0,0 +1,184 @@ +/** + * ubi-runner verify pass names and pull containment, quota cache and wait, reap, against the fake Ubicloud API and fake ssh in + * test/helpers/ubi-runner-fake-vm.ts. Split from test/ubicloud-runner.test.ts (GSTA-151). + */ +import { describe, expect, test } from 'bun:test'; +import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { read, fakeVm, src, run } from './helpers/ubi-runner-fake-vm'; + +describe('ubi-runner run, verify and quota against a fake API and a fake ssh', () => { + test('verify forwards a configured pass name only when the command line grants it, and never UBICLOUD_*', () => { + const f = fakeVm(); + try { + const dir = src(f, { setup: 'scripts/boot.sh', command: 'make verify', pass: ['UBI_TEST_TOKEN'] }); + const refused = run(f, ['verify', '--src', dir], { UBI_TEST_TOKEN: 'x' }); + expect(refused.exitCode).toBe(1); + expect(refused.stderr.toString()).toContain('asks to forward local UBI_TEST_TOKEN; grant each on this command line with --pass NAME, or every name it lists with --trust-config'); + expect(f.calls()).toBe(''); + const granted = run(f, ['verify', '--src', dir, '--pass', 'UBI_TEST_TOKEN'], { UBI_TEST_TOKEN: 'granted value' }); + expect(granted.exitCode).toBe(0); + expect(readFileSync(join(f.root, 'ubirun-env'), 'utf8')).toContain('export UBI_TEST_TOKEN=granted\\ value'); + expect(f.ssh()).toContain('bash -lc make\\ verify'); + rmSync(join(f.root, 'ubirun-env')); + const trusted = run(f, ['verify', '--src', dir, '--trust-config'], { UBI_TEST_TOKEN: 'trusted value' }); + expect(trusted.exitCode).toBe(0); + expect(readFileSync(join(f.root, 'ubirun-env'), 'utf8')).toContain('export UBI_TEST_TOKEN=trusted\\ value'); + rmSync(join(f.root, 'ubirun-env')); + const asValue = run(f, ['verify', '--src', dir, '--dest', '--trust-config', '--keep', '--dest', 'work/safe'], { UBI_TEST_TOKEN: 'x' }); + expect(asValue.exitCode).toBe(1); + expect(asValue.stderr.toString()).toContain('asks to forward local UBI_TEST_TOKEN'); + expect(existsSync(join(f.root, 'ubirun-env'))).toBe(false); + expect(run(f, ['verify', '--src', dir, '--pass']).stderr.toString()).toContain('verify: --pass needs a value'); + expect(run(f, ['verify', '--src', dir, '--bogus']).stderr.toString()).toContain('verify: unknown option --bogus'); + writeFileSync(join(dir, '.gstack/ubicloud.json'), JSON.stringify({ command: 'make verify', pass: ['UBICLOUD_API_KEY'] })); + const before = f.calls(); + const key = run(f, ['verify', '--src', dir, '--trust-config']); + expect(key.exitCode).toBe(1); + expect(key.stderr.toString()).toContain('never forwards its own Ubicloud credentials'); + expect(f.calls()).toBe(before); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + + test('verify keeps a configured setup script and pull destination inside --src: absolute, ~ and .. paths are refused before any API call', () => { + const f = fakeVm(); + try { + const dir = src(f, {}); + const outside = join(f.root, 'outside.sh'); writeFileSync(outside, 'echo outside\n'); + const cases: Array<[unknown, RegExp]> = [ + [{ setup: outside, command: 'x' }, /setup '[^']*outside\.sh' must be a relative path inside the checkout/], + [{ setup: '../outside.sh', command: 'x' }, /setup '\.\.\/outside\.sh' must be a relative path inside the checkout/], + [{ setup: 'scripts/../../outside.sh', command: 'x' }, /must be a relative path inside the checkout/], + [{ setup: '~/outside.sh', command: 'x' }, /must be a relative path inside the checkout/], + [{ command: 'x', pull: ['work/logs:/etc/cron.d'] }, /pull destination '\/etc\/cron\.d' must be a relative path inside the checkout/], + [{ command: 'x', pull: ['work/logs:../stolen'] }, /pull destination '\.\.\/stolen' must be a relative path inside the checkout/], + [{ command: 'x', pull: ['no-colon'] }, /pull entry 'no-colon' must be REMOTE_GLOB:LOCAL_DIR/], + ]; + for (const [cfg, want] of cases) { + writeFileSync(join(dir, '.gstack/ubicloud.json'), JSON.stringify(cfg)); + const r = run(f, ['verify', '--src', dir]); + expect(r.exitCode).toBe(1); + expect(r.stderr.toString()).toMatch(want); + } + expect(f.calls()).toBe(''); + writeFileSync(join(dir, '.gstack/ubicloud.json'), JSON.stringify({ setup: 'scripts/boot.sh', command: 'make verify', pull: ['work/repo/logs:.context/ubicloud'] })); + const ok = run(f, ['verify', '--src', dir]); + expect(ok.exitCode).toBe(0); + expect(readFileSync(join(f.root, 'ubirun-setup.sh'), 'utf8')).toBe('echo boot\n'); + expect(f.ssh()).toContain('ls -d -- logs'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + + test('verify rechecks every pull destination at pull time: a symlink an earlier pull planted cannot redirect a later pull outside --src', () => { + const f = fakeVm(); + try { + const dir = src(f, { command: 'make verify', pull: ['work/repo/escape:.', 'work/repo/artifacts:escape/new/sub'] }); + const victim = join(f.root, 'victim'); mkdirSync(victim); + mkdirSync(join(f.root, 'remote-fs/artifacts'), { recursive: true }); + writeFileSync(join(f.root, 'remote-fs/artifacts/authorized_keys'), 'ssh-ed25519 AAAA attacker\n'); + symlinkSync(victim, join(f.root, 'remote-fs/escape')); + const r = run(f, ['verify', '--src', dir]); + expect(r.exitCode).toBe(1); + expect(r.stderr.toString()).toMatch(/pull refused: .*\/repo\/escape\/new\/sub resolves outside .*\/repo/); + expect(lstatSync(join(dir, 'escape')).isSymbolicLink()).toBe(true); + expect(readdirSync(victim)).toEqual([]); + expect(f.calls()).toContain('destroy'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 60_000); + + test('quota: a refusal caches the maximum, the cache drives the preflight for a day and is dropped after; UBI_QUOTA_WAIT waits before refusing', () => { + const refuse = fakeVm({ refuse: 'Validation failed for following fields: Requested vCPU count: 16, currently used vCPU count: 32, maximum allowed vCPU count: 32' }); + try { + const r = run(refuse, ['up']); + expect(r.exitCode).toBe(1); + expect(r.stderr.toString()).toContain('create failed: vCPU quota exhausted'); + expect(readFileSync(join(refuse.state, 'quota-max'), 'utf8').trim()).toBe('32'); + expect(refuse.listed()).toBe(''); + } finally { rmSync(refuse.root, { recursive: true, force: true }); } + + const f = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' }); + try { + writeFileSync(join(f.state, 'quota-max'), '31\n'); + const cached = run(f, ['up']); + expect(cached.exitCode).toBe(1); + expect(cached.stderr.toString()).toMatch(/already use 16 of 31 \(ceiling from the last refusal, cached in .*quota-max;/); + expect(f.calls()).not.toContain('"create"'); + + const started = Date.now(); + const waited = run(f, ['up'], { UBI_QUOTA_WAIT: '3' }); + expect(waited.exitCode).toBe(1); + expect(waited.stderr.toString()).toMatch(/quota: 16 of 31 vCPUs in use and 16 needed; waiting up to 3s more for room/); + expect(waited.stderr.toString()).toContain('create refused before any request: vCPU quota exhausted'); + expect(Date.now() - started).toBeGreaterThanOrEqual(3000); + expect(f.calls()).not.toContain('"create"'); + + utimesSync(join(f.state, 'quota-max'), new Date(Date.now() - 2 * 86_400_000), new Date(Date.now() - 2 * 86_400_000)); + const stale = run(f, ['up']); + expect(stale.exitCode).toBe(0); + expect(stale.stderr.toString()).not.toContain('quota preflight refused'); + expect(f.calls()).toContain('"create"'); + expect(existsSync(join(f.state, 'quota-max'))).toBe(false); + const name = stale.stdout.toString().trim(); + expect(name).toMatch(/^ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3$/); + expect(run(f, ['down', name]).exitCode).toBe(0); + expect(f.listed()).toBe('eu-central-h1 ubirun-peer-9999999999-cccc\n'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + + // Value: protects=the quota wait budget counts seconds slept, not usage reads: a slow usage read keeps the printed budget, a wait above 30 s counts down in 30 s steps, and the wait releases when room appears; fails_when=the budget goes back to a wall-clock deadline taken before the first usage read (the first log line shrinks to 1s), the step cap or the decrement is lost, or room found mid-wait still refuses; why_new=the WAIT=3 case above passes under both the old deadline and the new countdown because its fake usage read is instant and it never sees a second step; seam=none (the fixture's bin/ is already first on PATH, so sleep and curl are stubbed there); no_claim=real elapsed time of the long wait (the 70 s case stubs sleep) + test('quota wait: a slow usage read does not shave the printed budget; a long wait counts down in 30s steps and releases when room appears', () => { + const slow = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' }); + try { + writeFileSync(join(slow.state, 'quota-max'), '31\n'); + const r = run(slow, ['up'], { UBI_QUOTA_WAIT: '2', FAKE_SHOW_DELAY: '1.2' }); + const err = r.stderr.toString(); + expect(r.exitCode, err).toBe(1); + expect(err).toMatch(/quota: 16 of 31 vCPUs in use and 16 needed; waiting up to 2s more for room/); + expect(err).toContain('create refused before any request: vCPU quota exhausted'); + expect(slow.calls()).not.toContain('"create"'); + } finally { rmSync(slow.root, { recursive: true, force: true }); } + + const f = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' }); + try { + writeFileSync(join(f.state, 'quota-max'), '31\n'); + const sleepLog = join(f.root, 'sleep.log'); + writeFileSync(join(f.root, 'bin/sleep'), '#!/usr/bin/env bash\necho "$1" >> ' + JSON.stringify(sleepLog) + '\n[ "$(wc -l < ' + JSON.stringify(sleepLog) + ')" -ne 2 ] || : > ' + JSON.stringify(join(f.root, 'listed')) + '\n'); + chmodSync(join(f.root, 'bin/sleep'), 0o755); + const r = run(f, ['up'], { UBI_QUOTA_WAIT: '70' }); + const err = r.stderr.toString(); + expect(r.exitCode, err).toBe(0); + expect(err).toMatch(/waiting up to 70s more for room/); + expect(err).toMatch(/waiting up to 40s more for room/); + expect(err).not.toContain('quota preflight refused'); + expect(readFileSync(sleepLog, 'utf8').split('\n').slice(0, 2)).toEqual(['30', '30']); + expect(f.calls()).toContain('"create"'); + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 90_000); + + test('reap reports a failed list and a failed destroy as failures; UBI_TTL_HOURS rejects leading zeros and more than three digits', () => { + const f = fakeVm({ listed: 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\n' }); + try { + writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\nbody=$(cat)\ncase "$body" in\n *\'"list"\'*) echo "eu-central-h1 ubirun-other-1000000000-bbbb-t1" > "$out"; printf 200 ;;\n *\'"show"\'*) printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200 ;;\n *) echo "boom" > "$out"; printf 500 ;;\nesac\n'); + const destroyFails = run(f, ['reap']); + expect(destroyFails.exitCode).toBe(1); + expect(destroyFails.stderr.toString()).toContain('failed to reap eu-central-h1/ubirun-other-1000000000-bbbb-t1'); + expect(destroyFails.stderr.toString()).toContain('reap: 1 of 1 expired VMs could not be destroyed'); + writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\ncat >/dev/null; echo "down" > "$out"; printf 503\n'); + const listFails = run(f, ['reap']); + expect(listFails.exitCode).toBe(1); + expect(listFails.stderr.toString()).toContain("reap: could not list the project's VMs; nothing destroyed"); + for (const ttl of ['0', '08', '1000', 'abc']) { + const r = run(f, ['up'], { UBI_TTL_HOURS: ttl }); + expect(r.exitCode).toBe(1); + expect(r.stderr.toString()).toContain('UBI_TTL_HOURS must be a whole number of hours from 1 to 999'); + } + // Value: protects=an old UBI_TTL_HOURS=0 or bad UBI_LEGACY_HOURS never blocks commands that create nothing (down, list, owner); fails_when=the TTL checks move back to startup; why_new=the checks were global and blocked cleanup; seam=none; no_claim=real down against the API (owner stands in for every read-only command) + expect(run(f, ['owner'], { UBI_TTL_HOURS: '0', UBI_LEGACY_HOURS: '0' }).exitCode).toBe(0); + for (const legacy of ['0', '04', 'x']) { + const r = run(f, ['reap'], { UBI_LEGACY_HOURS: legacy }); + expect(r.exitCode).toBe(1); + expect(r.stderr.toString()).toContain('UBI_LEGACY_HOURS must be a whole number of hours from 1 to 9999'); + } + } finally { rmSync(f.root, { recursive: true, force: true }); } + }, 30_000); +}); diff --git a/test/ubicloud-runner.test.ts b/test/ubicloud-runner.test.ts index fd6580884..ae075c25c 100644 --- a/test/ubicloud-runner.test.ts +++ b/test/ubicloud-runner.test.ts @@ -6,7 +6,7 @@ * call when the token is absent. */ import { describe, expect, test } from 'bun:test'; -import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -384,733 +384,3 @@ test('pack ships a linked worktree with a self-contained .git on the checked-out expect(runner).toContain('$HOME/.capy/work/ubi-runner/vms.tsv'); }); }); - -describe('ubi-runner run, verify and quota against a fake API and a fake ssh', () => { - /** - * Fake Ubicloud API that can create: create adds the name to the listed file - * (or answers the quota refusal in refuse with HTTP 400), show is running at - * 192.0.2.1 while listed, destroy unlists. The fake VM is the directory vm/: - * the fake ssh records every remote command in ssh.log, keeps copies of - * ~/.ubirun-env and ~/.ubirun-setup.sh under the fixture root, serves pulls - * from remote-fs/, and runs every other command there for real (HOME=vm/, - * vmbin/ first on PATH), so uploads unpack, detached phases write their pid, - * output and exit files, tail --pid follows them and pkill ends them. The stub - * make in vmbin/ echoes `ran: make ARGS`, or runs command-mode or - * make-.sh from the fixture root. No network, no VM. - */ - function fakeVm(opts: { listed?: string; refuse?: string } = {}) { - const root = mkdtempSync(join(tmpdir(), 'ubi-run-')); - const bin = join(root, 'bin'), state = join(root, 'state'), log = join(root, 'calls.log'), sshLog = join(root, 'ssh.log'), record = join(root, 'vms.tsv'); - const vm = join(root, 'vm'), vmbin = join(root, 'vmbin'), srcDir = join(root, 'src'), checkpoints = join(root, 'checkpoints'); - mkdirSync(vm); mkdirSync(vmbin); mkdirSync(srcDir); - writeFileSync(join(srcDir, 'README'), 'fixture checkout\n'); - mkdirSync(bin); mkdirSync(state, { recursive: true }); - writeFileSync(join(root, 'listed'), opts.listed ?? ''); - writeFileSync(join(bin, 'curl'), '#!/usr/bin/env bash\n' + - 'out=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\n' + - 'body=$(cat); echo "$body" >> ' + JSON.stringify(log) + '\n' + - 'listed=' + JSON.stringify(join(root, 'listed')) + '\n' + - 'loc=$(printf \'%s\' "$body" | sed -n \'s/.*"vm", *"\\([^/"]*\\)\\/[^"]*".*/\\1/p\')\n' + - 'name=$(printf \'%s\' "$body" | sed -n \'s/.*"vm", *"[^/"]*\\/\\([^"]*\\)".*/\\1/p\')\n' + - 'case "$body" in\n' + - ' *\'"create"\'*) if [ -n ' + JSON.stringify(opts.refuse ?? '') + ' ]; then printf \'%s\' ' + JSON.stringify(opts.refuse ?? '') + ' > "$out"; printf 400; else echo "$loc $name" >> "$listed"; : > "$out"; printf 200; fi ;;\n' + - ' *destroy*) : > "$out"; if [ -f "$listed.destroy-fails" ]; then printf 500; else grep -v " $name$" "$listed" > "$listed.tmp"; mv "$listed.tmp" "$listed"; printf 200; fi ;;\n' + - ' *\'"list"\'*) if [ -f "$listed.list-fails" ]; then echo down > "$out"; printf 503; else cat "$listed" > "$out"; printf 200; fi ;;\n' + - ' *\'"show"\'*) [ -z "${FAKE_SHOW_DELAY:-}" ] || sleep "$FAKE_SHOW_DELAY"; if grep -q " $name$" "$listed"; then printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200; else : > "$out"; printf 404; fi ;;\n' + - ' *) : > "$out"; printf 200 ;;\n' + - 'esac\n'); - writeFileSync(join(bin, 'ssh'), '#!/usr/bin/env bash\n' + - 'root=' + JSON.stringify(root) + '; vm="$root/vm"\n' + - 'cmd=${*: -1}\n' + - 'printf \'%s\\n\' "$cmd" >> "$root/ssh.log"\n' + - 'export HOME="$vm" PATH="$root/vmbin:$PATH"\n' + - 'cd "$vm" || exit 255\n' + - 'case "$cmd" in\n' + - ' *pkill*) echo "$cmd" >> "$root/cancel.log"; exec bash -c "$cmd" ;;\n' + - ' *captured*) cat > "$root/captured" ;;\n' + - ' *"cat > ~/.ubirun-env"*) tee "$root/ubirun-env" > "$vm/.ubirun-env" ;;\n' + - ' *"cat > ~/.ubirun-setup.sh"*) tee "$root/ubirun-setup.sh" > "$vm/.ubirun-setup.sh" ;;\n' + - ' *"tar -czf -"*) base=${cmd##* }; tar -czf - -C "$root/remote-fs" "$base" ;;\n' + - ' *"ls -d -- "*) : ;;\n' + - ' *) exec bash -c "$cmd" ;;\n' + - 'esac\n'); - writeFileSync(join(vmbin, 'make'), '#!/usr/bin/env bash\n' + - 'root=' + JSON.stringify(root) + '\n' + - 'if [ -f "$root/command-mode" ]; then exec bash -c "$(cat "$root/command-mode")"; fi\n' + - 'echo "ran: make $*"\n' + - '[ ! -f "$root/make-$1.sh" ] || exec bash "$root/make-$1.sh"\n'); - chmodSync(join(bin, 'curl'), 0o755); chmodSync(join(bin, 'ssh'), 0o755); chmodSync(join(vmbin, 'make'), 0o755); - const env: Record = { ...(process.env as Record), PATH: `${bin}:${process.env.PATH}`, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: state, UBI_POLL_SECONDS: '1', UBI_VM_RECORD: record, UBI_OWNER: 'thrd1', UBI_HEARTBEAT: '0', UBI_CHECKPOINT_DIR: checkpoints }; - const run = (args: string[], extra: Record = {}, stdin?: string) => - Bun.spawnSync(['bash', join(DIR, 'ubi-runner.sh'), ...args], { env: { ...env, ...extra }, stdin: stdin === undefined ? undefined : Buffer.from(stdin), timeout: 90_000 }); - const spawn = (args: string[], extra: Record = {}) => - Bun.spawn(['bash', join(DIR, 'ubi-runner.sh'), ...args], { env: { ...env, ...extra }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', timeout: 120_000 }); - return { - root, state, record, run, spawn, vm, src: srcDir, checkpoints, - calls: () => (existsSync(log) ? readFileSync(log, 'utf8') : ''), - ssh: () => (existsSync(sshLog) ? readFileSync(sshLog, 'utf8') : ''), - listed: () => readFileSync(join(root, 'listed'), 'utf8'), - }; - } - - const src = (f: { root: string }, cfg: unknown) => { - const dir = join(f.root, 'repo'); - mkdirSync(join(dir, '.gstack'), { recursive: true }); - mkdirSync(join(dir, 'scripts'), { recursive: true }); - writeFileSync(join(dir, 'scripts/boot.sh'), 'echo boot\n'); - writeFileSync(join(dir, '.gstack/ubicloud.json'), JSON.stringify(cfg)); - return dir; - }; - - const run = (f: ReturnType, args: string[], extra: Record = {}, stdin?: string) => f.run(args, extra, stdin); - - test('run: a hung setup is killed by the watchdog, exits 124, never reaches the command, and the VM is destroyed', () => { - const f = fakeVm(); - try { - const setup = join(f.root, 'setup.sh'); writeFileSync(setup, 'echo "setup start"; sleep 20\n'); - const r = run(f, ['run', '--src', f.src, '--setup', setup, '--', 'make test'], { UBI_IDLE_TIMEOUT: '2' }); - const err = r.stderr.toString(); - expect(r.exitCode, err).toBe(124); - expect(err).toMatch(/watchdog: setup printed nothing for \d+s \(UBI_IDLE_TIMEOUT=2\)/); - expect(err).toContain('setup failed (exit 124)'); - expect(f.ssh()).not.toContain('bash -lc'); - expect(err).toContain('setup start'); - // The setup runs detached in its own session: its leader's pid, both streams and the exit status are files on the VM. - expect(f.ssh()).toMatch(/setsid -f bash -c 'echo \$\$ >~\/\.ubirun-setup\.pid\.tmp && mv ~\/\.ubirun-setup\.pid\.tmp ~\/\.ubirun-setup\.pid; \( cd work\/[^ ]+ && \. ~\/\.ubirun-env && bash -l ~\/\.ubirun-setup\.sh \) >~\/\.ubirun-setup\.out 2>~\/\.ubirun-setup\.err <\/dev\/null; echo \$\? >~\/\.ubirun-setup\.exit\.tmp && mv ~\/\.ubirun-setup\.exit\.tmp ~\/\.ubirun-setup\.exit' <\/dev\/null/m); - expect(readFileSync(join(f.root, 'cancel.log'), 'utf8')).toMatch(/sid=\$\(cat ~\/\.ubirun-setup\.pid 2>\/dev\/null\); rm -f ~\/\.ubirun-setup\.pid; \[ -z "\$sid" \] \|\| \{ pkill -TERM -s "\$sid"; sleep 2; pkill -KILL -s "\$sid"; \}/); - expect(existsSync(join(f.vm, '.ubirun-setup.exit'))).toBe(false); - expect(f.calls()).toContain('destroy'); - expect(err).toMatch(/destroyed eu-central-h1\/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3/); - expect(f.listed()).toBe(''); - expect(readFileSync(f.record, 'utf8')).toBe(''); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 60_000); - - test('run: UBI_RUN_TIMEOUT kills a command that keeps printing, with exit 124 and its reason', () => { - const f = fakeVm(); - try { - writeFileSync(join(f.root, 'command-mode'), 'for i in 1 2 3 4 5 6 7 8 9 10; do echo "tick $i"; sleep 1; done'); - const r = run(f, ['run', '--src', f.src, '--', 'make test'], { UBI_RUN_TIMEOUT: '3' }); - expect(r.exitCode).toBe(124); - expect(r.stderr.toString()).toMatch(/watchdog: command ran \d+s \(UBI_RUN_TIMEOUT=3\); killing it/); - expect(r.stderr.toString()).toContain('command exited 124 after'); - expect(f.calls()).toContain('destroy'); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 60_000); - - test('run: ends with UBICLOUD: left=0 for its own VM; a same-owner sibling is counted as information and does not fail the run', () => { - const f = fakeVm({ listed: 'eu-central-h1 ubirun-thrd1-9999999999-sib1-t3\n' }); - try { - const leftFile = join(f.root, 'left'); - const r = f.run(['run', '--src', f.src, '--', 'true'], { UBI_REAP: '0', UBI_LEFT_FILE: leftFile }); - const err = r.stderr.toString(); - expect(r.exitCode, err).toBe(0); - expect(err).toMatch(/UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n/); - expect(f.listed()).toBe('eu-central-h1 ubirun-thrd1-9999999999-sib1-t3\n'); - // Value: protects=UBI_LEFT_FILE carries the same leftover line pregate-remote.sh parses, apart from the command's output; fails_when=the file is not written or its format drifts from pregate-remote.sh's parser; why_new=the pregate stub hand-copies the format; seam=none; no_claim=real VM teardown (fake API and ssh) - expect(readFileSync(leftFile, 'utf8')).toMatch(/^UBICLOUD: left=0 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n$/); - const parser = read('scripts/ubicloud/pregate-remote.sh').match(/left=\$\(sed -n '([^']+)' "\$log\.left"/)![1]; - expect(Bun.spawnSync(['sed', '-n', parser, leftFile], { timeout: 30_000 }).stdout.toString()).toBe('0\n'); - // Value: protects=run --keep reports its live VM as UBICLOUD: left=1 ... kept=1 and never a clean left=0; fails_when=the kept line is dropped or --keep still prints the teardown line; why_new=no test runs run --keep; seam=none; no_claim=real VM lifetime (fake API and ssh) - const kept = f.run(['run', '--src', f.src, '--keep', '--', 'true'], { UBI_REAP: '0' }); - const keptErr = kept.stderr.toString(); - expect(kept.exitCode, keptErr).toBe(0); - expect(keptErr).toMatch(/UBICLOUD: left=1 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 kept=1\n/); - expect(keptErr).not.toContain('left=0'); - expect(f.listed()).toMatch(/ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3\n/); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 30_000); - - test('run: a passing command whose teardown fails exits 1 with UBICLOUD: left=1 and the down command; an unreadable VM list fails closed', () => { - const f = fakeVm(); - try { - writeFileSync(join(f.root, 'listed.destroy-fails'), ''); - const leaked = f.run(['run', '--src', f.src, '--', 'true'], { UBI_REAP: '0', UBI_DOWN_TIMEOUT: '3' }); - const err = leaked.stderr.toString(); - expect(leaked.exitCode, err).toBe(1); - expect(err).toMatch(/UBICLOUD: left=1 owner=thrd1 vm=(ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3) others=0\n[^]*VM cleanup not confirmed for \1 \(left=1\); destroy it with: \S+ down \1/); - expect(f.listed()).toContain('ubirun-thrd1-'); - // Value: protects=a failing command whose VM also leaks keeps its own exit status, not 1; fails_when=run_teardown exits 1 whenever left is not 0; why_new=the leak case above only runs a passing command; seam=none; no_claim=real destroy failure modes (fake API returns 500) - writeFileSync(join(f.root, 'command-mode'), 'exit 7'); - const failedLeak = f.run(['run', '--src', f.src, '--', 'make test'], { UBI_REAP: '0', UBI_DOWN_TIMEOUT: '3' }); - expect(failedLeak.exitCode, failedLeak.stderr.toString()).toBe(7); - expect(failedLeak.stderr.toString()).toMatch(/UBICLOUD: left=1 owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=1\n/); - const blind = fakeVm(); - try { - writeFileSync(join(blind.root, 'bin/curl.real'), readFileSync(join(blind.root, 'bin/curl'))); - chmodSync(join(blind.root, 'bin/curl.real'), 0o755); - writeFileSync(join(blind.root, 'bin/curl'), '#!/usr/bin/env bash\nif [ -f ' + JSON.stringify(join(blind.root, 'armed')) + ' ]; then touch ' + JSON.stringify(join(blind.root, 'listed.list-fails')) + '; fi\nexec ' + JSON.stringify(join(blind.root, 'bin/curl.real')) + ' "$@"\n'); - chmodSync(join(blind.root, 'bin/curl'), 0o755); - writeFileSync(join(blind.root, 'command-mode'), 'touch ' + JSON.stringify(join(blind.root, 'armed'))); - const r = blind.run(['run', '--src', blind.src, '--', 'make test'], { UBI_REAP: '0' }); - expect(r.exitCode, r.stderr.toString()).toBe(1); - expect(r.stderr.toString()).toMatch(/UBICLOUD: left=unknown owner=thrd1 vm=ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3 others=unknown\n/); - } finally { rmSync(blind.root, { recursive: true, force: true }); } - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 60_000); - - test('run: the fake ssh dispatches on the remote command, so a checkout whose name holds -O still syncs and runs (GSTA-139)', () => { - const f = fakeVm(); - try { - const checkout = join(f.root, 'ubi-run-O1x2y3'); - mkdirSync(checkout); - const r = run(f, ['run', '--src', checkout, '--', 'make test']); - expect(r.exitCode, r.stderr.toString()).toBe(0); - expect(r.stdout.toString()).toContain('ran: '); - expect(f.ssh()).toContain('tar -xzf - -C work/ubi-run-O1x2y3'); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 60_000); - - test('run: --pass forwards a named local variable only; UBICLOUD_* and non-names are refused before any API call', () => { - const f = fakeVm(); - try { - const ok = run(f, ['run', '--src', f.src, '--pass', 'UBI_TEST_TOKEN', '--env', 'CI=1', '--', 'make test'], { UBI_TEST_TOKEN: 'sekret value' }); - expect(ok.exitCode).toBe(0); - expect(ok.stderr.toString()).toContain('command exited 0 after'); - const envFile = readFileSync(join(f.root, 'ubirun-env'), 'utf8'); - expect(envFile).toContain('export UBI_TEST_TOKEN=sekret\\ value'); - expect(envFile).toContain('export CI=1'); - expect(envFile).not.toContain('UBICLOUD'); - expect(ok.stdout.toString() + ok.stderr.toString()).not.toContain('sekret'); - for (const bad of ['UBICLOUD_API_KEY', 'UBICLOUD_API_TOKEN', 'X;Y', 'HOME/..']) { - const before = f.calls(); - const r = run(f, ['run', '--src', f.src, '--pass', bad, '--', 'true']); - expect(r.exitCode).toBe(1); - expect(r.stderr.toString()).toMatch(bad.startsWith('UBICLOUD_') ? /never forwards its own Ubicloud credentials/ : /not a variable name/); - expect(f.calls()).toBe(before); - } - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 60_000); - - test('ssh NAME COMMAND hands the caller’s stdin to the remote command', () => { - const f = fakeVm({ listed: 'eu-central-h1 vm-a\n' }); - try { - mkdirSync(join(f.state, 'vm-a'), { recursive: true }); - writeFileSync(join(f.state, 'vm-a/env'), 'NAME=vm-a\nLOCATION=eu-central-h1\nSIZE=standard-16\nIP=192.0.2.1\nCREATED=yes\nCREATE_AT=0\nCREATE_PID=1\n'); - const r = run(f, ['ssh', 'vm-a', 'cat > captured'], { UBI_IDLE_TIMEOUT: '0' }, 'payload line\n'); - expect(r.exitCode).toBe(0); - expect(readFileSync(join(f.root, 'captured'), 'utf8')).toBe('payload line\n'); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 30_000); - - test('verify forwards a configured pass name only when the command line grants it, and never UBICLOUD_*', () => { - const f = fakeVm(); - try { - const dir = src(f, { setup: 'scripts/boot.sh', command: 'make verify', pass: ['UBI_TEST_TOKEN'] }); - const refused = run(f, ['verify', '--src', dir], { UBI_TEST_TOKEN: 'x' }); - expect(refused.exitCode).toBe(1); - expect(refused.stderr.toString()).toContain('asks to forward local UBI_TEST_TOKEN; grant each on this command line with --pass NAME, or every name it lists with --trust-config'); - expect(f.calls()).toBe(''); - const granted = run(f, ['verify', '--src', dir, '--pass', 'UBI_TEST_TOKEN'], { UBI_TEST_TOKEN: 'granted value' }); - expect(granted.exitCode).toBe(0); - expect(readFileSync(join(f.root, 'ubirun-env'), 'utf8')).toContain('export UBI_TEST_TOKEN=granted\\ value'); - expect(f.ssh()).toContain('bash -lc make\\ verify'); - rmSync(join(f.root, 'ubirun-env')); - const trusted = run(f, ['verify', '--src', dir, '--trust-config'], { UBI_TEST_TOKEN: 'trusted value' }); - expect(trusted.exitCode).toBe(0); - expect(readFileSync(join(f.root, 'ubirun-env'), 'utf8')).toContain('export UBI_TEST_TOKEN=trusted\\ value'); - rmSync(join(f.root, 'ubirun-env')); - const asValue = run(f, ['verify', '--src', dir, '--dest', '--trust-config', '--keep', '--dest', 'work/safe'], { UBI_TEST_TOKEN: 'x' }); - expect(asValue.exitCode).toBe(1); - expect(asValue.stderr.toString()).toContain('asks to forward local UBI_TEST_TOKEN'); - expect(existsSync(join(f.root, 'ubirun-env'))).toBe(false); - expect(run(f, ['verify', '--src', dir, '--pass']).stderr.toString()).toContain('verify: --pass needs a value'); - expect(run(f, ['verify', '--src', dir, '--bogus']).stderr.toString()).toContain('verify: unknown option --bogus'); - writeFileSync(join(dir, '.gstack/ubicloud.json'), JSON.stringify({ command: 'make verify', pass: ['UBICLOUD_API_KEY'] })); - const before = f.calls(); - const key = run(f, ['verify', '--src', dir, '--trust-config']); - expect(key.exitCode).toBe(1); - expect(key.stderr.toString()).toContain('never forwards its own Ubicloud credentials'); - expect(f.calls()).toBe(before); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 90_000); - - test('verify keeps a configured setup script and pull destination inside --src: absolute, ~ and .. paths are refused before any API call', () => { - const f = fakeVm(); - try { - const dir = src(f, {}); - const outside = join(f.root, 'outside.sh'); writeFileSync(outside, 'echo outside\n'); - const cases: Array<[unknown, RegExp]> = [ - [{ setup: outside, command: 'x' }, /setup '[^']*outside\.sh' must be a relative path inside the checkout/], - [{ setup: '../outside.sh', command: 'x' }, /setup '\.\.\/outside\.sh' must be a relative path inside the checkout/], - [{ setup: 'scripts/../../outside.sh', command: 'x' }, /must be a relative path inside the checkout/], - [{ setup: '~/outside.sh', command: 'x' }, /must be a relative path inside the checkout/], - [{ command: 'x', pull: ['work/logs:/etc/cron.d'] }, /pull destination '\/etc\/cron\.d' must be a relative path inside the checkout/], - [{ command: 'x', pull: ['work/logs:../stolen'] }, /pull destination '\.\.\/stolen' must be a relative path inside the checkout/], - [{ command: 'x', pull: ['no-colon'] }, /pull entry 'no-colon' must be REMOTE_GLOB:LOCAL_DIR/], - ]; - for (const [cfg, want] of cases) { - writeFileSync(join(dir, '.gstack/ubicloud.json'), JSON.stringify(cfg)); - const r = run(f, ['verify', '--src', dir]); - expect(r.exitCode).toBe(1); - expect(r.stderr.toString()).toMatch(want); - } - expect(f.calls()).toBe(''); - writeFileSync(join(dir, '.gstack/ubicloud.json'), JSON.stringify({ setup: 'scripts/boot.sh', command: 'make verify', pull: ['work/repo/logs:.context/ubicloud'] })); - const ok = run(f, ['verify', '--src', dir]); - expect(ok.exitCode).toBe(0); - expect(readFileSync(join(f.root, 'ubirun-setup.sh'), 'utf8')).toBe('echo boot\n'); - expect(f.ssh()).toContain('ls -d -- logs'); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 90_000); - - test('verify rechecks every pull destination at pull time: a symlink an earlier pull planted cannot redirect a later pull outside --src', () => { - const f = fakeVm(); - try { - const dir = src(f, { command: 'make verify', pull: ['work/repo/escape:.', 'work/repo/artifacts:escape/new/sub'] }); - const victim = join(f.root, 'victim'); mkdirSync(victim); - mkdirSync(join(f.root, 'remote-fs/artifacts'), { recursive: true }); - writeFileSync(join(f.root, 'remote-fs/artifacts/authorized_keys'), 'ssh-ed25519 AAAA attacker\n'); - symlinkSync(victim, join(f.root, 'remote-fs/escape')); - const r = run(f, ['verify', '--src', dir]); - expect(r.exitCode).toBe(1); - expect(r.stderr.toString()).toMatch(/pull refused: .*\/repo\/escape\/new\/sub resolves outside .*\/repo/); - expect(lstatSync(join(dir, 'escape')).isSymbolicLink()).toBe(true); - expect(readdirSync(victim)).toEqual([]); - expect(f.calls()).toContain('destroy'); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 60_000); - - test('quota: a refusal caches the maximum, the cache drives the preflight for a day and is dropped after; UBI_QUOTA_WAIT waits before refusing', () => { - const refuse = fakeVm({ refuse: 'Validation failed for following fields: Requested vCPU count: 16, currently used vCPU count: 32, maximum allowed vCPU count: 32' }); - try { - const r = run(refuse, ['up']); - expect(r.exitCode).toBe(1); - expect(r.stderr.toString()).toContain('create failed: vCPU quota exhausted'); - expect(readFileSync(join(refuse.state, 'quota-max'), 'utf8').trim()).toBe('32'); - expect(refuse.listed()).toBe(''); - } finally { rmSync(refuse.root, { recursive: true, force: true }); } - - const f = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' }); - try { - writeFileSync(join(f.state, 'quota-max'), '31\n'); - const cached = run(f, ['up']); - expect(cached.exitCode).toBe(1); - expect(cached.stderr.toString()).toMatch(/already use 16 of 31 \(ceiling from the last refusal, cached in .*quota-max;/); - expect(f.calls()).not.toContain('"create"'); - - const started = Date.now(); - const waited = run(f, ['up'], { UBI_QUOTA_WAIT: '3' }); - expect(waited.exitCode).toBe(1); - expect(waited.stderr.toString()).toMatch(/quota: 16 of 31 vCPUs in use and 16 needed; waiting up to 3s more for room/); - expect(waited.stderr.toString()).toContain('create refused before any request: vCPU quota exhausted'); - expect(Date.now() - started).toBeGreaterThanOrEqual(3000); - expect(f.calls()).not.toContain('"create"'); - - utimesSync(join(f.state, 'quota-max'), new Date(Date.now() - 2 * 86_400_000), new Date(Date.now() - 2 * 86_400_000)); - const stale = run(f, ['up']); - expect(stale.exitCode).toBe(0); - expect(stale.stderr.toString()).not.toContain('quota preflight refused'); - expect(f.calls()).toContain('"create"'); - expect(existsSync(join(f.state, 'quota-max'))).toBe(false); - const name = stale.stdout.toString().trim(); - expect(name).toMatch(/^ubirun-thrd1-\d{10}-[0-9a-f]{8}-t3$/); - expect(run(f, ['down', name]).exitCode).toBe(0); - expect(f.listed()).toBe('eu-central-h1 ubirun-peer-9999999999-cccc\n'); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 90_000); - - // Value: protects=the quota wait budget counts seconds slept, not usage reads: a slow usage read keeps the printed budget, a wait above 30 s counts down in 30 s steps, and the wait releases when room appears; fails_when=the budget goes back to a wall-clock deadline taken before the first usage read (the first log line shrinks to 1s), the step cap or the decrement is lost, or room found mid-wait still refuses; why_new=the WAIT=3 case above passes under both the old deadline and the new countdown because its fake usage read is instant and it never sees a second step; seam=none (the fixture's bin/ is already first on PATH, so sleep and curl are stubbed there); no_claim=real elapsed time of the long wait (the 70 s case stubs sleep) - test('quota wait: a slow usage read does not shave the printed budget; a long wait counts down in 30s steps and releases when room appears', () => { - const slow = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' }); - try { - writeFileSync(join(slow.state, 'quota-max'), '31\n'); - const r = run(slow, ['up'], { UBI_QUOTA_WAIT: '2', FAKE_SHOW_DELAY: '1.2' }); - const err = r.stderr.toString(); - expect(r.exitCode, err).toBe(1); - expect(err).toMatch(/quota: 16 of 31 vCPUs in use and 16 needed; waiting up to 2s more for room/); - expect(err).toContain('create refused before any request: vCPU quota exhausted'); - expect(slow.calls()).not.toContain('"create"'); - } finally { rmSync(slow.root, { recursive: true, force: true }); } - - const f = fakeVm({ listed: 'eu-central-h1 ubirun-peer-9999999999-cccc\n' }); - try { - writeFileSync(join(f.state, 'quota-max'), '31\n'); - const sleepLog = join(f.root, 'sleep.log'); - writeFileSync(join(f.root, 'bin/sleep'), '#!/usr/bin/env bash\necho "$1" >> ' + JSON.stringify(sleepLog) + '\n[ "$(wc -l < ' + JSON.stringify(sleepLog) + ')" -ne 2 ] || : > ' + JSON.stringify(join(f.root, 'listed')) + '\n'); - chmodSync(join(f.root, 'bin/sleep'), 0o755); - const r = run(f, ['up'], { UBI_QUOTA_WAIT: '70' }); - const err = r.stderr.toString(); - expect(r.exitCode, err).toBe(0); - expect(err).toMatch(/waiting up to 70s more for room/); - expect(err).toMatch(/waiting up to 40s more for room/); - expect(err).not.toContain('quota preflight refused'); - expect(readFileSync(sleepLog, 'utf8').split('\n').slice(0, 2)).toEqual(['30', '30']); - expect(f.calls()).toContain('"create"'); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 90_000); - - test('reap reports a failed list and a failed destroy as failures; UBI_TTL_HOURS rejects leading zeros and more than three digits', () => { - const f = fakeVm({ listed: 'eu-central-h1 ubirun-other-1000000000-bbbb-t1\n' }); - try { - writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\nbody=$(cat)\ncase "$body" in\n *\'"list"\'*) echo "eu-central-h1 ubirun-other-1000000000-bbbb-t1" > "$out"; printf 200 ;;\n *\'"show"\'*) printf \'state: running\\nsize: standard-16\\nip4: 192.0.2.1\\n\' > "$out"; printf 200 ;;\n *) echo "boom" > "$out"; printf 500 ;;\nesac\n'); - const destroyFails = run(f, ['reap']); - expect(destroyFails.exitCode).toBe(1); - expect(destroyFails.stderr.toString()).toContain('failed to reap eu-central-h1/ubirun-other-1000000000-bbbb-t1'); - expect(destroyFails.stderr.toString()).toContain('reap: 1 of 1 expired VMs could not be destroyed'); - writeFileSync(join(f.root, 'bin/curl'), '#!/usr/bin/env bash\nout=""; while [ $# -gt 0 ]; do [ "$1" = -o ] && out=$2; shift; done\ncat >/dev/null; echo "down" > "$out"; printf 503\n'); - const listFails = run(f, ['reap']); - expect(listFails.exitCode).toBe(1); - expect(listFails.stderr.toString()).toContain("reap: could not list the project's VMs; nothing destroyed"); - for (const ttl of ['0', '08', '1000', 'abc']) { - const r = run(f, ['up'], { UBI_TTL_HOURS: ttl }); - expect(r.exitCode).toBe(1); - expect(r.stderr.toString()).toContain('UBI_TTL_HOURS must be a whole number of hours from 1 to 999'); - } - // Value: protects=an old UBI_TTL_HOURS=0 or bad UBI_LEGACY_HOURS never blocks commands that create nothing (down, list, owner); fails_when=the TTL checks move back to startup; why_new=the checks were global and blocked cleanup; seam=none; no_claim=real down against the API (owner stands in for every read-only command) - expect(run(f, ['owner'], { UBI_TTL_HOURS: '0', UBI_LEGACY_HOURS: '0' }).exitCode).toBe(0); - for (const legacy of ['0', '04', 'x']) { - const r = run(f, ['reap'], { UBI_LEGACY_HOURS: legacy }); - expect(r.exitCode).toBe(1); - expect(r.stderr.toString()).toContain('UBI_LEGACY_HOURS must be a whole number of hours from 1 to 9999'); - } - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 30_000); - - // GSTA-158 P3: detached phases, reattach and tier checkpoints. - const until = async (cond: () => boolean, ms: number) => { - const deadline = Date.now() + ms; - while (!cond()) { if (Date.now() > deadline) throw new Error('condition not met in time'); await Bun.sleep(100); } - }; - const vmDirs = (f: ReturnType) => (existsSync(f.state) ? readdirSync(f.state).filter(d => d.startsWith('ubirun-')) : []); - const phaseStarted = (f: ReturnType, phase: string) => vmDirs(f).some(d => existsSync(join(f.state, d, 'phase.' + phase + '.start'))); - const creates = (f: ReturnType) => f.calls().split('\n').filter(l => l.includes('"create"')).length; - const killSession = async (f: ReturnType, phase: string) => { - const pidFile = join(f.vm, '.ubirun-' + phase + '.pid'); - await until(() => existsSync(pidFile) && readFileSync(pidFile, 'utf8').trim() !== '', 10_000); - const pid = Number(readFileSync(pidFile, 'utf8').trim()); - try { process.kill(-pid, 'SIGKILL'); } catch { /* already gone */ } - await until(() => { try { process.kill(pid, 0); return false; } catch { return true; } }, 10_000); - }; - const gitSrc = (f: ReturnType, cfg?: unknown) => { - const dir = cfg === undefined ? join(f.root, 'repo') : src(f, cfg); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, 'README'), 'fixture repo\n'); - for (const args of [['init', '-q', '-b', 'main'], ['add', '-A'], ['commit', '-qm', 'fixture']]) { - const r = Bun.spawnSync(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { cwd: dir, timeout: 30_000 }); - if (r.exitCode !== 0) throw new Error('git ' + args.join(' ') + ': ' + r.stderr.toString()); - } - return dir; - }; - const TIERS = { setup: 'scripts/boot.sh', tiers: [{ name: 't1', command: 'make t1' }, { name: 't2', command: 'make t2' }, { name: 't3', command: 'make t3' }] }; - const bootCounts = (f: ReturnType, dir: string) => writeFileSync(join(dir, 'scripts/boot.sh'), 'echo boot >> ~/setup-count\n'); - const verifyLine = (out: string) => out.split('\n').find(l => l.startsWith('UBI_VERIFY: ')) ?? ''; - - test('run: a SIGKILLed runner leaves its VM; the same command reattaches, skips the finished setup and reports the command\'s exit', async () => { - const f = fakeVm(); - try { - const repo = gitSrc(f); - const setup = join(f.root, 'setup.sh'); writeFileSync(setup, 'echo setup >> ~/setup-count\n'); - writeFileSync(join(f.root, 'make-test.sh'), 'echo begin; sleep 4; echo end; exit 3\n'); - const args = ['run', '--src', repo, '--setup', setup, '--', 'make test']; - const a = f.spawn(args); - await until(() => vmDirs(f).some(d => existsSync(join(f.state, d, 'command.out')) && readFileSync(join(f.state, d, 'command.out'), 'utf8').includes('begin')), 30_000); - process.kill(a.pid, 'SIGKILL'); - await a.exited; - const name = vmDirs(f)[0]!; - expect(f.listed()).toContain(name); - const b = f.run(args); - const err = b.stderr.toString(); - expect(b.exitCode, err).toBe(3); - expect(err).toMatch(new RegExp('ubi-runner: reattached to ' + name + ' created_at=\\d{10} key=[0-9a-f]{12}\n')); - expect(b.stdout.toString().split('\n').filter(l => l === 'begin' || l === 'end')).toEqual(['begin', 'end']); - expect(err).toMatch(/command exited 3 after \d+s/); - expect(creates(f)).toBe(1); - expect(readFileSync(join(f.vm, 'setup-count'), 'utf8')).toBe('setup\n'); - expect(f.listed()).toBe(''); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 90_000); - - test('run: a second live runner of the same command refuses with UBI_ATTACH_BUSY and creates nothing', async () => { - const f = fakeVm(); - try { - const repo = gitSrc(f); - writeFileSync(join(f.root, 'make-test.sh'), 'sleep 4; echo done\n'); - const args = ['run', '--src', repo, '--', 'make test']; - const a = f.spawn(args); - await until(() => phaseStarted(f, 'command'), 30_000); - const b = f.run(args); - expect(b.exitCode).toBe(1); - expect(b.stderr.toString()).toMatch(new RegExp('UBI_ATTACH_BUSY: vm=' + vmDirs(f)[0] + ' key=[0-9a-f]{12}: runner pid ' + a.pid + ' ')); - expect(await a.exited).toBe(0); - expect(await new Response(a.stdout).text()).toContain('done'); - expect(creates(f)).toBe(1); - expect(f.listed()).toBe(''); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 90_000); - - test('run: HUP once the command is live detaches (UBI_DETACHED, exit 129, VM kept, no pass value on disk); a rerun reattaches and finishes', async () => { - const f = fakeVm(); - try { - const repo = gitSrc(f); - writeFileSync(join(f.root, 'make-test.sh'), 'sleep 3; echo finished\n'); - const args = ['run', '--src', repo, '--pass', 'UBI_TEST_TOKEN', '--', 'make test']; - const a = f.spawn(args, { UBI_TEST_TOKEN: 'hup-secret-value' }); - await until(() => phaseStarted(f, 'command'), 30_000); - await Bun.sleep(500); - process.kill(a.pid, 'SIGHUP'); - expect(await a.exited).toBe(129); - const name = vmDirs(f)[0]!; - const err = await new Response(a.stderr).text(); - expect(err).toMatch(new RegExp('UBI_DETACHED: vm=' + name + ' key=[0-9a-f]{12} reattach: rerun the same command')); - expect(f.listed()).toContain(name); - expect(f.calls()).not.toContain('destroy'); - for (const file of readdirSync(join(f.state, name))) { - const p = join(f.state, name, file); - if (lstatSync(p).isFile()) expect(readFileSync(p, 'utf8')).not.toContain('hup-secret-value'); - } - const b = f.run(args, { UBI_TEST_TOKEN: 'hup-secret-value' }); - expect(b.exitCode, b.stderr.toString()).toBe(0); - expect(b.stderr.toString()).toContain('reattached to ' + name); - expect(b.stdout.toString()).toContain('finished'); - expect(creates(f)).toBe(1); - expect(f.listed()).toBe(''); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 90_000); - - test('run: INT and TERM during a live command still tear the VM down', async () => { - for (const sig of ['SIGTERM', 'SIGINT'] as const) { - const f = fakeVm(); - try { - const repo = gitSrc(f); - writeFileSync(join(f.root, 'make-test.sh'), 'sleep 3\n'); - const a = f.spawn(['run', '--src', repo, '--', 'make test']); - await until(() => phaseStarted(f, 'command'), 30_000); - await Bun.sleep(500); - process.kill(a.pid, sig); - expect(await a.exited).toBe(130); - expect(await new Response(a.stderr).text()).toMatch(/destroyed eu-central-h1\/ubirun-thrd1-/); - expect(f.listed()).toBe(''); - } finally { rmSync(f.root, { recursive: true, force: true }); } - } - }, 90_000); - - test('run: a remote session gone without an exit status exits 125 with UBI_ATTACH_LOST and the VM name', async () => { - const f = fakeVm(); - try { - const repo = gitSrc(f); - writeFileSync(join(f.root, 'make-test.sh'), 'sleep 30\n'); - const args = ['run', '--src', repo, '--', 'make test']; - const a = f.spawn(args); - await until(() => existsSync(join(f.vm, '.ubirun-command.pid')) && readFileSync(join(f.vm, '.ubirun-command.pid'), 'utf8').trim() !== '', 30_000); - process.kill(a.pid, 'SIGKILL'); - await a.exited; - await killSession(f, 'command'); - const name = vmDirs(f)[0]!; - const b = f.run(args); - expect(b.exitCode).toBe(125); - expect(b.stderr.toString()).toContain('UBI_ATTACH_LOST: vm=' + name + ' phase=command'); - expect(f.listed()).toBe(''); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 90_000); - - test('run: the run key covers tree, command, setup content, env, pass value, image, origin/HEAD and the runner itself; a pass value never appears in it', () => { - const f = fakeVm(); - try { - const repo = gitSrc(f); - const setup = join(f.root, 'setup.sh'); writeFileSync(setup, 'echo one\n'); - const runner = join(f.root, 'runner-copy.sh'); writeFileSync(runner, readFileSync(join(DIR, 'ubi-runner.sh'), 'utf8')); - const key = (opts: { args?: string[]; env?: Record; cmd?: string; script?: string } = {}) => { - const r = Bun.spawnSync(['bash', opts.script ?? join(DIR, 'ubi-runner.sh'), 'run', '--probe', '--src', repo, '--setup', setup, '--env', 'MODE=a', '--pass', 'UBI_TEST_TOKEN', ...(opts.args ?? []), '--', opts.cmd ?? 'make test'], - { env: { ...process.env, PATH: join(f.root, 'bin') + ':' + process.env.PATH, UBICLOUD_API_KEY: 'offline', UBI_RUNNER_STATE: f.state, UBI_OWNER: 'thrd1', UBI_TEST_TOKEN: 'v1', ...(opts.env ?? {}) }, timeout: 30_000 }); - const out = r.stdout.toString() + r.stderr.toString(); - expect(out).not.toContain('v1'); - const m = /^UBI_ATTACH_INFO: none key=([0-9a-f]{12})$/m.exec(out); - if (!m) throw new Error(out); - return m[1]!; - }; - const base = key(); - expect(key()).toBe(base); - const variants: Record = {}; - variants.command = key({ cmd: 'make check' }); - variants.env = key({ args: ['--env', 'MODE=b'] }); - variants.pass = key({ env: { UBI_TEST_TOKEN: 'v2' } }); - variants.image = key({ args: ['-b', 'ubuntu-jammy'] }); - variants.size = key({ args: ['-s', 'standard-8'] }); - writeFileSync(runner, readFileSync(runner, 'utf8') + '# edited\n'); - variants.runner = key({ script: runner }); - writeFileSync(setup, 'echo two\n'); - variants.setup = key(); - writeFileSync(setup, 'echo one\n'); - expect(key()).toBe(base); - Bun.spawnSync(['git', 'update-ref', 'refs/remotes/origin/HEAD', 'HEAD'], { cwd: repo, timeout: 10_000 }); - variants.base = key(); - writeFileSync(join(repo, 'new-file.txt'), 'untracked source\n'); - variants.tree = key(); - for (const [what, k] of Object.entries(variants)) expect({ what, same: k === base }).toEqual({ what, same: false }); - expect(new Set(Object.values(variants)).size).toBe(Object.keys(variants).length); - expect(f.calls()).not.toContain('"create"'); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 60_000); - - test('verify tiers: config refuses command with tiers, bad and duplicate tier names, before any API call', () => { - const f = fakeVm(); - try { - const cases: Array<[unknown, RegExp]> = [ - [{ command: 'make', tiers: [{ name: 'a', command: 'x' }] }, /UBI_CONFIG_TIERS_AND_COMMAND/], - [{ tiers: [{ name: 'Unit', command: 'x' }] }, /UBI_CONFIG_TIER_NAME: tier name 'Unit' must match/], - [{ tiers: [{ name: '-x', command: 'x' }] }, /UBI_CONFIG_TIER_NAME: tier name '-x'/], - [{ tiers: [{ name: 'a'.repeat(33), command: 'x' }] }, /UBI_CONFIG_TIER_NAME/], - [{ tiers: [{ name: 'a.b', command: 'x' }] }, /UBI_CONFIG_TIER_NAME/], - [{ tiers: [{ name: 'a', command: 'x' }, { name: 'a', command: 'y' }] }, /UBI_CONFIG_TIER_NAME: tier name 'a' appears twice/], - [{ tiers: [{ name: 'a' }] }, /UBI_CONFIG_TIER_NAME: tier 'a' needs a command/], - [{ tiers: [] }, /UBI_CONFIG_TIER_NAME: tiers must be a non-empty list/], - ]; - for (const [cfg, want] of cases) { - const dir = src(f, cfg); - const r = run(f, ['verify', '--src', dir]); - expect(r.exitCode).toBe(1); - expect(r.stderr.toString()).toMatch(want); - } - expect(f.calls()).toBe(''); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 30_000); - - test('verify tiers: a run killed during tier 3 with its VM gone resumes on a new VM: setup reruns, t1 and t2 are reused, t3 reruns', async () => { - const f = fakeVm(); - try { - const dir = gitSrc(f, TIERS); - bootCounts(f, dir); - Bun.spawnSync(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'boot'], { cwd: dir, timeout: 10_000 }); - writeFileSync(join(f.root, 'make-t3.sh'), 'touch ' + JSON.stringify(join(f.root, 't3-started')) + '; sleep 30\n'); - const a = f.spawn(['verify', '--src', dir]); - await until(() => existsSync(join(f.root, 't3-started')), 60_000); - process.kill(a.pid, 'SIGKILL'); - await a.exited; - await killSession(f, 'tier-t3'); - const first = vmDirs(f)[0]!; - writeFileSync(join(f.root, 'listed'), ''); - writeFileSync(join(f.root, 'make-t3.sh'), 'echo t3 ok\n'); - const b = f.run(['verify', '--src', dir]); - const err = b.stderr.toString(), out = b.stdout.toString(); - expect(b.exitCode, err).toBe(0); - expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t1 reuse t1\.\d{10}\.\d+\.json \(\d+s\)\n/); - expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t2 reuse t2\.\d{10}\.\d+\.json \(\d+s\)\n/); - expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t3 run: interrupted \(only a start record, t3\.\d{10}\.\d+\.json\)\n/); - expect(err).toContain('run record for ' + first + ': the VM is gone; starting a new one'); - expect(err).not.toContain('reattached to'); - expect(creates(f)).toBe(2); - expect(readFileSync(join(f.vm, 'setup-count'), 'utf8')).toBe('boot\nboot\n'); - expect(out).not.toContain('ran: make t1'); - expect(out).toContain('t3 ok'); - expect(verifyLine(out)).toMatch(/^UBI_VERIFY: tree=[0-9a-f]{12} key=[0-9a-f]{12} tiers=3 passed=3 reused=t1,t2 failed=none prior_failures=none saved_s=\d+$/); - expect(f.listed()).toBe(''); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 120_000); - - test('verify tiers: a fail record blocks a rerun until --rerun-failed (then prior_failures); passes are reused unless stale, tampered, --no-resume or the tree changed', () => { - const f = fakeVm(); - try { - const dir = gitSrc(f, TIERS); - writeFileSync(join(f.root, 'make-t2.sh'), 'echo t2 broke; exit 7\n'); - const first = run(f, ['verify', '--src', dir]); - expect(first.exitCode, first.stderr.toString()).toBe(7); - expect(first.stdout.toString()).not.toContain('ran: make t3'); - expect(verifyLine(first.stdout.toString())).toMatch(/ tiers=3 passed=1 reused=none failed=t2 prior_failures=none saved_s=0$/); - expect(creates(f)).toBe(1); - - const blocked = run(f, ['verify', '--src', dir]); - expect(blocked.exitCode).toBe(7); - expect(blocked.stderr.toString()).toMatch(/UBI_VERIFY_RESUME: tier=t2 fail t2\.\d{10}\.\d+\.json \(\d+s, exit 7\): not rerun without --rerun-failed/); - expect(verifyLine(blocked.stdout.toString())).toMatch(/ failed=t2 prior_failures=none /); - expect(creates(f)).toBe(1); - - writeFileSync(join(f.root, 'make-t2.sh'), 'echo t2 fixed\n'); - const rerun = run(f, ['verify', '--src', dir, '--rerun-failed']); - expect(rerun.exitCode, rerun.stderr.toString()).toBe(0); - expect(rerun.stderr.toString()).toMatch(/UBI_VERIFY_RESUME: tier=t1 reuse /); - expect(rerun.stderr.toString()).toMatch(/UBI_VERIFY_RESUME: tier=t2 run: prior fail t2\.\d{10}\.\d+\.json \(exit 7; --rerun-failed\)/); - expect(verifyLine(rerun.stdout.toString())).toMatch(/ tiers=3 passed=3 reused=t1 failed=none prior_failures=t2:7 saved_s=\d+$/); - expect(creates(f)).toBe(2); - - const all = run(f, ['verify', '--src', dir]); - expect(all.exitCode).toBe(0); - expect(verifyLine(all.stdout.toString())).toMatch(/ passed=3 reused=t1,t2,t3 failed=none prior_failures=none saved_s=\d+$/); - expect(all.stderr.toString()).toContain('every tier passed under this key; no VM needed'); - expect(creates(f)).toBe(2); - - const probe = (args: string[] = [], extra: Record = {}) => run(f, ['verify', '--src', dir, '--probe', ...args], extra).stderr.toString(); - expect(probe(['--no-resume'])).toMatch(/tier=t1 run: --no-resume\n[^]*tier=t2 run: --no-resume\n[^]*tier=t3 run: --no-resume\n/); - expect(probe([], { UBI_CHECKPOINT_MAX_AGE: '0' })).toMatch(/tier=t1 run: pass t1\.\d{10}\.\d+\.json is \d+s old \(UBI_CHECKPOINT_MAX_AGE=0\)/); - const keyDir = join(f.checkpoints, readdirSync(f.checkpoints)[0]!); - const t1Log = readdirSync(keyDir).filter(n => n.startsWith('t1.') && n.endsWith('.log')).sort().at(-1)!; - writeFileSync(join(keyDir, t1Log), 'tampered\n'); - const tampered = probe(); - expect(tampered).toMatch(/tier=t1 run: pass t1\.\d{10}\.\d+\.json: its log does not re-hash/); - expect(tampered).toMatch(/tier=t2 reuse /); - writeFileSync(join(dir, 'new-source.ts'), 'export {}\n'); - const moved = probe(); - for (const t of ['t1', 't2', 't3']) expect(moved).toContain('UBI_VERIFY_RESUME: tier=' + t + ' run: no record'); - expect(creates(f)).toBe(2); - // Records are write-once JSON; every one carries its generation. - const gens = readdirSync(keyDir).filter(n => n.endsWith('.json')).map(n => JSON.parse(readFileSync(join(keyDir, n), 'utf8')).generation as number).sort((x, y) => x - y); - expect(gens).toEqual(gens.map((_, i) => i + 1)); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 120_000); - - test('verify tiers: a reattach whose VM TTL no longer covers the run left plus UBI_REATTACH_MARGIN starts a fresh VM, destroys the old one and reuses the passed tiers', async () => { - const f = fakeVm(); - try { - const dir = gitSrc(f, TIERS); - bootCounts(f, dir); - Bun.spawnSync(['git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'boot'], { cwd: dir, timeout: 10_000 }); - writeFileSync(join(f.root, 'make-t3.sh'), 'touch ' + JSON.stringify(join(f.root, 't3-started')) + '; sleep 30\n'); - const env = { UBI_REAP: '0', UBI_TTL_HOURS: '1' }; - const a = f.spawn(['verify', '--src', dir], env); - await until(() => existsSync(join(f.root, 't3-started')), 60_000); - process.kill(a.pid, 'SIGKILL'); - await a.exited; - const first = vmDirs(f)[0]!; - expect(first).toMatch(/-t1$/); - const probe = (extra: Record) => run(f, ['verify', '--src', dir, '--probe'], { ...env, ...extra }).stdout.toString(); - expect(probe({})).toMatch(new RegExp('UBI_ATTACH_INFO: vm=' + first + ' created_at=\\d{10} ')); - // A 1 h TTL leaves about 3600 s: t3's 1200 s run timeout plus a 2700 s margin does not fit. - const short = { UBI_RUN_TIMEOUT: '1200', UBI_REATTACH_MARGIN: '2700' }; - expect(probe(short)).toMatch(new RegExp('UBI_ATTACH_INFO: none key=[0-9a-f]{12} ttl_short vm=' + first + ' ttl_left=\\d+s needs=\\d+s \\(run still to do \\d+s \\+ UBI_REATTACH_MARGIN=2700s\\)')); - expect(creates(f)).toBe(1); - writeFileSync(join(f.root, 'make-t3.sh'), 'echo t3 ok\n'); - const b = run(f, ['verify', '--src', dir], { ...env, ...short }); - const err = b.stderr.toString(); - expect(b.exitCode, err).toBe(0); - expect(err).toMatch(new RegExp('UBI_REATTACH_TTL_SHORT: vm=' + first + ' key=[0-9a-f]{12} ttl_left=\\d+s needs=\\d+s \\(run still to do \\d+s \\+ UBI_REATTACH_MARGIN=2700s\\): starting a fresh VM \\(tier checkpoints carry over\\) and destroying ' + first)); - expect(err).not.toContain('reattached to'); - expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t1 reuse /); - expect(err).toMatch(/UBI_VERIFY_RESUME: tier=t2 reuse /); - expect(verifyLine(b.stdout.toString())).toMatch(/ tiers=3 passed=3 reused=t1,t2 failed=none /); - expect(creates(f)).toBe(2); - expect(f.listed()).toBe(''); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 120_000); - - test('verify tiers: a writer whose start record is no longer the newest publishes nothing (stale writer)', () => { - const py = /\nCKPT_PY='\n([^']*)'\n/.exec(readFileSync(join(DIR, 'ubi-runner.sh'), 'utf8'))![1]!; - const root = mkdtempSync(join(tmpdir(), 'ubi-ckpt-')); - try { - const d = join(root, 'key'); - const log = join(root, 'tier.log'); writeFileSync(log, 'ok\n'); - const publish = (status: string, startgen: string, pid: string) => Bun.spawnSync(['python3', '-c', py, 'publish', d, 't1', status, 'vm', '100', '200', '0', status === 'start' ? '' : log, startgen, 'k', 'tree', pid], { timeout: 30_000 }); - expect(publish('start', '0', '1').stdout.toString().trim()).toBe('1'); - expect(publish('start', '0', '2').stdout.toString().trim()).toBe('2'); - const stale = publish('pass', '1', '1'); - expect(stale.exitCode).toBe(2); - expect(stale.stderr.toString()).toContain('stale writer: its start record is generation 1, the newest for t1 is 2'); - expect(readdirSync(d).filter(n => n.endsWith('.json')).length).toBe(2); - expect(publish('pass', '2', '2').stdout.toString().trim()).toBe('3'); - } finally { rmSync(root, { recursive: true, force: true }); } - }); - - test('verify tiers: a tier that edits the source gets no pass record (UBI_VERIFY_TREE_MOVED) and reruns next time', () => { - const f = fakeVm(); - try { - const dir = gitSrc(f, { tiers: [{ name: 'mover', command: 'make mover' }, { name: 'clean', command: 'make clean' }] }); - writeFileSync(join(f.root, 'make-mover.sh'), 'echo edited > moved.txt\n'); - const r = run(f, ['verify', '--src', dir]); - expect(r.exitCode, r.stderr.toString()).toBe(0); - expect(r.stderr.toString()).toMatch(/UBI_VERIFY_TREE_MOVED: tier=mover before=[0-9a-f]{12} after=[0-9a-f]{12}: a tier must leave the source tree as it found it; no pass record/); - expect(verifyLine(r.stdout.toString())).toMatch(/ tiers=2 passed=2 reused=none failed=none /); - const next = run(f, ['verify', '--src', dir, '--probe']).stderr.toString(); - expect(next).toMatch(/tier=mover run: interrupted \(only a start record/); - expect(next).toMatch(/tier=clean reuse /); - } finally { rmSync(f.root, { recursive: true, force: true }); } - }, 90_000); -}); diff --git a/test/windows-native-workflows.test.ts b/test/windows-native-workflows.test.ts index 269943058..549803548 100644 --- a/test/windows-native-workflows.test.ts +++ b/test/windows-native-workflows.test.ts @@ -20,7 +20,7 @@ const native = load('native-qualification.yml'); * intentionally excluded Windows-safe tests, lower this floor to the printed * count in the same PR and say why; when the count grows, raise it. */ -const WINDOWS_CURATED_FLOOR = 638; +const WINDOWS_CURATED_FLOOR = 795; test('the Windows lane plans, runs one strict shard per job and verifies every result', () => { expect(Object.keys(windows.on).sort()).toEqual(['pull_request', 'push', 'workflow_call', 'workflow_dispatch']);