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