← 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);