agent-manager-template / scripts /run-suites.mjs
Agent Manager
Review: report what discovery skipped, wherever it stopped
dcfa89e
Raw
History Blame Contribute Delete
6.79 kB
// Runs a package's test suites: every `*.test.mjs` in `test/`, then every one at
// the package root, in that order, one at a time.
//
// WHY THIS EXISTS. Both package.json files used to carry the suite list by hand
// β€” `node a.test.mjs && node b.test.mjs && …` on one line. Every PR that added
// a test edited that line, so any two such PRs conflicted by construction (six
// times in the last few days), and the natural resolution β€” take one side β€”
// silently drops the other PR's suite from the run. It stays green and nobody
// notices the coverage is gone. Discovery removes the shared line: adding
// `test/foo.test.mjs` is enough to make it run.
//
// SEQUENTIAL, DELIBERATELY. `node --test` would also discover these files, but
// it runs them in PARALLEL by default, and several suites here start a real
// server on a fixed port (migration on 7893, resize on 7895, trace-download on
// 7898) or drive Chromium. Those ports do not collide today, but only because
// whoever added each one picked a free number β€” nothing enforces it, and the
// first suite that copies an existing PORT constant produces a flake that reads
// as a product bug. This fleet has already lost time to exactly that symptom.
// One at a time costs wall-clock and nothing else.
//
// EXIT SEMANTICS match the `&&` chain it replaces: the first failing suite stops
// the run and its exit code is this process's exit code. That holds for both
// kinds of file here β€” the standalone scripts that print their own results and
// call process.exit, and the two `node:test` suites, which node exits non-zero
// for when a test fails (verified, not assumed).
//
// OPTING OUT. A suite that must not run in the default set says so in its own
// header, on a line containing `am-test: manual` plus the reason. It is declared
// where a reader will see it rather than by absence from a list somewhere else,
// and every run prints what it skipped and why β€” on the failure path too, since
// that is the moment someone is actually reading this output.
//
// DISCOVERY IS TWO DEEP, ON PURPOSE: `test/` and the package root, so a
// `test/fixtures/` directory is fixtures rather than a source of surprise runs.
// A `*.test.mjs` anywhere below that is reported at the end of every run instead
// of being ignored β€” a file that looks like a suite and never runs is the exact
// failure this script exists to end, and it does not matter that the cause is a
// subdirectory rather than a hand-edited list.
//
// Usage: node ../scripts/run-suites.mjs [substring …] [--manual]
// a substring filters to matching suites β€” for running one by hand;
// --manual lets that filter reach the suites marked manual.
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const MANUAL = /am-test:\s*manual\s*[β€”:-]?\s*(.*)/;
const HEAD_BYTES = 4096; // the marker belongs in the header, not line 900
const listDir = (dir) => {
let names = [];
try { names = fs.readdirSync(dir); } catch { return []; }
return names
.filter((n) => n.endsWith('.test.mjs'))
.sort((a, b) => a.localeCompare(b, 'en'))
.map((n) => path.join(dir, n))
.filter((p) => fs.statSync(p).isFile());
};
// `test/` first, then the package root: the root files are the older ones, and
// keeping them behind the directory keeps the common case (a new suite in
// test/) at the front of the run.
const found = [...listDir('test'), ...listDir('.')];
const args = process.argv.slice(2);
const wantManual = args.includes('--manual');
const filters = args.filter((a) => !a.startsWith('--'));
const matches = (file) => !filters.length || filters.some((f) => file.includes(f));
const suites = [];
const skipped = [];
for (const file of found) {
let head = '';
try {
const fd = fs.openSync(file, 'r');
const buf = Buffer.alloc(HEAD_BYTES);
head = buf.subarray(0, fs.readSync(fd, buf, 0, HEAD_BYTES, 0)).toString('utf8');
fs.closeSync(fd);
} catch { /* unreadable: let node report it */ }
const manual = head.match(MANUAL);
// Named explicitly with --manual, a manual suite runs: the filter is the
// run-one-by-hand path, and the suites worth running by hand are mostly these.
if (manual && !(wantManual && filters.length && matches(file))) {
if (matches(file)) skipped.push({ file, why: manual[1].trim() });
continue;
}
if (!matches(file)) continue;
suites.push(file);
}
// Anything that looks like a suite but sits below the two scanned depths.
const stray = [];
(function walk(dir, depth) {
let entries = [];
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (['node_modules', '.git', 'dist', 'coverage'].includes(e.name)) continue;
walk(p, depth + 1);
} else if (e.name.endsWith('.test.mjs') && depth > 0 && path.dirname(p) !== 'test') {
stray.push(p);
}
}
}('.', 0));
const plural = (n) => `${n} suite${n === 1 ? '' : 's'}`;
// Printed by BOTH exits. What did not run is most worth saying when something
// failed, and that is exactly when an early `process.exit` used to swallow it.
const report = () => {
for (const { file, why } of skipped) console.log(` skipped ${file} β€” ${why || 'marked manual'}`);
for (const file of stray) {
console.log(` NOT RUN ${file} β€” below \`test/\` and the package root, where discovery looks.`);
console.log(' Move it up, or mark it `am-test: manual` with a reason, or rename it.');
}
};
const pkg = path.basename(process.cwd());
if (!suites.length) {
// Blaming the filter here sends people looking for a typo when the file was
// found and deliberately excluded.
const manualOnly = skipped.length && filters.length;
console.error(manualOnly
? `${pkg}/: ${plural(skipped.length)} matched ${filters.join(', ')}, all marked manual:`
: `no suites found in ${pkg}/${filters.length ? ` matching ${filters.join(', ')}` : ''}`);
report();
if (manualOnly) console.error(`Run one anyway with: npm test -- ${filters.join(' ')} --manual`);
process.exit(1);
}
console.log(`${pkg}: ${plural(suites.length)}\n`);
for (const [i, file] of suites.entries()) {
console.log(`── [${i + 1}/${suites.length}] ${file}`);
const r = spawnSync(process.execPath, [file], { stdio: 'inherit' });
const code = r.status === null ? 1 : r.status;
if (code !== 0) {
console.error(`\n${file} FAILED (${r.signal ? `signal ${r.signal}` : `exit ${code}`})`);
console.error(`${plural(i)} had passed before it; the rest were not started.`);
report();
process.exit(code);
}
console.log('');
}
console.log(`${pkg}: ${plural(suites.length)} passed`);
report();