Files
Ekklesia/scripts/lib/deploy-smoke-browser.mjs
T
ekklesiaandClaude Opus 5.5 af31724cb4
CI / verify (pull_request) Failing after 57s
CI / infrastructure-smoke (pull_request) Skipped
CI / migration-smoke (pull_request) Skipped
CI / package-release (pull_request) Skipped
CI / package-publication (pull_request) Skipped
CI / image-build (pull_request) Skipped
CI / deploy-smoke (same-origin) (pull_request) Skipped
CI / deploy-smoke (split) (pull_request) Skipped
feat(shell): collapsible sidebar, account menu, and phone drawer
The Dashboard's shell now follows DESIGN.md §4 (ADR 0058):

- A collapsible left sidebar replaces the header-and-column layout. It
  is icons and labels when expanded and icons only when collapsed; each
  collapsed item keeps its name, visually hidden, and shows a tooltip
  on hover and on keyboard focus (WCAG 2.2 §1.4.13). The Overview comes
  first, then the server's groups and modules. There is no top bar.
- The expanded/collapsed choice is remembered in this browser's
  localStorage, behind a SidebarPreference interface that a synced
  store can replace later.
- An account menu at the bottom of the sidebar follows the WAI-ARIA menu
  button pattern and offers only Sign out, which now lives there rather
  than in the header.
- On phones (below 48em) the same navigation opens in a modal drawer:
  the page behind it is inert and focus is trapped inside it. Escape,
  its close button, and the backdrop close it and return focus to the
  menu button. Choosing a page closes it and focuses the page. Changing
  layout keeps the route, the desktop mode, and the mounted module.
- Lucide (lucide-react 1.48.0, shell only) is Ekklesia's icon
  vocabulary. Every module shows one generic icon until manifest v9
  lets modules declare their own; there is no per-module icon map.
- The identity slot is a neutral placeholder with no inputs, waiting
  for branding (#101).

The tooltip, menu, and drawer stay shell-local until a second consumer
needs them (ADR 0039). The deploy smoke now signs out through the
account menu, and drives the drawer at phone size in a real browser.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 17:49:56 -05:00

371 lines
14 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 = [];
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 -------------------------------------------------
// 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",
);
// --- 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");
await cdp.send("Target.closeTarget", { targetId });
cdp.close();
if (failures > 0) {
console.error(`${failures} check(s) failed`);
process.exit(1);
}