CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m29s
CI / image-build (pull_request) Successful in 1m4s
CI / deploy-smoke (same-origin) (pull_request) Successful in 54s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m56s
The Settings foundation (ADR 0061):
- URL model. /settings is a real landing page. The platform sections
are /settings/general, /branding, /displays, and /system. Module
lifecycle is Discover: /settings/discover/browse, /installed, and
/updates. /settings/discover is respelled in place to Browse's one
canonical address. A module's settings are /settings/<module-id>,
never nested under Discover. Deeper Settings addresses are
reserved. "Modules" stays the user-facing word: "Discover modules",
"Installed modules", "Module updates".
- Collisions. Core's one reserved list gains the Settings section
names (branding, discover, displays, general, system) as a second
documented group, so no module id can shadow a section; `modules` is
an ordinary id. apps/api tests hold it equal to the shell's sections.
- Navigation. One Settings item in the same sidebar expands in place
into Platform and Modules (and Discover's three views) while in
Settings, and the phone drawer has the same hierarchy. The rest of
the navigation stays.
- Permissions. GET /api/v1/dashboard/settings evaluates, through
Core's evaluator, which destinations the viewer may see:
- Displays: core.devices.manage
- Discover: core.modules.lifecycle.manage
- a module's settings: core.modules.configuration.manage, for each
module that declares configuration (ADR 0029)
- General, Branding, System: the platform owner, until each has its
own permission
These are presentation hints; every data route keeps its own
enforcement. With nothing visible, there is no Settings link.
- Pages are honest placeholders with Back to Settings. A disabled
module's settings say it is turned off; a section or module the
person cannot see says so without detail; an unknown name is not
found; a failed load withholds the link and offers Retry.
- The deploy smoke opens Settings by deep link, refreshes a section,
enters Discover, opens the module's settings, uses the phone
drawer, and returns to the Dashboard.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
445 lines
17 KiB
JavaScript
445 lines
17 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",
|
|
);
|
|
|
|
// --- 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",
|
|
);
|
|
|
|
// --- 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);
|
|
}
|