CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m17s
CI / image-build (pull_request) Successful in 1m10s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
The Dashboard had one address. The API served the page only at `/`, both reference proxies forwarded only `/`, and the open module lived in React state, so a refresh went back to the first module, nothing could be bookmarked, and Back left the Dashboard (DESIGN.md §2.7). ADR 0057 makes the address bar the source of truth: - `/` is a host-owned Overview, now the landing page; `/modules/<id>` mounts that module. An id the server's navigation does not list shows "This page isn't available" and loads nothing; any other Dashboard address is a host-owned "Page not found". - `/modules`, `/settings`, and `/account` are reserved whole, from one list (`@ekklesia/shell/dashboard-routes`) that the shell's router and the API's page routes both read. The API serves the same frozen page, ETag, and security headers anywhere inside them; look-alikes and other methods stay the sanitized 404. - A small host-owned history replaces the selected-module state, built in the entry point so it stays the only file touching `window`. Navigation items are real links; modified clicks are left to the browser. Focus moves to the page after an in-place change, and the tab is named after the page. - Sign-in still happens in place with no return value (ADR 0034), so a deep link opens its own page after sign-in. - Both reference Caddyfiles forward the new paths; the deploy smoke now reloads a module's address through the real proxy and requires it to mount again. Co-Authored-By: Claude Opus 5.5 <[email protected]>
304 lines
12 KiB
JavaScript
304 lines
12 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");
|
|
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 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}/modules/${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",
|
|
);
|
|
}
|
|
|
|
// --- 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);
|
|
}
|