Testing Strategy
This monorepo uses a workspace-first testing architecture under apps/*/tests. Production code stays in src/, and tests are grouped by intent as unit or integration.
Directory Structure
apps/
api/
tests/
unit/
integration/
support/
setup/ # test-environment.ts (bootstrap) + preload.ts
harness/ # create-api-test-app.ts
factories/ # insertTestUser, insertTestChat
mocks/ # fake collaborators
frontend/
tests/
unit/
integration/
support/
setup/ # dom-setup.ts + bun.setup.ts (bunfig preloads)
harness/ # render.tsx, render-with-router.tsx, timers.ts
mocks/ # create-fetch-scenario.ts (happy-dom hook tests only)
shared/
tests/
unit/
support/ is reserved for helpers that remove real duplication inside a workspace. Only create subfolders that are immediately used.
Test Taxonomy
unit: isolates a single hook, component, service, route module, or utility.integration: covers a flow that crosses module boundaries inside the same workspace.browser-smoke: minimal Playwright Chromium suite covering end-to-end auth flows (signup, login, authenticated landing, logout, re-login).
Interactive MCP coverage matrix
Keep the combinatorial protocol matrix below the browser layer. The API suites use real MCP SDK transports and the public SSE/HTTP contracts; frontend integration owns mounted and reloaded React state; Playwright is reserved for behavior that depends on an actual browser.
| Behavior | Owning layer | Coverage |
|---|---|---|
| Text success, tool error, result cap, disabled server, Skill coexistence | API turn integration | modules/generation/mcp-turn.integration.test.ts |
| Image, audio, text resource, binary resource, unsupported link, malformed content, durable provenance | API turn integration | modules/generation/mcp-turn.integration.test.ts |
| Elicitation accept, decline, cancel, timeout, user abort, stale response, pending/terminal reload | API route E2E | routes/respond-stream-mcp-interactive.integration.test.ts |
Ordered StreamChunkSchema events and tool lifecycle snapshots |
API route E2E | routes/respond-stream-mcp-interactive.integration.test.ts |
| Same-server FIFO/correlation, disconnect, interrupted recovery handoff | API route E2E | routes/respond-stream-mcp-interactive.integration.test.ts |
| SDK wrapper, HTTP transport, stdio spawn/exit, session reconnect (through the Local runtime) | API transport integration | services/mcp/*-transport.integration.test.ts, services/mcp/wrapper-contract.integration.test.ts |
| Mounted terminal elicitation, persisted MCP media, question resume | Frontend integration | components/interactive-chat-flows.integration.test.tsx |
| Streamed todo cache update and persisted reload on chat switch | Frontend integration | components/interactive-chat-flows.integration.test.tsx |
| Browser-only navigation, focus, download, or native rendering behavior | Browser smoke | Add a focused Playwright case only when happy-dom and API E2E cannot prove the behavior |
Interactive fixtures must use local SDK servers, explicit synchronization barriers, ephemeral ports, bounded diagnostics, and teardown assertions. They must not contact public MCP services, use production credentials, or depend on arbitrary sleeps for tool ordering.
Workspace Runners
| Workspace | Runner | Environment |
|---|---|---|
apps/api |
bun test |
Bun native |
apps/frontend |
bun test |
Bun native for pure logic, happy-dom for React |
apps/shared |
bun:test |
Bun native |
Root Scripts
bun run check # format + lint + typecheck + code health
bun run code-health # standalone Knip unused code/dependency report
bun run test # unit + integration (e2e is opt-in)
bun run test --unit # API, shared, and frontend unit suites
bun run test --integration # API and frontend integration suites
bun run test:e2e:setup # install Playwright Chromium + OS dependencies
bun run test --e2e # Playwright Chromium auth smoke suite (opt-in)
bun run test --coverage # coverage collection across applicable workspaces
bun run test --all # all lanes including e2e
bun run test --changed # only tests affected since merge-base HEAD origin/main
bun run test --changed --base <ref> # ... since merge-base HEAD <ref>
bun run verify # local gate: check → test --coverage → build --all--changed is a local fast loop, not a gate. It diffs the merge-base against
the working tree (committed, staged, unstaged and untracked files) and hands
bun test --changed=<merge-base> to each lane, which keeps only the test
files whose import graph reaches a changed file. Bun’s walk stops at a
workspace symlink — on 1.4.2 an edit under apps/shared selects no api or
frontend test — and never sees a file that is read rather than imported, so a
lane runs whole instead when a workspace it imports by package name changed
(CHANGED_LANE_DEPENDENCIES in scripts/lib/test.ts, pinned against the
manifests), when one of its own non-module files changed, or, for the root
lane, when any non-module file changed. The protocol lane runs only when the
protocol surface changed. The runner prints each lane’s mode and reason before
it starts. It refuses --coverage, since the floors need the whole suite;
--e2e still runs whole. A test that reads source as text rather than
importing it is invisible to --changed, so CI keeps running everything.
Lane Taxonomy
| Lane | Task name | Workspaces | Runner | Turbo cached |
|---|---|---|---|---|
| unit | test:unit |
api, frontend, shared | bun test (api: worker processes) | yes |
| integration | test:integration |
api, frontend | bun test | no |
| coverage | test:coverage |
api, frontend, shared | bun test | no |
| e2e | — | root (browser-smoke) | Playwright Chromium | — |
| scripts | //#test:scripts |
root | bun test | yes |
Turbo skips packages that do not define a given task, so passing all workspace filters is safe — no per-workspace metadata is needed to gate lane participation.
A unit lane runs the sources of the workspaces it imports, not only its own (a
frontend test drives API code, an API test drives shared), so its cache key covers
them: test:unit depends on the script-less transit task, which hashes every
workspace upstream without running anything or making a lane wait. A unit result is
replayed only while those workspaces are unchanged. An edit in apps/api therefore
re-runs the API and frontend lanes, and an edit in apps/frontend re-runs only its
own. scripts/tests/unit-cache-graph.unit.test.ts pins this.
Timeouts
Every lane sets a 15s floor — --timeout 15000, passed on the command line
because bunfig.toml’s timeout key is ignored in this workspace. bun test
otherwise defaults to 5s, and a loaded CI runner is 4–14x slower than a dev
machine on subprocess-heavy tests, so the default leaves tests that pass in
milliseconds locally one bad runner away from failing on wall clock alone.
A per-test or per-hook timeout still wins in both directions: a test
declaring 20_000 keeps it, and one deliberately declaring a short budget stays
held to it. Declare an explicit timeout only where the number is part of what the
test asserts; otherwise take the floor.
Do not set this in
bunfig.toml. Bun has no[test] timeoutkey and ignores one silently rather than erroring — a 6s test still fails undertimeout = 20000.BUN_TEST_TIMEOUTis ignored too. The--timeoutCLI flag is the only mechanism that works (re-verified on Bun1.4.0-canary.1and1.4.2: a 6s test still times out at 5000ms under both, and passes under--timeout 20000).
Raising the floor is not a substitute for fixing a slow test. Prefer removing the
cost — replay migrations against :memory: rather than a temp-dir SQLite file,
and reach for a real filesystem only when the test is about the filesystem.
Parallelism
test:unit in apps/api passes --parallel=1 and test:coverage:unit passes
--parallel=4. Read the first as “one worker, isolated” rather than “no
parallelism”: Bun’s --parallel=N runs files in N worker processes and
implies --isolate, which gives each file a fresh global object. Isolation
is the load-bearing half. (test:unit then runs that one isolated worker
several times, as separate processes: see API unit workers.
The coverage lane goes wide inside one invocation instead, which
Bun 1.4.3 made safe.) Bun’s default — no --parallel at all — runs every file in one process
off a single module graph, where the in-memory database from
setupTestEnvironment() and any mock.module registration outlive the file
that made them.
The api coverage lane is two invocations, with opposite settings:
test:coverage:unit runs --parallel=4, test:coverage:integration takes no
flag at all, and test:coverage delegates to
scripts/ci/run-workspace-coverage.ts, which runs both lanes and merges their
LCOV slices into the one staged coverage/api/lcov.info. The orchestrator is
there instead of an && chain because a chain lets one failing unit test skip
the integration lane and the merge: the red shard then uploads no integration
JUnit and no api LCOV, and a failure shared across the shards kills the merge
job on a missing input rather than reporting the test failures. Every lane runs
whatever the earlier ones did, the merge runs over whatever slices exist (none
is not an error), and the exit code is the first failing lane’s. The split
itself exists because the unit
suite cannot live without isolation (the 172-failure table below) while the
integration suite cannot safely live inside it — Bun’s isolate machinery is
what intermittently hangs the whole invocation in CI (see The isolate runner can hang), and the spawn-heavy integration
files are where the wedge has been observed.
Measured on apps/api/tests/unit (3437 tests, 301 files), Bun
1.4.0-canary.1, --timeout 15000 throughout:
| Invocation | Result |
|---|---|
--parallel=1 (what the lane runs) |
3437 pass, 188.8s |
--isolate alone |
3437 pass, 186.7s — isolation alone is sufficient |
| neither | 172 fail, 3263 pass, 128.7s |
Dropping the flag costs 172 failures, not the seven this table used to record.
The seven are still there — the same turn-recovery and checkpoint tests, now
timing out at the 15s floor rather than the old 5s default — but they are 7 of
172. The other 165 fail in under 2ms, on assertions rather than wall clock:
todo repository, requiresExternalDisclosure, external turn controller and
external session manager account for most of them. So the mode does not merely
buy failures that read as unrelated flakes; it buys a suite that is wrong about
its own subject matter. Nor is it a race — repeated runs return the same count.
test:integration deliberately does not take the flag. The lane passes with
--isolate (779 pass), but it costs 268s against 75s without — 3.5x, on a CI job
already using 7 of its 10 minutes. Isolation is the better default and the wrong
trade here; prefer substituting through production’s own seams over mock.module
in that lane, so nothing needs a fresh global to stay correct.
Reproducing a cross-worker race
Raising the worker count is the only way these failures appear, and two things decide whether you find one or watch eight clean runs go by:
- Match CI’s core count. CI runners are 4-core; a 28-core dev machine
schedules a different race population entirely. Pin the run —
taskset -c 0-3 bun test --parallel=4 tests/integrationreproduced on its first attempt where unpinned loops on the same machine had gone clean eight times in a row. - Capture whole logs. A file that aborts under worker parallelism reports
through its worker rather than the reporter, so grepping the live output for
(fail)misses it. Redirect to a file per run and read the failures after.
Run the whole lane, never one file: by construction these failures land in files other than the one at fault, and the signature is a handful of failures plus a block of tests that never ran.
Never bind a well-known port in a test
A fixed port is one machine-wide resource, so two files that want it cannot both
run. Ask for port 0 and read the bound port back — createManagedProcessFixture
and startOAuthLoopbackServer both report theirs, and the ChatGPT sign-in flow
builds its redirect URI from the bound port rather than from the registered
constant so tests can take an OS-assigned one
(setChatGptLoopbackPortForTest). Before that, apps/api/tests/integration at
four workers failed 5 runs in 24 with a spurious 503 from whichever file lost
the race for 127.0.0.1:1455; after, 22 runs in 22 were clean locally and 12 of
12 on a CI runner. A test that needs a busy port should bind one itself and
point the code under test at it.
The same rule covers paths: mkdtemp rather than a static name under
tmpdir(), and the managed test config directory is scoped by pid and
BUN_TEST_WORKER_ID and removed in afterAll (process.on('exit') does not
run under the Bun test runner).
Known upstream blocker on the unit lane
Lifted on Bun 1.4.3. The coverage lane runs --parallel=4; what follows is
the record of what blocked it and of what the release did and did not fix.
Before 1.4.3, tests/unit aborted whole files intermittently under worker
parallelism, losing every remaining case in the file it hit. It was not a
property of one machine — it reproduced on GitHub’s runners:
| Host | Invocation | Runs affected |
|---|---|---|
ubuntu-latest, 4-core, Azure kernel |
--parallel=4 |
2 of 36 |
| WSL2, pinned to 4 cores | --parallel=4 |
4 of 10 |
| WSL2, pinned to 4 cores | --parallel=8, to a file |
4 of 12 |
| WSL2, pinned to 4 cores | --parallel=8, into a pipe |
2 of 8 |
A runner run took ~58s at four workers against 232–248s at one.
Re-measured on 1.4.3+c6da4a4d3 (2026-10-12, WSL2 pinned to four cores, 437
files, output to a file):
| Invocation | Runs clean | Wall time |
|---|---|---|
--parallel=4 tests/unit |
12 of 12 | 75–82s |
--coverage --parallel=1 tests/unit |
2 of 2 | 270–271s |
--coverage --parallel=4 tests/unit |
3 of 3 | 79–80s |
coverage lane flags, --parallel=1, per --shard=i/4 |
4 of 4 | 65–69s |
coverage lane flags, --parallel=4, per --shard=i/4 |
4 of 4 | 19–20s |
Every run passed the same 5,380 cases with no epoll_ctl, no crashed worker and
no hang. At either width the four shards claimed the same 437 files once each,
split identically. Covered lines are the same at one worker and at four
(61,807). Coverable lines are not: the merged four-shard LCOV reports 77,116 at
one worker and 77,065 at four, and two source files moved by a line or two
between otherwise identical four-worker runs. That is the run-dependent LF:
of Merging is not concatenation, and it puts
the lane’s line coverage 0.05pp higher at four workers — inside the QA
verdict’s 0.1pp epsilon, and worth knowing when a coverage delta that small
shows up.
The pinned soak is strong evidence against the 4-of-10 rate it replaces and weak
evidence against the hosted 2-of-36 one: twelve clean runs happen about half the
time at that rate. CI is the soak that settles it. If the abort returns there,
it is this section’s error below, and the lane goes back to --parallel=1.
What blocked it:
The error is always
error: EEXIST: file already exists, epoll_ctl
at new WriteStream (internal:fs/streams:244:58)
— Bun building a process.stdout/process.stderr WriteStream for a fresh
isolate on a descriptor the process-wide epoll set still holds from the previous
one. The victim is whichever file is loading at the time, so it is never a defect
in the test that reports it, and it is not fixable from this repository.
It is oven-sh/bun#37968, reproduced and root-caused upstream — a leaked epoll registration on a reused stdio descriptor at the isolate global swap. Note that the issue recommends redirecting to regular files as a workaround; that made it worse here, not better, so measure before adopting it.
Two patches address it from opposite ends, and only one has shipped:
- oven-sh/bun#44581, in 1.4.3,
closed the issue. A finished file’s
process.stderrsink is released by a garbage collection; when that ran inside aBun.spawnSynccall, the VM was pointed atspawnSync’s own event loop, so the sink’sEPOLL_CTL_DELwent to the wrong epoll and the entry stayed in the main one. The next dup of the stdio fd to get that number failed withEEXIST. The patch stops repointing the VM, so the release reaches the right epoll. The same swap was behind thespawnSyncspin described under The isolate runner can hang. - oven-sh/bun#38008, still open, ends the outgoing file’s stdio sinks at the global swap so nothing leaks in the first place.
The leak is still there; the collision is what went away. Counting how many of an isolate’s fds point at the same pipe as fd 2 — PR #38008’s own regression test — gives
4 6 8 10 12 14 16 18across eight files on1.4.0-canary.1+32e87032b, on1.4.2+744846f84and on1.4.3+c6da4a4d3alike: two leaked per file, still climbing, under--isolateand--parallel=1(a flat4without isolation). Only #38008 flattens it. So 1.4.3 removes the one known path from that leak to a failure, not the leak; a suite with thousands of files per worker still accumulates descriptors.A release note is not proof a patch shipped. Check the tag:
gh api repos/oven-sh/bun/compare/<merge-sha>...bun-v<version> --jq .statusanswersaheadwhen the tag contains the merge commit.
Do not read the stack frames below new WriteStream as the cause. They usually
point at google-logging-utils inside google-auth-library’s module init
(reached from @google/genai), because that package copies the process module
namespace and so materializes both streams eagerly. Loading @google/genai
lazily removes those frames and the aborts continue — measured, not assumed. It
is the messenger.
Redirecting the run to a file makes it likelier but is not the cause; it happens through a pipe too, which is what CI gives it.
So before 1.4.3 the unit lane could not take Bun’s in-process worker
parallelism (the integration lane could: 12 of 12 clean at four workers on a
runner, 50.6s against 71–75s unflagged). Its plain run took separate processes
instead, each of them a one-worker bun test that never shares an isolate with
another, and still does: see API unit workers.
The isolate runner can hang
The abort above is the loud failure. The quiet one: a bun test in isolate
mode — including --parallel=1 — sometimes never exits. Every lane that
finished shows its summary, the log freezes, and the job burns its whole
timeout-minutes before the runner kills orphaned bun/turbo processes. A
milder form of the same wedge shows up as spawnSync git --version stalling
15s+ inside integration files (visible via MANGOSTUDIO_SPAWN_DIAGNOSTICS),
timing out tests with killed 1 dangling process.
Observed on stock Bun 1.4.0 across at least seven CI runs (different PRs and
main) in the week after the 1.4.0 pin, always in the api coverage lane — the
only isolate-mode lane in the shard matrix. Upstream this is
oven-sh/bun#39709 (isolate
runner never exits after its per-file work; dropping --isolate removes it)
and oven-sh/bun#39584. In the
234-run soak recorded above, the only build with zero hangs across 85 runs was
the oven-sh/bun#38008 build, so
that patch is the unblock for this too.
Still true on the 1.4.2 pin (re-checked 2026-09-27). None of #38008, #39709, #39584, or #37190 — the candidate fix for #39584 — is merged upstream; #39709 has no fix in flight at all. Both mitigations below stay. What 1.4.1 did ship is adjacent and easy to mistake for the unblock:
bun test --isolateno longer keeps a finished file’s module graph alive when that file calledmock(),spyOn(),mock.module()orBun.plugin(). That is a memory leak, not the hang — worth having on a lane that mocks in nearly every file, but it does not retire the watchdog.- A
bun test --parallelhang when a worker exited before finishing startup. Narrower than #39709, which parks after every per-file child has finished. - 1.4.2 closed a
FileSinkdouble-close on a failed epoll registration. Same file, different bug: #38008 is a leaked dup on a successful one.
Re-check the merge state of #38008 before reading a quiet lane as a fix.
Re-checked on the 1.4.3 pin (2026-10-12). #39709 is still open with no fix
in flight, and #38008 is still unmerged. What 1.4.3 did ship is
oven-sh/bun#44581, which removes
the spawnSync spin — the milder form above, where a finalizer run inside
spawnSync drifted that loop’s poll count until a later call spun at 100% CPU
to its timeout — along with the isolate realm fencing in #41831, #42110 and
#44279 and the crashed-worker cleanup in #41467. In the first 65 CI runs on the
pin, 460 shard test steps had a median of 48s and a maximum of 120s; none
reached the watchdog’s bound, so it killed nothing. The watchdog stays until
#39709 closes: the runner-never-exits form has no fix, and a quiet week was
also what the lane looked like before it first hung.
Two mitigations are in place until it closes:
- The api integration suite runs outside isolate mode (see Parallelism).
- Every CI test invocation runs under
scripts/ci/run-tests-watchdog.ts, which kills the invocation’s process group after a per-attempt bound and retries once, restoring the timings baseline first so the retry computes the same shard partition as the other shard jobs. Test failures or unhandled errors reported before the hang still fail the invocation after a clean retry, even when crash retries are disabled.
Why “re-run failed jobs” used to re-hang while “re-run all jobs” passed:
the hang follows specific files, and the file-to-shard partition is a function
of the resolved timings baseline. Rerunning only failed jobs reuses the
successful resolve-timings job’s output — the identical partition, so the
trigger files land on the same shard again. Rerunning all jobs re-resolves the
baseline, which usually rotated in the meantime, moving or splitting the
trigger files.
Sharding
Sharding came first because --parallel=N was blocked and --shard=i/N was
not. Workers put concurrent isolates inside one Bun process, which was the
precondition for the abort above. A shard is a subset of files run by a process
that never shares itself with another shard, so that precondition never exists.
The two compose, and since Bun 1.4.3 they do: each shard runs the api unit lane
at --parallel=4, and --timings balances both the shard split and the
workers inside it.
What sharding does not sidestep is the isolate runner hang: that needs no concurrency, only isolate mode, so it rode along into the shard jobs until the api integration suite left isolate mode. More shards is not a defense either — it only reshuffles which shard the trigger files land on.
bun run test --coverage --shard=i/N splits every sharded lane at once;
each takes the flag through MANGOSTUDIO_BUN_TEST_ARGS. --shard requires
--coverage: it is the only lane with a merge step behind it, and a shard of
any other lane would run a fraction of the files and exit 0.
The frontend lane does not shard. Bun’s LCOV cannot be reassembled from
slices (see Merging is not concatenation), so
its coverage has to come from one invocation. CI runs it whole in its own job
via bun run test --coverage --only=frontend, parallelised in-process instead:
--parallel=4 --isolate, adopted after a 12-run soak of the full 167-file
suite pinned to four cores came back 12/12 clean — 1397 pass / 0 fail every
run, zero epoll_ctl, zero hangs, ~34s per run against a ~102s serial
baseline. The lane registry (scripts/lib/test-lanes.ts) carries the
sharded flag, and test-lanes.unit.test.ts pins that the frontend’s script
cannot even receive $MANGOSTUDIO_BUN_TEST_ARGS.
The soak mattered because oven-sh/bun#37968 (the fd-leak/
epoll_ctlabort) was open upstream when the lane was adopted. Bun 1.4.3 closed it by removing the collision, not the leak (see Known upstream blocker on the unit lane). If CI ever reproduces it, drop to--parallel=2first — parallelism divides the stale-registration accumulation across workers, so serial--isolateconcentrates rather than avoids it.
CI runs three shards, the frontend job, and one merge job
(.github/workflows/test.yml). Where the sharded run’s time went when the
split was designed, measured on run 32331139863 (four-core runner, Turbo
running the lanes concurrently, the frontend then still on Vitest at 189.4s):
| Lane | Files | Duration |
|---|---|---|
apps/api bun test --coverage --parallel=1 |
398 | 278.3s |
apps/runtime |
63 | 26.4s |
root test:scripts |
81 | 16.3s |
apps/shared |
54 | 1.8s |
The shard boundary is a slice of every sharded lane rather than one job per
workspace: a per-workspace split leaves apps/api alone at 278s.
The count before the current one came from five first-attempt hosted CI runs
each at 4, 6 and 8 shards on 2026-10-11/12 UTC. The runs were interleaved sequentially on
frozen source 683f69a,
with Bun 1.4.3 and the shipping native runtime profile. The variants changed
only the count, matrix and job name. The latency rule fixed before testing ranked
counts by median from workflow run_started_at to completion of Test / Merge shard results, with Test job runner-seconds breaking a tie. A shard test step at or
above 147 seconds would reject that count, preserving 30% headroom under the
existing 210-second watchdog.
| Shards | Median Test end | Observed range | Median Test runner-seconds | Median summed shard queue | Worst shard test step |
|---|---|---|---|---|---|
| 4 | 291s | 246s to 902s | 588s | 8s | 120s |
| 6 | 288s | 225s to 306s | 678s | 121s | 78s |
| 8 | 342s | 277s to 1248s | 759s | 433s | 62s |
Test runner-seconds sum the elapsed execution of every Test / job, including
the frontend, timings resolver and merge. Summed shard queue measures each
numbered shard’s created_at to started_at; it is aggregate waiting, not
the workflow’s critical path.
Four was the count that comparison deployed, to reduce runner demand. Its observed median Test end was 51 seconds lower than eight, with 171 fewer Test runner-seconds. Six ranked first under the latency rule, but its three-second lead over four is small relative to the observed variation. Four used 90 fewer Test runner-seconds than six and had less aggregate queue waiting. Its worst shard test step stayed below the 147-second rejection limit. The shard watchdog was then raised from 210 to 240 seconds, twice that 120-second worst step, so a loaded runner has more room before a slow shard is killed as a hung one. Two 240-second attempts still fit the job’s 10-minute timeout: the other steps took at most 45 seconds in these runs. The cohort ran under the 210-second bound and did not exercise the new one. This cohort does not establish a universal optimum or isolate shard count from ambient account load. Timing baseline keys varied, and each eight-shard run restored one root Turbo task while the four- and six-shard runs missed all root tasks. Manual dispatch skipped QA Metrics, which requires normal PR CI separately.
Every run retained the same case identities and outcomes: 13,981 passed and
82 skipped. Coverage source-file inventories matched, and every four- and
six-shard run retained the initial control’s positive-hit lines. The final
eight-shard control
had 28 fewer positive-hit lines in shared’s instance-reader.ts after its
timings baseline split related test files across processes. Cases and outcomes
still matched. Source and raw-report inspection support a within-control LCOV
attribution variation; Bun’s precise internal mechanism was not reproduced.
The sample remains in the comparison with that reviewed limitation. This audit
does not establish full branch or function coverage equivalence.
The comparison predates the subsequent SDK and external-notice changes on main. The chosen configuration is validated on updated source separately; these timings do not compare the new workload across counts. Re-measure after a runtime profile or CI concurrency/topology change.
The current configuration uses three shards with the api unit lane at
--parallel=4. The comparison above ran that lane at one worker per shard, so
it does not rank three. The initial three-shard estimate used per-lane times
from one four-shard run on main
(38202725009),
where the api lane runs unit then integration back to back and root scripts
runs beside it:
| Shard | api unit | api integration | root scripts | Test step |
|---|---|---|---|---|
| 1 | 50s | 32s | 58s | 82s |
| 2 | 55s | 30s | 65s | 86s |
| 3 | 39s | 29s | 30s | 68s |
| 4 | 46s | 73s | 25s | 119s |
The initial estimate assumed the unit lane would get about 2.5x from four workers on a runner shared with root scripts, against 3.4x on four idle local cores. Redistributing those totals over three shards gave roughly 25s of unit, 55s of integration and 60s of root scripts per shard, a step near 80s.
The parent’s first hosted run at four unit workers, still at four shards,
was 38208603805,
on source 74f557a5:
| Shard | api unit | api integration | root scripts | Test step |
|---|---|---|---|---|
| 1 | 21.0s | 38.1s | 62.7s | 62.8s |
| 2 | 25.4s | 31.2s | 72.9s | 73.0s |
| 3 | 27.7s | 29.8s | 43.3s | 57.8s |
| 4 | 19.2s | 73.6s | 28.3s | 93.2s |
The unit lane improved about 2.0x between these observations, and root scripts bounded two of the four shards. Redistributing the newer totals over three shards gives about 31s of unit, 58s of integration and 69s of root scripts per shard. Unit and integration run back to back, so this projects to a step near 90s. A shard drawing both slow integration files and a long root slice could exceed 110s. These runs are on different source revisions and do not establish a paired speedup or a distribution for three shards.
One fewer shard removes a runner job and its roughly 30s of fixed setup. Total runner time and Test completion still need a hosted three-versus-four comparison with the same source, worker width and timing baseline. Four shards already waited a median 8s in aggregate in the earlier cohort; that queue figure is historical, not a saving predicted for this configuration. Reject three shards if any shard test step reaches 147s or Test completion is clearly worse than four shards over several first-attempt runs. Keep the 240s watchdog bound while collecting that evidence.
Two shards remains a later candidate. The newer totals project to about 133s of unit plus integration per shard before imbalance, close to the 147s rejection limit. Integration parallelism would need separate qualification; the lane stays unisolated while oven-sh/bun#39709 remains open. The unit lane’s hosted soak does not change that gate. Root scripts could then become the bound, so widening integration alone would not establish a two-shard win.
Changing the count requires three matching edits: SHARD_COUNT, the matrix
list, and the shard job’s name. env is unavailable in jobs.<id>.name, so
the /3 there cannot interpolate the value.
Root scripts workers
The unit phase of bun run test runs test:scripts:workers through
scripts/run-test-workers.ts --lane=root. The command after -- remains
bun test --timeout 15000 scripts. Four workers is the POSIX default, capped
at half the CPU cores with a minimum of one. Windows defaults to one;
MANGO_TEST_WORKERS=<1-8> overrides either default.
The original test:scripts command remains serial for coverage, CI shards,
and changed-file selection. Its report is .mango/artifacts/junit/root.xml;
the plain workers merge into .mango/artifacts/test-workers/root.xml, away
from coverage evidence. The worker task is cached with its runner import
closure and test inputs. Worker width is a Turbo passThroughEnv, so changing
width reuses the same successful result.
bun test scripts matches paths containing scripts, including two API
files outside scripts/: wsl-runtime-scripts.integration.test.ts and
runtime-slot-scripts.test.ts. The root lane inventories both. The runner
requires every file exactly once and complete, nonempty worker reports before
accepting its merged report. Serial comparisons qualify case and outcome parity.
The width comparison used three serial samples and three at each candidate
width on source 49bb715f686620f94e04e650c229ca34a8b5ca96, under WSL with direct
lane commands and no Turbo hits. All reports contained 2,599 cases across 182
files: 2,586 pass, 13 skip, zero fail. The rule selected the smallest clean
width within 10% of the fastest median; four was within that band and reduced
median wall time by 54.59% from serial. One cell overlapped known external host
activity and was replaced under the original conditions, preserving its
original parity evidence.
| Width | Median wall | User CPU | System CPU | Linux process-tree peak RSS |
|---|---|---|---|---|
| Serial | 211.51s | 61.89s | 39.24s | 562,796 KiB |
| Four | 96.05s | 68.99s | 42.13s | 990,940 KiB |
| Six | 109.21s | 69.81s | 40.67s | 1,330,468 KiB |
| Eight | 90.12s | 68.27s | 39.87s | 1,790,156 KiB |
These medians compare the same runtime feature set: the measurement runtime was built together with the fake Cursor example, enabling the SDK testing feature. They do not qualify a default-only runtime or native Windows width. Process-tree RSS counts shared pages per process and excludes native PowerShell memory. Final correctness uses a separately built default runtime, whose checksum must remain unchanged while building the fake example.
The actual root workers and launchers retain --no-orphans. Named handshake,
signal, watchdog and cancellation fakes use fixtureChildEnvironment() with
Bun’s inherited orphan marker set to zero, so their deliberately surviving
children remain available to the existing lifetime and cleanup assertions.
The regression cases run through the actual root worker and require the
selected outcome plus the full lane census.
API unit workers
bun run test (and bun run --filter @mangostudio/api test:unit) splits the API
unit lane across worker processes. The script is
scripts/run-test-workers.ts --lane=api-unit -- bun test --timeout 15000 --parallel=1 tests/unit: the command after -- is the serial lane, unchanged,
and the runner starts it N times with --shard=i/N.
| Worker owns | How |
|---|---|
| its files | Bun’s --shard=i/N: round-robin over the sorted file list, so a file set always splits the same way |
| its home | each worker starts through scripts/with-test-home.ts, a <tmpdir>/mangostudio-test-home-<random> of its own, which also scopes the managed config directory |
| its database | the preload’s :memory: database, one per process |
| its ports | every unit test binds port 0 (audited: no fixed-port bind under tests/unit or tests/support) |
| its report | --reporter=junit to worker-<i>-of-<N>.xml in a directory made for this run and removed at the end |
Each worker also runs with --no-orphans, so the bun test behind a launcher
that gets a SIGKILL (the last step when a cancelled worker ignores SIGTERM)
exits with it, and takes down what its tests started. (--parallel=1 runs the
files in that one process; there is no separate test worker.) The launcher cannot
forward that signal and cannot clean its home, so that home stays in the OS temp
directory, as the launcher’s section below says it does.
Width is six, capped at floor(cpus/2). The cap is there because the lane
shares the host with the other lanes of bun run test (the root scripts, frontend
and shared lanes run beside it, the integration lane after it), and the 15s case
timeouts are what an oversubscribed CPU breaks first. MANGO_TEST_WORKERS=<1-8>
overrides it; 1 is the serial lane. The variable is a Turbo passThroughEnv of the
task, not an env entry: the merged result is the same at any width, so it stays out of
the cache key. The runner and everything it loads are in the task’s inputs
(apps/api/turbo.json; api-lanes-hermetic.unit.test.ts derives the import closure and
fails on a file missing from it).
On Windows the default is one worker, the serial lane. With six concurrent processes
chatgpt-loopback.test.ts failed with ECONNRESET in 12 of 72 runs (0 of 40 serially),
and a bare Bun server that sends Connection: close, or stops while it answers, was reset
in about 40% of requests under six processes there (3 of 100 in one process), with no
MangoStudio code involved. That is a connection-teardown problem to understand before
Windows goes wide. resolveWorkerCount carries the win32 branch to lift, and
MANGO_TEST_WORKERS=6 overrides it meanwhile.
The lane fails, naming api-unit worker i/N, when a worker:
- exits non-zero, is killed by a signal (an OOM kill reaches the runner as
128 + 9), cannot be started, or is cancelled (an interrupt of the runner stops every worker, then exits128 + signal); - exits 0 but wrote no report, wrote a cut-off one, or ran no case.
A failing worker does not stop its siblings, because the serial lane also
reports every failure. After the workers end, the files their reports name must
be exactly the files under tests/unit, each in one report: a file in two
reports, in none, or outside the directory fails the lane and names the first.
A test file that registers no case at all is invisible to JUnit and so fails
that last check; register a skipped case instead of an empty file.
The merged report is written to .mango/artifacts/test-workers/api-unit.xml
only when every worker left a whole report, and the previous one is removed
first, so a failed lane (or a selected run, which writes none) cannot leave the
last green run’s totals. It is deliberately not the lane’s junitPath under
.mango/artifacts/junit/: that is the coverage run’s evidence, read by the QA
gate and the shard merge, and a plain run must never leave a green report there
for a coverage lane that died before writing its own.
It holds every worker’s <testsuite>, with the header counters summed. The case
and outcome set equals the single-worker lane’s, which
test-workers.unit.test.ts asserts with named fake workers (and names the first
missing or extra case when it does not).
Things this deliberately does not do:
- Coverage stays one invocation.
test:coverage:unitnever goes through this runner: a union of per-process LCOV carried 3,501 extra zero-hit line keys across 249 sources on two strict comparisons, with identical cases and covered lines, and nobody has found the cause. It goes wide with Bun’s own--parallel=4instead, where one invocation writes one LCOV and there is nothing to union. CI’s Test job is coverage with shards, so this runner does not change it. - No timings file. The split would then depend on an untracked file that every coverage run refreshes, and rotate between runs (see Why the unisolated lane opts out too for what that costs). Round-robin is a function of the file set.
- A selected run is one process.
bun run test --changedappends--changed=<sha>after the lane directory; anything there selects files, so the runner starts the command through the launcher as it always was. A split run would hand some workers no file, and the file-set check would have nothing to compare.
A fixed port or a fixed path under the shared temp directory is what would break here. The rule in Never bind a well-known port in a test therefore covers the unit lane too: a test that breaks that rule fails only when two workers happen to run it side by side.
The lane trades memory for wall time. Measured on a 34-CPU WSL host (Bun 1.4.2, the same 5,231 cases in 428 files at every width, merged JUnit equal to the serial one, median of three interleaved runs, lane run directly):
| Workers | Wall | CPU (user+sys) | Peak resident memory, process tree, sampled |
|---|---|---|---|
| 1 | 253.2 s | 328 s | 1.1 GB |
| 4 | 76.0 s | 342 s | 1.8 GB |
| 6 | 48.6 s | 306 s | 2.4 GB |
| 8 | 38.1 s | 295 s | 3.1 GB |
The default is six: the rule fixed before the 6 and 8 runs was to move
off four only for a width whose median wall time was at least 10% lower with every
report equal and no failure, and to take the smaller if both qualified. Both did
(0.64 and 0.50 of the four-worker time), so eight is available through
MANGO_TEST_WORKERS=8 for a run of this lane alone, but it is not the default:
inside bun run test the unit phase is bound by the root scripts lane (about
160 s), which a wider API lane does not shorten. At every width one worker holds
the wall, 17% to 25% above the mean, and it is the same one run after run, because
the split is a function of the file set.
scripts/tests/test-workers-shard-contract.unit.test.ts pins what the check relies
on from Bun (every file in exactly one shard, the same split twice, the same files
bun test discovers, and that --no-orphans takes a killed launcher’s bun test
and the child one of its tests started down with it) by running real Bun over a
throwaway tree.
API integration workers
bun run test and bun run --filter @mangostudio/api test:integration also use
scripts/run-test-workers.ts. Integration defaults to four workers, capped at
half the available cores. MANGO_TEST_WORKERS=<1-8> overrides the width. Each
worker runs the unchanged unisolated command, bun test --timeout 15000 tests/integration, over its deterministic --shard=i/N slice. It does not use
Bun’s in-process --parallel or a timings file. Coverage keeps its existing
single-process, unisolated command and CI shard matrix.
The integration workers own their temporary homes and managed config paths,
in-memory databases, OS-assigned ports and separate reports, as the unit workers
do. Helpers that start a real server or runtime create a fresh scratch directory
and request port 0. An ambient MANGO_HOME is removed before starting a worker,
so it cannot redirect every runtime to the developer’s shared home. Selected
runs use the same filter before taking the serial fallback. A fixture
that needs a runtime home sets its own scratch path on the child.
On POSIX each integration worker leads its own process group. After it exits,
the runner gives descendants two seconds to finish and checks for live members
of that group. On Linux it also looks for the worker’s unique environment token,
which finds children that started another session. A live descendant fails the
lane with expected live descendants: 0 | received: N, its PID and command. The
runner kills it before returning the failure. An unreadable process table also
fails. Zombies are excluded because they hold no ports or locks. --no-orphans
is omitted on this path so Bun cannot kill the evidence before the check.
Cancellation sends SIGTERM to the whole worker group and, on Linux, to token-bearing children that left the group. It escalates both to SIGKILL after at most two seconds, which leaves settlement its full grace before the outer runner’s five-second deadline. Output readers have a bounded drain grace so an inherited pipe cannot hold the lane open forever. The worker, settlement and cancellation regression tests use real fake processes as well as named process-table fakes.
Windows retains a serial default with --no-orphans. The POSIX settlement guard
does not run there; Windows integration concurrency and leftover diagnostics
are unqualified. The override is available for explicit experiments. On macOS
the guard sees group members through ps, but has no Linux environment-token
fallback. A process that starts a new session and discards its inherited
environment is outside the Linux guard’s evidence too.
The report goes to .mango/artifacts/test-workers/api-integration.xml, leaving
coverage evidence alone. Every owned file must occur in exactly one whole
worker report. Named fake workers compare the full merged case and outcome
multiset with the serial lane, including skips. The live soak compares the same
sets and records post-run descendants independently of the runner’s guard.
Balancing the split by time
An earlier revision of this section concluded that no timings file was needed and that
--timingswould buy “under 1%”. That was reasoned from the slowest single file (20.2s of 348s) as the lower bound on the critical shard. The reasoning was wrong: the imbalance does not come from one big file, it comes from five lanes each round-robining their own file list, and the sums landing unevenly. Re-measured from real per-file durations on Bun 1.4.0:
| Split | Critical shard | Spread across shards |
|---|---|---|
Round-robin (Bun’s default --shard) |
69.6s | 34.5s |
--timings, each lane balanced |
58.6s | 16.4s |
| Perfect joint balance (not reachable) | 45.0s | 0s |
Bun’s --shard=i/N is a round-robin over the alphabetical file list — file
at index k runs on shard k % N + 1 (verified against apps/shared). Given
--timings=<file>, it balances by measured duration instead.
The joint optimum is out of reach because each lane balances independently, and
they all put their own slowest file on the same shard index — apps/api (8.7s),
root (9.6s) and apps/runtime (6.5s) all land on shard 1, which is why the
balanced critical shard is 58.6s rather than the 45.0s mean.
The timings file is a correctness dependency, not just a speed one. Under
round-robin, all N shards derive the same partition independently and cannot
disagree. Under --timings the partition is a function of a shared file, so N
shards reading different bytes will not cover the file set between them — some
files run twice, others not at all, and every shard still exits 0. Three things
hold that down:
- The resolver pins one exact trusted-main cache key for every reader in a run.
Only the merge job on a successful
mainpush writes a new baseline, after both test jobs pass and their receipts, coverage and timings merge successfully. PR runs never save timings: their run-suffixed keys have no future reader. scripts/ci/merge-timings-shards.tsfails the run if two shards claim the same file, which is the observable symptom of a disagreement.- A missing file is tolerated by Bun and falls back to round-robin, so a cold
cache degrades to the old behaviour instead of failing. A malformed file is
a hard error — including
{}— so nothing writes a placeholder.
The root //#test:scripts lane opts out. It is the only test task Turbo
caches, and a timings file is untracked, so it cannot enter that cache key: shard
i would restore a root.xml produced when the split put different files on
shard i. Balancing it is worth 0.5s within the lane and 0.6s overall (59.2s vs
58.6s critical shard), which does not pay for that hazard.
Why the unisolated lane opts out too
api-integration also opts out, for the opposite reason: everything above
assumes each file starts from a fresh global, and that lane is the one where it
does not.
It is the only lane that is both sharded and unisolated. Its files share one
module graph, so what a shard runs a file beside decides what that file
inherits — the in-memory database from setupTestEnvironment(), any
mock.module registration, the memoized getAuth(). A timings-balanced
partition is a function of a file that --update-timings refreshes every run and
CI caches across runs, so it rotates: a file’s companions change from run to
run, and the shard a file lands on moves with the baseline. A leak then surfaces
as an intermittent failure in a file nobody touched. This is not hypothetical —
PR #951’s own first CI run failed on shard 3 because app.ts snapshotted
getConfig().corsOrigins at module evaluation, so which file imported app
first decided what the CORS gate saw.
Round-robin is derived by each shard from the file set alone, so the same commit
always produces the same partition and a leak is reproducible from the SHA.
Measured on the 101-file lane (Bun 1.4.0, 85.3s total, --shard=i/8):
| Split | Critical shard | Reproducible |
|---|---|---|
--timings, balanced (rotates) |
20.3s | no |
| Round-robin (what the lane now runs) | 29.9s | yes |
+9.6s, and cheap because one file (spawn-runtime-child, 20.3s) is 24% of the
lane and is the floor under any split — the balancing had little left to win.
Determinism verified rather than assumed: two consecutive no-timings runs
produced byte-identical partitions, union 101 files, no duplicates.
Two things this does not buy, so do not read more into it:
- The partition is stable for a given file set, not across them. Adding or deleting an integration file shifts the whole stride.
- Determinism is not immunity. It makes a leak reproducible; it does not stop one. Detection is the randomized-order nightly’s job, and a soak of 48 shard runs across six partitions (two round-robin, four rotated) came back 0 fail — the class is not currently manifesting, which is the evidence for taking this option over unsharding the lane.
If the nightly’s unisolated lane does go red on cross-file leakage, the
escalation is to unshard api-integration and run it whole in its own CI job the
way the frontend lane does — correct by construction, at 85.3s on the critical
path instead of 29.9s.
Merging is not concatenation
Bun’s per-file LF:/FNF: are run-dependent: a source
file a shard loaded but never exercised reports every line as coverable, while
the same file under a shard that ran its code reports the collapsed set lazy
parsing leaves behind. On apps/shared at --shard=i/3,
src/errors/negotiation.ts is LF:208 LH:0 in two shards and LF:98 LH:92 in
the third. Union the DA: lines and the denominator inflates — 15,303 coverable
lines against the unsharded 14,740, reporting 94.26% where the truth is
97.86%, a coverage regression that never happened.
So scripts/qa-gate/merge-lcov-shards.ts takes each file’s coverable-line shape
from the shard record that covered the most of it, marks a line covered if any
shard hit it, and keeps a positive-hit line that only exists on a non-shape
record (a lazily parsed region another shard exercised). Zero-hit padding from
those other records stays out so the denominator does not inflate. Same corpus:
97.86% lines against 97.86%.
What the merge cannot make exact is function coverage. When the reports
carry FN:/FNDA: records the merge unions them by function identity (a
function hit in two shards counts once), but Bun emits only the FNF:/FNH:
totals (checked on 1.4.2 against every shard artifact of a CI run), so for its
LCOV the union of hit functions is not recoverable; the merge reports the best
shard’s count, clamped to the total, which is a lower bound. Function coverage
for the sharded workspaces (api, shared) is therefore a lower bound until Bun
emits FN: records. It never sums the shards’ totals, so a function is not
double-counted. Measured on
apps/runtime against its own unsharded run:
| Shards | Lines | Functions |
|---|---|---|
| 2 | −0.47pp | −1.60pp |
| 4 | −0.55pp | −2.00pp |
| 8 | −0.54pp | −2.52pp |
Line drift is bounded and effectively flat past two shards. Function drift grows
with N, because the more shards a file spreads across, the further the best
single shard sits from the union. Only line coverage reaches the QA verdict
(scripts/qa-gate/render/verdict.ts, 0.1pp epsilon), so the function number is
a table entry rather than a signal.
This approximation is also why the frontend lane refuses to shard at all: its coverage carries an enforced gate (see Coverage), and a gate on a number the merge can only approximate would drift with the shard count rather than with the tests. Its single whole-run LCOV rides the same merge machinery as a one-input degenerate case — a copy.
The merge is also strict about what it is given. Which job directories owe a
workspace a report comes from the lane registry (expectedLcovShards in
scripts/ci/merge-test-shards.ts): a sharded workspace expects one from every
numbered shard, the frontend one from test-shard-frontend alone. A missing,
unreadable, empty or truncated report fails that workspace’s merge naming the
shard, deletes any earlier merged file, and reaches the QA envelope as
unavailable with that reason — never a lower complete-looking number. The
merge is order-independent: the same set of shard reports writes the same bytes
in any order. The same rule holds one level down: if an api lane fails and
leaves no slice (it could not start, crashed or was killed),
scripts/ci/run-workspace-coverage.ts names the missing slice and stages no
partial LCOV, and the frontend floor check
(scripts/qa-gate/enforce-coverage-thresholds.ts) fails on a missing, empty or
truncated frontend report instead of enforcing floors against it. A shard whose
slice holds no file from a workspace writes no LCOV at all (Bun 1.4.2 writes none
for a zero-file shard), so it now fails the merge as a missing report; every
workspace has far more test files than shards, so that does not occur today. A shard report carries no commit stamp, so staleness is not
detectable per shard; the fragment-level sourceSha check catches a fragment
from another commit.
One transitional report. The PR QA report compares a head against a
mainbaseline. The first PRs after sharding landed compare a sharded head against a pre-shard baseline and show a one-timeline coverage −0.54pp. Oncemainhas a sharded baseline, both sides are sharded and the delta is real signal again. A drop after that is a drop.
JUnit, and the one thing it cannot carry
Every lane writes JUnit to .mango/artifacts/junit/<lane>.xml, and
scripts/qa-gate/junit-results.ts counts <testcase> elements out of the
test-job directories. Counts come from the elements rather than the
<testsuites> header because header conventions differ across runners — Bun
emits tests/assertions/failures/skipped and nests a <testsuite> once
per describe, so summing headers double-counts. A <testcase> is a leaf.
Unhandled errors are the exception. An error raised between tests prints a
# Unhandled error between tests block and a N error summary line and exits
1, while the JUnit report reads failures="0" with no failing <testcase>
(measured on 1.4.0-canary.1, re-verified on 1.4.2). So the failure class in
Unhandled Errors With Green Test Counts
exists only in the log, and each test job — the shards and the frontend job
alike — extracts it with scripts/qa-gate/unhandled-errors.ts before the log
leaves the job. If you ever replace that with a structured source, check the
reporter first rather than assuming the XML grew an errors count.
Bun does not create the parent directory for
--reporter-outfile, and it does not fail the run when it is missing: it printsJUnitReportFailedand still exits 0 (measured on 1.4.0-canary.1, re-verified on 1.4.2), so the lane’s counts go silently to zero. That is whyscripts/test.tscreates.mango/artifacts/junit/before any lane starts — and clears it, so a lane that did not run this time cannot contribute last run’s counts. The QA collector also treats a configured lane with no JUnit file across the shard set asparseMiss, so a missing report cannot become a green suite of zero tests. Bun’sfileattribute is workspace-relative; failed-file counts are per lane soapps/apiandapps/sharedfiles with the same relative path stay distinct. Invoking a workspacetest:coveragescript directly on a fresh checkout skips the directory step; create it first if you care about the report.
Per-lane results and what makes a lane partial
The QA report carries one result per lane (root, api-unit,
api-integration, shared, frontend), rolled up per component and for the
repository, not one number per workspace. The two API lanes stay separate
because they run under opposite isolation settings and fail for different
reasons. The pieces live in scripts/qa-gate/results/; they only read the JUnit
reports and shard-meta.json receipts the run already wrote, and never run a
test.
- Expected job set. It comes from the lane registry and the workflow’s shard
count (
collect-test-metrics.ts <summary> <shards-dir> <shard-count>): the numbered shards carry every sharded lane, each unsharded lane has its owntest-shard-<lane>job. Listing the directories that exist would hide a job that died before its upload step. partial, never a smaller total. A lane ismeasuredonly when every expected job delivered a whole JUnit report and a process receipt that is not a watchdog kill (exit 124). A missing report, a report cut off (no closing</testsuites>, or atestsheader that disagrees with the cases), a missing receipt or a timeout makes itpartial: the counts are a lower bound and the report shows≥. A lane with nothing readable isunavailable, never zero. Bun writes no report for an empty--shardslice, so a missing report is treated as lost; every sharded lane has far more files than shards.- Retries. The current pipeline never puts two runs of a test into one
report:
scripts/test.tsclears the JUnit directory on every start, so a watchdog retry leaves only the final attempt, and Bun’s per-testretryoption reports one passing case with no trace of the earlier failure. What the fold does guarantee is that a test seen in more than one report (a duplicated artifact, another runner) counts once, keyed on file, class, title and line; the later run wins, and an earlier failure it no longer has is kept asrecoveredwith its message. Rows that share all four inside one report (it.eachwith a fixed title) are never collapsed. - Hang retried. The watchdog records
attemptsinshard-meta.json. A hang that a clean second attempt recovered still exits 0, so the lane row carries anote: N shard(s) ran twiceinstead of reading as a first-try pass. It does not change the lane’s state or the verdict. - Non-zero exits.
shard-meta.jsonis per job, not per lane, so a non-zero exit cannot be pinned on one lane; the lane row shows it and the suite-level verdict fails on it.
The verdict policy is scripts/qa-gate/policy/: pass only when nothing
regressed and every head metric is fully measured, fail for a concrete
regression, incomplete for missing, partial or stale evidence or a partial
base. scripts/qa-gate/policy/verdict.unit.test.ts is its truth table.
Randomized order
randomized-order-nightly.yml runs three lanes under
--randomize --seed=<run number> every night. It exists for the one class the
merge gate cannot see: a test that passes only because of what the file before it
left behind, which is a live hazard here while bun test shares one module graph
across a lane’s files.
--randomize shuffles file order as well as the tests inside each file —
verified, not assumed: three probe files run as c, b, a under seed 3 where
seeds 1 and 2 keep a, b, c. File order is the half that matters for leaked
mock.module registrations.
The isolation setting decides which half of the hazard a lane can detect, which is why the matrix carries it per entry:
| Lane | Invocation | Detects |
|---|---|---|
api |
--parallel=1 over the whole workspace |
order dependence within a file |
api-integration |
no --parallel, over tests/integration |
cross-file leakage |
shared |
--parallel=1 |
order dependence within a file |
--parallel=1 means “one worker, isolated” (see Parallelism),
so a fresh global per file is exactly what hides cross-file leakage. The
api-integration entry deliberately re-runs files the api entry already
covered, in the mode the merge gate actually uses — without it, the class this
workflow exists for had no detector anywhere in CI, which is how the sharded,
unisolated lane came to rely on a rotating partition for safety.
It is deliberately not merge-gating. A suite that fails weekly on a different
seed is a bug report; making it block merges teaches everyone to re-run CI, and
that habit outlasts the fix. fail-fast: false keeps one red lane from
cancelling the others’ evidence. A failure logs its seed and uploads the run log
under randomized-order-<lane>-seed-<n>, because the order is the finding.
Each job’s log opens with the exact command to reproduce its own lane; read what
ran before the failing file rather than the failing file itself.
Every lane runs under scripts/ci/run-tests-watchdog.ts with
--retry-on-crash, because the isolate runner can hang and can also abort outright. A hang dies at
the watchdog’s per-attempt bound rather than burning the step budget, and a
crash gets one same-seed retry so the rest of the lane’s file list still runs.
Both are annotated ::warning so an infra failure does not read as an ordering
finding — but neither is allowed to hide one. The watchdog scans the hung or
crashed attempt’s log for Bun’s inline (fail) lines, its N fail summary, and the
unhandled-error signal (# Unhandled error between tests / N error(s))
that JUnit cannot carry, and reports non-zero when any is present even if the
retry came back clean: the retry proves the runner recovered, not that the
failures did not happen. A retried night keeps attempt 1 at
run.log.attempt-1 and uploads it even when the step itself is green, since a
truncated attempt is exactly where the night’s only finding may sit.
What it caught on its first run
The unisolated lane failed 4–5 tests on every seed tried (1, 2 and 3), in four
files that are green in the fixed order. All of them are one of three shapes,
and --randomize reaches all three because it reorders it blocks and
describe blocks within a file as well as the files themselves:
| Shape | Files | Symptom |
|---|---|---|
| Per-user rows written under one fixed user id | settings-agents, settings-app-settings |
a later test reads back an earlier test’s persisted settings |
One fixture built in beforeAll and shared by every test |
chat-todos |
the “empty list” test reads todos a sibling test wrote |
beforeAll seeding rows a sibling describe consumes |
settings-connectors |
SQLITE_CONSTRAINT_FOREIGNKEY → 500 on every persisting route |
The third is the least obvious and the most worth remembering: beforeAll runs
per describe, so a block that authenticates as a user another block seeded
only passes while the runner happens to schedule them in that order. Because
PRAGMA foreign_keys = ON is set unconditionally in src/db/database.ts, a
missing user row is a hard failure rather than a silent one.
The fixes are all “own your own state”, never “truncate these tables”: a fresh
identity per test, fixtures minted in beforeEach rather than beforeAll, and
each describe seeding exactly the identities it authenticates as. Namespacing
means a test that later writes a new table cannot reopen the hole; a truncation
list means it can.
Process-wide state is the exception, because no individual suite can own it.
setupTestEnvironment() reinstalls the canonical test config in both
beforeEach and afterEach: beforeEach alone leaves the last test’s
loadConfigForTest override installed through the next file’s module
evaluation and beforeAll hooks, which both run before any beforeEach. That
window is where an override reaches module-level state — it once dropped
server.allowedOrigins for a whole shard. Suite-local afterEach hooks run
before the preload-registered one, so a local teardown still sees its own
override — describe scopes unwind inner→outer, and hooks sharing a scope run
in reverse registration order, which is what covers a test file’s own top-level
afterEach (root scope, same as the preload’s, registered later). Both verified
on Bun 1.4.0 and 1.4.2. afterAll is the other side of that: it runs after the
preload’s afterEach, so per-file teardown cannot read the last test’s
override.
Code Health
bun run check includes a repository-wide Knip scan. Change-scoped checks run it
when staged or changed files can affect dependencies, package exports, runtime or
test entrypoints, scripts, hooks, workflows, or workspace source. Run
bun run code-health directly while classifying a report.
Files loaded by runtime discovery, process spawning, test configuration, or build
aliases belong in the narrowest entry list in knip.json. Do not hide them with
file ignores. A dependency may be ignored only when repository execution evidence
shows it is loaded outside a JavaScript or TypeScript import:
| Workspace | Dependency | Execution evidence |
|---|---|---|
| root | @dprint/markdown, @dprint/toml, @dprint/dockerfile |
dprint.json loads each package’s WASM plugin from node_modules |
| root | jscpd |
scripts/qa-gate/collect/duplication.ts spawns the installed CLI |
| frontend | tailwindcss |
apps/frontend/src/index.css loads it through the CSS @import syntax |
Browser Smoke
Playwright Chromium suite under tests/browser-smoke/. Covers the full auth flow against a live dev stack on :3001 — one server serving both the API and the frontend.
bun run test:e2e:setup
bun run test --e2eRun bun run test:e2e:setup once on a new machine or whenever Playwright reports a missing Chromium binary. It wraps bunx playwright install --with-deps chromium, so it installs only the browser used by this suite and avoids npx commands.
The CI browser-smoke job runs on ubuntu-24.04 because Playwright 1.60 cannot install Chromium dependencies on Ubuntu 26.04 yet. If you are on Ubuntu 26.04 locally, use an Ubuntu 24.04/22.04 container or VM for the e2e lane until upstream support lands.
playwright.config.ts at the repo root starts both servers via webServer before running tests. In CI it enforces workers: 1 and uploads traces/screenshots on failure.
The suite is hermetic. playwright.config.ts creates a fresh <tmpdir>/mangostudio-smoke-<random> directory per run and starts the hub with HOME (and USERPROFILE) pointing at it, so the database, uploads, checkpoints, run/, logs/, and the config.toml and .env the hub reads all live under <tmpdir>/mangostudio-smoke-<random>/.mango — nothing is read from or written to your real ~/.mango. MANGO_HOME alone would not do this, because it moves only the runtime home. The hub’s storage variables (DATABASE_PATH, UPLOADS_DIR, MANGO_HOME, and the rest) are pinned to the temporary home as well, so one exported in your shell cannot redirect the suite back to real data, and connector tokens go to a file secret store inside it instead of the OS keychain. The hub is bound to 127.0.0.1 (API_HOST), because a fresh home has no config.toml to narrow the default all-interfaces bind and the suite relies on open signup. CARGO_HOME and RUSTUP_HOME keep their real values so the dev server’s cargo build still finds your toolchain. The directory is removed when the run ends.
The suite never attaches to a hub that is already running, because that hub’s home is whatever it was started with. A dev hub on :3001 makes the run fail with “already used”; stop it first. The web server is also asked to shut down with SIGTERM, so the bun run dev → turbo → bun run --watch chain does not leave a hub listening on :3001 afterwards. scripts/tests/browser-smoke-hermetic.unit.test.ts fails, naming the resolved path and the expected <tmpdir>/mangostudio-smoke-<random> shape, if the config ever resolves the hub’s home to anywhere else. To inspect a run’s data, export MANGO_SMOKE_HOME=<tmpdir>/mangostudio-smoke-<name> before the run: that directory is used as given and is not deleted.
The web server is bun run dev --api, which runs cargo build -p mangostudio-runtime --locked before the hub starts, because the hub launches Local as that binary. CI downloads the binary built once for the run instead, and MANGOSTUDIO_RUNTIME_BINARY makes bun run dev skip its own build (see CI runtime binary).
Test scenarios (tests/browser-smoke/auth-flow.spec.ts):
/loginpage renders/signuppage renders- Sign up with a unique random email → lands in authenticated area
- Logout → redirected to login
- Log back in with same credentials → lands in authenticated area
| Lane | Runner | Environment |
|---|---|---|
browser-smoke |
playwright (Chromium) |
real browser + stack |
Binary Smoke
scripts/test-build.ts builds the standalone binary for a target platform, validates
packaging layout, and boots the binary on matching CI runners to exercise core HTTP routes
plus the Cursor deprecation refusal and ChatGPT connector validation.
PLATFORM=linux-x64 bun run scripts/test-build.tsOn Windows hosts, use PLATFORM=windows-x64. The CI Smoke — Binary workflow runs this
script across all six native platform runners.
The runtime leg signs up a throwaway user, posts a Cursor connector with a fake API key, and
asserts the binary refuses it with 410 — Cursor is a deprecated provider, and the refusal
must hold in the shipped executable, not only where a picker hid the option. The response and
the server’s stderr are still scanned for module-resolution failures such as
Cannot find module or ./642.js, which is where a broken compiled binary shows itself. It
also completes a ChatGPT OAuth loopback sign-in against the fake ChatGPT auth/backend server,
then verifies the connector and model catalog. The smoke stays hermetic by pointing
MANGO_CHATGPT_AUTH_BASE_URL / MANGO_CHATGPT_BASE_URL at the fake ChatGPT server. ChatGPT
smoke tokens use MANGO_SECRET_STORE_UNSAFE_FILE_FALLBACK_DIR under the temporary smoke home
so the binary never writes fake tokens to the user’s OS secret store. No real Cursor or
ChatGPT API calls are made.
The ChatGPT smoke needs callback port 1455. When that port is already bound, the script
prints a skip message for the ChatGPT connector leg while keeping the rest of the binary
smoke active.
| Lane | Runner | Environment |
|---|---|---|
smoke-binary |
test-build.ts |
compiled binary on native OS/arch |
Workspace Scripts
API
bun run --filter @mangostudio/api test:unit
bun run --filter @mangostudio/api test:integrationLocal needs the runtime binary. The hub launches Local as the Rust
mangostudio-runtime, so every API test that reaches Local spawns it. Build it first withcargo build -p mangostudio-runtime, or pointMANGOSTUDIO_RUNTIME_BINARYat one; without either, those tests fail withRuntimeBinaryNotFoundErrorand the build command — there is no TypeScript fallback.tests/integration/services/local-rust-runtime.integration.test.tsqualifies Local itself through the real connector: thehostslot, the single-owner attestation, terminal revocation, reaping on disconnect, and a home the TypeScript runtime wrote. No API test imports the TypeScript runtime or spawns it by path;runtime-module-allow-list.test.tsfails the lane if one does.
The cached unit lane keys on the binary’s content.
test:unitis a cached Turbo task. Turbo hashes the pathMANGOSTUDIO_RUNTIME_BINARYnames (the task allowsMANGOSTUDIO_*), never the file behind it, and a defaulttarget/debugbuild is not in the key at all, so a rebuilt or replaced runtime used to replay the pass recorded against the old one.bun run testnow takes the SHA-256 of the binary the tests spawn once, before Turbo starts, and exports it asMANGOSTUDIO_RUNTIME_BINARY_SHA256: replacing the bytes is a cache miss, an unchanged binary is a hit, and neither the path nor the mtime counts. The binary is the oneMANGOSTUDIO_RUNTIME_BINARYnames (a relative path fromapps/api, where the tests run), otherwise the newest oftarget/debugandtarget/releaseunderCARGO_TARGET_DIR, debug on a tie — the resolutionresolveRustRuntimeBinaryand the hub share, pinned byscripts/tests/runtime-binary-digest.unit.test.ts. With no binary the variable is left out and the tests skip or fail with their own message. The hub never reads it, soapps/api/src/lib/config.tsis untouched. The allowlist is shared by everytest:unittask in the same Turbo run, so a new runtime build also re-runs the frontend and shared unit tests.bun run testis the supported entry: a directturbo run test:unitskipsscripts/test.tsand gets no digest, no worse than before.
Run API tests from the workspace.
apps/api/bunfig.tomldeclares the test preload, and Bun resolvesbunfig.tomlrelative to the current directory. Runningbun test apps/api/...from the repo root silently skips the preload — so always usebun run --filter @mangostudio/api test:unit/test:integration, orcd apps/api && bun test. When the preload is skipped, the config layer falls back to an isolated in-memory sandbox (never the real~/.mango) and the harness throws an actionable error, so a wrong-directory run fails loudly instead of corrupting data.
Hermetic home
The API unit and integration lanes run on a throwaway home. Code under test derives
~/.mango, ~/.claude, ~/.ssh and its shell profiles from homedir(), and Bun honours
HOME only when a process starts — a runtime assignment to process.env.HOME is ignored,
so a preload or a test cannot redirect it. The test:unit, test:integration and
test:coverage:* scripts of apps/api therefore start bun test through
scripts/with-test-home.ts, which creates a fresh <tmpdir>/mangostudio-test-home-<random>,
starts the tests with HOME (and USERPROFILE) pointing at it, and removes it when the run
ends. test:unit and test:integration reach it through the
worker runner, which starts every worker through the
launcher, so each worker has a home of its own. That covers bun run test, the CI shards behind the watchdog, the coverage
orchestrator and a direct bun run test:unit from apps/api; the nightly randomized-order
workflow wraps its bun test the same way.
What the launcher sets, besides the home:
CARGO_HOME,RUSTUP_HOME,BUN_INSTALLandBUN_INSTALL_CACHE_DIRkeep their real values (an exported one wins), so a moved home does not hide the toolchain or make Bun re-download its cache.MANGOSTUDIO_RUNTIME_BINARYand every other inherited variable pass through untouched.GIT_CONFIG_GLOBALnames a global Git config that does not exist, so the developer’s signing policy, identity andcore.hooksPathnever reach the fixture repositories the tests build. A fixture that builds its ownenvforgitmust forward it (tests/support/git-fixture-env.ts), or Git falls back to the real~/.gitconfig.MANGOSTUDIO_REAL_HOMEcarries the launcher’s original home. The API preload aborts the lane with the received and expected values whenhomedir()still equals it, which is how a launcher whoseHOMEdid not take effect is caught before the first test.
A bare bun test from apps/api bypasses the launcher and runs on your real home — use the
package scripts. scripts/tests/api-lanes-hermetic.unit.test.ts fails, naming the resolved
homedir() and the expected <tmpdir>/mangostudio-test-home-<random> shape, if an API lane
script stops going through it. The launcher reuses the guarded create/shape-check/delete of
scripts/lib/temp-home.ts, which the browser-smoke home is built on: it only ever deletes a
direct child of the OS temp directory with the expected name and refuses .. segments. A run
killed with SIGKILL cannot clean up and leaves its directory in the OS temp directory.
The frontend, shared, protocol and root-scripts lanes were checked with a node:fs spy and
do not read or write the real home (only lstat ancestor walks of the home directory itself
in the path-containment tests), so they are not behind the launcher.
Environment-gated suites
Some API integration suites need something the machine may not have and skip themselves rather than fail. Git and sshd suites probe for their tool; the container transport suite needs more than a tool, so it is told what to use:
cd apps/api
MANGO_CONTAINER_E2E_IMAGE=mango-container-smoke:test \
MANGO_CONTAINER_E2E_RUNTIME=/abs/path/to/mangostudio-runtime \
bun test tests/integration/services/connect-container-runtime.integration.test.tsA compiled Linux runtime binary matching the image’s libc is the part a checkout does not
have. Build one with bun run build:runtime --platform linux-x64 --zig (or linux-x64-musl;
the binary lands in .mango/runtime/<platform>/), and use an image that satisfies the three requirements in
hub-runtime.md — for musl that
means alpine:3 plus apk add --no-cache bash libstdc++. MANGO_CONTAINER_E2E_ENGINE=podman
runs the same suite against podman.
CI does not compile a second copy: the Smoke — Container job consumes the Linux runtime
binaries the distribution lane already built, so the suite runs against release bytes.
API support lives in apps/api/tests/support/:
setup/test-environment.ts— single source of truth for the test bootstrap (config, registration, migrations, and per-test config-file reset); used by the preload and harnesssetup/preload.ts— thin bunfig preload that delegates tosetupTestEnvironment()harness/create-api-test-app.ts— wraps route plugins in a minimal Elysia app forapp.handle()testingfactories/— DB row factories (insertTestUser,insertTestChat)mocks/— fake collaborators (secret store, etc.)
Test isolation
The bootstrap points the config singleton at an in-memory database and a managed temp
config file, never the developer’s real ~/.mango. The managed config file is wiped
between every test, so a config-file connector written by one test cannot leak into
another test’s reads. Tests needing custom config call loadConfigForTest({ ... }) in
their own beforeEach; it defaults to :memory: and the managed path.
Frontend
bun run --filter @mangostudio/frontend test:unit
bun run --filter @mangostudio/frontend test:integration
bun run --filter @mangostudio/frontend test:coverageFrontend support lives in apps/frontend/tests/support/:
setup/dom-setup.tsandsetup/bun.setup.ts— runtime bootstrap only, loaded asbunfig.toml[test] preloadentries in that orderharness/render.tsx— minimal render surface with providersmocks/create-fetch-scenario.ts— method-and-path fetch registry for React hook tests only (see scope below)
Bun test runner facts
- Coverage thresholds are enforced per file, not in total.
bunfig.toml’scoverageThresholdfails as soon as any one file misses the bar, so a suite with legitimately uncovered files cannot express a total-coverage gate through it.scripts/qa-gate/enforce-coverage-thresholds.tsreads the emitted LCOV back and compares floors fromscripts/lib/test-lanes.tsagainst the suite’s totals instead, chained aftertest:coverage. - happy-dom is registered at
http://localhost:3001— where the API really listens in dev — so a relative request a scenario never registered opens a real socket instead of failing fast.bun.setup.tsinstalls afetchthat rejects immediately and names the unanswered request, reinstalled inafterEach. mockReset()strips a mock’s implementation rather than restoring it (the opposite of Vitest); a mock built withjest.fn(impl)returnsundefinedafter a reset.mockClear()is the one call that means the same thing in both runners.- Bun links a mocked module’s whole namespace at import. A
mock.modulefactory returning fewer names than the real module throws a hardSyntaxError: Export named 'x' not foundfrom whichever other consumer imports it next — Vitest resolved lazily and let a partial factory pass unnoticed. Spreadconst actual = await import(spec)into any factory that does not cover the full module. mock.restore()does not revertmock.module(). It restoresmock()/spyOndoubles and leaves module registrations installed for the rest of the process — in an unisolated lane, for every file scheduled after the one that registered them.mock.module()on an already-loaded module also merges into the live namespace rather than replacing it, so re-registering a hand-listed subset leaves the rest of the fake in place. The sanctioned undo is a whole-namespace capture (const real = { ...mod }) in a support module, re-registered from the suite’safterEach:support/mocks/google-genai.tsfor the Gemini SDK,support/connectors/index.tsfor the first-party provider modules.integration/routes/_respond-stream-helpers.tspredates this pattern: itsrestoreAllMocksstill re-registers hand-listed subsets for seven of the eleven modules it covers, so it cannot undo a partial fake of any of them. Follow the capture pattern above in new code rather than that file’s shape.- Never add a direct import of a mocked module to the bunfig preload. The
capture above has to run before any test installs a fake, which makes the
preload look like its natural home. It is the one place it cannot go: mocking
a module the preload imports at top level re-evaluates that graph, producing
a second
test-environment.tswhoseinitializedflag is false, and every latercreateApiTestAppthrows the not-initialized guard. Measured on Bun 1.4.0 — importing@google/genaifrom the preload took--randomize --seed=1overapps/api/tests/integrationfrom 36 failures to 605. Transitive reachability is not the trigger, and the rule is not “nothing the preload can reach”:test-environment.tsalready pulls@google/genai,src/services/geminiand the openai modules in throughregisterApplicationServices(), and the connector suites mock all of them today. Import the capture from a support module test files pull in at module scope instead; module evaluation still precedes every hook in that file, so the capture is early enough in any file order. - Better Auth turns off origin, rate-limit and secret validation under
NODE_ENV=test(bun testsets it), so nobun testcase can assert any of the three — the positive case passes while checking nothing and the negative case reads as a discovered vulnerability.scripts/test-build.tsspawns the compiled binary withNODE_ENV=productionand is the only vehicle that can make that assertion.
Shared
bun run --filter @mangostudio/shared test:unitshared keeps runtime test utilities in src/test-utils/, but tests for that workspace live in apps/shared/tests/unit/.
Writing Tests
API Integration — with Typebox schema validation
import { describe, expect, it } from 'bun:test';
import Type from 'typebox';
import Value from 'typebox/value';
import { settingsRoutes } from '../../../src/routes/settings';
import { createApiTestApp } from '../../support/harness/create-api-test-app';
// Route plugin uses .group('/settings', ...) — no /api prefix in tests
const app = createApiTestApp(settingsRoutes);
const ResponseSchema = Type.Object({
configured: Type.Boolean(),
status: Type.Union([Type.Literal('idle'), Type.Literal('ready'), Type.Literal('error')]),
allModels: Type.Array(Type.Any()),
});
describe('settingsRoutes', () => {
it('validates response shape with Typebox', async () => {
const response = await app.handle(new Request('http://localhost/settings/models/gemini'));
expect(response.status).toBe(200);
const payload = await response.json();
expect(Value.Check(ResponseSchema, payload)).toBe(true);
});
});
Important: route plugins use
.group('/path', ...)without the/apiprefix. That prefix is added inapp.tsvianew Elysia({ prefix: '/api' }). Test URLs must use the plugin’s own group path.
API Unit Example
import { describe, expect, it } from 'bun:test';
import { createGeminiSecretService } from '../../../src/services/gemini-secret';
import { InMemorySecretStore } from '../../support/mocks/mock-secret-store';
describe('createGeminiSecretService', () => {
it('returns environment fallback when no stored key exists', async () => {
const service = createGeminiSecretService({
secretStore: new InMemorySecretStore(),
getEnvironmentKey: () => 'env-key-5678',
});
const status = await service.getGeminiSecretStatus();
expect(status.source).toBe('environment');
});
});
Frontend Integration — React hook tests (use fetch mock)
create-fetch-scenario.ts is scoped to React hook tests in happy-dom — hooks that call fetch via Eden Treaty and cannot access the Elysia app directly. Do not use it for API contract tests.
import { render, screen } from '../../support/harness/render';
import { createFetchScenario } from '../../support/mocks/create-fetch-scenario';
const fetchScenario = createFetchScenario();
fetchScenario.install().respondWithJson('GET', '/api/settings/secrets/gemini', {
body: { configured: false, source: 'none' },
});
render(<SettingsPage {...props} />);
await screen.findByText('Not Configured');
fetchScenario.restore();
Support Rules
- Do not add empty
supportsubfolders for symmetry. - Keep helpers local to a test file unless they remove duplication across multiple files.
- Prefer one explicit harness over layered abstractions.
- Keep mocks focused on real request or dependency seams.
- For API contract validation, use
Value.Checkwith an inline Typebox schema — this catches breaking response shape changes immediately.
Module Tests (API)
API domain modules place tests under the workspace-level tests/ directory:
apps/api/tests/
unit/modules/<module-name>/ # Unit tests for application services
integration/modules/<module-name>/ # Integration tests using createApiTestApp
Module integration tests use createApiTestApp with the module’s HTTP route
plugin (e.g., createApiTestApp(chatRoutes)). Test URLs must use the plugin’s
own group path (no /api prefix — that is added in app.ts).
Continuation / Provider Test Matrix
Refer to docs/architecture/continuation.md and docs/providers/development.md for the
architecture and development guide. The test matrix covers three layers:
Decision engine (continuation.test.ts)
Pure-function tests in apps/api/tests/unit/services/providers/continuation.test.ts:
| Test | What it validates |
|---|---|
| Envelope parse/serialise | Round-trip identity, null/undefined/malformed, schema version, mode validation, cursor requirement |
| Envelope validation | Provider/model/system prompt/toolset mismatch detection |
decideContinuation |
continue_with_cursor, degrade_to_replay, start_replay decisions |
decideTurnPersistence |
Durable cursor persisted, stateless-loop filtered |
| Provider switch | OpenAI→Gemini first turn degrades, Gemini cursor on second |
Replay builders (replay-builder.test.ts)
Tests in apps/api/tests/unit/services/providers/replay-builder.test.ts:
- Each provider’s replay format (OpenAI, Gemini, OpenAI-compatible)
- Text-only, tool-call-only, mixed content
- Empty history and backward-compatible plain text
Provider-specific cursor-loss handling
Each provider stream test must cover:
- First turn with no cursor → full replay
- Cursor continuation → minimal input
- Cursor loss (no tool results) → retry with replay
- Cursor loss (with tool results) → abort with
tool_result_cursor_loss
Coverage
Coverage reports are written under .mango/artifacts/coverage/, one directory
per workspace; the frontend’s LCOV lands at
.mango/artifacts/coverage/frontend/lcov.info:
bun run --filter @mangostudio/frontend test:coverageThe frontend carries total-coverage floors — lines 81 / functions 76 /
statements 81 / branches 53, measured 2026-08-21 at 82.35 / 77.53 / 82.39 /
54.45 over the full 167-file suite and floored with ~1pt of headroom for
run-to-run LCOV jitter. (These are Bun-instrumentation numbers; the retired
istanbul 70/60/64/72 are not comparable.) The floors live in
scripts/lib/test-lanes.ts and are enforced by
scripts/qa-gate/enforce-coverage-thresholds.ts, chained inside
test:coverage itself, so a miss fails the same invocation CI watches — lines
and functions read from the LCOV, statements and branches derived from the
sources by coverage-summary.ts.
Why not
bunfig.toml’scoverageThreshold? Measured on Bun 1.4.0 and re-verified on 1.4.2, it is a different feature than it looks: the threshold applies per file (every file must individually clear the bar, so one legitimately uncovered file fails any positive value), a key you omit still enforces a hidden ~0.9 default, singular key names (line) are silently ignored, and a miss prints nothing — it exists only in the exit code (oven-sh/bun#17028, still open). On 1.4.0 the gate was also silently inert undercoverageReporter = ["lcov"]without"text"; on 1.4.2 it enforces under either reporter.test-lanes.unit.test.tspins the key’s absence so it cannot come back by accident.
When raising or repairing coverage, prioritize release-critical surfaces first:
- Auth lifecycle routes and logout/session transitions
- Connector settings CRUD and provider validation errors
- Chat orchestration and streaming UI states
- Gallery loading, empty, pagination, and download flows
Unhandled Errors With Green Test Counts
A frontend run can report every file and every test as passed and still print a
# Unhandled error between tests block — and exit 1. That is not a flake. Do
not re-run it. CI already failed correctly. The green counts are why it looks
like noise.
The mechanism is always the same. Something outlives the test that started it —
a timer, a lazy() chunk behind a Suspense boundary, an unanswered fetch — and
lands after the environment it belonged to is gone or after React stopped
watching. The result is attributed to whichever file happened to be running.
It only reproduces when the suite is loaded enough for the callback to land in
that gap, so it does not show up in a local single-file run, and re-running the
job in CI is a coin flip.
Three guards exist for the three shapes this takes under bun test + happy-dom,
and a change that removes one will not fail any test:
bun.setup.tsinstalls afetchthat rejects immediately, so an unanswered request cannot open a socket tolocalhost:3001and strand anECONNREFUSEDunder an unrelated file.support/harness/timers.tsdrains the fake-timer queue before restoring real ones, because React Query announces through a livesetTimeout(callback, 0)and dropping one leaves its notify manager unable to deliver again.support/harness/render.tsxexportsflushAsyncRender()for a render whose content arrives asynchronously; without it the Suspense resolution lands outsideactand prints a warning on a passing test.
Where to look
The stack in the job log names the leaking library. The “originated in” file is where the run was when the timer fired, not where the timer was scheduled. Treating that path as the owner is half of why this class is expensive.
How to find the owner
Log inside the suspect hook or module and run the whole suite. The file that reports the error is usually not the one that scheduled the timer.
The rule
A component that schedules a timer clears it on unmount. A test never mounts a third-party client whose teardown is deferred.
Known instances
4d936696.ToastProviderscheduled a 4s auto-dismisssetTimeoutper toast and never cleared it. The originated-in file wastests/unit/components/git-panel.test.tsx.b96e319a.useRealtimeInvalidationreads the Better Auth session, so every component that syncs anything subscribed the session atom. nanostores tears an atom down 1s after its last listener leaves, and that disposer removes awindowevent listener. 18 test files mounted the real client. The originated-in file wastests/unit/features/library/backup-list.test.tsx.
The QA report and the Test job step summary lead with these error headlines when the suite failed, and link here.
Dangling Processes And Spawn Diagnostics
killed N dangling process in a bun test log means a test timed out and Bun
killed the children that test spawned. Two things about that line cost real time
in #922 and are worth knowing before you read one:
- It is a consequence of the timeout, not a cause. A test that leaks a child and passes prints nothing.
- It is printed above the
(fail)it belongs to, so it reads as if the previous, passing test leaked something.
It also never says which child. MANGOSTUDIO_SPAWN_DIAGNOSTICS=1 does:
MANGOSTUDIO_SPAWN_DIAGNOSTICS=1 bun run --filter @mangostudio/api test:integrationEvery Bun.spawn/Bun.spawnSync and every node:child_process
spawn/exec/execFile/fork (and their sync forms) logs one stderr line
with the command, the pid and elapsed time, and a second line when the child
exits. A child that logs a spawn and no exit is one that outlived the run.
bun test on 1.4.2 exposes no current-test API — not expect.getState(), not a
hook argument — so events carry elapsed time rather than a test name. Interleaved
with the reporter’s (pass)/(fail) lines that still places a spawn between two
named tests.
The wrappers live in apps/api/tests/support/setup/spawn-diagnostics.ts and are
installed by that workspace’s preload only, so this is an api-lane instrument.
The CI test lanes set the flag (.github/workflows/test.yml); unset, nothing is
installed and nothing is paid for.
Local is a child process.
createLocalRuntimeConnectorspawns themangostudio-runtimebinary for every connection, so a test suite that connects Local per test spawns and handshakes one runtime per test. Connect once per file instead (tests/support/fixtures/local-runtime-user.ts); the checkpoint suites are the worked example. Closing the connection waits for the child to exit.
pgrep -fmatches its own command line. A wait loop shaped likeuntil ! pgrep -f "bun run test"; do sleep 1; donenever terminates when it runs inside abash -c "..."wrapper (a background watcher, a CI step) whose own command line contains the same pattern —pgrep -fgreps full command lines, including the grepping process’s. Capture the target’s pid at launch and pollkill -0 "$pid"instead, or run the command in the foreground and skip the wait loop entirely.
CI Runtime Binary
Every lane that starts a hub needs the mangostudio-runtime binary Local launches.
.github/workflows/local-runtime.yml builds it once per CI run (a Linux debug build, with
the cargo cache) and uploads it as local-runtime-linux-x64. The composite action
.github/actions/local-runtime downloads that artifact — or builds the binary itself when
no artifact is named — asks it for --version, and exports MANGOSTUDIO_RUNTIME_BINARY
for the rest of the job. A missing or broken binary fails that step by name instead of
surfacing as scattered Local connect failures. The artifact also carries the
fake_cursor_agent example, built in its own cargo invocation and exported as
MANGOSTUDIO_FAKE_CURSOR_AGENT, so the external-agent qualification suites run in the
ordinary shards instead of skipping.
test.ymltakes the artifact name as a requiredruntime_artifactinput, and every shard runs the action before its tests. The frontend lane does not need it.browser-smoke.ymltakes the same input; a manual dispatch has none and builds in-job.randomized-order-nightly.ymlbuilds it in its api lanes.cargo-shim.yml’sreal-binary-qualificationbuilds its own and runs every api test file that importstests/support/rust-runtime-binary.ts, directly or through another support module, on macOS and Windows, except the opt-in live smoke named inOPT_IN_TESTS.scripts/lib/rust-lanes.tsdiscovers those files and owns the paths that make the lane relevant, so a new Rust-backed test needs no workflow edit;scripts/tests/rust-lanes.unit.test.tsfails if one would be missed. Ubuntu runs these files in the ordinary CI shards against the same debug runtime and fake agent.scripts/tests/qualification-shards.unit.test.tschecks that every discovered qualification file belongs to a sharded coverage lane, and that every shard receives both binaries fromlocal-runtime.ymlbefore its tests. A missing file or artifact handoff fails the guard by name.
API serve shutdown fixture
The serve signal case in rust-runtime-qualification.integration.test.ts gives
its never-finishing installer a 10-second deadline. An independent
expectRuntimeChildAlive(child, 5_000) observer starts before SIGTERM and rejects
an exit during the next five seconds. The eventual runtime exit, installer-PID
cleanup and 30-second case budget still apply. A three-second installer deadline
was shown to fail the live observer against the real binary. Other stdio fixture
holds and their default 20-second installer deadline are retained. The serve
signal case remains skipped on Windows. This deadline applies to the qualification fixture.
Cargo targets in worktrees
Each worktree must own its writable Cargo target directory. Sharing one across worktrees with different sources or version stamps can make Cargo exit zero while the binary still reports the other worktree’s version. A successful build alone therefore does not establish source identity.
Keep the default <worktree>/target/, or set a dedicated real-disk
CARGO_TARGET_DIR for that worktree. Derive runtime and fixture paths from that
same target. No two active worktrees may write the same target.
For runtime-backed API tests, build the normal runtime first, then the named fixture in a separate invocation so the fixture’s SDK testing feature does not enter the runtime build:
cargo build --locked -p mangostudio-runtime --bin mangostudio-runtime
cargo build --locked -p mangostudio-runtime --example fake_cursor_agentConfirm that the runtime’s checksum remains unchanged after the fixture build,
and point MANGOSTUDIO_RUNTIME_BINARY and MANGOSTUDIO_FAKE_CURSOR_AGENT at
those qualified files when they are outside the default paths.
Rust Workspace Tests
The Rust workspace tests run under cargo-nextest, locally and in
cargo-shim.yml, one process per test:
cargo install cargo-nextest --locked --version 0.9.144
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo nextest run --workspace --all-targets --all-features --locked --retries 0
cargo test --doc --workspace --all-features --locked
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features --lockedThe version is the one cargo-shim.yml installs through the SHA-pinned
taiki-e/install-action; scripts/tests/rust-nextest.unit.test.ts fails when the two
differ, and when the CI command stops matching the one above. nextest does not run
doctests, so they stay on cargo test --doc, and the ignored fixture generator
(generate_rust_fixture) stays on cargo test -- --ignored.
- Same test set. nextest lists the same libtest binaries, and the workspace job’s
“Check nextest runs the libtest test set” step runs
bun scripts/bench/rust-test-inventory.ts capture,capture-nextestandcompareon every OS. A case that one runner lists and the other does not, or flags#[ignore]differently, fails the job and is printed by its<package>/<kind>/<binary>::<test>identity. - No retries.
--retries 0is on every command line, and the workflow tests rejectNEXTEST_*environment overrides and a.config/nextest.tomlthat retries, filters or overrides, so a flaky test fails the gate instead of passing on a second attempt. - Process isolation. A test that passed only because another test in its binary had already initialised process state now runs alone and fails; fix the test, do not retry it.
- Ubuntu’s CI run of this set is the instrumented libtest one in
rust-coverage.yml(see below); macOS, Windows and the Windows ARM64 runtime lane run nextest. Ubuntu also runscargo test -p mangostudio --all-targets --lockedto test the launcher with its own default features, separately from workspace feature unification, on the shared Rust 1.99.0 minimum.
Rust Coverage In CI
The Ubuntu run of the Rust workspace tests is instrumented: rust-coverage.yml
(called from ci.yml) runs cargo llvm-cov --no-report --workspace --all-targets --all-features --locked on the plain libtest runner, and the QA
report shows line, function and region coverage per crate (mango-protocol,
mangostudio, mangostudio-runtime, mangostudio-runtime-contract). Branch
coverage is not collected and doctests are not instrumented. See
docs/reference/ci.md for where the job lives and why.
Reproduce it locally with cargo install cargo-llvm-cov --locked --version 0.9.1, rustup component add llvm-tools-preview, then cargo llvm-cov --no-report --workspace --all-targets --all-features --locked and cargo llvm-cov report --json --summary-only. Tests that spawn an instrumented binary
with a cleared environment write default_*.profraw into the child’s working
directory (ignored by the repository) instead of the collected profile, so
those children contribute no coverage.
CI Artifact Retention
CI artifacts fall into four retention classes; keep new uploads aligned with them:
| Class | Examples | Policy |
|---|---|---|
| Job-to-job handoff | test-shard-<n> (shard → merge), qa-test-metrics and qa-rust-coverage (→ qa-metrics), local-runtime-linux-x64 (build → shards, browser smoke) |
1 day — consumed within the same run |
| Failure diagnostics | test-shard-<n>-log, merged coverage, Playwright traces and report |
7–14 days, uploaded only if: failure() |
| Release assets | staged binaries and packages in the release pipeline | 30 days |
| Main-push baselines | qa-metrics envelopes from green main CI runs |
90 days — exact-SHA baselines for future PR QA reports |
Green runs summarize their outcome in the step summary ($GITHUB_STEP_SUMMARY)
instead of uploading success-only artifacts. The browser-smoke workflow keeps a
workflow_dispatch input (always_upload_report) to upload the HTML report for
a passing run when needed.
CI Cache Policy
All dependency and intermediate-state caches are owned by composites under
.github/actions/; workflows must not call actions/cache directly. The shared
cache-scoped composite owns the write-scope, trusted-main restore keys, and
step-summary diagnostics for every family. Callers supply family, path, and
a validity string (toolchain versions plus content hashes). setup-mango
wraps the Bun install family; every other family is invoked from the workflow
that produces or consumes it.
| Family | Producer / consumer | Path | Invalidators | Restore behavior |
|---|---|---|---|---|
| Bun install | every job using setup-mango |
~/.bun/install/cache |
OS, arch, Bun revision, lockfile | loose trusted-main prefix |
| Turbo task output | check, test, build, QA metrics | .turbo/cache |
OS, arch, Bun, Turbo, lane, lockfile, manifests, configs | lane-scoped trusted-main prefix |
| Workflow lint tools | check | .mango/artifacts/tools/ |
pinned tool manifest | exact trusted restore only |
| Playwright browser | browser smoke | ~/.cache/ms-playwright |
OS, arch, Playwright version | exact trusted restore only |
Inside the Turbo task output family each task is keyed by Turbo’s own hash. The cached API unit task includes the SHA-256 of the runtime binary in it (see API); without that, a rebuilt binary would replay a stale pass out of this cache.
Root Bun dependency patches are global Turbo inputs through patches/** in turbo.jsonc.
The Bun lockfile records their paths, so changing only patch bytes otherwise leaves task
keys unchanged. Editing a patch invalidates every cached task, including build, typecheck
and unit lanes, while unchanged patches retain the same keys.
mode selects restore-save (default), restore, or save. Exact-restore
families (lint-tools, playwright) set exact-restore: true so a loose
prefix hit cannot mark cache-restored and skip a required install —
Playwright browsers and checksum-pinned lint tools are unusable across
versions. Only mode: restore uses actions/cache/restore, which is the sole
mode that reports a trusted-main match; restore-save sees a primary-key hit
only. cache-restored is therefore only safe to gate an install step when the
call site opts into both exact restore and mode: restore (today: Playwright).
Turbo snapshot rotation
actions/cache never overwrites a key. A snapshot keyed by validity alone keeps
the contents of the first run that saved it: a later run that restores it
exactly computes whatever the snapshot lacked, skips its save because the key
exists, and throws those outputs away. The Turbo lanes therefore pass
rotate: "true" to a restore before the work and a save after it
(rotate is rejected for restore-save and restore-exact, whose entry cannot
be conditional):
- Key. The save key is the write-scope key plus
-<run id>-<attempt>, so it never collides and never leaves its scope:pr-<number>for a pull request,mainonly for a push tomain,run-<id>for anything else. The restore asks for the newest entry under the scope’s own prefix (pull requests only), then undermainwith the same validity, then under the lane’s loosemainprefix. A pull request therefore restores its own earlier snapshots andmain’s, andmainrestores nothing a pull request wrote. - Validity. Besides the OS, Bun revision and Turbo version, the lane key
hashes
bun.lock, everypackage.jsonand every workspaceturbo.json(apps/*,packages/*) next toturbo.jsonc. Turbo’s own task hashes already cover each of these, so none of them decides whether a task hits. A change does not isolate the old snapshots: the restore falls through to the lane’s loosemainprefix, takes the newest snapshot of the previous validity and carries its entries forward, so only Turbo’s task hash decides what replays. - When it saves.
restorerecords a baseline right after the archive is extracted;saveruns only if the job succeeded and.turbo/cacheholds a*.tar.zstnewer than that baseline, which means Turbo ran a task the snapshot lacked. Only the payload counts: Turbo rewrites an entry’s-manifest.jsonon every hit. A run that only hit saves nothing. Intest.ymlonly shard 1 saves. - Retention. Every save carries what it restored plus the new outputs, so
the chain would only grow. Each job sets
TURBO_CACHE_MAX_SIZE=16MB: at the start of the run Turbo deletes the oldest entries (by mtime, which the archive preserves) until the rest fit, so a snapshot is at most 16 MB plus one run’s outputs. A lane saves at most one snapshot per run, and only a run that computed something; the newest is the only one anyone restores, and the superseded ones are not accessed again, so GitHub removes each seven days after its last restore (or earlier, least recently used first, at the 10 GB repository limit). Turbo does not delete caches or needactions: write. - Quota. The three lanes’ complete sets are 3.6 MB (build), 6.5 KB (check)
and about 70 KB (test shard 1). A run that recomputed every build task wrote
3.6 MB of new entries, so the next snapshot was 7.2 MB, and its save took
about a second. With roughly 56 pushes to
mainand 15 pull requests a week, every run saving at today’s sizes would hold about 0.4 GB in a rolling week; if every run saved a full 16 MB chain it would be about 2.3 GB. The gate job’s cache-usage summary lists theturbofamily, so growth shows there before it shows as an eviction.
Pull-request runtime artifact profile
runtime-build.yml defaults to the shipping profile: fat LTO and one codegen
unit. CI’s distribution and PR release dry runs explicitly request light on
pull_request: thin LTO and 16 units, including PRs that change Rust. The
binary-smoke rebuild passes the same selection, but it runs only from
workflow_dispatch, so it resolves to shipping. The overrides live at job scope, so
rust-cache’s CARGO environment hash separates both exact and fallback keys by
profile. A light cache miss does not restore shipping state. PRs never save.
Shipping receives an empty override map, preserving its existing Cargo
defaults and cache environment identity.
The builder rejects empty/unknown profiles and every non-PR light artifact request before checkout, Cargo setup, or cache access. Light requests also require the actual Cargo overrides to be thin LTO and 16 codegen units, and shipping requests reject any LTO or codegen-unit override. Main, tags, schedules, manual runs, and publication remain shipping. Main is the first shipping build of a PR change; its runtime and complete smoke lanes remain mandatory before Canary. A failure unique to the shipping profile can make main red after a green PR and blocks publication.
This preparation adds no main seed jobs, cache saves, or capacity claim. Light builds start cold until separate cache-only seeds are admitted. Before enabling seeds for the eight existing targets, verify cache write eligibility and older generation lifecycle/older-base fallback, measure each light archive, and account for peak retained generations plus an explicit reserve under a 35 GB admission ceiling. Recheck the repository’s configured storage limit before enabling seeds. Seed output must have no artifact or publication route. Historical size estimates and single Linux benchmarks do not establish that admission or a hosted performance gain.
Hosted acceptance still requires current-head logs showing thin/16 on PRs and fat/1 with green smoke on main, then at least five comparable PR pushes with per-platform build and Gate medians. Report cold runs separately from light cache hits, and retain the Windows binary smoke’s 60-second handshake budget.
Rust cache generations
Rust build caches share the repository’s configured Actions cache budget with
the shorter lanes. rust-cache-cleanup.yml runs trusted main tooling after a
main cache writer completes, after rust-cache’s post-job save, and grants
actions: write only to its cleanup job. scripts/ci/rust-cache-cleanup.ts
deletes older committed generations by exact ID only when a newer replacement
is listed under refs/heads/main. The explicit family registry preserves the
runner OS and architecture, target, compiler/environment hash and cache archive
version, including distinct shipping and light-profile environment hashes.
Only the root-manifest prefix and dependency hash rotate. Sole entries, unknown
families, fuzz and other cache kinds remain untouched. Before each deletion the
script audits active main writers and fetches the complete listing again; a
detected writer, changed generation or lost replacement defers cleanup until a
later writer’s completion. The audit checks pre-start statuses before running
and rejects duplicate run IDs within each paginated status listing. Separate
status queries and cache deletion are not atomic: a newly starting or moving
writer can still escape the audit, and external eviction can remove a keeper.
The script verifies the retained replacement after each deletion and stops
further cleanup if it disappears. It fails on malformed or incomplete API
responses. Preview a captured listing without
network or deletion with bun ./scripts/ci/rust-cache-cleanup.ts --fixture <listing.json>. Verify the total and older-base fallback after the first
post-merge lockfile change before adding more Rust cache consumers.
Cargo incremental compilation
Every job that compiles Rust in the dev or test profile sets CARGO_INCREMENTAL: "0": at workflow level in cargo-shim.yml, protocol-ci.yml,
protocol-fuzz.yml, rust-fresh-dependencies.yml and protocol-release.yml,
at job level in rust-coverage.yml, and on the build step of the local-runtime
composite. scripts/tests/cargo-incremental.unit.test.ts derives the jobs from
the workflow files and fails when one lacks it.
Incremental state lets a rebuild of one target directory skip work. A CI job builds once, so it only costs time and disk. Cold target, 34 cores, three interleaved runs per side, medians, the exact commands the lanes run:
| Command | Wall, unset to 0 |
CPU | Target directory |
|---|---|---|---|
local-runtime build (runtime, then fake_cursor_agent) |
134.5 s to 118.6 s | -5.4% | 2.65 GB to 1.65 GB |
cargo clippy --workspace --all-targets --all-features |
67.6 s to 64.6 s | +1.3% | 0.76 GB to 0.37 GB |
cargo nextest run --no-run --workspace --all-targets --all-features |
126.7 s to 112.0 s | -7.2% | 3.88 GB to 2.09 GB |
Swatinem/rust-cache (the pinned v2.9.2 exports it in restore.ts) already sets
the same value for jobs that restore a cache, so the explicit setting changes
nothing there. It matters for the uncached workspace-windows-arm64 lane and for the
local-runtime composite when its cache step is skipped. --release steps are
untouched: the rustc command line of a release build is byte-identical with the
variable unset and set to 0. Local builds stay incremental, because nothing in
.cargo/config.toml or the profiles sets it.
At the time of these measurements, the separate workspace-msrv and target-msrv lanes were
also uncached. Their checks now run in the native workspace lanes at the shared Rust 1.99.0
minimum, as mapped in ci.md.
The binary and Docker smoke matrix (smoke-binary.yml) restores no caches: it
only pins Bun and runs dependency-free release scripts. Manual rebuild dispatches
still use setup-mango because they compile inside the job. The QA metrics job
consumes the frontend dist artifact and restores Check’s Turbo snapshot in
restore-only mode, alongside the Bun install cache that setup-mango brings
with it. It uses Check’s key inputs and 16 MB bound, never saves a Turbo
snapshot, and does not wait for the same run’s Check job. A cache miss runs the
normal checks; a hit reuses typecheck results while quick checks still execute.
CI_CACHE_EPOCH is the repository-wide emergency invalidation lever. It is an
Actions repository variable with a documented v1 fallback, passed explicitly
to every cache call site. Composite actions cannot read the vars context, so
callers must keep supplying cache-epoch rather than relying on a composite
default. Increment the variable (for example, to v2) to force a clean miss
after suspected poisoning or a broken key rollout. Removing the variable returns
to v1; only do that for a benign rollback because old v1 entries may still
exist.
Main pushes write reusable main keys. Pull requests first restore a matching
trusted main key (and, for a rotated family, their own pr-<number> chain),
then write only a pr-<number> primary key; fork permissions
may make that final save restore-only. Other trusted triggers use run-scoped
primary keys and can restore only main prefixes. Consequently, privileged
release jobs never restore a PR-produced cache. Cache paths contain dependencies
or rebuildable intermediate state only—distribution and QA artifacts keep their
separate upload/download policies above.
Verification Checklist
Before merging, run:
bun run check
bun run test
# Local check, coverage and build gate:
bun run verifybun run verify runs check → test --coverage → build --all and stops at the
first failure. Its coverage phase does not schedule the protocol TypeScript
tests; add bun run protocol:test --ts-only to cover them. The ordinary
bun run test unit phase includes that protocol suite.
Relevant changes still require the full Rust and protocol contributor gates:
use the Rust workspace validation and the protocol’s
bun run protocol:check && bun run protocol:test commands. The TypeScript-only
complement does not replace Rust, feature-powerset or interop qualification.
Run browser and binary smoke separately with bun run test --e2e and
PLATFORM=linux-x64 bun run scripts/test-build.ts on matching runners.