[object Object]

← back to Wild Orbs Opus

Keep the neutral build comparator; leave the Qwen3 repo pristine

4e055c9779df0cb446b0f3990d58b419fdafac1a · 2026-09-09 07:55:10 -0700 · Steve Abrams

test/compare.mjs is the internals-agnostic head-to-head harness written during
the decision panel: it knows nothing about either build's variable names, so
neither candidate gets a home-field advantage. It measures only what is
observable from outside — console errors, whether the canvas actually animates,
response to synthetic input, resize survival, 25s of input hammering, and
framerate before and after. It found a dead heat between the two arena entries,
which is what moved the verdict off 'pick a winner'.

It was sitting untracked in the Qwen3 entry's directory; moved here where it
belongs. That directory is now pristine — index.html, README.md, .gitignore and
.git, nothing of mine left in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XP13P5ZKs7oWJQ3pjbvpnG

Files touched

Diff

commit 4e055c9779df0cb446b0f3990d58b419fdafac1a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 9 07:55:10 2026 -0700

    Keep the neutral build comparator; leave the Qwen3 repo pristine
    
    test/compare.mjs is the internals-agnostic head-to-head harness written during
    the decision panel: it knows nothing about either build's variable names, so
    neither candidate gets a home-field advantage. It measures only what is
    observable from outside — console errors, whether the canvas actually animates,
    response to synthetic input, resize survival, 25s of input hammering, and
    framerate before and after. It found a dead heat between the two arena entries,
    which is what moved the verdict off 'pick a winner'.
    
    It was sitting untracked in the Qwen3 entry's directory; moved here where it
    belongs. That directory is now pristine — index.html, README.md, .gitignore and
    .git, nothing of mine left in it.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01XP13P5ZKs7oWJQ3pjbvpnG
---
 package.json     |   3 +-
 test/compare.mjs | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 134 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index e65981b..1682d33 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,8 @@
     "test": "node test/smoke.mjs",
     "test:browsers": "node test/browsers.mjs",
     "test:headed": "node test/smoke.mjs --headed --shots",
-    "profile": "node test/profile.mjs"
+    "profile": "node test/profile.mjs",
+    "test:compare": "node test/compare.mjs"
   },
   "license": "MIT"
 }
diff --git a/test/compare.mjs b/test/compare.mjs
new file mode 100644
index 0000000..8b7b914
--- /dev/null
+++ b/test/compare.mjs
@@ -0,0 +1,132 @@
+/**
+ * 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  '));

← 8a61163 Split out as the standalone Claude Opus arena entry  ·  back to Wild Orbs Opus  ·  chore: lint, refactor, v1.0.1 (session close) 40f3e99 →