163 lines
7.6 KiB
JavaScript
163 lines
7.6 KiB
JavaScript
// 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);
|