// Captures README screenshots (docs/screenshots/*.jpg) against a real, isolated instance // of the app - own temp SQLite dir, own headless Chrome profile, exactly like the // tests/browser_*.js suite and scripts/record-video-guides.mjs. Nothing here ever touches // a real installation's data. // // Usage: node scripts/capture-screenshots.mjs [shot-name ...] // With no arguments, captures every shot below. Pass one or more names (e.g. "guide") // to capture only those. Output overwrites docs/screenshots/.jpg. // // Captured at 1600x900 CSS with deviceScaleFactor 2 (3200x1800 JPEG), matching every // existing screenshot in that folder - a plain viewport capture, not a full-page stitch, // so a shot's setup should scroll to whatever should be visible before capturing. import { spawn, execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { startFakeDecoder } from "../tests/browser_fake_decoder.js"; const projectDir = dirname(dirname(fileURLToPath(import.meta.url))); const outputDir = join(projectDir, "docs", "screenshots"); const chrome = [process.env.CHROME_PATH, "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && existsSync(p)); if (!chrome) throw new Error("Chrome/Chromium not found. Set CHROME_PATH to the browser executable."); const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function waitFor(check, label, timeout = 15000) { const until = Date.now() + timeout; while (Date.now() < until) { try { if (await check()) return; } catch {} await pause(100); } throw new Error("Timed out: " + label); } async function freePort() { const server = createServer(); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); const port = server.address().port; await new Promise((resolve) => server.close(resolve)); return port; } // Demo data shared by every shot, so screenshots taken at different times still look like // one consistent club/session - matching the existing screenshots' "JMK RB" branding and // active-session header. function seedDemoState(state, decoderPort) { state.settings.wsUrl = `ws://127.0.0.1:${decoderPort}`; state.settings.autoReconnect = true; const classId = "shot_class"; const driverA = { id: "shot_driver_a", name: "Alex Karlsson", classId, transponder: "7960001" }; const driverB = { id: "shot_driver_b", name: "Sam Nilsson", classId, transponder: "7960002" }; const driverC = { id: "shot_driver_c", name: "Jamie Lee", classId, transponder: "7960003" }; const driverD = { id: "shot_driver_d", name: "Robin Berg", classId, transponder: "7960004" }; const raceEvent = { id: "shot_event", name: "Klubbmästare Race 4", classId, mode: "race", raceConfig: { participantsConfigured: true, driverIds: [driverA.id, driverB.id, driverC.id, driverD.id], teams: [], minLapMs: 3000, maxLapMs: 60000 }, }; const practice = { id: "shot_practice", eventId: raceEvent.id, name: "Practice 1", type: "practice", durationMin: 10, status: "finished", startedAt: Date.now() - 3600000, endedAt: Date.now() - 3000000 }; const qualifying = { id: "shot_quali", eventId: raceEvent.id, name: "Qualification 1", type: "qualification", startMode: "position", durationMin: 5, status: "finished", startedAt: Date.now() - 2400000, endedAt: Date.now() - 2100000 }; state.classes.push({ id: classId, name: "2WD Buggy" }); state.drivers.push(driverA, driverB, driverC, driverD); state.events.push(raceEvent); state.sessions.push(practice, qualifying); state.activeSessionId = qualifying.id; state.settings.theme = "light"; state.settings.clubName = "JMK RB"; state.settings.clubTagline = "Live Timing & Race Control"; } async function captureShot(name, { setup }) { const temporary = mkdtempSync(join(tmpdir(), "racecore-shot-")); const children = []; let socket; const decoder = await startFakeDecoder(); const launch = (command, args, options = {}) => { const child = spawn(command, args, { cwd: projectDir, stdio: "ignore", ...options }); children.push(child); child.on("error", (e) => console.error(e.message)); return child; }; try { const port = await freePort(); const debugPort = await freePort(); const base = `http://127.0.0.1:${port}`; launch(process.execPath, ["server.js"], { env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: join(temporary, "data"), RC_TIMING_PID_FILE: "" } }); await waitFor(async () => (await fetch(base + "/api/health")).ok, "isolated server"); const initial = { classes: [], drivers: [], cars: [], events: [], sessions: [], resultsBySession: {}, settings: { language: "en", autoReconnect: false, backendUrl: base, raceAudio: { enabled: false } }, training: {}, }; seedDemoState(initial, decoder.port); await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(initial) }); launch(chrome, [ "--headless", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--disable-background-networking", ...(process.env.RC_BROWSER_NO_SANDBOX === "1" ? ["--no-sandbox"] : []), "--window-size=1600,900", `--user-data-dir=${join(temporary, "browser")}`, `--remote-debugging-port=${debugPort}`, "about:blank", ]); let target; await waitFor(async () => { target = (await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json()).find((p) => p.type === "page"); return target; }, "headless browser"); socket = new WebSocket(target.webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", reject, { once: true }); }); let nextId = 0; const requests = new Map(); socket.addEventListener("message", (event) => { const message = JSON.parse(event.data); if (message.id) { const request = requests.get(message.id); requests.delete(message.id); if (message.error) request?.reject(new Error(message.error.message)); else request?.resolve(message.result); } }); function send(method, params = {}) { return new Promise((resolve, reject) => { const id = ++nextId; requests.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); }); } async function evaluate(expression) { const result = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || JSON.stringify(result.exceptionDetails)); return result.result.value; } async function navigate(view, readySelector) { await send("Page.navigate", { url: base + "/?view=" + view }); await waitFor(async () => Boolean(await evaluate(`document.querySelector(${JSON.stringify(readySelector)})`)) && (await evaluate(`document.getElementById("syncStatusDetail")?.textContent`) || "").includes("Data loaded"), view + " hydrated"); // Match the "Decoder Online" state every other screenshot already shows, and let any // save the connection triggers finish so the sidebar doesn't show a stray "Changes // waiting to save" banner in the capture. await waitFor(async () => await evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "decoder connected"); await waitFor(async () => (await evaluate('document.getElementById("syncStatus")?.dataset.state')) === "saved", "settings saved"); } const click = (selector) => evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`); const scrollTo = (selector, block = "start") => evaluate(`document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({block: ${JSON.stringify(block)}})`); await send("Runtime.enable"); await send("Page.enable"); await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 900, deviceScaleFactor: 2, mobile: false }); await setup({ evaluate, navigate, click, scrollTo, pause, waitFor }); await pause(300); // let any final CSS transition/re-render settle const { data } = await send("Page.captureScreenshot", { format: "jpeg", quality: 90 }); mkdirSync(outputDir, { recursive: true }); const outputPath = join(outputDir, `${name}.jpg`); writeFileSync(outputPath, Buffer.from(data, "base64")); console.log(`[${name}] wrote ${outputPath}`); } finally { try { socket?.close(); } catch {} for (const child of children.reverse()) { try { child.kill(); } catch {} } await pause(300); try { await decoder.close(); } catch {} rmSync(temporary, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } } const shots = { guide: { async setup({ navigate }) { await navigate("guide", ".guide-list"); }, }, "race-manage-tabs": { async setup({ navigate, click, scrollTo, waitFor: waitForFn, evaluate, pause: pauseFn }) { await navigate("race_setup", "#raceWizardStepForm"); await click("#event-manage-shot_event"); await waitForFn(async () => Boolean(await evaluate('document.querySelector(".manage-tabs")')), "manage tabs rendered"); await click('[data-manage-tab="live"]'); await scrollTo(".manage-tabs"); await pauseFn(200); }, }, }; const requested = process.argv.slice(2); const names = requested.length ? requested : Object.keys(shots); for (const name of names) { const shot = shots[name]; if (!shot) { console.error(`Unknown shot "${name}". Available: ${Object.keys(shots).join(", ")}`); process.exitCode = 1; continue; } await captureShot(name, shot); }