Files
Ekklesia/scripts/lib/deploy-smoke-browser.mjs
T
ekklesiaandClaude Opus 5.5 4671622b9d Add the module navigation contract: manifest v9, module routes, return-to-origin
Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard.

- Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated
  MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe
  label, kebab-case path relative to the module root; no traversal, schemes,
  queries, duplicates, or nesting). Validated before activation.
- Version 8 stays accepted beside version 9, each read strictly as itself
  (discriminated union); no released manifest is rewritten. The template
  moves to v9; the released Signage 0.1.5 manifest is a test fixture.
- Real module routes: /<id> is the landing view, /<id>/<page> a declared
  page, anything beneath a page a module-owned detail route; anything else
  is not-found and mounts nothing. api/assets/display are never pages.
- Sidebar: declared icons, pages nested under an open module, one
  aria-current, collapsed and drawer behaviour; v8 modules unchanged.
- module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe,
  navigate, link, settings), refusing anything outside the module's routes;
  undefined on the display host. Mount ABI unchanged.
- Return-to-origin: one `?return=` carrier on Settings URLs, validated by
  parseReturnTarget and against running modules' declared routes; Settings
  Back returns to exactly the originating module page, across refreshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:21:50 -05:00

880 lines
37 KiB
JavaScript

// 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",
);
// --- Module navigation (ADR 0063): pages in the sidebar, real routes, module → Settings → back ---
if (openedModuleId) {
const moduleId = openedModuleId;
const declared =
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.id === ${JSON.stringify(moduleId)})?.navigation ?? null;
})`);
const inModule = (inner) =>
`document.querySelector("[data-ekklesia-module-ui]")?.shadowRoot?.querySelector(${JSON.stringify(inner)})`;
const moduleMounted = `${selector("module-container")}?.childElementCount > 0 && !${selector("module-error")}`;
const isCurrent = (testId) => `${selector(testId)}?.getAttribute("aria-current") === "page"`;
if (declared === null) {
check(false, "the navigation view describes the opened module");
} else if (declared.pages.length === 0) {
// A manifest v8 module (or a v9 one with no pages): exactly as before.
await navigate(`${dashboardOrigin}/${moduleId}`);
check(
await waitFor(
`${moduleMounted} && ${isCurrent(`select-${moduleId}`)} && !${selector(`module-subnavigation-${moduleId}`)} && ${selector(`select-${moduleId}`)}?.querySelector("svg")?.dataset.icon === ${JSON.stringify(declared.icon === null ? "module" : `module:${declared.icon}`)}`,
"the single-page module with its icon and no sub-navigation",
),
`${moduleId} declares no pages: one sidebar item with its icon, mounted at its root`,
);
await navigate(`${dashboardOrigin}/${moduleId}/definitely-not-a-page`);
check(
await waitFor(
`${selector("host-not-found")} && !(${selector("module-container")}?.childElementCount > 0)`,
"not-found beneath a single-page module",
),
`an address beneath ${moduleId}'s root is not found, and mounts nothing`,
);
} else {
const [first, second] = declared.pages;
const pagePath = (page) => `/${moduleId}/${page.path}`;
await navigate(`${dashboardOrigin}/${moduleId}`);
check(
await waitFor(
`${moduleMounted} && ${isCurrent(`select-${moduleId}`)} && ${selector(`module-subnavigation-${moduleId}`)}?.querySelectorAll("a").length === ${declared.pages.length} && ${selector(`select-${moduleId}`)}?.querySelector("svg")?.dataset.icon === ${JSON.stringify(declared.icon === null ? "module" : `module:${declared.icon}`)}`,
"the module's pages beneath it in the sidebar",
),
`${moduleId}'s declared icon and ${declared.pages.length} pages appear beneath it while it is open`,
);
await evaluate(
`window.__ekklesiaModuleRoot = document.querySelector("[data-ekklesia-module-ui]")`,
);
await evaluate(`${selector(`select-${moduleId}-${first.id}`)}.click()`);
check(
await waitFor(
`location.pathname === ${JSON.stringify(pagePath(first))} && ${isCurrent(`select-${moduleId}-${first.id}`)} && document.querySelector("[data-ekklesia-module-ui]") === window.__ekklesiaModuleRoot`,
"the first page, without remounting the module",
),
`choosing ${first.label} opens ${pagePath(first)} in place, marks it, and keeps the module mounted`,
);
if (second) {
await navigate(`${dashboardOrigin}${pagePath(second)}`);
check(
await waitFor(
`location.pathname === ${JSON.stringify(pagePath(second))} && ${moduleMounted} && ${isCurrent(`select-${moduleId}-${second.id}`)}`,
"a declared page from its deep link",
),
`a deep link to ${pagePath(second)} opens that page (what a refresh or bookmark does)`,
);
await evaluate("history.back()");
check(
await waitFor(
`location.pathname === ${JSON.stringify(pagePath(first))} && ${isCurrent(`select-${moduleId}-${first.id}`)} && ${moduleMounted}`,
"Back to the previous page",
),
"Back returns to the previously open page",
);
await evaluate("history.forward()");
check(
await waitFor(
`location.pathname === ${JSON.stringify(pagePath(second))} && ${isCurrent(`select-${moduleId}-${second.id}`)}`,
"Forward to the next page",
),
"Forward returns to it again",
);
}
await navigate(`${dashboardOrigin}/${moduleId}/definitely-not-a-page`);
check(
await waitFor(
`${selector("host-not-found")} && !(${selector("module-container")}?.childElementCount > 0)`,
"not-found for an undeclared page",
),
`an undeclared page under ${moduleId} is not found, and mounts nothing`,
);
if (moduleId === "example") {
// The template's own pages: a detail route beneath Records, and its Settings link.
await navigate(`${dashboardOrigin}/example/records`);
await waitFor(
inModule('[data-testid^="example-record-link-"]'),
"a record link inside the module",
);
await evaluate(`${inModule('[data-testid^="example-record-link-"]')}.click()`);
check(
await waitFor(
`location.pathname.startsWith("/example/records/") && ${isCurrent("select-example-records")} && ${inModule('[data-testid="example-record-value"]')}`,
"the record's detail route",
),
"a detail route beneath a declared page opens inside the module, its page marked",
);
await navigate(`${dashboardOrigin}/example`);
await waitFor(
inModule('[data-testid="example-open-settings"]'),
"the module's Settings button",
);
await evaluate(`${inModule('[data-testid="example-open-settings"]')}.click()`);
check(
await waitFor(
`location.pathname === "/settings/example" && location.search === "?return=%2Fexample" && ${selector("module-settings")}`,
"the module's Settings page, carrying where it came from",
),
"the module opens its centralized Settings page through its navigation capability",
);
}
// Settings → Back to exactly the module page that sent the person there, across a refresh.
await navigate(
`${dashboardOrigin}/settings/${moduleId}?return=${encodeURIComponent(pagePath(first))}`,
);
check(
await waitFor(
`${selector("module-settings")}?.querySelector(".ek-page-header__back")?.getAttribute("href") === ${JSON.stringify(pagePath(first))}`,
"Back to the originating module page",
),
"after a refresh, Settings' Back still names the exact module page it was opened from",
);
await evaluate(`${selector("module-settings")}.querySelector(".ek-page-header__back").click()`);
check(
await waitFor(
`location.pathname === ${JSON.stringify(pagePath(first))} && ${moduleMounted} && ${isCurrent(`select-${moduleId}-${first.id}`)}`,
"the originating module page",
),
`Back from Settings returns to exactly ${pagePath(first)}`,
);
await navigate(
`${dashboardOrigin}/settings/${moduleId}?return=${encodeURIComponent("https://evil.example.test/")}`,
);
check(
await waitFor(
`${selector("module-settings")}?.querySelector(".ek-page-header__back")?.getAttribute("href") === "/settings"`,
"an unsafe return target ignored",
),
"an off-site return target is ignored: Back goes to Settings",
);
// The phone drawer: the identical hierarchy, closing once a page is chosen.
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 2,
height: 844,
mobile: true,
width: 390,
});
await navigate(`${dashboardOrigin}/${moduleId}`);
await waitFor(selector("mobile-navigation-trigger"), "the phone navigation trigger");
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
await waitFor(
`${selector("navigation-drawer")}?.querySelector('[data-testid="module-subnavigation-${moduleId}"]')`,
"the module's pages in the drawer",
);
await evaluate(`${selector(`select-${moduleId}-${first.id}`)}.click()`);
check(
await waitFor(
`location.pathname === ${JSON.stringify(pagePath(first))} && !${selector("navigation-drawer")} && ${moduleMounted}`,
"a page chosen from the drawer",
),
"the phone drawer shows the module's pages, and closes once one is chosen",
);
await send("Emulation.setDeviceMetricsOverride", {
deviceScaleFactor: 1,
height: 1080,
mobile: false,
width: 1920,
});
}
}
// --- 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);
}