← back to Games Agentabrams
tests: add motion regression test — proves all 19 action games actually animate
8b233d37df7aeecff1228329eb12aa9527a9f781 · 2026-07-25 09:45:02 -0700 · Steve Abrams
Existing e2e/popout tests only checked structure + pop-out links; nothing
verified the moving games' loops actually run. Engine-agnostic (screenshot
diff, so WebGL games like atmos-skyfire count), starts each game via
Space/Enter/canvas-click (not chrome buttons — those pause), and requires
>=2 distinct post-start frames to distinguish a live loop from a splash fade.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit 8b233d37df7aeecff1228329eb12aa9527a9f781
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Jul 25 09:45:02 2026 -0700
tests: add motion regression test — proves all 19 action games actually animate
Existing e2e/popout tests only checked structure + pop-out links; nothing
verified the moving games' loops actually run. Engine-agnostic (screenshot
diff, so WebGL games like atmos-skyfire count), starts each game via
Space/Enter/canvas-click (not chrome buttons — those pause), and requires
>=2 distinct post-start frames to distinguish a live loop from a splash fade.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
tests/motion.mjs | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 112 insertions(+)
diff --git a/tests/motion.mjs b/tests/motion.mjs
new file mode 100644
index 0000000..80f16e3
--- /dev/null
+++ b/tests/motion.mjs
@@ -0,0 +1,112 @@
+// Motion regression test for the arcade's ACTION games.
+//
+// Why this exists: e2e.mjs / popout.mjs only prove games LOAD and pop out —
+// nothing verified that the moving games actually ANIMATE. A one-off manual
+// sweep (2026-07-25) flagged 8 games as "still", but every one was a FALSE
+// NEGATIVE from a crude probe (top-left crop + generic input + blur auto-pause).
+// This test encodes the corrected, control-agnostic technique so a genuinely
+// frozen game loop can never ship silently again.
+//
+// Technique: for each game, hash the FULL canvas, then drive a scripted
+// "player" burst that (a) dismisses any splash, (b) presses every plausible
+// start/steer control, (c) sweeps the mouse across the canvas — sampling the
+// full-canvas hash repeatedly over ~2s (long enough for a slow grid tick like
+// snake). PASS if ANY later hash differs from the initial one.
+import { createRequire } from 'module';
+import { fileURLToPath } from 'url';
+import path from 'path'; import http from 'http'; import fs from 'fs';
+
+const require = createRequire(import.meta.url);
+let chromium;
+try { ({ chromium } = require('/Users/macstudio3/.npm-global/lib/node_modules/playwright')); }
+catch { ({ chromium } = require('playwright')); }
+
+const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
+const MIME = { '.html': 'text/html', '.json': 'application/json', '.png': 'image/png', '.js': 'text/javascript' };
+const server = http.createServer((req, res) => {
+ let p = decodeURIComponent(req.url.split('?')[0].split('#')[0]);
+ if (p.endsWith('/')) p += 'index.html';
+ const f = path.join(root, p);
+ if (!f.startsWith(root) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404); res.end('nf'); return; }
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(f)] || 'text/plain' });
+ fs.createReadStream(f).pipe(res);
+});
+await new Promise(r => server.listen(0, '127.0.0.1', r));
+const base = `http://127.0.0.1:${server.address().port}`;
+
+// Action / moving games only — turn-based games (sudoku, wordle, minesweeper,
+// 2048, etc.) intentionally have a STILL canvas until the player acts and are
+// not motion-tested here.
+const GAMES = [
+ 'asteroids', 'atmos-skyfire', 'breakout', 'dvd-bounce', 'flappy-clone',
+ 'flappy-flight', 'flappy-sky', 'hopper', 'invaders', 'missile', 'pong',
+ 'racer', 'rodeo-runner', 'snake', 'tron', 'tower-stack', 'spirograph',
+ 'constellation-clock', 'rope-physics',
+];
+
+// Engine-agnostic motion detector: hash a page SCREENSHOT rather than the
+// canvas's 2D ImageData. getImageData() throws on a WebGL/Three.js canvas
+// (e.g. atmos-skyfire), so canvas hashing is blind to 3D games; a screenshot
+// captures rendered output regardless of Canvas2D vs WebGL, and is highly
+// sensitive (a dismissed splash overlay = a large frame delta).
+const frameHash = async (page) => {
+ const buf = await page.screenshot({ type: 'jpeg', quality: 40 });
+ let h = 0;
+ for (let i = 0; i < buf.length; i += 7) h = (h * 31 + buf[i]) >>> 0;
+ return h;
+};
+
+const browser = await chromium.launch();
+const results = [];
+
+for (const g of GAMES) {
+ const ctx = await browser.newContext({ viewport: { width: 520, height: 740 } });
+ const page = await ctx.newPage();
+ const errs = [];
+ page.on('pageerror', e => errs.push(e.message.split('\n')[0]));
+ page.on('console', m => { if (m.type() === 'error') errs.push('con: ' + m.text().slice(0, 80)); });
+ let moved = false, hasCanvas = false;
+ try {
+ await page.goto(`${base}/games/${g}/`, { waitUntil: 'networkidle', timeout: 15000 });
+ await page.bringToFront(); // avoid window-blur auto-pause (snake et al.)
+ await page.waitForTimeout(300);
+ hasCanvas = await page.evaluate(() => !!document.querySelector('canvas'));
+
+ // Start the game the way EVERY game here accepts: Space / Enter / a canvas
+ // click. Deliberately do NOT click chrome <button>s — the first button is
+ // often pause/mute, and clicking it can pause a game that just started
+ // (tron's pauseBtn) or otherwise fight the start. Then give ONE gentle
+ // non-reversing steer so grid games (snake/hopper) begin advancing.
+ await page.keyboard.press('Space').catch(() => {});
+ await page.keyboard.press('Enter').catch(() => {});
+ await page.mouse.click(260, 380).catch(() => {});
+ await page.keyboard.press('ArrowRight').catch(() => {});
+
+ // Collect POST-START frame hashes over ~2s. A live loop keeps repainting on
+ // its own (pipes scroll, the cycle advances, the ball flies), so require >=2
+ // DISTINCT frames — resisting false negatives (missed motion) AND false
+ // positives (a one-shot splash fade with no running loop behind it). We do
+ // NOT hammer opposing arrows (that insta-crashes cycle/grid games back to a
+ // static game-over); a harmless mouse sweep drives paddle games instead.
+ const seen = new Set();
+ for (let i = 0; i < 9 && seen.size < 2; i++) {
+ if (i === 2) await page.keyboard.press('Space').catch(() => {}); // serve/launch (pong, breakout)
+ await page.mouse.move(120 + i * 26, 300).catch(() => {});
+ await page.waitForTimeout(230);
+ seen.add(await frameHash(page));
+ }
+ moved = seen.size >= 2;
+ } catch (e) { errs.push('THREW: ' + e.message.split('\n')[0]); }
+
+ const ok = hasCanvas && moved;
+ results.push({ g, ok, errs: [...new Set(errs)] });
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${g}${errs.length ? ' [' + [...new Set(errs)].slice(0, 2).join(' | ') + ']' : ''}`);
+ await ctx.close();
+}
+
+await browser.close();
+server.close();
+const failed = results.filter(r => !r.ok);
+console.log(`\n${results.length - failed.length}/${results.length} moving games animate`);
+if (failed.length) console.log('FROZEN:', failed.map(r => r.g).join(', '));
+process.exit(failed.length ? 1 : 0);
← d6891a2 Every game opens in its own window: pop-out controls are now
·
back to Games Agentabrams
·
Cross-link main arcade -> Beverly Hills 90210 collection hub 903d618 →