/** * 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 { execSync, 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'; import { compareBuffers } from './compare.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'; const FILTER = args.filter ? String(args.filter) : null; const ACTIVE_ROUTES = FILTER ? ROUTES.filter((r) => r.name.includes(FILTER) || r.path.includes(FILTER)) : ROUTES; function wait(ms) { return new Promise((r) => setTimeout(r, ms)); } function freePorts(ports) { try { const portList = ports.map((p) => `${p}/tcp`).join(' '); execSync(`fuser -k ${portList} 2>/dev/null || true`, { stdio: 'ignore' }); } catch { // ignore } } 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); } } } const activeProcs = new Set(); let activeProfile = null; function startProc(cmd, procArgs, env, tag) { const child = spawn(cmd, procArgs, { cwd: FRONT_ROOT, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32', }); activeProcs.add(child); child.stdout.on('data', (d) => { const text = String(d); if ((tag === 'preview' || tag === 'vite') && text.includes('[vite-proxy]')) return; process.stdout.write(`[${tag}] ${d}`); }); child.stderr.on('data', (d) => { const text = String(d); if ((tag === 'preview' || tag === 'vite') && text.includes('[vite-proxy]')) return; process.stderr.write(`[${tag}] ${d}`); }); child.on('exit', () => activeProcs.delete(child)); child.stdout.unref?.(); child.stderr.unref?.(); child.unref?.(); return child; } async function stopProc(child, tag, timeoutMs = 4000) { if (!child || child.exitCode != null || child.signalCode != null) { activeProcs.delete(child); return; } const killTree = (sig) => { try { if (child.pid && process.platform !== 'win32') { process.kill(-child.pid, sig); return; } } catch { // fall through } 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'); }); activeProcs.delete(child); try { child.stdout?.destroy(); } catch {} try { child.stderr?.destroy(); } catch {} } async function cleanupEverything() { for (const child of Array.from(activeProcs)) { await stopProc(child, 'proc'); } if (activeProfile) { try { fs.rmSync(activeProfile, { recursive: true, force: true }); } catch {} activeProfile = null; } freePorts([DUMMY_PORT, VITE_PORT, DEBUG_PORT]); } process.on('SIGINT', async () => { console.log('\n[capture] Interrupted, cleaning up...'); await cleanupEverything(); process.exit(130); }); process.on('SIGTERM', async () => { await cleanupEverything(); process.exit(143); }); 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 }); // Ensure ports are free before starting freePorts([DUMMY_PORT, VITE_PORT, DEBUG_PORT]); // 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, NODE_ENV: 'production' }, 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}`, NODE_ENV: 'production' }, 'preview', ); await waitForPort(VITE_PORT); await wait(1500); // Fresh profile per run: the production SW registers and caches dist/ — // never let it leak between reference/compare runs. activeProfile = fs.mkdtempSync(path.join(os.tmpdir(), 'rig-chrome-')); fs.mkdirSync(activeProfile, { recursive: true }); const chrome = startProc( CHROME, [ '--headless=new', '--no-sandbox', '--disable-gpu', '--hide-scrollbars', `--remote-debugging-port=${DEBUG_PORT}`, `--user-data-dir=${activeProfile}`, 'about:blank', ], {}, 'chrome', ); await waitForPort(DEBUG_PORT); const isReference = OUT_DIR.endsWith('reference') || Boolean(args['diff-only']); const force = Boolean(args.force); let failed = 0; try { for (const viewport of VIEWPORTS) { for (const route of ACTIVE_ROUTES) { const file = path.join(OUT_DIR, `${route.name}.${viewport.name}.png`); try { const buf = await capturePage(DEBUG_PORT, `${BASE}${route.path}`, null, { width: viewport.width, height: viewport.height, settleMs: SETTLE_MS, dialog: route.dialog, icons: route.icons, }); const kb = (buf.length / 1024).toFixed(0); if (fs.existsSync(file)) { if (isReference && !force) { const existingBuf = fs.readFileSync(file); let diff; try { diff = compareBuffers(existingBuf, buf); } catch (err) { diff = { ok: false, reason: err.message }; } if (diff.ok) { console.log( `[capture] ${route.name} (${route.path}) @ ${viewport.name} → unchanged (no visible difference)`, ); continue; } const pct = diff.frac != null ? `${(diff.frac * 100).toFixed(2)}%` : diff.reason; console.log( `[capture] ${route.name} (${route.path}) @ ${viewport.name} → UPDATED reference (visible diff ${pct}, maxΔ=${diff.maxDelta})`, ); } else { console.log( `[capture] ${route.name} (${route.path}) @ ${viewport.name} → ${path.basename(file)} (${kb} KiB)`, ); } } else { console.log( `[capture] ${route.name} (${route.path}) @ ${viewport.name} → ${path.basename(file)} (${kb} KiB) [NEW]`, ); } fs.writeFileSync(file, buf); } catch (err) { console.error(`[capture] FAIL ${route.name} (${route.path}) @ ${viewport.name}: ${err.message}`); failed++; } } } } finally { await cleanupEverything(); } if (failed > 0) { console.error(`[capture] ${failed} shots failed`); process.exit(1); } console.log(`[capture] done → ${OUT_DIR}`); process.exit(0); } main().catch(async (err) => { console.error(`[capture] FAILED: ${err.message}`); await cleanupEverything(); process.exit(1); });