race day tester + fixa open practice start och decoder-omritning

Nytt browser-test för start/paus/fortsätt/finish och byte till
nästa heat i kön, mot en simulerad decoder-socket (ingen riktig
AMMC-hårdvara). Hittade och fixade två buggar under vägen:

- Open Practice på sponsor/track-event kunde inte startas trots
  grönt ljus i förkontrollen (separat, striktare kontroll saknade
  samma undantag)
- Race Day-vyn ritades aldrig om när decodern anslöt/kopplades ner

Testramverket hanterar nu även native alert()/confirm()-dialoger
automatiskt, annars fryser en headless körning helt.
This commit is contained in:
larssand
2026-09-09 06:59:54 +02:00
parent b3bec531de
commit acab99ed4d
6 changed files with 188 additions and 5 deletions

150
tests/browser_race_day.js Normal file
View File

@@ -0,0 +1,150 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import { createServer as createNetServer } from "node:net";
async function freePort() {
const server = createNetServer();
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;
}
// Accepts the WebSocket upgrade handshake so the frontend's real decoder-connect path
// (src/decoder_runtime.js) reaches `connected: true` without a physical AMMC decoder.
// No frames are sent or parsed: this scenario only needs an established socket, not lap data.
async function startFakeDecoder() {
const port = await freePort();
const server = createServer();
// The upgraded sockets are intentionally left open (no decoder frames needed for this
// scenario), so server.close() alone would wait forever for them to end; track and
// destroy them explicitly instead. closeAllConnections() does not reach upgraded sockets.
const sockets = new Set();
server.on("upgrade", (req, socket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
const key = req.headers["sec-websocket-key"];
if (!key) { socket.destroy(); return; }
const accept = createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64");
socket.write(["HTTP/1.1 101 Switching Protocols", "Upgrade: websocket", "Connection: Upgrade", `Sec-WebSocket-Accept: ${accept}`, "", ""].join("\r\n"));
});
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, "127.0.0.1", resolve); });
return {
port,
close: () => new Promise(resolve => {
for (const socket of sockets) socket.destroy();
server.close(() => resolve());
}),
};
}
export async function runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs = [] }) {
const click = id => evaluate(`document.getElementById(${JSON.stringify(id)}).click()`);
const submit = id => evaluate(`document.getElementById(${JSON.stringify(id)}).requestSubmit()`);
const findEvent = async name => (await savedState()).events.find(event => event.name === name);
const findSession = async name => (await savedState()).sessions.find(session => session.name === name);
const patchState = async patch => {
const current = await savedState();
Object.assign(current, patch);
const response = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current) });
assert.equal(response.ok, true);
};
const buttonDisabled = async id => await evaluate(`document.getElementById(${JSON.stringify(id)})?.disabled`);
const decoder = await startFakeDecoder();
try {
// Reliability tests must not depend on real AMMC hardware; a socket that only completes
// the WebSocket handshake is enough to satisfy Race Day's decoder-connected preflight check.
const seeded = await savedState();
await patchState({ settings: { ...seeded.settings, autoReconnect: true, wsUrl: `ws://127.0.0.1:${decoder.port}` } });
await navigate("events", "eventForm");
// Decoder connection state is deliberately excluded from buildPersistableState() in
// src/app.js (it's per-tab, not shared/persisted), so check the live connection badge
// in the page instead of polling backend state.
await waitFor(async () => await evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "fake decoder connected");
await fill("eventForm", { name: "Race Day Event", date: "2026-09-08", classId: "test_class" });
await submit("eventForm");
await waitFor(async () => Boolean(await findEvent("Race Day Event")), "create race day event");
const event = await findEvent("Race Day Event");
await click("event-manage-" + event.id);
await fill("sessionForm", { name: "Race Day Heat 1", type: "open_practice", durationMin: "5" });
await submit("sessionForm");
await waitFor(async () => Boolean(await findSession("Race Day Heat 1")), "create heat 1");
await fill("sessionForm", { name: "Race Day Heat 2", type: "open_practice", durationMin: "5" });
await submit("sessionForm");
await waitFor(async () => Boolean(await findSession("Race Day Heat 2")), "create heat 2");
const heat1 = await findSession("Race Day Heat 1");
const heat2 = await findSession("Race Day Heat 2");
// Pin the active session explicitly: other scenarios may have left earlier "ready" sessions
// that would otherwise win the Race Day queue's global fallback lookup.
await patchState({ activeSessionId: heat1.id });
await navigate("race_day", "raceDayStart");
await waitFor(async () => (await buttonDisabled("raceDayStart")) === false, "start enabled once preflight is ready");
assert.equal(await buttonDisabled("raceDayPause"), true);
assert.equal(await buttonDisabled("raceDayFinish"), true);
await click("raceDayStart");
await waitFor(async () => (await findSession("Race Day Heat 1")).status === "running", "heat 1 running");
const started = await findSession("Race Day Heat 1");
assert.ok(started.startedAt, "startedAt recorded");
// normalizeSession() in src/app.js stores "not paused" as 0, not null (Number(null) === 0
// passes its Number.isFinite check) - every real usage treats pausedAt as falsy, so match that.
assert.ok(!started.pausedAt, "not paused after start");
await waitFor(async () => (await buttonDisabled("raceDayPause")) === false, "pause enabled after start");
assert.equal(await buttonDisabled("raceDayFinish"), false);
assert.equal(await buttonDisabled("raceDayResume"), true);
await click("raceDayPause");
await waitFor(async () => (await findSession("Race Day Heat 1")).status === "paused", "heat 1 paused");
assert.ok((await findSession("Race Day Heat 1")).pausedAt, "pausedAt recorded");
await waitFor(async () => (await buttonDisabled("raceDayResume")) === false, "resume enabled after pause");
assert.equal(await buttonDisabled("raceDayPause"), true);
await pause(1200);
await click("raceDayResume");
await waitFor(async () => (await findSession("Race Day Heat 1")).status === "running", "heat 1 resumed");
const resumed = await findSession("Race Day Heat 1");
assert.ok(!resumed.pausedAt, "not paused after resume");
assert.equal(resumed.startedAt, started.startedAt, "resume does not reset the original start time");
assert.ok(resumed.totalPausedMs >= 1000, "paused duration accumulated in totalPausedMs");
await waitFor(async () => (await buttonDisabled("raceDayPause")) === false, "pause enabled after resume");
await click("raceDayFinish");
await waitFor(async () => (await findSession("Race Day Heat 1")).status === "finished", "heat 1 finished");
const finished = await findSession("Race Day Heat 1");
assert.ok(finished.endedAt, "endedAt recorded");
assert.ok(!finished.pausedAt, "not paused after finish");
console.log("PASS race day heat 1 start/pause/resume/finish preserves session timing fields");
await waitFor(async () => await evaluate(`Boolean(document.querySelector('[data-race-day-select="${heat2.id}"]'))`), "heat 2 listed as next in queue");
await evaluate(`document.querySelector('[data-race-day-select="${heat2.id}"]').click()`);
await waitFor(async () => (await savedState()).activeSessionId === heat2.id, "heat 2 selected as active session");
await waitFor(async () => (await evaluate('document.querySelector(".race-day-hero h2")?.textContent')) === "Race Day Heat 2", "heat 2 shown as current");
assert.equal(await buttonDisabled("raceDayStart"), false, "next heat is startable");
await click("raceDayStart");
await waitFor(async () => (await findSession("Race Day Heat 2")).status === "running", "heat 2 running");
await click("raceDayFinish");
await waitFor(async () => (await findSession("Race Day Heat 2")).status === "finished", "heat 2 finished");
const heat1After = await findSession("Race Day Heat 1");
assert.equal(heat1After.status, "finished");
assert.equal(heat1After.startedAt, started.startedAt, "heat 1 timing untouched by heat 2's lifecycle");
assert.ok(heat1After.totalPausedMs >= 1000);
console.log("PASS switching to next heat keeps the previous heat's recorded timing intact");
// A native alert()/confirm() anywhere in this flow (e.g. a start validation surprise)
// would otherwise pass silently: it freezes the page rather than failing an assertion.
assert.deepEqual(dialogs, [], "no unexpected native dialog during the race day flow");
} finally {
// Leave autoReconnect off again so later scenarios in the shared harness don't retry
// against a decoder socket that no longer exists.
const cleanup = await savedState();
await patchState({ settings: { ...cleanup.settings, autoReconnect: false } });
await decoder.close();
}
}

View File

@@ -1,5 +1,6 @@
import { runOverlaySmoke } from "./browser_overlays.js";
import { runRaceSetupSmoke } from "./browser_race_setup.js";
import { runRaceDaySmoke } from "./browser_race_day.js";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
@@ -42,7 +43,7 @@ try {
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 }, training: {} };
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"]);
@@ -53,6 +54,7 @@ try {
let nextId = 0;
const requests = new Map();
const errors = [];
const dialogs = [];
socket.addEventListener("message", event => {
const message = JSON.parse(event.data);
if (message.id) {
@@ -60,6 +62,13 @@ try {
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 })); });
@@ -108,6 +117,7 @@ try {
console.log("PASS create/edit/reload and draft retention: " + collection);
}
await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause });
await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs });
await navigate("classes", "classForm");
await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
await fill("classForm", { name: "Offline Draft" });
@@ -150,6 +160,7 @@ try {
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();