← back to Wild Orbs Opus
test/compare.mjs
133 lines
/**
* Neutral head-to-head comparator for two candidate builds.
*
* Deliberately internals-agnostic: it knows nothing about either build's
* variable names, so neither candidate gets a home-field advantage. Everything
* it measures is observable from outside — console errors, whether the canvas
* actually animates, whether input changes what's on screen, framerate, resize
* survival, and whether it's still alive after sustained hammering.
*
* Usage: node test/compare.mjs <a.html> <b.html>
*/
import { chromium } from 'playwright';
import { pathToFileURL } from 'node:url';
import path from 'node:path';
const files = process.argv.slice(2).filter(a => !a.startsWith('--'));
if (files.length !== 2) { console.error('usage: node test/compare.mjs <a.html> <b.html>'); process.exit(1); }
const sleep = ms => new Promise(r => setTimeout(r, ms));
/** hash the canvas pixels so we can tell "animating" from "frozen picture" */
const canvasHash = () => {
const c = document.querySelector('canvas');
if (!c) return 'no-canvas';
const g = c.getContext('2d');
try {
const d = g.getImageData(0, 0, Math.min(c.width, 300), Math.min(c.height, 300)).data;
let h = 0;
for (let i = 0; i < d.length; i += 97) h = (h * 31 + d[i]) >>> 0;
return String(h);
} catch (e) { return 'blocked'; }
};
async function evaluateBuild(file) {
const r = { file: path.basename(file), errors: [], notes: [] };
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
page.on('console', m => { if (m.type() === 'error') r.errors.push(m.text()); });
page.on('pageerror', e => r.errors.push('pageerror: ' + e.message));
await page.goto(pathToFileURL(path.resolve(file)).href);
await sleep(900);
r.bootErrors = r.errors.length;
// start: try Enter, then any visible button
await page.keyboard.press('Enter');
await sleep(400);
const h1 = await page.evaluate(canvasHash);
await sleep(250);
const h2 = await page.evaluate(canvasHash);
if (h1 === h2) {
const btn = await page.$('button');
if (btn) { await btn.click().catch(() => {}); await sleep(500); }
}
// animating?
const frames = [];
for (let i = 0; i < 6; i++) { frames.push(await page.evaluate(canvasHash)); await sleep(180); }
r.animating = new Set(frames).size > 3;
// does input visibly change the world? thrust+turn+fire for 2s, compare motion
const before = await page.evaluate(canvasHash);
await page.keyboard.down('ArrowUp'); await page.keyboard.down('ArrowRight'); await page.keyboard.down('Space');
await sleep(2000);
await page.keyboard.up('ArrowUp'); await page.keyboard.up('ArrowRight'); await page.keyboard.up('Space');
const after = await page.evaluate(canvasHash);
r.respondsToInput = before !== after;
// sustained framerate
r.fps = +(await page.evaluate(async () => {
let n = 0; const t0 = performance.now();
await new Promise(res => { const t = () => { n++; (performance.now() - t0 < 2500) ? requestAnimationFrame(t) : res(); }; requestAnimationFrame(t); });
return n / ((performance.now() - t0) / 1000);
})).toFixed(1);
// resize / orientation
const preResize = r.errors.length;
await page.setViewportSize({ width: 420, height: 880 }); await sleep(700);
await page.setViewportSize({ width: 1600, height: 620 }); await sleep(700);
r.resizeErrors = r.errors.length - preResize;
r.aliveAfterResize = await page.evaluate(canvasHash) !== 'no-canvas';
// 25s of hammering every key + rapid fire, then confirm still animating
const preStress = r.errors.length;
const t0 = Date.now();
while (Date.now() - t0 < 25000) {
await page.keyboard.down('Space');
await page.keyboard.down(['ArrowLeft', 'ArrowRight', 'ArrowUp'][Math.floor(Math.random() * 3)]).catch(() => {});
await sleep(120);
for (const k of ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space']) await page.keyboard.up(k).catch(() => {});
if (Math.random() < 0.2) await page.keyboard.press('ShiftLeft').catch(() => {});
if (Math.random() < 0.2) await page.keyboard.press('KeyH').catch(() => {});
}
r.stressErrors = r.errors.length - preStress;
const s1 = await page.evaluate(canvasHash); await sleep(220);
const s2 = await page.evaluate(canvasHash);
r.aliveAfterStress = s1 !== s2;
r.fpsAfterStress = +(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);
})).toFixed(1);
r.totalErrors = r.errors.length;
r.sampleErrors = r.errors.slice(0, 3);
await browser.close();
return r;
}
const out = [];
for (const f of files) { console.log(`running ${path.basename(f)} …`); out.push(await evaluateBuild(f)); }
console.log('\n' + '='.repeat(72));
const rows = [
['boot console errors', r => r.bootErrors],
['canvas animates', r => r.animating ? 'yes' : 'NO'],
['responds to input', r => r.respondsToInput ? 'yes' : 'NO'],
['fps (idle-ish)', r => r.fps],
['resize errors', r => r.resizeErrors],
['alive after resize', r => r.aliveAfterResize ? 'yes' : 'NO'],
['errors in 25s stress',r => r.stressErrors],
['alive after stress', r => r.aliveAfterStress ? 'yes' : 'NO'],
['fps after stress', r => r.fpsAfterStress],
['TOTAL errors', r => r.totalErrors],
];
console.log('metric'.padEnd(24) + out.map(r => r.file.padEnd(20)).join(''));
console.log('-'.repeat(72));
for (const [label, fn] of rows) {
console.log(label.padEnd(24) + out.map(r => String(fn(r)).padEnd(20)).join(''));
}
for (const r of out) if (r.sampleErrors.length) console.log(`\n${r.file} errors:\n ` + r.sampleErrors.join('\n '));