← 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); });