From 30b62fc57fe93b72449f486e5819708395452550 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 14 Sep 2026 10:35:55 -0600 Subject: [PATCH] Add dev/run-all runner and self-contained test setup - Every `dev/*.test.mjs` now imports the Phaser stub loader, so plain `node dev/.test.mjs` works without `--import`. - New `dev/run-all.mjs` runs the whole suite in isolated Node processes with filtering, concurrency, timeouts, and aggregated failure output. - Update README and adjust dynamic imports/paths for headless test reliability. --- README.md | 1 + dev/actionbar.test.mjs | 3 +- dev/contact.test.mjs | 7 +-- dev/explore.test.mjs | 3 +- dev/mineral-hud.test.mjs | 3 +- dev/mining.test.mjs | 3 +- dev/music.test.mjs | 5 +- dev/quests.test.mjs | 6 +-- dev/research-cue.test.mjs | 5 +- dev/run-all.mjs | 110 ++++++++++++++++++++++++++++++++++++++ dev/shop-ui.test.mjs | 3 +- dev/trade-scout.test.mjs | 2 +- dev/tutorial.test.mjs | 3 +- 13 files changed, 137 insertions(+), 17 deletions(-) create mode 100644 dev/run-all.mjs diff --git a/README.md b/README.md index 670a075..b1d184e 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,7 @@ runtime data — loaders and tests ignore them. ## Dev tools ```sh +node dev/run-all.mjs # the whole suite (dev/*.test.mjs) — every test runs with plain `node` node dev/ship-behavior.test.mjs # runs the real Ship.update() loop in Node node dev/starfield.test.mjs # runs the real Starfield.create() in Node node dev/galaxy.test.mjs # galaxy determinism, composition, lazy vs eager, gradient + report diff --git a/dev/actionbar.test.mjs b/dev/actionbar.test.mjs index 7f7d511..8b855df 100644 --- a/dev/actionbar.test.mjs +++ b/dev/actionbar.test.mjs @@ -1,7 +1,7 @@ /** * ActionBar test (dev tool, run with Node — no browser needed): * - * node --import ./dev/phaser-loader.mjs dev/actionbar.test.mjs + * node dev/actionbar.test.mjs * * The command deck's PER-SLOT lock (js/ui/ActionBar.js → setSlotEnabled), * the "shop is up" state: every slot but SHOP goes standby (dim, inert — @@ -28,6 +28,7 @@ import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import assert from 'node:assert/strict'; +import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/dev/contact.test.mjs b/dev/contact.test.mjs index 20be898..91bd431 100644 --- a/dev/contact.test.mjs +++ b/dev/contact.test.mjs @@ -7,9 +7,10 @@ // `if (touched && ship.state === 'thrust') ship.stop()`) reads those, // - a thrusting ship driven into a planet stops dead ON its rim, // - a coasting (non-thrust) ship is untouched by the rule. -// Run: node --import ./dev/phaser-loader.mjs dev/contact.test.mjs +// Run: node dev/contact.test.mjs import assert from 'node:assert/strict'; import { readdirSync, readFileSync } from 'node:fs'; +import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) const { config } = await import('../js/config/Config.js'); const data = {}; @@ -39,11 +40,11 @@ function makeScene() { add: { existing: (o) => o, image: (x, y, key, frame) => makeImage(x, y, key), - graphics: () => ({ clear() {}, destroy() {} }), + graphics: () => ({ clear() {}, destroy() {}, setDepth() { return this; }, setPosition(x, y) { this.x = x; this.y = y; return this; } }), circle: (x, y, r, fill, fa) => makeImage(x, y, 'circle'), text: (x, y, s, st) => makeImage(x, y, 'text'), }, - make: { graphics: () => ({ clear() {}, destroy() {} }) }, + make: { graphics: () => ({ clear() {}, destroy() {}, setDepth() { return this; }, setPosition(x, y) { this.x = x; this.y = y; return this; } }) }, tweens: { add: (o) => { if (o && typeof o.onComplete === 'function') o.onComplete(); return { destroy() {} }; }, killTweensOf: () => {} }, textures: { exists: () => true }, physics: { diff --git a/dev/explore.test.mjs b/dev/explore.test.mjs index 605d8cd..d26bb1a 100644 --- a/dev/explore.test.mjs +++ b/dev/explore.test.mjs @@ -1,7 +1,7 @@ /** * EXPLORE bar test (dev tool, run with Node — no browser needed): * - * node --import ./dev/phaser-loader.mjs dev/explore.test.mjs + * node dev/explore.test.mjs * * The surface deck's Explore slot + the full-width EXPLORE drawer * (js/ui/ExploreBar.js item logic + js/ui/MenuSubBar.js's span/items @@ -34,6 +34,7 @@ import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import assert from 'node:assert/strict'; +import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/dev/mineral-hud.test.mjs b/dev/mineral-hud.test.mjs index fdb561c..d51baec 100644 --- a/dev/mineral-hud.test.mjs +++ b/dev/mineral-hud.test.mjs @@ -1,7 +1,7 @@ /** * Mineral HUD test (dev tool, run with Node — no browser needed): * - * node --import ./dev/phaser-loader.mjs dev/mineral-hud.test.mjs + * node dev/mineral-hud.test.mjs * * Runs the REAL MineralHud component (js/ui/MineralHud.js — the * upper-right hold readout that replaced the tether stack) against a @@ -22,6 +22,7 @@ */ import { readdirSync, readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; +import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) const { config } = await import('../js/config/Config.js'); const data = {}; diff --git a/dev/mining.test.mjs b/dev/mining.test.mjs index 8fb828a..4cd5f78 100644 --- a/dev/mining.test.mjs +++ b/dev/mining.test.mjs @@ -3,9 +3,10 @@ // the break threshold (1/3 of the ORIGINAL size lost), // halving (> 64 px) and shattering (≤ 64 px, pieces sucked to the hull), // the full-hold stop. -// Run: node --import ./dev/phaser-loader.mjs dev/mining.test.mjs +// Run: node dev/mining.test.mjs import { readdirSync, readFileSync } from 'node:fs'; import assert from 'node:assert/strict'; +import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) const { config } = await import('../js/config/Config.js'); const data = {}; diff --git a/dev/music.test.mjs b/dev/music.test.mjs index 164ff58..024ec2f 100644 --- a/dev/music.test.mjs +++ b/dev/music.test.mjs @@ -179,9 +179,10 @@ const makeScene = (o = {}) => { s.queued.some(([k, u]) => k === 'music_frames_1' && u === MUSIC.frames['1'])); // A frame with no track is silent (nothing queued, no crash). + const noTrack = Math.max(...Object.keys(MUSIC.frames).map(Number)) + 1; // past the last authored frame const s2 = Object.create(SurfaceScene.prototype); - s2.planetFrame = 12; // beyond the sheet — no such frame - s2.musicSpec = 'music.frames.12'; + s2.planetFrame = noTrack; + s2.musicSpec = `music.frames.${noTrack}`; s2.queued = []; s2.load = { audio: (k, u) => s2.queued.push([k, u]), video: () => {} }; SurfaceScene.prototype.preload.call(s2); diff --git a/dev/quests.test.mjs b/dev/quests.test.mjs index c6ab111..3d9ce7e 100644 --- a/dev/quests.test.mjs +++ b/dev/quests.test.mjs @@ -39,7 +39,7 @@ * surface: right of EXPLORE — the surface deck's SHIP slot became * EXPLORE, the full-width service drawer). */ -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join } from 'node:path'; import fs from 'node:fs'; @@ -54,10 +54,10 @@ const actionbar = read('actionbar.json'); const ship = read('ship.json'); // The real code (config singleton → the quest helpers + the ledger). -const { config } = await import(join(root, 'js/config/Config.js')); +const { config } = await import(pathToFileURL(join(root, 'js/config/Config.js')).href); config.init({ quests, ship }); const { QuestState, questCheckKey, questDefs, questDef, starterQuestIds } = await import( - join(root, 'js/quests/QuestState.js') + pathToFileURL(join(root, 'js/quests/QuestState.js')).href ); let failures = 0; const check = (label, cond) => { diff --git a/dev/research-cue.test.mjs b/dev/research-cue.test.mjs index 29a241f..bdc72fa 100644 --- a/dev/research-cue.test.mjs +++ b/dev/research-cue.test.mjs @@ -22,6 +22,7 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import fs from 'node:fs'; +import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) const __dirname = dirname(fileURLToPath(import.meta.url)); const root = join(__dirname, '..'); @@ -59,8 +60,8 @@ check('research: the tab shimmer is enabled + yellow (deck-only face → faceMs // ---------------------------------------------------------------------- // The pure cycle model (js/ui/ShimmerCue.js) // ---------------------------------------------------------------------- -import { shimmerPhase, drawShimmer } from '../js/ui/ShimmerCue.js'; -import { config } from '../js/config/Config.js'; +const { shimmerPhase, drawShimmer } = await import('../js/ui/ShimmerCue.js'); +const { config } = await import('../js/config/Config.js'); config.init({ research, gates: read('gates.json'), exploration: read('research/exploration.json') }); const { categories, loadCategory, isAvailable, hasAvailable } = await import('../js/research/ResearchModel.js'); diff --git a/dev/run-all.mjs b/dev/run-all.mjs new file mode 100644 index 0000000..c46a0f7 --- /dev/null +++ b/dev/run-all.mjs @@ -0,0 +1,110 @@ +/** + * 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); diff --git a/dev/shop-ui.test.mjs b/dev/shop-ui.test.mjs index ab74cc9..a40a03f 100644 --- a/dev/shop-ui.test.mjs +++ b/dev/shop-ui.test.mjs @@ -1,7 +1,7 @@ /** * Shop UI test (dev tool, run with Node — no browser needed): * - * node --import ./dev/phaser-loader.mjs dev/shop-ui.test.mjs + * node dev/shop-ui.test.mjs * * The SHOP SCREEN's economic core (js/ui/shop/ShopView.js + TradeGrid.js), * driven headlessly against the real config + a real galaxy + a real @@ -42,6 +42,7 @@ import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import assert from 'node:assert/strict'; +import './phaser-loader.mjs'; // ../vendor/phaser.js -> ./phaser-stub.mjs (Node only) const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/dev/trade-scout.test.mjs b/dev/trade-scout.test.mjs index 827590b..7224a6b 100644 --- a/dev/trade-scout.test.mjs +++ b/dev/trade-scout.test.mjs @@ -1,7 +1,7 @@ /** * TradeScout test (dev tool — run headless with the Phaser loader): * - * node --import ./dev/phaser-loader.mjs dev/trade-scout.test.mjs + * node dev/trade-scout.test.mjs * * The PURE strategy layer behind the shop's goods inspector (tap a BUY row's * box → the cargo grid is swapped for the row's strategy report): diff --git a/dev/tutorial.test.mjs b/dev/tutorial.test.mjs index 778ba9c..54f4d8d 100644 --- a/dev/tutorial.test.mjs +++ b/dev/tutorial.test.mjs @@ -21,10 +21,11 @@ */ import fs from 'node:fs'; import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import assert from 'node:assert/strict'; import { tutorialTarget, WELCOME_COMMS, UPGRADE_COMM } from '../js/quests/TutorialGuide.js'; -const dataDir = join(dirname(new URL(import.meta.url).pathname), '..', 'data'); +const dataDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'data'); const comms = JSON.parse(fs.readFileSync(join(dataDir, 'comms.json'), 'utf8')); const quests = JSON.parse(fs.readFileSync(join(dataDir, 'quests.json'), 'utf8'));