Add the account area and account-synced Appearance preferences
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance chooses System, Light, or Dark and reduced motion (System, Reduce, Allow). - Core: ekklesia_core_user_preference, one typed, CHECK-constrained, nullable column per preference (null = never chosen), migration 0008_user_preferences (additive), @ekklesia/core/account store, and the authenticated-baseline permission core.account.self.manage. - API: GET /api/v1/account (name, email, preferences) and partial PATCH /api/v1/account/preferences, self-only by construction: no user id is accepted anywhere; trusted Origin and body limit on the mutation. - Shell: the account control shows name, email, and initials (never the unvetted image URL) and offers Account, Appearance, Sign out; native radio groups apply a choice at once through the ADR 0060 controller and save it; the server's answer wins and a failed save reverts. - First paint: a classic, same-origin, render-blocking appearance.js in <head> applies a two-word localStorage cache before paint (no inline script, CSP unchanged). The account replaces the cache on every load, sign-out clears it, and other tabs follow via the storage event. - Deploy smoke: real-Chromium checks for light/dark, OS emulation, overrides, reload persistence, pre-paint, Shadow DOM modules, reduced motion, the display staying light, and phone width; the browser container gets a 512 MiB /dev/shm so a long session no longer fails requests with ERR_INSUFFICIENT_RESOURCES. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8571e6854c
commit
cd0287537e
@@ -102,10 +102,27 @@ const send = (method, params) => cdp.send(method, params, sessionId);
|
||||
* `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 });
|
||||
@@ -145,6 +162,37 @@ async function waitFor(expression, description) {
|
||||
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;
|
||||
}
|
||||
@@ -421,6 +469,208 @@ check(
|
||||
"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");
|
||||
@@ -435,6 +685,12 @@ const principal = await evaluate(`fetch(${JSON.stringify(`${apiOrigin}/api/v1/pr
|
||||
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();
|
||||
|
||||
@@ -73,8 +73,9 @@ async function edgeChecks() {
|
||||
check(
|
||||
page.headers.get("content-type") === "text/html; charset=utf-8" &&
|
||||
html.includes('id="ekklesia-host"') &&
|
||||
html.includes('src="/assets/ekklesia-dashboard/app.js"'),
|
||||
"GET / is the Dashboard HTML",
|
||||
html.includes('src="/assets/ekklesia-dashboard/app.js"') &&
|
||||
html.includes('<script src="/assets/ekklesia-dashboard/appearance.js"></script>'),
|
||||
"GET / is the Dashboard HTML, with its pre-paint appearance script (ADR 0062)",
|
||||
);
|
||||
check(
|
||||
page.headers.get("x-frame-options") === "DENY" &&
|
||||
@@ -90,7 +91,12 @@ async function edgeChecks() {
|
||||
|
||||
// Every Dashboard page answers with that same page, so a refresh stays put (ADR 0057). No module
|
||||
// is installed yet at this point; a module's own address is reloaded in a real browser later.
|
||||
for (const deepLink of ["/settings", "/settings/displays/add", "/account"]) {
|
||||
for (const deepLink of [
|
||||
"/settings",
|
||||
"/settings/displays/add",
|
||||
"/account",
|
||||
"/account/appearance",
|
||||
]) {
|
||||
const deepPage = await fetch(`${dashboardOrigin}${deepLink}`);
|
||||
check(
|
||||
deepPage.status === 200 &&
|
||||
@@ -113,6 +119,17 @@ async function edgeChecks() {
|
||||
await script.arrayBuffer();
|
||||
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
|
||||
|
||||
const appearanceScript = await fetch(
|
||||
`${dashboardOrigin}/assets/ekklesia-dashboard/appearance.js`,
|
||||
);
|
||||
const appearanceSource = await appearanceScript.text();
|
||||
check(
|
||||
appearanceScript.status === 200 &&
|
||||
appearanceScript.headers.get("content-type") === "text/javascript; charset=utf-8" &&
|
||||
appearanceSource.includes("ekklesia.dashboard.appearance"),
|
||||
"GET /assets/ekklesia-dashboard/appearance.js is the pre-paint script",
|
||||
);
|
||||
|
||||
const outside = await fetch(`${dashboardOrigin}/favicon.ico`);
|
||||
check(
|
||||
outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"),
|
||||
@@ -181,6 +198,10 @@ async function splitRoutingChecks() {
|
||||
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/app.js`),
|
||||
"the API hostname refuses the Dashboard's script",
|
||||
);
|
||||
check(
|
||||
await refusedByProxy(`${apiOrigin}/assets/ekklesia-dashboard/appearance.js`),
|
||||
"the API hostname refuses the Dashboard's pre-paint script",
|
||||
);
|
||||
const display = await fetch(`${apiOrigin}/display/does-not-exist`);
|
||||
check(
|
||||
display.status === 404 && (await display.text()) === '{"code":"NOT_FOUND"}',
|
||||
@@ -460,6 +481,38 @@ async function sessionChecks() {
|
||||
"the session resolves an authenticated principal through the proxy",
|
||||
);
|
||||
|
||||
// The person's own account (ADR 0062): who they are, and a preference that round-trips.
|
||||
const account = await fetch(`${apiOrigin}/api/v1/account`, {
|
||||
headers: { cookie, origin: dashboardOrigin },
|
||||
});
|
||||
const accountBody = await account.json();
|
||||
check(
|
||||
account.status === 200 &&
|
||||
accountBody.email === required("SMOKE_OWNER_EMAIL") &&
|
||||
Object.keys(accountBody).sort().join(",") === "email,name,preferences",
|
||||
"GET /api/v1/account returns the signed-in person's own name, email, and preferences only",
|
||||
);
|
||||
const changePreferences = async (body) => {
|
||||
const response = await fetch(`${apiOrigin}/api/v1/account/preferences`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
|
||||
method: "PATCH",
|
||||
});
|
||||
return { body: await response.json(), status: response.status };
|
||||
};
|
||||
const saved = await changePreferences({ motion: "reduce" });
|
||||
check(
|
||||
saved.status === 200 && saved.body.motion === "reduce",
|
||||
"PATCH /api/v1/account/preferences saves a change to the person's own account",
|
||||
);
|
||||
const restored = await changePreferences(accountBody.preferences);
|
||||
check(restored.status === 200, "the account's previous preferences are put back");
|
||||
const anonymousAccount = await fetch(`${apiOrigin}/api/v1/account`, {
|
||||
headers: { origin: dashboardOrigin },
|
||||
});
|
||||
await anonymousAccount.arrayBuffer();
|
||||
check(anonymousAccount.status === 403, "an anonymous caller is refused the account");
|
||||
|
||||
const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, {
|
||||
body: "{}",
|
||||
headers: { "content-type": "application/json", cookie, origin: dashboardOrigin },
|
||||
|
||||
@@ -607,8 +607,11 @@ if [[ "$topology" == split ]]; then
|
||||
docker rm --force "$browser_container" >/dev/null 2>&1 || true
|
||||
# The image's entry script passes its arguments on unquoted, so none may contain a space: the
|
||||
# hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule.
|
||||
# Docker's default 64 MiB /dev/shm is too small for one tab that loads the Dashboard, a module,
|
||||
# and its live streams dozens of times: Chromium then fails requests with
|
||||
# ERR_INSUFFICIENT_RESOURCES partway through. A real desktop has far more.
|
||||
docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \
|
||||
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \
|
||||
--cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp --shm-size 512m \
|
||||
--network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \
|
||||
"$browser_image" \
|
||||
"--ignore-certificate-errors-spki-list=${spki}" \
|
||||
|
||||
Reference in New Issue
Block a user