mirror of https://github.com/garrytan/gstack.git
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:
parent
3f176d2226
commit
e0bfc8fff5
|
|
@ -42,9 +42,14 @@ beforeAll(async () => {
|
||||||
// The test needs to start a server. Let's use the existing server infrastructure.
|
// The test needs to start a server. Let's use the existing server infrastructure.
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(async () => {
|
||||||
try { testServer.server.stop(); } catch {}
|
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.
|
// We need a running browse server for HTTP tests.
|
||||||
|
|
|
||||||
|
|
@ -94,11 +94,14 @@ beforeAll(async () => {
|
||||||
await bm.launch();
|
await bm.launch();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(async () => {
|
||||||
// Force kill browser instead of graceful close (avoids hang)
|
|
||||||
try { testServer.server.stop(); } catch {}
|
try { testServer.server.stop(); } catch {}
|
||||||
// bm.close() can hang — just let process exit handle it
|
// Close only this file's own browser — never process.exit(): bun test runs
|
||||||
setTimeout(() => process.exit(0), 500);
|
// 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 ─────────────────────────────────────────────────
|
// ─── Navigation ─────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,15 @@ beforeAll(async () => {
|
||||||
await handleWriteCommand('goto', [boardUrl], bm);
|
await handleWriteCommand('goto', [boardUrl], bm);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(async () => {
|
||||||
try { server.stop(); } catch {}
|
try { server.stop(); } catch {}
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
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 ──────────────────────────────────────────────
|
// ─── DOM Structure ──────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -460,9 +460,14 @@ describe('Hidden element stripping', () => {
|
||||||
await bm.launch();
|
await bm.launch();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(async () => {
|
||||||
try { testServer.server.stop(); } catch {}
|
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 () => {
|
test('detects CSS-hidden elements on injection-hidden page', async () => {
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,14 @@ beforeAll(async () => {
|
||||||
await bm.launch();
|
await bm.launch();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(async () => {
|
||||||
try { testServer.server.stop(); } catch {}
|
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) ────────────
|
// ─── 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.
|
// 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.
|
// 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', () => {
|
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();
|
const hbm = new BrowserManager();
|
||||||
await hbm.launch();
|
await hbm.launch();
|
||||||
|
|
||||||
|
|
@ -206,7 +218,7 @@ describe('handoff integration', () => {
|
||||||
}
|
}
|
||||||
}, 45000);
|
}, 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();
|
const hbm = new BrowserManager();
|
||||||
await hbm.launch();
|
await hbm.launch();
|
||||||
|
|
||||||
|
|
@ -223,7 +235,7 @@ describe('handoff integration', () => {
|
||||||
}
|
}
|
||||||
}, 45000);
|
}, 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();
|
const hbm = new BrowserManager();
|
||||||
await hbm.launch();
|
await hbm.launch();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,14 @@ describe('defense-in-depth — live Playwright fixture', () => {
|
||||||
await bm.launch();
|
await bm.launch();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(async () => {
|
||||||
try { testServer.server.stop(); } catch {}
|
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 () => {
|
test('L2 — content-security.ts hidden-element stripper detects the .sneaky div', async () => {
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,14 @@ beforeAll(async () => {
|
||||||
await bm.launch();
|
await bm.launch();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(async () => {
|
||||||
try { testServer.server.stop(); } catch {}
|
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 ────────────────────────────────────────────
|
// ─── Snapshot Output ────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -361,16 +361,27 @@ describe("daemon /shutdown", () => {
|
||||||
await fetchHandler(
|
await fetchHandler(
|
||||||
req("POST", `/boards/${board.id}/api/feedback`, { regenerated: false }),
|
req("POST", `/boards/${board.id}/api/feedback`, { regenerated: false }),
|
||||||
);
|
);
|
||||||
// Now non-done count is 0 — handler should return shuttingDown:true.
|
// The handler arms setTimeout(gracefulShutdown, 50), and gracefulShutdown
|
||||||
// We DON'T let the real gracefulShutdown timer fire (it calls process.exit
|
// arms setTimeout(process.exit, 50). bun test runs ALL files in one
|
||||||
// after 50ms which would tear down the test runner); instead we just
|
// process, so letting that exit fire would kill the whole suite ~100ms
|
||||||
// observe the immediate response.
|
// later (exit 0, no summary — see test/no-suicide-exit.test.ts). Stub
|
||||||
const r = await fetchHandler(req("POST", "/shutdown"));
|
// process.exit, wait past both timers so they fire harmlessly while
|
||||||
expect(r.status).toBe(200);
|
// stubbed, then restore. (resetForTest does NOT defuse the timers: the
|
||||||
const body = (await r.json()) as any;
|
// exit callback is unconditional.)
|
||||||
expect(body.shuttingDown).toBe(true);
|
const origExit = process.exit;
|
||||||
// Reset state for subsequent tests; the shutdown timer will be a no-op
|
(process as any).exit = (() => undefined) as any;
|
||||||
// because the next resetForTest flips shuttingDown back to false.
|
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();
|
resetDaemon();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,16 @@ import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
let bm: BrowserManager;
|
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 baseUrl: string;
|
||||||
let server: ReturnType<typeof Bun.serve>;
|
let server: ReturnType<typeof Bun.serve>;
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
|
|
@ -121,10 +131,15 @@ beforeAll(async () => {
|
||||||
await bm.launch();
|
await bm.launch();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(async () => {
|
||||||
try { server.stop(); } catch {}
|
try { server.stop(); } catch {}
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
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 ─────────────
|
// ─── The critical test: browser click → file on disk ─────────────
|
||||||
|
|
@ -137,32 +152,32 @@ describe('Submit: browser click → feedback.json on disk', () => {
|
||||||
serverState = 'serving';
|
serverState = 'serving';
|
||||||
|
|
||||||
// Navigate to the board (board JS uses relative URLs + location.protocol detect)
|
// 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
|
// Verify the board detects HTTP mode (so postFeedback will actually fetch
|
||||||
// instead of falling into the file:// DOM-only path)
|
// 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:'"
|
"location.protocol === 'http:' || location.protocol === 'https:'"
|
||||||
], bm);
|
]);
|
||||||
expect(httpDetected).toBe('true');
|
expect(httpDetected).toBe('true');
|
||||||
|
|
||||||
// User picks variant A, rates it 5 stars
|
// User picks variant A, rates it 5 stars
|
||||||
await handleReadCommand('js', [
|
await readCmd('js', [
|
||||||
'document.querySelectorAll("input[name=\\"preferred\\"]")[0].click()'
|
'document.querySelectorAll("input[name=\\"preferred\\"]")[0].click()'
|
||||||
], bm);
|
]);
|
||||||
await handleReadCommand('js', [
|
await readCmd('js', [
|
||||||
'document.querySelectorAll(".stars")[0].querySelectorAll(".star")[4].click()'
|
'document.querySelectorAll(".stars")[0].querySelectorAll(".star")[4].click()'
|
||||||
], bm);
|
]);
|
||||||
|
|
||||||
// User adds overall feedback
|
// User adds overall feedback
|
||||||
await handleReadCommand('js', [
|
await readCmd('js', [
|
||||||
'document.getElementById("overall-feedback").value = "Ship variant A"'
|
'document.getElementById("overall-feedback").value = "Ship variant A"'
|
||||||
], bm);
|
]);
|
||||||
|
|
||||||
// User clicks Submit
|
// User clicks Submit
|
||||||
await handleReadCommand('js', [
|
await readCmd('js', [
|
||||||
'document.getElementById("submit-btn").click()'
|
'document.getElementById("submit-btn").click()'
|
||||||
], bm);
|
]);
|
||||||
|
|
||||||
// Wait a beat for the async POST to complete
|
// Wait a beat for the async POST to complete
|
||||||
await new Promise(r => setTimeout(r, 300));
|
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));
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
// After submit, the page should be read-only
|
// After submit, the page should be read-only
|
||||||
const submitBtnExists = await handleReadCommand('js', [
|
const submitBtnExists = await readCmd('js', [
|
||||||
'document.getElementById("submit-btn").style.display'
|
'document.getElementById("submit-btn").style.display'
|
||||||
], bm);
|
]);
|
||||||
// submit button is hidden after post-submit lifecycle
|
// submit button is hidden after post-submit lifecycle
|
||||||
expect(submitBtnExists).toBe('none');
|
expect(submitBtnExists).toBe('none');
|
||||||
|
|
||||||
const successVisible = await handleReadCommand('js', [
|
const successVisible = await readCmd('js', [
|
||||||
'document.getElementById("success-msg").style.display'
|
'document.getElementById("success-msg").style.display'
|
||||||
], bm);
|
]);
|
||||||
expect(successVisible).toBe('block');
|
expect(successVisible).toBe('block');
|
||||||
|
|
||||||
// Success message should mention /design-shotgun
|
// Success message should mention /design-shotgun
|
||||||
const successText = await handleReadCommand('js', [
|
const successText = await readCmd('js', [
|
||||||
'document.getElementById("success-msg").textContent'
|
'document.getElementById("success-msg").textContent'
|
||||||
], bm);
|
]);
|
||||||
expect(successText).toContain('design-shotgun');
|
expect(successText).toContain('design-shotgun');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -211,17 +226,17 @@ describe('Regenerate: browser click → feedback-pending.json on disk', () => {
|
||||||
serverState = 'serving';
|
serverState = 'serving';
|
||||||
|
|
||||||
// Fresh page
|
// Fresh page
|
||||||
await handleWriteCommand('goto', [baseUrl], bm);
|
await writeCmd('goto', [baseUrl]);
|
||||||
|
|
||||||
// User clicks "Totally different" chiclet
|
// User clicks "Totally different" chiclet
|
||||||
await handleReadCommand('js', [
|
await readCmd('js', [
|
||||||
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
|
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
|
||||||
], bm);
|
]);
|
||||||
|
|
||||||
// User clicks Regenerate
|
// User clicks Regenerate
|
||||||
await handleReadCommand('js', [
|
await readCmd('js', [
|
||||||
'document.getElementById("regen-btn").click()'
|
'document.getElementById("regen-btn").click()'
|
||||||
], bm);
|
]);
|
||||||
|
|
||||||
// Wait for async POST
|
// Wait for async POST
|
||||||
await new Promise(r => setTimeout(r, 300));
|
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);
|
if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath);
|
||||||
serverState = 'serving';
|
serverState = 'serving';
|
||||||
|
|
||||||
await handleWriteCommand('goto', [baseUrl], bm);
|
await writeCmd('goto', [baseUrl]);
|
||||||
|
|
||||||
// Click "More like this" on variant B (index 1)
|
// Click "More like this" on variant B (index 1)
|
||||||
await handleReadCommand('js', [
|
await readCmd('js', [
|
||||||
'document.querySelectorAll(".more-like-this")[1].click()'
|
'document.querySelectorAll(".more-like-this")[1].click()'
|
||||||
], bm);
|
]);
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 300));
|
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 () => {
|
test('board shows spinner after regenerate (user stays on same tab)', async () => {
|
||||||
serverState = 'serving';
|
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()'
|
'document.querySelector(".regen-chiclet[data-action=\\"different\\"]").click()'
|
||||||
], bm);
|
]);
|
||||||
await handleReadCommand('js', [
|
await readCmd('js', [
|
||||||
'document.getElementById("regen-btn").click()'
|
'document.getElementById("regen-btn").click()'
|
||||||
], bm);
|
]);
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
// Board should show "Generating new designs..." text
|
// Board should show "Generating new designs..." text
|
||||||
const bodyText = await handleReadCommand('js', [
|
const bodyText = await readCmd('js', [
|
||||||
'document.body.textContent'
|
'document.body.textContent'
|
||||||
], bm);
|
]);
|
||||||
expect(bodyText).toContain('Generating new designs');
|
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);
|
if (fs.existsSync(feedbackPath)) fs.unlinkSync(feedbackPath);
|
||||||
serverState = 'serving';
|
serverState = 'serving';
|
||||||
|
|
||||||
await handleWriteCommand('goto', [baseUrl], bm);
|
await writeCmd('goto', [baseUrl]);
|
||||||
|
|
||||||
// Step 1: User clicks Regenerate
|
// Step 1: User clicks Regenerate
|
||||||
await handleReadCommand('js', [
|
await readCmd('js', [
|
||||||
'document.querySelector(".regen-chiclet[data-action=\\"match\\"]").click()'
|
'document.querySelector(".regen-chiclet[data-action=\\"match\\"]").click()'
|
||||||
], bm);
|
]);
|
||||||
await handleReadCommand('js', [
|
await readCmd('js', [
|
||||||
'document.getElementById("regen-btn").click()'
|
'document.getElementById("regen-btn").click()'
|
||||||
], bm);
|
]);
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
|
|
@ -329,21 +344,21 @@ describe('Full regeneration round-trip: regen → reload → submit', () => {
|
||||||
expect(serverState).toBe('serving');
|
expect(serverState).toBe('serving');
|
||||||
|
|
||||||
// Step 4: Board auto-refreshes (simulated by navigating again)
|
// 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)
|
// Verify the board is fresh (no prior picks)
|
||||||
const status = await handleReadCommand('js', [
|
const status = await readCmd('js', [
|
||||||
'document.getElementById("status").textContent'
|
'document.getElementById("status").textContent'
|
||||||
], bm);
|
]);
|
||||||
expect(status).toBe('');
|
expect(status).toBe('');
|
||||||
|
|
||||||
// Step 5: User picks variant C on round 2 and submits
|
// Step 5: User picks variant C on round 2 and submits
|
||||||
await handleReadCommand('js', [
|
await readCmd('js', [
|
||||||
'document.querySelectorAll("input[name=\\"preferred\\"]")[2].click()'
|
'document.querySelectorAll("input[name=\\"preferred\\"]")[2].click()'
|
||||||
], bm);
|
]);
|
||||||
await handleReadCommand('js', [
|
await readCmd('js', [
|
||||||
'document.getElementById("submit-btn").click()'
|
'document.getElementById("submit-btn").click()'
|
||||||
], bm);
|
]);
|
||||||
|
|
||||||
await new Promise(r => setTimeout(r, 300));
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -261,14 +261,39 @@ function formatShardSummary(shards: string[][]): string[] {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a shard's output shows the run ended WITHOUT bun's final summary
|
||||||
|
* ("Ran N tests across ..."). A process.exit() fired mid-suite skips the
|
||||||
|
* summary AND hands back whatever code the caller passed — historically 0,
|
||||||
|
* which made a truncated shard indistinguishable from a green one. Exit code
|
||||||
|
* alone is therefore not evidence of completion; the summary line is.
|
||||||
|
* (Fault-injection coverage: test/exit-propagation.test.ts.)
|
||||||
|
*/
|
||||||
|
export function shardRunLooksTruncated(status: number | null, output: string): boolean {
|
||||||
|
if (status !== 0) return false; // already failing — not the silent case
|
||||||
|
return !/Ran \d+ tests? across \d+ files?/.test(output);
|
||||||
|
}
|
||||||
|
|
||||||
function runShard(files: string[], shardNumber: number, totalShards: number): number {
|
function runShard(files: string[], shardNumber: number, totalShards: number): number {
|
||||||
const header = `[test:free] shard ${shardNumber}/${totalShards} (${files.length} files)`;
|
const header = `[test:free] shard ${shardNumber}/${totalShards} (${files.length} files)`;
|
||||||
console.log(header);
|
console.log(header);
|
||||||
const result = spawnSync(process.execPath, buildShardArgs(files), {
|
const result = spawnSync(process.execPath, buildShardArgs(files), {
|
||||||
cwd: ROOT,
|
cwd: ROOT,
|
||||||
stdio: 'inherit',
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
encoding: 'utf8',
|
||||||
env: process.env,
|
env: process.env,
|
||||||
});
|
});
|
||||||
|
// Preserve the inherit-style UX: replay the shard's output.
|
||||||
|
if (result.stdout) process.stdout.write(result.stdout);
|
||||||
|
if (result.stderr) process.stderr.write(result.stderr);
|
||||||
|
const combined = `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
||||||
|
if (shardRunLooksTruncated(result.status, combined)) {
|
||||||
|
console.error(
|
||||||
|
`${header} exited 0 WITHOUT bun's final summary — the run was truncated ` +
|
||||||
|
'(a process.exit fired mid-suite). Treating as FAILED.',
|
||||||
|
);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
console.error(`${header} failed with exit code ${result.status ?? 1}`);
|
console.error(`${header} failed with exit code ${result.status ?? 1}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { shardRunLooksTruncated } from '../scripts/test-free-shards';
|
||||||
|
|
||||||
|
// Fault-injection companion to test/no-suicide-exit.test.ts.
|
||||||
|
//
|
||||||
|
// The static tripwire prevents OUR files from scheduling a delayed
|
||||||
|
// process.exit. This file proves, with real bun output, WHY that guard and
|
||||||
|
// the sharded runner's summary check both exist: `bun test` itself exits 0
|
||||||
|
// when a mid-suite process.exit(0) fires — the truncated run is
|
||||||
|
// indistinguishable from a green one by exit code alone. The sharded
|
||||||
|
// runner's shardRunLooksTruncated() predicate is the detection layer; these
|
||||||
|
// tests drive it with genuine truncated and genuine complete runs.
|
||||||
|
|
||||||
|
function runBunTest(dir: string) {
|
||||||
|
return spawnSync('bun', ['test', '.'], {
|
||||||
|
cwd: dir,
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 60000,
|
||||||
|
env: { ...process.env },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function withFixtureDir(files: Record<string, string>, fn: (dir: string) => void) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'exit-prop-'));
|
||||||
|
try {
|
||||||
|
for (const [name, content] of Object.entries(files)) {
|
||||||
|
fs.writeFileSync(path.join(dir, name), content);
|
||||||
|
}
|
||||||
|
fn(dir);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixture sources live as .txt (test/fixtures/exit-propagation/) and are
|
||||||
|
// copied to .test.ts names inside a temp dir at runtime — the no-suicide-exit
|
||||||
|
// static tripwire scans every *.test.ts in the repo, and inlining the suicide
|
||||||
|
// pattern here (even as a string) would rightly trip it.
|
||||||
|
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'exit-propagation');
|
||||||
|
const SUICIDE_FIXTURE = fs.readFileSync(path.join(FIXTURES, 'suicide.txt'), 'utf8');
|
||||||
|
const FAILING_FIXTURE = fs.readFileSync(path.join(FIXTURES, 'failing.txt'), 'utf8');
|
||||||
|
const PASSING_FIXTURE = fs.readFileSync(path.join(FIXTURES, 'passing.txt'), 'utf8');
|
||||||
|
|
||||||
|
describe('exit-code propagation (fault injection)', () => {
|
||||||
|
test('a mid-suite process.exit(0) yields exit 0 with NO summary — and the shard predicate catches it', () => {
|
||||||
|
withFixtureDir(
|
||||||
|
{ 'a-suicide.test.ts': SUICIDE_FIXTURE, 'b-failing.test.ts': FAILING_FIXTURE },
|
||||||
|
(dir) => {
|
||||||
|
const r = runBunTest(dir);
|
||||||
|
const combined = `${r.stdout ?? ''}${r.stderr ?? ''}`;
|
||||||
|
if (r.status === 0) {
|
||||||
|
// The dangerous shape: green exit, truncated run. The predicate
|
||||||
|
// MUST flag it — this is the assertion that guards the suite.
|
||||||
|
expect(shardRunLooksTruncated(r.status, combined)).toBe(true);
|
||||||
|
} else {
|
||||||
|
// If a future bun version starts propagating the failure itself,
|
||||||
|
// even better — nothing to detect. Either way, never green+silent.
|
||||||
|
expect(r.status).not.toBe(0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a complete green run is NOT flagged as truncated', () => {
|
||||||
|
withFixtureDir({ 'ok.test.ts': PASSING_FIXTURE }, (dir) => {
|
||||||
|
const r = runBunTest(dir);
|
||||||
|
const combined = `${r.stdout ?? ''}${r.stderr ?? ''}`;
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(shardRunLooksTruncated(r.status, combined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a plain failing run propagates nonzero and is not the silent case', () => {
|
||||||
|
withFixtureDir({ 'fail.test.ts': FAILING_FIXTURE }, (dir) => {
|
||||||
|
const r = runBunTest(dir);
|
||||||
|
const combined = `${r.stdout ?? ''}${r.stderr ?? ''}`;
|
||||||
|
expect(r.status).not.toBe(0);
|
||||||
|
expect(shardRunLooksTruncated(r.status, combined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
import { test, expect } from 'bun:test';
|
||||||
|
test('this failure must be visible', () => { expect(1).toBe(2); });
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
import { test, expect } from 'bun:test';
|
||||||
|
test('passes', () => { expect(1).toBe(1); });
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { test, expect } from 'bun:test';
|
||||||
|
test('passes then arms a delayed exit', () => {
|
||||||
|
expect(1).toBe(1);
|
||||||
|
setTimeout(() => process.exit(0), 300);
|
||||||
|
});
|
||||||
|
test('waits long enough for the timer to fire', async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* Guard: no test file may schedule a delayed process.exit().
|
||||||
|
*
|
||||||
|
* `bun test` runs EVERY test file in one process. The pattern of arming a
|
||||||
|
* 500ms timer in afterAll whose callback calls process.exit(0) — once used
|
||||||
|
* in several browse/design tests as a "bm.close() can hang" workaround —
|
||||||
|
* assumes each file gets its own process. It doesn't: the armed timer fires
|
||||||
|
* 500ms later, mid-way through a LATER test file, and kills the entire
|
||||||
|
* suite with exit code 0 and no summary. The truncated run silently masks
|
||||||
|
* every downstream failure (observed: only ~16 of 434 files ran, shell
|
||||||
|
* exit 0).
|
||||||
|
*
|
||||||
|
* This test statically scans every *.test.ts in the repo and fails if any
|
||||||
|
* schedules process.exit via setTimeout. Teardown must only release the
|
||||||
|
* file's own resources (e.g. `await bm.close()` — BrowserManager.close()
|
||||||
|
* is already time-boxed internally) — never terminate the shared runner.
|
||||||
|
*
|
||||||
|
* If a future test legitimately needs this pattern inside a child-process
|
||||||
|
* script (template literal passed to `bun -e`), split the child script
|
||||||
|
* into a fixture file instead of exempting it here.
|
||||||
|
*/
|
||||||
|
import { test, expect } from 'bun:test';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(import.meta.dir, '..');
|
||||||
|
|
||||||
|
// Matches a setTimeout whose arrow callback (with or without an argument)
|
||||||
|
// immediately calls process.exit. Doesn't match its own escaped source text
|
||||||
|
// (the backslashes in this regex literal prevent a literal-text match).
|
||||||
|
const DELAYED_EXIT = /setTimeout\(\s*(?:\(\s*\)|\(?\w+\)?)\s*=>\s*process\.exit\(/;
|
||||||
|
|
||||||
|
test('no test file schedules a delayed process.exit (kills the whole bun test run)', () => {
|
||||||
|
const glob = new Bun.Glob('**/*.test.ts');
|
||||||
|
const violations: string[] = [];
|
||||||
|
|
||||||
|
for (const rel of glob.scanSync({ cwd: repoRoot })) {
|
||||||
|
if (rel.includes('node_modules/')) continue;
|
||||||
|
const source = fs.readFileSync(path.join(repoRoot, rel), 'utf-8');
|
||||||
|
const lines = source.split('\n');
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (DELAYED_EXIT.test(lines[i])) {
|
||||||
|
violations.push(`${rel}:${i + 1}: ${lines[i].trim()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
@ -35,6 +35,12 @@ function runConfig(args: string[], extraEnv: Record<string, string> = {}): { std
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
// HOME isolation: endpoint_hash() reads $HOME/.claude.json for the
|
||||||
|
// gbrain MCP URL. Pointing HOME at the empty TMP_HOME makes it
|
||||||
|
// deterministically 'local' regardless of the developer's real
|
||||||
|
// ~/.claude.json (which would otherwise change the persisted key
|
||||||
|
// namespace to user_slug_at_<sha8-of-url>).
|
||||||
|
HOME: TMP_HOME,
|
||||||
...extraEnv,
|
...extraEnv,
|
||||||
},
|
},
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
|
@ -92,7 +98,9 @@ describe('resolve-user-slug fallback chain', () => {
|
||||||
const configFile = join(TMP_HOME, 'config.yaml');
|
const configFile = join(TMP_HOME, 'config.yaml');
|
||||||
expect(existsSync(configFile)).toBe(true);
|
expect(existsSync(configFile)).toBe(true);
|
||||||
const content = readFileSync(configFile, 'utf-8');
|
const content = readFileSync(configFile, 'utf-8');
|
||||||
expect(content).toMatch(/^user_slug_at_(local|[a-f0-9]{8}|[a-f0-9]{16}):\s+persisttest/m);
|
// HOME is isolated to the empty TMP_HOME, so endpoint_hash() is
|
||||||
|
// deterministically the literal 'local' on every machine.
|
||||||
|
expect(content).toMatch(/^user_slug_at_local:\s+persisttest/m);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subsequent calls return same slug (stable across sessions)', () => {
|
test('subsequent calls return same slug (stable across sessions)', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue