121 lines
4.6 KiB
JavaScript
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);
|