fix obs popup issues

This commit is contained in:
larssand
2026-09-09 07:52:07 +02:00
parent acab99ed4d
commit 09524944dd
7 changed files with 214 additions and 42 deletions

142
tests/browser_obs_popup.js Normal file
View 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();
}
}