// Browser smoke test for the Jigsaw game (Playwright + headless Chromium). // // Usage: // npx playwright install chromium # one-time // python3 -m http.server 8000 # serve the repo root (or pass a base URL) // node tools/smokeJigsaw.mjs # defaults to http://127.0.0.1:8000 // node tools/smokeJigsaw.mjs http://localhost:9000 // // What it checks: // - game loads, menu → Start Puzzle → playing, pieces spawn on the table // - forced win (placed = total; onWin()) runs the full showcase: // * camera sweep lands on WIN_ZOOM framing the board // * stats counter animates up to the total // * celebration emitters fire (burst + curtain) and confetti is alive // * gold frame + shine sweep exist at the right depths // - "Play Again" restarts a fresh board and tears the win layer down // - "Menu" returns to the menu and destroys the win layer // - menu → Start Puzzle works again // - zero JS console/page errors across the whole flow // // NOTE: headless Chromium renders this 1920×1080 WebGL scene at only a few // FPS (SwiftShader), so the scene clock advances slowly in real time. All // waits here are condition-based polling (generous timeouts), never fixed // sleeps. A full run takes roughly 1–3 minutes of wall time. import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import os from 'node:os'; import path from 'node:path'; import { chromium } from 'playwright'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const BASE = process.argv[2] || 'http://127.0.0.1:8000/__jig_test.html'; let server = null; if (!process.argv[2]) { try { await fetch(new URL('..', BASE), { method: 'HEAD', signal: AbortSignal.timeout(1500) }); } catch { server = spawn('python3', ['-m', 'http.server', '8000'], { cwd: ROOT, stdio: 'ignore', detached: true }); await new Promise((r) => setTimeout(r, 1200)); console.log('started local static server on :8000'); } } const errors = []; let failed = false; const ok = (m) => console.log(' ok ' + m); const bad = (m) => { failed = true; console.log(' FAIL ' + m); }; const browser = await chromium.launch({ args: ['--no-sandbox'] }); const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); const evalS = (fn, ...a) => page.evaluate(fn, ...a); const waitFor = async (name, cond, timeoutMs) => { const t0 = Date.now(); while (Date.now() - t0 < timeoutMs) { if (await evalS(cond)) return true; await new Promise((r) => setTimeout(r, 500)); } bad(`timeout waiting for: ${name}`); return false; }; const snap = async (name) => { const dest = path.join(os.tmpdir(), `jigsaw_smoke_${name}.png`); await page.screenshot({ path: dest, fullPage: false }); ok(`screenshot ${dest}`); }; const click = (x, y) => evalS((pt) => { window.__mouseDown(pt[0], pt[1]); window.__mouseUp(pt[0], pt[1]); }, [x, y]); const settleTimeout = Number(process.env.JIG_SMOKE_SETTLE_MS || 180000); try { console.log('— load —'); await page.goto(BASE, { waitUntil: 'load' }); await page.waitForFunction(() => document.getElementById('log').textContent.includes('harness ready'), null, { timeout: 30000 }); ok('harness ready'); console.log('— start puzzle —'); await evalS(() => window.__startPuzzle()); if (!(await waitFor('state=playing', () => window.__scene().state === 'playing', 60000))) throw new Error('never reached playing'); ok('state = playing'); const pieces = await evalS(() => window.__pieces()); ok(`pieces on table: ${pieces.length} (placed=${pieces.filter((p) => p.placed).length})`); if (pieces.length === 0) bad('no pieces spawned'); await snap('1_playing'); console.log('— force win —'); await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); }); ok('onWin() called (state=' + (await evalS(() => window.__scene().state)) + ')'); console.log('— wait for panel settle (camera at WIN_ZOOM, counter done) —'); if (await waitFor('camera settled + counter=total', () => { const s = window.__scene(); return s.state === 'won' && Math.abs(s.cameras.main.zoom - 1.24) < 0.01 && s.piecesBig && s.piecesBig.text === String(s.total); }, settleTimeout)) { const wi = await evalS(() => { const s = window.__scene(); return { zoom: +s.cameras.main.zoom.toFixed(3), sx: Math.round(s.cameras.main.scrollX), sy: Math.round(s.cameras.main.scrollY), counter: s.piecesBig.text }; }); ok('settled: ' + JSON.stringify(wi)); await snap('2_win_panel'); } console.log('— wait for celebration (emitters created + particles alive) —'); if (await waitFor('emitters alive', () => { const s = window.__scene(); return (s.winEmitters || []).length === 2 && s.winEmitters.every((e) => e.getAliveParticleCount() > 0); }, settleTimeout)) { const alive = await evalS(() => window.__scene().winEmitters.map((e) => e.getAliveParticleCount())); ok('particles alive: ' + JSON.stringify(alive)); await snap('3_confetti'); const fx = await evalS(() => { const s = window.__scene(); return { frame: !!s.winFrame, shine: !!s.winShine, frameDepth: s.winFrame && s.winFrame.depth, shineDepth: s.winShine && s.winShine.depth }; }); ok('frame/shine: ' + JSON.stringify(fx)); if (!fx.frame || !fx.shine) bad('winFrame or winShine missing'); } console.log('— Play Again button —'); await click(400, 640); // inside "Play Again" (visual 90..476 × 591..653, hit rect 283..669 × 622..684) if (await waitFor('state=playing after Play Again', () => window.__scene().state === 'playing', 120000)) { const placed = await evalS(() => window.__scene().pieces.filter((p) => p.placed).length); const winGone = await evalS(() => !window.__scene().winLayer); ok(`restarted: placed=${placed}, winLayer destroyed=${winGone}`); if (placed !== 0) bad('pieces not reset after Play Again'); if (!winGone) bad('winLayer not destroyed after Play Again'); await snap('4_restarted'); } console.log('— force win again, then Menu button —'); await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); }); if (await waitFor('second showcase emitters', () => (window.__scene().winEmitters || []).length === 2, settleTimeout)) { ok('second win showcase running'); await click(400, 720); // inside "Menu" if (await waitFor('state=menu after Menu', () => window.__scene().state === 'menu', 120000)) { const menuVisible = await evalS(() => window.__scene().menu && window.__scene().menu.visible); const winGone = await evalS(() => !window.__scene().winLayer); ok(`back to menu: menu.visible=${menuVisible}, winLayer destroyed=${winGone}`); if (!menuVisible) bad('menu not visible'); if (!winGone) bad('winLayer not destroyed'); await snap('5_menu'); } } console.log('— Start Puzzle from menu works —'); await evalS(() => { const s = window.__scene(); s.selectedDiff = 'easy'; s.startPuzzle(); }); if (await waitFor('state=playing from menu', () => window.__scene().state === 'playing', 60000)) ok('menu → playing OK'); } finally { await browser.close(); if (server) { try { process.kill(-server.pid); } catch { /* already gone */ } } } console.log('\n— JS errors (' + errors.length + ') —'); errors.forEach((e) => console.log(' ' + e)); if (errors.length) failed = true; console.log(failed ? '\nSMOKE TEST: FAILED' : '\nSMOKE TEST: ALL PASS'); process.exit(failed ? 1 : 0);