Step 3 becomes a rounds table: pick drivers inline (copy from the previous round, add a late entry), set qualifying rounds/finals/max cars per heat per round, see the status, and create qualifying and finals from there. Qualifying heats are now split evenly so nobody drives alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
12 KiB
JavaScript
210 lines
12 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 = {
|
|
series: {
|
|
async setup({ evaluate, navigate, scrollTo }) {
|
|
// A series over two of four classes (added out of name order on purpose: the page sorts them).
|
|
await navigate("dashboard", "#view");
|
|
await evaluate(`(async () => {
|
|
const { state } = await (await fetch("/api/state")).json();
|
|
state.classes.push({ id: "shot_rookie", name: "Rookie" }, { id: "shot_4wd", name: "4WD Buggy" }, { id: "shot_nitro", name: "1/8 Nitro" });
|
|
state.drivers.push({ id: "shot_rookie_1", name: "Elsa Rookie", classId: "shot_rookie" }, { id: "shot_rookie_2", name: "Theo Rookie", classId: "shot_rookie" });
|
|
const round = (id, date, classId, name) => ({ id, name, date, classId, mode: "race", raceConfig: { qualifyingRounds: 2, finalLegs: 1, carsPerHeat: 6, countedFinalLegs: 1 } });
|
|
state.events.push(round("shot_r1_2wd", "2026-11-01", "shot_class", "Winter #1 2WD Buggy"), round("shot_r1_rookie", "2026-11-01", "shot_rookie", "Winter #1 Rookie"), round("shot_r2_2wd", "2026-11-15", "shot_class", "Winter #2 2WD Buggy"));
|
|
state.series = [{ id: "shot_series", name: "Winter Championship 2026/2027", classIds: ["shot_class", "shot_rookie"], eventIds: ["shot_r1_2wd", "shot_r1_rookie", "shot_r2_2wd"], scoring: "club", club: "JMK RB", countMode: "best", countValue: 9 }];
|
|
await fetch("/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(state) });
|
|
})()`);
|
|
await navigate("series", "#seriesForm");
|
|
// For checking other parts while developing: SHOT_EVAL runs in the page first.
|
|
if (process.env.SHOT_EVAL) { await evaluate(process.env.SHOT_EVAL); await new Promise((resolve) => setTimeout(resolve, 300)); }
|
|
await scrollTo(process.env.SHOT_SCROLL || "#seriesForm", process.env.SHOT_SCROLL ? "center" : "start");
|
|
},
|
|
},
|
|
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);
|
|
}
|