/** * Capture screenshots for every route × viewport against the dummy backend. * * Usage: * node test/capture.mjs [--out test/screenshots/current] * * Flow: builds the production bundle (`vite build`), serves `dist/` via * `vite preview` (default :4229) with RIG_API_TARGET pointing at the dummy * backend (default :4218), then drives a persistent headless Chrome * (remote-debugging port) via CDP: one fresh page target per shot, * waits for React hydration + data, captures full-page PNGs. No npm deps. * * Production (not dev) so screenshots reflect the shipped app: no HMR client, * no AutomatInspector, minified bundles. Each run uses a fresh Chrome profile * so the PWA service worker / caches never leak between runs. * * The dummy backend is always-authed, so no login step is needed. */ import { spawn } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { ROUTES, VIEWPORTS } from './routes.mjs'; import { capturePage } from './cdp.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FRONT_ROOT = path.resolve(__dirname, '..'); const args = Object.fromEntries( process.argv.slice(2).map((a) => { const [k, v] = a.split('='); return [k.replace(/^--/, ''), v ?? true]; }), ); const OUT_DIR = path.resolve(FRONT_ROOT, args.out ?? 'test/screenshots/current'); const DUMMY_PORT = Number(args['dummy-port'] ?? 4218); const VITE_PORT = Number(args['vite-port'] ?? 4229); const DEBUG_PORT = Number(args['debug-port'] ?? 9333); const BASE = `http://localhost:${VITE_PORT}`; const SETTLE_MS = Number(args.settle ?? 2500); const CHROME = args.chrome ?? '/usr/bin/google-chrome'; function wait(ms) { return new Promise((r) => setTimeout(r, ms)); } async function waitForPort(port, host = '127.0.0.1', timeoutMs = 45000) { const net = await import('node:net'); const start = Date.now(); for (;;) { try { await new Promise((resolve, reject) => { const s = net.default.connect(port, host); s.on('connect', () => { s.end(); resolve(); }); s.on('error', reject); }); return; } catch { if (Date.now() - start > timeoutMs) throw new Error(`port ${port} not ready`); await wait(300); } } } function startProc(cmd, procArgs, env, tag) { const child = spawn(cmd, procArgs, { cwd: FRONT_ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], // Own process group per child so stopProc can kill the whole tree // (e.g. `npx vite` wrapper + the real vite process). detached: process.platform !== 'win32', }); // Piped stdio keeps the parent alive even after kill(); detach the streams // so teardown below can actually exit. child.stdout.on('data', (d) => process.stdout.write(`[${tag}] ${d}`)); child.stderr.on('data', (d) => { const text = String(d); // preview proxy logs are noise during capture; keep errors. if ((tag === 'preview' || tag === 'vite') && text.includes('[vite-proxy]')) return; process.stderr.write(`[${tag}] ${d}`); }); child.stdout.unref?.(); child.stderr.unref?.(); child.unref?.(); return child; } async function stopProc(child, tag, timeoutMs = 8000) { if (!child || child.exitCode != null || child.signalCode != null) return; // Kill the whole process group (child + grandchildren like npx/vite). const killTree = (sig) => { try { if (child.pid && process.platform !== 'win32') { process.kill(-child.pid, sig); return; } } catch { // fall through to single kill } try { child.kill(sig); } catch { // already gone } }; await new Promise((resolve) => { const timer = setTimeout(() => { killTree('SIGKILL'); resolve(); }, timeoutMs); timer.unref?.(); child.once('exit', () => { clearTimeout(timer); resolve(); }); killTree('SIGTERM'); }); // Drain any buffered pipe listeners so they can't hold the loop open. try { child.stdout?.destroy(); } catch { // ignore } try { child.stderr?.destroy(); } catch { // ignore } } async function main() { if (!fs.existsSync(CHROME)) { throw new Error(`Chrome not found at ${CHROME} (pass --chrome=...)`); } if (!fs.existsSync(path.join(FRONT_ROOT, 'test', 'fixtures', 'snapshot.json'))) { throw new Error('Missing test/fixtures/snapshot.json — run: npm run rig:collect'); } fs.mkdirSync(OUT_DIR, { recursive: true }); // Production bundle first — screenshots must reflect the shipped app. console.log('[capture] vite build…'); await new Promise((resolve, reject) => { const build = spawn('npx', ['vite', 'build'], { cwd: FRONT_ROOT, env: { ...process.env }, stdio: ['ignore', 'pipe', 'pipe'], }); build.stdout.on('data', () => process.stdout.write('[build] ')); build.stderr.on('data', (d) => process.stderr.write(`[build] ${d}`)); build.on('exit', (code) => { if (code === 0) resolve(); else reject(new Error(`vite build exited with ${code}`)); }); build.on('error', reject); }); const dummy = startProc('node', ['test/dummyBackend.mjs', `--port=${DUMMY_PORT}`], {}, 'dummy'); await waitForPort(DUMMY_PORT); const server = startProc( 'npx', ['vite', 'preview', '--port', String(VITE_PORT), '--strictPort'], { RIG_API_TARGET: `http://localhost:${DUMMY_PORT}` }, 'preview', ); await waitForPort(VITE_PORT); await wait(2000); // Fresh profile per run: the production SW registers and caches dist/ — // never let it leak between reference/compare runs. const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'rig-chrome-')); fs.mkdirSync(profile, { recursive: true }); const chrome = startProc( CHROME, [ '--headless=new', '--no-sandbox', '--disable-gpu', '--hide-scrollbars', `--remote-debugging-port=${DEBUG_PORT}`, `--user-data-dir=${profile}`, 'about:blank', ], {}, 'chrome', ); await waitForPort(DEBUG_PORT); let failed = 0; try { for (const viewport of VIEWPORTS) { for (const route of ROUTES) { const file = path.join(OUT_DIR, `${route.name}.${viewport.name}.png`); try { await capturePage(DEBUG_PORT, `${BASE}${route.path}`, file, { width: viewport.width, height: viewport.height, settleMs: SETTLE_MS, }); const kb = (fs.statSync(file).size / 1024).toFixed(0); console.log(`[capture] ${route.path} @ ${viewport.name} → ${path.basename(file)} (${kb} KiB)`); } catch (err) { console.error(`[capture] FAIL ${route.path} @ ${viewport.name}: ${err.message}`); failed++; } } } } finally { await stopProc(chrome, 'chrome'); await stopProc(server, 'preview'); await stopProc(dummy, 'dummy'); // Remove the throwaway Chrome profile (SW + caches). try { fs.rmSync(profile, { recursive: true, force: true }); } catch { // ignore } } if (failed > 0) { console.error(`[capture] ${failed} shots failed`); process.exit(1); } console.log(`[capture] done → ${OUT_DIR}`); // Explicit exit: spawned grandchildren (preview server, chrome helpers) can // otherwise keep the event loop alive after teardown. process.exit(0); } main().catch((err) => { console.error(`[capture] FAILED: ${err.message}`); process.exit(1); });