Files
dashfront/test/capture.mjs
2026-10-08 04:51:28 +02:00

309 lines
9.3 KiB
JavaScript
Raw 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.

/**
* 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 },
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(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,
});
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);
});