fix obs popup issues
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
12
src/app.js
12
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"));
|
||||
|
||||
40
tests/browser_fake_decoder.js
Normal file
40
tests/browser_fake_decoder.js
Normal file
@@ -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());
|
||||
}),
|
||||
};
|
||||
}
|
||||
142
tests/browser_obs_popup.js
Normal file
142
tests/browser_obs_popup.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()`);
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user