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(); } }