CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s
Real-device review of the platform display flow found four defects in display management: - Shared SelectMenu in @ekklesia/ui (additive): an Ekklesia-drawn single choice following the WAI-ARIA select-only combobox pattern, replacing the native select for "Show on this screen". It has a themed list, a chevron inside the control, keyboard support (arrows, Home/End, type-ahead, Enter/Space/Tab, and Escape that closes only the list), pointer and touch support, and focus that stays on the control. It is an ek-control, so it keeps the 16px phone size. - Newly paired displays appear without a reload. Approval doesn't create the display; the screen does when it claims its credential a few seconds later. The page now shows a "Waiting for the screen to connect" placeholder and refetches every second until the new display appears (for up to a minute), and refreshes the list every 30 seconds. - What a display shows can change after pairing. In the single-target Stage 1 model, a display's current target is its one active display permission, so no migration is needed. PUT /api/v1/devices/:id/display, with core.devices.manage, a trusted origin and a bounded body, revokes its other active permission rows (kept as history) and grants the chosen module's declared display permission, in one transaction on the same device. The screen notices within about 30 seconds: a paired device on a module page that is no longer set to show it goes to /display/setup instead of re-pairing, and /display/setup opens the new target. - Revoked displays leave the normal list at once. Their rows stay in the database as history. Each display card now shows its name, Online/Offline/Not connected yet, "Showing: ...", last seen and paired dates, and Change and Revoke. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1109 lines
48 KiB
JavaScript
1109 lines
48 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's own /display/setup on the API
|
|
// origin, whatever is installed, with Add display right under it.
|
|
check(
|
|
await waitFor(
|
|
`${selector("display-setup")}?.contains(${selector("add-display")}) && ${selector("display-setup-url")}?.textContent === ${JSON.stringify(`${apiOrigin}/display/setup`)}`,
|
|
"the display setup steps",
|
|
),
|
|
"Settings → Displays sets up a new screen at the platform's own /display/setup, with Add display",
|
|
);
|
|
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");
|
|
|
|
// ADR 0065, end to end: a new screen — its own browser context, holding no session, as a real
|
|
// screen would — opens the platform's /display/setup, shows a code, and is added through the real
|
|
// Settings form. It needs no module: chosen, it opens that module's display by itself; not chosen,
|
|
// it shows its name and waits.
|
|
{
|
|
const { browserContextId } = await cdp.send("Target.createBrowserContext", {});
|
|
const kiosk = await cdp.send("Target.createTarget", { browserContextId, url: "about:blank" });
|
|
const { sessionId: kioskSession } = await cdp.send("Target.attachToTarget", {
|
|
flatten: true,
|
|
targetId: kiosk.targetId,
|
|
});
|
|
const kioskEvaluate = async (expression) => {
|
|
const result = await cdp.send(
|
|
"Runtime.evaluate",
|
|
{ awaitPromise: true, expression, returnByValue: true },
|
|
kioskSession,
|
|
);
|
|
return result.exceptionDetails ? undefined : result.result.value;
|
|
};
|
|
const kioskWaitFor = async (expression, within = timeoutMs) => {
|
|
const deadline = Date.now() + within;
|
|
while (Date.now() < deadline) {
|
|
if (await kioskEvaluate(`Boolean(${expression})`).catch(() => false)) {
|
|
return true;
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
}
|
|
// What the screen was doing instead: its page, which display state it shows, and what the
|
|
// platform says it may show. Paths and test ids only, never a cookie or a body beyond that.
|
|
const state = await kioskEvaluate(`(async () => {
|
|
const shown = [...document.querySelectorAll("[data-testid]")].map((element) => element.dataset.testid).filter((id) => id.startsWith("display-")).slice(0, 8);
|
|
const self = await fetch("/api/v1/device-pairing/self", { credentials: "include" }).then(async (response) => response.status + " " + JSON.stringify((await response.json()).displays ?? null)).catch(() => "unreachable");
|
|
return { path: location.pathname, shown, self, visibility: document.visibilityState };
|
|
})()`).catch(() => "unavailable");
|
|
console.log(`info kiosk ${JSON.stringify(state)}`);
|
|
return false;
|
|
};
|
|
await cdp.send("Page.navigate", { url: `${apiOrigin}/display/setup` }, kioskSession);
|
|
const shown = (await kioskWaitFor(
|
|
`/^[0-9]{6}$/.test(${selector("display-pairing-code")}?.textContent ?? "")`,
|
|
))
|
|
? await kioskEvaluate(`${selector("display-pairing-code")}.textContent`)
|
|
: undefined;
|
|
check(shown !== undefined, "a new screen at /display/setup shows a six-digit pairing code");
|
|
if (shown !== undefined) {
|
|
await evaluate(`${selector("add-display")}.click()`);
|
|
await waitFor(selector("device-pairing-code-input"), "the Add display code field");
|
|
await evaluate(`(() => {
|
|
const input = ${selector("device-pairing-code-input")};
|
|
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(input, ${JSON.stringify(shown)});
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
input.form.requestSubmit();
|
|
})()`);
|
|
check(
|
|
await waitFor(
|
|
`${selector("device-pairing-found")}?.textContent.includes("A new screen is waiting to be paired")`,
|
|
"the platform screen found by its code",
|
|
),
|
|
"Add display finds the screen from /display/setup as a new platform display, with no module",
|
|
);
|
|
// "Show on this screen" is Ekklesia's own select (a combobox and listbox), never a native one:
|
|
// it is opened, and an option pressed, as a person would.
|
|
check(
|
|
!(await evaluate(`Boolean(${selector("add-display-dialog")}.querySelector("select"))`)),
|
|
"Add display uses the themed select, not the browser's native one",
|
|
);
|
|
const chosen = await evaluate(`(() => {
|
|
const name = ${selector("device-pairing-name")};
|
|
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(name, "Smoke platform display");
|
|
name.dispatchEvent(new Event("input", { bubbles: true }));
|
|
const shows = ${selector("device-pairing-shows")};
|
|
if (!shows) {
|
|
return "";
|
|
}
|
|
shows.click();
|
|
const list = document.getElementById(shows.getAttribute("aria-controls"));
|
|
const option = [...list.querySelectorAll('[role="option"]')].find((candidate) => candidate.dataset.value !== "");
|
|
if (!option) {
|
|
return "";
|
|
}
|
|
option.click();
|
|
return option.dataset.value;
|
|
})()`);
|
|
await waitFor(`!${selector("device-pairing-approve")}?.disabled`, "Pair display enabled");
|
|
await evaluate(`${selector("device-pairing-approve")}.click()`);
|
|
// The display's own card — keyed by its device id — not the success notice or the "waiting for
|
|
// the screen" placeholder, which carry its name before the display exists.
|
|
const newestCard = `[...document.querySelectorAll('li[data-testid^="device-"]')].filter((card) => /^device-[0-9a-f-]{36}$/.test(card.dataset.testid) && card.textContent.includes("Smoke platform display")).at(-1)`;
|
|
check(
|
|
await waitFor(
|
|
`!${selector("add-display-dialog")} && ${newestCard}`,
|
|
"the new platform display in the list",
|
|
),
|
|
"the paired platform display appears under Paired displays without a reload",
|
|
);
|
|
check(
|
|
await kioskWaitFor(
|
|
chosen === ""
|
|
? `${selector("display-setup-waiting-headline")}?.textContent === "Smoke platform display"`
|
|
: `location.pathname === ${JSON.stringify(`/display/${chosen}`)}`,
|
|
),
|
|
chosen === ""
|
|
? "with no module to show, the paired screen shows its name and waits"
|
|
: "the paired screen opens the module display it was set to show, by itself",
|
|
);
|
|
|
|
// What it shows can change later, on the same display: from Settings, through Change.
|
|
const cardId = await evaluate(`${newestCard}?.dataset.testid ?? ""`);
|
|
check(cardId !== "", "the paired display's own card is listed, to manage");
|
|
if (chosen !== "" && cardId !== "") {
|
|
await evaluate(`${selector(cardId.replace("device-", "device-change-"))}.click()`);
|
|
await waitFor(selector("device-change-shows"), "the Change dialog");
|
|
await evaluate(`(() => {
|
|
const shows = ${selector("device-change-shows")};
|
|
shows.click();
|
|
const list = document.getElementById(shows.getAttribute("aria-controls"));
|
|
[...list.querySelectorAll('[role="option"]')].find((candidate) => candidate.dataset.value === "").click();
|
|
})()`);
|
|
await evaluate(`${selector("device-change-save")}.click()`);
|
|
check(
|
|
await waitFor(
|
|
`!${selector("device-change-dialog")} && ${selector(`${cardId}-showing`)}?.textContent === "Showing: Nothing yet"`,
|
|
"the display showing nothing",
|
|
),
|
|
"Change sets what a paired display shows, and the list says so at once",
|
|
);
|
|
check(
|
|
await kioskWaitFor(
|
|
`location.pathname === "/display/setup" && ${selector("display-setup-waiting-headline")}?.textContent === "Smoke platform display"`,
|
|
75_000,
|
|
),
|
|
"the screen notices the change on its own and leaves the module, as the same paired display",
|
|
);
|
|
}
|
|
|
|
// Revoked, it leaves the normal list at once, and its screen returns to a pairing code.
|
|
if (cardId !== "") {
|
|
await evaluate(`${selector(cardId.replace("device-", "device-revoke-"))}.click()`);
|
|
await waitFor(selector("device-revoke-confirm"), "the revoke confirmation");
|
|
await evaluate(`${selector("device-revoke-confirm")}.click()`);
|
|
check(
|
|
await waitFor(
|
|
`!${selector(cardId)} && ${selector("device-revoke-success")}`,
|
|
"the revoked display gone from the list",
|
|
),
|
|
"a revoked display leaves Paired displays at once, without a reload",
|
|
);
|
|
check(
|
|
await kioskWaitFor(
|
|
`/^[0-9]{6}$/.test(${selector("display-pairing-code")}?.textContent ?? "")`,
|
|
75_000,
|
|
),
|
|
"the revoked screen returns to a pairing code on its own",
|
|
);
|
|
}
|
|
}
|
|
await cdp.send("Target.closeTarget", { targetId: kiosk.targetId });
|
|
await cdp.send("Target.disposeBrowserContext", { browserContextId });
|
|
}
|
|
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.
|
|
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);
|
|
}
|