← back to Wild Orbs Opus
test/smoke.mjs
307 lines
/**
* Wild Orbs — headless smoke + regression test.
*
* Runs the real game in Chromium and:
* 1. loads it and asserts the start screen / legend built
* 2. plays it with synthetic input
* 3. detonates EVERY orb type (all 13 wild behaviours) and lets the
* resulting entities live out their full lifetimes
* 4. exercises pulse bomb, hyperspace, chain cascades, wave clear,
* ship death, respawn, game over, pause and restart
* 5. fails on ANY console error or uncaught page exception
*
* Usage: node test/smoke.mjs [--headed] [--shots]
*/
import { chromium } from 'playwright';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const GAME = pathToFileURL(path.join(ROOT, 'index.html')).href;
const SHOTS = path.join(ROOT, 'test', 'shots');
const headed = process.argv.includes('--headed');
const wantShots = process.argv.includes('--shots');
if (wantShots) fs.mkdirSync(SHOTS, { recursive: true });
const errors = [];
const results = [];
let failed = 0;
function check(name, ok, detail) {
results.push({ name, ok, detail });
if (!ok) failed++;
console.log(`${ok ? ' ok ' : ' FAIL '} ${name}${detail ? ' — ' + detail : ''}`);
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
/** Poll a page predicate until true or the deadline passes. Timing-based
* assertions were flaky whenever the host machine was busy; polling makes the
* suite measure the game rather than the load average. */
async function waitFor(page, fn, timeout = 12000, arg) {
const t0 = Date.now();
for (;;) {
if (await page.evaluate(fn, arg)) return true;
if (Date.now() - t0 > timeout) return false;
await sleep(120);
}
}
(async () => {
const browser = await chromium.launch({ headless: !headed });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
page.on('pageerror', e => errors.push('pageerror: ' + (e && e.message ? e.message : String(e))));
await page.goto(GAME);
await page.waitForTimeout(600);
/* ---------- 1. boot ---------- */
check('page loaded without errors', errors.length === 0, errors[0]);
check('canvas sized', await page.evaluate(() => cv.width > 0 && cv.height > 0));
const legendCount = await page.evaluate(() => document.querySelectorAll('#orbLegend .orbrow').length);
check('legend lists every orb type', legendCount === 13, `${legendCount} rows`);
check('attract mode is animating', await page.evaluate(async () => {
const a = orbs.length; await new Promise(r => setTimeout(r, 400));
return orbs.length >= a && G.time > 10;
}));
if (wantShots) await page.screenshot({ path: path.join(SHOTS, '01-start.png') });
/* ---------- 2. start + play ---------- */
await page.keyboard.press('Enter');
await page.waitForTimeout(300);
check('game started', await page.evaluate(() => G.state === 'play' && orbs.length > 0));
// fly + shoot for a while
await page.keyboard.down('ArrowUp');
await page.keyboard.down('ArrowRight');
await page.keyboard.down('Space');
await page.waitForTimeout(1800);
await page.keyboard.up('ArrowRight');
await page.waitForTimeout(1200);
await page.keyboard.up('ArrowUp');
await page.keyboard.up('Space');
check('bullets were fired', await page.evaluate(() => G.shots > 5), );
check('ship moved', await page.evaluate(() => Math.hypot(ship.vx, ship.vy) > 0.05 || ship.x !== innerWidth / 2));
check('no errors during play', errors.length === 0, errors[0]);
if (wantShots) await page.screenshot({ path: path.join(SHOTS, '02-play.png') });
/* ---------- 3. every wild behaviour ---------- */
const kinds = await page.evaluate(() => ORB_KEYS);
check('13 orb kinds registered', kinds.length === 13, kinds.join(','));
for (const kind of kinds) {
const before = errors.length;
await page.evaluate(k => {
// put the ship somewhere predictable, then detonate one of each tier
ship.x = innerWidth * 0.5; ship.y = innerHeight * 0.5;
ship.inv = 100000; // invincible so the test can't die mid-sweep
for (const tier of [3, 2, 1]) {
const o = makeOrb(innerWidth * 0.3, innerHeight * 0.4, tier, k);
orbs.push(o);
destroyOrb(o);
}
}, kind);
await page.waitForTimeout(220);
check(`orb behaviour: ${kind}`, errors.length === before, errors[before]);
}
// elder (tier-4) variant of each kind
for (const kind of kinds) {
const before = errors.length;
await page.evaluate(k => {
const o = makeOrb(innerWidth * 0.6, innerHeight * 0.6, 4, k);
orbs.push(o); destroyOrb(o);
}, kind);
await page.waitForTimeout(90);
check(`elder behaviour: ${kind}`, errors.length === before, errors[before]);
}
if (wantShots) await page.screenshot({ path: path.join(SHOTS, '03-chaos.png') });
// let every spawned hunter / shard / sentry / well / hole live out its life
await page.evaluate(() => { ship.inv = 100000; });
await page.waitForTimeout(3000);
check('survived the full-bestiary blast', errors.length === 0, errors[0]);
check('entity counts stayed bounded', await page.evaluate(() =>
orbs.length < 200 && parts.length <= 1250 && bullets.length < 200),
await page.evaluate(() => `orbs=${orbs.length} parts=${parts.length}`));
/* ---------- 4. chain cascade stress ---------- */
{
const before = errors.length;
await page.evaluate(() => {
for (let i = 0; i < 24; i++) {
orbs.push(makeOrb(innerWidth * 0.5 + Math.cos(i) * 90, innerHeight * 0.5 + Math.sin(i) * 90, 2, 'chain'));
}
destroyOrb(orbs[orbs.length - 1]);
});
const resolved = await waitFor(page, () => !orbs.some(o => o.chainT >= 0), 8000);
check('24-orb chain cascade resolves', resolved && errors.length === before, errors[before]);
}
/* ---------- 4b. nova chain-reaction bounds ----------
A nova shockwave kills orbs, and a dying nova spawns another shockwave.
That is deliberately recursive, so assert it stays bounded and settles. */
{
const before = errors.length;
const nova = await page.evaluate(async () => {
ship.inv = 1e9;
orbs.length = 0; waves.length = 0;
for (let i = 0; i < 20; i++) orbs.push(makeOrb(innerWidth/2 + Math.cos(i)*70, innerHeight/2 + Math.sin(i)*70, 3, 'nova'));
destroyOrb(orbs[0]);
let peakWaves = 0, peakOrbs = 0, f = 0;
while (f++ < 900) {
peakWaves = Math.max(peakWaves, waves.length);
peakOrbs = Math.max(peakOrbs, orbs.length);
await new Promise(r => requestAnimationFrame(r));
if (waves.length === 0 && f > 60) break;
}
return { peakWaves, peakOrbs, settled: waves.length, frames: f };
});
check('nova chain reaction actually chains', nova.peakWaves > 5, `peak ${nova.peakWaves} shockwaves`);
check('nova chain reaction stays bounded and settles',
nova.settled === 0 && nova.peakWaves < 400 && nova.peakOrbs < 200 && errors.length === before,
`peak ${nova.peakWaves} waves / ${nova.peakOrbs} orbs, settled in ${nova.frames} frames`);
}
/* ---------- 5. player abilities ---------- */
{
const before = errors.length;
// assert on the effect, not the counter — a wave-clear can refund a charge
await page.evaluate(() => { G.pulses = 3; waves.length = 0; });
await page.keyboard.press('Shift');
const pulseFired = await page.evaluate(() => waves.some(w => w.dmg === 3));
check('pulse bomb fires a shockwave', pulseFired);
await page.waitForTimeout(400);
await page.keyboard.press('KeyH');
await page.waitForTimeout(200);
check('hyperspace fires', await page.evaluate(() => G.hyperCool > 0));
for (const p of ['spread', 'rapid', 'shield', 'pulse', 'life']) {
await page.evaluate(k => grantPower(k, innerWidth / 2, innerHeight / 2), p);
}
await page.waitForTimeout(200);
check('powerups grant', await page.evaluate(() => G.spreadT > 0 && G.rapidT > 0 && ship.shield === 1));
await page.keyboard.down('Space'); await page.waitForTimeout(500); await page.keyboard.up('Space');
check('spread fire works', errors.length === before, errors[before]);
}
/* ---------- 6. pause ---------- */
{
await page.keyboard.press('KeyP');
await page.waitForTimeout(300);
check('pause halts simulation', await page.evaluate(async () => {
if (G.state !== 'pause') return false;
const f = G.frames; await new Promise(r => setTimeout(r, 400)); return G.frames === f;
}));
check('pause overlay visible', await page.evaluate(() =>
!document.getElementById('pauseScreen').classList.contains('hidden')));
if (wantShots) await page.screenshot({ path: path.join(SHOTS, '04-pause.png') });
await page.keyboard.press('KeyP');
await page.waitForTimeout(200);
check('resumed', await page.evaluate(() => G.state === 'play'));
}
/* ---------- 7. wave progression ---------- */
{
// spawnGuard must be cleared too: with a countdown already in flight the
// wave-clear detector is suppressed and the next spawn refills the field
// without ever incrementing, so the test would wait forever.
const w0 = await page.evaluate(() => {
G.wave = 4; G.spawnGuard = 0; orbs.length = 0; hunters.length = 0; return G.wave;
});
const advanced = await waitFor(page, () => G.wave > 4 && orbs.length > 0);
const w1 = await page.evaluate(() => G.wave);
check('wave clear advances + respawns', advanced, `wave ${w0} -> ${w1}`);
// wave 5 is an elder wave
await page.evaluate(() => { G.wave = 4; G.spawnGuard = 0; orbs.length = 0; });
check('elder orb spawns on wave 5',
await waitFor(page, () => G.wave === 5 && orbs.some(o => o.elder)));
}
/* ---------- 8. death, respawn, game over ---------- */
{
await page.evaluate(() => { ship.inv = 0; ship.shield = 0; G.lives = 2; killShip(); });
check('respawn after death', await waitFor(page, () => ship.alive && G.lives === 1));
await page.evaluate(() => { ship.inv = 0; ship.shield = 0; G.lives = 1; killShip(); });
check('game over at zero lives', await waitFor(page, () => G.state === 'over'));
check('game over stats populated', await page.evaluate(() =>
document.getElementById('stOrbs').textContent !== '' &&
document.getElementById('stAcc').textContent.endsWith('%')));
if (wantShots) await page.screenshot({ path: path.join(SHOTS, '05-gameover.png') });
}
/* ---------- 9. restart ---------- */
{
await page.keyboard.press('Enter');
check('restart resets run', await waitFor(page, () =>
G.state === 'play' && G.score === 0 && G.wave === 1 && G.lives === 3 &&
hunters.length === 0 && holes.length === 0 && orbs.length > 0));
}
/* ---------- 10. resize resilience ---------- */
{
const before = errors.length;
await page.setViewportSize({ width: 480, height: 900 }); // portrait phone
await page.waitForTimeout(600);
await page.setViewportSize({ width: 1600, height: 700 });
await page.waitForTimeout(600);
check('survives resize / orientation change', errors.length === before, errors[before]);
if (wantShots) await page.screenshot({ path: path.join(SHOTS, '06-resized.png') });
}
/* ---------- 11. sustained perf ---------- */
{
// Absolute fps is meaningless without knowing the environment's ceiling:
// headed Playwright is vsync-capped ~30fps, headless uses a software
// rasteriser. Measure the ceiling, then require the loaded game to hold
// a healthy fraction of it after adaptive scaling has settled.
const ceiling = await page.evaluate(async () => {
let n = 0; const t0 = performance.now();
await new Promise(res => {
const t = () => { n++; (performance.now() - t0 < 1000) ? requestAnimationFrame(t) : res(); };
requestAnimationFrame(t);
});
return n / ((performance.now() - t0) / 1000);
});
await page.evaluate(() => {
for (let i = 0; i < 26; i++) orbs.push(makeOrb(Math.random() * innerWidth, Math.random() * innerHeight, 3));
ship.inv = 1e6;
});
await page.waitForTimeout(3000); // let dynamic resolution settle
const fps = await page.evaluate(async () => {
let n = 0; const t0 = performance.now();
await new Promise(res => {
const t = () => { n++; (performance.now() - t0 < 2000) ? requestAnimationFrame(t) : res(); };
requestAnimationFrame(t);
});
return n / ((performance.now() - t0) / 1000);
});
const scale = await page.evaluate(() => renderScale);
check('holds framerate under load', fps >= Math.min(50, ceiling * 0.55),
`${fps.toFixed(1)} fps vs ${ceiling.toFixed(1)} fps ceiling, renderScale ${scale.toFixed(2)}, 30+ orbs`);
check('adaptive resolution engages when needed', ceiling < 45 || scale <= 1,
`renderScale ${scale.toFixed(2)}`);
}
check('zero console errors for the whole session', errors.length === 0,
errors.slice(0, 3).join(' | '));
await browser.close();
console.log('\n' + '─'.repeat(58));
console.log(`${results.length - failed}/${results.length} checks passed`);
if (errors.length) {
console.log('\nErrors captured:');
errors.slice(0, 12).forEach(e => console.log(' ' + e));
}
process.exit(failed ? 1 : 0);
})().catch(e => { console.error(e); process.exit(1); });