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
+21 -10
View File
@@ -361,16 +361,27 @@ describe("daemon /shutdown", () => {
await fetchHandler(
req("POST", `/boards/${board.id}/api/feedback`, { regenerated: false }),
);
// Now non-done count is 0 — handler should return shuttingDown:true.
// We DON'T let the real gracefulShutdown timer fire (it calls process.exit
// after 50ms which would tear down the test runner); instead we just
// observe the immediate response.
const r = await fetchHandler(req("POST", "/shutdown"));
expect(r.status).toBe(200);
const body = (await r.json()) as any;
expect(body.shuttingDown).toBe(true);
// Reset state for subsequent tests; the shutdown timer will be a no-op
// because the next resetForTest flips shuttingDown back to false.
// The handler arms setTimeout(gracefulShutdown, 50), and gracefulShutdown
// arms setTimeout(process.exit, 50). bun test runs ALL files in one
// process, so letting that exit fire would kill the whole suite ~100ms
// later (exit 0, no summary — see test/no-suicide-exit.test.ts). Stub
// process.exit, wait past both timers so they fire harmlessly while
// stubbed, then restore. (resetForTest does NOT defuse the timers: the
// exit callback is unconditional.)
const origExit = process.exit;
(process as any).exit = (() => undefined) as any;
try {
const r = await fetchHandler(req("POST", "/shutdown"));
expect(r.status).toBe(200);
const body = (await r.json()) as any;
expect(body.shuttingDown).toBe(true);
// Let both 50ms timers (gracefulShutdown, then its process.exit) fire
// against the stub before restoring the real process.exit.
await new Promise((resolve) => setTimeout(resolve, 200));
} finally {
(process as any).exit = origExit;
}
// Reset state for subsequent tests (gracefulShutdown set shuttingDown).
resetDaemon();
});
});
+61 -46
View File
@@ -22,6 +22,16 @@ import * as fs from 'fs';
import * as path from 'path';
let bm: BrowserManager;
// The command handlers take (command, args, session: TabSession, bm) — mirror
// the real call sites (browse/src/cli.ts, browse/test/commands.test.ts) by
// resolving the active TabSession from the manager on every call. Passing the
// manager itself where a session is expected breaks as soon as a handler uses
// a session method the manager doesn't delegate (e.g. clearLoadedHtml).
const writeCmd = (cmd: string, args: string[]) =>
handleWriteCommand(cmd, args, bm.getActiveSession(), bm);
const readCmd = (cmd: string, args: string[]) =>
handleReadCommand(cmd, args, bm.getActiveSession(), bm);
let baseUrl: string;
let server: ReturnType<typeof Bun.serve>;
let tmpDir: string;
@@ -121,10 +131,15 @@ beforeAll(async () => {
await bm.launch();
});
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 {}
});
// ─── The critical test: browser click → file on disk ─────────────
@@ -137,32 +152,32 @@ describe('Submit: browser click → feedback.json on disk', () => {
serverState = 'serving';
// Navigate to the board (board JS uses relative URLs + location.protocol detect)
await handleWriteCommand('goto', [baseUrl], bm);
await writeCmd('goto', [baseUrl]);
// Verify the board detects HTTP mode (so postFeedback will actually fetch
// instead of falling into the file:// DOM-only path)
const httpDetected = await handleReadCommand('js', [
const httpDetected = await readCmd('js', [
"location.protocol === 'http:' || location.protocol === 'https:'"
], bm);
]);
expect(httpDetected).toBe('true');
// User picks variant A, rates it 5 stars
await handleReadCommand('js', [
await readCmd('js', [
'document.querySelectorAll("input[name=\\"preferred\\"]")[0].click()'
], bm);
await handleReadCommand('js', [
]);
await readCmd('js', [
'document.querySelectorAll(".stars")[0].querySelectorAll(".star")[4].click()'
], bm);
]);
// User adds overall feedback
await handleReadCommand('js', [
await readCmd('js', [
'document.getElementById("overall-feedback").value = "Ship variant A"'
], bm);
]);
// User clicks Submit
await handleReadCommand('js', [
await readCmd('js', [
'document.getElementById("submit-btn").click()'
], bm);
]);
// Wait a beat for the async POST to complete
await new Promise(r => setTimeout(r, 300));
@@ -184,21 +199,21 @@ describe('Submit: browser click → feedback.json on disk', () => {
await new Promise(r => setTimeout(r, 500));
// After submit, the page should be read-only
const submitBtnExists = await handleReadCommand('js', [
const submitBtnExists = await readCmd('js', [
'document.getElementById("submit-btn").style.display'
], bm);
]);
// submit button is hidden after post-submit lifecycle
expect(submitBtnExists).toBe('none');
const successVisible = await handleReadCommand('js', [
const successVisible = await readCmd('js', [
'document.getElementById("success-msg").style.display'
], bm);
]);
expect(successVisible).toBe('block');
// Success message should mention /design-shotgun
const successText = await handleReadCommand('js', [
const successText = await readCmd('js', [
'document.getElementById("success-msg").textContent'
], bm);
]);
expect(successText).toContain('design-shotgun');
});
});
@@ -211,17 +226,17 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => {
serverState = 'serving';
// Fresh page
await handleWriteCommand('goto', [baseUrl], bm);
await writeCmd('goto', [baseUrl]);
// User clicks "Totally different" chiclet
await handleReadCommand('js', [
await readCmd('js', [
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
], bm);
]);
// User clicks Regenerate
await handleReadCommand('js', [
await readCmd('js', [
'document.getElementById("regen-btn").click()'
], bm);
]);
// Wait for async POST
await new Promise(r => setTimeout(r, 300));
@@ -244,12 +259,12 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => {
if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath);
serverState = 'serving';
await handleWriteCommand('goto', [baseUrl], bm);
await writeCmd('goto', [baseUrl]);
// Click "More like this" on variant B (index 1)
await handleReadCommand('js', [
await readCmd('js', [
'document.querySelectorAll(".more-like-this")[1].click()'
], bm);
]);
await new Promise(r => setTimeout(r, 300));
@@ -263,21 +278,21 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => {
test('board shows spinner after regenerate (user stays on same tab)', async () => {
serverState = 'serving';
await handleWriteCommand('goto', [baseUrl], bm);
await writeCmd('goto', [baseUrl]);
await handleReadCommand('js', [
await readCmd('js', [
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
], bm);
await handleReadCommand('js', [
]);
await readCmd('js', [
'document.getElementById("regen-btn").click()'
], bm);
]);
await new Promise(r => setTimeout(r, 300));
// Board should show "Generating new designs..." text
const bodyText = await handleReadCommand('js', [
const bodyText = await readCmd('js', [
'document.body.textContent'
], bm);
]);
expect(bodyText).toContain('Generating new designs');
});
});
@@ -291,15 +306,15 @@ describe('Full regeneration round-trip: regen → reload → submit', () => {
if (fs.existsSync(feedbackPath)) fs.unlinkSync(feedbackPath);
serverState = 'serving';
await handleWriteCommand('goto', [baseUrl], bm);
await writeCmd('goto', [baseUrl]);
// Step 1: User clicks Regenerate
await handleReadCommand('js', [
await readCmd('js', [
'document.querySelector(".regen-chiclet[data-action=\\"match\\"]").click()'
], bm);
await handleReadCommand('js', [
]);
await readCmd('js', [
'document.getElementById("regen-btn").click()'
], bm);
]);
await new Promise(r => setTimeout(r, 300));
@@ -329,21 +344,21 @@ describe('Full regeneration round-trip: regen → reload → submit', () => {
expect(serverState).toBe('serving');
// Step 4: Board auto-refreshes (simulated by navigating again)
await handleWriteCommand('goto', [baseUrl], bm);
await writeCmd('goto', [baseUrl]);
// Verify the board is fresh (no prior picks)
const status = await handleReadCommand('js', [
const status = await readCmd('js', [
'document.getElementById("status").textContent'
], bm);
]);
expect(status).toBe('');
// Step 5: User picks variant C on round 2 and submits
await handleReadCommand('js', [
await readCmd('js', [
'document.querySelectorAll("input[name=\\"preferred\\"]")[2].click()'
], bm);
await handleReadCommand('js', [
]);
await readCmd('js', [
'document.getElementById("submit-btn").click()'
], bm);
]);
await new Promise(r => setTimeout(r, 300));