CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
A second real-iPhone review of the Stage 1 Dashboard found three defects. This fixes them, and ADR 0065 records the decisions. - Settings -> Displays now sets up a screen in one place. A "Set up a new screen" panel shows the platform display page to open on the screen, a Copy link button, and Add display directly beneath it; the step-by-step version is folded into a <details>. The addresses come from a new GET /api/v1/devices/setup, which requires core.devices.manage and lists each running display module's /display/<module-id> page. Nothing names Signage. With no display module installed, the panel says so and offers no Add display. - iOS Safari zoomed into the pairing field because the shared .ek-control rendered at 14px. On phones and touch screens it now renders at max(1rem, 16px). The viewport is untouched, so zoom stays available, and a test guards that. The shared focus trap now returns focus with preventScroll, so closing a dialog no longer moves the page under the phone header. - While the phone drawer is open, the page behind it no longer scrolls. The root's overflow is hidden, a non-passive touchmove listener cancels drags outside the drawer's list and overscroll past its ends, and root overscroll is turned off. The list itself still scrolls. Closing restores the exact scroll position unless another page opened. Co-Authored-By: Claude Opus 5.5 <[email protected]>
960 lines
41 KiB
JavaScript
960 lines
41 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, ADR 0064): a deep link, a refresh, Displays, 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",
|
|
);
|
|
check(
|
|
await evaluate(
|
|
`!${selector("host-sidebar")}.querySelector("input, form") && document.activeElement?.tagName !== "INPUT"`,
|
|
),
|
|
"the sidebar is navigation only, and arriving at Displays focuses no field",
|
|
);
|
|
// ADR 0065: how to set up a new screen, in one place — the platform display page of every module
|
|
// that uses displays, on the API origin where the display host lives, with Add display right under
|
|
// it; or a plain note, and no Add display, when no installed module uses displays.
|
|
check(
|
|
await waitFor(
|
|
`${selector("display-setup")} && !${selector("display-setup-failed")} && (${selector("display-setup-none")} || (${selector("display-setup")}.contains(${selector("add-display")}) && [...document.querySelectorAll('[data-testid="display-setup-url"]')].every((element) => element.textContent.startsWith(${JSON.stringify(`${apiOrigin}/display/`)}))))`,
|
|
"the display setup steps",
|
|
),
|
|
"Settings → Displays explains how to set up a new screen, from the platform's own display page",
|
|
);
|
|
const displayModuleInstalled = !(await evaluate(`Boolean(${selector("display-setup-none")})`));
|
|
if (displayModuleInstalled) {
|
|
await evaluate(`${selector("add-display")}.click()`);
|
|
check(
|
|
await waitFor(
|
|
`${selector("add-display-dialog")} && document.activeElement === ${selector("device-pairing-code-input")}`,
|
|
"the Add display dialog with its code field focused",
|
|
),
|
|
"Add display opens a dialog that focuses the pairing code only once asked",
|
|
);
|
|
await evaluate(`${selector("add-display-cancel")}.click()`);
|
|
await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing");
|
|
} else {
|
|
check(
|
|
!(await evaluate(`Boolean(${selector("add-display")})`)),
|
|
"with no module that uses displays, Settings → Displays offers no Add display that could only fail",
|
|
);
|
|
}
|
|
await navigate(`${dashboardOrigin}/settings/discover`);
|
|
check(
|
|
await waitFor(
|
|
`location.pathname === "/settings/discover" && ${selector("settings-discover-browse")} && ${selector("select-settings-discover")}?.getAttribute("aria-current") === "page"`,
|
|
"Discover's own page",
|
|
),
|
|
"Discover is its own page at /settings/discover",
|
|
);
|
|
await navigate(`${dashboardOrigin}/settings/discover/browse`);
|
|
check(
|
|
await waitFor(
|
|
`location.pathname === "/settings/discover" && ${selector("settings-discover-browse")}`,
|
|
"the earlier Browse address respelled",
|
|
),
|
|
"the earlier /settings/discover/browse respells to /settings/discover",
|
|
);
|
|
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("window.scrollTo(0, document.documentElement.scrollHeight)");
|
|
check(
|
|
await waitFor(
|
|
`${selector("mobile-header")} && ${selector("mobile-navigation-trigger")}.getBoundingClientRect().top >= 0 && document.documentElement.scrollWidth <= innerWidth`,
|
|
"the phone header after scrolling",
|
|
),
|
|
"the phone header keeps the menu in reach after scrolling, with no horizontal overflow",
|
|
);
|
|
const scrolledTo = await evaluate("scrollY");
|
|
await evaluate(`${selector("mobile-navigation-trigger")}.click()`);
|
|
await waitFor(
|
|
`${selector("navigation-drawer")}?.querySelector('[data-testid="select-settings-system"]')`,
|
|
"Settings in the phone drawer",
|
|
);
|
|
// ADR 0065: the page behind the open drawer is locked where it was, and only the drawer scrolls.
|
|
check(
|
|
await evaluate(
|
|
`getComputedStyle(document.documentElement).overflowY === "hidden" && getComputedStyle(document.documentElement).overscrollBehaviorY === "none" && scrollY === ${Number(scrolledTo)}`,
|
|
),
|
|
"opening the phone drawer locks the page behind it in place",
|
|
);
|
|
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",
|
|
);
|
|
check(
|
|
await evaluate(
|
|
`getComputedStyle(document.documentElement).overflowY !== "hidden" && document.documentElement.style.getPropertyValue("overscroll-behavior") === ""`,
|
|
),
|
|
"choosing a page from the drawer releases the page lock",
|
|
);
|
|
// ADR 0065: on a phone the pairing code is typed at 16px or more, so iOS Safari does not zoom.
|
|
if (displayModuleInstalled) {
|
|
await navigate(`${dashboardOrigin}/settings/displays`);
|
|
await waitFor(selector("add-display"), "Settings → Displays on a phone");
|
|
await evaluate(`${selector("add-display")}.click()`);
|
|
check(
|
|
await waitFor(
|
|
`${selector("device-pairing-code-input")} && parseFloat(getComputedStyle(${selector("device-pairing-code-input")}).fontSize) >= 16`,
|
|
"the pairing code field's size on a phone",
|
|
),
|
|
"the pairing code field is at least 16px on a phone, so iOS does not zoom into it",
|
|
);
|
|
await evaluate(`${selector("add-display-cancel")}.click()`);
|
|
await waitFor(`!${selector("add-display-dialog")}`, "the Add display dialog closing on a phone");
|
|
}
|
|
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);
|
|
}
|