Files
Live_RC/scripts/capture-screenshots.mjs
larssand d76b0e8eef Refresh stale screenshots and add one for Event Manager's new tabs
guide.jpg was captured before this session's Schedule feature and video guides existed,
so it was missing both. Event Manager's tabbed layout (also from this session) had never
been pictured in the README at all. Added scripts/capture-screenshots.mjs as a reusable
tool for this instead of another throwaway script, since screenshot refreshes are clearly
a recurring need - reuses the same isolated-server/headless-Chrome pattern as the video
recorder and test suite, with one shared seeded demo dataset so future captures stay
visually consistent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 17:30:44 +02:00

191 lines
9.8 KiB
JavaScript

// 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/<name>.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);
}