Files
Live_RC/tests/browser_smoke.js
larssand 05f0291ee9 Add permanent browser tests for the Settings tabs, Event Manager tabs, and Schedule panel
These three UIs (all shipped earlier this session) had only been verified with throwaway
scratch scripts during development. Adds tests/browser_settings_tabs.js,
browser_event_manager_tabs.js and browser_event_schedule.js, wired into the existing
test:browser suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 16:45:24 +02:00

204 lines
15 KiB
JavaScript

import { runOverlaySmoke } from "./browser_overlays.js";
import { runRaceSetupSmoke } from "./browser_race_setup.js";
import { runRaceDaySmoke } from "./browser_race_day.js";
import { runRaceDayStartSequencesSmoke } from "./browser_race_day_start_sequences.js";
import { runTeamMembershipSmoke } from "./browser_team_membership.js";
import { runEventManagerTabsSmoke } from "./browser_event_manager_tabs.js";
import { runEventScheduleSmoke } from "./browser_event_schedule.js";
import { runSettingsTabsSmoke } from "./browser_settings_tabs.js";
import { runObsPopupSmoke } from "./browser_obs_popup.js";
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const project = dirname(dirname(fileURLToPath(import.meta.url)));
const chrome = [process.env.CHROME_PATH, "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", ...[process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean).flatMap(base => [join(base, "Google", "Chrome", "Application", "chrome.exe"), join(base, "Microsoft", "Edge", "Application", "msedge.exe")])].find(path => path && existsSync(path));
if (!chrome) throw new Error("Chrome/Chromium/Edge not found. Set CHROME_PATH to the browser executable.");
const temporary = mkdtempSync(join(tmpdir(), "racecore-browser-"));
const children = [];
let socket;
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;
}
function launch(command, args, options = {}) {
const child = spawn(command, args, { cwd: project, stdio: "ignore", ...options });
children.push(child);
child.on("error", error => { console.error(error.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: [{ id: "test_class", name: "Test Class" }], drivers: [], cars: [], events: [], sessions: [], resultsBySession: {}, settings: { language: "en", autoReconnect: false, backendUrl: base, raceAudio: { enabled: false } }, training: {} };
const seedResponse = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(initial) });
assert.equal(seedResponse.ok, true);
launch(chrome, ["--headless", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--disable-background-networking", ...(process.env.RC_BROWSER_NO_SANDBOX === "1" ? ["--no-sandbox"] : []), `--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(page => page.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();
const errors = [];
const dialogs = [];
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);
} else if (message.method === "Runtime.exceptionThrown") errors.push(message.params.exceptionDetails.text + ": " + (message.params.exceptionDetails.exception?.description || ""));
// A native confirm()/alert() blocks the page's JS thread until dismissed. Nothing in a
// headless run can click it, so every scenario would hang forever on the first one;
// auto-accept and record it so an unexpected dialog still surfaces as a finding.
else if (message.method === "Page.javascriptDialogOpening") {
dialogs.push({ type: message.params.type, message: message.params.message });
send("Page.handleJavaScriptDialog", { accept: true });
}
});
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 || result.exceptionDetails.text);
return result.result.value;
}
// A synthetic element.click() from Runtime.evaluate does not count as a trusted user
// gesture, so Chrome's popup blocker silently drops any window.open() it triggers.
// Dispatching real input events is required for scenarios that open a popup window.
async function realClick(selector) {
// scrollIntoView first: Input.dispatchMouseEvent uses viewport coordinates, so a
// button below the fold (the default headless window is short) would otherwise get
// "clicked" at a point outside the visible page and silently hit nothing.
const box = await evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return null; el.scrollIntoView({ block: "center" }); const r = el.getBoundingClientRect(); return JSON.stringify({ x: r.x + r.width / 2, y: r.y + r.height / 2, w: r.width, h: r.height }); })()`);
if (!box) throw new Error("realClick: element not found for " + selector);
const { x, y, w, h } = JSON.parse(box);
if (!w || !h) throw new Error("realClick: element has zero size for " + selector);
await send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button: "left", clickCount: 1 });
await send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button: "left", clickCount: 1 });
}
await send("Runtime.enable");
await send("Page.enable");
await send("Network.enable");
await send("Network.setBlockedURLs", { urls: ["*fonts.googleapis.com*", "*fonts.gstatic.com*"] });
const status = () => evaluate('document.getElementById("syncStatus")?.dataset.state');
async function navigate(view, form) {
await send("Page.navigate", { url: base + "/?view=" + view });
try {
await waitFor(async () => await evaluate(`Boolean(document.getElementById(${JSON.stringify(form)})) && document.getElementById("syncStatusDetail")?.textContent.includes("Data loaded")`), view + " hydrated");
} catch (error) {
throw new Error(error.message + "\n" + JSON.stringify({ errors, page: await evaluate("document.body.innerText.slice(0, 1800)") }));
}
}
async function fill(form, fields) {
await evaluate(`(() => { const form = document.getElementById(${JSON.stringify(form)}); for (const [name,value] of Object.entries(${JSON.stringify(fields)})) { const field=form.elements.namedItem(name); if (!field) throw new Error("Missing field "+name); field.value=value; field.dispatchEvent(new Event("input", {bubbles:true})); } })()`);
}
const savedState = async () => (await (await fetch(base + "/api/state")).json()).state;
for (const [view, prefix, collection, fields] of [
["classes", "class", "classes", { name: "Browser Class" }],
["drivers", "driver", "drivers", { name: "Browser Driver", classId: "test_class", transponder: "123456", club: "Test Club" }],
["cars", "car", "cars", { name: "Browser Car", transponder: "654321", brand: "Test Brand" }],
]) {
await navigate(view, prefix + "Form");
await fill(prefix + "Form", fields);
await evaluate(`document.getElementById("${prefix}Form").requestSubmit()`);
await waitFor(async () => (await savedState())[collection]?.some(item => item.name === fields.name), "create " + prefix);
const item = (await savedState())[collection].find(item => item.name === fields.name);
await evaluate(`document.getElementById(${JSON.stringify(prefix + "-edit-" + item.id)}).click()`);
await fill(prefix + "EditForm", { name: fields.name + " Updated" });
// Two version polls must not replace the active editor or its draft.
await pause(10500);
assert.equal(await evaluate(`document.getElementById("${prefix}EditForm").elements.namedItem("name").value`), fields.name + " Updated");
await evaluate(`document.getElementById("${prefix}EditSave").click()`);
await waitFor(async () => (await savedState())[collection]?.some(row => row.id === item.id && row.name === fields.name + " Updated"), "edit " + prefix);
await navigate(view, prefix + "Form");
assert.ok(await evaluate(`document.getElementById("view").textContent.includes(${JSON.stringify(fields.name + " Updated")})`));
console.log("PASS create/edit/reload and draft retention: " + collection);
}
await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause });
await runTeamMembershipSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base });
await runEventManagerTabsSmoke({ evaluate, navigate, savedState, waitFor, base });
await runEventScheduleSmoke({ evaluate, navigate, fill, savedState, waitFor, base });
await runSettingsTabsSmoke({ evaluate, navigate });
await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs });
await runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs });
await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick });
await runDecoderReconnectSmoke({ evaluate, navigate, savedState, waitFor, pause, base });
await navigate("classes", "classForm");
await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
await fill("classForm", { name: "Offline Draft" });
await evaluate('document.getElementById("classForm").requestSubmit()');
await waitFor(async () => (await status()) === "error", "visible save failure");
assert.ok(!(await savedState()).classes.some(item => item.name === "Offline Draft"));
await send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
await evaluate('document.getElementById("syncStatusRetry").click()');
await waitFor(async () => (await savedState()).classes.some(item => item.name === "Offline Draft"), "retry persisted draft");
await waitFor(async () => (await status()) === "saved", "confirmed saved status");
await navigate("settings", "settingsForm");
await evaluate(`(async () => {
const panel = document.getElementById("decoderDiagnostics");
if (!panel) throw new Error("Missing decoder diagnostics");
panel.open = true;
const field = document.querySelector('#settingsForm [name="wsUrl"]');
field.value = "ws://draft.example:9000";
const { decoderDiagnostics } = await import("/src/decoder_diagnostics.js");
decoderDiagnostics.frame("main", "diagnostic sample");
await new Promise(resolve => setTimeout(resolve, 1200));
if (!panel.textContent.includes("diagnostic sample")) throw new Error("Diagnostics did not update");
if (field.value !== "ws://draft.example:9000") throw new Error("Diagnostics reset settings draft");
})()`);
console.log("PASS decoder diagnostics update without resetting settings draft");
await runOverlaySmoke({ evaluate });
const mobileState = await savedState();
const mobileToken = "browser-mobile-profile-token-1234567890";
mobileState.training ||= {};
mobileState.training.shares = [{ driverId: mobileState.drivers[0].id, token: mobileToken, enabled: true }];
const mobileSave = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(mobileState) });
assert.ok(mobileSave.ok);
await send("Page.navigate", { url: base + "/public-training/" + mobileToken + "?lang=sv" });
await waitFor(async () => await evaluate('document.getElementById("publicTrainingConnection")?.dataset.state === "current"'), "mobile profile loaded");
await evaluate('window.mobileContentBeforeFailure = document.getElementById("publicTrainingRoot").firstElementChild');
await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
await waitFor(async () => await evaluate('document.getElementById("publicTrainingConnection")?.dataset.state === "failed"'), "mobile connection warning");
assert.ok(await evaluate('window.mobileContentBeforeFailure === document.getElementById("publicTrainingRoot").firstElementChild'));
await send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
await evaluate('document.getElementById("publicTrainingRetry").click()');
await waitFor(async () => await evaluate('document.getElementById("publicTrainingConnection")?.dataset.state === "current"'), "mobile retry recovered");
console.log("PASS mobile offline status preserves profile nodes and retry recovers");
assert.deepEqual(errors, []);
if (dialogs.length) console.log("NOTE auto-accepted native dialog(s):", JSON.stringify(dialogs));
console.log("PASS offline save failure, local draft retention, retry and confirmed saved status");
} finally {
socket?.close();
for (const child of children.reverse()) {
if (child.exitCode !== null || child.signalCode !== null) continue;
const exited = new Promise(resolve => child.once("exit", resolve));
child.kill();
await Promise.race([exited, pause(2000)]);
if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); await Promise.race([exited, pause(1000)]); }
}
rmSync(temporary, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
}