#!/usr/bin/env node import { spawn, spawnSync } from 'node:child_process'; import { closeSync, existsSync, mkdtempSync, openSync, readdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createRegressionPlan, REGRESSION_AREAS } from './lib/regression-plan.mjs'; import { digest, sourceInputs } from './lib/verification-inputs.mjs'; import { evidenceRefs, offlineEvent, readRun, updateRun } from './lib/verification-record.mjs'; import { offlineStepLabel } from './lib/verification-offline.mjs'; import { assertNodeVersion } from './lib/node-version.mjs'; import { waitForHostChecks } from './lib/verification-host-wait.mjs'; import { lockfileDrift, staleDependenciesMessage } from './lib/installed-dependencies.mjs'; import { failedOnlyOnRetry, readRunResults, RUN_RESULT_FILE_ENV } from './lib/test-run-results.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); export function parseRegressionArgs(argv) { const options = { mode: 'changed', areas: [], planOnly: false, base: undefined, record: undefined, timeoutMs: 1_200_000, waitMs: 0 }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--plan') { options.planOnly = true; continue; } if (!['--mode', '--area', '--base', '--record', '--timeout-ms', '--wait-ms'].includes(arg)) throw new Error(`Unknown argument: ${arg}`); const value = argv[++index]; if (!value || value.startsWith('-')) throw new Error(`${arg} requires a value`); if (arg === '--mode') options.mode = value; if (arg === '--area') options.areas.push(value); if (arg === '--base') options.base = value; if (arg === '--record') options.record = value; if (arg === '--timeout-ms') options.timeoutMs = Number(value); if (arg === '--wait-ms') options.waitMs = Number(value); } if (!Number.isSafeInteger(options.timeoutMs) || options.timeoutMs < 1_000 || options.timeoutMs > 3_600_000) throw new Error('--timeout-ms must be 1000..3600000.'); if (!Number.isSafeInteger(options.waitMs) || options.waitMs < 0 || options.waitMs > 7_200_000) throw new Error('--wait-ms must be 0..7200000.'); return options; } export function regressionEnvironment(env = process.env) { const clean = { ...env }; delete clean.CHICKPEA_CHECK_OWNER; // Coordination tokens are not verification inputs. // Keep parent-process Node injection from changing tests and their nested tools. delete clean.NODE_OPTIONS; delete clean.NODE_PATH; // Do not let a QA deploy/local lane's selectors stamp offline build artifacts. for (const key of Object.keys(clean)) { if (/^(?:CHICKPEA_DEPLOY_|CHICKPEA_LOCAL_|CHICKPEA_ENV_|WRANGLER_CI_|WORKERS_CI|CLOUDFLARE_ENV$)/.test(key)) delete clean[key]; } return { ...clean, DO_NOT_TRACK: '1', TAG_REQUIRE_LOOPBACK: '1', PATH: [path.dirname(process.execPath), clean.PATH].filter(Boolean).join(path.delimiter), FLUE_NODE_BIN: process.execPath, TAG_DB_PATH: ':memory:', SLACK_STATE_DB_PATH: ':memory:', CHICKPEA_AUTH_DB_PATH: ':memory:', }; } export function assertReleaseEnvironment(root) { const privateFiles = readdirSync(root).filter((file) => /^(?:\.env|\.dev\.vars)(?:$|\.)/.test(file) && !file.endsWith('.example')); if (privateFiles.length) throw new Error(`Release checks require a checkout without private environment files: ${privateFiles.join(', ')}. Use an isolated clean checkout; do not delete operator configuration.`); } function git(args, root = ROOT) { const result = spawnSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 }); if (result.status !== 0) throw new Error(`git ${args[0]} failed; supply a valid --base or explicit --area`); return result.stdout.trimEnd(); } /** Branch changes plus staged, unstaged, and untracked source, and the commit they were diffed against. */ export function changedFiles(base, root = ROOT) { // A passed base is resolved to a commit first, never interpreted as a shell command. let revision; if (base) revision = git(['rev-parse', '--verify', `${base}^{commit}`], root); else { const reference = spawnSync('git', ['merge-base', 'HEAD', 'origin/main'], { cwd: root, encoding: 'utf8' }); if (reference.status !== 0) throw new Error('No origin/main comparison available; supply --base or --area'); revision = reference.stdout.trim(); } return { revision, files: [...new Set([ ...git(['diff', '--name-only', '-z', '--no-renames', revision, '--'], root).split('\0'), ...git(['ls-files', '--others', '--exclude-standard', '-z'], root).split('\0'), ].filter(Boolean))] }; } /** * Focused test files run through the root suite's runner: the same per-test * timeout, and a file whose tests do not all report fails instead of passing. * A file that passes only when rerun alone fails the step too: this command * never reruns to green, and a flaky changed test must not pass quietly. The * full suite (`npm test`) keeps the runner's one rerun over hundreds of files; * every step records the files its runners reran (stepOutcome). */ export const testStepArgs = (files) => [path.join(ROOT, 'scripts', 'run-tests.mjs'), '--fail-on-retry', ...files]; /** * A finished step's status and receipt fields. `runs` holds the passes its * test runners recorded (scripts/lib/test-run-results.mjs), or is undefined * when the step ran none. Such a step's receipt lists every file a runner * reran alone, pass or fail, so a retry is never only a line in a log. */ export function stepOutcome({ exitCode, error, stable, runs }) { const status = stable ? exitCode ?? 1 : 1; const category = !stable ? 'inputs_changed' : error ? 'infrastructure' : status === 0 ? null : runs && failedOnlyOnRetry(runs) ? 'flaky' : 'unknown'; return { status, category, ...(runs ? { retries: runs.flatMap((run) => run.retries) } : {}) }; } export const isHygieneStep = (step) => step.kind === 'npm' && step.script === 'verify:hygiene'; /** * Hygiene and typecheck start no workerd, server, port or test database, so * the leading run of them executes before the host reservation is taken. * Everything from the first build, test file, proof or smoke onward runs * under it (host-checks.md). */ export const HOST_FREE_SCRIPTS = Object.freeze(['verify:hygiene', 'typecheck']); export const needsHostReservation = (step) => !(step.kind === 'npm' && HOST_FREE_SCRIPTS.includes(step.script)); export function splitAtHostReservation(steps) { const first = steps.findIndex(needsHostReservation); return first === -1 ? { hostFree: steps, hostBound: [] } : { hostFree: steps.slice(0, first), hostBound: steps.slice(first) }; } /** * Runs steps in plan order. Consecutive steps that share a `group` run * together, and all of them finish: a killed sibling would strand its child * processes and the host reservation. The run stops after the first step or * group with a failure and never reruns to green. `run` resolves to a step's * exit status, or to `{ status, ...details }` whose details (such as the test * runner's retries) join that step's result. */ export async function runRegressionSteps(steps, run) { const results = []; for (let index = 0; index < steps.length;) { let end = index + 1; while (steps[index].group && steps[end]?.group === steps[index].group) end += 1; const batch = steps.slice(index, end); const settled = await Promise.all(batch.map(async (step) => { const started = Date.now(); const outcome = await run(step, batch.length > 1); const { status, ...details } = typeof outcome === 'number' ? { status: outcome } : outcome; return { ...step, status, ...details, durationMs: Date.now() - started }; })); results.push(...settled); if (settled.some(({ status }) => status !== 0)) break; index = end; } return results; } /** * Runs the plan's host-free prefix, then takes the host reservation through * `acquire` before the first build, test file, proof or smoke. A failed prefix * never reserves. */ export async function runWithHostReservation(steps, run, acquire) { const { hostFree, hostBound } = splitAtHostReservation(steps); const results = await runRegressionSteps(hostFree, run); if (hostBound.length && results.every(({ status }) => status === 0)) { await acquire(); results.push(...await runRegressionSteps(hostBound, run)); } return results; } /** * Releases the reservation after the run. An interrupted step may have left * children running, so its reservation stays for inspection. A run whose * host-free prefix stopped never took one and has nothing to keep or report. */ export function settleHostLease(lease, interrupted, warn = console.error) { if (!interrupted) lease?.release(); else if (lease) warn(`Interrupted check: host reservation ${lease.file} retained. Inspect the owner and child processes before releasing it.`); } /** * AGENTS.md's merge gate: a train's full regression on its merged tree. It * builds first, so the budgets measure each merge and the tests that read a * build check this one. */ export const MERGE_GATE_COMMAND = 'npm run verify:host -- --wait-ms 3600000 npm run verify:train'; /** * Null when the plan runs the full suite (`fullTests`, including release mode's * export). Otherwise the plan runs only part of it and is not the merge gate. */ export const mergeGateWarning = (plan) => (plan.fullTests ? null : `This plan is NOT the merge gate: it runs only part of the test suite. Before a train of changes above T0 merges, run its full regression on the train's merged tree, each PR merged with --no-ff onto current origin/main: ${MERGE_GATE_COMMAND}`); /** The end summary on stdout, then the merge-gate warning, if any, on stderr. */ export function printRunSummary(plan, { passed, retries, results }, { log = console.log, warn = console.error } = {}) { log(JSON.stringify({ result: passed ? 'pass' : 'fail', retries, results, coverage: plan.coverage, fullTests: plan.fullTests }, null, 2)); const warning = mergeGateWarning(plan); if (warning) warn(warning); } /** Runs one step to completion; piped output is collected into `output`. */ function spawnStep(command, args, options, timeoutMs) { return new Promise((done) => { const child = spawn(command, args, options); const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs); let output = ''; for (const stream of [child.stdout, child.stderr]) stream?.on('data', (chunk) => { output += chunk; }); child.once('error', (error) => { clearTimeout(timer); done({ status: null, signal: null, error, output }); }); child.once('close', (status, signal) => { clearTimeout(timer); done({ status, signal, output }); }); }); } export async function main(argv) { let lease, interrupted = false; try { if (argv.includes('--help')) { console.log(`Usage: npm run verify:regression -- [--mode changed|regression|release] [--area NAME] [--base REF] [--plan] [--record PRIVATE_RUN] [--timeout-ms MS] [--wait-ms MS]\nAreas: ${Object.keys(REGRESSION_AREAS).join(', ')}\n--area may repeat and selects explicit scope; otherwise changed mode includes branch and uncommitted changes.\nEvery plan starts with verify:hygiene, then typecheck when the plan has a separate one; both run before the host reservation is taken. The remaining steps run cheapest first under it.\nA plan with fullTests false runs only part of the test suite and is not the merge gate; the gate is ${MERGE_GATE_COMMAND} on a train's merged tree, each PR merged with --no-ff onto current origin/main.\n--record saves private logs, durations, failures, test files rerun alone and final release receipts.`); return 0; } const options = parseRegressionArgs(argv); const { revision, files } = options.mode === 'changed' && (options.areas.length === 0 || options.base) ? changedFiles(options.base) : { files: [] }; const testFiles = readdirSync(path.join(ROOT, 'tests'), { recursive: true }) .map(String).filter((file) => file.endsWith('.test.ts')) .map((file) => `tests/${file.replaceAll('\\', '/')}`); const plan = createRegressionPlan({ ...options, files, testFiles }); // A plan names the commit it diffed against and every file it classified, // so a caller can confirm it planned the intended range. Chickpea Cloud // parses --plan stdout as one JSON document, so the warning goes to stderr. console.log(JSON.stringify(options.planOnly ? { ...plan, base: revision ?? null, files: [...files].sort() } : plan, null, 2)); const warning = mergeGateWarning(plan); if (warning) console.error(warning); if (options.planOnly) return 0; assertNodeVersion(process.version, { baseline: options.mode === 'release' }); if (options.mode === 'release' && git(['status', '--porcelain']).length > 0) { throw new Error('Release checks require clean committed source; verify:oss-export archives HEAD. Use changed or regression for working changes.'); } if (options.mode === 'release') assertReleaseEnvironment(ROOT); // Hygiene alone needs neither installed dependencies nor loopback; // everything else does. Only steps from the first host-bound one onward // take the host reservation. const expensive = plan.steps.some((step) => !isHygieneStep(step)); if (expensive && !existsSync(path.join(ROOT, 'node_modules', 'tsx'))) throw new Error('Run npm ci first with the repository Node version.'); const drift = expensive ? lockfileDrift(ROOT) : []; if (drift.length) throw new Error(staleDependenciesMessage(drift)); if (options.record) { const run = readRun(options.record); // Validate before executing any checks. if (run.events.some((event) => event.type === 'offline_begin' && !run.events.some((end) => end.type === 'offline_finish' && end.attemptId === event.id))) { throw new Error('An offline attempt is still open. Inspect the owning process and log; record offline_interrupted only after its processes have stopped.'); } } const scratch = mkdtempSync(path.join(tmpdir(), 'chickpea-regression-')); let env = { ...regressionEnvironment(), npm_config_cache: path.join(scratch, 'npm-cache'), WRANGLER_LOG_PATH: path.join(scratch, 'wrangler.log'), }; const acquireHost = async () => { const waitingSource = options.waitMs ? sourceInputs(ROOT) : null; const controller = new AbortController(); const cancel = () => controller.abort(); process.once('SIGINT', cancel); process.once('SIGTERM', cancel); try { lease = await waitForHostChecks({ cwd: ROOT, waitMs: options.waitMs, signal: controller.signal, onWait: (event) => console.error(JSON.stringify(event)) }); } finally { process.removeListener('SIGINT', cancel); process.removeListener('SIGTERM', cancel); } if (waitingSource) { const afterWait = sourceInputs(ROOT); if (afterWait.head !== waitingSource.head || afterWait.tree !== waitingSource.tree) throw new Error('Source changed while waiting for host checks. Rerun the affected plan before executing it.'); } if (options.waitMs) console.error(JSON.stringify({ status: 'host_acquired', waitedMs: lease.waitedMs })); env = { ...env, ...lease.env }; }; if (expensive) { const probe = spawnSync(process.execPath, ['--input-type=module', '-e', "import {createServer} from 'node:net'; const server=createServer(); server.on('error',()=>process.exit(2)); server.listen(0,'127.0.0.1',()=>server.close());", ], { cwd: ROOT, env, timeout: 5_000, stdio: 'ignore' }); if (probe.status !== 0) throw new Error('LOOPBACK_UNAVAILABLE: these checks need local test servers. Run in an execution environment that permits loopback; do not disable or skip the checks.'); } const input = options.record ? sourceInputs(ROOT) : undefined; // Include all effective environment values only as a digest; never store secrets. // Temporary scratch paths are intentionally excluded from the input identity. const fingerprint = options.record ? digest({ source: input.tree, node: process.version, config: regressionEnvironment(), inventory: plan.steps, timeoutMs: options.timeoutMs }) : undefined; const executionFingerprint = options.record ? digest({ node: process.version, config: regressionEnvironment(), timeoutMs: options.timeoutMs }) : undefined; const record = (value) => updateRun(options.record, (run) => offlineEvent(run, value)); const receiptPlan = options.record ? record({ type: 'offline_plan', source: input, node: process.version, mode: options.mode, steps: plan.steps, fingerprint, executionFingerprint, sourceExportCoverage: 1, testFiles: testFiles.filter((file) => /^tests\/(?:[^/]+|usage\/[^/]+)\.test\.ts$/.test(file)) }) : undefined; const nodeBuilt = plan.steps.some((step) => step.kind === 'npm' && step.script === 'flue:build'); let stepCount = 0; const runStep = async (step, concurrent) => { const label = offlineStepLabel(step); const started = Date.now(); const attempt = options.record ? record({ type: 'offline_begin', planId: receiptPlan.id, label, fingerprint, node: process.version, source: input, ownerPid: process.pid }) : undefined; const log = attempt ? path.join(path.dirname(path.resolve(options.record)), `${attempt.id}.log`) : undefined; const fd = log ? openSync(log, 'wx', 0o600) : undefined; console.log(`Running ${label}${log ? `; log ${log}` : ''}`); const scriptArgs = step.kind === 'npm' && step.args?.length ? ['--', ...step.args] : []; const args = step.kind === 'npm' ? [process.env.npm_execpath, 'run', step.script, ...scriptArgs] : step.kind === 'tests' ? testStepArgs(step.files) : [step.file]; // npm supplies its executable path on every platform. Direct node users // can use PATH without enabling a shell for any user input. const directNpm = step.kind === 'npm' && !process.env.npm_execpath; // Each test runner the step starts records the files it reran alone here. const runResults = path.join(scratch, `test-runs-${stepCount += 1}.jsonl`); const stepEnv = { ...env, ...(step.group && nodeBuilt ? { CHICKPEA_NODE_BUILD_READY: '1' } : {}), [RUN_RESULT_FILE_ENV]: runResults }; // Concurrent unrecorded steps would interleave on the terminal; their output is printed when they finish. const piped = fd === undefined && concurrent; const stdio = fd !== undefined ? ['ignore', fd, fd] : piped ? ['ignore', 'pipe', 'pipe'] : 'inherit'; let result; try { result = await spawnStep(directNpm ? 'npm' : process.execPath, directNpm ? ['run', step.script, ...scriptArgs] : args, { cwd: ROOT, env: stepEnv, stdio }, options.timeoutMs); } finally { if (fd !== undefined) closeSync(fd); } if (piped) console.log(`\n--- ${label} (exit ${result.status ?? result.signal}) ---\n${result.output}`); if (result.signal || result.error) interrupted = true; const stable = !options.record || sourceInputs(ROOT).tree === input.tree; const { status, category, retries } = stepOutcome({ exitCode: result.status, error: result.error, stable, runs: readRunResults(runResults) }); if (retries?.length) console.error(`${label}: ${retries.length} test file(s) rerun alone: ${retries.map(({ file, alone }) => `${file} (${alone} alone)`).join(', ')}`); if (attempt) record({ type: 'offline_finish', planId: receiptPlan.id, attemptId: attempt.id, label, fingerprint, node: process.version, result: status === 0 ? 'pass' : 'fail', exitCode: result.status, signal: result.signal, category, ...(retries ? { retries } : {}), durationMs: Date.now() - started, evidence: evidenceRefs([log]) }); return retries ? { status, retries } : status; }; const results = await runWithHostReservation(plan.steps, runStep, acquireHost); const passed = results.length === plan.steps.length && results.every(({ status }) => status === 0) && (!options.record || sourceInputs(ROOT).tree === input.tree); // Every test file a runner reran alone, by step, whether or not the run passed. const retries = results.flatMap((result) => (result.retries ?? []).map((retry) => ({ step: offlineStepLabel(result), ...retry }))); if (receiptPlan) { record({ type: 'offline_summary', planId: receiptPlan.id, result: passed ? 'pass' : 'fail', retries }); if (passed && options.mode === 'release') { const evidence = readRun(options.record).events.filter((event) => event.type === 'offline_finish' && event.planId === receiptPlan.id).flatMap((event) => event.evidence); record({ type: 'checkpoint', result: 'pass', source: input, node: process.version, planId: receiptPlan.id, fingerprint, evidence }); } } printRunSummary(plan, { passed, retries, results }); return passed ? 0 : 1; } catch (error) { console.error(error instanceof Error ? error.message : String(error)); return error.code === 'HOST_CHECKS_TIMEOUT' ? 3 : error.code === 'HOST_CHECKS_CANCELLED' ? 130 : 2; } finally { settleHostLease(lease, interrupted); } } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { process.exitCode = await main(process.argv.slice(2)); }