Files
dashfront/test/capture.mjs
2026-10-07 22:26:40 +02:00

238 lines
7.4 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 { 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);
});