// Real-browser display-device scenarios for scripts/smoke-deploy.sh in the split topology (ADR 0036, // ADR 0045). Runs inside a disposable container on the deployment's `edge` network and drives two // headless Chromiums over the Chrome DevTools Protocol, with Node.js's own fetch and WebSocket and no // other dependency: the smoke run's browser, where the owner signs in, and a "kiosk" browser whose // profile directory is a Docker volume that outlives the kiosk container. // // node deploy-smoke-display.mjs // // pair seed content, pair the kiosk, then close its browser cleanly // restart same profile, new browser process: still paired and live? // two-pages two pages, one device credential: does either flap? // connectivity-blip the browser reports offline then online: is a live display remounted? // session-blip Signage's first viewer-session request fails once: does the display recover? // stream-failure the first realtime connection is refused: is realtime re-established? // caching-probe can a display on the API origin store storage-origin images in Cache Storage? // stream-recycle one page past the five-minute realtime recycle, with a timestamped timeline // revoke revoke the paired kiosk while its display is open, then pair it again // // Every DevTools command and every page-side request has its own deadline, and a step has an overall // budget. A call that never answers fails the step with a line naming that exact call, because a call // that can hang the driver may be a request that can hang a real display. // // Output is one line per check or observation; nothing printed carries a credential, cookie value, // signed URL, query string, or response body. function required(name) { const value = process.env[name]; if (!value) { throw new Error(`${name} is required.`); } return value; } /** A positive whole number of milliseconds from the environment, or the default. */ function milliseconds(name, fallback) { const raw = process.env[name]; const value = raw === undefined || raw === "" ? fallback : Number(raw); if (!Number.isSafeInteger(value) || value <= 0) { throw new Error(`${name} must be a positive whole number of milliseconds.`); } return value; } const mode = process.argv[2]; const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`; const dashboardOrigin = `https://${required("SMOKE_DASHBOARD_HOSTNAME")}`; const ownerAddress = required("SMOKE_CDP_ADDRESS"); const kioskAddress = required("SMOKE_KIOSK_CDP_ADDRESS"); const watchMs = milliseconds("SMOKE_WATCH_MS", 120_000); const recycleWatchMs = milliseconds("SMOKE_RECYCLE_WATCH_MS", 330_000); const stepBudgetMs = milliseconds("SMOKE_STEP_BUDGET_MS", 180_000); /** How long one DevTools command may go unanswered. */ const callTimeoutMs = milliseconds("SMOKE_CALL_TIMEOUT_MS", 10_000); /** How long one page-side HTTP request may go unanswered; shorter than the call deadline, so a hung * request is reported as the request rather than as the evaluation that awaited it. */ const requestTimeoutMs = Math.max(1_000, callTimeoutMs - 2_000); const waitTimeoutMs = 30_000; const deviceCookie = "ekklesia_device_session"; /** Far above what three cached slides need, which is one object URL each. */ const MAX_LIVE_OBJECT_URLS = 50; const stepStartedAt = Date.now(); let failures = 0; function check(condition, description) { console.log(`${condition ? "ok " : "FAIL"} ${description}`); if (!condition) { failures += 1; } } function note(description) { console.log(` ${description}`); } const clock = (ms = Date.now()) => new Date(ms).toISOString().slice(11, 19); const since = (from) => `+${Math.round((Date.now() - from) / 1000)}s`; function progress(description) { console.log(`···· ${clock()} ${since(stepStartedAt)} ${description}`); } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** A URL's path only: a signed delivery URL's query is a credential. */ const pathOf = (url) => { try { return new URL(url).pathname; } catch { return "(unparseable)"; } }; // --- Hang guards --------------------------------------------------------------------------------- class HangError extends Error { constructor(label, ms) { super(`HANG ${label}: no answer within ${ms / 1000}s`); this.name = "HangError"; } } /** Everything awaited right now, by name, so a step that overruns its budget says what it was * waiting for. */ const inFlight = new Map(); let nextOperation = 1; function guard(label, promise, ms = callTimeoutMs) { const id = nextOperation; nextOperation += 1; inFlight.set(id, { at: Date.now(), label }); let timer; return Promise.race([ promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new HangError(label, ms)), ms); }), ]).finally(() => { clearTimeout(timer); inFlight.delete(id); }); } function describeInFlight() { const pending = [...inFlight.values()].map( (operation) => `${operation.label} (${Math.round((Date.now() - operation.at) / 1000)}s)`, ); return pending.length === 0 ? "nothing — the step itself overran" : pending.join("; "); } setTimeout(() => { console.log( `FAIL ${mode}: step budget of ${stepBudgetMs / 1000}s exceeded; in flight: ${describeInFlight()}`, ); process.exit(2); }, stepBudgetMs).unref(); // --- DevTools ------------------------------------------------------------------------------------ /** The same minimal DevTools client as deploy-smoke-browser.mjs, with a deadline on every command. */ async function connect(devtools, name) { const deadline = Date.now() + 30_000; let version; while (version === undefined) { try { const response = await fetch(`http://${devtools}/json/version`, { signal: AbortSignal.timeout(2_000), }); version = await response.json(); } catch { if (Date.now() > deadline) { throw new HangError(`the ${name} browser's DevTools endpoint`, 30_000); } await sleep(500); } } const socketUrl = version.webSocketDebuggerUrl.replace(/^ws:\/\/[^/]+/u, `ws://${devtools}`); const socket = new WebSocket(socketUrl); await guard( `the ${name} browser's DevTools socket`, new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", () => reject(new Error("DevTools connection failed.")), { once: true, }); }), ); let nextId = 1; const pending = new Map(); const listeners = []; socket.addEventListener("message", (event) => { const message = JSON.parse(String(event.data)); if (message.id !== undefined) { const waiter = pending.get(message.id); pending.delete(message.id); if (message.error) { waiter?.reject(new Error(`DevTools ${waiter.method} failed.`)); } else { waiter?.resolve(message.result); } return; } for (const listener of listeners) { listener(message); } }); return { close: () => socket.close(), on: (listener) => listeners.push(listener), send(method, params = {}, sessionId = undefined, where = name) { const id = nextId; nextId += 1; socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); return guard( `DevTools ${method} on ${where}`, new Promise((resolve, reject) => pending.set(id, { method, reject, resolve })), ); }, }; } /** Finds a test id anywhere in the document, including inside module artifacts' open shadow roots * (ADR 0040). */ const deepFind = `((testId) => { const walk = (root) => { const found = root.querySelector('[data-testid="' + testId + '"]'); if (found) return found; for (const element of root.querySelectorAll("*")) { if (element.shadowRoot) { const inner = walk(element.shadowRoot); if (inner) return inner; } } return null; }; return walk(document); })`; const find = (testId) => `${deepFind}(${JSON.stringify(testId)})`; /** Installed before any page script: the browser's own connectivity events and the display service * worker's fallback reports, which are what the display host remounts on (ADR 0043). */ const instrumentation = `(() => { const events = []; // Object URLs created and revoked in this document. Each live one pins its blob in the browser's // blob storage until it is revoked or the document goes away. const blobs = { created: 0, revoked: 0 }; Object.defineProperty(window, "__displayRepro", { value: { blobs, events } }); const create = URL.createObjectURL.bind(URL); const revoke = URL.revokeObjectURL.bind(URL); URL.createObjectURL = (object) => { blobs.created += 1; return create(object); }; URL.revokeObjectURL = (url) => { blobs.revoked += 1; return revoke(url); }; const record = (kind) => events.push({ at: Date.now(), kind }); addEventListener("online", () => record("online")); addEventListener("offline", () => record("offline")); navigator.serviceWorker?.addEventListener("message", (event) => { const status = event.data && event.data.ekklesiaDisplayOffline; if (status) record(status.usingFallback ? "sw-fallback:" + (status.kind || "") : "sw-network"); }); })()`; /** One page target with its own flat session, and what it does on the network. */ async function openPage(cdp, url, label) { const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" }); const { sessionId } = await cdp.send("Target.attachToTarget", { flatten: true, targetId }); const send = (method, params) => cdp.send(method, params, sessionId, `page "${label}"`); const requests = new Map(); const page = { label, /** Every realtime connection: when it opened, its status, and when and how it ended. */ streams: [], /** Signage viewer-session requests: one per module mount. */ sessionRequests: [], /** Heartbeat statuses, by status code. */ heartbeats: new Map(), /** Channel-state fetches and how they went. */ stateFetches: [], /** Non-2xx responses and failed requests, by path only. */ problems: [], }; cdp.on((message) => { if (message.sessionId !== sessionId) { return; } const params = message.params ?? {}; if (message.method === "Network.requestWillBeSent") { const path = pathOf(params.request.url); const entry = { at: Date.now(), path, type: params.type }; requests.set(params.requestId, entry); if (params.type === "EventSource") { entry.stream = { openedAt: Date.now(), path, status: undefined, endedAt: undefined, how: undefined, }; page.streams.push(entry.stream); } else if (path.endsWith("/viewers/session")) { page.sessionRequests.push(Date.now()); } else if (path.endsWith("/display-state") && !path.includes("module-realtime")) { entry.state = { at: Date.now(), outcome: "pending" }; page.stateFetches.push(entry.state); } } else if (message.method === "Network.responseReceived") { const entry = requests.get(params.requestId); const { status } = params.response; if (entry?.stream) { entry.stream.status = status; } if (entry?.state) { entry.state.outcome = String(status); } if (entry?.path.endsWith("/heartbeat")) { page.heartbeats.set(status, (page.heartbeats.get(status) ?? 0) + 1); } if (status >= 400 || status === 0) { page.problems.push(`${clock()} ${status} ${pathOf(params.response.url)}`); } } else if (message.method === "Network.loadingFinished") { const entry = requests.get(params.requestId); if (entry?.stream) { entry.stream.endedAt = Date.now(); entry.stream.how = "finished"; } } else if (message.method === "Network.loadingFailed") { const entry = requests.get(params.requestId); if (entry?.stream) { entry.stream.endedAt = Date.now(); entry.stream.how = params.canceled ? "canceled" : `failed ${params.errorText}`; } if (entry?.state) { entry.state.outcome = params.canceled ? "canceled" : `failed ${params.errorText}`; } if (entry && !params.canceled) { page.problems.push(`${clock()} failed ${params.errorText} ${entry.path}`); } } }); await send("Network.enable"); await send("Page.enable"); await send("Runtime.enable"); await send("Page.addScriptToEvaluateOnNewDocument", { source: instrumentation }); await send("Emulation.setDeviceMetricsOverride", { deviceScaleFactor: 1, height: 1080, mobile: false, width: 1920, }); page.send = send; /** Runs an expression in the page. `what` names it if it never comes back. */ page.evaluate = async (expression, what = "an evaluation") => { const result = await guard( `page "${label}": ${what}`, send("Runtime.evaluate", { awaitPromise: true, expression, returnByValue: true }), ); if (result.exceptionDetails) { throw new Error(`A page expression threw (${what}).`); } return result.result.value; }; page.waitFor = async (expression, description, ms = waitTimeoutMs) => { const deadline = Date.now() + ms; while (Date.now() < deadline) { if ( await page .evaluate(`Boolean(${expression})`, `waiting for ${description}`) .catch((error) => { if (error instanceof HangError) throw error; return false; }) ) { return true; } await sleep(250); } note(`timed out waiting for ${description}`); return false; }; /** A credentialed request from this page, bounded in the page itself, so a request that never * answers is reported as that request. */ page.request = async (method, url, body) => { const result = await page.evaluate( `fetch(${JSON.stringify(url)}, { credentials: "include", method: ${JSON.stringify(method)}, signal: AbortSignal.timeout(${requestTimeoutMs}), ${body === undefined ? "" : `headers: { "content-type": "application/json" }, body: ${JSON.stringify(JSON.stringify(body))},`} }).then( async (response) => ({ status: response.status, body: await response.json().catch(() => null) }), (error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }), )`, `${method} ${pathOf(url)}`, ); if (result.status === "timeout") { throw new HangError(`HTTP ${method} ${pathOf(url)} from page "${label}"`, requestTimeoutMs); } return result; }; page.close = () => cdp.send("Target.closeTarget", { targetId }); page.navigate = (to) => send("Page.navigate", { url: to }); if (url) { await page.navigate(url); } return page; } /** The owner, signed in on the Dashboard hostname of the smoke run's own browser. */ async function ownerPage() { const cdp = await connect(ownerAddress, "owner"); const page = await openPage(cdp, `${dashboardOrigin}/`, "owner"); await page.waitFor(`${find("login-email")} || ${find("host-shell")}`, "the Dashboard"); if (!(await page.evaluate(`Boolean(${find("host-shell")})`, "checking the session"))) { await page.evaluate( `(() => { const setValue = (element, value) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(element, value); element.dispatchEvent(new Event("input", { bubbles: true })); }; setValue(${find("login-email")}, ${JSON.stringify(required("SMOKE_OWNER_EMAIL"))}); setValue(${find("login-password")}, ${JSON.stringify(required("SMOKE_OWNER_PASSWORD"))}); ${find("login-email")}.form.requestSubmit(); })()`, "submitting the sign-in form", ); check(await page.waitFor(find("host-shell"), "the signed-in Dashboard"), "the owner signs in"); } page.api = (method, path, body) => page.request(method, `${apiOrigin}${path}`, body); return { cdp, page }; } /** Closes the owner's tab as well as the connection: the owner browser is shared by every step, and a * tab left behind by each one accumulates until the Dashboard no longer renders in time. */ async function finishOwner(owner) { await owner.page.close().catch(() => undefined); owner.cdp.close(); } async function displayModuleId(owner) { const navigation = await owner.api("GET", "/api/v1/dashboard/navigation"); const entries = [ ...navigation.body.ungrouped, ...navigation.body.groups.flatMap((group) => group.modules), ]; const withDisplay = entries.filter((entry) => entry.module.display !== null); const signage = withDisplay.find((entry) => entry.module.id === "signage"); return (signage ?? withDisplay[0])?.module.id; } /** Three slides of plain colour, on a five-second rotation, so a display has real content to persist * and continue from, as the production screen did. Signage only. */ async function seedSignage(owner) { const base = "/api/v1/modules/signage"; const images = []; for (const [index, colour] of ["#b03030", "#3060b0", "#30a050"].entries()) { let uploaded; for (let attempt = 1; attempt <= 2; attempt += 1) { uploaded = await owner.evaluate( `(async () => { const canvas = document.createElement("canvas"); canvas.width = 640; canvas.height = 360; const context = canvas.getContext("2d"); context.fillStyle = ${JSON.stringify(colour)}; context.fillRect(0, 0, 640, 360); const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png")); return fetch(${JSON.stringify(`${apiOrigin}${base}/images?filename=slide-${index}.png&displayName=Slide%20${index}`)}, { body: blob, credentials: "include", headers: { "content-type": "image/png" }, method: "POST", signal: AbortSignal.timeout(${requestTimeoutMs}), }).then( async (response) => ({ status: response.status, body: await response.json().catch(() => null) }), (error) => ({ status: error && error.name === "TimeoutError" ? "timeout" : "network-error" }), ); })()`, `uploading slide ${index}`, ); if ( attempt === 1 && uploaded.status === 503 && uploaded.body?.code === "SIGNAGE_STORAGE_UNAVAILABLE" ) { note(`slide ${index} met one transient storage outage; retrying once`); await sleep(1_000); continue; } break; } if (uploaded.status === "timeout") { throw new HangError(`HTTP POST ${base}/images from page "owner"`, requestTimeoutMs); } if (uploaded.status !== 200 && uploaded.status !== 201) { const code = typeof uploaded.body?.code === "string" && /^SIGNAGE_[A-Z_]+$/u.test(uploaded.body.code) ? ` (${uploaded.body.code})` : ""; throw new Error(`Uploading slide ${index} returned HTTP ${uploaded.status}${code}.`); } images.push(uploaded.body?.image?.id); } check( images.every((id) => typeof id === "string"), "three slides are uploaded to Signage", ); const timeline = await owner.api("POST", `${base}/timelines`, { name: "Display repro" }); const timelineId = timeline.body?.timeline?.id ?? timeline.body?.id; for (const itemId of images) { await owner.api("POST", `${base}/timelines/${timelineId}/slots`, { durationOverrideMs: 5_000, itemId, providerId: "signage.native-images", }); } const groups = await owner.api("GET", `${base}/channel-groups`); const groupId = groups.body?.groups?.[0]?.id; const activated = await owner.api("POST", `${base}/playback/${groupId}/activate`, { timelineId, }); check(activated.status === 200, "a three-slide timeline is playing on the default channel group"); } async function signageViewers(owner) { const listed = await owner.api("GET", "/api/v1/modules/signage/viewers"); return listed.body?.viewers ?? []; } async function coreDevices(owner) { const listed = await owner.api("GET", "/api/v1/devices"); return listed.body?.devices ?? []; } /** The display's own view of itself: which host state is showing, and Signage's offline flag. */ function displayState(page) { return page.evaluate( `(() => { const stage = ${find("signage-stage")}; return { pairing: Boolean(${find("display-pairing")}), hostLoading: Boolean(${find("display-host-loading")}) || Boolean(${find("display-loading")}), hostError: Boolean(${find("display-host-error")}) || Boolean(${find("display-error")}), hostOffline: Boolean(${find("display-offline")}), mounted: (${find("display-module-container")}?.childElementCount ?? 0) > 0, signageOffline: stage ? stage.dataset.offline === "true" : null, }; })()`, "reading the display state", ); } async function blobCounts(page) { return page.evaluate( "window.__displayRepro ? window.__displayRepro.blobs : { created: 0, revoked: 0 }", "reading object-URL counts", ); } async function connectivityEvents(page) { return page.evaluate( "window.__displayRepro ? window.__displayRepro.events : []", "reading connectivity events", ); } async function describeDeviceCookie(cdp) { const { cookies } = await cdp.send("Storage.getCookies"); const cookie = cookies.find((candidate) => candidate.name === deviceCookie); if (!cookie) { note(`${deviceCookie}: absent from the profile`); return undefined; } const days = cookie.session ? "session" : `${((cookie.expires * 1000 - Date.now()) / 86_400_000).toFixed(1)} days`; note( `${deviceCookie}: domain=${cookie.domain} session=${cookie.session} lifetime=${days} httpOnly=${cookie.httpOnly} secure=${cookie.secure} sameSite=${cookie.sameSite}`, ); return cookie; } const liveExpression = `(() => { const stage = ${find("signage-stage")}; const mounted = (${find("display-module-container")}?.childElementCount ?? 0) > 0; return mounted && !${find("display-pairing")} && (stage === null || stage.dataset.offline === "false"); })()`; function waitLive(page, description, ms = 45_000) { return page.waitFor(liveExpression, description, ms); } /** A page's network story, one line each, by path only. */ function summarize(page, from) { const at = (ms) => (ms === undefined ? "-" : `${clock(ms)} (${Math.round((ms - from) / 1000)}s)`); note(`${page.label}: viewer sessions opened ${page.sessionRequests.length} (= module mounts)`); note( `${page.label}: heartbeats ${[...page.heartbeats].map(([status, count]) => `${status}×${count}`).join(" ") || "none"}`, ); const states = page.stateFetches.reduce((tally, fetch) => { tally[fetch.outcome] = (tally[fetch.outcome] ?? 0) + 1; return tally; }, {}); note(`${page.label}: channel-state fetches ${JSON.stringify(states)}`); for (const stream of page.streams) { const lived = stream.endedAt === undefined ? "open" : `${Math.round((stream.endedAt - stream.openedAt) / 1000)}s`; note( `${page.label}: stream opened ${at(stream.openedAt)} status ${stream.status ?? "-"} ended ${at(stream.endedAt)} ${stream.how ?? ""} lived ${lived}`, ); } note(`${page.label}: problems ${page.problems.slice(-12).join(" | ") || "none"}`); } /** Samples display state on an interval for a fixed time, printing progress, and returns a tally. */ async function watch(pages, ms, { onSample, viewerOwner } = {}) { const from = Date.now(); const tally = new Map( pages.map((page) => [ page.label, { loading: 0, offline: 0, pairing: 0, samples: 0, transitions: [] }, ]), ); const last = new Map(); let nextProgress = from + 15_000; const roster = []; while (Date.now() - from < ms) { for (const page of pages) { const state = await displayState(page); const t = tally.get(page.label); t.samples += 1; if (state.signageOffline || state.hostOffline) t.offline += 1; if (state.pairing) t.pairing += 1; if (state.hostLoading) t.loading += 1; const key = JSON.stringify(state); if (last.get(page.label) !== key) { last.set(page.label, key); t.transitions.push(`${clock()} ${since(from)} ${key}`); } if (onSample && (await onSample(page, state))) { return { from, roster, stopped: true, tally }; } } if (Date.now() >= nextProgress) { nextProgress = Date.now() + 15_000; if (viewerOwner) { const viewers = await signageViewers(viewerOwner); roster.push( `${since(from)} ${viewers.map((v) => `${v.name}:${v.online ? "Online" : "Offline"}`).join(",")}`, ); } const lines = []; for (const page of pages) { const t = tally.get(page.label); const blobs = await blobCounts(page); lines.push( `${page.label}: ${t.samples} samples, offline ${t.offline}, mounts ${page.sessionRequests.length}, streams ${page.streams.length}, live object URLs ${blobs.created - blobs.revoked}`, ); } progress(lines.join(" · ")); } await sleep(2_000); } return { from, roster, stopped: false, tally }; } function printTally(result) { for (const [label, t] of result.tally) { note( `${label}: ${t.samples} samples, offline in ${t.offline}, pairing in ${t.pairing}, loading in ${t.loading}`, ); for (const transition of t.transitions.slice(0, 30)) { note(` ${label} ${transition}`); } } if (result.roster.length > 0) { note(`roster over time: ${result.roster.join(" ; ")}`); } } // --- Steps --------------------------------------------------------------------------------------- async function run() { if (mode === "pair") { const owner = await ownerPage(); const moduleId = await displayModuleId(owner.page); check(moduleId !== undefined, `a module publishes a display (${moduleId})`); if (moduleId === "signage") { await seedSignage(owner.page); } const kiosk = await connect(kioskAddress, "kiosk"); const display = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "kiosk"); check( await display.waitFor(find("display-pairing-code"), "a pairing code"), "an unpaired kiosk shows a pairing code", ); const code = ( await display.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code") ).replace(/\D/gu, ""); const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", { code, deviceName: "Repro kiosk", }); check(approved.status === 200, "the owner approves the code"); check( await waitLive(display, "the paired display to go live"), "the paired kiosk mounts the display and is live", ); await describeDeviceCookie(kiosk); progress("letting heartbeats, a persisted snapshot, and media prefetch happen (20s)"); await sleep(20_000); for (const viewer of await signageViewers(owner.page)) { note( `signage viewer "${viewer.name}" online=${viewer.online} offlineReady=${viewer.offlineReady} cachedAssets=${viewer.cachedAssetCount} error=${viewer.lastError?.code ?? "none"}`, ); } summarize(display, stepStartedAt); // A clean exit: the browser shuts down normally, exactly as a systemd stop of a well-behaved kiosk. await kiosk.send("Browser.close").catch(() => undefined); kiosk.close(); await finishOwner(owner); } else if (mode === "restart") { const owner = await ownerPage(); const moduleId = await displayModuleId(owner.page); const kiosk = await connect(kioskAddress, "kiosk"); const cookie = await describeDeviceCookie(kiosk); check(cookie !== undefined, "the device credential survived a clean browser exit"); const display = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "kiosk"); const live = await waitLive(display, "the restarted display to go live"); const state = await displayState(display); check( live && !state.pairing, `after a clean restart the kiosk is still paired and live (${JSON.stringify(state)})`, ); summarize(display, stepStartedAt); await display.close(); kiosk.close(); await finishOwner(owner); } else if (mode === "two-pages") { const owner = await ownerPage(); const moduleId = await displayModuleId(owner.page); const kiosk = await connect(kioskAddress, "kiosk"); const url = `${apiOrigin}/display/${moduleId}`; const first = await openPage(kiosk, url, "first"); check(await waitLive(first, "the first page"), "the first page is live"); const second = await openPage(kiosk, url, "second"); check(await waitLive(second, "the second page"), "the second page is live"); const result = await watch([first, second], watchMs, { viewerOwner: owner.page }); printTally(result); for (const page of [first, second]) { summarize(page, result.from); note(`${page.label}: connectivity events ${JSON.stringify(await connectivityEvents(page))}`); } check( [...result.tally.values()].every((t) => t.offline === 0), `neither page showed an offline state while both were open (${watchMs / 1000}s)`, ); check( [first, second].every((page) => page.sessionRequests.length === 1), "neither page was remounted while both were open", ); await first.close(); await second.close(); kiosk.close(); await finishOwner(owner); } else if (mode === "connectivity-blip") { // The browser's own offline/online events, as a Wi-Fi reassociation or a link flap produces. The // display host re-resolves its descriptor on "online" (ADR 0043); this measures what that costs. const owner = await ownerPage(); const moduleId = await displayModuleId(owner.page); const kiosk = await connect(kioskAddress, "kiosk"); const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "blip"); check(await waitLive(page, "the display"), "the display is live"); const mountsBefore = page.sessionRequests.length; await page.evaluate( `dispatchEvent(new Event("offline")); setTimeout(() => dispatchEvent(new Event("online")), 1000)`, "dispatching offline then online", ); let firstOfflineAt; let lastOfflineAt; const result = await watch([page], 30_000, { onSample: (_page, state) => { if (state.signageOffline || state.hostOffline || state.hostLoading || !state.mounted) { firstOfflineAt ??= Date.now(); lastOfflineAt = Date.now(); } return false; }, }); printTally(result); const remounts = page.sessionRequests.length - mountsBefore; note(`remounts caused by one offline/online pair: ${remounts}`); note( firstOfflineAt === undefined ? "nothing visible changed" : `visible disruption from ${clock(firstOfflineAt)} to ${clock(lastOfflineAt)} (sampled every 2s)`, ); summarize(page, result.from); check(remounts === 0, "a connectivity blip does not remount a live display"); check( firstOfflineAt === undefined, "a connectivity blip does not show an offline state on a live display", ); await page.close(); kiosk.close(); await finishOwner(owner); } else if (mode === "session-blip") { // One transient failure of Signage's viewer-session request at start-up, as a kiosk booting // before its network settles, or an API restart, would produce. Everything else reaches the // server. const owner = await ownerPage(); const moduleId = await displayModuleId(owner.page); const kiosk = await connect(kioskAddress, "kiosk"); const page = await openPage(kiosk, undefined, "session-blip"); let blocked = 0; kiosk.on((message) => { if (message.method === "Fetch.requestPaused") { const { requestId, request } = message.params; if (blocked === 0 && pathOf(request.url).endsWith("/viewers/session")) { blocked += 1; page .send("Fetch.failRequest", { errorReason: "ConnectionRefused", requestId }) .catch((error) => note(`interception reply failed: ${error.message}`)); } else { page .send("Fetch.continueRequest", { requestId }) .catch((error) => note(`interception reply failed: ${error.message}`)); } } }); await page.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/v1/modules/*" }] }); await page.navigate(`${apiOrigin}/display/${moduleId}`); await page.waitFor( `(${find("display-module-container")}?.childElementCount ?? 0) > 0`, "the module to mount", ); note(`viewer-session requests failed on purpose: ${blocked}`); const recovered = await page.waitFor( `${find("signage-stage")}?.dataset.offline === "false"`, "recovery from one failed viewer-session request", 90_000, ); summarize(page, stepStartedAt); check( recovered, `one failed viewer-session request does not leave the display offline (${JSON.stringify(await displayState(page))})`, ); await page.close(); kiosk.close(); await finishOwner(owner); } else if (mode === "stream-failure") { // The first realtime connection is refused with a 503, as an API mid-restart or a proxy with no // upstream answers. A non-200 ends an EventSource for good; the question is whether anything // re-establishes realtime without a reload. const owner = await ownerPage(); const moduleId = await displayModuleId(owner.page); const kiosk = await connect(kioskAddress, "kiosk"); const page = await openPage(kiosk, undefined, "stream-failure"); let refused = 0; kiosk.on((message) => { if (message.method === "Fetch.requestPaused") { const { requestId } = message.params; if (refused === 0) { refused += 1; page .send("Fetch.fulfillRequest", { body: Buffer.from('{"code":"REALTIME_UNAVAILABLE"}').toString("base64"), requestId, responseCode: 503, responseHeaders: [ { name: "content-type", value: "application/json" }, { name: "cache-control", value: "no-store" }, ], }) .catch((error) => note(`interception reply failed: ${error.message}`)); } else { page .send("Fetch.continueRequest", { requestId }) .catch((error) => note(`interception reply failed: ${error.message}`)); } } }); await page.send("Fetch.enable", { patterns: [{ urlPattern: "*/api/v1/module-realtime/*" }] }); await page.navigate(`${apiOrigin}/display/${moduleId}`); await page.waitFor( `(${find("display-module-container")}?.childElementCount ?? 0) > 0`, "the module to mount", ); note(`realtime connections refused on purpose: ${refused}`); const result = await watch([page], 90_000, { onSample: () => page.streams.some((stream) => stream.status === 200), }); printTally(result); summarize(page, result.from); check( page.streams.some((stream) => stream.status === 200), "after one refused realtime connection, realtime is re-established without a reload", ); await page.close(); kiosk.close(); await finishOwner(owner); } else if (mode === "caching-probe") { // Whether a display on the API origin can store an image from the storage origin in Cache // Storage, which is what Signage's offline media relies on. Reports only statuses, response // types, and whether CORS headers were present — never a URL. const owner = await ownerPage(); const moduleId = await displayModuleId(owner.page); const groups = await owner.page.api("GET", "/api/v1/modules/signage/channel-groups"); const channelId = groups.body?.groups?.[0]?.channels?.[0]?.id; const kiosk = await connect(kioskAddress, "kiosk"); const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "probe"); check(await waitLive(page, "the display"), "the display is live"); progress("letting the display prefetch its slides (15s)"); await sleep(15_000); const probe = await page.evaluate( `(async () => { const result = {}; const state = await fetch(${JSON.stringify(`${apiOrigin}/api/v1/modules/signage/channels/${channelId}/display-state`)}, { credentials: "include", signal: AbortSignal.timeout(${requestTimeoutMs}) }).then((r) => r.json()); const url = (state.selections || []).map((s) => s.media && s.media.display && s.media.display.url).find(Boolean); result.selections = (state.selections || []).length; result.haveUrl = Boolean(url); if (!url) return result; result.sameOrigin = new URL(url).origin === location.origin; try { const response = await fetch(url, { signal: AbortSignal.timeout(${requestTimeoutMs}) }); result.cors = { status: response.status, type: response.type, allowOrigin: response.headers.get("access-control-allow-origin") === null ? "absent" : (response.headers.get("access-control-allow-origin") === location.origin || response.headers.get("access-control-allow-origin") === "*" ? "matches" : "other") }; try { const cache = await caches.open("display-repro-probe"); await cache.put("/display-repro-probe/cors", response.clone()); result.cors.cachePut = "stored"; } catch (error) { result.cors.cachePut = "refused: " + error.name; } } catch (error) { result.cors = { error: error.name }; } try { const response = await fetch(url, { mode: "no-cors", signal: AbortSignal.timeout(${requestTimeoutMs}) }); result.noCors = { status: response.status, type: response.type, ok: response.ok }; try { const cache = await caches.open("display-repro-probe"); await cache.put("/display-repro-probe/opaque", response.clone()); result.noCors.cachePut = "stored"; } catch (error) { result.noCors.cachePut = "refused: " + error.name; } } catch (error) { result.noCors = { error: error.name }; } result.imgPlain = await new Promise((resolve) => { const i = new Image(); i.onload = () => resolve("loaded"); i.onerror = () => resolve("error"); i.src = url; }); result.imgCors = await new Promise((resolve) => { const i = new Image(); i.crossOrigin = "anonymous"; i.onload = () => resolve("loaded"); i.onerror = () => resolve("error"); i.src = url + (url.includes("?") ? "&" : "?") + "cors-probe=1"; }); await caches.delete("display-repro-probe"); result.signageCachedEntries = await caches.open("ekklesia-signage-media-v1").then((c) => c.keys()).then((k) => k.length).catch(() => "unavailable"); return result; })()`, "probing cross-origin image caching", ); note(`caching probe: ${JSON.stringify(probe)}`); summarize(page, stepStartedAt); check(probe.signageCachedEntries > 0, "Signage has stored slide images in Cache Storage"); await page.close(); kiosk.close(); await finishOwner(owner); } else if (mode === "stream-recycle") { // One page, past the five-minute realtime connection age, with every stream's open and end // time in UTC so they can be laid against the proxy's own log. const owner = await ownerPage(); const moduleId = await displayModuleId(owner.page); const kiosk = await connect(kioskAddress, "kiosk"); const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "recycle"); check(await waitLive(page, "the display"), "the display is live"); const result = await watch([page], recycleWatchMs, { viewerOwner: owner.page }); printTally(result); summarize(page, result.from); note(`connectivity events ${JSON.stringify(await connectivityEvents(page))}`); const blobs = await blobCounts(page); const exhausted = page.stateFetches.filter((fetch) => fetch.outcome.includes("INSUFFICIENT_RESOURCES"), ); note( `object URLs: created ${blobs.created}, revoked ${blobs.revoked}, live ${blobs.created - blobs.revoked}; channel-state fetches refused for insufficient resources: ${exhausted.length}`, ); check( blobs.created - blobs.revoked <= MAX_LIVE_OBJECT_URLS, `live object URLs stay bounded (at most ${MAX_LIVE_OBJECT_URLS}; ${blobs.created - blobs.revoked} live)`, ); check( result.tally.get("recycle").offline === 0, `the display stayed live for the whole ${recycleWatchMs / 1000}s watch${recycleWatchMs > 300_000 ? ", across a realtime recycle" : ""}`, ); check(page.sessionRequests.length === 1, "the display was never remounted during the watch"); await page.close(); kiosk.close(); await finishOwner(owner); } else if (mode === "revoke") { const owner = await ownerPage(); const moduleId = await displayModuleId(owner.page); const kiosk = await connect(kioskAddress, "kiosk"); const page = await openPage(kiosk, `${apiOrigin}/display/${moduleId}`, "revoked"); check( await waitLive(page, "the display before revocation"), "the display is live before revocation", ); const devices = (await coreDevices(owner.page)).filter((device) => !device.revoked); for (const device of devices) { await owner.page.api("POST", `/api/v1/devices/${device.id}/revoke`); } note(`revoked ${devices.length} device(s) at ${clock()}; watching the open page`); const result = await watch([page], watchMs, { onSample: (_page, state) => state.pairing }); printTally(result); summarize(page, result.from); check( result.stopped, `a revoked display returns to the pairing screen without a reload (within ${watchMs / 1000}s)`, ); // Revocation is recoverable: the same screen pairs again and resumes, keeping its place in the // module's roster. It also leaves the kiosk paired for the steps that run after this one. const rosterBefore = moduleId === "signage" ? (await signageViewers(owner.page)).length : 0; if (await page.waitFor(find("display-pairing-code"), "a fresh pairing code after revocation")) { const code = ( await page.evaluate(`${find("display-pairing-code")}.textContent`, "reading the code") ).replace(/\D/gu, ""); const approved = await owner.page.api("POST", "/api/v1/device-pairing/approvals", { code, deviceName: "Repro kiosk", }); check(approved.status === 200, "the owner approves the revoked screen's new code"); check( await waitLive(page, "the re-paired display to go live"), "a revoked screen is paired again and resumes without a reload", ); if (moduleId === "signage") { const rosterAfter = (await signageViewers(owner.page)).length; check( rosterAfter === rosterBefore, `re-pairing keeps the screen's existing roster entry (${rosterBefore} before, ${rosterAfter} after)`, ); } } else { check(false, "a revoked screen shows a fresh pairing code"); } await page.close(); kiosk.close(); await finishOwner(owner); } else { throw new Error( "usage: deploy-smoke-display.mjs pair|restart|two-pages|connectivity-blip|session-blip|stream-failure|caching-probe|stream-recycle|revoke", ); } } try { await run(); } catch (error) { if (error instanceof HangError) { console.log(`FAIL ${mode}: ${error.message}; also in flight: ${describeInFlight()}`); } else { console.log(`FAIL ${mode}: ${error instanceof Error ? error.message : "an unexpected error"}`); } process.exit(2); } if (failures > 0) { console.error(`${failures} check(s) failed`); process.exit(1); } process.exit(0);