/** * 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/.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 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 }); }, 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);