← back to Games Agentabrams

tests/motion.mjs

135 lines

// 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();
}

// Desktop-scale guard: the portrait-tuned flappy games must render as a capped
// portrait COLUMN on a wide desktop, not stretch to fill the window (which made
// flappy-sky an ultra-wide field with one tiny bird). The animation loop above
// runs at a phone viewport where this bug can't manifest — this block is the
// only thing that catches a sync/edit revert of the #stage aspect cap.
const PORTRAIT = ['flappy-sky', 'flappy-clone', 'flappy-flight'];
const dctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
for (const g of PORTRAIT) {
  const page = await dctx.newPage();
  let w = null;
  try {
    await page.goto(`${base}/games/${g}/`, { waitUntil: 'networkidle', timeout: 15000 });
    await page.waitForTimeout(200);
    w = await page.evaluate(() => { const c = document.querySelector('canvas'); return c ? Math.round(c.getBoundingClientRect().width) : null; });
  } catch (e) { /* leaves w null -> fails below */ }
  const ok = w !== null && w < 800; // portrait column, not a 1440-wide stretch
  results.push({ g: `${g}@desktop-scale`, ok, errs: [] });
  console.log(`${ok ? 'PASS' : 'FAIL'}  ${g}@desktop-scale  (canvas ${w}px wide at 1440 viewport; want <800)`);
  await page.close();
}
await dctx.close();

await browser.close();
server.close();
const failed = results.filter(r => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
if (failed.length) console.log('FAILED:', failed.map(r => r.g).join(', '));
process.exit(failed.length ? 1 : 0);