Summary
bun test runs every test file in one process, in a deterministic but platform-dependent order. Several files mutate shared state and never restore it, so the mutation applies to every file that runs afterwards. Which files those are differs between macOS and Linux CI.
This is the bug class behind #2267, where process-manager.test.ts left a spyOn(childProcess, 'spawn') stub installed and gateway.test.ts hung for 45s on Linux only. That one instance is fixed (#2268). An audit of all 48 test files at 3c176a6 found the pattern is widespread.
The suite is currently 518 pass / 0 fail — but by luck of ordering, not by construction.
Findings
Ranked with silent leaks (later tests pass without exercising anything) above loud ones.
| # | File:line | What leaks | Blast radius |
|---|---|---|---|
| 1 | test/integration.test.ts:29-30 | spyOn(Hosts, 'addHosts'), spyOn(Hosts, 'checkHosts') installed inside an it; the file has no hook of any kind | Runs at position ~#41 with nothing after it that launders — the worst position in the run. src/start.ts:16 static-imports both |
| 2 | test/hosts.test.ts:22,26,31 | spyOn on checkHosts/addHosts/removeHosts in beforeEach; zero afterEach/mock.restore/mockRestore in the file | Any later file reaching startProxy(ies) with hosts management on gets checkHosts → [false] and a no-op addHosts. A regression that broke /etc/hosts writing could not fail a test |
| 3 | test/cleanup.test.ts:36,40 | mock.module('../src/hosts', …) and mock.module('../src/https', …) | mock.restore() does not undo mock.module — unrecoverable for the rest of the process. Also fragile: the rest of both namespaces survives only because line 4 pre-loads them; drop that import and later files hit undefined |
| 4 | test/start-lb-passthrough.test.ts:30 | spyOn(Start, 'startServer'), no restore in the 60-line file | Silent: the non-shared per-proxy branch of startProxies becomes a no-op. Loud: spyOn on an already-spied property returns the same spy with its history, so a later file's own spy inherits this call — reproducible as a two-file run against start-single-proxy-production-certs.test.ts:63 |
| 5 | test/start-single-proxy-production-certs.test.ts:34-35 | createSharedProxyServer → null and startServer → no-op, restored at lines 65-66, the last two statements of the it | Success path only. Armed whenever any assertion throws or startProxies rejects (the openssl exit code at line 32 is never checked) |
| 6 | test/start-on-demand.test.ts:57-58, 83-84 | Same conditional pattern, restored only after all assertions | In-file, a leaked no-op means nothing binds :49081 for the second describe, producing three ECONNREFUSED failures in a describe with no bug |
| 7 | packages/vite-plugin/test/vite-plugin.test.ts:65,90,96 | Three unrestored mock.modules; the node:process one spreads { ...process }, dropping every EventEmitter.prototype method | Zero impact today — "test" runs two separate bun test processes and that dir holds one file. Fires on plain bun test at the repo root, or on adding a second file there: 10 rpx modules import node:process, so signal registration and process.exit silently no-op |
Why it is green today
Only three files in the suite launder anything: start.test.ts (afterEach), process-manager.test.ts (afterEach, the #2267 fix), and https.test.ts (beforeEach). Everything between them is an unprotected window.
The current victims are immune for incidental reasons — e.g. single-port.test.ts and local-ca.test.ts pass cleanup: false, which disables hosts management via src/start.ts:828-836. Drop that one option and #2 fires.
Useful mechanic, verified: Bun's mockRestore() restores the original function, not a previously-leaked spy. So a later file that spies-and-restores the same target also clears an inbound leak. That is exactly why #2267 reproduced on Linux but not macOS.
Suggested fix
One idiom, already the repo convention (start.test.ts:16-18, process-manager.test.ts, https.test.ts):
afterEach(() => {
mock.restore()
})placed in the describe that installs the spies. Unlike a trailing .mockRestore() it also fires when an assertion throws or the test times out, and being global it launders inbound leaks too.
Two exceptions:
cleanup.test.tsandvite-plugin.test.tsusemock.module, whichmock.restore()does not undo. Those must be deleted or converted tospyOn.header-modification.test.ts:17-24installsspyOn(console,'log')inbeforeAlland restores inafterAll— correct as is. A blanketafterEach(mock.restore)there would drop the spy after the first test. Noted so nobody "standardises" it.
sni-memory.test.ts looks partial by grep but is correct; leave it.