orbit/dev/run-all.mjs

121 lines
4.6 KiB
JavaScript

/**
* dev/run-all.mjs — the canonical test runner for the suite in dev/.
*
* node dev/run-all.mjs # every dev/*.test.mjs
* node dev/run-all.mjs galaxy # only tests whose name matches /galaxy/
* node dev/run-all.mjs --jobs 8 # concurrency (default 4)
*
* Each test runs in its OWN node process (the config singleton + the
* Phaser stub are per-process globals — they must not leak between
* tests), with a per-test timeout. A test "passes" when its process
* exits 0 with no crash markers on stdout/stderr.
*
* The suite's contract (see the individual test headers): EVERY test
* runs with plain `node dev/<name>.test.mjs` — tests that load
* phaser-backed modules import dev/phaser-loader.mjs themselves (a
* resolve hook → dev/phaser-stub.mjs), so no `--import` flag is needed
* anywhere. If a test can only run under `--import`, that's a bug in
* the test, not a runner detail.
*/
import { readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { spawn } from 'node:child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const argv = process.argv.slice(2);
const jobsIdx = argv.indexOf('--jobs');
const JOBS = jobsIdx !== -1 ? Math.max(1, Number(argv[jobsIdx + 1]) || 4) : 4;
const TIMEOUT_MS = 120_000; // a test should never take this long
/** Exceptions to the blanket budget — each with WHY it legitimately needs longer.
* A browser test that samples RENDERED PIXELS pays for a real compositor capture,
* and refusing to fake a recharge means waiting one (data/skills.json → cooldown). */
const BUDGETS = {
'skills-bar-browser.test.mjs': 300_000, // seat screenshots + one real 6s recharge
// The skill console drives a real compositor: injected trees, wheel/drag input and
// a presented-frame pixel read for the join lines.
'skills-window-browser.test.mjs': 240_000,
};
const FILTER = argv
.filter((a, i) => a !== '--jobs' && !(jobsIdx !== -1 && i === jobsIdx + 1))
.map((f) => new RegExp(f, 'i'));
const files = readdirSync(__dirname)
.filter((f) => f.endsWith('.test.mjs'))
.filter((f) => FILTER.length === 0 || FILTER.some((re) => re.test(f))) // filters are OR
.sort();
if (files.length === 0) {
console.log(`no tests matched ${FILTER.map((r) => r).join(' ') || '(nothing)'}`);
process.exit(1);
}
function runOne(file) {
return new Promise((resolve) => {
const started = Date.now();
const child = spawn(process.execPath, [join(__dirname, file)], {
cwd: root,
stdio: ['ignore', 'pipe', 'pipe'],
});
let out = '';
child.stdout.on('data', (d) => { out += d; });
child.stderr.on('data', (d) => { out += d; });
const timer = setTimeout(() => {
child.kill('SIGKILL');
resolve({ file, ok: false, ms: Date.now() - started, note: 'TIMEOUT', out });
}, BUDGETS[file] ?? TIMEOUT_MS);
child.on('close', (code) => {
clearTimeout(timer);
const ms = Date.now() - started;
// exit code + the suite's own failure marker (✘ / FAIL) — a test
// that crashes the process exits non-zero, and tests that report
// a failed check (e.g. "✘ FAIL") set their exit code themselves.
const failed = out.includes('✘') || /\bFAIL\b/.test(out);
resolve({ file, ok: code === 0 && !failed, ms, note: code === 0 ? '' : `exit ${code}`, out });
});
child.on('error', (e) => {
clearTimeout(timer);
resolve({ file, ok: false, ms: Date.now() - started, note: String(e), out });
});
});
}
// A small fixed-size pool.
const queue = [...files];
const results = [];
const workers = Array.from({ length: Math.min(JOBS, queue.length) }, async () => {
while (queue.length > 0) {
const file = queue.shift();
if (file === undefined) break;
results.push(await runOne(file));
}
});
await Promise.all(workers);
results.sort((a, b) => a.file.localeCompare(b.file));
let pass = 0;
const failures = [];
for (const r of results) {
if (r.ok) {
pass++;
console.log(`ok ${r.file} (${Math.round(r.ms)} ms)`);
} else {
failures.push(r);
}
}
console.log('---');
if (failures.length === 0) {
console.log(`ALL ${results.length} TESTS PASSED ✔`);
} else {
for (const r of failures) {
console.log(`FAIL ${r.file} (${Math.round(r.ms)} ms) ${r.note}`);
const tail = r.out.split('\n').slice(-14).join('\n');
console.log(tail ? ` ${tail.split('\n').map((l) => ' | ' + l).join('\n')}` : ' | (no output)');
}
console.log(`\n${failures.length}/${results.length} FAILED ✘`);
}
process.exit(failures.length === 0 ? 0 : 1);