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

207 lines
6.1 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: spawns the dummy backend (default :4218) + `vite --port 4229` with
* RIG_API_TARGET pointing at the dummy, 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.
*
* The dummy backend is always-authed, so no login step is needed.
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
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);
// vite proxy logs are noise during capture; keep errors.
if (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 });
const dummy = startProc('node', ['test/dummyBackend.mjs', `--port=${DUMMY_PORT}`], {}, 'dummy');
await waitForPort(DUMMY_PORT);
const vite = startProc(
'npx',
['vite', '--port', String(VITE_PORT), '--strictPort'],
{ RIG_API_TARGET: `http://localhost:${DUMMY_PORT}` },
'vite',
);
await waitForPort(VITE_PORT);
await wait(3000);
const profile = '/tmp/rig-chrome-profile';
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(vite, 'vite');
await stopProc(dummy, 'dummy');
}
if (failed > 0) {
console.error(`[capture] ${failed} shots failed`);
process.exit(1);
}
console.log(`[capture] done → ${OUT_DIR}`);
// Explicit exit: spawned grandchildren (vite/esbuild, 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);
});