// 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 = []; /** Requests the page has started and not finished, by id: what a timed-out wait was stuck behind. * Only paths are ever printed, never a query, header, or body. */ const inflight = new Map(); /** Every request's URL by id, so a failure can be named after the request it ended. */ const inflightUrls = new Map(); cdp.on((message) => { if (message.sessionId !== sessionId) { return; } if (message.method === "Network.requestWillBeSent") { inflightUrls.set(message.params.requestId, message.params.request.url); inflight.set(message.params.requestId, { type: message.params.type, url: message.params.request.url, }); } else if ( message.method === "Network.loadingFinished" || message.method === "Network.loadingFailed" ) { inflight.delete(message.params.requestId); } 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}`); const page = await evaluate( `({ path: location.pathname, ready: document.readyState, shell: Boolean(document.querySelector('[data-testid="host-shell"]')), loading: Boolean(document.querySelector('[data-testid="host-loading"]')), error: Boolean(document.querySelector('[data-testid="host-error"], [data-testid="module-error"]')) })`, ).catch(() => "unavailable"); const pending = [...inflight.values()].map((request) => { try { const url = new URL(request.url); return `${request.type} ${url.origin === apiOrigin ? "api" : url.origin === dashboardOrigin ? "dashboard" : "other"}${url.pathname}`; } catch { return request.type; } }); const shortPath = (value) => { try { const url = new URL(value); return `${url.origin === apiOrigin ? "api" : url.origin === dashboardOrigin ? "dashboard" : "other"}${url.pathname}`; } catch { return "unparseable"; } }; // The last few refused or failed requests: which one broke the page, and how. const refusedRecently = responses .filter((response) => response.status >= 400) .slice(-6) .map((response) => `${response.status} ${shortPath(response.url)}`); const failedRecently = failed .slice(-6) .map( (failure) => `${failure.errorText}${failure.corsErrorStatus ? ` cors:${failure.corsErrorStatus.corsError}` : ""} ${shortPath(inflightUrls.get(failure.requestId) ?? "")}`, ); console.log(`info ${JSON.stringify({ failedRecently, page, pending, refusedRecently })}`); 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 ------------------------------------------------- // The first module link — never the Overview, which is the Dashboard's own landing page (ADR 0057). const moduleButton = 'document.querySelector(\'[data-testid^="select-"]:not([data-testid="select-overview"])\')'; const hasModule = await waitFor(moduleButton, "a module in the Dashboard navigation"); let openedModuleId; if (hasModule) { const moduleId = await evaluate(`${moduleButton}.dataset.testid.slice("select-".length)`); openedModuleId = moduleId; 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 refresh stays on the module's own page: the proxy forwards the deep link, the API serves the // Dashboard page there, and the shell mounts the same module again (ADR 0057). const moduleUrl = await evaluate("location.href"); check( moduleUrl === `${dashboardOrigin}/${moduleId}`, "opening the module put its own address in the address bar", ); await navigate(moduleUrl); const remounted = await waitFor( `location.href === ${JSON.stringify(moduleUrl)} && ${selector(`select-${moduleId}`)}?.getAttribute("aria-current") === "page" && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-loading")} && !${selector("module-error")}`, "the module UI to mount again after a refresh", ); check(remounted, `refreshing ${moduleUrl} reopens the ${moduleId} module`); } // 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", ); } // --- The phone layout: the same navigation, in a drawer (ADR 0058) ------------------------------ await send("Emulation.setDeviceMetricsOverride", { deviceScaleFactor: 2, height: 844, mobile: true, width: 390, }); await navigate(`${dashboardOrigin}/`); check( await waitFor( `${selector("mobile-navigation-trigger")} && !${selector("host-sidebar")}`, "the phone navigation trigger", ), "a phone-sized window shows the navigation trigger instead of the sidebar", ); await evaluate(`${selector("mobile-navigation-trigger")}.click()`); check( await waitFor( `document.activeElement === ${selector("navigation-drawer-close")} && ${selector("host-main-region")}.inert`, "focus inside the opened drawer", ), "the drawer opens as a modal, with focus inside it and the page inert", ); await evaluate( `document.activeElement.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }))`, ); check( await waitFor( `!${selector("navigation-drawer")} && document.activeElement === ${selector("mobile-navigation-trigger")}`, "the drawer to close on Escape", ), "Escape closes the drawer and returns focus to its trigger", ); if (openedModuleId) { await evaluate(`${selector("mobile-navigation-trigger")}.click()`); await waitFor(selector(`select-${openedModuleId}`), "the module in the drawer"); await evaluate(`${selector(`select-${openedModuleId}`)}.click()`); check( await waitFor( `location.pathname === ${JSON.stringify(`/${openedModuleId}`)} && !${selector("navigation-drawer")} && ${selector("module-container")}?.childElementCount > 0 && !${selector("module-error")}`, "the module chosen from the drawer to open", ), `choosing ${openedModuleId} in the drawer closes it and opens the module at its own address`, ); } await send("Emulation.setDeviceMetricsOverride", { deviceScaleFactor: 1, height: 1080, mobile: false, width: 1920, }); check( await waitFor( `${selector("host-sidebar")} && !${selector("mobile-navigation-trigger")}${ openedModuleId ? ` && ${selector(`select-${openedModuleId}`)}?.getAttribute("aria-current") === "page"` : "" }`, "the sidebar after widening the window", ), "widening the window brings the sidebar back on the same page", ); // --- Settings (ADR 0061): a deep link, a refresh, Discover, a module's settings, the drawer ------- await navigate(`${dashboardOrigin}/settings`); check( await waitFor( `${selector("settings-home")} && ${selector("select-settings")}?.getAttribute("aria-current") === "page"`, "the Settings landing page from its own address", ), "a deep link to /settings opens the Settings landing page", ); await evaluate(`${selector("select-settings-displays")}.click()`); await waitFor(selector("settings-section-displays"), "Settings → Displays"); await navigate(`${dashboardOrigin}/settings/displays`); check( await waitFor( `location.pathname === "/settings/displays" && ${selector("settings-section-displays")} && ${selector("select-settings-displays")}?.getAttribute("aria-current") === "page"`, "Settings → Displays after a refresh", ), "refreshing a Settings page stays on it, marked in the navigation", ); await navigate(`${dashboardOrigin}/settings/discover`); check( await waitFor( `location.pathname === "/settings/discover/browse" && ${selector("settings-discover-browse")}`, "Discover opening at Browse", ), "entering Discover opens Browse at its one canonical address", ); if (openedModuleId) { await navigate(`${dashboardOrigin}/settings/${openedModuleId}`); check( await waitFor( `${selector("module-settings")} && ${selector(`select-module-settings-${openedModuleId}`)}?.getAttribute("aria-current") === "page"`, "the module's settings page", ), `/settings/${openedModuleId} opens the module's settings`, ); } await send("Emulation.setDeviceMetricsOverride", { deviceScaleFactor: 2, height: 844, mobile: true, width: 390, }); await navigate(`${dashboardOrigin}/settings`); await waitFor(selector("mobile-navigation-trigger"), "the phone navigation trigger in Settings"); await evaluate(`${selector("mobile-navigation-trigger")}.click()`); await waitFor( `${selector("navigation-drawer")}?.querySelector('[data-testid="select-settings-system"]')`, "Settings in the phone drawer", ); await evaluate(`${selector("select-settings-system")}.click()`); check( await waitFor( `location.pathname === "/settings/system" && !${selector("navigation-drawer")} && ${selector("settings-section-system")}`, "Settings → System chosen from the drawer", ), "the phone drawer offers the same Settings hierarchy and closes once a page is chosen", ); await send("Emulation.setDeviceMetricsOverride", { deviceScaleFactor: 1, height: 1080, mobile: false, width: 1920, }); await waitFor(selector("select-overview"), "the sidebar after widening the window"); await evaluate(`${selector("select-overview")}.click()`); check( await waitFor( `location.pathname === "/" && ${selector("host-overview")} && !${selector("settings-subnavigation")}`, "the Overview from Settings", ), "leaving Settings returns to the normal Dashboard navigation", ); // --- Account and Appearance (ADR 0062): identity, live switching, persistence, first paint ------ const emulate = (colorScheme, reducedMotion) => send("Emulation.setEmulatedMedia", { features: [ { name: "prefers-color-scheme", value: colorScheme }, { name: "prefers-reduced-motion", value: reducedMotion }, ], }); const rootScheme = "getComputedStyle(document.documentElement).colorScheme"; const rootToken = (name) => `getComputedStyle(document.documentElement).getPropertyValue("--ekklesia-${name}").trim()`; const option = (group, value) => `document.querySelector('[data-testid="appearance-${group}-${value}"] input')`; const savedPreferences = () => responses.filter( (response) => response.url === `${apiOrigin}/api/v1/account/preferences` && response.status === 200, ).length; /** * Chooses one option on the Appearance page and waits until the account has saved it. An option * already chosen needs no save: a radio that is already checked does not change. */ async function choose(group, value) { if (await evaluate(`Boolean(${option(group, value)}?.checked)`)) { return true; } const before = savedPreferences(); await evaluate(`${option(group, value)}.click()`); const deadline = Date.now() + timeoutMs; while (savedPreferences() <= before && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 100)); } return ( savedPreferences() > before && (await waitFor( `${option(group, value)}?.checked && ${selector("appearance-status")}?.textContent.includes("Saved")`, `${group} ${value} to be saved`, )) ); } await emulate("light", "no-preference"); await navigate(`${dashboardOrigin}/`); await waitFor(selector("host-account-menu"), "the account control"); check( await waitFor( `${selector("host-account-menu")}.textContent.includes(${JSON.stringify(required("SMOKE_OWNER_EMAIL"))}) && ${selector("host-account-menu")}.querySelector('[data-testid="account-avatar"]')`, "the account control to name the signed-in person", ), "the account control shows who is signed in, with their initials", ); await evaluate(`${selector("host-account-menu")}.click()`); await waitFor(selector("host-appearance-link"), "Appearance in the account menu"); await evaluate(`${selector("host-appearance-link")}.click()`); check( await waitFor( `location.pathname === "/account/appearance" && ${selector("account-appearance")}`, "the Appearance page", ), "Appearance opens from the account menu at /account/appearance", ); // Start from System for both, whatever an earlier pass left on this account. check( (await choose("appearance", "system")) && (await choose("motion", "system")), "System appearance and System motion save to the account", ); const lightBackground = await evaluate(`getComputedStyle(document.body).backgroundColor`); await emulate("dark", "no-preference"); check( await waitFor(`${rootScheme} === "dark"`, "System to follow a dark operating system"), "with System chosen, an operating system switching to dark restyles the page live", ); await emulate("light", "no-preference"); check( await waitFor(`${rootScheme} === "light"`, "System to follow a light operating system"), "and switching back to light restyles it again, with no reload", ); check(await choose("appearance", "dark"), "choosing Dark saves it to the account"); check( await waitFor( `${rootScheme} === "dark" && getComputedStyle(document.body).backgroundColor !== ${JSON.stringify(lightBackground)}`, "Dark to take effect under a light operating system", ), "Dark takes effect at once, overriding a light operating system", ); await navigate(`${dashboardOrigin}/account/appearance`); check( await waitFor( `${selector("account-appearance")} && ${option("appearance", "dark")}?.checked && ${rootScheme} === "dark" && localStorage.getItem("ekklesia.dashboard.appearance") === "dark system"`, "Dark after a reload", ), "a reload keeps Dark: the account holds it, and this browser cached two words for first paint", ); // First paint without the application: only the pre-paint script runs, and it already applies Dark. await send("Network.setBlockedURLs", { urls: ["*/assets/ekklesia-dashboard/app.js"] }); await navigate(`${dashboardOrigin}/`); check( await waitFor( `document.readyState === "complete" && !${selector("host-shell")} && document.documentElement.getAttribute("data-ekklesia-appearance") === "dark" && document.querySelector('meta[name="color-scheme"]').content === "dark"`, "the pre-paint script alone to apply Dark", ), "before the application script runs at all, the pre-paint script has applied Dark", ); await send("Network.setBlockedURLs", { urls: [] }); if (openedModuleId) { const moduleScheme = `(() => { const host = document.querySelector("[data-ekklesia-module-ui]"); const inner = host?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]"); if (!inner) return null; const style = getComputedStyle(inner); return { page: style.getPropertyValue("--ekklesia-background-page").trim(), scheme: style.colorScheme }; })()`; await navigate(`${dashboardOrigin}/${openedModuleId}`); await waitFor( `${selector("module-container")}?.childElementCount > 0 && (${moduleScheme})?.scheme === "dark"`, "the module's isolated root in Dark", ); const darkModule = await evaluate(moduleScheme); check( darkModule?.scheme === "dark" && darkModule.page === (await evaluate(rootToken("background-page"))), `the ${openedModuleId} module's Shadow DOM root follows Dark through the tokens`, ); await navigate(`${dashboardOrigin}/account/appearance`); await waitFor(selector("account-appearance"), "the Appearance page"); await emulate("dark", "no-preference"); check(await choose("appearance", "light"), "choosing Light saves it to the account"); check( await waitFor(`${rootScheme} === "light"`, "Light under a dark operating system"), "Light takes effect at once, overriding a dark operating system", ); await navigate(`${dashboardOrigin}/${openedModuleId}`); await waitFor( `${selector("module-container")}?.childElementCount > 0 && (${moduleScheme})?.scheme === "light"`, "the module's isolated root in Light", ); const lightModule = await evaluate(moduleScheme); check( lightModule?.scheme === "light" && lightModule.page !== darkModule?.page, `the ${openedModuleId} module's Shadow DOM root follows Light too`, ); await navigate(`${dashboardOrigin}/account/appearance`); await waitFor(selector("account-appearance"), "the Appearance page"); } // Reduced motion: System follows the operating system; Allow and Reduce override it. const shortDuration = rootToken("motion-duration-short"); await emulate("light", "reduce"); check( await waitFor(`${shortDuration} === "0ms"`, "System motion to follow a reduce request"), "with System motion, an operating system asking for less motion zeroes durations", ); check(await choose("motion", "allow"), "choosing Allow saves it to the account"); check( await waitFor(`${shortDuration} !== "0ms"`, "Allow under a reduce request"), "Allow keeps the platform's subtle motion even when the system asks for less", ); await emulate("light", "no-preference"); check(await choose("motion", "reduce"), "choosing Reduce saves it to the account"); check( await waitFor(`${shortDuration} === "0ms"`, "Reduce with no system request"), "Reduce zeroes motion even when the system asks for none", ); if (displayPath !== null) { // The display host is a lobby screen, not a person: it stays Light whatever the OS or the account. check(await choose("appearance", "dark"), "Dark is chosen again before visiting a display"); await emulate("dark", "no-preference"); await navigate(`${apiOrigin}${displayPath}`); check( await waitFor( `${selector("display-module-container")}?.childElementCount > 0 && ${rootScheme} === "light"`, "the display to stay Light", ), "the display page stays Light under a dark operating system and a Dark account choice", ); await emulate("light", "no-preference"); } await send("Emulation.setDeviceMetricsOverride", { deviceScaleFactor: 2, height: 844, mobile: true, width: 390, }); await navigate(`${dashboardOrigin}/account/appearance`); check( await waitFor( `${selector("account-appearance")} && document.documentElement.scrollWidth <= window.innerWidth && [...document.querySelectorAll(".ek-shell-choice")].length === 6 && [...document.querySelectorAll(".ek-shell-choice")].every((element) => element.getBoundingClientRect().height >= 44)`, "the Appearance page at phone width", ), "at phone width the Appearance page fits without sideways scrolling, every option a 44px+ target", ); check( (await choose("appearance", "system")) && (await choose("motion", "system")), "the account is put back to System for both", ); await send("Emulation.setDeviceMetricsOverride", { deviceScaleFactor: 1, height: 1080, mobile: false, width: 1920, }); await send("Emulation.setEmulatedMedia", { features: [] }); // --- Sign out on the Dashboard, from the account menu at the bottom of the sidebar --------------- await navigate(`${dashboardOrigin}/`); await waitFor(selector("host-account-menu"), "the account control"); await evaluate(`${selector("host-account-menu")}.click()`); await waitFor(selector("host-logout"), "the sign-out item in the account menu"); 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"); check( await evaluate( `localStorage.getItem("ekklesia.dashboard.appearance") === null && document.documentElement.getAttribute("data-ekklesia-appearance") === "system"`, ), "sign-out forgets this browser's cached appearance, and the page follows the system", ); await cdp.send("Target.closeTarget", { targetId }); cdp.close(); if (failures > 0) { console.error(`${failures} check(s) failed`); process.exit(1); }