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