fix(test): remove all 8 delayed process.exit teardown bombs — the tier-1 gate can finally fail

bun test runs every file in ONE process, so a 500ms setTimeout(process.exit(0))
armed in afterAll fired mid-way through a LATER file and killed the entire
suite with exit 0 and no summary — only ~16 of 434 files ran, and every
downstream failure was invisible (observed live throughout this wave's
enumeration). Changes, all guarded by fault injection:

- Replace every delayed-exit teardown with a time-boxed close of the file's
  own browser (8 files across browse/ and design/); stub the daemon
  /shutdown timer instead of letting its unconditional process.exit tear
  the runner down.
- test/no-suicide-exit.test.ts: static tripwire — no *.test.ts may schedule
  a delayed process.exit again.
- test/exit-propagation.test.ts + fixtures: fault injection with REAL bun
  output proves the truncation shape (exit 0, no summary) and that
  scripts/test-free-shards.ts now detects it: a shard exiting 0 WITHOUT
  bun's final summary line is treated as FAILED (exit code alone is not
  evidence of completion).
- handoff: the three headed-mode integration tests are darwin-skipped with
  a pointer to the known macOS headed-launch breakage (#2242/#2554); they
  keep running on Linux CI. Un-skip in the browse-daemon wave.
- feedback-roundtrip: repair the handler call sites unmasked by the fix —
  handlers take (command, args, session, bm); passing the manager where a
  session belongs broke all six tests.
- user-slug-fallback: HOME isolation makes endpoint_hash deterministic.

Fixes #2421, #2435.

Contributed by @sneakygriff (PR #2172) with repairs from @time-attack
(PR #2230 feedback-roundtrip hunks); supersedes PR #2252 by @whd4 (same
defect, credited).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-14 20:20:51 -07:00
co-authored by Claude Fable 5
parent 3f176d2226
commit e0bfc8fff5
16 changed files with 322 additions and 77 deletions
+7 -2
View File
@@ -42,9 +42,14 @@ beforeAll(async () => {
// The test needs to start a server. Let's use the existing server infrastructure.
});
afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
// We need a running browse server for HTTP tests.
+7 -4
View File
@@ -94,11 +94,14 @@ beforeAll(async () => {
await bm.launch();
});
afterAll(() => {
// Force kill browser instead of graceful close (avoids hang)
afterAll(async () => {
try { testServer.server.stop(); } catch {}
// bm.close() can hang — just let process exit handle it
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
// ─── Navigation ─────────────────────────────────────────────────
+7 -2
View File
@@ -69,10 +69,15 @@ beforeAll(async () => {
await handleWriteCommand('goto', [boardUrl], bm);
});
afterAll(() => {
afterAll(async () => {
try { server.stop(); } catch {}
fs.rmSync(tmpDir, { recursive: true, force: true });
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
// ─── DOM Structure ──────────────────────────────────────────────
+7 -2
View File
@@ -460,9 +460,14 @@ describe('Hidden element stripping', () => {
await bm.launch();
});
afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test
// runs all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
test('detects CSS-hidden elements on injection-hidden page', async () => {
+17 -5
View File
@@ -26,9 +26,14 @@ beforeAll(async () => {
await bm.launch();
});
afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
// ─── Unit Tests: Failure Tracking (no browser needed) ────────────
@@ -172,8 +177,15 @@ describe('handoff edge cases', () => {
// Each handoff test creates its own BrowserManager since handoff swaps the browser.
// These tests run sequentially (one browser at a time) to avoid resource issues.
// Headed-mode launch is broken on current macOS (the rebrand invalidates the
// Chrome-for-Testing bundle signature and XProtect kills the relaunch —
// #2242, #2554, #2138). These three integration tests drive a real headed
// handoff and fail ~5s in on any darwin box. They stay ENABLED on Linux CI.
// Un-skip when the browse-daemon lifecycle wave lands the signature fix.
const HEADED_BROKEN_ON_DARWIN = process.platform === 'darwin';
describe('handoff integration', () => {
test('full handoff: cookies preserved, headed mode active, commands work', async () => {
test.skipIf(HEADED_BROKEN_ON_DARWIN)('full handoff: cookies preserved, headed mode active, commands work', async () => {
const hbm = new BrowserManager();
await hbm.launch();
@@ -206,7 +218,7 @@ describe('handoff integration', () => {
}
}, 45000);
test('multi-tab handoff preserves all tabs', async () => {
test.skipIf(HEADED_BROKEN_ON_DARWIN)('multi-tab handoff preserves all tabs', async () => {
const hbm = new BrowserManager();
await hbm.launch();
@@ -223,7 +235,7 @@ describe('handoff integration', () => {
}
}, 45000);
test('handoff meta command joins args as message', async () => {
test.skipIf(HEADED_BROKEN_ON_DARWIN)('handoff meta command joins args as message', async () => {
const hbm = new BrowserManager();
await hbm.launch();
+7 -2
View File
@@ -56,9 +56,14 @@ describe('defense-in-depth — live Playwright fixture', () => {
await bm.launch();
});
afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test
// runs all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
test('L2 — content-security.ts hidden-element stripper detects the .sneaky div', async () => {
+7 -2
View File
@@ -31,9 +31,14 @@ beforeAll(async () => {
await bm.launch();
});
afterAll(() => {
afterAll(async () => {
try { testServer.server.stop(); } catch {}
setTimeout(() => process.exit(0), 500);
// Close only this file's own browser — never process.exit(): bun test runs
// all files in one process, so a delayed exit kills the whole suite
// (see test/no-suicide-exit.test.ts). close() can hang when the browser
// already died, and its internal 5s timeout ties bun's 5s hook timeout —
// so race it at 3s and abandon; the child is reaped at process exit.
try { await Promise.race([bm?.close(), new Promise((resolve) => setTimeout(resolve, 3000))]); } catch {}
});
// ─── Snapshot Output ────────────────────────────────────────────