← back to Wild Orbs Opus

test/browsers.mjs

180 lines

/**
 * Cross-engine + touch verification.
 *
 * The README claims this runs everywhere and supports touch; this proves it.
 * Boots the real game in Chromium (Blink), Firefox (Gecko) and WebKit (Safari's
 * engine), plays it, exercises the touch path on an emulated phone, and fails on
 * any console error or uncaught exception in any engine.
 *
 * Usage: node test/browsers.mjs
 */
import { chromium, firefox, webkit, devices } from 'playwright';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const GAME = pathToFileURL(path.join(__dirname, '..', 'index.html')).href;

let failed = 0;
const check = (name, ok, detail) => {
  if (!ok) failed++;
  console.log(`${ok ? '  ok  ' : ' FAIL '} ${name}${detail ? '  — ' + detail : ''}`);
};
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function waitFor(page, fn, timeout = 10000) {
  const t0 = Date.now();
  for (;;) {
    try { if (await page.evaluate(fn)) return true; } catch (e) { /* mid-navigation */ }
    if (Date.now() - t0 > timeout) return false;
    await sleep(120);
  }
}

/* ---------------- desktop pass, one per engine ---------------- */
async function desktopPass(name, launcher) {
  let browser;
  try { browser = await launcher.launch(); }
  catch (e) { check(`${name}: launch`, false, e.message.split('\n')[0]); return; }

  const errors = [];
  const page = await browser.newPage({ viewport: { width: 1100, height: 720 } });
  page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
  page.on('pageerror', e => errors.push(e.message));

  await page.goto(GAME);
  await sleep(700);

  check(`${name}: boots + attract mode runs`,
    await waitFor(page, () => typeof G !== 'undefined' && G.time > 20 && orbs.length > 0));

  await page.keyboard.press('Enter');
  check(`${name}: game starts`, await waitFor(page, () => G.state === 'play' && orbs.length > 0));

  // fly, turn and shoot
  await page.keyboard.down('ArrowUp');
  await page.keyboard.down('ArrowLeft');
  await page.keyboard.down('Space');
  await sleep(1500);
  await page.keyboard.up('ArrowUp'); await page.keyboard.up('ArrowLeft'); await page.keyboard.up('Space');
  check(`${name}: ship flies and fires`, await page.evaluate(() => G.shots > 3 && G.frames > 30));

  // every wild behaviour in this engine (canvas APIs differ: ellipse, setLineDash,
  // createRadialGradient with r0>0, composite ops)
  const before = errors.length;
  await page.evaluate(() => {
    ship.inv = 1e9;
    for (const k of ORB_KEYS) for (const tier of [4, 3, 1]) {
      const o = makeOrb(innerWidth * 0.4, innerHeight * 0.5, tier, k);
      orbs.push(o); destroyOrb(o);
    }
  });
  await sleep(2200);
  check(`${name}: all 13 orb behaviours render`, errors.length === before, errors[before]);

  // audio graph actually built (WebKit is fussy about AudioContext)
  const audio = await page.evaluate(() => ({ ready: Snd.ready, ctxState: Snd.ctx ? Snd.ctx.state : 'none' }));
  check(`${name}: WebAudio graph built`, audio.ready === true, `state=${audio.ctxState}`);

  // localStorage persistence path
  const stored = await page.evaluate(() => { Store.set('probe', 42); return Store.get('probe', 0); });
  check(`${name}: persistence works`, stored === 42);

  check(`${name}: zero console errors`, errors.length === 0, errors.slice(0, 2).join(' | '));
  await browser.close();
}

/* ---------------- touch pass on an emulated phone ---------------- */
async function touchPass() {
  const browser = await chromium.launch();
  const ctx = await browser.newContext({ ...devices['iPhone 13'] });
  const errors = [];
  const page = await ctx.newPage();
  page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
  page.on('pageerror', e => errors.push(e.message));

  await page.goto(GAME);
  await sleep(700);

  const vp = page.viewportSize();
  check('touch: portrait phone layout boots', await waitFor(page, () => G.time > 20));

  // start via the on-screen button (no keyboard on a phone)
  await page.tap('#startBtn');
  check('touch: Launch button starts the game', await waitFor(page, () => G.state === 'play'));

  // left half = virtual stick. Drag from centre-left outward and hold.
  const a0 = await page.evaluate(() => ship.a);
  await page.touchscreen.tap(vp.width * 0.25, vp.height * 0.7);   // registers touch.on
  await sleep(100);

  // a real drag: down, move, hold, up (touchscreen.tap can't hold, so use raw CDP-free
  // pointer events through dispatchEvent on the canvas)
  await page.evaluate(({ w, h }) => {
    const opts = (id, x, y) => ({ pointerId: id, pointerType: 'touch', clientX: x, clientY: y, bubbles: true });
    cv.dispatchEvent(new PointerEvent('pointerdown', opts(1, w * 0.25, h * 0.7)));
    cv.dispatchEvent(new PointerEvent('pointermove', opts(1, w * 0.25 + 60, h * 0.7 - 60)));
  }, { w: vp.width, h: vp.height });
  await sleep(900);

  const steered = await page.evaluate(() => ({
    a: ship.a, thrusting: ship.thrust > 0.2, stick: touch.stickId !== -1, on: touch.on
  }));
  check('touch: virtual stick registers', steered.stick && steered.on);
  check('touch: stick steers the ship', Math.abs(steered.a - a0) > 0.05, `heading ${a0.toFixed(2)} -> ${steered.a.toFixed(2)}`);
  check('touch: stick thrusts', steered.thrusting, `thrust=${(await page.evaluate(() => ship.thrust)).toFixed(2)}`);

  // right side fires
  const shots0 = await page.evaluate(() => G.shots);
  await page.evaluate(({ w, h }) => {
    const opts = (id, x, y) => ({ pointerId: id, pointerType: 'touch', clientX: x, clientY: y, bubbles: true });
    cv.dispatchEvent(new PointerEvent('pointerdown', opts(2, w * 0.8, h * 0.45)));
  }, { w: vp.width, h: vp.height });
  await sleep(600);
  check('touch: right side fires', await page.evaluate(s => G.shots > s, shots0));

  // release everything
  await page.evaluate(() => {
    [1, 2].forEach(id => cv.dispatchEvent(new PointerEvent('pointerup', { pointerId: id, pointerType: 'touch', bubbles: true })));
  });
  await sleep(200);
  check('touch: release stops input', await page.evaluate(() => touch.stickId === -1 && touch.fireId === -1));

  // on-screen PULSE / WARP buttons
  const btns = await page.evaluate(() => touchButtons().map(b => ({ id: b.id, x: b.x, y: b.y })));
  check('touch: on-screen buttons are on-screen',
    btns.every(b => b.x > 0 && b.x < vp.width && b.y > 0 && b.y < vp.height),
    btns.map(b => `${b.id}@${Math.round(b.x)},${Math.round(b.y)}`).join(' '));

  await page.evaluate(() => { G.pulses = 2; waves.length = 0; });
  const pulseBtn = btns.find(b => b.id === 'pulse');
  await page.evaluate(({ x, y }) => {
    cv.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 5, pointerType: 'touch', clientX: x, clientY: y, bubbles: true }));
  }, pulseBtn);
  check('touch: PULSE button fires the bomb', await page.evaluate(() => waves.some(w => w.dmg === 3)));

  // a mouse must NOT summon the thumb controls
  const mouseCtx = await browser.newContext({ viewport: { width: 1000, height: 700 } });
  const mp = await mouseCtx.newPage();
  await mp.goto(GAME); await sleep(500);
  await mp.keyboard.press('Enter'); await sleep(300);
  await mp.mouse.click(500, 400);
  await sleep(200);
  check('touch UI stays hidden for mouse users', await mp.evaluate(() => touch.on === false));

  check('touch: zero console errors', errors.length === 0, errors.slice(0, 2).join(' | '));
  await browser.close();
}

/* ---------------- run ---------------- */
console.log('cross-engine + touch verification\n');
for (const [name, l] of [['chromium', chromium], ['firefox ', firefox], ['webkit  ', webkit]]) {
  await desktopPass(name, l);
  console.log('');
}
await touchPass();

console.log('\n' + '─'.repeat(58));
console.log(failed ? `${failed} check(s) FAILED` : 'all cross-engine + touch checks passed');
process.exit(failed ? 1 : 0);