Files
pbakaus_impeccable/scripts/run-tests.mjs
T
Paul Bakaus 47f1871385 Tests: stop two harness hangs from wedging a whole run
Two suites could hang forever and never print a tally, because the one
mechanism that could interrupt the wedged work was missing on both paths.

Hang 1 (bun run test / build-phase.test.mjs): the test's run() helper
spawned every child with spawnSync and no timeout. spawnSync blocks the
test worker's thread, so node's --test-timeout (an event-loop timer)
cannot interrupt a child that wedges (a fork/exec blocked on OS resources
under concurrency, a gate's comp-diff grandchild, or a stray browser
launch). Bound every child with spawnSync timeout + killSignal SIGKILL so
a wedge becomes a fast, named failure the next test survives.

Hang 2 (bun run test:skill-behavior): runTurn called generateText with no
client-side deadline, so a stalled provider stream kept the fetch (and the
whole node process) alive past the per-test timeout, producing no tally.
Attach a real AbortSignal (default 840s, under the 900s per-test cap):
on expiry the fetch aborts, the turn throws, and the scenario
fails-and-continues. The unref'd timer is cleared on completion.

Runner backstops: run-tests.mjs now spawns each command as a detached
process-group leader and enforces a per-suite wall-clock cap that SIGKILLs
the entire group (workers, grandchildren, browsers) on expiry, with
SIGINT/SIGTERM forwarded so Ctrl-C still reaps the tree. The core node
batch gets a finite --test-timeout (180s); skill-behavior gets a 60min
group cap. Env overrides: IMPECCABLE_TEST_WALL_CLOCK_MS,
IMPECCABLE_SKILL_BEHAVIOR_TURN_TIMEOUT_MS,
IMPECCABLE_BUILD_PHASE_RUN_TIMEOUT_MS.

Proof: bun run test green twice (~60s); scoped claude-sonnet-5
skill-behavior sweep terminates with a tally (20 tests, ~32min) where the
840s abort caught a wedged redesign turn and the sweep continued instead
of hanging.

Prepared with AI assistance (Claude Code).
2026-09-01 11:09:44 -07:00

149 lines
5.2 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { DEFAULT_SUITES, OPT_IN_SUITES, SUITES, expandSuites } from './test-suites.mjs';
// Global wall-clock backstop for any one command. Even with per-test timeouts
// and client-side network deadlines in place, a wedged tool or an orphaned
// grandchild can keep a runner alive forever; this cap guarantees the sweep
// terminates. Per-suite `wallClockMs` overrides it; the env var overrides both.
const DEFAULT_WALL_CLOCK_MS = Number(process.env.IMPECCABLE_TEST_WALL_CLOCK_MS) || 1_200_000;
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
printHelp();
process.exit(0);
}
if (args.includes('--list')) {
printSuites();
process.exit(0);
}
const requestedSuites = args.filter((arg) => !arg.startsWith('-'));
let suites;
try {
suites = expandSuites(requestedSuites);
} catch (err) {
console.error(err.message);
process.exit(1);
}
await main();
async function main() {
for (const suiteName of suites) {
const suite = SUITES[suiteName];
console.log(`\n## test:${suiteName}`);
console.log(suite.description);
for (const command of suite.commands) {
await runCommand(command);
}
}
}
async function runCommand(command) {
const env = { ...process.env, ...(command.env || {}) };
const wallClockMs = command.wallClockMs ?? DEFAULT_WALL_CLOCK_MS;
if (command.runner === 'bun') {
await runProcess('bun', ['test', ...command.files], { env, wallClockMs });
return;
}
if (command.runner === 'node') {
// One invocation for the whole file list: node --test runs each file in
// its own child process regardless, so isolation is unchanged, but the
// runner-per-file spawn overhead is gone and files execute concurrently.
// Measured on the live suite (38 files): 52s serial-per-file vs 18s
// batched at concurrency 4. Suites can pin `concurrency: 1` if their
// tests ever contend for a shared resource.
const args = ['--test', `--test-concurrency=${command.concurrency ?? 4}`];
if (command.timeoutMs) args.push(`--test-timeout=${command.timeoutMs}`);
if (command.forceExit) args.push('--test-force-exit');
args.push(...command.files);
await runProcess(process.execPath, args, { env, wallClockMs });
return;
}
throw new Error(`Unsupported test runner "${command.runner}"`);
}
// Spawn as a detached process-group leader so the wall-clock cap can SIGKILL
// the entire tree — the runner, its per-file `node --test` workers, and any
// grandchildren or browsers they left open — not just the top process. A
// blocked spawnSync inside a test can't be reached by node's `--test-timeout`;
// this group kill is the guaranteed cleanup that lets the sweep always end.
function runProcess(cmd, args, { env, wallClockMs }) {
return new Promise((resolve) => {
console.log(`$ ${formatCommand(cmd, args)}`);
const child = spawn(cmd, args, { stdio: 'inherit', env, detached: true });
let timedOut = false;
// A detached child is its own group leader, so an interactive Ctrl-C on
// the runner no longer reaches it. Forward the interrupt to the group so
// the whole tree is torn down instead of orphaned.
const forward = (signal) => () => {
try { process.kill(-child.pid, signal); } catch { try { child.kill(signal); } catch { /* gone */ } }
};
const onInt = forward('SIGINT');
const onTerm = forward('SIGTERM');
process.on('SIGINT', onInt);
process.on('SIGTERM', onTerm);
const timer = wallClockMs
? setTimeout(() => {
timedOut = true;
console.error(
`\n[run-tests] wall-clock cap of ${wallClockMs}ms exceeded for "${formatCommand(cmd, args)}"; ` +
'killing the process group (SIGKILL).',
);
try { process.kill(-child.pid, 'SIGKILL'); }
catch { try { child.kill('SIGKILL'); } catch { /* already gone */ } }
}, wallClockMs)
: null;
const cleanup = () => {
if (timer) clearTimeout(timer);
process.off('SIGINT', onInt);
process.off('SIGTERM', onTerm);
};
child.on('error', (err) => {
cleanup();
console.error(err.message);
process.exit(1);
});
child.on('exit', (code, signal) => {
cleanup();
if (timedOut) process.exit(1);
if (signal) { console.error(`[run-tests] "${formatCommand(cmd, args)}" killed by signal ${signal}`); process.exit(1); }
if (code !== 0) process.exit(code || 1);
resolve();
});
});
}
function formatCommand(cmd, args) {
const bin = cmd === process.execPath ? 'node' : cmd;
return [bin, ...args].join(' ');
}
function printHelp() {
console.log(`Usage: node scripts/run-tests.mjs [suite...]
Aliases:
default ${DEFAULT_SUITES.join(', ')}
all-local ${DEFAULT_SUITES.join(', ')}
all ${[...DEFAULT_SUITES, ...OPT_IN_SUITES].join(', ')}
Run with --list to see suite contents.`);
}
function printSuites() {
for (const [name, suite] of Object.entries(SUITES)) {
const marker = suite.optIn ? ' (opt-in)' : '';
console.log(`\n${name}${marker}`);
console.log(` ${suite.description}`);
for (const command of suite.commands) {
console.log(` ${command.runner}:`);
for (const file of command.files) console.log(` ${file}`);
}
}
}