const { chromium } = require("playwright"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); require("./control-room-register.cjs"); const { fixtureChannel } = require("../src/components/control-room/fixtures.ts"); const web = path.resolve(__dirname, ".."); const origin = "http://audit.local"; const workspaceId = "50000000-0000-4000-8000-000000000001"; const mime = { ".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".html": "text/html", ".rsc": "text/x-component" }; const results = []; const cors = { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Headers": "Authorization,Content-Type,Idempotency-Key", "Access-Control-Allow-Methods": "GET,POST,OPTIONS" }; const json = (route, body, status = 200) => route.fulfill({ status, headers: cors, json: body }); async function auditViewport(browser, viewport) { const context = await browser.newContext({ viewport, isMobile: viewport.width < 1024, hasTouch: viewport.width < 1024 }); const page = await context.newPage(); const errors = []; page.on("pageerror", error => errors.push(error.message)); let channel = fixtureChannel(), mode = "live", seq = 0, socket; const requests = [], keys = new Set(); const observation = version => ({ workspaceId, channelId: channel.channelId, workerId: channel.workerId, assignmentEpoch: channel.assignmentEpoch, state: "RUNNING", realizedVersion: version, updatedAt: new Date().toISOString(), reason: "Audit worker" }); channel.observed = observation(1); const inject = (kind, data) => socket.send(JSON.stringify({ type: "state", workspaceId, channelId: channel.channelId, seq: ++seq, kind, data })); await context.addInitScript(() => localStorage.setItem("tmbh.session", "audit-token")); await page.routeWebSocket(/\/realtime$/, ws => { socket = ws; ws.onMessage(raw => { const message = JSON.parse(String(raw)); if (message.type === "auth") { assert.equal(message.token, "audit-token"); assert.equal(message.workspaceId, workspaceId); ws.send(JSON.stringify({ type: "auth_ack", workspaceId })); } if (message.type === "subscribe") { ws.send(JSON.stringify({ type: "subscribe_ack", channelId: channel.channelId, seq })); inject("desired", channel.desired); inject("observed", channel.observed); } }); }); await page.route("**/*", async route => { const request = route.request(), url = new URL(request.url()); if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors }); if (url.pathname === "/auth/me") return json(route, { user: { id: workspaceId, email: "operator@audit.local", displayName: "Operator" }, workspace: { id: workspaceId, name: "Audit studio", slug: "audit" }, role: "owner", permissions: ["director.take", "director.view", "director.lock_program", "director.override_auto", "director.change_layout", "media.view", "stream.view"], expiresAt: Date.now() + 3600000 }); if (url.pathname === "/media-control/status") { if (mode === "offline") return route.abort(); if (mode === "forbidden") return json(route, { message: "Missing media.view" }, 403); return json(route, { channels: mode === "empty" ? [] : [channel], workers: [] }); } const action = /\/media-control\/channels\/[^/]+\/(pst-select|cut|take|lock|override)$/.exec(url.pathname)?.[1]; if (action && request.method() === "POST" || url.pathname.startsWith("/production/overlays/") && request.method() === "POST") { const key = request.headers()["idempotency-key"]; assert.match(key, /^[\x20-\x7e]{1,128}$/); assert.match(key, /^[0-9a-f-]{36}$/); assert.ok(!keys.has(key)); keys.add(key); const body = request.postDataJSON(); requests.push({ action: action ?? "overlay", body, key }); if (action === "pst-select") channel.desired.production.buses.preview = body.sceneId; if (action === "cut" || action === "take") channel.desired.production.buses.program = channel.desired.production.buses.preview; if (action === "lock") channel.desired.director.locked = body.enabled; if (action === "override") channel.desired.director.override = body.mode ? { mode: body.mode, expiresAt: null } : null; if (!action) channel.desired.production.overlays.find(item => url.pathname.endsWith(item.id)).revisionId = "60000000-0000-4000-8000-000000000001"; channel.desired.desiredVersion += 1; await json(route, { desiredVersion: channel.desired.desiredVersion, commandId: key }); inject("desired", channel.desired); return; } if (url.pathname.includes("/preview/")) return json(route, { message: "No audit media" }, 404); if (url.hostname !== "audit.local") return route.abort(); let file; if (url.pathname.startsWith("/_next/")) file = path.join(web, ".next", url.pathname.slice(7)); else if (url.pathname.startsWith("/brand/")) file = path.join(web, "public", url.pathname); else { const name = url.pathname === "/" ? "index" : url.pathname.slice(1); const rsc = request.headers().rsc === "1" || url.searchParams.has("_rsc"); file = path.join(web, ".next/server/app", `${name}.${rsc ? "rsc" : "html"}`); } if (!fs.existsSync(file)) return route.fulfill({ status: 404, body: "Not found" }); return route.fulfill({ contentType: mime[path.extname(file)] || "application/octet-stream", body: fs.readFileSync(file) }); }); const first = channel.desired.production.sources[0].id, second = channel.desired.production.sources[1].id; const sceneB = channel.desired.production.scenes[1].id; const tally = id => page.locator(`[data-source='${id}']`).getAttribute("data-tally"); const waitTally = (id, value) => page.waitForFunction(({ id, value }) => document.querySelector(`[data-source='${id}']`)?.getAttribute("data-tally") === value, { id, value }); const checkLayout = async stage => { const layout = await page.evaluate(() => { const root = document.querySelector(".control-room"); const undersized = Array.from(root.querySelectorAll("button, input, select, textarea, a[href]")).filter(el => { const r = el.getBoundingClientRect(); return r.width && r.height && (r.width < 43.5 || r.height < 43.5); }).map(el => ({ text: el.getAttribute("aria-label") || el.textContent.trim().slice(0, 50), width: el.getBoundingClientRect().width, height: el.getBoundingClientRect().height })); return { width: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, rootWidth: root.clientWidth, rootScrollWidth: root.scrollWidth, undersized, liveDecodes: document.querySelectorAll("[data-live-decode]").length }; }); assert.ok(layout.scrollWidth <= layout.width, `${stage}: document overflow`); assert.ok(layout.rootScrollWidth <= layout.rootWidth, `${stage}: control room overflow`); assert.deepEqual(layout.undersized, [], `${stage}: tap targets`); assert.ok(layout.liveDecodes <= (viewport.width < 1024 ? 1 : 4)); results.push({ viewport, stage, ...layout }); }; await page.goto(`${origin}/control-room`, { waitUntil: "networkidle" }); await waitTally(first, "program"); await checkLayout("initial"); await page.getByRole("combobox", { name: "PST scene", exact: true }).selectOption(sceneB); await page.getByRole("status").filter({ hasText: "Request accepted" }).waitFor(); assert.equal(await tally(first), "program"); assert.equal(await tally(second), "none"); inject("observed", observation(channel.desired.desiredVersion)); await waitTally(second, "preview"); await page.getByRole("button", { name: "CUT", exact: true }).click(); await page.waitForFunction(() => document.querySelector("[role='status']")?.textContent.includes("version 3")); assert.equal(await tally(first), "program"); assert.equal(await tally(second), "preview"); const injectedAt = Date.now(); inject("observed", observation(3)); await waitTally(second, "program"); assert.equal(await tally(first), "none"); const observedToTallyMs = Date.now() - injectedAt; assert.ok(observedToTallyMs < 300, "observed frame to tally must take under 300 ms"); await page.getByRole("button", { name: "TAKE", exact: true }).click(); await page.getByRole("button", { name: "Lock program", exact: true }).click(); await page.getByRole("button", { name: "Unlock program", exact: true }).waitFor(); await page.getByRole("combobox", { name: "Operator override" }).selectOption("five_minutes"); await page.getByRole("combobox", { name: "Operator override" }).selectOption("auto"); for (const template of ["lower_third", "chyron", "logo"]) { const slot = channel.desired.production.overlays.find(item => item.kind === template); await page.getByRole("combobox", { name: "Overlay slot" }).selectOption(slot.id); if (template === "logo") await page.getByRole("textbox", { name: "Logo SVG" }).fill(''); else await page.getByRole("textbox", { name: template === "chyron" ? /Chyron text/ : /Title/ }).fill("Audit title"); await page.getByRole("button", { name: "Render overlay" }).click(); await page.getByRole("status").filter({ hasText: "Render accepted" }).waitFor(); assert.equal(requests.at(-1).body.templateId, template); } for (const tier of ["2x1", "2x2", "3x3", "4x4", "custom"]) { await page.getByRole("combobox", { name: "Multiview layout" }).selectOption(tier); await checkLayout(tier); } if (viewport.width >= 1024) { await page.getByRole("combobox", { name: "Multiview layout" }).selectOption("4x4"); await page.getByRole("button", { name: "Focus Sample source 1", exact: true }).click(); const upgrades = page.getByRole("button", { name: "Upgrade live", exact: true }); await upgrades.nth(0).click(); await upgrades.nth(0).click(); assert.ok(await upgrades.nth(0).isDisabled(), "5th decode must be refused"); } const resyncBefore = requests.length; socket.send(JSON.stringify({ type: "RESYNC_REQUIRED", channelId: channel.channelId })); await page.waitForTimeout(100); assert.equal(requests.length, resyncBefore); mode = "empty"; await page.getByRole("button", { name: "Reconnect", exact: true }).click(); await page.getByRole("heading", { name: "No production scenes configured" }).waitFor(); assert.equal(await page.locator("[data-tally='program']").count(), 0); assert.equal(await page.getByText("FIXTURE · sample data", { exact: true }).count(), 0); await checkLayout("empty"); mode = "forbidden"; await page.getByRole("button", { name: "Reconnect", exact: true }).click(); await page.getByRole("status").filter({ hasText: "Missing media.view" }).waitFor(); assert.equal(await page.getByText("FIXTURE · sample data", { exact: true }).count(), 0); mode = "offline"; await page.getByRole("button", { name: "Reconnect", exact: true }).click(); await page.getByText("FIXTURE · sample data", { exact: true }).waitFor(); assert.equal(await page.locator("[data-tally='program']").count(), 0); const before = requests.length; await page.getByRole("button", { name: "CUT", exact: true }).click(); assert.equal(requests.length, before); assert.equal(await page.locator("[data-tally='program']").count(), 0); await checkLayout("offline-fixture"); assert.deepEqual(errors, [], "browser runtime errors"); results.push({ viewport, flow: "PST→observed preview→CUT→observed program", actions: requests.map(item => item.action), uniqueKeys: keys.size, observedToTallyMs, runtimeErrors: errors }); await context.close(); } async function run() { const browser = await chromium.launch({ headless: true }); try { for (const viewport of [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) await auditViewport(browser, viewport); } finally { await browser.close(); } console.log(JSON.stringify(results, null, 2)); } run().catch(error => { console.error(error); process.exitCode = 1; });