310 lines
9.4 KiB
JavaScript
310 lines
9.4 KiB
JavaScript
/**
|
||
* 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);
|
||
});
|