Files
gbrain/test/scripts/run-unit-parallel.test.ts
Garry TanandClaude Fable 5 130d321d23 v0.42.76.0 fix(upgrade,security,cli): strict flag validation, bootstrap-wedge class kill, federated chunk scope (#3902)
* fix(schema): bootstrap timeline_entries.event_page_id forward reference — un-wedge pre-v121 upgrades (#2626 #2594 #2579 #2537 #2536)

v0.42.56.0 (Chronicle, migration v121) added timeline_entries.event_page_id
and two partial indexes in the embedded schema blobs without extending
applyForwardReferenceBootstrap — any brain whose timeline_entries predates
v121 wedged initSchema at blob replay ("column event_page_id does not
exist") before runMigrations could apply v121, with no in-band recovery.

- Add the timeline_entries.event_page_id probe + column-only ALTER to
  applyForwardReferenceBootstrap in BOTH engines; FK + partial indexes land
  via the idempotent v121 / blob replay afterwards. Stays in the always-run
  bootstrap (never a migration hook — those skip oddly-stamped brains).
- REQUIRED_BOOTSTRAP_COVERAGE entry + strip blocks in both runtime tests.
- e2e: pre-v121 rewind → full initSchema converges (indexes re-created);
  wedged-brain recovery — a brain that already FAILED the upgrade attempt
  converges on retry with full final shape (column + FK + both partial
  indexes) and no ledger residue.

Absorbs PR #2548 (@chetan-guevara) and the e2e test from PR #2623
(@colinagent) — thank you both.

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

* test(schema): coverage guard cross-references migration-added columns — close the scan hole that shipped the v121 wedge

The A2 static check treated a column as covered when the current CREATE
TABLE body declared it. But a column that is BOTH in the blob's CREATE
TABLE AND added by a migration is a forward reference by definition — on
pre-existing tables CREATE TABLE IF NOT EXISTS no-ops and the blob's
CREATE INDEX crashes initSchema before runMigrations can help. That mask
is exactly how timeline_entries.event_page_id passed the guard while
wedging every pre-v121 brain.

- buildIndexRefCoveragePredicate: migration-added columns (from
  extractAddedColumnsFromMigrations over the MIGRATIONS array) require a
  bootstrap ALTER; CREATE TABLE presence no longer counts for them.
- Unit test pins the v121 regression shape red/green with synthetic inputs;
  the A2 test pins the incident triple directly (migration-added +
  blob-indexed + bootstrap-covered).
- The strengthened predicate immediately surfaced two more latent wedges of
  the same class: minion_jobs.timeout_at + minion_jobs.idempotency_key
  (migration v7, blob-indexed, unprobed). Added probes in both engines +
  coverage entries + runtime strip blocks.

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

* fix(security): serve --http hides the generated admin token from non-TTY output by default (#2624)

Generated admin bootstrap tokens printed into container/log-aggregator
stdout on every headless start. shouldSuppressBootstrapPrint now defaults
to hidden unless stderr is an interactive TTY; env-sourced tokens are
never printed; --print-admin-token is the explicit escape hatch for
capturing the value on a trusted non-TTY start; --suppress-bootstrap-token
still overrides everything. Unit-tested across all five postures.

Absorbs PR #2625 (@irresi) — thank you.

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

* fix(security): legacy bearer tokens honor permissions.takes_holders over serve --http (#2529)

GBrainOAuthProvider.verifyAccessToken never returned takesHoldersAllowList,
so the serve --http dispatch site always fell back to ['world'] — remote
MCP callers with an operator-configured takes_holders grant saw only
public takes. The legacy branch now extracts permissions.takes_holders
exactly like src/mcp/http-transport.ts (fail-safe ['world'] default,
non-string entries dropped, malformed permissions JSON fails closed
without throwing), and AuthInfo carries the field as a typed contract.

OAuth-registered clients have no takes_holders storage on oauth_clients;
that lane is design work tracked in TODOS (column migration + DCR/
registration surface), not part of this hotfix.

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

* fix(security): get_chunks honors the federated source grant + stops shipping embedding vectors (#2555, getChunks half of #2544)

The get_chunks op still used the pre-#2200 scalar pattern
(ctx.sourceId ? {sourceId} : {}) and engine.getChunks had no sourceIds[]
support — a federated client that could read a page via get_page got []
from get_chunks. The op now routes through sourceScopeOpts (canonical
ladder: federated array > scalar floor > nothing) and both engines gain
the getPage-style sourceIds[] precedence branch; the unset-opts 'default'
floor is preserved for local callers (importCodeFile contract).

While in the function: SELECT cc.* pulled every embedding vector over the
wire per chunk only for rowToChunk to discard them — replaced with the
explicit non-vector column list in both engines (the getChunks half of
#2544; the per-put_page getAllSlugs half is tracked separately).

getChunksWithEmbeddings stays scalar-only by design (engine-internal,
zero remote-reachable callers — documented at the interface).

Tests: op-level federated repro + isolation + default-floor bleed guard
(PGLite), engine precedence + Chunk-shape pin, and a DATABASE_URL-gated
engine-parity test covering all three scope shapes.

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

* fix(auth): /admin/api/register-client accepts source + federatedRead bindings (#2143 enabler)

The HTTP register endpoint hardcoded source_id='default' and
federated_read=undefined — only the CLI could mint a client bound to a
non-default source, so HTTP-registered MCP clients wrote into 'default'
regardless of intent. The endpoint now accepts optional source /
federatedRead body fields, validated via assertValidSourceId with a
structured 400 on bad input; omitting both preserves the historical
default. The admin-UI form layer is a tracked follow-up.

Absorbs PR #2016 — thank you.

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

* fix(scope,calibration): think reads over MCP; source-scoped takes reads; calibration CLI reachability + model resolution (#2078-class #2451)

- think op → scope:'read' for OAuth/MCP clients: the handler already forces
  save/take off for remote callers before persistence, so a read-scoped
  token can think without a write grant; local CLI persistence unchanged.
  Scope-annotation test carries an explicit remote-gated allowlist.
- takes_list / takes_search / takes_scorecard / takes_calibration route
  through sourceScopeOpts (the #2200 class on the takes read lane) with
  engine support in BOTH engines + tests.
- 'calibration' added to CLI_ONLY (the command was registered but
  unreachable — dispatch-gap class) and calibration_profile/voice-gate
  resolve models through the canonical gateway tier resolver instead of
  bare ids that parseModelId rejects.
- BigInt-safe local-op output normalization (bigintToStringReplacer,
  postgres.js wire parity) — first half of the #2450 fix; the
  formatResult default case lands with the cli-output commit.

Absorbs PR #2598 (@colinagent) and PR #2452 (@spinsirr) — thank you both.

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

* fix(cli): output correctness — BigInt-safe rendering, search --json, files bigint, and 5 unreachable commands (#2450 #2527 #2042 #2035-class)

- normalizeLocalResult wraps the local-op output round-trip with the
  bigint→string replacer (postgres.js wire parity; a bare stringify THROWS
  on BIGSERIAL keys); formatResult's default renderer gets the same
  replacer so nothing upstream can crash it.
- search/query --json: CLI-local formatter flag threaded through the shared
  formatter — stdout is a parseable result array, never human text on the
  --json path (the #2042 residual).
- file_list normalizes size_bytes (Postgres BIGINT → Number) so MCP
  serialization and the CLI KB math survive; null preserved.
- NEW dispatch-gap guard: every handleCliOnly top-level case label must be
  reachable via CLI_ONLY. It immediately caught FIVE live unreachable
  commands: pages, backfill, reconcile-links, notability-eval (added to
  CLI_ONLY), and the documented 'gbrain search modes|stats|tune' dashboards
  (pre-fix, 'search modes' silently keyword-searched the word "modes") —
  now routed via a pre-dispatch subcommand gate. 'whoknows' stays on its
  op-alias route (collision guard); tracked with PR #2509.

Absorbs PR #2494 and PR #2531 (@javieraldape) and adapts PR #472
(@vinsew) — thank you.

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

* fix(db): updateSourceConfig survives mixed-array config rows + repair/doctor cover the subagent jsonb columns (#2251)

The array-coercion branch called jsonb_each(elem) bare — a mixed array
(e.g. '["x", {"last_full_cycle_at": ...}]') threw 'cannot call
jsonb_each on a non-object' DURING row production, permanently failing
every subsequent updateSourceConfig (last_full_cycle_at could never be
written again). Non-object elements are now neutralized inline via a
CASE-guarded jsonb_each; the row self-heals to a flat object on the next
write, object elements' keys recovered. Pinned ungated on PGLite (real
Postgres semantics) and via a DATABASE_URL-gated e2e on the real engine.

repair-jsonb + doctor's jsonb_integrity check extend from 5 to 8 columns
(subagent_messages.content_blocks, subagent_tool_executions.input/output
— historical damage rows from the pre-v0.42.53.0 positional double-encode;
the write paths themselves were fixed in #2375) with a to_regclass skip
for brains predating those tables.

Adapts the repair/doctor extension from PR #597 (@vinsew) — thank you.

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

* fix(cli): dry-run honesty — strict unknown-flag rejection CLI-wide + unify-types worker defaults to dry-run (#2185 #1575)

#2185: 'gbrain init --migrate-only --dry-run' applied REAL migrations —
flags are read ad hoc (args.includes) so anything a handler doesn't look
for was silently ignored, including intent-bearing safety flags. The CLI
now validates every flag pre-dispatch and pre-engine:

- Op commands validate against the operation contract (op.params +
  CLI-local --json/--explain), mirroring parseOpArgs so flag values that
  begin with '--' are never misread. parseOpArgs also gains the
  --key=value inline form (previously parsed as a junk key that consumed
  the NEXT token).
- CLI_ONLY commands validate against a GENERATED per-command registry
  (scripts/generate-flag-registry.ts scans each command's case block +
  imported modules + one level of relative imports; deliberately
  over-inclusive so a missed flag can't break a working invocation).
  Committed as src/core/cli-flag-registry.generated.ts;
  'bun run build:flag-registry' regenerates.
- Passthrough by construction: everything after '--', plus call / config /
  'jobs submit' payloads (handler-defined params are their contract).
- Guards: sweep test (every command × nonsense flag → error), acceptance
  tests (real flags, --no- negation, = form, -- passthrough), drift guard
  (every CLI_ONLY member has an entry), freshness guard (committed
  registry == fresh generator run), subprocess smokes incl. the literal
  #2185 repro failing loud with zero engine work.

BREAKING: scripts passing stray flags now fail loud with
"Unknown flag --x for 'gbrain <cmd>'" — that is the point.

#1575: the unify-types worker registration passed apply ?? true while the
handler documents 'Default false (dry-run)' — the canonical operator
invocation destructively retyped 25K+ pages by default. Now ?? false with
a structural test; explicit --params '{"apply":true}' is the only way to
mutate.

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

* docs(todos): file fix-wave 1 follow-ups (OAuth takes_holders design, parseFlags end-state, whoknows routing, #2544 half, #1558 UI, #2536 diagnostics)

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

* fix(test): memory-safe unit runner — adaptive concurrency + serial OOM rescue pass

A default run (4 shards × 4 intra-shard files) holds up to 16 concurrent
PGLite WASM instances (~1.5GB each). With sibling Conductor workspaces
running their own suites, PGLite connect failed with 'Out of memory'
across every shard at once — 369 phantom test failures on a healthy
branch, indistinguishable from real breakage at a glance.

Two default-on layers in scripts/run-unit-parallel.sh:
1. Memory-aware sizing: total concurrency is capped to available memory
   (vm_stat on macOS, MemAvailable on Linux) at GBRAIN_TEST_MEM_PER_FILE_MB
   (default 1536) per concurrent file, shedding shards before intra-shard
   width. Quiet machines are unaffected (banner: mem-ok); pressured ones
   degrade instead of OOMing (banner: mem-adapted AxB→CxD).
2. Serial OOM rescue: failures whose shard log carries the WASM
   out-of-memory signature are re-run at --max-concurrency 1 after the
   fan-out drains. Phantoms pass serially → run goes green with an
   oom_rescued note and the failure blocks marked superseded; real
   failures fail again and stay red. Plain assertion failures never match
   the signature and never enter the rescue lane (existing exit-code and
   failure-log contract tests unchanged).

Escape hatches: GBRAIN_TEST_NO_MEM_ADAPT=1, GBRAIN_TEST_NO_OOM_FALLBACK=1.
Tests: OOM-once fixture rescued to exit 0; kill-switch stays red; banner
advertises the sizing verdict. Documented in docs/TESTING.md.

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

* fix(test,cli): merge-seam repairs — shard timeout for the tripled suite, init flag-error contract, think scope exemption

Three post-merge repairs surfaced by the first full-suite run:
- Shard timeout 1500s -> 3000s: the suite roughly tripled since the cap was
  sized (~3900 -> 11k+ tests; PGLite inits replay 120 migrations, was 92).
  Two shards were killed mid-progress at 1500s.
- The #2185 pre-dispatch validator now emits the same error contract as
  init.ts's in-handler check it preempts: lowercase 'unknown flag' on
  stderr + structured {status:'error', reason:'invalid_flag'} on stdout
  for --json callers (pinned by test/init-migrate-only.test.ts).
- test/operations-trust-boundary.test.ts gets the same documented
  remote-gated allowlist for think's read scope (#2598) that
  test/oauth.test.ts already carries.

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

* fix(test): rescue lane also covers externally-killed shards (sibling-workspace pkill / memory jetsam)

Second phantom class observed on a multi-workspace Conductor machine:
3 shards SIGTERM'd + 1 SIGKILL'd at ~700s under a 3000s cap, all
mid-progress — an external killer, not a wedge. The dead shards then
poisoned the serial pass (lock/state residue → 18 more phantoms), and
every one of the 18 passed standalone.

The runner now stamps per-shard start/end epochs; a shard dying on
143/137 before 80% of SHARD_TIMEOUT is classified externally-killed and
its file list joins the serial rescue queue (real wedges die AT the cap
and stay red). Serial-pass failures that occur while any shard was
externally killed are treated as suspect residue and rescued too.
Structural tests pin the detector, threshold, and routing.

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

* fix(auth): keep tokenEndpointAuthMethod terminal in the register-client destructure (PKCE structural contract)

The #2016 absorb appended source/federatedRead after
tokenEndpointAuthMethod; test/fix-wave-structural.test.ts pins
tokenEndpointAuthMethod as the final destructured field (v0.36.1.x #1077
PKCE regression contract). The added fields move into the regex's
optional-middle slot.

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

* fix(test): re-apply the #2598 remote-gated scope exemption to master's oauth scope-annotation test

Taking master's v0.42.74.0 oauth.test.ts (its #2529 implementation) dropped
the think read-scope allowlist that PR #2598 carries; re-applied to match
test/operations-trust-boundary.test.ts.

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

* fix(review): pre-landing review batch — CLI-local flag acceptance, json= coherence, re-landed getChunks trim, payload-aware jsonb repair, rescue-lane tightening

Ship Step 9 findings (checklist pass + 4 specialists), all verified before fixing:
- CRITICAL: the #2185 validator rejected --source/--dry-run on op commands
  (makeContext CLI-locals consumed outside the op contract) — 'gbrain search
  "x" --source y' exited 1. Exempted with parser-mirroring value consumption
  + unit/subprocess tests incl. global-flag acceptance.
- CRITICAL: --json=<v> diverged between validator (accepted) and parseOpArgs
  (junk-key path consumed the NEXT token, corrupting positionals). Parser now
  handles --json=true|false; =-forms of bare-only CLI-locals reject loud.
  parseOpArgs inline-= suite added (regression rule).
- CRITICAL: the master merge silently restored SELECT cc.* in both engines'
  getChunks while docs claimed the #2544 trim. Re-landed the explicit
  non-vector column list + a source-level structural pin so a merge can't
  silently undo it again.
- repair-jsonb/doctor: the subagent columns legitimately hold jsonb string
  scalars (persistToolExec binds pre-serialized strings) — unconditional
  unwrap would abort the repair run or corrupt legit values. jsonPayloadOnly
  predicate (JSON-container content only) on those 3 targets, mirrored in
  doctor + parameterized to_regclass + behavioral test (damage flagged,
  legit string ignored, absent table skipped).
- runner rescue-lane tightening: serial failures rescue-eligible only with
  their own OOM signature or after an external shard kill (residue), never
  because a sibling shard OOM'd — flaky serial tests stay red. Rescue passes
  no longer double-count into TOTAL_PASS; shard timeout scales when
  mem-adaptation sheds shards; negative-path tests (mixed run stays red,
  deterministic OOM-signature failure stays red).
- fail-closed remote spelling at 2 forward sites (ctx.remote !== false per
  the CLAUDE.md invariant); registry generator drops template-literal flag
  prefixes; real-PG e2es for the v121 + minion_jobs wedge classes; stale
  comments corrected.

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

* chore: regenerate flag registry after master merge (#3864 added extract help flags)

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

* fix(review): red-team batch — dispatch-order validation, real --dry-run boolean, rescue-lane timeout/isolation/cap, parseable-payload repair gate

Red-team pass (post-specialist) findings, all verified before fixing:
- CRITICAL: validateCommandFlags checked the op lane before CLI_ONLY while
  dispatch runs CLI_ONLY first — dual-lane commands (think/salience/
  anomalies) were validated against the WRONG contract, rejecting documented
  invocations ('salience --kind entity'). Lane order now mirrors dispatch.
- CRITICAL: --dry-run was blessed as legal on op commands but parseOpArgs
  never SET it (trailing → nothing → ctx.dryRun false → the REAL destructive
  action ran; leading → consumed the next token). Now a CLI-local boolean
  exactly like --json, with --dry-run=false support and regression tests.
- CRITICAL: the rescue lane ran bun test WITHOUT --timeout=60000 (bun default
  5s) — PGLite phantoms re-failed on timeout and were mislabeled 'confirmed
  real'. Both rescue invocations now mirror the shard flags; serial files
  re-run one process per file (run-serial-tests.sh isolation contract);
  rescue wallclock capped at 2x the shard timeout.
- CRITICAL: the jsonPayloadOnly probe matched container-LOOKING invalid JSON
  ('[INFO] fetch complete') whose repair cast would throw and abort the run
  mid-loop. Predicate now gates on pg_input_is_valid (PG16+, same floor as
  the existing IS JSON usage) + per-target catch records and continues;
  doctor mirrors; behavioral test covers the lookalike row.
- Registry generator bounded at handleCliOnly's closing brace (the LAST case
  block absorbed ~100 junk flags from the rest of cli.ts, neutering strict
  validation for it); uppercase flag typos reject loudly in both lanes
  (handlers are lowercase-sensitive); --json=true spelling gets the
  structured invalid_flag envelope; get_chunks __all__ narrowing filed as a
  Wave 3 TODO.

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

* fix(engines): getChunks trimmed SELECT must carry modality (codex P1-B)

The #2544 egress trim replaced SELECT cc.* with an explicit column list but
omitted cc.modality — every rowToChunk field except the vector must survive
the trim, or the embed round-trip (getChunks -> upsertChunks) rewrites image
chunks as text. Both engines; the structural pin now iterates the full
rowToChunk field list instead of spot-checking.

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

* fix(cli): safety flags require consumption evidence in the flag registry (codex P1-A)

upgrade.ts prints a help hint naming another command's --dry-run; that
literal is depth-0 text for post-upgrade, so the generator allowlisted
--dry-run there — recreating the exact #2185 repro this wave kills
(post-upgrade --dry-run accepted, ignored, migrations run for real).
Safety flags now need a tight-quoted standalone literal (an args read
like has('--dry-run')) before the registry grants them; prose bleed
embeds the flag inside a longer string and never qualifies. Regenerated
registry drops --dry-run from post-upgrade, keeps genuine consumers.

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

* v0.42.76.0 fix(upgrade,security,cli): strict flag validation, bootstrap-wedge class kill, federated chunk scope

Version bump + CHANGELOG for the fix wave: CLI-wide unknown-flag
rejection with a generated per-command registry (#2185, #1575 class),
minion_jobs bootstrap probes + migration-aware coverage guard (v121
wedge class), get_chunks federated scope + egress trim (#2555, half of
#2544), think read-scope over MCP (#2598), register-client source
bindings (#2016, #2143 enabler), repair-jsonb/doctor subagent columns
with a parse-validated damage predicate, memory-safe unit-test runner.

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

* docs: update project documentation for v0.42.76.0

CONTRIBUTING.md: test-runner claims match the memory-safe 4-shard default
(was 8-shard) and the CLI-only command recipe now includes the
build:flag-registry regen step. KEY_FILES.md: repair-jsonb entry updated
to the 8-column parse-gated current state; new entries for the strict
flag-validation subsystem and src/core/source-id.ts; operations/engine/
serve-http entries updated for get_chunks scope ladder + trimmed SELECT
and the register-client HTTP source bindings.

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

* docs: apply doc-review fixes for v0.42.76.0

Cross-model doc review caught stragglers: docs/TESTING.md still said
8-shard in the file taxonomy, carried a two-generations-stale shard
timeout default (600s -> 3000s), and didn't name the new
run-unit-parallel regression test or the remaining runner knobs;
CONTRIBUTING.md's fast-loop file count predated the tripled suite
(92+ -> 1000+); test-count claims unified at 3700+; the
CLIENT_FENCED_WRITE_OPS comment in operations.ts still described
think as scope write; KEY_FILES names the exported findUnknownFlag.

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

* fix(test): rescue lane survives CI — strip ::group:: prefixes, satisfy the bun-test-timeout guard

Two CI-only breaks from the master merge: (1) under GITHUB_ACTIONS the
shard wraps file sections as ::group::path.test.ts, so the rescue pass
extracted literal ::group:: non-paths that matched zero test files —
failing_files_in_log now strips the prefix; (2) master's new
check-bun-test-timeout guard greps for bare 'bun test' and tripped on
run_rescue's comment text (the invocations themselves carry
--timeout=60000) — comment reworded.

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

* fix(test): shard-mechanics tests disable mem-adaptation — CI's 7GB runner collapsed explicit 2 shards to 1

The runner deliberately adapts even explicit --shards to available memory
(GBRAIN_TEST_NO_MEM_ADAPT=1 is the escape hatch); on GitHub's ~7GB runners
that collapsed the tests' 2-shard sandbox runs to 1 shard, breaking every
'shard 1/2:' expectation while passing locally. The tests pin shard
MECHANICS with tiny synthetic files, so they now set the escape hatch;
the one test that checks the mem banner overrides it back on.

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

---------

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

403 lines
18 KiB
TypeScript

/**
* Regression tests (a) + (d) for scripts/run-unit-parallel.sh:
* (a) Exit-code propagation: a failing test in any shard MUST cause the
* wrapper to exit non-zero. The hardest contract to silently break
* in a fan-out wrapper (`for ... &; wait` returns the LAST child's
* status, not any failure's).
* (d) Failure-log contract: when any test fails, the wrapper writes
* extracted failure block(s) to .context/test-failures.log with
* `--- shard $i:` prefixes, and prints a loud stderr banner with
* the absolute path. Empty log ⇔ exit 0.
*
* The wrapper takes ~1.5 minutes against the real test suite. To keep
* this regression test fast and hermetic, we point it at a tiny tempdir
* containing one passing and one failing test, override the discovery
* roots via env-vars, and run with --shards=2.
*
* NOT covered behaviorally here: the heartbeat and a real hung Bun process
* (both timing-sensitive). The timeout escalation wiring is covered as a
* source contract below and exercised separately by a process-leak smoke.
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { execFileSync, spawnSync } from 'child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, copyFileSync, chmodSync, symlinkSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
const REPO_ROOT = resolve(import.meta.dir, '..', '..');
const PARALLEL_SH_SRC = resolve(REPO_ROOT, 'scripts/run-unit-parallel.sh');
const SHARD_SH_SRC = resolve(REPO_ROOT, 'scripts/run-unit-shard.sh');
const SERIAL_SH_SRC = resolve(REPO_ROOT, 'scripts/run-serial-tests.sh');
let TMPROOT: string;
beforeAll(() => {
// Build a tiny repo-shaped tempdir with the wrapper scripts copied in
// and 4 fixture test files (3 pass, 1 fail). The wrapper's `find test`
// expression will pick them up via cwd.
TMPROOT = mkdtempSync(join(tmpdir(), 'gbrain-parallel-test-'));
mkdirSync(join(TMPROOT, 'scripts'), { recursive: true });
mkdirSync(join(TMPROOT, 'test'), { recursive: true });
copyFileSync(PARALLEL_SH_SRC, join(TMPROOT, 'scripts', 'run-unit-parallel.sh'));
copyFileSync(SHARD_SH_SRC, join(TMPROOT, 'scripts', 'run-unit-shard.sh'));
copyFileSync(SERIAL_SH_SRC, join(TMPROOT, 'scripts', 'run-serial-tests.sh'));
chmodSync(join(TMPROOT, 'scripts', 'run-unit-parallel.sh'), 0o755);
chmodSync(join(TMPROOT, 'scripts', 'run-unit-shard.sh'), 0o755);
chmodSync(join(TMPROOT, 'scripts', 'run-serial-tests.sh'), 0o755);
// 3 passing + 1 failing test file. Round-robin sharding will land
// them across 2 shards so we exercise the multi-shard merge path.
const passing = `import { describe, it, expect } from 'bun:test';
describe('passing', () => {
it('arithmetic works', () => { expect(1 + 1).toBe(2); });
});`;
const failing = `import { describe, it, expect } from 'bun:test';
describe('failing-on-purpose', () => {
it('expects 1 to equal 2 (this should fail)', () => { expect(1).toBe(2); });
});`;
writeFileSync(join(TMPROOT, 'test', 'a-pass.test.ts'), passing);
writeFileSync(join(TMPROOT, 'test', 'b-pass.test.ts'), passing);
writeFileSync(join(TMPROOT, 'test', 'c-pass.test.ts'), passing);
writeFileSync(join(TMPROOT, 'test', 'd-fail.test.ts'), failing);
});
afterAll(() => {
if (TMPROOT) rmSync(TMPROOT, { recursive: true, force: true });
});
function runWrapper(extraArgs: string[] = []): { code: number; stdout: string; stderr: string } {
const result = spawnSync(
'bash',
[join(TMPROOT, 'scripts', 'run-unit-parallel.sh'), '--shards', '2', ...extraArgs],
// Shard-mechanics tests pin explicit --shards behavior with tiny
// synthetic files; disable mem-adaptation so a RAM-limited runner (CI's
// ~7GB) can't collapse 2 shards -> 1 and break the shard 1/2 expectations.
{ cwd: TMPROOT, encoding: 'utf-8', env: { ...process.env, GBRAIN_TEST_NO_MEM_ADAPT: '1' } },
);
return {
code: result.status ?? -1,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
describe('run-unit-parallel.sh exit-code propagation (a)', () => {
it('exits non-zero when any shard contains a failing test', () => {
const r = runWrapper();
expect(r.code).not.toBe(0);
});
it('exits zero when all shards pass (after removing the failing fixture)', () => {
rmSync(join(TMPROOT, 'test', 'd-fail.test.ts'));
try {
const r = runWrapper();
expect(r.code).toBe(0);
} finally {
// Restore the failing fixture for any downstream tests in the same
// describe block (afterAll cleans the whole tempdir; this is belt-
// and-suspenders).
const failing = `import { describe, it, expect } from 'bun:test';
describe('failing-on-purpose', () => {
it('expects 1 to equal 2', () => { expect(1).toBe(2); });
});`;
writeFileSync(join(TMPROOT, 'test', 'd-fail.test.ts'), failing);
}
});
});
describe('run-unit-parallel.sh failure-log contract (d)', () => {
it('writes failures to .context/test-failures.log with --- shard prefix on failure', () => {
const r = runWrapper();
expect(r.code).not.toBe(0);
const failureLog = join(TMPROOT, '.context/test-failures.log');
expect(existsSync(failureLog)).toBe(true);
const contents = readFileSync(failureLog, 'utf-8');
expect(contents.length).toBeGreaterThan(0);
expect(contents).toMatch(/--- shard \d+:/);
expect(contents).toContain('failing-on-purpose');
});
it('prints loud stderr banner with absolute failure-log path on failure', () => {
const r = runWrapper();
expect(r.code).not.toBe(0);
expect(r.stderr).toContain('TEST FAILURES');
// Banner includes the absolute path so users can `cat` it directly.
expect(r.stderr).toContain(join(TMPROOT, '.context', 'test-failures.log'));
});
it('clears .context/test-failures.log to empty when all shards pass', () => {
// Pre-seed a stale failure log to prove it gets cleared.
mkdirSync(join(TMPROOT, '.context'), { recursive: true });
writeFileSync(join(TMPROOT, '.context', 'test-failures.log'), 'STALE\n');
rmSync(join(TMPROOT, 'test', 'd-fail.test.ts'));
try {
const r = runWrapper();
expect(r.code).toBe(0);
const contents = readFileSync(join(TMPROOT, '.context', 'test-failures.log'), 'utf-8');
expect(contents).toBe('');
} finally {
const failing = `import { describe, it, expect } from 'bun:test';
describe('failing-on-purpose', () => {
it('expects 1 to equal 2', () => { expect(1).toBe(2); });
});`;
writeFileSync(join(TMPROOT, 'test', 'd-fail.test.ts'), failing);
}
});
it('writes per-shard summary lines to .context/test-summary.txt', () => {
runWrapper();
const summary = readFileSync(join(TMPROOT, '.context', 'test-summary.txt'), 'utf-8');
// Format: `shard 1/2: pass=N fail=N skip=N rc=N`
expect(summary).toMatch(/shard 1\/2: pass=\d+ fail=\d+ skip=\d+ rc=\d+/);
expect(summary).toMatch(/shard 2\/2: pass=\d+ fail=\d+ skip=\d+ rc=\d+/);
});
});
describe('run-unit-parallel.sh timeout escalation contract', () => {
it('gives a timed-out shard 30 seconds after TERM, then forces KILL', () => {
const source = readFileSync(PARALLEL_SH_SRC, 'utf-8');
expect(source).toContain('SHARD_KILL_AFTER="${GBRAIN_TEST_SHARD_KILL_AFTER:-30}"');
expect(source).toContain('--signal=TERM --kill-after="${SHARD_KILL_AFTER}s"');
expect(source).toContain('sleep "$SHARD_KILL_AFTER" && kill -KILL "$pid"');
});
it('marks both ordinary timeout and forced-KILL timeout exits as wedged', () => {
const source = readFileSync(PARALLEL_SH_SRC, 'utf-8');
expect(source).toContain('[ "$rc" = "124" ] || [ "$rc" = "137" ]');
});
});
describe('run-unit-parallel.sh no-timeout-binary fallback (rc from shard wait, not watchdog teardown)', () => {
// Forces the no-gtimeout/no-timeout branch by running the wrapper under a
// curated PATH that has every tool the scripts call EXCEPT timeout
// binaries (real `bun` symlinked in), so the fallback executes even on
// hosts with coreutils installed.
//
// Regression pinned here: the shard's sentinel .exit file must record the
// exit code read right after `wait $pid` (the shard's own rc). The
// watchdog subshell is killed with SIGTERM and reports 143; reading `$?`
// after that teardown stamped rc=143 into every shard's sentinel — the
// wrapper exited non-zero with rc=143 summaries even when every test
// passed.
let FROOT: string;
let FENV: Record<string, string>;
beforeAll(() => {
FROOT = mkdtempSync(join(tmpdir(), 'gbrain-parallel-fallback-'));
mkdirSync(join(FROOT, 'scripts'), { recursive: true });
mkdirSync(join(FROOT, 'test'), { recursive: true });
for (const s of ['run-unit-parallel.sh', 'run-unit-shard.sh', 'run-serial-tests.sh']) {
copyFileSync(resolve(REPO_ROOT, 'scripts', s), join(FROOT, 'scripts', s));
chmodSync(join(FROOT, 'scripts', s), 0o755);
}
const passing = `import { describe, it, expect } from 'bun:test';
describe('passing', () => {
it('arithmetic works', () => { expect(1 + 1).toBe(2); });
});`;
writeFileSync(join(FROOT, 'test', 'a-pass.test.ts'), passing);
writeFileSync(join(FROOT, 'test', 'b-pass.test.ts'), passing);
const bin = join(FROOT, 'bin');
mkdirSync(bin);
for (const tool of ['bash', 'sh', 'env', 'dirname', 'basename', 'mktemp', 'date', 'sleep', 'cat', 'tail', 'head', 'rm', 'mkdir', 'pkill', 'grep', 'sed', 'awk', 'wc', 'tr', 'seq', 'find', 'sort', 'bun']) {
const p = Bun.which(tool);
if (p) symlinkSync(p, join(bin, tool));
}
FENV = {
PATH: bin,
HOME: process.env.HOME ?? FROOT,
TMPDIR: process.env.TMPDIR ?? '/tmp',
GBRAIN_TEST_SHARD_TIMEOUT: '300',
// Same rationale as runWrapper: explicit-shard mechanics under test.
GBRAIN_TEST_NO_MEM_ADAPT: '1',
};
});
afterAll(() => {
if (FROOT) rmSync(FROOT, { recursive: true, force: true });
});
function runFallbackWrapper(): { code: number; stdout: string; stderr: string } {
const result = spawnSync(
'bash',
[join(FROOT, 'scripts', 'run-unit-parallel.sh'), '--shards', '2'],
{ cwd: FROOT, encoding: 'utf-8', env: FENV },
);
return {
code: result.status ?? -1,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
it('exits zero with rc=0 shard sentinels when all shards pass', () => {
const r = runFallbackWrapper();
const summary = readFileSync(join(FROOT, '.context', 'test-summary.txt'), 'utf-8');
expect(summary).toMatch(/shard 1\/2: pass=\d+ fail=0 skip=0 rc=0/);
expect(summary).toMatch(/shard 2\/2: pass=\d+ fail=0 skip=0 rc=0/);
expect(summary).not.toContain('rc=143');
expect(r.code).toBe(0);
});
it('propagates a failing shard rc as the test runner rc (1), not the watchdog 143', () => {
const failing = `import { describe, it, expect } from 'bun:test';
describe('failing-on-purpose', () => {
it('expects 1 to equal 2', () => { expect(1).toBe(2); });
});`;
writeFileSync(join(FROOT, 'test', 'z-fail.test.ts'), failing);
try {
const r = runFallbackWrapper();
expect(r.code).not.toBe(0);
const summary = readFileSync(join(FROOT, '.context', 'test-summary.txt'), 'utf-8');
expect(summary).toMatch(/shard \d\/2: pass=\d+ fail=1 skip=0 rc=1/);
expect(summary).not.toContain('rc=143');
const failureLog = readFileSync(join(FROOT, '.context', 'test-failures.log'), 'utf-8');
expect(failureLog).toContain('failing-on-purpose');
} finally {
rmSync(join(FROOT, 'test', 'z-fail.test.ts'), { force: true });
}
});
});
describe('run-unit-parallel.sh OOM rescue lane', () => {
// A fixture that fails WITH the WASM out-of-memory signature on its first
// run (no sentinel file yet) and passes once the sentinel exists — exactly
// the phantom-failure shape: dies under parallel memory pressure, passes
// serially. The runner must (1) detect the signature, (2) re-run the file
// at --max-concurrency 1, (3) exit 0 with an oom_rescued note.
let OROOT: string;
beforeAll(() => {
OROOT = mkdtempSync(join(tmpdir(), 'gbrain-parallel-oom-'));
mkdirSync(join(OROOT, 'scripts'), { recursive: true });
mkdirSync(join(OROOT, 'test'), { recursive: true });
for (const s of ['run-unit-parallel.sh', 'run-unit-shard.sh', 'run-serial-tests.sh']) {
copyFileSync(resolve(REPO_ROOT, 'scripts', s), join(OROOT, 'scripts', s));
chmodSync(join(OROOT, 'scripts', s), 0o755);
}
const passing = `import { describe, it, expect } from 'bun:test';
describe('passing', () => {
it('arithmetic works', () => { expect(1 + 1).toBe(2); });
});`;
const oomOnce = `import { describe, it, expect } from 'bun:test';
import { existsSync, writeFileSync } from 'fs';
describe('oom-once', () => {
it('fails with the WASM OOM signature on first run, passes on retry', () => {
const sentinel = new URL('./oom-sentinel.txt', import.meta.url).pathname;
if (!existsSync(sentinel)) {
writeFileSync(sentinel, 'ran-once');
console.error('Original error: Out of memory');
throw new Error('Out of memory (simulated PGLite WASM connect failure)');
}
expect(1).toBe(1);
});
});`;
writeFileSync(join(OROOT, 'test', 'a-pass.test.ts'), passing);
writeFileSync(join(OROOT, 'test', 'b-oom-once.test.ts'), oomOnce);
});
afterAll(() => {
if (OROOT) rmSync(OROOT, { recursive: true, force: true });
});
function runOom(env: Record<string, string> = {}): { code: number; stdout: string; stderr: string } {
rmSync(join(OROOT, 'test', 'oom-sentinel.txt'), { force: true });
const result = spawnSync(
'bash',
[join(OROOT, 'scripts', 'run-unit-parallel.sh'), '--shards', '2'],
{ cwd: OROOT, encoding: 'utf-8', env: { ...process.env, GBRAIN_TEST_NO_MEM_ADAPT: '1', ...env } },
);
return { code: result.status ?? -1, stdout: result.stdout || '', stderr: result.stderr || '' };
}
it('rescues an OOM-signature failure serially and exits 0 with an oom_rescued note', () => {
const r = runOom();
expect(r.stdout + r.stderr).toContain('OOM rescue pass');
expect(r.stderr).toContain('oom_rescued=');
expect(r.code).toBe(0);
}, 120_000);
it('GBRAIN_TEST_NO_OOM_FALLBACK=1 disables the rescue lane (stays red)', () => {
const r = runOom({ GBRAIN_TEST_NO_OOM_FALLBACK: '1' });
expect(r.code).not.toBe(0);
expect(r.stdout + r.stderr).not.toContain('OOM rescue pass');
}, 120_000);
it('memory-aware sizing is advertised in the banner (mem-ok or mem-adapted)', () => {
// The one test that needs adaptation ON — override the harness-wide
// NO_MEM_ADAPT base (which keeps the shard-mechanics tests deterministic
// on RAM-limited CI runners).
const r = runOom({ GBRAIN_TEST_NO_MEM_ADAPT: '0' });
expect(r.stderr).toMatch(/mem-(ok|adapted)/);
}, 120_000);
it('mixed run: a plain assertion failure stays red even when the OOM phantom rescues green', () => {
// The NON_OOM_FAIL gate — the branch that stops the rescue lane from
// absolving real failures that happened to share a run with phantoms.
const realFail = `import { describe, it, expect } from 'bun:test';
describe('real-failure', () => {
it('expects 1 to equal 2', () => { expect(1).toBe(2); });
});`;
writeFileSync(join(OROOT, 'test', 'c-real-fail.test.ts'), realFail);
try {
const r = runOom();
expect(r.code).not.toBe(0);
} finally {
rmSync(join(OROOT, 'test', 'c-real-fail.test.ts'), { force: true });
}
}, 120_000);
it('a deterministic failure carrying the OOM signature re-fails serially and stays red', () => {
// The oom_rescue_failed lane: signature match queues the file, but the
// serial re-run confirms the failure is real — run must stay red.
const alwaysOom = `import { describe, it } from 'bun:test';
describe('oom-always', () => {
it('always fails with the signature', () => {
console.error('Original error: Out of memory');
throw new Error('Out of memory (deterministic)');
});
});`;
writeFileSync(join(OROOT, 'test', 'd-oom-always.test.ts'), alwaysOom);
try {
const r = runOom();
expect(r.code).not.toBe(0);
expect(r.stderr).toContain('oom_rescue_failed=');
expect(r.stdout + r.stderr).toContain('oom-rescue (serial, confirmed real)');
} finally {
rmSync(join(OROOT, 'test', 'd-oom-always.test.ts'), { force: true });
}
}, 120_000);
});
describe('run-unit-parallel.sh external-kill rescue contract', () => {
// An externally-killed shard (sibling workspace pkill, memory jetsam)
// presents as rc 143/137 well before the shard timeout. Simulating a
// mid-run external kill deterministically in a fixture is flaky, so this
// pins the load-bearing structure instead: the early-death detector, the
// 80%-of-timeout threshold that separates external kills from real wedges,
// and the rescue-queue routing for both the wedged and non-wedged branches.
it('detects early SIGTERM/SIGKILL deaths against the 80% timeout threshold', () => {
const source = readFileSync(PARALLEL_SH_SRC, 'utf-8');
expect(source).toContain('[ "$rc" = "143" ] || [ "$rc" = "137" ]');
expect(source).toContain('$((SHARD_TIMEOUT * 80 / 100))');
expect(source).toContain('shard_external_kill=1');
});
it('routes externally-killed shards into the serial rescue queue, not the red path', () => {
const source = readFileSync(PARALLEL_SH_SRC, 'utf-8');
const killBranches = source.split('shard_external_kill" = "1"').length - 1;
expect(killBranches).toBeGreaterThanOrEqual(2); // wedged + non-wedged branch
expect(source).toContain('KILLED externally after ${s_elapsed}s');
});
it('stamps per-shard start/end epochs so early death is measurable', () => {
const source = readFileSync(PARALLEL_SH_SRC, 'utf-8');
expect(source).toContain('date +%s > "$LOG_DIR/shard-$i.start"');
expect(source).toContain('date +%s > "$LOG_DIR/shard-$i.end"');
});
});