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:
parent
54d2886c91
commit
30b62fc57f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue