← 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
M package.jsonA test/compare.mjs
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 →