fertig-classic-games/tools/smokeJigsaw.mjs

163 lines
7.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 13 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);