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 { runGuideTabsSmoke } from "./browser_guide_tabs.js"; import { runAuthSmoke } from "./browser_auth.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 runGuideTabsSmoke({ evaluate, navigate }); await runAuthSmoke({ evaluate, navigate, fill, savedState, waitFor }); 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 }); }