// Real-browser assertions for scripts/smoke-deploy.sh in the split topology (ADR 0045). Runs inside a // disposable container on the deployment's `edge` network and drives a headless Chromium, itself a // disposable container on the same network, over the Chrome DevTools Protocol, with Node.js's own // fetch and WebSocket and no other dependency. // // node deploy-smoke-browser.mjs // // What only a browser can prove: that a person signing in on the Dashboard's hostname gets a session // cookie on the API's hostname alone, that the Dashboard then imports a module's UI from the API's // origin and the module's credentialed requests succeed under CORS, that the module's display page // mounts on the API's origin with that same session, and that sign-out ends it. Output is one line // per check; nothing printed carries a credential, cookie value, or response body. function required(name) { const value = process.env[name]; if (!value) { throw new Error(`${name} is required.`); } return value; } const apiOrigin = `https://${required("SMOKE_HOSTNAME")}`; const dashboardOrigin = `https://${required("SMOKE_DASHBOARD_HOSTNAME")}`; const apiHostname = new URL(apiOrigin).hostname; const devtools = required("SMOKE_CDP_ADDRESS"); const timeoutMs = 30_000; let failures = 0; function check(condition, description) { console.log(`${condition ? "ok " : "FAIL"} ${description}`); if (!condition) { failures += 1; } } /** The smallest Chrome DevTools Protocol client: numbered commands, flat sessions, and events. */ async function connect() { // The browser container was started moments ago; give it time to listen. // One overall deadline, with every attempt bounded, so a browser that never comes up fails the // run in seconds rather than waiting out TCP timeouts. 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 Error("The headless browser never answered."); } await new Promise((resolve) => setTimeout(resolve, 500)); } } // Chromium names its WebSocket after the Host it was asked on; ask on the same address. const socketUrl = version.webSocketDebuggerUrl.replace(/^ws:\/\/[^/]+/u, `ws://${devtools}`); const socket = new WebSocket(socketUrl); await 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) { const id = nextId; nextId += 1; socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); return new Promise((resolve, reject) => pending.set(id, { method, reject, resolve })); }, }; } const cdp = await connect(); 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); /** Every response and failure the page sees, for the API's origin. A CORS refusal is a * `loadingFailed` here, never a response. */ const responses = []; const failed = []; cdp.on((message) => { if (message.sessionId !== sessionId) { return; } if (message.method === "Network.responseReceived") { const { response, type } = message.params; responses.push({ status: response.status, type, url: response.url }); } else if (message.method === "Network.loadingFailed") { failed.push(message.params); } }); await send("Network.enable"); await send("Page.enable"); await send("Runtime.enable"); // A desktop-sized window: a module may reasonably decline to render its management UI on a small one. await send("Emulation.setDeviceMetricsOverride", { deviceScaleFactor: 1, height: 1080, mobile: false, width: 1920, }); async function evaluate(expression) { const result = await send("Runtime.evaluate", { awaitPromise: true, expression, returnByValue: true, }); if (result.exceptionDetails) { throw new Error("A page expression threw."); } return result.result.value; } async function waitFor(expression, description) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (await evaluate(`Boolean(${expression})`).catch(() => false)) { return true; } await new Promise((resolve) => setTimeout(resolve, 250)); } console.log(`FAIL timed out waiting for ${description}`); failures += 1; return false; } async function navigate(url) { await send("Page.navigate", { url }); } const selector = (testId) => `document.querySelector('[data-testid="${testId}"]')`; async function cookiesFor(origin) { const { cookies } = await send("Network.getCookies", { urls: [`${origin}/`] }); return cookies; } // --- Sign in on the Dashboard's hostname ------------------------------------------------------- await navigate(`${dashboardOrigin}/`); await waitFor(selector("login-email"), "the sign-in form on the Dashboard hostname"); await evaluate(`(() => { // React tracks input values through the native setter; assigning .value alone is invisible to it. const setValue = (element, value) => { Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(element, value); element.dispatchEvent(new Event("input", { bubbles: true })); }; setValue(${selector("login-email")}, ${JSON.stringify(required("SMOKE_OWNER_EMAIL"))}); setValue(${selector("login-password")}, ${JSON.stringify(required("SMOKE_OWNER_PASSWORD"))}); ${selector("login-email")}.form.requestSubmit(); })()`); check( await waitFor(selector("host-shell"), "the signed-in Dashboard"), "a person signs in through the real form on the Dashboard hostname", ); const apiCookies = await cookiesFor(apiOrigin); const dashboardCookies = await cookiesFor(dashboardOrigin); check( apiCookies.some((cookie) => cookie.httpOnly && cookie.secure && cookie.domain === apiHostname) && dashboardCookies.length === 0, "the session cookie is host-only on the API hostname, and the Dashboard hostname holds none", ); // --- A module UI, imported from the API's origin ------------------------------------------------- const moduleButton = "document.querySelector('[data-testid^=\"select-\"]')"; const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation"); if (hasModule) { const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`); await evaluate(`${moduleButton}.click()`); const mounted = await waitFor( `${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`, "the module UI to mount", ); check( mounted && !(await evaluate(`Boolean(${selector("module-error")})`)), `the ${moduleId} module UI mounts`, ); // Give the module's first requests a moment to settle before judging them. await new Promise((resolve) => setTimeout(resolve, 1_000)); check( responses.some( (response) => response.url.startsWith(`${apiOrigin}/api/v1/module-ui/${moduleId}/`) && response.status === 200, ), "its UI artifact was imported from the API's origin", ); check( responses.some( (response) => response.url.startsWith(`${apiOrigin}/api/v1/modules/${moduleId}`) && response.status >= 200 && response.status < 300, ), "its own credentialed API request to the API's origin succeeded under CORS", ); const streams = responses.filter((response) => response.type === "EventSource"); check( streams.every((response) => response.status === 200), `every realtime stream it opened was accepted (${streams.length} opened)`, ); } // A CORS refusal or a blocked request is always a failure. A request the page itself cancelled (a // module unmounting, a navigation) is not. const refused = failed.filter( (failure) => failure.corsErrorStatus !== undefined || failure.blockedReason !== undefined || !(failure.canceled || failure.errorText === "net::ERR_ABORTED"), ); check( refused.length === 0, `no request from the Dashboard failed or was refused by CORS (${refused.length} refused)`, ); // --- The display host, on the API's origin, with the same session -------------------------------- const displayPath = await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/dashboard/navigation`)}, { credentials: "include", }).then((response) => response.json()).then((navigation) => { const entries = [...navigation.ungrouped, ...navigation.groups.flatMap((group) => group.modules)]; return entries.find((entry) => entry.module.display !== null)?.module.display.url ?? null; })`); if (displayPath === null) { console.log("skip no installed module publishes a display"); } else { await navigate(`${apiOrigin}${displayPath}`); check( await waitFor( `${selector("display-module-container")}?.childElementCount > 0 && !${selector("display-error")}`, "the display to mount", ), "the module's display page mounts on the API hostname with the same session", ); check( await waitFor( "navigator.serviceWorker.getRegistrations().then((registrations) => registrations.length > 0)", "the display's service worker", ), "the display's offline service worker registers on the API's origin", ); } // --- Sign out on the Dashboard ------------------------------------------------------------------- await navigate(`${dashboardOrigin}/`); await waitFor(selector("host-logout"), "the sign-out control"); await evaluate(`${selector("host-logout")}.click()`); check( await waitFor(selector("login-email"), "the sign-in form after sign-out"), "sign-out returns the Dashboard to the sign-in form", ); const principal = await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/principal`)}, { credentials: "include", }).then((response) => response.json()).then((body) => body.kind)`); check(principal === "anonymous", "after sign-out, the API answers anonymous to the same browser"); await cdp.send("Target.closeTarget", { targetId }); cdp.close(); if (failures > 0) { console.error(`${failures} check(s) failed`); process.exit(1); }