[object Object]

← 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 →