import assert from "node:assert/strict"; import { startFakeDecoder } from "./browser_fake_decoder.js"; 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(); } }