From 09524944dde571d121999daa0fc34b455f4f75cb Mon Sep 17 00:00:00 2001 From: larssand Date: Wed, 9 Sep 2026 07:52:07 +0200 Subject: [PATCH] fix obs popup issues --- CHANGELOG.md | 2 + ROADMAP.md | 4 +- src/app.js | 12 ++- tests/browser_fake_decoder.js | 40 ++++++++++ tests/browser_obs_popup.js | 142 ++++++++++++++++++++++++++++++++++ tests/browser_race_day.js | 40 +--------- tests/browser_smoke.js | 16 ++++ 7 files changed, 214 insertions(+), 42 deletions(-) create mode 100644 tests/browser_fake_decoder.js create mode 100644 tests/browser_obs_popup.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 90c2663..adc0847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,13 @@ The application version is maintained in `package.json` and displayed in the adm - Personal mobile profiles show last successful data retrieval, stale-data and failed-update warnings, and a Retry button without replacing loaded statistics or active forms. Requests time out after 15 seconds and cannot overlap. - Browser coverage for Race Day start/pause/resume/finish and switching to the next queued heat, against a simulated decoder socket. +- Browser coverage for opening the OBS overlay as a real popup window (not a direct URL load): compact layout, and live clock updates that freeze on pause, polling independently of the admin page. ### Fixed - Open Practice sessions on a sponsor (track-mode) event could show a ready/green Race Day preflight yet refuse to start, blocked by a stricter assignment check the preflight never warned about. Open Practice is now exempt from that check everywhere, matching the preflight. - The Race Day view did not refresh when the decoder connected, disconnected, or errored, so its Start button and preflight checklist could stay stale after a page load until something else triggered a re-render. +- Printing or exporting start lists, results, heat sheets and team reports always showed a false "popup blocked" error and produced nothing, in every modern browser. Opening the OBS overlay as a popup had the same false "blocked" check and its window never received focus. Both used `window.open(..., "noopener,noreferrer,...")`, which returns `null` even on success by current browser standards; both now open without those flags. ## 1.1.2 - 2026-09-08 diff --git a/ROADMAP.md b/ROADMAP.md index 8989353..3e0e502 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,9 +27,11 @@ Acceptance: real create/edit/save flows persist after reload; background updates - [ ] Cover session-type-specific start sequences (heat/qualification/race/team_race countdown and audio) and other transitions beyond Open Practice. - [x] Verify leaderboard/TV/team sorting heading alignment at 390/900/1600 px and preserved race positions. - [x] Verify OBS views have no interactive sorting controls. -- [ ] Cover actual popup launching and compact OBS layout automatically. +- [x] Cover actual popup launching and compact OBS layout automatically. - [ ] Add Windows and Linux CI runs for supported runtimes. +Verified 2026-09-09: the OBS overlay popup opens via a real click on the admin "Open OBS overlay" button (`window.open`, not a direct URL navigation), renders the compact race-info/standings split layout, and its clock advances live and freezes on pause without a manual reload - polling independently of the admin page (`tests/browser_obs_popup.js`). Found and fixed a real bug in the process: `window.open(..., "noopener,noreferrer,...")` always returns `null` in modern browsers even when the window opens successfully (WHATWG spec), which made the blocked-popup check in `openPrintWindow()` and `openOverlayWindow()` always fire - print/export (start lists, results, heat sheets, team reports) showed a false "popup blocked" error and never wrote any content, and the overlay popup's `.focus()` call was unreachable. Both now open without those flags (same-origin, first-party content, so no security loss). + Verified 2026-09-08: Race Day start/pause/resume/finish and next-heat selection pass end to end (`tests/browser_race_day.js`) against a simulated decoder socket, with native-dialog auto-accept added to the browser harness so an unexpected `alert()`/`confirm()` fails the test instead of hanging it. Found and fixed two real bugs in the process: Open Practice sessions on a track-mode (sponsor) event showed a ready/green preflight but were silently blocked from starting by a stricter assignment check; and the Race Day view never refreshed when the decoder connected, disconnected, or errored, so its Start button and preflight checklist could stay stale after a page load. ## 2. Connection and save status diff --git a/src/app.js b/src/app.js index fcd6759..98fb99e 100644 --- a/src/app.js +++ b/src/app.js @@ -4888,7 +4888,11 @@ function clearGeneratedFinals(eventId) { } function openPrintWindow(title, bodyHtml) { - const printWindow = window.open("", "_blank", "noopener,noreferrer,width=1200,height=900"); + // noopener/noreferrer force window.open() to return null even when the window opens + // successfully (WHATWG spec, all modern browsers), which made the blocked-popup check + // below always fire and this always exit before writing any content. The opened window + // only ever holds first-party generated HTML, so dropping both flags is safe here. + const printWindow = window.open("", "_blank", "width=1200,height=900"); if (!printWindow) { alert(t("error.print_blocked")); return; @@ -5309,10 +5313,14 @@ function buildOverlayUrl(mode = "leaderboard", options = {}) { function openOverlayWindow(mode = "leaderboard", options = {}) { const width = Math.max(1280, window.screen?.availWidth || 1600); const height = Math.max(720, window.screen?.availHeight || 900); + // noopener/noreferrer force window.open() to return null even when the window opens + // successfully (WHATWG spec, all modern browsers), which made the blocked-popup check + // below always fire and overlayWindow.focus() below unreachable. The overlay URL is + // always same-origin (see buildOverlayUrl), so dropping both flags is safe here. const overlayWindow = window.open( buildOverlayUrl(mode, options), "_blank", - `noopener,noreferrer,popup=yes,left=0,top=0,width=${width},height=${height}` + `popup=yes,left=0,top=0,width=${width},height=${height}` ); if (!overlayWindow) { alert(t("error.print_blocked")); diff --git a/tests/browser_fake_decoder.js b/tests/browser_fake_decoder.js new file mode 100644 index 0000000..f0b84c6 --- /dev/null +++ b/tests/browser_fake_decoder.js @@ -0,0 +1,40 @@ +import { createHash } from "node:crypto"; +import { createServer } from "node:http"; +import { createServer as createNetServer } from "node:net"; + +export 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: scenarios using this only need an established socket, +// not lap data. +export async function startFakeDecoder() { + const port = await freePort(); + const server = createServer(); + // The upgraded sockets are intentionally left open (no decoder frames needed), so a + // plain server.close() 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()); + }), + }; +} diff --git a/tests/browser_obs_popup.js b/tests/browser_obs_popup.js new file mode 100644 index 0000000..7f0af38 --- /dev/null +++ b/tests/browser_obs_popup.js @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; +import { startFakeDecoder } from "./browser_fake_decoder.js"; + +const localPause = ms => new Promise(resolve => setTimeout(resolve, ms)); + +// Attaches an independent CDP connection to one browser page target. Needed because the +// popup opened by window.open() is a second, separate page target with its own JS/DOM, +// distinct from the admin page the harness is already attached to. +async function attach(target) { + const 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 || result.exceptionDetails.text); + return result.result.value; + } + async function waitFor(check, label, timeout = 15000) { + const until = Date.now() + timeout; + while (Date.now() < until) { + try { if (await check()) return; } catch {} + await localPause(100); + } + throw new Error("Timed out: " + label); + } + return { evaluate, waitFor, close: () => socket.close() }; +} + +export async function runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs = [], realClick }) { + 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 listPageTargets = async () => (await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json()).filter(page => page.type === "page"); + + const decoder = await startFakeDecoder(); + let popup = null; + const dialogsBefore = dialogs.length; + try { + const seeded = await savedState(); + await patchState({ settings: { ...seeded.settings, autoReconnect: true, wsUrl: `ws://127.0.0.1:${decoder.port}` } }); + + await navigate("events", "eventForm"); + await waitFor(async () => await evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "fake decoder connected"); + await fill("eventForm", { name: "OBS Popup Event", date: "2026-09-09", classId: "test_class" }); + await submit("eventForm"); + await waitFor(async () => Boolean(await findEvent("OBS Popup Event")), "create obs popup event"); + const event = await findEvent("OBS Popup Event"); + await click("event-manage-" + event.id); + + await fill("sessionForm", { name: "OBS Popup Heat", type: "open_practice", durationMin: "5" }); + await submit("sessionForm"); + await waitFor(async () => Boolean(await findSession("OBS Popup Heat")), "create obs popup heat"); + const heat = await findSession("OBS Popup Heat"); + await patchState({ activeSessionId: heat.id }); + + await navigate("race_day", "raceDayStart"); + await waitFor(async () => (await evaluate('document.getElementById("raceDayStart")?.disabled')) === false, "start enabled once preflight is ready"); + await click("raceDayStart"); + await waitFor(async () => (await findSession("OBS Popup Heat")).status === "running", "heat running"); + + // Real popup opening: drive the actual admin "Open OBS overlay" button (window.open), + // not a direct URL navigation, so a regression in that path - such as window.open + // always returning null with noopener/noreferrer, which was true until this fix - + // is caught here instead of only in view-rendering unit tests. + // + // Reach the admin Overlay page via the sidebar nav (client-side routing), not a full + // reload to ?view=overlay: app.js computes its overlayMode flag once from the URL at + // initial load, so a fresh Page.navigate there is indistinguishable from opening a + // standalone overlay window and renders the bare overlay content instead of the + // launch-buttons panel - exactly what a full reload would never do for a real operator + // clicking through the nav from an already-loaded session. + await evaluate(`[...document.querySelectorAll(".nav-item")].find(el => el.textContent === "Overlay")?.click()`); + await waitFor(async () => await evaluate('Boolean(document.getElementById("overlayLaunchObs"))'), "admin overlay page shows the launch buttons"); + const before = await listPageTargets(); + // A synthetic .click() is not a trusted gesture, so Chrome's popup blocker would + // silently swallow the window.open() this button triggers; dispatch a real click. + await realClick("#overlayLaunchObs"); + let target; + await waitFor(async () => { + const list = await listPageTargets(); + target = list.find(page => page.url.includes("/public-overlay/obs") && !before.some(prior => prior.id === page.id)); + return Boolean(target); + }, "obs popup window opens as a real browser target"); + + popup = await attach(target); + await popup.waitFor(async () => await popup.evaluate('document.querySelector(".overlay-shell-obs") !== null'), "obs popup renders the OBS shell"); + + // Compact OBS layout: the race-info/standings split, not the full admin dashboard. + assert.ok(await popup.evaluate('document.querySelector(".overlay-shell-obs-layout-leaderboard") !== null'), "obs popup uses the leaderboard layout shell"); + assert.ok(await popup.evaluate('document.querySelector(".overlay-obs-tower") !== null'), "obs popup renders the compact tower panels"); + assert.equal(await popup.evaluate('document.querySelector(".overlay-obs-tower-brand h2")?.textContent'), "OBS Popup Heat", "popup shows the live session name"); + + // Live update: the popup polls the backend independently (overlay sync every 800ms, + // dynamic refresh every 250ms - see startOverlaySyncHelper/startOverlayLiveRefreshHelper + // in src/runtime_services.js) and must reflect state changes without a manual reload. + const clockAt = () => popup.evaluate('document.querySelector("[data-overlay-clock]")?.textContent'); + const firstClock = await clockAt(); + assert.ok(firstClock, "popup shows a running clock"); + await pause(2500); + const secondClock = await clockAt(); + assert.notEqual(secondClock, firstClock, "popup clock advances live without a manual reload"); + + // Back on the admin side, the Race Day view was replaced by the Overlay nav view + // above, so raceDayPause no longer exists in the DOM until we return to it. + await navigate("race_day", "raceDayPause"); + await click("raceDayPause"); + await waitFor(async () => (await findSession("OBS Popup Heat")).status === "paused", "heat paused in main window"); + await popup.waitFor(async () => { + const clock = await clockAt(); + await localPause(1000); + return clock === (await clockAt()); + }, "popup clock stops advancing once the session is paused"); + + assert.equal(dialogs.length, dialogsBefore, "no unexpected native dialog while opening or reading the obs popup"); + console.log("PASS obs overlay opens as a real popup with the compact layout and live-updates independently of the admin page"); + } finally { + await popup?.evaluate("window.close()").catch(() => {}); + popup?.close(); + const cleanup = await savedState(); + await patchState({ settings: { ...cleanup.settings, autoReconnect: false } }); + await decoder.close(); + } +} diff --git a/tests/browser_race_day.js b/tests/browser_race_day.js index ebaa3da..eab2b67 100644 --- a/tests/browser_race_day.js +++ b/tests/browser_race_day.js @@ -1,43 +1,5 @@ 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()); - }), - }; -} +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()`); diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 6d07fd9..556b8b0 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -1,6 +1,7 @@ import { runOverlaySmoke } from "./browser_overlays.js"; import { runRaceSetupSmoke } from "./browser_race_setup.js"; import { runRaceDaySmoke } from "./browser_race_day.js"; +import { runObsPopupSmoke } from "./browser_obs_popup.js"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; @@ -78,6 +79,20 @@ try { 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"); @@ -118,6 +133,7 @@ try { } await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause }); await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs }); + await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick }); await navigate("classes", "classForm"); await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); await fill("classForm", { name: "Offline Draft" });