scripts/run-test-shard.mjs
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { readdirSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const DEFAULT_TIMEOUT_MS = 90_000;
const TEST_SETUP = pathToFileURL(resolve(process.cwd(), 'test/setup-env.mjs')).href;
export function parseArgs(argv) {
const positional = [];
let timeoutMs = Number(process.env.TEST_FILE_TIMEOUT_MS || DEFAULT_TIMEOUT_MS);
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--timeout-ms') {
timeoutMs = Number(argv[++i]);
continue;
}
if (arg?.startsWith('--timeout-ms=')) {
timeoutMs = Number(arg.slice('--timeout-ms='.length));
continue;
}
positional.push(arg);
}
const shardIndex = Number(positional[0] ?? process.env.TEST_SHARD_INDEX ?? 0);
const shardTotal = Number(positional[1] ?? process.env.TEST_SHARD_TOTAL ?? 1);
if (!Number.isInteger(shardIndex) || shardIndex < 0) {
throw new Error(`Invalid shard index: ${positional[0] ?? process.env.TEST_SHARD_INDEX ?? ''}`);
}
if (!Number.isInteger(shardTotal) || shardTotal < 1) {
throw new Error(`Invalid shard total: ${positional[1] ?? process.env.TEST_SHARD_TOTAL ?? ''}`);
}
if (shardIndex >= shardTotal) {
throw new Error(`Shard index ${shardIndex} must be smaller than shard total ${shardTotal}`);
}
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
throw new Error(`Invalid per-file timeout: ${timeoutMs}`);
}
return { shardIndex, shardTotal, timeoutMs };
}
export function listTopLevelTestFiles(root = process.cwd()) {
// The historical export name is retained; discovery includes nested test files.
const files = [];
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
// `test/_research/` is deliberately outside the runnable set, the same way the
// secret scanner already skips it: those files are captured-evidence notebooks,
// and this one's expected token counts are stale relative to today's handler.
// Making it run again means re-baselining it, not discovering it by accident.
if (entry.isDirectory() && entry.name === '_research') continue;
if (entry.isDirectory()) walk(path);
else if (entry.isFile() && entry.name.endsWith('.test.js')) {
files.push(path.slice(root.length + 1).replace(/\\/g, '/'));
}
}
};
root = resolve(root);
walk(join(root, 'test'));
return files.sort((a, b) => a.localeCompare(b));
}
export function selectShard(files, shardIndex, shardTotal) {
return files.filter((_, i) => i % shardTotal === shardIndex);
}
export function parseFileSummary(output) {
const counts = {};
const stripped = output.replace(/\x1b\[[0-9;]*m/g, '');
for (const match of stripped.matchAll(
/^(?:#|ℹ) (tests|pass|fail|skipped|cancelled|todo) (\d+)\r?$/gm,
)) {
if (Object.hasOwn(counts, match[1])) throw new Error(`Duplicate summary: ${match[1]}`);
counts[match[1]] = Number(match[2]);
}
for (const field of ['tests', 'pass', 'fail', 'skipped', 'cancelled', 'todo']) {
if (!Number.isSafeInteger(counts[field])) throw new Error(`Missing summary: ${field}`);
}
if (counts.tests !== counts.pass + counts.fail + counts.skipped + counts.cancelled + counts.todo) {
throw new Error('Incomplete test summary');
}
// A file whose whole suite is skipped at the describe level reports zero tests —
// node emits the suite with its skip reason and no test entries at all. That is a
// measured skip, not a truncated run, so it needs the suite line as its witness:
// `tests 0` with no suite at all is still an incomplete summary.
if (counts.tests === 0 && !/(?:^|\n)(?:#|\u2139) suites [1-9]/.test(stripped)) {
throw new Error('Incomplete test summary');
}
return counts;
}
function runOne(file, timeoutMs) {
return new Promise(resolveRun => {
const env = { ...process.env };
delete env.NODE_TEST_CONTEXT;
const child = spawn(process.execPath, [
// Do not force exit: a timeout is a named failure, not a truncated green run.
'--import', TEST_SETUP, '--test', file,
], { cwd: process.cwd(), env, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let spawnError = null;
const prefix = `[${file}] `;
for (const [stream, destination] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
// Decode and prefix complete lines; arbitrary pipe chunk boundaries must not
// split the reporter's count fields or a multibyte character.
stream.setEncoding('utf8');
let pending = '';
stream.on('data', chunk => {
if (stream === child.stdout) stdout = (stdout + chunk).slice(-64 * 1024);
pending += chunk;
let newline;
while ((newline = pending.indexOf('\n')) !== -1) {
destination.write(prefix + pending.slice(0, newline + 1));
pending = pending.slice(newline + 1);
}
});
stream.on('end', () => { if (pending) destination.write(prefix + pending + '\n'); });
}
child.on('error', error => { spawnError = error.message; });
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 5_000).unref?.();
}, timeoutMs);
child.on('close', code => {
clearTimeout(timer);
let counts = null;
let summaryError = null;
try { counts = parseFileSummary(stdout); }
catch (error) { summaryError = error.message; }
resolveRun({ file, code, timedOut, counts, summaryError, spawnError,
ok: !timedOut && !spawnError && !summaryError && code === 0 && counts.fail === 0 });
});
});
}
export async function runShard({ shardIndex, shardTotal, timeoutMs, root = process.cwd() }) {
const files = listTopLevelTestFiles(root);
const selected = selectShard(files, shardIndex, shardTotal);
if (!selected.length) throw new Error('Test shard selected zero files');
console.log(`Running test shard ${shardIndex + 1}/${shardTotal}: ${selected.length}/${files.length} files`);
for (const file of selected) console.log(`- ${file}`);
const totals = { files: selected.length, measuredFiles: 0, tests: 0, pass: 0, fail: 0, skipped: 0, cancelled: 0, todo: 0 };
const failures = [];
for (const file of selected) {
const result = await runOne(file, timeoutMs);
if (result.counts) {
totals.measuredFiles++;
for (const key of ['tests', 'pass', 'fail', 'skipped', 'cancelled', 'todo']) totals[key] += result.counts[key];
}
if (!result.ok) failures.push(result);
}
console.log(`TEST_SHARD_SUMMARY ${JSON.stringify(totals)}`);
console.log(`Suite: ${totals.pass} pass / ${totals.fail} fail / ${totals.skipped} skip (${totals.measuredFiles}/${totals.files} files measured)`);
if (failures.length) {
console.error(`Test shard ${shardIndex + 1}/${shardTotal} failed:`);
for (const failure of failures) {
const suffix = failure.timedOut ? `timed out after ${timeoutMs}ms`
: failure.spawnError || failure.summaryError || `exit ${failure.code}`;
console.error(`- ${failure.file}: ${suffix}`);
}
return 1;
}
return 0;
}
if (resolve(process.argv[1] || '') === resolve(fileURLToPath(import.meta.url))) {
try {
const opts = parseArgs(process.argv.slice(2));
process.exitCode = await runShard(opts);
} catch (err) {
console.error(err?.message || err);
process.exitCode = 2;
}
}