#!/usr/bin/env bun /** * test-paid-shards — enumerate, shard, and run the paid (gate/periodic) tier. * * The single-process `test:gate` fan-out has never completed a run: one wedged * or spinning file takes the whole tier down, and an in-process `--timeout` * cannot save it because a spinning main thread never fires a timer. This * runner applies the free tier's proven fix — one Bun process per shard — plus * the two things the paid tier additionally needs: * * - an EXTERNAL wall-clock timeout that kills the shard's process GROUP, and * - an aggregate that distinguishes failed from timed-out from never-started, * so 26% execution can never again look like a pass. * * Why not Bun 1.3.13's native `--shard` / isolated runs? Three gaps, each one * fatal for this tier: * 1. No detached-process-group SIGKILL. Paid tests spawn `claude` / `codex` * PTY grandchildren; when a shard hangs, in-process isolation kills the * Bun worker but the grandchildren survive and burn cores for hours. * 2. No never-started taxonomy. A run that aborts partway reports only what * executed — the shards that never ran are invisible, which is exactly * the 26%-execution-looks-like-a-pass bug. * 3. No per-shard env / eval dir. Each shard needs its own GSTACK_EVAL_DIR * so eval baselines are per-test-file instead of last-flush-wins. * * Worst-case wall clock (all shards hit the 30min timeout, 4 parallel jobs): * gate tier is 49 shards × 30min / 4 jobs ≈ 6.2h; periodic is 59 shards ≈ 7.4h. * The eval:bg:* detach timeouts (25200s / 28800s) are sized against these. * * Enumeration matches package.json's `test:gate` globs (via the shared * test/helpers/paid-test-set.ts) and honors EVALS_TIER against the E2E_TIERS * map in test/helpers/touchfiles.ts. Output classification reuses * scripts/test-strict-output.ts rather than reimplementing it. * * Parallelism now lives ACROSS shards (--jobs), not inside one Bun process, so * each shard runs its own file sequentially and can be killed independently. * * Usage: * bun run scripts/test-paid-shards.ts --list # shard plan only * bun run scripts/test-paid-shards.ts --tier gate # run gate tier * bun run scripts/test-paid-shards.ts --timeout 600 --jobs 2 */ import { spawn, type ChildProcess } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { normalizeRelativePath } from './test-free-shards'; import { BunTestOutputClassifier, exactTestFileSelectors, forwardAndClassify, installChildSignalForwarding, strictTestExitCode, } from './test-strict-output'; import { PAID_TEST_GLOBS, isPaidTestFile } from '../test/helpers/paid-test-set'; import { getProjectEvalDir } from '../test/helpers/eval-store'; export { PAID_TEST_GLOBS, isPaidTestFile }; const ROOT = path.resolve(import.meta.dir, '..'); export type PaidTier = 'gate' | 'periodic'; export const DEFAULT_TIER: PaidTier = 'gate'; export const DEFAULT_SHARD_TIMEOUT_MS = 30 * 60_000; export const DEFAULT_MAX_FILES_PER_SHARD = 1; export const DEFAULT_JOBS = 4; export function collectPaidTestFiles(rootDir = ROOT): string[] { const testDir = path.join(rootDir, 'test'); if (!fs.existsSync(testDir)) return []; return fs.readdirSync(testDir) .map((name) => `test/${name}`) .filter(isPaidTestFile) .sort(); } export interface TierClassification { included: boolean; reason: string; } /** * Decide whether a paid test file has anything to run in `tier`. * * Per-TEST tier filtering already happens at runtime: test/helpers/e2e-helpers.ts * intersects the selected tests with E2E_TIERS whenever EVALS_TIER is set, and * this runner passes EVALS_TIER down to every shard. So this file-level pass is * only an optimization — skipping a file merely saves one near-instant shard. * * Exclusion is the dangerous direction (a wrongly-skipped gate test is exactly * the invisible-non-execution bug this runner exists to kill), so the only * exclusion evidence accepted is an explicit whole-file tier guard: either the * raw `EVALS_TIER === ''` predicate or the consolidated helper form * `describeE2ETier('')` / `e2eTierEnabled('')` from * test/helpers/e2e-gate.ts (same semantics, read from env at module load). * Inferring a file's tier from which E2E_TIERS names appear in its source * is guesswork that silently drops real work: short keys like 'retro' match * unrelated strings, and LLM-judge tests are keyed off LLM_JUDGE_TOUCHFILES and * carry no E2E_TIERS name at all. Everything without an explicit other-tier * guard runs and self-skips. */ export function classifyPaidTestFile(source: string, tier: PaidTier): TierClassification { const other: PaidTier = tier === 'gate' ? 'periodic' : 'gate'; const declares = (candidate: PaidTier) => new RegExp(`EVALS_TIER\\s*===\\s*['"\`]${candidate}['"\`]`).test(source) || new RegExp(`\\b(?:describeE2ETier|e2eTierEnabled)\\(\\s*['"\`]${candidate}['"\`]`).test(source); if (declares(tier)) return { included: true, reason: `declares tier '${tier}'` }; if (declares(other)) return { included: false, reason: `declares tier '${other}' only` }; return { included: true, reason: 'no whole-file tier guard — runtime E2E_TIERS filter decides' }; } export interface TierSelection { selected: string[]; excluded: Array<{ file: string; reason: string }>; } export function selectPaidTestFiles(files: string[], tier: PaidTier, rootDir = ROOT): TierSelection { const selected: string[] = []; const excluded: Array<{ file: string; reason: string }> = []; for (const file of files) { const source = fs.readFileSync(path.join(rootDir, file), 'utf8'); const classification = classifyPaidTestFile(source, tier); if (classification.included) selected.push(file); else excluded.push({ file, reason: classification.reason }); } return { selected, excluded }; } export function planPaidShards( files: string[], options: { maxFilesPerShard?: number } = {}, ): string[][] { const size = Math.max(1, options.maxFilesPerShard ?? DEFAULT_MAX_FILES_PER_SHARD); const unique = [...new Set(files.map(normalizeRelativePath))].sort(); const shards: string[][] = []; for (let index = 0; index < unique.length; index += size) shards.push(unique.slice(index, index + size)); return shards; } export function buildPaidShardArgs(files: string[], timeoutMs: number): string[] { return ['test', ...files, '--retry', '2', `--timeout=${timeoutMs}`]; } /** * Stable per-shard eval-dir slug: test filename sans extension, sanitized. * Stable across runs so each shard baselines against its own prior run. */ export function shardSlug(files: string[]): string { return files .map((file) => path.basename(normalizeRelativePath(file)).replace(/\.test\.(?:[cm]?[jt]s|tsx|jsx)$/, '')) .join('+') .replace(/[^a-zA-Z0-9._+-]/g, '-'); } export type ShardStatus = 'passed' | 'failed' | 'timed-out' | 'never-started'; export interface ShardOutcome { shard: number; files: string[]; status: ShardStatus; exitCode: number | null; elapsedMs: number; groupPid: number | null; } export interface ShardCommand { command: string; args: string[]; } export interface RunShardsOptions { timeoutMs?: number; jobs?: number; rootDir?: string; env?: NodeJS.ProcessEnv; /** When set, each shard child gets GSTACK_EVAL_DIR=/shards//. */ evalDirBase?: string; /** Override the spawned command. Tests inject fake slow/spinning commands. */ commandFor?: (files: string[]) => ShardCommand; log?: (line: string) => void; } /** * SIGKILL the shard's whole process group. Orphaned grandchildren (browsers, * claude sessions) are how a stalled run once burned a core for 15.7 hours. */ function killProcessGroup(child: ChildProcess, signal: NodeJS.Signals): void { if (process.platform === 'win32' || typeof child.pid !== 'number') { child.kill(signal); return; } try { process.kill(-child.pid, signal); } catch (err) { const code = (err as NodeJS.ErrnoException).code; if (code === 'ESRCH') return; // group already gone if (code !== 'EPERM') throw err; // Observed on macOS after a SIGKILLed group is reaped: signalling the // now-empty group id returns EPERM, not ESRCH. Throwing here loses the // shard's real outcome (a timeout gets recorded as a failure) and, from // the timeout timer, leaves the shard promise unsettled — a hang, which // is the exact failure class this runner exists to kill. Fall back to the // direct pid so a genuinely-live child is still signalled. try { child.kill(signal); } catch { // Best-effort reap: nothing actionable is left if this fails too. } } } export async function runPaidShard( files: string[], shardNumber: number, totalShards: number, options: RunShardsOptions = {}, ): Promise { if (files.length === 0) throw new Error('Cannot run an empty paid-test shard.'); const rootDir = options.rootDir ?? ROOT; const timeoutMs = options.timeoutMs ?? DEFAULT_SHARD_TIMEOUT_MS; const streamLive = (options.jobs ?? DEFAULT_JOBS) === 1; const log = options.log ?? ((line: string) => console.log(line)); const label = `[test:paid] shard ${shardNumber}/${totalShards}`; const { command, args } = options.commandFor ? options.commandFor(files) : { command: process.execPath, args: buildPaidShardArgs(exactTestFileSelectors(files, rootDir), timeoutMs), }; const env = { ...(options.env ?? process.env) }; if (options.evalDirBase) { env.GSTACK_EVAL_DIR = path.join(options.evalDirBase, 'shards', shardSlug(files)); } const startedAt = Date.now(); log(`${label} START ${files.join(' ')} (timeout ${Math.round(timeoutMs / 1000)}s)`); const child = spawn(command, args, { cwd: rootDir, env, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', windowsHide: true, }); const groupPid = child.pid ?? null; // Group-kill on parent SIGINT/SIGTERM too, not just on timeout. const forwarding = installChildSignalForwarding({ kill: (signal?: NodeJS.Signals | number) => { killProcessGroup(child, (signal as NodeJS.Signals) ?? 'SIGTERM'); return true; }, }); const classifier = new BunTestOutputClassifier(); const buffered: Buffer[] = []; const sink = (destination: NodeJS.WriteStream): NodeJS.WriteStream => (streamLive ? destination : ({ write: (chunk: Buffer | string) => buffered.push(Buffer.from(chunk)) } as unknown as NodeJS.WriteStream)); let timedOut = false; const killTimer = setTimeout(() => { timedOut = true; killProcessGroup(child, 'SIGKILL'); }, timeoutMs); let exitCode: number | null = null; try { const streams: Array> = []; if (child.stdout) streams.push(forwardAndClassify(child.stdout, sink(process.stdout), classifier)); if (child.stderr) streams.push(forwardAndClassify(child.stderr, sink(process.stderr), classifier)); exitCode = await new Promise((resolve, reject) => { child.once('error', reject); child.once('close', (code) => resolve(code)); }); await Promise.all(streams); } finally { clearTimeout(killTimer); forwarding.dispose(); // Reap survivors of this shard even on the clean path. killProcessGroup(child, 'SIGKILL'); } const summary = classifier.end(); if (!streamLive && buffered.length > 0) process.stdout.write(Buffer.concat(buffered)); // Pass expectedFiles so a shard whose bun child ran fewer files than planned // (or zero, all self-skipped) with exit 0 is NOT recorded 'passed' — the // invisible-non-execution class this runner exists to kill. bun prints // "Ran N tests across M files" with M = selected files even when every test // self-skips, so terminalFileCounts must include files.length. Only enforced // on the real bun path: an injected commandFor (tests) isn't bun and emits no // terminal summary, so there's no file count to check against. const expectedFiles = options.commandFor ? undefined : files.length; const status: ShardStatus = timedOut ? 'timed-out' : strictTestExitCode(exitCode ?? 1, summary, expectedFiles) === 0 ? 'passed' : 'failed'; const elapsedMs = Date.now() - startedAt; log(`${label} ${status.toUpperCase()} in ${Math.round(elapsedMs / 1000)}s (exit ${exitCode ?? 'signal'})`); return { shard: shardNumber, files, status, exitCode, elapsedMs, groupPid }; } export interface RunSummary { total: number; executed: number; passed: number; failed: number; timedOut: number; neverStarted: number; outcomes: ShardOutcome[]; } export function summarize(outcomes: ShardOutcome[]): RunSummary { const count = (status: ShardStatus) => outcomes.filter((o) => o.status === status).length; return { total: outcomes.length, executed: outcomes.length - count('never-started'), passed: count('passed'), failed: count('failed'), timedOut: count('timed-out'), neverStarted: count('never-started'), outcomes, }; } /** Run every shard in its own process. A timeout or failure never aborts the run. */ export async function runPaidShards( shards: string[][], options: RunShardsOptions = {}, ): Promise { const jobs = Math.max(1, options.jobs ?? DEFAULT_JOBS); const outcomes: ShardOutcome[] = shards.map((files, index) => ({ shard: index + 1, files, status: 'never-started', exitCode: null, elapsedMs: 0, groupPid: null, })); let next = 0; const worker = async (): Promise => { while (true) { const index = next; next += 1; if (index >= shards.length) return; try { outcomes[index] = await runPaidShard(shards[index], index + 1, shards.length, { ...options, jobs }); } catch (error) { outcomes[index] = { shard: index + 1, files: shards[index], status: 'failed', exitCode: null, elapsedMs: 0, groupPid: null, }; console.error(`[test:paid] shard ${index + 1} could not run: ${error instanceof Error ? error.message : String(error)}`); } } }; await Promise.all(Array.from({ length: Math.min(jobs, shards.length) }, worker)); return summarize(outcomes); } export function formatSummary(summary: RunSummary): string[] { const lines = [ '', `[test:paid] ${summary.executed}/${summary.total} shards executed — ` + `${summary.passed} passed, ${summary.failed} failed, ` + `${summary.timedOut} timed out, ${summary.neverStarted} never started`, ]; for (const outcome of summary.outcomes) { lines.push( ` ${outcome.status.padEnd(13)} ${String(Math.round(outcome.elapsedMs / 1000)).padStart(5)}s ` + outcome.files.join(' '), ); } return lines; } type CliOptions = { tier: PaidTier; listOnly: boolean; timeoutMs: number; jobs: number; maxFilesPerShard: number; }; function parsePositiveInt(value: string | undefined, flag: string): number { const parsed = Number.parseInt(value ?? '', 10); if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${flag} needs a positive integer. Received: ${value}`); return parsed; } function validatedTier(value: string | undefined, source: string): PaidTier { if (value === undefined || value === '') return DEFAULT_TIER; // A typo'd EVALS_TIER (e.g. 'e2e', the tier string eval-store uses) would // otherwise cast through unchecked, match nothing in the runtime E2E_TIERS // filter, self-skip every test, and exit 0 with all shards 'passed' — the // exact 0%-execution-looks-like-a-pass class this runner exists to kill. if (value !== 'gate' && value !== 'periodic') { throw new Error(`${source} must be gate or periodic. Received: ${value}`); } return value; } export function parseCliOptions(argv: string[], env: NodeJS.ProcessEnv = process.env): CliOptions { const options: CliOptions = { tier: validatedTier(env.EVALS_TIER, 'EVALS_TIER'), listOnly: false, timeoutMs: env.EVALS_SHARD_TIMEOUT_MS ? parsePositiveInt(env.EVALS_SHARD_TIMEOUT_MS, 'EVALS_SHARD_TIMEOUT_MS') : DEFAULT_SHARD_TIMEOUT_MS, jobs: env.EVALS_CONCURRENCY ? parsePositiveInt(env.EVALS_CONCURRENCY, 'EVALS_CONCURRENCY') : DEFAULT_JOBS, maxFilesPerShard: DEFAULT_MAX_FILES_PER_SHARD, }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--list') { options.listOnly = true; continue; } if (arg === '--tier') { const value = argv[index += 1]; if (value !== 'gate' && value !== 'periodic') throw new Error(`--tier must be gate or periodic. Received: ${value}`); options.tier = value; continue; } if (arg === '--timeout') { options.timeoutMs = parsePositiveInt(argv[index += 1], '--timeout') * 1000; continue; } if (arg === '--jobs') { options.jobs = parsePositiveInt(argv[index += 1], '--jobs'); continue; } if (arg === '--files-per-shard') { options.maxFilesPerShard = parsePositiveInt(argv[index += 1], '--files-per-shard'); continue; } throw new Error(`Unknown argument: ${arg}`); } return options; } async function main(): Promise { const options = parseCliOptions(process.argv.slice(2)); const discovered = collectPaidTestFiles(); if (discovered.length === 0) throw new Error('No paid test files were discovered.'); const { selected, excluded } = selectPaidTestFiles(discovered, options.tier); const shards = planPaidShards(selected, { maxFilesPerShard: options.maxFilesPerShard }); console.log( `[test:paid] tier=${options.tier}: ${selected.length}/${discovered.length} files, ` + `${shards.length} shards, jobs=${options.jobs}, timeout=${Math.round(options.timeoutMs / 1000)}s`, ); if (options.listOnly) { for (let index = 0; index < shards.length; index += 1) { console.log(` shard ${index + 1}/${shards.length}: ${shards[index].join(' ')}`); } if (excluded.length > 0) { console.log(`\nExcluded (${excluded.length}):`); for (const { file, reason } of excluded) console.log(` - ${file} [${reason}]`); } return 0; } const summary = await runPaidShards(shards, { // Tier reaches the children only via EVALS_TIER below; the runtime // E2E_TIERS filter inside each child is the real selection mechanism. timeoutMs: options.timeoutMs, jobs: options.jobs, env: { ...process.env, EVALS: '1', EVALS_TIER: options.tier }, evalDirBase: process.env.GSTACK_EVAL_DIR || getProjectEvalDir(), }); for (const line of formatSummary(summary)) console.log(line); return summary.passed === summary.total ? 0 : 1; } if (import.meta.main) { try { process.exitCode = await main(); } catch (error) { console.error(`[test:paid] ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; } }