mirror of https://github.com/garrytan/gstack.git
480 lines
18 KiB
TypeScript
480 lines
18 KiB
TypeScript
#!/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 `EVALS_TIER === '<other>'`
|
||
* guard. 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);
|
||
|
||
if (declares(tier)) return { included: true, reason: `declares EVALS_TIER === '${tier}'` };
|
||
if (declares(other)) return { included: false, reason: `declares EVALS_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=<evalDirBase>/shards/<slug>/. */
|
||
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<ShardOutcome> {
|
||
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<Promise<void>> = [];
|
||
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<number | null>((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<RunSummary> {
|
||
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<void> => {
|
||
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<number> {
|
||
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;
|
||
}
|
||
}
|