Add dev/run-all runner and self-contained test setup

- Every `dev/*.test.mjs` now imports the Phaser stub loader, so plain
  `node dev/<name>.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.
This commit is contained in:
Brian Fertig 2026-09-14 10:35:55 -06:00
parent 54d2886c91
commit 30b62fc57f
13 changed files with 137 additions and 17 deletions

View File

@ -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

View File

@ -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));

View File

@ -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: {

View File

@ -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));

View File

@ -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 = {};

View File

@ -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 = {};

View File

@ -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);

View File

@ -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) => {

View File

@ -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');

110
dev/run-all.mjs Normal file
View File

@ -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/<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
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);

View File

@ -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));

View File

@ -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):

View File

@ -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'));