Files
gstack/scripts/test-free-shards.ts
T
b1485d8897 v1.74.0.0 test/CI overhaul: green means green, suites restructured for speed (#2721)
* fix(ci): free-tests lane actually runs the make-pdf e2e gates

The 9 make-pdf/test/e2e gate tests probe make-pdf/dist/pdf,
browse/dist/browse, and the diagram-render bundle, then self-skip when
absent. The required free-tests lane never built any of them, so the
gates silently skipped on Linux for their entire life (verified: 9 of
14 skip, exit 0). make-pdf-gate.yml's justification for deleting its
Linux leg claimed the free lane covered this — it didn't.

- new build:gates script: exactly the three artifacts the gates probe
  (full bun run build compiles five binaries; ~60-90s tax on the only
  required check is not warranted)
- free-tests.yml: build:gates step + poppler-utils +
  fonts-noto-color-emoji (fonts must precede the first browse daemon
  launch — Chromium snapshots fontconfig at startup; verified live:
  a warm daemon renders tofu, a fresh one embeds NotoColorEmoji)
- make-pdf/test/e2e/ci-prereqs.test.ts: GSTACK_EXPECT_BINARIES=1 (set
  by the workflow) inverts the skip polarity in CI — dropping the
  build step or poppler fails the lane instead of re-opening the
  silent-skip hole

Pre-flight: all 9 gates green on Linux locally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): kill the three zero-test eval jobs (hollow green)

- delete the vestigial e2e-codex / e2e-gemini matrix rows: both files
  are whole-file periodic-tier, so with no row tier: they ran ZERO
  tests and reported green on every PR (~2 min of runner each, pure
  false confidence; the periodic lane owns those suites)
- e2e-pty-plan-smoke gains tier: gate — its two files are whole-file
  describeE2ETier('gate'), so the job burned ~7 min of container setup
  then skipped every describe
- KNOWN_TIER_UNSET burned down to empty; the ratchet stays armed so a
  future row/file tier mismatch fails the suite instead of shipping
  hollow green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): least-privilege permissions + fork-safe concurrency keys

- evals.yml / evals-periodic.yml evals jobs: explicit contents:read +
  packages:read (container-image pull) and persist-credentials:false —
  the jobs that execute PR-authored code with three provider API keys
  ran on the repo-default token grant with the token written into
  .git/config
- permissions blocks for the 4 workflows that had none (skill-docs,
  make-pdf-gate, windows-free-tests, windows-setup-e2e)
- fork-safe concurrency keys: actionlint, skill-docs, make-pdf-gate,
  windows-setup-e2e switch from head_ref to PR-number keying — a bare
  branch name carries no fork prefix, so same-name branches from two
  forks shared one group and cancelled each other's runs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): one bun version everywhere + drift tripwire

Lanes disagreed four ways: 1.3.13 (free-tests, windows, Dockerfile.ci),
latest (quality-gate, make-pdf-gate), unpinned (skill-docs,
version-gate — setup-bun installs latest), 1.3.10 (.gitlab-ci.yml).
Different Bun versions change the runner output shapes the strict
classifiers regex-match, spawn semantics, and shell parsing — a lane on
a different Bun tests a different product; Dockerfile.ci's own comment
records this class biting once already (silent 1.3.13/1.3.14 drift).

All surfaces pinned to 1.3.13; test/bun-version-drift.test.ts scans
every workflow setup-bun stanza + Dockerfile.ci + .gitlab-ci.yml and
fails on any mismatch or unpinned stanza. skill-docs also gains
--frozen-lockfile (was bare bun install).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ci): bind the three-way image-tag hashFiles() expressions

evals.yml, evals-periodic.yml, and ci-image.yml each compute the CI
image tag from hashFiles('.github/docker/Dockerfile.ci', 'bun.lock',
'patches/**') — synced by comment only (TODOS.md 'CI three-way
image-tag drift'). If one input list drifts, that workflow computes a
different tag for the same content: eval lanes silently rebuild the
image every run, or ci-image prebuilds a tag nobody looks up. The test
extracts each tag-computation site and fails on any mismatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): ci-image stops rebuilding the identical image every ship

- package.json out of the trigger paths: the tag hash deliberately
  excludes it (version bumps every ship), so every merge rebuilt and
  re-pushed the IDENTICAL tag (~2m26s for zero content change);
  patches/** added (it IS a tag input)
- manifest existence check (mirrors evals.yml): tag already exists →
  skip the build
- concurrency group: two rapid main pushes raced pushing the same
  :latest/:buildcache tags
- cron staggered 06:00→04:00 Monday: it shared the exact minute with
  evals-periodic, which could race a half-pushed tag or duplicate the
  build
- timeout-minutes: 30 (was unbounded → 360-min default for a hung
  docker build)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): quality-gate drops the 74s full-history checkout

fetch-depth:0 cost 74 of the job's 92 seconds; the three gates it feeds
take ~12s combined. Shallow checkout + exact-SHA fetches for the diff's
base/head (an exact-SHA fetch, not a guessed depth — long-lived
branches and merge queues still resolve), with a --deepen fallback for
push events whose 'before' is unusable. timeout right-sized 20→10 min.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): small-lane batch — timeouts, right-sizing, windows cache warm-start

- timeout-minutes on the 6 remaining unbounded jobs (actionlint 5,
  skill-docs 10, version-gate 10, make-pdf-gate 15, pr-title-sync 5,
  evals build-image 15) — a hung step sat on GitHub's 360-min default
- right-size measured-over-long timeouts: dependency-review 10→5,
  windows-setup-e2e 15→10
- dependency-review: 2-core runner (28s API call on an 8-core box) and
  drop .github/workflows/** from its trigger paths (workflow edits have
  no dependencies to review)
- windows caches gain restore-keys: a lockfile bump paid the 26s/43s
  restore for a guaranteed cold miss

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): scope GSTACK_HOME to each file's execution window

Five files assigned process.env.GSTACK_HOME at module scope. Shard
processes evaluate sibling modules before running their tests, so the
assignment leaked into every other file in the shard — the damage was
already visible in defensive workarounds (relink.test.ts:28 'fresh
install test saw a neighbor's skill_prefix'; cdp-e2e's own comment
documents a sibling's temp dir baked into artifacts).

Pattern: save original, assign in beforeAll, restore in afterAll
(cdp-e2e already restored but still assigned at load — its window now
matches the others). GSTACK_TELEMETRY_OFF and GSTACK_PROJECT_SLUG get
the same treatment where they rode along. Victim files' defenses stay
in place (cheap insurance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: tripwire against module-scope GSTACK_HOME assignments

Column-0 assignment of GSTACK_HOME / GSTACK_STATE_ROOT in any tracked
*.test.ts fails with the file:line and the fix (beforeAll + afterAll
restore). Kills the cross-file env-leak class the previous commit
swept.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): e2e-harness-audit derives its skill census from disk

The hand-maintained 39-name SKILL_GLOBS list had drifted to 39 of 54
SKILL.md.tmpl on disk. No live gap today (none of the 15 unlisted
skills is interactive), but the next interactive skill would have
landed unguarded with zero signal. The audit now walks top-level dirs
for SKILL.md.tmpl (statSync so symlinked dirs like connect-chrome
count), so new skills are in scope the commit they appear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(evals): judges honor the eval-model resolution chain + real 429 backoff

callJudge inlined GSTACK_EVAL_MODEL_JUDGE || sonnet, silently ignoring
the global GSTACK_EVAL_MODEL override every other eval call site honors
via lib/eval-model.ts. New 'judge' kind in DEFAULTS (sonnet — the D1a
pin-on-regressors calibration stands; model CHOICE unchanged) and
callJudge resolves through it: explicit arg > GSTACK_EVAL_MODEL_JUDGE >
GSTACK_EVAL_MODEL > default.

429 handling upgraded from one fixed 1s retry (reliably lost races at
CI concurrency) to three jittered exponential retries (~1s/4s/16s),
honoring the server's retry-after when present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): the two expect(true) paid stubs become test.todo

skill-e2e-spec-execute (600s budget) and skill-llm-eval-spec (300s)
reported PASS on every periodic run while asserting nothing. Deleting
them would remove the periodic-tier selector surface they exist to
register (diff-based selection for spec/ changes), so they become
test.todo — reported as todo/skip, never pass — with the v1.1
implementation specs kept in-file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): reactivate 5 quarantined browse tests (2 security)

extension-sender-auth's two privileged-message denial tests (content
script + missing sender.url — the extension's security boundary) and
snapshot's three skips were quarantined 'pre-existing' failures. Root
cause: machine-local state on the quarantining dev machines — the test
and gate code are byte-identical between the quarantining commit
(410b4928) and HEAD, and all five pass deterministically on a clean
checkout (68/68 across both files, multiple runs). No assertions
weakened, no product changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(evals): activate the 4 paid test files that could never run anywhere

carve-section-loading, codex-e2e-plan-format,
codex-e2e-recommendation-substance, and llm-judge-recommendation gated
on EVALS/tier (free suite loads them as describe.skip) but their names
fell outside PAID_TEST_GLOBS, so no paid lane ever selected them — net
execution zero, forever. The existing matrix tripwire filtered on
isPaidTestFile() first, so it was blind to exactly this class (the same
bug that hid the pre-split monolith's gate tests for ~8 releases).

- PAID_TEST_GLOBS: codex-e2e* + skill-llm-eval* wildcards (replacing
  exact names) + llm-judge-recommendation + carve-section-loading;
  package.json's six test-script glob lists mirrored
- codex-e2e-plan-format gains the explicit periodic tier gate its
  siblings carry (external-service rule) — without it the sharded
  runner's no-guard default would spawn Codex in the gate tier per PR
- eval:bg:periodic --timeout 32400→37800: the census growth pushed the
  periodic worst case to 35910s; the old value had 270s of headroom
  BEFORE this change and would now kill healthy runs mid-flight
- new test/paid-orphan-tripwire.test.ts: any EVALS/tier-gated test file
  outside the globs fails the free suite (reasoned SCANNER_EXEMPT for
  the gate helpers + meta-tests) — the class-killer
- paid-shards pins updated: the four orphans now assert INSIDE the
  census

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): restrictDirectoryPermissions warns and skips symlinked dirs

Closes the Windows Free Tests red: recent lane failures showed a
platform-unguarded POSIX mode-bit assertion ('Expected: 493' — a
symlink-skip test) from PR-branch variants; the KNOWN_WINDOWS_SAFE
force-include reason ('mode-bitmask hits are POSIX-branch only') did
not hold for that shape, and main had neither the guard nor the
behavior.

- product: lstat first; a symlinked dir gets a warning and a skip on
  both platforms — chmod AND icacls dereference the link, so
  restricting through a symlink hardens an unvetted target (and
  /inheritance:r could lock out its real owner). All callers already
  treat hardening as best-effort (try/catch).
- test: the symlink regression test, platform-aware — symlinkSync in
  the house try/catch skip pattern (Windows runners without Developer
  Mode can't create symlinks), mode-bit assertion guarded off win32,
  behavior assertions (no throw, warning text, target readable)
  everywhere; POSIX still proves the skip (0o755 unchanged, not 0o700)
- KNOWN_WINDOWS_SAFE reason updated to the now-true premise

20/20 pass on Linux.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): unique tmp dirs for plan artifacts + audited live-repo cwd sites

Six paid PTY tests wrote their expected plan artifact to a FIXED shared
/tmp path ('/tmp/gstack-test-plan-<mode>.md') and rmSync'd it in
finally — under --retry 1, EVALS_JOBS>1, or two concurrent worktrees, a
sibling's cleanup deletes this run's artifact and the D19 'agent did
not produce expected plan file' assertion fires spuriously. Each test
now mkdtemps its own dir, interpolates the unique path into the agent
prompt (fixture-sourced prompts get a replaceAll + drift guard that
throws if the fixture's literal ever moves), and cleans up its own dir.

The 18 cwd:-into-the-live-repo sites were audited: all deliberate
(skill registry + hermetic pre-trusted dir, in-repo gen renders, git
history reads, slug resolution) — each now carries a
'// LIVE-REPO CWD: <reason>' comment so the next audit can tell
deliberate from accidental.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): trim the seven over-wall 1700s timeouts to the 1500s physical ceiling

1,700,000ms (28.3 min) exceeded every wall these tests run inside: the
25-min CI job timeout and the 1800s sharded-runner wall (which also
leaves --retry 1 zero room for a second attempt). Budget above the wall
is fiction, not headroom — a test that actually used it produced a
job-level kill (no bun summary, no artifact) instead of a clean
per-test timeout. No recorded p95 exists for this family (they are
being retiered to periodic in the re-platform wave); the trim stops at
the physical ceiling rather than guessing lower. Final policy lands in
the Wave-2 eval-budgets constants module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(gen): main() guard — importing gen-skill-docs no longer regenerates the tree

The generator's whole body executed at module load, so any import of it
(test/gen-skill-docs.test.ts pulls assertSinglePreamble via require();
test/catalog-trim.test.ts imports helpers) regenerated all 71 SKILL.md
in place — the root cause of half the TREE_MUTATING serial-shard
entries (hazard class #2532). The body now lives in an exported
main(): number behind if (import.meta.main).

Semantics preserved exactly: failure exits are immediate (matching the
old top-level process.exit), success leaves the event loop to drain so
the llms.txt fire-and-forget IIFE finishes its write, and the module
stays synchronous/require()-able. Proofs: byte-identical --host all
output (git status clean), --dry-run stale-tree still exits 1 (the
skill-docs freshness lane depends on it), and the new
test/gen-skill-docs-import-purity.test.ts pins load-time purity via a
subprocess probe (mtime-based, so a dirty worktree can't false-fail).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(gen): --out-dir renders every host, outputs-only

--out-dir was Claude-host-only (gen-skill-docs.ts:842), which forced
the codex/factory-regenerating tests (gen-skill-docs, skill-validation,
host-config) to mutate the live tree — the reason they sit in the
TREE_MUTATING serial shard. The flag now mirrors ALL outputs into the
out-dir: external-host trees (.agents/.factory/... via
processExternalHost), external section files, openclaw docs, and
gstack/llms.txt (a catalog-mode render must never rewrite the tracked
index). OUTPUTS ONLY — inputs (templates, sections/, host configs) are
always read from ROOT, so an empty out-dir can never feed the render.
rewriteSectionBase stays Claude-only (external hosts have their own
path grammar).

Proofs: in-place --host all is byte-identical (tree clean);
--host all --out-dir <mkdtemp> renders the full multi-host tree with
ROOT untouched; gen-skill-docs-out-dir tests + 415/415
gen-skill-docs.test.ts green (bin/dev-setup's claude rendering
byte-compat).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(evals): every E2E key's dep list names its own declaring test file

129-of-177 keys omitted their own test file, so editing only a test's
prompt or assertions selected NOTHING — the changed test never ran on
the change that changed it. 135 keys self-registered (110 E2E + 25
LLM-judge), resolved by strict declaration evidence (testName:/
testIfSelected/judge call sites), with skill-name false positives
excluded.

e2e-tier-alignment's warn-only branch for unregistered files is now a
hard failure with a 4-entry KNOWN_UNREGISTERED ratchet (template-
literal testNames, fail-open-safe) + a burn-down test so the set only
shrinks. Selection sanity: a one-file diff on skill-e2e-qa-workflow now
selects its 4 tests (was 0); skill-llm-eval 0 → 25.

Known follow-ups (filed): 15 E2E + 2 judge PHANTOM keys select tests
that exist nowhere; codex-e2e-plan-format's testIfSelected names have
no map keys (run-all only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(evals): ratchet the 8 newly-visible gate-matrix gaps

The self-registration sweep made these eight files' gate-tier keys
visible to the census for the first time — their gate tests run in NO
CI lane today (pre-existing hole, newly measurable). Ratcheted into
KNOWN_MATRIX_GAPS with the burn-down note: the paid-lane re-platform
runs every gate file by construction and retires this ratchet class.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): duration-aware LPT shard packing for the free suite

Hash sharding balances file COUNTS (1.15x spread) but not cost — the
Playwright-launching files landed 4/3/4/1/2/1 across 6 shards, giving a
measured 28s–97s shard spread and ~40s of idle tail on every run.
Full-suite mode now packs by recorded per-file durations
(longest-processing-time-first) when the committed seed
scripts/free-test-durations.json exists.

- ONE store, no overlay: the seed is refreshed occasionally via the new
  --record-durations mode (each file timed in its own child — exact,
  and immune to bun's stream buffering, where silent passers print no
  header to timestamp); GSTACK_FREE_TEST_DURATIONS overrides the path
  for experiments; CI never records
- seed is a hint: missing → silent hash-shard fallback; corrupt (bad
  merge) → one warning + fallback; unknown files → 75th-percentile
  pessimism so a surprise long-runner can't recreate the tail
- packed shards get duration-aware walls (max(base, predicted x 3)) —
  LPT decouples count from cost BY DESIGN, so the 5s/file heuristic
  would undersize a shard holding few expensive files
- one log line per shard (files + predicted seconds) so packing
  regressions are diagnosable from any run log
- the --shard CI-matrix path is untouched: stable hash indices are its
  contract
- successor note in-code: bun >=1.3.14 ships native --timings/--shard
  LPT — swap this packer when the repo unpins 1.3.13

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): decouple slop:diff from bun run test; quality-gate runs it per PR

'bun run test' silently appended up to two 120s npx slop-scan runs plus
a git worktree add/remove after the suite (2>/dev/null || true) —
invisible in the documented '~90-100s' timing and pure friction in the
pre-commit loop. Decoupling is not coverage removal: quality-gate.yml
now runs slop:diff on every PR (advisory, matching its in-repo 'never
blocking' contract), and /review already invokes it explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): eval-budgets timeout tiers + fit/ceiling policy test

Five named tiers (JUDGE 120s / CAPTURE 300s / CAPTURE_LONG 600s /
PTY 900s / PTY_LONG 1200s) replace hand-ratcheted sprawl (46x300s,
46x120s, 44x360s, 44x180s, 27x240s, 19x150s, 13x420s, 12x600s...),
much of it inflated to paper over the old 40-way in-shard concurrency
that the sharded runner's 1-file-per-shard model kills. Policy test
pins: every tier fits the shard wall minus 120s overhead (the
structural fix for budgets-above-the-wall fiction), tiers stay ordered,
and no paid literal exceeds PTY_LONG x1.25 — oversized tests get split,
not budgeted past the wall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): shared runBin helper for bin-script unit tests

~36 free test files each carry a near-identical local run() (spawnSync
+ utf-8 + {status, stdout, stderr}) differing only in env composition,
cwd, and timeout. runBin absorbs the invariant core; options carry the
variance (gstackHome sets BOTH GSTACK_HOME and GSTACK_STATE_DIR — the
config-precedence trap several locals rediscovered independently; home
for $HOME-anchored bins; input/trim/timeout/maxBuffer). Free-test-only
by design so it never becomes a de facto global touchfile. Migration of
the 36 call sites lands separately (mechanical batches).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): runBin trim assertion — trim shapes stream ends, not interior

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(test): mechanical sweep — 298 paid-test timeouts onto eval-budget tiers

69 files, both shapes (trailing bun-test budgets and runner
timeout/timeoutMs options), ROUND-UP ONLY so nothing that passed can
start failing: 75 → JUDGE_MS, 137 → CAPTURE_MS, 74 → CAPTURE_LONG_MS,
9 → PTY_MS, 3 → PTY_LONG_MS. Raw >=60s literal count in the paid scope:
395 → 97, of which 51 are non-timeout noise (fixture dates, run IDs)
and 46 are enumerated justified holds (comment-carrying calibrated
budgets, poll-loop constants, utility spawn waits, and the seven
physical-ceiling 1_500_000 sites). The eval-budgets policy ratchet
keeps the residue from regrowing.

Known collapse: where an inner runner budget and its enclosing test
budget now share a tier, the old stagger is gone — an overrun surfaces
as a bun test timeout instead of a graceful runner timeout
(diagnosability trade, not a correctness one).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: coverage fill — 95 tests for six zero-coverage surfaces

- eval CLI family (eval-list/compare/summary + eval-select smoke): the
  primary interface to eval results had no tests; isolation via a fake
  gstack-slug under a mkdtemp HOME (the scripts' real resolution path —
  they do NOT honor GSTACK_EVAL_DIR; only EvalCollector does). Pinned
  current behavior: eval-list does NOT exclude _partial runs (documented
  improvement candidate)
- slop-diff (runs on every /review + quality-gate): fixture git repo +
  first-on-PATH npx stub (never downloads real slop-scan); no-diff
  early exit, missing-scanner fallback, fingerprint line-insensitivity,
  merge-base worktree scan
- bin/gstack-code-intelligence CLI arg surface (lib was covered, the
  284-line CLI wasn't): select/consent/suggest/index/search gating;
  pinned: --help routes to usage failure exit 1 (no handler)
- browse media-extract: the page.evaluate callback exercised in-process
  against a mock DOM (no exports added) — lazy-src fallback chain,
  HLS/DASH detection, bg-image url() parsing, 500-element cap
- browse session-cookie-store: factory contract (cookieName/ttlMs/
  maxSessions eviction, cross-store isolation, mint→validate
  round-trip); store is in-memory — no fs cases exist
- lib/version-source direct unit tests (gstack-version-bump.test.ts
  spawns the bin, never imports the lib): parse/format/cmp/bump
  coercion, npm 4→3 translation, #2501 mangled-JSON regression class

All hermetic (mkdtemp homes, runBin child isolation); windows curation
correctly partitions the six.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(test): first runBin migration batch (3 of ~36 run() duplicates)

explain-level-config, benchmark-cli, evidence move onto the shared
helper; each file's remaining special-case spawnSync sites (raw-buffer
probes, env-scrub probes) stay put deliberately. 55/55 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(evals): paid shards spool to disk + shared runShardChild lifecycle

- runPaidShard no longer buffers whole 30-min stream-json streams in
  RAM (x concurrent jobs): every byte tees to a per-shard log file
  (slug-named, path printed at START for mid-run inspection and on the
  FAILED terminal line); failures print a 64KiB tail read back from
  disk; passing shards stay quiet (the file is the record) — the free
  runner's proven contract. Classification unchanged: the strict
  classifier still sees every byte first.
- the ~35 duplicated spawn/group-kill/wall-timer/finally-reap lines
  move into runShardChild in test-strict-output.ts (detached-per-
  platform spawn, signal forwarding, SIGKILL group kill at the wall,
  drain-before-verdict); designed so the free runner can migrate later
- expectedFiles drift fixed toward ENFORCEMENT: the injected-command
  exemption is gone — a fake command exiting 0 without bun's terminal
  summary now reads FAILED (pinned: silent-pass → failed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(evals): parent-computed selection propagates to shard children

The sharded runner computed diff selection once, then each of its 48-73
children recomputed it at module load — including, on touchfiles-diff
branches, a per-child bun subprocess evaluating the old data file (20s
timeout each). The parent now serializes {version, selected, reason} as
EVALS_SELECTION_JSON into the shard env; e2e-helpers adopts it at load.
Fail-open preserved: any parse/shape violation → ONE stderr warning +
local recompute; absent env → silent local compute (non-sharded
entrypoints unchanged). Drift test pins parent→child round-trip to
identical selection decisions plus the malformed/absent cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): kill the four worst fixed sleeps (300s/30s/30s/20s)

- watchdog.test: the 20s blind wait for one production parent-watchdog
  tick becomes BROWSE_PARENT_WATCHDOG_INTERVAL_MS=250 (new env knob in
  server.ts, NaN-safe, production default unchanged) + polls for the
  boot line and the tick's stay-alive log — strictly stronger (the old
  form never proved a tick observed the parent death). 24s → 3.6s.
- stop-dead-daemon / terminal-agent-owner-watchdog: the 300s/30s
  stand-in child lifetimes become stdin-EOF-bound — the child can never
  self-exit mid-test on a slow runner (spurious-failure class) and
  self-reaps instantly if the test dies (no 300s orphans). Node-compat
  stdin APIs (owner-watchdog runs on the Windows lane).
- browser-skill-commands: the sleeper fixture's 30s self-time becomes
  8s (no stdin pipe exists in runToFiles) — far above the 1s product
  timeout it must outlive, below the test ceiling, so a timeout-kill
  regression fails on clean assertions instead of an opaque bun
  timeout; added: stdout must NOT contain 'done'.

45/45 green across the four files + server tripwires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): gen-skill-docs + catalog-trim leave the serial mutator shard

gen-skill-docs.test.ts's 15 in-place generator spawns now render into
mkdtemp out-dirs (gitignored-artifact reads repointed; the handshake
scan's silent console.warn degrade became a hard assertion); its
tracked-tree reads (freshness dry-run, SKILL.md content pins) stay
reads. catalog-trim needed no change beyond the earlier main() guard —
its import is now side-effect-free (pinned by the import-purity test).
Both TREE_MUTATING entries deleted in this commit, per the transition
rule: an entry leaves in the same commit as the file's last in-place
write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): skill-validation renders codex host into an out-dir

Its 3 in-place --host codex regeneration sites collapse into one
module-level --out-dir render; assertions untouched. TREE_MUTATING
entry deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): host-config self-provisions goldens (ordering dependency severed)

Its goldens were 'produced by gen-skill-docs.test.ts' with a
when-missing beforeAll fallback that wrote the live tree — an
inter-test ordering dependency the serial shard hid. It now renders
codex+factory UNCONDITIONALLY into its own out-dir and reads goldens
only from there (the Claude golden deliberately keeps reading tracked
ship/SKILL.md — a read; out-dir claude renders repoint section-base
paths by design). TREE_MUTATING entry deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): gbrain-detection-override drops mutate-then-git-restore

regenAndSnapshot renders --host claude --out-dir <mkdtemp> (+
--respect-detection) and snapshots probes from the out-dir. The
git-restore machinery is deleted outright — it restored only
PROBE_FILES of the 71 files each call wrote, so a stale tree kept the
other 68 dirty (the partial-restore bug), and its 'no output-path arg'
comment had been false since --out-dir landed. TREE_MUTATING entry
deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): catalog-mode-full renders to out-dir; restore machinery deleted

The full-catalog smoke no longer rewrites all 71 SKILL.md then
regenerates to restore (with its 'CRITICAL: failed to restore' prayer
path) — it renders into a mkdtemp and additionally asserts tracked
ship/SKILL.md is byte-unchanged. TREE_MUTATING entry deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): idempotency proof strengthens to two-out-dir recursive diff

Two renders into two separate out-dirs, EVERY file diffed byte-for-byte
(claude-only and --host all; normalization only for each dir's own
sanctioned section-base repoint; presence-sanity lists guard against a
vacuous empty-dir pass) — strictly stronger than the old in-place
double-regen that sampled 5 files. TREE_MUTATING entry deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): spec-template-sync compares an out-dir render, not an in-place one

TREE_MUTATING entry deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): the serial tree-mutating shard dissolves — TREE_MUTATING is empty

Zero mutators remain (all eight render into out-dirs now), so the four
ratchet READERS (parity caps, size budgets, carve parity/ordering) get
a quiet tree by construction in any shard and rejoin the parallel
phase. The ~35-40s serial tail on every full-suite run is gone. The
mechanism stays: a future test that genuinely must write shared
artifacts in place earns an entry with a reason and is serialized
again; the census pin still fails on renamed keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(gen): out-dir byte-identity + tree-clean pins for external hosts

codex render: porcelain unchanged AND out-dir gstack-ship/SKILL.md
byte-identical to a fresh in-place render (+openai.yaml presence);
--host all render: exit 0, porcelain unchanged, claude + .agents +
.factory + llms.txt + openclaw docs all present in the out-dir.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(test): commit the initial free-test durations seed (496 files)

Recorded via --record-durations on a quiescent tree: 479s serial
total, p50 92ms / p90 1.8s / max 31.4s — the top-heavy cost shape LPT
packing exists for. A hint, not a contract: refresh opportunistically
with bun run test:free --record-durations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(evals): planner/executor/report modes — the CI re-platform surface

One PLANNER computes diff selection + the slice plan ONCE and writes a
manifest (--emit-plan <path> --slices K); K executors consume it
(--plan <path> --slice i), never self-selecting, and write slice-result
artifacts; a REPORT reconciles results against the manifest (--report
<dir>) fail-closed: a slice whose artifact never landed is a FAILURE,
a planned shard nobody reported fails, wrong-slice/duplicate/cross-tier
results fail. Kills per-slice selector divergence and hollow-lane
aggregation at the root.

- hollow-shard guard: under EVALS_ALL, exit 0 with ZERO executed tests
  (bun's 'Ran N tests' now captured by the classifier — additive) is
  'passed-empty' and fails the run; selective runs keep it 'passed'
  with one warning (in-file diff/tier self-skips are legitimate there);
  unknown counts are never guessed hollow
- retry parity: --retry 1 default + RETRY_OVERRIDES literals for the
  three files whose old matrix rows earned retries: 2 (stale entries
  pinned against disk)
- live smoke: gate plan = 48 shards across 6 slices; report mode exits
  1 on a fabricated missing slice, 0 when complete

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ci): sliced paid lane (planner -> 6 executors -> fail-closed report)

The parity-phase re-platform: evals.yml gains a second, sliced lane
driven by scripts/test-paid-shards.ts — the SAME engine local
eval:bg:gate uses, so CI and local share one selection engine.

- plan-slices: ONE planner (fetch-depth 0 — the only job needing
  history) emits the manifest; selection fails open to run-all, never
  per-slice (the divergence class is structurally dead)
- eval-slices: 6-way matrix consuming the manifest; PTY seed +
  skill-registration steps run unconditionally (idempotent — a sliced
  lane cannot key them on suite names); aggregate spawn budget
  6 x EVALS_JOBS=2 x EVALS_CONCURRENCY=2 = 24 lane-wide (the matrix's
  40-way per row queued session startup behind 39 siblings — the
  timeout-flake family root); slice results + spooled shard logs
  uploaded as artifacts
- slices-report: reconciles slice artifacts against the manifest
  FAIL-CLOSED via --report — a slice whose artifact never landed, or a
  planned shard nobody reported, is a failure, not an absence
- sequenced needs: evals so provider concurrency never doubles while
  both lanes coexist; the matrix + its ratchets are deleted after
  demonstrated parity (intersection + expected-additions comparison)
- workflow_dispatch gains evals_all (default true) for parity runs and
  post-merge smokes — a dispatch can never silently select zero

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ci): weekly periodic lane runs EVERY periodic test + gate census backstop

evals-periodic.yml re-platforms onto the sharded runner: planner
manifest → 6 executor slices → FAIL-CLOSED report. This IS the coverage
contract: all ~70 periodic-tier files weekly (EVALS_ALL=1), killing the
silent-rot class where a hard-coded 9-file matrix left ~57 files
running NOWHERE (the autoplan E2E rotted invisibly for months).

- test/helpers/periodic-exclude-data.ts: reasoned exclusions in their
  OWN literals file (deliberately not touchfiles-data — map-diff
  evaluates old versions of that file standalone). Every entry carries
  reason + tracking with a re-entry condition; the runner surfaces each
  exclusion per run; policy test pins real-file + non-empty fields.
  Initial: ship-idempotency + brain-privacy-gate (documented-red,
  never green) and skill-e2e-ios (manual hardware). The TODOS 'sidebar
  E2E trio' turned out already deleted — only tombstone tests remain.
- gate-census job: weekly EVALS_ALL gate-tier run — PR lanes are
  diff-billed, so without this the full gate census might never execute
  anywhere; with the hollow-shard guard it is a census-health check
  (exit 0 + zero executed tests fails), not just a test run.
- failure notification is a concrete gh issue UPSERT (one tracking
  issue, commented per red week — never issue-per-week spam), with
  issues:write scoped to the report job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: TESTING_INTERNALS covers the 2026-08 runner overhaul

LPT-packed free suite + --record-durations, the emptied TREE_MUTATING
mechanism, the sharded paid runner as the single selection engine,
CI planner/executor/report with the fail-closed report and hollow-shard
guard, the weekly coverage contract + exclusions policy, and the
eval-budgets timeout tiers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(CLAUDE.md): testing prose matches the overhauled runners

- bun run test: duration-packed shards + --record-durations; the
  trailing serial tree-mutating shard no longer exists
- two-tier system: the sliced CI lanes (one engine local+CI), the
  weekly all-periodic coverage contract + exclusions, the gate census
- periodic detach timeout 32400 → 37800

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(TODOS): close the absorbed test-infra items, file the overhaul follow-ups

Closed with receipts: the periodic coverage contract (implemented as
full weekly coverage + exclusions), the eval-harness observability P1
(verified already landed: heartbeat, incremental _partial persistence,
live stderr + eval-watch), and the sidebar trio (already deleted —
tombstones remain). Filed: matrix deletion after parity, the
required-check maintainer decision, browse /tmp-namespace hardening,
PTY boot-readiness waits, the single typed test registry, bun-native
LPT swap, runBin/free-runner migrations, eval-list partial exclusion,
phantom key cleanup, duration-weighted slicing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v1.73.0.0: test/CI overhaul — green means green, suites restructured for speed

Version + release notes for the audit-and-overhaul branch: every
silently-skipping or never-running test class fixed and tripwired, the
free suite duration-packed with the serial mutator shard dissolved, the
paid lane re-platformed onto the sharded runner (planner/slices/
fail-closed report, parity phase), the weekly all-periodic coverage
contract, eval-budget timeout tiers, and 95 new coverage tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ci): first-live-run fixes — executor history + two environment-blind assertions

The sliced lane's first run (PR #2721) did its job: the planner and
report worked, the manifest governed, and every failure had a name.
Three were fixable on the spot:

- executor + gate-census checkouts get fetch-depth: 0 — files with
  SELF-derived selection (the LLM-judge map, routing) walk git at
  module load, and selection is deliberately fail-closed on git errors,
  so the shallow checkout crashed those shards ('ambiguous argument
  main...HEAD'). The manifest still governs WHICH shards run.
- landscape --toc gate: the exact toBe(3) landscape-page count was
  font-metric-dependent (3 on Amazon Linux, 2 on ubuntu CI — the same
  disease the file's own page-index comment warns about). Now a
  comparative invariant: --toc must not CHANGE the landscape count vs
  a baseline render.
- paid-run-manifest parse test builds its manifest under EVALS_ALL so
  it never walks git (proven with GIT_DIR=/nonexistent).

Remaining first-run failures are newly-exposed rot in gate files that
had never executed in CI (skillify D1 refusal, session-intelligence
context-restore, one tpa-apple-ban retry flake) — being probed
separately; they are the lane WORKING, not the lane failing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(TODOS): file the three first-execution findings from the sliced lane's live run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* v1.74.0.0: queue-advance — #2722 claims the v1.73.0.0 slot

The version gate caught a live queue collision (its whole job); same
MINOR bump level, next free slot per bin/gstack-next-version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): per-shard CHROMIUM_PROFILE — the collision class duration packing exposed

Nine test files launch in-process persistent contexts or daemons that
default to the SHARED ~/.gstack/chromium-profile. Two concurrent shard
processes on one profile dir kill each other's browser — observed live
on CI once duration packing recomposed shards: handoff's
launchPersistentContext died 'Target page, context or browser has been
closed' (--user-data-dir=~/.gstack/chromium-profile in the call log)
while a sibling shard's daemon logged 'Chromium process crashed'. Hash
sharding had masked the collision by chance placement; handoff passes
standalone everywhere.

Fix at the runner, not per file: each shard child gets
CHROMIUM_PROFILE=<shard-state>/chromium-profile (the documented env
knob, same isolation idea as the existing per-shard TMPDIR). Files
within a shard run serially, so sharing the per-shard profile is safe;
config.test's resolution-order tests save/restore the env around their
assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): landscape --toc gate asserts promotion PRESENCE, not counts

Two rounds of CI receipts: the exact toBe(3) was font-metric-coupled
(3 on Amazon Linux, 2 on ubuntu), and the baseline-comparison repair
then failed 2-vs-3 across renders SECONDS apart in one CI job while the
sibling no-toc test saw 3 — per-render image-promotion timing makes any
count assertion here a coin flip. The sibling test owns exact promotion
counts; this test's actual invariant is that --toc does not break the
promotion machinery: >=1 landscape page + the TOC rendered. Also drops
the second render (halves the test's runtime).

Flaky per-render image promotion itself is worth its own look — noted
in TODOS with these receipts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(TODOS): file the per-render image-promotion nondeterminism (receipts from PR #2721)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): per-FILE Chromium profiles for the nine in-process launcher files

Completes the profile-isolation work: the per-shard CHROMIUM_PROFILE
stopped cross-shard kills; these nine files launch in-process
persistent contexts and could still collide with a lingering daemon a
sibling file spawned on the SAME shard profile. Each now scopes a
mkdtemp profile via beforeAll/afterAll (the module-scope-tripwire-safe
pattern), cleaned up per file. All nine green solo and in combined
runs, except the pre-existing commands+snapshot pairing — proven
identical WITH and WITHOUT these edits (baseline receipts) — which is
the daemon-lifecycle follow-up now extended in TODOS with this
session's receipts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(browse): Chromium-crash exit is daemon-only — embedded launches never kill their host

handleChromiumDisconnect unconditionally process.exit()ed. Correct for
the standalone daemon (its supervisor/user must notice); suicidal when
a TEST launches BrowserManager in-process: a mid-suite Chromium death
exited the whole bun shard with no terminal summary — the exact
truncation class the strict runner flags (observed live: CI shard 1 on
eb233299 died at cache-concurrent-refresh right after a daemon-spawning
gate test; with this fix the same pairing runs to completion and
REPORTS instead of dying).

The standalone entrypoint opts in via markDaemonProcess() under
server.ts's import.meta.main gate — the same embedder contract its
signal handlers already use (gbrowser phoenix keeps its own handlers).
Embedded contexts now get the disconnect log line and continue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): context-restore assertion is evidence-based, not prose-matching

The test failed twice per run in TWO CI cycles while passing locally
4/4: the prompt said 'present the content' and the check grepped the
FINAL message for exact phrases — local runs quoted the file, CI runs
paraphrased ('the most recent context is from branch-b...') and the
substring check lost the coin flip.

- prompt now demands machine-checkable output: the newest file's
  '## Working on:' heading VERBATIM + a literal 'RESTORED: <filename>'
  marker (the mtime-scramble and cross-branch subject matter untouched)
- assertion ordered strongest-first: RESTORED marker → legacy content
  phrases → tool-call corroboration (Read/Bash input naming the newer
  file, credited ONLY when the older file was never read — a
  both-files run must still present the right one)
- the older-file negative got STRONGER: an explicit RESTORED marker
  naming the older file fails even if wintermute words appear elsewhere
- sibling scan: context-recovery-artifacts got the additive prompt-side
  treatment only (quote the matched literals verbatim); its lenient
  1-of-6 assertion deliberately unchanged

3/3 consecutive local green with all evidence classes firing
(marker=true, content=true, toolNewer=true, toolOlder=false).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): skillify family — HOME==cwd broke project-skill registration

Root cause (forensically pinned from stream-json init events + a
kill-after-init probe): with HOME set EQUAL to the child's cwd, claude
resolves <cwd>/.claude/skills as the PERSONAL skills directory and the
seeded project-tier skills never register — the Skill tool returned
'Unknown skill'. The provenance-refusal test then improvised a refusal
whose wording missed the regex (the deterministic CI+local red); the
happy-path and approval-reject siblings passed only because their
agents self-recovered by Reading SKILL.md manually — silently not
exercising the Skill-tool path at all.

All three tests now use HOME=<workDir>/home (a fresh subdir keeps the
override's intent: child ~/.gstack writes land in the assertable
sandbox, without the cwd collision). Refusal test additionally: a
'not registered/unknown skill' tripwire (a not-loaded skill can never
pass as a refusal) and the refusal regex now matches assistant text
only — the skill BODY echoed into the transcript contains the exact
refusal message, so the old full-surface match could pass vacuously
once the skill loaded. Sibling disk assertions sweep both $HOME/.gstack
and cwd .gstack roots (positives and negatives).

Verified paid: refusal 2x consecutive green with the skill's EXACT
message rendered ('Launching skill: skillify' in-transcript), then the
full file 5/5 green (~$1.35) with both siblings driving real Skill
calls (25-27 turns each).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(TODOS): two of three first-execution findings fixed (skillify family, context-restore)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): context-restore gets a private home — the REAL root cause was fixture sharing

The evidence-based assertion fix was treating a symptom. The slice
artifact's embedded transcript showed the CI agent restoring
20260829-context-save-skill-test.md — the checkpoint the SIBLING
context-save test wrote into the SHARED gstackHome checkpoints dir,
which by filename-prefix ordering genuinely IS the newest. The agent
behaved CORRECTLY; the test's fixture set was open to concurrent
sibling writes, and bun --concurrent ordering differs between CI (save
finished first) and local (restore listed first) — the entire
local-green/CI-red split explained.

The restore test now uses its own .gstack-restore-home (the whole home
moves, not just the handed path — an agent deriving the dir from
GSTACK_HOME/projects/<slug> must land in the closed set too). Full file
4/4 paid green with all evidence flags firing.

Also: the on-failure shard-log artifact glob uploaded nothing — the
Fix-bun-temp step points TMPDIR at /home/runner/.cache, so the spool
lands there, not /tmp. Both eval workflows now glob both locations
(this gap is why diagnosing THIS failure required digging transcripts
out of the slice-results artifact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(evidence): carry the real index mtime onto gstack-wtree's temp copy

The stat-cache seed (cp of the real index) stamped the temp index "now",
which defeats git's racy-git protection: an entry is only re-hashed when
its cached mtime is not older than the index file itself, so a same-size
rewrite landing in the same second as the last real index write looked
non-racy, kept its stale stat-cache entry, and vanished from the
fingerprint — evidence stayed FRESH after a source change. This is the
CI flake in test/evidence.test.ts "allow-paths carve-out" (sub-second
alignment on fast runners: expected STALE exit 1, got FRESH exit 0).

touch -r restores the original index timestamp, reinstating the exact
racy window git itself uses. Deterministic regression pin in
test/review-log.test.ts reproduces the miss with pinned zero-nsec
timestamps (fails on the old script, passes now); receipts: manual
probe shows the fresh-stamped copy returning the clean tree for a
same-size 'hello'→'howdy' rewrite while the mtime-carried copy detects
it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): landscape gate bounds the promotion count instead of pinning 3

The alt-hinted image promotion rides the per-render measurement race
already filed in TODOS (2-vs-3 landscape pages on renders seconds
apart — CI receipts from PR #2721, now reproduced locally). Pin the
two deterministic promotions as the floor and the three promotable
blocks as the ceiling (anything above 3 means the veto leaked); the
veto/portrait assertions remain exact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Test <test@test.com>
2026-08-29 09:06:54 -07:00

1444 lines
66 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bun
/**
* test-free-shards — enumerate, shard, curate, and run the free test suite.
*
* Four jobs:
* 1. Enumeration. Walk `browse/test/`, `test/`, `make-pdf/test/` and return
* every `*.test.{ts,tsx,js,jsx,mjs,cjs}` that isn't a paid-eval test.
* 2. Sharding. Stable-hash assign each test to one of N shards. Used by CI
* to parallelize the free suite when needed.
* 3. Curation (Windows-safe filter). Scan each test's content for POSIX-only
* patterns (`/bin/bash`, `sh -c`, raw `/tmp/`, `chmod`, `xargs`). Files
* that match are excluded from the Windows-safe subset — they would fail
* on `windows-latest` no matter how the runner shards them.
* 4. Execution. Spawn `bun test` children and refuse to trust their exit
* code alone: every byte of output is classified through
* scripts/test-strict-output.ts, so a child that exits 0 without bun's
* terminal summary (a mid-suite process.exit truncation), with `(fail)`
* result lines, or with fewer files run than planned is a FAILURE. An
* external wall-clock timeout SIGKILLs the child's process group and
* reports the shard as timed-out — distinct from failed.
*
* Execution strategy (decision ledger V3/D6 — evaluate the Bun built-in
* first; probed 2026-08 on Bun 1.3.13):
* - Full-suite runs (`bun test` via package.json, `bun run test:free`) use
* N CONCURRENT SHARD PROCESSES, serial within each (the paid runner's
* model). A single `--parallel` invocation was probed and initially
* adopted, then abandoned: three distinct Bun 1.3.13 worker pathologies
* (segfault + crash-retry wedge, skipped-file hooks stalling a worker,
* spawn-heavy files hanging under load) each stalled the whole
* invocation, while process shards isolate any wedge to its own shard.
* Original --parallel probe results, kept for the record: it
* showed --parallel (a) prints the standard `Ran N tests across M files`
* terminal summary, (b) exits non-zero when any file fails, (c) runs each
* file in its own worker process (distinct pids, no shared globals), and
* (d) converts a mid-suite process.exit(0) — which silently truncates a
* serial run at exit 0 — into a per-file `(crashed: exited)` failure with
* a complete summary and exit 1. Strictly SAFER than the serial path and
* ~2x faster on a 6-file probe (0.22s -> 0.11s wall, 280% CPU); the win
* grows with suite size since the serial suite measured 454s.
* - CI-matrix runs (`--shards M --shard i`) keep the hash-partitioned
* one-child-per-shard path. Cross-runner partitioning must be
* deterministic and per-file stable, so bun's own `--shard=M/N`
* (round-robin over sorted paths — every assignment shifts when a file
* lands) is not used, and there are no static per-file weight lists.
* Shard indices are STABLE: assignFilesToShards never renumbers on
* occupancy, and an empty shard is a fast no-op success.
*
* Adapted from the McGluut/gstack fork's test-free-shards.ts (190 LOC). The
* Windows-safe filter is upstream-original — codex flagged that sharding alone
* doesn't fix POSIX-bound tests, so we curate the subset that actually runs
* on the windows-latest CI job.
*
* Output contract (v1.66): the full child stream ALWAYS lands in a per-run
* log file under os.tmpdir() (path printed once at start and again in the
* epilogue). The console is quiet by default — only the runner's own
* [test:free] lines, `(fail)` result lines, bun error/crash markers
* (`error:`, `panic:`, `crashed`, `Unhandled error`), and the terminal
* `Ran N tests across M files` summary reach it; `--verbose` restores full
* forwarding. After every run a stable epilogue names the failing tests
* (attributed to files via bun's `path/to/file.test.ts:` chunk headers),
* crashed+retried workers, and — on a wall-timeout kill — the wedge-suspect
* files. The strict classifier consumes the FULL stream regardless of what
* the console shows.
*
* Exit codes: 0 pass, 1 fail, 124 wall-clock timeout.
*
* Usage:
* bun run scripts/test-free-shards.ts # full suite, N concurrent shard processes
* bun run scripts/test-free-shards.ts --list # show all
* bun run scripts/test-free-shards.ts --windows-only --list # show curated
* bun run scripts/test-free-shards.ts --windows-only # run curated
* bun run scripts/test-free-shards.ts --shards 4 --shard 1 # one shard (CI matrix)
* bun run scripts/test-free-shards.ts --wall-timeout 600 # override the kill deadline
* bun run scripts/test-free-shards.ts --verbose # forward the full child stream
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawn, spawnSync } from 'child_process';
import { StringDecoder } from 'node:string_decoder';
import { isPaidTestFile } from '../test/helpers/paid-test-set';
import {
BunTestOutputClassifier,
exactTestFileSelectors,
installChildSignalForwarding,
isTerminationRequested,
killProcessGroup,
strictTestExitCode,
stripAnsiLine,
} from './test-strict-output';
const ROOT = path.resolve(import.meta.dir, '..');
// design/test was silently absent from BOTH the package.json test script and
// this list — design tests (including a teardown bomb) never ran in any CI
// or local free run. Keep the two lists in sync. This list is the single
// source of truth for free-suite roots: package.json's `test` script routes
// through this runner rather than passing its own directory globs.
export const TEST_ROOTS = [
'browse/test',
'test',
'make-pdf/test',
'design/test',
// v1.65 orphan wire-in (decision D3a): these ran under NO script or CI —
// written coverage that caught nothing. All were green on arrival.
'ios-qa/daemon/test',
'ios-qa/scripts',
'browser-skills',
] as const;
const TEST_FILE_REGEX = /\.test\.(?:[cm]?[jt]s|tsx|jsx)$/;
// POSIX-only patterns that indicate a test will fail on windows-latest no
// matter how the runner shards. Codex's v1.18.0.0 review flagged the first
// three as concrete examples in the existing free suite (test/ship-version-sync.test.ts:72,
// test/helpers/providers/claude.ts:22, package.json:12). We scan the test's
// own content here so the filter stays automatic as new tests land. The
// "Windows-incompatible APIs" patterns at the bottom were added after the
// first windows-free-tests CI run surfaced concrete failure modes.
const WINDOWS_FRAGILE_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [
// Hardcoded POSIX shells / commands.
{ pattern: /['"`]\/bin\/(?:ba)?sh/, reason: 'hardcoded /bin/sh or /bin/bash' },
{ pattern: /spawnSync\(['"]sh['"],|spawn\(['"]sh['"],|exec\(['"]sh /, reason: 'spawn("sh", ...)' },
{ pattern: /['"]bash -c['"]|['"]sh -c['"]/, reason: 'bash -c / sh -c' },
{ pattern: /['"`]\/tmp\//, reason: 'raw /tmp/ path (use os.tmpdir())' },
{ pattern: /['"]chmod\b/, reason: 'chmod shell command' },
{ pattern: /['"]xargs\b/, reason: 'xargs pipeline' },
{ pattern: /\bwhich claude\b/, reason: 'which claude (use Bun.which)' },
// Windows-incompatible APIs.
{ pattern: /\.mode\s*&\s*0o[0-7]+/, reason: 'POSIX file mode bitmask (mode & 0o600 etc — Windows fakes mode bits)' },
{ pattern: /\.endsWith\(['"]\//, reason: 'hardcoded forward-slash path assertion (Windows uses \\\\)' },
{ pattern: /['"]\.\/[a-zA-Z][^"']*['"]\)\s*\.\s*toBe\(true\)/, reason: 'forward-slash path comparison' },
// Tests that spawn a bash shebang script in bin/ via spawnSync. Git Bash on
// Windows can run `bash /path/to/script` but spawnSync(scriptPath, ...)
// tries to execute the file directly via CreateProcess, which fails on the
// shebang. The pattern matches `, 'bin'` as a path-join argument (closing
// OR followed by another segment), which catches:
// - path.join(ROOT, 'bin', 'script-name') — typical
// - join(import.meta.dir, '..', 'bin', 'name') — destructured (diff-scope)
// - path.join(ROOT, 'bin') — bare BIN constant (brain-sync)
{ pattern: /,\s*['"]bin['"]\s*[,)]|['"]\.?\/?bin\/[a-z][\w-]+['"]/, reason: 'spawns bin/ shebang script (Windows CreateProcess does not parse shebangs)' },
// Tests that launch a real Playwright browser. The windows-free-tests CI job
// runs a curated subset that intentionally does NOT install Chromium —
// browser bring-up on Windows is a separate concern (see PR #1238). Tests
// matching `await foo.launch(` need Chromium and fail with "Executable
// doesn't exist" on the runner.
{ pattern: /await\s+\w+\.launch\(/, reason: 'launches Playwright browser (Chromium not installed in windows-free CI)' },
// Tests that spawn the browse server as a subprocess via `bun run server.ts`.
// The Bun → server.ts → Playwright path is the same one that doesn't work
// on Windows (PR #1238 windows-pty-bun-pty-fix). Tests typically set
// BROWSE_HEADLESS_SKIP=1 to skip the browser launch but still need a working
// server, which they don't get on Windows.
{ pattern: /BROWSE_HEADLESS_SKIP|spawn\(\[['"]bun['"],\s*['"]run['"]/, reason: 'spawns the browse server subprocess (Bun-driven path is Windows-broken)' },
];
// Explicit known-Windows-incompatible test files that don't fit a regex
// pattern. Listed here with the precise reason. Prefer adding a pattern above
// when possible; this list is for environment-/runtime-specific tests where
// the failure mode is structural rather than detectable via source-file scan.
export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> = [
{
file: 'test/host-config.test.ts',
reason: 'asserts "claude" binary on PATH (only true when running inside Claude Code, not on bare CI runner)',
},
{
file: 'browse/test/findport.test.ts',
reason: 'asserts Bun.serve.stop() is fire-and-forget — Bun behavior differs on Windows for this polyfill',
},
// First full run of the expanded lane (v1.66, 13 → ~258 files) surfaced
// seven POSIX-bound files the content patterns cannot see (their
// POSIX-ness is what they TEST, or arrives via a variable). Receipts:
// PR #2593 windows-free-tests run 31918591602.
{
file: 'test/codex-under-codex-detection.test.ts',
reason: 'drives the rendered preflight bash under a hardcoded POSIX PATH (/usr/bin:/bin) — bash is unreachable through that PATH on Windows, so every case sees empty output (v1.67 windows lane run 95234224148)',
},
{
file: 'test/regression-pr1169-build-app-sed.test.ts',
reason: 'tests sed escape sequences in build-app.sh — sed/bash are the subject under test',
},
{
file: 'test/setup-conductor-worktree.test.ts',
reason: 'tests ln -snf symlink semantics in the setup script — POSIX ln is the subject under test',
},
{
file: 'test/artifacts-init-migration.test.ts',
reason: 'runs a bash migration script + jq against a scaffolded git state — POSIX toolchain paths break under cmd spawn',
},
{
file: 'test/gstack-decision-semantic.test.ts',
reason: 'installs a fake gbrain SHEBANG SHIM on PATH; Windows spawn cannot exec shebang scripts',
},
{
file: 'test/question-log-hook.test.ts',
reason: 'spawns the PostToolUse hook script (bash shebang) directly; Windows spawn cannot exec it',
},
{
file: 'browse/test/browser-skills-e2e.test.ts',
reason: 'asserts forward-slash tier paths (<repo>/browser-skills/) that resolve with backslashes on Windows',
},
{
file: 'design/test/variants-retry-after.test.ts',
reason: 'wall-clock retry-timing assertions — flaky on the slow windows-latest runner even with widened bounds',
},
// Round-2 census (PR #2593 run 31919227507) after the first seven:
{
file: 'test/skill-census.test.ts',
reason: 'census walk throws at module load on Windows (skill-census.ts:63) — the skills-tree symlink layout needs Developer Mode that CI runners lack',
},
{
file: 'browse/test/browser-manager-unit.test.ts',
reason: 'wedges the shard to its wall deadline on windows-latest (in-flight at kill); needs a Windows repro to diagnose — macOS + Linux lanes cover the file',
},
// Round-3 census (PR #2593 run 31919871680): the round-2 wedge had been
// TRUNCATING its shard, so these seven only surfaced once shard 2 completed.
// All the same POSIX-environment classes: PID/cmdline identity probing,
// bash scripts as the subject under test, env-scrubbed child spawns.
{
file: 'browse/test/server-embedder-terminal-port.test.ts',
reason: 'identity-based terminal-agent kill probes PID/cmdline with POSIX semantics; teardown asserts fail on windows-latest',
},
{
file: 'design/test/daemon-discovery.test.ts',
reason: 'verifyIdentity matches a spawned daemon via /proc-style cmdline probing — POSIX identity semantics',
},
{
file: 'test/context-save-hardening.test.ts',
reason: 'bash context-save/migration scripts (HOME-unset semantics, random-suffix path) are the subject under test',
},
{
file: 'test/eval-list-cli.test.ts',
reason: 'spawns the eval:list CLI via bun with a constructed env — bun resolution fails under Windows spawn',
},
{
file: 'test/memory-cache-injection.test.ts',
reason: 'exercises hook/deny-enforcement shell scripts — POSIX toolchain is the subject under test',
},
{
file: 'test/migrations-v1.65.0.0.test.ts',
reason: 'bash migration script (bunx re-fetch, .done markers) is the subject under test',
},
{
file: 'test/question-preference-hook.test.ts',
reason: 'spawns the PreToolUse preference hook (shebang script) directly; Windows spawn cannot exec it',
},
// Round-4 census (PR #2593 run 31920052810): unhandled errors with no
// (fail) lines — attributed statically (the lane had no log artifact yet).
{
file: 'browse/test/browser-skill-commands.test.ts',
reason: 'spawnSkill spawns bun with a constructed env — bun resolution fails under Windows spawn (unhandled, no (fail) line)',
},
{
file: 'browse/test/security-audit-r2.test.ts',
reason: 'symlink-attack fixtures (evil-link) need Developer Mode CI runners lack; expect(toThrow) fires unhandled on Windows',
},
];
// Force-include overrides: files a WINDOWS_FRAGILE_PATTERNS regex excludes for
// a reason that does not actually apply to them. Each entry documents WHY the
// pattern hit is a false positive — the point of these files is Windows
// coverage, so auto-excluding them defeats the regression tests they carry.
const KNOWN_WINDOWS_SAFE: Array<{ file: string; reason: string }> = [
{
file: 'test/setup-windows-rerun-refresh.test.ts',
// Trips the "spawns bin/ shebang script" pattern via path.join(..., 'bin',
// 'tool.sh') fixture paths, but every spawn goes through spawnSync('bash',
// ['-c', ...]) — Git Bash executes it fine on windows-latest. This file IS
// the #2444 Windows regression coverage (IS_WINDOWS=1 copy-refresh path);
// excluding it here would keep the bug class unexercised on the one
// platform it bites.
reason: 'bin/ hits are fixture path segments; spawns bash explicitly — the IS_WINDOWS=1 refresh path must run on windows-latest',
},
{
file: 'test/uninstall-windows-copies.test.ts',
// Trips the "spawns bin/ shebang script" pattern via the
// path.join(ROOT, 'bin', 'gstack-uninstall') constant, but the script is
// always spawned through spawnSync('bash', [UNINSTALL, ...]). This file
// carries the #2563 Windows real-dir-copy uninstall coverage — the bug
// ONLY reproduces on the copy install shape windows-latest exercises.
// The symlink-shape describe block self-skips on win32.
reason: 'bin/ hit is a bash-spawned script path; #2563 real-dir uninstall coverage must run on windows-latest',
},
{
file: 'browse/test/file-permissions.test.ts',
// Trips the POSIX-mode-bitmask pattern, but every `mode & 0o777` assertion
// is platform-guarded: win32-only tests return early, POSIX-only tests
// guard the bitmask behind `process.platform !== 'win32'`, and the
// symlink-skip regression test both wraps symlinkSync in try/catch
// (runners without Developer Mode can't create symlinks) and guards its
// bitmask — on win32 it asserts behavior (warns, skips, doesn't throw,
// target stays usable), never fake Windows mode bits (dirs stat 0o777
// there, so a 0o755 expectation fails on runner semantics, not our code).
// This file carries the win32-only icacls-by-SID regression tests, which
// can ONLY execute on windows-latest — excluding it here means the
// machine-account ACL lockout regression is never exercised on the one
// platform it bricks.
reason: 'every mode-bitmask assertion is guarded off win32 (behavior asserted instead); win32-only ACL regression tests must run on windows-latest',
},
{
file: 'browse/test/terminal-agent-owner-watchdog.test.ts',
// Trips the spawn(['bun','run',...]) pattern, whose reason is the
// Playwright-bound browse server. This test spawns terminal-agent.ts,
// which imports only fs/path/crypto + local helpers (no Playwright, no
// PTY at module scope) and boots under Bun on Windows — the owner-PID
// orphan leak it pins was reported on Windows (#2019).
reason: 'spawns terminal-agent (no Playwright), not the browse server; owner-orphan leak is a Windows defect',
},
];
export const DEFAULT_SHARD_COUNT = 20;
// Per-test timeout passed to `bun test --timeout`. 30s matches what
// package.json's `test` script used before it was repointed at this runner —
// the runner is now the single owner of that semantic.
export const FREE_TEST_TIMEOUT_MS = 30_000;
// External wall-clock deadline per spawned child (whole shard or the single
// full-suite --parallel invocation). A wedged child — a spinning main thread
// no in-process --timeout timer can interrupt — is SIGKILLed at the group
// level and reported 'timed-out', distinct from 'failed'.
// ~3.5x the observed full-suite wall (~100-160s). A wedged run should be
// killed-and-diagnosed (the epilogue prints the in-flight suspects) in
// minutes, not sat out — 15min of silence was pure diagnosis latency.
// Override per run with --wall-timeout <secs>.
export const DEFAULT_WALL_TIMEOUT_MS = 6 * 60_000;
/**
* Full-suite shards scale their wall deadline with shard size:
* max(DEFAULT_WALL_TIMEOUT_MS, files × PER_FILE_WALL_MS). The 6-min floor
* keeps wedge diagnosis fast on a typical ~70-file local shard, while a
* low-core machine (jobs=1 → the whole suite in one shard) or the Windows
* lane (~130 files/shard) gets proportional headroom instead of a false
* timed-out kill of a healthy run. Explicit --wall-timeout disables scaling.
*/
export const PER_FILE_WALL_MS = 5_000;
export function wallTimeoutForShard(fileCount: number, baseMs = DEFAULT_WALL_TIMEOUT_MS): number {
return Math.max(baseMs, fileCount * PER_FILE_WALL_MS);
}
/**
* Wall for a duration-packed shard. The count heuristic above assumes count
* approximates cost; LPT packing breaks that BY DESIGN (a shard may hold six
* slow Playwright files), so packed shards get max(base, predicted x 3) —
* generous against seed drift, still bounded.
*/
export function wallTimeoutForPackedShard(predictedMs: number, baseMs = DEFAULT_WALL_TIMEOUT_MS): number {
return Math.max(baseMs, Math.ceil(predictedMs * 3));
}
/**
* Full-suite parallelism: leave RESERVED_CPUS cores for the parent runner +
* OS, cap at MAX_FULL_SUITE_JOBS — beyond ~6 concurrent bun processes the
* playwright-heavy shards contend on browser launches instead of finishing
* sooner (measured on an M-series dev box).
*/
export const MAX_FULL_SUITE_JOBS = 6;
export const RESERVED_CPUS = 2;
/**
* Files that crash or wedge Bun's --parallel WORKERS but run fine in a plain
* serial process. Full-suite mode now uses shard PROCESSES (no workers), so
* this list is inert placement-wise — retained as the paper trail of why the
* one-invocation --parallel strategy was abandoned, and as the exclusion list
* should anyone re-attempt it on a newer Bun.
*/
export const WORKER_HOSTILE: Record<string, string> = {
'browse/test/security-live-playwright.test.ts':
'Bun 1.3.13 segfaults running this file in a --parallel worker ("panic: '
+ 'Segmentation fault ... a bug in Bun"), and the crashed-worker retry then '
+ 'wedges the whole invocation past the wall clock. Passes serially.',
};
/**
* TREE-SERIAL files: run in ONE serial shard AFTER the parallel shards.
* EMPTY since the 2026-08 dissolution — kept as a mechanism, not a museum:
* a test that must regenerate shared repo artifacts IN PLACE (and cannot
* render into an out-dir instead) earns an entry here with a reason, and
* the runner will serialize it again.
*
* How it emptied: gen-skill-docs gained a main() guard (imports stopped
* regenerating 71 files at load) and --out-dir grew to every host, so all
* eight mutators now render into mkdtemps — the live tree is never written
* by the suite (pinned by gen-skill-docs-import-purity + each migrated
* file's own porcelain/mtime assertions). With zero mutators, the four
* ratchet READERS (parity caps, size budgets, carve parity/ordering) get a
* quiet tree by construction in any shard, so they rejoined the parallel
* phase — the ~35-40s serial tail on every full-suite run is gone.
* Keys are pinned against the live file census by test-free-shards.test.ts —
* a renamed file fails the suite instead of silently dropping serialization.
*/
export const TREE_MUTATING: Record<string, string> = {};
export function normalizeRelativePath(filePath: string): string {
return filePath.replace(/\\/g, '/');
}
export function isFreeTestFile(relativePath: string): boolean {
const normalized = normalizeRelativePath(relativePath);
if (!TEST_FILE_REGEX.test(normalized)) return false;
return !isPaidTestFile(normalized);
}
/**
* Returns the first POSIX-only pattern hit in the file, or null if Windows-safe.
*/
export function detectWindowsFragility(absolutePath: string): { reason: string } | null {
let content: string;
try {
content = fs.readFileSync(absolutePath, 'utf-8');
} catch {
return null;
}
for (const { pattern, reason } of WINDOWS_FRAGILE_PATTERNS) {
if (pattern.test(content)) return { reason };
}
return null;
}
function walkTestFiles(dirPath: string): string[] {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
files.push(...walkTestFiles(fullPath));
continue;
}
if (TEST_FILE_REGEX.test(entry.name)) {
files.push(fullPath);
}
}
return files;
}
export function collectFreeTestFiles(rootDir = ROOT): string[] {
const discovered = new Set<string>();
for (const testRoot of TEST_ROOTS) {
const absoluteRoot = path.join(rootDir, testRoot);
if (!fs.existsSync(absoluteRoot)) continue;
for (const fullPath of walkTestFiles(absoluteRoot)) {
const relativePath = normalizeRelativePath(path.relative(rootDir, fullPath));
if (isFreeTestFile(relativePath)) {
discovered.add(relativePath);
}
}
}
return [...discovered].sort();
}
export interface CurationResult {
safe: string[];
excluded: Array<{ file: string; reason: string }>;
}
export function curateWindowsSafe(files: string[], rootDir = ROOT): CurationResult {
const safe: string[] = [];
const excluded: Array<{ file: string; reason: string }> = [];
const knownBad = new Map(KNOWN_WINDOWS_INCOMPATIBLE.map((e) => [e.file, e.reason]));
const knownSafe = new Set(KNOWN_WINDOWS_SAFE.map((e) => e.file));
for (const relativePath of files) {
const knownReason = knownBad.get(relativePath);
if (knownReason) {
excluded.push({ file: relativePath, reason: knownReason });
continue;
}
if (knownSafe.has(relativePath)) {
safe.push(relativePath);
continue;
}
const absolute = path.join(rootDir, relativePath);
const fragility = detectWindowsFragility(absolute);
if (fragility) {
excluded.push({ file: relativePath, reason: fragility.reason });
} else {
safe.push(relativePath);
}
}
return { safe, excluded };
}
export function stableHash(input: string): number {
let hash = 0x811c9dc5;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
/**
* Hash-partition files across EXACTLY shardCount shards. Empty shards are
* preserved: a file's shard index is a pure function of its own path and the
* shard count, never of which other files happen to exist. A CI matrix keys
* runners off the index, so filtering empty shards (the old behavior) would
* renumber every later shard whenever occupancy shifted — runner 3 silently
* running shard 4's files. An empty shard is instead a fast no-op success at
* run time.
*/
export function assignFilesToShards(files: string[], shardCount: number): string[][] {
if (!Number.isInteger(shardCount) || shardCount <= 0) {
throw new Error(`Shard count must be a positive integer. Received: ${shardCount}`);
}
const shards = Array.from({ length: shardCount }, () => [] as string[]);
for (const file of files) {
const shardIndex = stableHash(file) % shardCount;
shards[shardIndex].push(file);
}
return shards.map(filesInShard => filesInShard.sort());
}
// ─── Duration-aware packing (full-suite path ONLY) ─────────────────────────
// Hash sharding balances file COUNTS (~1.15x spread) but not cost: the 15
// Playwright-launching files land 4/3/4/1/2/1 across 6 shards, giving a
// measured 28s97s shard spread and ~40s of idle tail on every run. LPT
// packing over recorded per-file durations reclaims most of it. The `--shard`
// CI-matrix path is deliberately untouched — its contract is stable indices
// via assignFilesToShards/stableHash (empty shards no-op; see above).
//
// One store, no overlay: durations come from the committed seed
// (scripts/free-test-durations.json), refreshed occasionally via
// `--record-durations` (each file timed in its own child — exact, and immune
// to bun's stream buffering, where silent passers print no header to
// timestamp). GSTACK_FREE_TEST_DURATIONS overrides the path for experiments.
// The seed is a HINT, not a contract: missing file → hash-shard fallback;
// unknown file → 75th-percentile pessimism (placed early by LPT, bounding
// tail risk). Successor note: bun ≥1.3.14 ships native --timings/--shard LPT
// scheduling — when the repo unpins 1.3.13, this packer is the code to
// replace (keep it swappable).
export const FREE_TEST_DURATIONS_FILE = 'scripts/free-test-durations.json';
export function loadFreeTestDurations(rootDir = ROOT): Record<string, number> | null {
const file = process.env.GSTACK_FREE_TEST_DURATIONS
?? path.join(rootDir, FREE_TEST_DURATIONS_FILE);
let raw: string;
try {
raw = fs.readFileSync(file, 'utf-8');
} catch {
return null; // no seed — hash sharding, silently (fresh checkouts are normal)
}
try {
const parsed = JSON.parse(raw) as { durations?: Record<string, unknown> };
const entries = Object.entries(parsed.durations ?? {})
.filter((entry): entry is [string, number] =>
typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] >= 0);
if (entries.length === 0) return null;
return Object.fromEntries(entries);
} catch (error) {
// A corrupt seed (bad merge) must cost a warning, never the suite.
console.error(`[test:free] WARNING: corrupt durations seed ${file} (${(error as Error).message}) — falling back to hash sharding`);
return null;
}
}
export interface PackedShards {
shards: string[][];
/** Predicted total per shard, aligned with `shards` — feeds walls + logs. */
predictedMs: number[];
}
/**
* Longest-processing-time-first bin packing: files sorted by predicted
* duration (desc, path-stable tiebreak) each go to the currently-lightest
* shard. Deterministic for a given (files, shardCount, durations).
*/
export function packShardsByDuration(
files: string[],
shardCount: number,
durations: Record<string, number>,
): PackedShards {
if (!Number.isInteger(shardCount) || shardCount <= 0) {
throw new Error(`Shard count must be a positive integer. Received: ${shardCount}`);
}
const known = files
.map((f) => durations[normalizeRelativePath(f)])
.filter((v): v is number => typeof v === 'number')
.sort((a, b) => a - b);
// Unknown files get the 75th percentile of known durations: pessimistic, so
// LPT places them early and a surprise long-runner can't recreate the tail.
const fallback = known.length > 0 ? known[Math.min(known.length - 1, Math.floor(known.length * 0.75))] : 1;
const predicted = (f: string): number => durations[normalizeRelativePath(f)] ?? fallback;
const ordered = [...files].sort((a, b) => predicted(b) - predicted(a) || (a < b ? -1 : 1));
const shards = Array.from({ length: shardCount }, () => [] as string[]);
const loads = new Array<number>(shardCount).fill(0);
for (const file of ordered) {
let lightest = 0;
for (let i = 1; i < shardCount; i += 1) {
if (loads[i] < loads[lightest]) lightest = i;
}
shards[lightest].push(file);
loads[lightest] += predicted(file);
}
return { shards: shards.map((s) => s.sort()), predictedMs: loads };
}
export interface BuildShardArgsOptions {
/**
* Pass bun's --parallel (worker-per-file, implies --isolate). No production
* caller today — full-suite mode uses N shard PROCESSES after the worker
* pathologies documented in main(); retained for a future re-attempt on a
* newer Bun (see WORKER_HOSTILE).
*/
parallel?: boolean;
rootDir?: string;
}
export function buildShardArgs(files: string[], options: BuildShardArgsOptions = {}): string[] {
// Exact absolute selectors: bun treats positional test paths as substring
// filters, so a relative `test/x.test.ts` would ALSO select
// `browse/test/x.test.ts` — shard bleed that double-runs files.
const selectors = exactTestFileSelectors(files, options.rootDir ?? ROOT);
const args = ['test', ...selectors, `--timeout=${FREE_TEST_TIMEOUT_MS}`];
if (options.parallel) args.push('--parallel');
else args.push('--max-concurrency=1');
return args;
}
type CliOptions = {
dryRun: boolean;
listOnly: boolean;
recordDurations: boolean;
windowsOnly: boolean;
verbose: boolean;
shardCount: number;
shardIndex: number | null;
wallTimeoutMs: number;
/** True when --wall-timeout was passed explicitly; full-suite mode only auto-scales the default. */
wallTimeoutExplicit: boolean;
};
function parseCliOptions(argv: string[]): CliOptions {
let dryRun = false;
let listOnly = false;
let recordDurations = false;
let windowsOnly = false;
let verbose = false;
let shardCount = DEFAULT_SHARD_COUNT;
let shardIndex: number | null = null;
let wallTimeoutMs = DEFAULT_WALL_TIMEOUT_MS;
let wallTimeoutExplicit = false;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--dry-run') { dryRun = true; continue; }
if (arg === '--list') { listOnly = true; continue; }
if (arg === '--record-durations') { recordDurations = true; continue; }
if (arg === '--windows-only') { windowsOnly = true; continue; }
if (arg === '--verbose') { verbose = true; continue; }
if (arg === '--shards') {
const value = argv[index + 1];
if (!value) throw new Error('Missing value for --shards');
shardCount = Number.parseInt(value, 10);
index += 1;
continue;
}
if (arg === '--shard') {
const value = argv[index + 1];
if (!value) throw new Error('Missing value for --shard');
shardIndex = Number.parseInt(value, 10);
index += 1;
continue;
}
if (arg === '--wall-timeout') {
const value = Number.parseInt(argv[index + 1] ?? '', 10);
if (!Number.isInteger(value) || value <= 0) throw new Error('--wall-timeout needs a positive integer (seconds)');
wallTimeoutMs = value * 1000;
wallTimeoutExplicit = true;
index += 1;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
return { dryRun, listOnly, recordDurations, windowsOnly, verbose, shardCount, shardIndex, wallTimeoutMs, wallTimeoutExplicit };
}
function formatShardSummary(shards: string[][]): string[] {
return shards.map((files, index) => {
const preview = files.slice(0, 3).join(', ');
const suffix = files.length > 3 ? ', ...' : '';
return `Shard ${index + 1}/${shards.length}: ${files.length} files${preview ? ` -> ${preview}${suffix}` : ''}`;
});
}
/**
* True when a shard's output shows the run ended WITHOUT bun's final summary
* ("Ran N tests across ..."). A process.exit() fired mid-suite skips the
* summary AND hands back whatever code the caller passed — historically 0,
* which made a truncated shard indistinguishable from a green one. Exit code
* alone is therefore not evidence of completion; the summary line is.
*
* The runner itself now enforces this (and more) through
* scripts/test-strict-output.ts inside runFreeShard; this predicate remains
* the minimal documented primitive that test/exit-propagation.test.ts drives
* with genuine truncated and genuine complete bun runs.
*/
export function shardRunLooksTruncated(status: number | null, output: string): boolean {
if (status !== 0) return false; // already failing — not the silent case
return !/Ran \d+ tests? across \d+ files?/.test(output);
}
// ---------------------------------------------------------------------------
// Output contract: console filtering + per-file failure attribution.
//
// Bun groups each file's output under a `path/to/file.test.ts:` header line
// (cwd-relative, sometimes ../-prefixed through a symlinked cwd). The
// reporter tracks the current header while consuming the stream, attributes
// `(fail)` lines and crash markers to files, and decides which lines reach
// the console in the default quiet mode. All matching happens on
// ANSI-stripped lines — colored `(fail)` lines defeated a prior grep.
// ---------------------------------------------------------------------------
const TEST_PATH_SOURCE = String.raw`\.test\.(?:[cm]?[jt]s|tsx|jsx)`;
/** A file chunk header: the path bun printed, terminated by a bare colon. */
const FILE_HEADER_RE = new RegExp(`^(\\S.*${TEST_PATH_SOURCE}):$`);
/** Same shape strict-output classifies as failed-test, with the name captured. */
const FAIL_RESULT_CAPTURE_RE = /^\(fail\) (.+) \[\d+(?:\.\d+)?(?:ns|us|µs|ms|s)\]$/;
/** bun --parallel retries a crashed worker once: `<icon> crashed running <path>, retrying`. */
const CRASH_RETRY_RE = new RegExp(`crashed running (\\S*${TEST_PATH_SOURCE}), retrying`);
/** The give-up marker after the retry also crashes: `✗ <path> (crashed: exited)`. */
const CRASH_FINAL_RE = new RegExp(`(\\S*${TEST_PATH_SOURCE}) \\(crashed: [^)]+\\)`);
const TERMINAL_SUMMARY_CAPTURE_RE = /^Ran (\d+) tests? across (\d+) files?\. \[/;
/** Substrings that must reach the console even in the default quiet mode. */
const CONSOLE_ALWAYS_MARKERS = ['error:', 'panic:', 'Unhandled error', 'crashed'] as const;
export type StreamOrigin = 'stdout' | 'stderr';
export interface FreeRunFailure {
/** Planned relative path when attributable, else the raw header path, else null. */
file: string | null;
testName: string;
}
export interface FreeRunReport {
testsRan: number | null;
filesRan: number | null;
sawTerminalSummary: boolean;
/** Deduped `(fail)` lines in arrival order, attributed to the current file header. */
failures: FreeRunFailure[];
/** Files that crashed a worker (bun retries once; a second crash is final). Deduped. */
crashedFiles: string[];
/**
* "# Unhandled error between tests" markers, attributed to the chunk they
* appeared in. These fail the shard via the strict classifier but produce
* NO (fail) lines — without surfacing them here, the epilogue reads
* "FAIL — 0 failing test(s)" and the culprit is undiscoverable from CI
* output (first Windows lane run: a module-load throw in skill-census).
*/
unhandledErrors: Array<{ file: string | null }>;
/**
* Wedge-suspect heuristic for a wall-timeout kill: files whose header was
* seen but whose chunk never ENDED (chunk end = the next file's header, or
* a final crash marker) before the terminal summary — i.e. "started but
* never produced a result chunk end". Result lines deliberately do NOT end
* a chunk: a file that printed a fail and then wedged stays listed. Known
* limits of the approximation:
* - Serial (--shard CI path): bun streams live but prints a file's header
* lazily, on its first output line — a wedged file that printed ANY
* line is listed; a fully silent wedge is not.
* - Parallel (full-suite path): bun buffers a file's whole chunk until it
* COMPLETES, so a wedged file usually never prints a header (see
* filesWithNoOutput), and the LAST flushed chunk before the kill has no
* closing header, so one completed noisy file can be over-listed.
*/
inFlight: string[];
/** Planned files never observed in the stream (silent passers + never-flushed wedges). */
filesWithNoOutput: number;
}
interface FileProgress {
headerSeen: boolean;
/** The file's chunk ended: a later file's header arrived, or it crashed out. */
ended: boolean;
}
/**
* Incrementally consumes the child's stdout/stderr (chunk boundaries need not
* align to lines), attributing results to files and forwarding only
* always-visible lines to `forward` (omit `forward` for verbose/quiet modes —
* attribution still runs so the epilogue works in every mode).
*/
export class FreeRunReporter {
private readonly decoders: Record<StreamOrigin, StringDecoder> = {
stdout: new StringDecoder('utf8'),
stderr: new StringDecoder('utf8'),
};
private readonly pending: Record<StreamOrigin, string> = { stdout: '', stderr: '' };
private readonly plannedSet: Set<string>;
private readonly canonicalCache = new Map<string, string>();
private readonly progress = new Map<string, FileProgress>();
private readonly failureKeys = new Set<string>();
private readonly failures: FreeRunFailure[] = [];
private readonly crashed = new Set<string>();
private currentFile: string | null = null;
private inRecap = false;
private readonly unhandled: Array<{ file: string | null }> = [];
private testsRan: number | null = null;
private filesRan: number | null = null;
private sawSummary = false;
constructor(
private readonly plannedFiles: string[],
private readonly forward?: (text: string, origin: StreamOrigin) => void,
) {
this.plannedSet = new Set(plannedFiles.map(normalizeRelativePath));
}
write(chunk: Uint8Array | string, origin: StreamOrigin): void {
this.pending[origin] += typeof chunk === 'string'
? chunk
: this.decoders[origin].write(Buffer.from(chunk));
let newline = this.pending[origin].indexOf('\n');
while (newline !== -1) {
this.handleLine(this.pending[origin].slice(0, newline), origin);
this.pending[origin] = this.pending[origin].slice(newline + 1);
newline = this.pending[origin].indexOf('\n');
}
}
/** Flush partial trailing lines (a stream killed mid-line still classifies). */
end(): void {
for (const origin of ['stdout', 'stderr'] as const) {
this.pending[origin] += this.decoders[origin].end();
if (this.pending[origin].length > 0) this.handleLine(this.pending[origin], origin);
this.pending[origin] = '';
}
}
report(): FreeRunReport {
const inFlight = this.sawSummary
? []
: [...this.progress.entries()]
.filter(([, p]) => p.headerSeen && !p.ended)
.map(([file]) => file)
.sort();
return {
testsRan: this.testsRan,
filesRan: this.filesRan,
sawTerminalSummary: this.sawSummary,
failures: [...this.failures],
crashedFiles: [...this.crashed].sort(),
unhandledErrors: [...this.unhandled],
inFlight,
filesWithNoOutput: this.plannedFiles.filter((f) => !this.progress.has(normalizeRelativePath(f))).length,
};
}
private handleLine(rawLine: string, origin: StreamOrigin): void {
// GitHub Actions: bun wraps each file's section in ::group::<header>.
// Without stripping, the real header fails FILE_HEADER_RE, failures get
// attributed to the PREVIOUS file, and the terminal recap's re-printed
// (fail) lines land under a second phantom file (observed on the first
// Linux run: 5 real failures reported as 10 across 2 files).
const line = stripAnsiLine(rawLine).replace(/^::group::/, '');
let visible = false;
// Bun's terminal recap ("N tests failed:") re-prints every (fail) line
// WITHOUT re-printing file headers. Attributing those to the stale
// currentFile invented a phantom failing file on the first Linux run
// (5 real failures reported as 10 across 2 files, one innocent).
if (/^\d+ tests? failed:$/.test(line)) {
this.inRecap = true;
if (this.currentFile) this.progressFor(this.currentFile).ended = true;
this.currentFile = null;
}
if (line === '# Unhandled error between tests') {
this.unhandled.push({ file: this.currentFile });
}
const header = FILE_HEADER_RE.exec(line);
if (header) {
const file = this.canonicalize(header[1]);
// A new header ends the previous file's chunk — that file is no longer
// a wedge suspect. (Bun 1.3.x prints NO (pass) lines, so chunk
// delimiters, not result lines, are the completion signal.)
if (this.currentFile && this.currentFile !== file) this.progressFor(this.currentFile).ended = true;
this.currentFile = file;
this.progressFor(file).headerSeen = true;
} else {
const fail = FAIL_RESULT_CAPTURE_RE.exec(line);
const retry = fail ? null : CRASH_RETRY_RE.exec(line);
const final = fail || retry ? null : CRASH_FINAL_RE.exec(line);
if (fail) {
visible = true;
// In the recap, a (fail) line only records a failure the main run
// somehow never attributed (belt and braces); known names dedupe.
const recapDuplicate = this.inRecap
&& this.failures.some((f) => f.testName === fail[1]);
const key = `${this.currentFile ?? ''}\u0000${fail[1]}`;
if (!recapDuplicate && !this.failureKeys.has(key)) {
this.failureKeys.add(key);
this.failures.push({ file: this.currentFile, testName: fail[1] });
}
} else if (retry) {
// The file will run again — a crash+retry does not end its chunk.
visible = true;
this.crashed.add(this.canonicalize(retry[1]));
} else if (final) {
visible = true;
const file = this.canonicalize(final[1]);
this.crashed.add(file);
this.progressFor(file).ended = true;
} else {
const summary = TERMINAL_SUMMARY_CAPTURE_RE.exec(line);
if (summary) {
visible = true;
this.sawSummary = true;
this.testsRan = Number.parseInt(summary[1], 10);
this.filesRan = Number.parseInt(summary[2], 10);
}
}
}
if (!visible) visible = CONSOLE_ALWAYS_MARKERS.some((marker) => line.includes(marker));
if (visible && this.forward) this.forward(`${rawLine.replace(/\r$/, '')}\n`, origin);
}
private progressFor(file: string): FileProgress {
let entry = this.progress.get(file);
if (!entry) {
entry = { headerSeen: false, ended: false };
this.progress.set(file, entry);
}
return entry;
}
/**
* Map a printed path back to its planned relative path. Bun prints paths
* relative to the child's (real)cwd, so a symlinked cwd (macOS /tmp) yields
* `../..`-prefixed forms — strip the prefix and suffix-match.
*/
private canonicalize(printedPath: string): string {
const cached = this.canonicalCache.get(printedPath);
if (cached) return cached;
const stripped = normalizeRelativePath(printedPath).replace(/^(?:\.{1,2}\/)+/, '');
let resolved = stripped;
if (!this.plannedSet.has(stripped)) {
const match = this.plannedFiles.find(
(planned) => stripped.endsWith(`/${planned}`) || planned.endsWith(`/${stripped}`),
);
if (match) resolved = match;
}
this.canonicalCache.set(printedPath, resolved);
return resolved;
}
}
/**
* The stable post-run epilogue. Success is one line; failure names every
* failing test (deduped, attributed) and crashed worker; a wall-timeout kill
* additionally prints the wedge-suspect list (see FreeRunReport.inFlight for
* the heuristic and its limits).
*/
export function buildRunEpilogue(
status: FreeShardStatus,
report: FreeRunReport,
elapsedMs: number,
logPath: string,
): string[] {
const seconds = Math.round(elapsedMs / 1000);
if (status === 'passed') {
return [
`[test:free] PASS — ${report.testsRan ?? '?'} tests, ${report.filesRan ?? '?'} files, ${seconds}s. Full log: ${logPath}`,
];
}
const failingFiles = new Set(report.failures.map((f) => f.file ?? '(unattributed)'));
const lines = [
`[test:free] FAIL — ${report.failures.length} failing test(s) in ${failingFiles.size} file(s), `
+ `${report.crashedFiles.length} crashed worker(s)${report.unhandledErrors.length > 0 ? `, ${report.unhandledErrors.length} unhandled error(s) between tests` : ''}. Full log: ${logPath}`,
];
for (const failure of report.failures) {
lines.push(` ✗ ${failure.file ?? '(unattributed)'}${failure.testName}`);
}
for (const file of report.crashedFiles) {
lines.push(` ⚠ crashed+retried: ${file}`);
}
for (const u of report.unhandledErrors) {
lines.push(` ⚠ unhandled error between tests (around ${u.file ?? 'unknown file'})`);
}
if (status === 'timed-out') {
if (report.inFlight.length > 0) {
lines.push(` ⏱ in flight at kill: ${report.inFlight.join(', ')}`);
} else {
lines.push(
' ⏱ in flight at kill: unknown — no open file chunk was observed '
+ '(bun --parallel buffers a file\'s output until it completes, so a silent wedge never prints); '
+ `${report.filesWithNoOutput} planned file(s) produced no output before the kill.`,
);
}
}
return lines;
}
export type FreeShardStatus = 'passed' | 'failed' | 'timed-out';
export interface FreeShardOutcome {
shard: number;
files: string[];
status: FreeShardStatus;
exitCode: number | null;
elapsedMs: number;
groupPid: number | null;
}
export interface ShardCommand {
command: string;
args: string[];
}
export interface RunFreeShardOptions {
/** External wall-clock deadline; on expiry the child's process GROUP is SIGKILLed. */
wallTimeoutMs?: number;
rootDir?: string;
env?: NodeJS.ProcessEnv;
/** Pass bun's --parallel. No production caller today (see BuildShardArgsOptions.parallel). */
parallel?: boolean;
/** Override the spawned command. Tests inject fake pass/fail/slow commands. */
commandFor?: (files: string[]) => ShardCommand;
/** Suppress ALL child output from the console (tests). The classifier and the log file still see every byte. */
quiet?: boolean;
/** Forward the full child stream to the console (legacy firehose). Default: the quiet filtered console. */
verbose?: boolean;
/**
* Console sink for child-stream output (tests inject to assert quiet vs
* verbose behavior). Default: process.stdout / process.stderr by origin.
* Runner-owned [test:free] lines go through `log`, not this sink.
*/
consoleWrite?: (text: string) => void;
/** Per-run full-stream log path (tests inject). Default: a timestamped file under os.tmpdir(). */
logFilePath?: string;
log?: (line: string) => void;
}
const EPILOGUE_WORD: Record<FreeShardStatus, string> = {
passed: 'pass',
failed: 'fail',
'timed-out': 'timed-out',
};
/** One line per shard, printed after the run: `[test:free] shard i/N: M files, XXs, pass|fail|timed-out`. */
function shardEpilogue(outcome: FreeShardOutcome, totalShards: number): string {
return `[test:free] shard ${outcome.shard}/${totalShards}: ${outcome.files.length} files, `
+ `${Math.round(outcome.elapsedMs / 1000)}s, ${EPILOGUE_WORD[outcome.status]}`;
}
/**
* Run one shard (or the whole suite, in --parallel full-suite mode) in its own
* bun process and classify the result strictly.
*
* Verdict integrity: the child's exit code is never trusted alone. Output is
* fed through BunTestOutputClassifier, and strictTestExitCode requires bun's
* terminal summary to report EXACTLY the planned file count — a shard that
* exits 0 without the summary (mid-suite process.exit truncation), with
* `(fail)` result lines, or having run fewer files than planned is a FAILURE.
* This is enforced for injected fake commands too (unlike the paid runner),
* so tests can pin the summary-missing => failure backstop; fake passing
* commands must print a synthetic `Ran N tests across M files. [Xms]` line.
*
* Per-shard temp isolation: each spawned child gets its own throwaway TMPDIR
* (TEMP/TMP on Windows) so shards can't trip over each other's temp files.
* Deliberately NOT GSTACK_HOME: injecting one shared scratch home for a whole
* invocation made 6,900 tests share a MUTABLE state dir — config tests wrote
* keys into it and relink/update-check tests then read them (measured: 12
* cross-contamination failures on the first full run). Tests that need
* GSTACK_HOME isolation mkdtemp their own per test — the repo convention —
* and the hermetic-env machinery covers E2E children.
*/
export async function runFreeShard(
files: string[],
shardNumber: number,
totalShards: number,
options: RunFreeShardOptions = {},
): Promise<FreeShardOutcome> {
const log = options.log ?? ((line: string) => console.log(line));
const label = `[test:free] shard ${shardNumber}/${totalShards}`;
// Empty shard = fast no-op SUCCESS. Indices are stable for the CI matrix,
// so an unoccupied index must not fail or shift work to a different runner.
if (files.length === 0) {
const outcome: FreeShardOutcome = {
shard: shardNumber, files: [], status: 'passed', exitCode: 0, elapsedMs: 0, groupPid: null,
};
log(shardEpilogue(outcome, totalShards));
return outcome;
}
const rootDir = options.rootDir ?? ROOT;
const wallTimeoutMs = options.wallTimeoutMs ?? DEFAULT_WALL_TIMEOUT_MS;
log(`${label} (${files.length} files${options.parallel ? ', bun --parallel' : ''})`);
// Full-stream capture: EVERY child byte lands here, whatever the console
// shows. Printed once at start so a wedged or noisy run is inspectable
// without a re-run.
const logPath = options.logFilePath ?? nextDefaultLogPath();
const logStream = fs.createWriteStream(logPath);
let logWriteFailed = false;
logStream.on('error', (err) => {
if (logWriteFailed) return;
logWriteFailed = true;
console.error(`${label} could not write the full log at ${logPath}: ${err.message}`);
});
log(`[test:free] full log: ${logPath}`);
const { command, args } = options.commandFor
? options.commandFor(files)
: { command: process.execPath, args: buildShardArgs(files, { parallel: options.parallel, rootDir }) };
const env = { ...(options.env ?? process.env) };
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-free-shard-'));
const childTmp = path.join(stateDir, 'tmp');
fs.mkdirSync(childTmp);
env.TMPDIR = childTmp;
env.TEMP = childTmp;
env.TMP = childTmp;
// Per-shard Chromium profile (same isolation idea as TMPDIR): nine test
// files launch in-process persistent contexts or daemons that default to
// the SHARED ~/.gstack/chromium-profile, and two concurrent shards on one
// profile dir kill each other's browser — observed live on CI once
// duration packing recomposed shards (handoff's launchPersistentContext
// died "Target page, context or browser has been closed" while a sibling
// shard's daemon logged "Chromium process crashed"). Hash sharding had
// masked the collision by chance placement. Within a shard, files run
// serially, so sharing the per-shard profile is safe; config tests that
// assert resolution order save/restore this env around their assertions.
env.CHROMIUM_PROFILE = path.join(stateDir, 'chromium-profile');
const startedAt = Date.now();
const child = spawn(command, args, {
cwd: rootDir,
env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
windowsHide: true,
});
const groupPid = child.pid ?? null;
// Group-kill on parent SIGINT/SIGTERM too, not just on timeout.
const forwarding = installChildSignalForwarding({
kill: (signal?: NodeJS.Signals | number) => {
killProcessGroup(child, (signal as NodeJS.Signals) ?? 'SIGTERM');
return true;
},
});
const classifier = new BunTestOutputClassifier();
// Console policy: quiet => nothing; verbose => the raw firehose; default =>
// only always-visible lines (fail results, crash markers, error/panic
// markers, the terminal summary), selected by the reporter. The reporter
// consumes the stream in EVERY mode so the epilogue can attribute failures.
const emitToConsole = (text: string, origin: StreamOrigin): void => {
if (options.quiet) return;
if (options.consoleWrite) {
options.consoleWrite(text);
return;
}
(origin === 'stdout' ? process.stdout : process.stderr).write(text);
};
const reporter = new FreeRunReporter(files, options.verbose ? undefined : emitToConsole);
const consumeStream = (stream: NodeJS.ReadableStream, origin: StreamOrigin): Promise<void> =>
new Promise((resolve, reject) => {
stream.on('data', (chunk: Buffer | string) => {
classifier.write(chunk, origin); // strict verdict ALWAYS sees the full stream
if (!logWriteFailed) logStream.write(chunk);
reporter.write(chunk, origin);
if (options.verbose) emitToConsole(typeof chunk === 'string' ? chunk : chunk.toString('utf8'), origin);
});
stream.on('end', resolve);
stream.on('error', reject);
});
let timedOut = false;
const killTimer = setTimeout(() => {
timedOut = true;
killProcessGroup(child, 'SIGKILL');
}, wallTimeoutMs);
let exitCode: number | null = null;
try {
const streams: Array<Promise<void>> = [];
if (child.stdout) streams.push(consumeStream(child.stdout, 'stdout'));
if (child.stderr) streams.push(consumeStream(child.stderr, 'stderr'));
exitCode = await new Promise<number | null>((resolve, reject) => {
child.once('error', reject);
child.once('close', (code) => resolve(code));
});
await Promise.all(streams);
} finally {
clearTimeout(killTimer);
forwarding.dispose();
// Reap survivors of this shard even on the clean path.
killProcessGroup(child, 'SIGKILL');
reporter.end();
await new Promise<void>((resolve) => logStream.end(() => resolve()));
try {
fs.rmSync(stateDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup of a throwaway temp dir — a locked file on
// Windows must not turn a real verdict into an exception.
}
}
const summary = classifier.end();
const status: FreeShardStatus = timedOut
? 'timed-out'
: strictTestExitCode(exitCode ?? 1, summary, files.length) === 0 ? 'passed' : 'failed';
if (status === 'timed-out') {
console.error(
`${label} exceeded the ${Math.round(wallTimeoutMs / 1000)}s wall-clock deadline — `
+ 'killed the process group. Reporting as TIMED-OUT (distinct from failed).',
);
} else if (status === 'failed' && (exitCode ?? 1) === 0) {
const reason = summary.failedTests > 0 || summary.unhandledBetweenTests > 0
? `printed ${summary.failedTests} failing result(s) and ${summary.unhandledBetweenTests} unhandled error(s) between tests`
: summary.terminalFileCounts.length === 0
? "never printed bun's terminal summary — the run was truncated (a process.exit fired mid-suite)"
: `bun's summary reported ${summary.terminalFileCounts.join(', ')} file(s), expected ${files.length}`;
console.error(`${label} exited 0 but ${reason}. Treating as FAILED.`);
} else if (status === 'failed') {
console.error(`${label} failed with exit code ${exitCode ?? 'signal'}`);
}
const outcome: FreeShardOutcome = {
shard: shardNumber, files, status, exitCode, elapsedMs: Date.now() - startedAt, groupPid,
};
log(shardEpilogue(outcome, totalShards));
for (const line of buildRunEpilogue(status, reporter.report(), outcome.elapsedMs, logPath)) log(line);
return outcome;
}
let logPathSequence = 0;
/** Timestamped per-run log file under os.tmpdir(); pid+sequence defeat same-ms collisions. */
function nextDefaultLogPath(): string {
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
logPathSequence += 1;
return path.join(os.tmpdir(), `gstack-free-test-${stamp}-${process.pid}-${logPathSequence}.log`);
}
function exitCodeFor(status: FreeShardStatus): number {
if (status === 'passed') return 0;
return status === 'timed-out' ? 124 : 1;
}
/**
* `--record-durations`: time every file in its own child (exact per-file wall,
* immune to bun's stream buffering) and write the committed seed atomically.
* Occasional + manual by design — CI never records (a hint refreshed by a
* human beats per-run churn), and the runtime (~serial suite / jobs) is fine
* for an operation run a few times a quarter.
*/
async function recordFreeTestDurations(files: string[], jobs: number): Promise<number> {
const durations: Record<string, number> = {};
const failed: string[] = [];
let cursor = 0;
console.log(`[test:free] recording per-file durations: ${files.length} files across ${jobs} workers`);
const worker = async (): Promise<void> => {
for (;;) {
const index = cursor;
cursor += 1;
if (index >= files.length) return;
const file = files[index];
const started = Date.now();
const child = spawn('bun', ['test', file, `--timeout=${FREE_TEST_TIMEOUT_MS}`], {
cwd: ROOT,
stdio: ['ignore', 'ignore', 'ignore'],
env: { ...process.env, GSTACK_HEADLESS: '1' },
});
const code = await new Promise<number>((resolve) => {
const timer = setTimeout(() => { child.kill('SIGKILL'); }, wallTimeoutForShard(1));
child.on('close', (c) => { clearTimeout(timer); resolve(c ?? 1); });
child.on('error', () => { clearTimeout(timer); resolve(1); });
});
durations[normalizeRelativePath(file)] = Date.now() - started;
if (code !== 0) failed.push(file);
}
};
await Promise.all(Array.from({ length: Math.max(1, jobs) }, () => worker()));
const target = process.env.GSTACK_FREE_TEST_DURATIONS ?? path.join(ROOT, FREE_TEST_DURATIONS_FILE);
const payload = {
version: 1,
recordedAt: new Date().toISOString(),
durations: Object.fromEntries(Object.entries(durations).sort(([a], [b]) => (a < b ? -1 : 1))),
};
// Atomic temp+rename (capture-context-budget's pattern): a killed recorder
// must never leave a truncated seed for loadFreeTestDurations to warn on.
const tmp = `${target}.tmp-${process.pid}`;
fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`);
fs.renameSync(tmp, target);
console.log(`[test:free] wrote ${Object.keys(durations).length} durations to ${path.relative(ROOT, target)}`);
if (failed.length > 0) {
// Failures still recorded (a red file's duration is still a real cost),
// but surfaced loudly — recording from a broken tree deserves a look.
console.error(`[test:free] WARNING: ${failed.length} file(s) failed while recording:`);
for (const f of failed) console.error(` ✗ ${f}`);
return 1;
}
return 0;
}
async function main(): Promise<number> {
const options = parseCliOptions(process.argv.slice(2));
const allFiles = collectFreeTestFiles();
if (allFiles.length === 0) {
throw new Error('No free test files were discovered.');
}
let files = allFiles;
let curationReport: CurationResult | null = null;
if (options.windowsOnly) {
curationReport = curateWindowsSafe(allFiles);
files = curationReport.safe;
console.log(`[test:free] curated ${files.length} Windows-safe tests (${curationReport.excluded.length} excluded)`);
if (options.listOnly && curationReport.excluded.length > 0) {
console.log('\nExcluded (POSIX-fragile):');
for (const { file, reason } of curationReport.excluded) {
console.log(` - ${file} [${reason}]`);
}
}
}
if (options.listOnly) {
console.log(`\nDiscovered ${files.length} test files.`);
for (const file of files) console.log(` ${file}`);
return 0;
}
if (options.recordDurations) {
const jobs = Math.max(1, Math.min(MAX_FULL_SUITE_JOBS, os.cpus().length - RESERVED_CPUS));
return recordFreeTestDurations(files, jobs);
}
if (options.dryRun) {
const shards = assignFilesToShards(files, options.shardCount);
const occupied = shards.filter((s) => s.length > 0).length;
console.log(
`\nWould run ${files.length} files across ${shards.length} shards (${occupied} occupied). `
+ 'Without --shard, the full suite runs as N concurrent shard processes '
+ '(plus a serial tree-mutating shard) instead.',
);
for (const line of formatShardSummary(shards)) console.log(line);
return 0;
}
if (options.shardIndex !== null) {
// Bounds-check against the REQUESTED shard count, not post-assignment
// occupancy — indices must be stable for a CI matrix, and an empty shard
// is a valid fast no-op.
if (!Number.isInteger(options.shardIndex) || options.shardIndex < 1 || options.shardIndex > options.shardCount) {
throw new Error(`--shard must be between 1 and ${options.shardCount}. Received: ${options.shardIndex}`);
}
const shards = assignFilesToShards(files, options.shardCount);
const outcome = await runFreeShard(shards[options.shardIndex - 1], options.shardIndex, options.shardCount, {
wallTimeoutMs: options.wallTimeoutMs,
verbose: options.verbose,
});
return exitCodeFor(outcome.status);
}
// Full-suite mode: N concurrent shard PROCESSES, serial within each — the
// paid runner's proven model. One `bun test --parallel` invocation was
// tried first (decision V3) and abandoned after three distinct
// worker-runtime pathologies in a single day on Bun 1.3.13: a segfault
// whose crashed-worker retry wedged the run (security-live-playwright), a
// gated file's still-running file-level hooks stalling a worker
// (compare-board), and spawn-heavy files hanging workers under load
// (session-runner-timeout). Plain child processes have none of these:
// proven spawn semantics, per-shard group-kill, per-shard logs, and a
// wedge only ever costs its own shard. WORKER_HOSTILE files are moot in
// process shards (no workers) and fold back into normal assignment.
const jobs = Math.max(1, Math.min(MAX_FULL_SUITE_JOBS, os.cpus().length - RESERVED_CPUS));
// Phase split: tree-mutating tests run AFTER the parallel shards, in one
// serial shard, so no concurrent shard ever reads a half-regenerated tree.
const mutators = files.filter((f) => f in TREE_MUTATING);
const readers = files.filter((f) => !(f in TREE_MUTATING));
const durations = loadFreeTestDurations();
const packed = durations ? packShardsByDuration(readers, jobs, durations) : null;
const shards = packed ? packed.shards : assignFilesToShards(readers, jobs);
const totalShards = jobs + (mutators.length > 0 ? 1 : 0);
console.log(`[test:free] full suite: ${readers.length} files across ${jobs} shard processes`
+ (packed ? ' (duration-packed)' : '')
+ (mutators.length > 0 ? `, then ${mutators.length} tree-mutating file(s) serially` : ''));
if (packed) {
// One line per shard so a packing regression is diagnosable from any log.
packed.predictedMs.forEach((ms, i) => {
console.log(`[test:free] shard ${i + 1}: ${shards[i].length} files, predicted ~${Math.round(ms / 1000)}s`);
});
}
const shardTimeout = (fileCount: number): number =>
options.wallTimeoutExplicit ? options.wallTimeoutMs : wallTimeoutForShard(fileCount, options.wallTimeoutMs);
const outcomes = await Promise.all(
shards.map((shardFiles, index) => runFreeShard(shardFiles, index + 1, totalShards, {
// Packed shards get duration-aware walls: LPT decouples file count from
// cost BY DESIGN, so the 5s/file heuristic would undersize a shard
// holding few expensive files.
wallTimeoutMs: packed && !options.wallTimeoutExplicit
? wallTimeoutForPackedShard(packed.predictedMs[index], options.wallTimeoutMs)
: shardTimeout(shardFiles.length),
verbose: options.verbose,
})),
);
let worst = Math.max(...outcomes.map((o) => exitCodeFor(o.status)));
// Cancellation stops the run: don't launch the serial tree-mutating shard
// after a SIGINT/SIGTERM already killed the parallel phase.
if (mutators.length > 0 && !isTerminationRequested()) {
const mutatorOutcome = await runFreeShard(mutators, totalShards, totalShards, {
wallTimeoutMs: shardTimeout(mutators.length),
verbose: options.verbose,
});
worst = Math.max(worst, exitCodeFor(mutatorOutcome.status));
if (mutatorOutcome.status !== 'passed') {
// Mutator safety rests on each test restoring default state itself; a
// SIGKILL at the wall deadline (or a mid-regeneration crash) defeats
// that by construction. Say so, loudly, before someone commits
// regenerated SKILL.md / .agents artifacts by accident.
const dirty = spawnSyncGitStatusGenerated();
if (dirty.length > 0) {
console.error('[test:free] ⚠ tree-mutating shard did not finish cleanly — generated artifacts may be mid-regeneration:');
for (const line of dirty.slice(0, 20)) console.error(`[test:free] ${line}`);
console.error('[test:free] restore with: bun run gen:skill-docs (or git checkout -- <paths>)');
}
}
}
return worst;
}
/** Dirty generated artifacts (SKILL.md / host outputs) after a failed mutator shard. */
function spawnSyncGitStatusGenerated(): string[] {
const result = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf8' });
if (result.status !== 0 || !result.stdout) return [];
return result.stdout.split('\n').filter((line) =>
/SKILL\.md$/.test(line) || line.includes('.agents/') || line.includes('.factory/'));
}
if (import.meta.main) {
try {
process.exitCode = await main();
} catch (error) {
console.error(`[test:free] ${error instanceof Error ? error.message : String(error)}`);
process.exitCode = 1;
}
}