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
@@ -0,0 +1,530 @@
|
||||
import {
|
||||
createEkklesiaThemeController,
|
||||
DESIGN_TOKEN_APPEARANCE_ATTRIBUTE,
|
||||
DESIGN_TOKEN_MOTION_ATTRIBUTE,
|
||||
} from "@ekklesia/design-tokens";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type AppearanceChoice,
|
||||
createMemoryAppearanceCache,
|
||||
} from "../src/browser/appearance-cache.js";
|
||||
import { createAppearancePreference } from "../src/browser/appearance-preference.js";
|
||||
import { HostApp } from "../src/browser/HostApp.js";
|
||||
import { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js";
|
||||
import { createMemoryHostViewport } from "../src/browser/host-viewport.js";
|
||||
import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account area (ADR 0062): the account control's real identity, `/account`,
|
||||
* `/account/appearance`, and how a chosen appearance and motion take effect, persist, and are
|
||||
* reconciled with the account.
|
||||
*/
|
||||
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
|
||||
const ADA = {
|
||||
email: "[email protected]",
|
||||
name: "Ada Lovelace",
|
||||
preferences: { appearance: "dark", motion: "reduce" },
|
||||
} as const;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let consoleError: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
interface Deferred {
|
||||
readonly resolve: (response: Response) => void;
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
readonly account?:
|
||||
| typeof ADA
|
||||
| { readonly email: string; readonly name: string; readonly preferences: AppearanceChoice }
|
||||
| "fails";
|
||||
readonly cached?: AppearanceChoice;
|
||||
readonly compact?: boolean;
|
||||
readonly history?: MemoryHostHistory;
|
||||
/** Answers each preferences PATCH; by default the server merges the change, as the API does. */
|
||||
readonly save?: (body: Record<string, string>) => Response | "hold";
|
||||
readonly principalAfterSave?: "authenticated" | "anonymous";
|
||||
}
|
||||
|
||||
async function render(path: string, options: RenderOptions = {}) {
|
||||
const history = options.history ?? createMemoryHostHistory(path);
|
||||
const calls: { method: string; path: string; body?: unknown }[] = [];
|
||||
const held: Deferred[] = [];
|
||||
const titles: string[] = [];
|
||||
const rootAttributes = new Map<string, string>();
|
||||
const meta = new Map<string, string>();
|
||||
const cache = createMemoryAppearanceCache(options.cached);
|
||||
const appearance = createAppearancePreference({
|
||||
cache,
|
||||
colorSchemeMeta: { setAttribute: (name, value) => meta.set(name, value) },
|
||||
theme: createEkklesiaThemeController({
|
||||
appearance: options.cached?.appearance ?? "system",
|
||||
motion: options.cached?.motion ?? "system",
|
||||
target: { setAttribute: (name, value) => rootAttributes.set(name, value) },
|
||||
}),
|
||||
});
|
||||
let stored =
|
||||
options.account === "fails" ? undefined : { ...(options.account ?? ADA).preferences };
|
||||
let signedIn = true;
|
||||
const fetchImplementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
const method = init?.method ?? "GET";
|
||||
const body =
|
||||
typeof init?.body === "string"
|
||||
? (JSON.parse(init.body) as Record<string, string>)
|
||||
: undefined;
|
||||
calls.push({ body, method, path: url.pathname });
|
||||
if (url.pathname === "/api/v1/principal") {
|
||||
return Response.json(
|
||||
signedIn ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
|
||||
);
|
||||
}
|
||||
if (url.pathname === "/api/auth/sign-out") {
|
||||
signedIn = false;
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
if (url.pathname === "/api/v1/dashboard/navigation") {
|
||||
return Response.json({ groups: [], ungrouped: [] });
|
||||
}
|
||||
if (url.pathname === "/api/v1/dashboard/settings") {
|
||||
return Response.json({ modules: [], sections: [] });
|
||||
}
|
||||
if (url.pathname === "/api/v1/account" && method === "GET") {
|
||||
return options.account === "fails"
|
||||
? Response.json({ code: "ACCOUNT_READ_FAILED" }, { status: 503 })
|
||||
: Response.json({ ...(options.account ?? ADA), preferences: stored });
|
||||
}
|
||||
if (url.pathname === "/api/v1/account/preferences" && method === "PATCH" && body) {
|
||||
const answer = options.save?.(body);
|
||||
if (answer === "hold") {
|
||||
return new Promise<Response>((resolve) => held.push({ resolve }));
|
||||
}
|
||||
if (answer) {
|
||||
if (answer.status === 403 && options.principalAfterSave === "anonymous") {
|
||||
signedIn = false;
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
stored = {
|
||||
...(stored ?? { appearance: "system", motion: "system" }),
|
||||
...body,
|
||||
} as AppearanceChoice;
|
||||
return Response.json(stored);
|
||||
}
|
||||
return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
|
||||
}) as unknown as typeof fetch;
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<HostApp
|
||||
appearance={appearance}
|
||||
config={{ apiBaseUrl, fetch: fetchImplementation }}
|
||||
history={history}
|
||||
setDocumentTitle={(title) => titles.push(title)}
|
||||
sidebarPreference={createMemorySidebarPreference()}
|
||||
viewport={createMemoryHostViewport(options.compact ?? false)}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return {
|
||||
appearance,
|
||||
cache,
|
||||
calls,
|
||||
held,
|
||||
history,
|
||||
meta,
|
||||
patches: () => calls.filter((call) => call.method === "PATCH"),
|
||||
rootAttribute: (name: string) => rootAttributes.get(name),
|
||||
titles,
|
||||
};
|
||||
}
|
||||
|
||||
function query(testId: string): HTMLElement | null {
|
||||
return container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
|
||||
}
|
||||
|
||||
function required(testId: string): HTMLElement {
|
||||
const element = query(testId);
|
||||
if (!element) {
|
||||
throw new Error(`missing ${testId}`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
async function click(element: Element): Promise<void> {
|
||||
await act(async () => {
|
||||
element.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function radio(group: "appearance" | "motion", value: string): HTMLInputElement {
|
||||
const input = required(`appearance-${group}-${value}`).querySelector("input");
|
||||
if (!(input instanceof HTMLInputElement)) {
|
||||
throw new Error(`missing ${group} ${value} radio`);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function checked(group: "appearance" | "motion"): string | undefined {
|
||||
return (
|
||||
required(`appearance-${group}`).querySelector<HTMLInputElement>("input:checked")?.value ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
async function choose(group: "appearance" | "motion", value: string): Promise<void> {
|
||||
await click(radio(group, value));
|
||||
}
|
||||
|
||||
describe("the account control", () => {
|
||||
it("names who is signed in: their name and email beside their initials", async () => {
|
||||
await render("/");
|
||||
const trigger = required("host-account-menu");
|
||||
|
||||
expect(trigger.textContent).toContain("Ada Lovelace");
|
||||
expect(trigger.textContent).toContain("[email protected]");
|
||||
expect(trigger.querySelector('[data-testid="account-avatar"]')?.textContent).toBe("AL");
|
||||
// Initials are decoration; the name is what assistive technology hears.
|
||||
expect(
|
||||
trigger.querySelector('[data-testid="account-avatar"]')?.getAttribute("aria-hidden"),
|
||||
).toBe("true");
|
||||
// No image is ever loaded for an account, and the opaque user id is never shown.
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.textContent).not.toContain("user-1");
|
||||
});
|
||||
|
||||
it("repeats who is signed in inside the menu, above its three destinations", async () => {
|
||||
await render("/");
|
||||
|
||||
await click(required("host-account-menu"));
|
||||
|
||||
expect(required("host-account-identity").textContent).toBe("Ada [email protected]");
|
||||
expect(
|
||||
[...required("host-account-menu-list").querySelectorAll('[role="menuitem"]')].map(
|
||||
(item) => item.textContent,
|
||||
),
|
||||
).toEqual(["Account", "Appearance", "Sign out"]);
|
||||
});
|
||||
|
||||
it("keeps the name in the accessible name of the collapsed control", async () => {
|
||||
await render("/");
|
||||
await click(container.querySelector('[aria-label="Collapse navigation"]') as Element);
|
||||
|
||||
const trigger = required("host-account-menu");
|
||||
expect(trigger.querySelector(".ek-shell-visually-hidden")?.textContent).toBe("Ada Lovelace");
|
||||
expect(trigger.querySelector('[data-testid="account-avatar"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens Account and Appearance in place, closing the menu", async () => {
|
||||
const { history } = await render("/");
|
||||
|
||||
await click(required("host-account-menu"));
|
||||
await click(required("host-appearance-link"));
|
||||
|
||||
expect(history.entries).toEqual(["/", "/account/appearance"]);
|
||||
expect(query("account-appearance")).not.toBeNull();
|
||||
expect(query("host-account-menu-list")).toBeNull();
|
||||
|
||||
await click(required("host-account-menu"));
|
||||
await click(required("host-account-link"));
|
||||
expect(history.pathname()).toBe("/account");
|
||||
expect(query("account-home")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("names the area instead of the person when the account cannot be loaded", async () => {
|
||||
await render("/", { account: "fails" });
|
||||
|
||||
expect(required("host-account-menu").textContent).toBe("Your account");
|
||||
expect(query("account-avatar")).toBeNull();
|
||||
await click(required("host-account-menu"));
|
||||
expect(query("host-account-identity")).toBeNull();
|
||||
// Every destination still works.
|
||||
expect(query("host-appearance-link")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/account", () => {
|
||||
it("shows who you are signed in as and links to your own settings — and no administration", async () => {
|
||||
const { titles } = await render("/account");
|
||||
|
||||
const page = required("account-home");
|
||||
expect(page.querySelector("h1")?.textContent).toBe("Account");
|
||||
expect(required("account-name").textContent).toBe("Ada Lovelace");
|
||||
expect(required("account-email").textContent).toBe("[email protected]");
|
||||
expect(required("account-home-appearance").getAttribute("href")).toBe("/account/appearance");
|
||||
expect(titles.at(-1)).toBe("Account");
|
||||
// Nothing editable pretends to exist: no profile form.
|
||||
expect(page.querySelector("input, textarea, select")).toBeNull();
|
||||
// Personal, not Settings: the Settings hierarchy does not open, and no nav item is current.
|
||||
expect(container.querySelector('nav [aria-current="page"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows only the email when the name is blank", async () => {
|
||||
await render("/account", {
|
||||
account: {
|
||||
email: "[email protected]",
|
||||
name: " ",
|
||||
preferences: { appearance: "system", motion: "system" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(query("account-name")).toBeNull();
|
||||
expect(required("account-email").textContent).toBe("[email protected]");
|
||||
expect(required("host-account-menu").textContent).toContain("[email protected]");
|
||||
});
|
||||
|
||||
it("signs out, and the signed-out page follows the system with no cached choice", async () => {
|
||||
const { appearance, cache, calls } = await render("/account");
|
||||
expect(appearance.current()).toEqual(ADA.preferences);
|
||||
|
||||
await click(required("account-sign-out"));
|
||||
|
||||
expect(query("host-login")).not.toBeNull();
|
||||
expect(calls.some((call) => call.path === "/api/auth/sign-out")).toBe(true);
|
||||
expect(appearance.current()).toEqual({ appearance: "system", motion: "system" });
|
||||
expect(cache.read()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("says when the account could not be loaded, with Retry, and the Dashboard still works", async () => {
|
||||
await render("/account", { account: "fails" });
|
||||
|
||||
expect(required("account-load-failed").textContent).toContain(
|
||||
"Your account could not be loaded",
|
||||
);
|
||||
expect(query("host-sidebar")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("opens the same page after a refresh, and Back and Forward move between the account pages", async () => {
|
||||
const history = createMemoryHostHistory("/account");
|
||||
await render("/account", { history });
|
||||
await click(required("account-home-appearance"));
|
||||
expect(query("account-appearance")).not.toBeNull();
|
||||
|
||||
await act(async () => history.go(-1));
|
||||
expect(query("account-home")).not.toBeNull();
|
||||
await act(async () => history.go(1));
|
||||
expect(query("account-appearance")).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
await render("/account/appearance");
|
||||
expect(query("account-appearance")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("is not found beneath it for a page that does not exist", async () => {
|
||||
await render("/account/profile");
|
||||
|
||||
expect(query("host-not-found")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/account/appearance", () => {
|
||||
it("shows the account's choices as two native radio groups, with Back to Account", async () => {
|
||||
const { titles } = await render("/account/appearance");
|
||||
const page = required("account-appearance");
|
||||
|
||||
expect(page.querySelector("h1")?.textContent).toBe("Appearance");
|
||||
expect(titles.at(-1)).toBe("Appearance");
|
||||
const back = page.querySelector<HTMLAnchorElement>(".ek-page-header__back");
|
||||
expect(back?.getAttribute("href")).toBe("/account");
|
||||
expect(back?.textContent).toContain("Back to Account");
|
||||
|
||||
const groups = [...page.querySelectorAll("fieldset")];
|
||||
expect(groups.map((group) => group.querySelector("legend")?.textContent)).toEqual([
|
||||
"Appearance",
|
||||
"Reduced motion",
|
||||
]);
|
||||
for (const group of groups) {
|
||||
// A real radio group: one name, each option labelled, and described by its group's line.
|
||||
const inputs = [...group.querySelectorAll<HTMLInputElement>('input[type="radio"]')];
|
||||
expect(inputs).toHaveLength(3);
|
||||
expect(new Set(inputs.map((input) => input.name)).size).toBe(1);
|
||||
for (const input of inputs) {
|
||||
expect(input.closest("label")?.textContent?.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(group.getAttribute("aria-describedby")).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
[...page.querySelectorAll('input[name="appearance"]')].map(
|
||||
(input) => (input as HTMLInputElement).value,
|
||||
),
|
||||
).toEqual(["system", "light", "dark"]);
|
||||
expect(
|
||||
[...page.querySelectorAll('input[name="motion"]')].map(
|
||||
(input) => (input as HTMLInputElement).value,
|
||||
),
|
||||
).toEqual(["system", "reduce", "allow"]);
|
||||
expect(checked("appearance")).toBe("dark");
|
||||
expect(checked("motion")).toBe("reduce");
|
||||
});
|
||||
|
||||
it("gives every option a full-width touch target and marks the choice by more than colour", async () => {
|
||||
await render("/account/appearance", { compact: true });
|
||||
|
||||
for (const option of container.querySelectorAll<HTMLElement>(".ek-shell-choice")) {
|
||||
expect(option.style.minHeight).toBe("var(--ekklesia-control-height-lg)");
|
||||
}
|
||||
expect(required("appearance-appearance-dark").dataset.checked).toBe("true");
|
||||
expect(required("appearance-appearance-light").dataset.checked).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["appearance", "light"],
|
||||
["appearance", "system"],
|
||||
["motion", "allow"],
|
||||
["motion", "system"],
|
||||
] as const)("applies %s %s at once and saves it to the account", async (group, value) => {
|
||||
const { cache, patches, rootAttribute, history } = await render("/account/appearance");
|
||||
|
||||
await choose(group, value);
|
||||
|
||||
const attribute =
|
||||
group === "appearance" ? DESIGN_TOKEN_APPEARANCE_ATTRIBUTE : DESIGN_TOKEN_MOTION_ATTRIBUTE;
|
||||
expect(rootAttribute(attribute)).toBe(value);
|
||||
expect(patches()).toEqual([
|
||||
{ body: { [group]: value }, method: "PATCH", path: "/api/v1/account/preferences" },
|
||||
]);
|
||||
expect(checked(group)).toBe(value);
|
||||
expect(cache.read()?.[group]).toBe(value);
|
||||
// No reload, no navigation: the same page, the same history.
|
||||
expect(history.entries).toEqual(["/account/appearance"]);
|
||||
expect(required("appearance-status").textContent).toBe("Saved to your account.");
|
||||
});
|
||||
|
||||
it("switches before the server has answered, so the change is immediate", async () => {
|
||||
const { held, rootAttribute } = await render("/account/appearance", { save: () => "hold" });
|
||||
|
||||
await choose("appearance", "light");
|
||||
|
||||
expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("light");
|
||||
expect(held).toHaveLength(1);
|
||||
await act(async () => {
|
||||
held[0]?.resolve(Response.json({ appearance: "light", motion: "reduce" }));
|
||||
});
|
||||
expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("light");
|
||||
});
|
||||
|
||||
it("applies what the server answered, which is the source of truth", async () => {
|
||||
const { rootAttribute } = await render("/account/appearance", {
|
||||
save: () => Response.json({ appearance: "light", motion: "allow" }),
|
||||
});
|
||||
|
||||
await choose("appearance", "light");
|
||||
|
||||
// Motion changed elsewhere in the meantime; the account's answer wins.
|
||||
expect(rootAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("allow");
|
||||
expect(checked("motion")).toBe("allow");
|
||||
});
|
||||
|
||||
it("puts the choice back and says so when it could not be saved", async () => {
|
||||
const { cache, rootAttribute } = await render("/account/appearance", {
|
||||
save: () => Response.json({ code: "ACCOUNT_WRITE_FAILED" }, { status: 503 }),
|
||||
});
|
||||
|
||||
await choose("appearance", "light");
|
||||
|
||||
expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark");
|
||||
expect(checked("appearance")).toBe("dark");
|
||||
expect(cache.read()).toEqual(ADA.preferences);
|
||||
const notice = required("appearance-status").querySelector('[role="alert"]');
|
||||
expect(notice?.textContent).toContain("Your choice wasn't saved");
|
||||
});
|
||||
|
||||
it("checks the session when a save is refused, and signs the page out if it has ended", async () => {
|
||||
const { appearance } = await render("/account/appearance", {
|
||||
principalAfterSave: "anonymous",
|
||||
save: () => Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }),
|
||||
});
|
||||
|
||||
await choose("motion", "allow");
|
||||
|
||||
expect(query("host-login")).not.toBeNull();
|
||||
expect(appearance.current()).toEqual({ appearance: "system", motion: "system" });
|
||||
});
|
||||
|
||||
it("follows a change made in another tab, without saving it again", async () => {
|
||||
const { appearance, cache, patches } = await render("/account/appearance");
|
||||
|
||||
await act(async () => {
|
||||
cache.write({ appearance: "light", motion: "allow" });
|
||||
appearance.syncFromCache();
|
||||
});
|
||||
|
||||
expect(checked("appearance")).toBe("light");
|
||||
expect(checked("motion")).toBe("allow");
|
||||
expect(patches()).toEqual([]);
|
||||
});
|
||||
|
||||
it("is reachable on a phone from the drawer's account menu, which closes on choosing", async () => {
|
||||
await render("/", { compact: true });
|
||||
|
||||
await click(required("mobile-navigation-trigger"));
|
||||
await click(required("host-account-menu"));
|
||||
await click(required("host-appearance-link"));
|
||||
|
||||
expect(query("account-appearance")).not.toBeNull();
|
||||
expect(query("navigation-drawer")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconciling with the account", () => {
|
||||
it("replaces this browser's cached choice with the account's on every load", async () => {
|
||||
const { appearance, cache, meta, rootAttribute } = await render("/", {
|
||||
cached: { appearance: "light", motion: "allow" },
|
||||
});
|
||||
|
||||
expect(appearance.current()).toEqual(ADA.preferences);
|
||||
expect(rootAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark");
|
||||
expect(rootAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce");
|
||||
expect(meta.get("content")).toBe("dark");
|
||||
expect(cache.read()).toEqual(ADA.preferences);
|
||||
});
|
||||
|
||||
it("keeps the cached choice when the account cannot be loaded, rather than guessing", async () => {
|
||||
const { appearance } = await render("/", {
|
||||
account: "fails",
|
||||
cached: { appearance: "light", motion: "allow" },
|
||||
});
|
||||
|
||||
expect(appearance.current()).toEqual({ appearance: "light", motion: "allow" });
|
||||
});
|
||||
|
||||
it("follows the system on the signed-out page and forgets the previous person's choice", async () => {
|
||||
const { appearance, cache } = await render("/", {
|
||||
cached: { appearance: "dark", motion: "reduce" },
|
||||
});
|
||||
await click(required("host-account-menu"));
|
||||
await click(required("host-logout"));
|
||||
|
||||
expect(query("host-login")).not.toBeNull();
|
||||
expect(appearance.current()).toEqual({ appearance: "system", motion: "system" });
|
||||
expect(cache.read()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createEkklesiaThemeController,
|
||||
DESIGN_TOKEN_APPEARANCE_ATTRIBUTE,
|
||||
DESIGN_TOKEN_MOTION_ATTRIBUTE,
|
||||
EKKLESIA_APPEARANCES,
|
||||
EKKLESIA_MOTION_PREFERENCES,
|
||||
} from "@ekklesia/design-tokens";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { accountDisplayName, accountInitials } from "../src/browser/AccountAvatar.js";
|
||||
import {
|
||||
APPEARANCE_CACHE_KEY,
|
||||
createLocalAppearanceCache,
|
||||
createMemoryAppearanceCache,
|
||||
parseAppearanceCache,
|
||||
serializeAppearanceChoice,
|
||||
} from "../src/browser/appearance-cache.js";
|
||||
import { createAppearancePreference } from "../src/browser/appearance-preference.js";
|
||||
|
||||
/**
|
||||
* The browser half of account appearance (ADR 0062): the pre-paint cache, the pre-paint script,
|
||||
* and the one handle that puts a choice into effect through the ADR 0060 theme controller.
|
||||
*/
|
||||
|
||||
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
function memoryStorage(initial: Record<string, string> = {}) {
|
||||
const values = new Map(Object.entries(initial));
|
||||
return {
|
||||
getItem: vi.fn((key: string) => values.get(key) ?? null),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
values.delete(key);
|
||||
}),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
}),
|
||||
values,
|
||||
};
|
||||
}
|
||||
|
||||
function throwingStorage() {
|
||||
const fail = () => {
|
||||
throw new Error("SecurityError: storage is disabled");
|
||||
};
|
||||
return { getItem: vi.fn(fail), removeItem: vi.fn(fail), setItem: vi.fn(fail) };
|
||||
}
|
||||
|
||||
function recordingTarget() {
|
||||
const attributes = new Map<string, string>();
|
||||
return {
|
||||
attributes,
|
||||
setAttribute: (name: string, value: string) => {
|
||||
attributes.set(name, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("the appearance cache", () => {
|
||||
it("accepts every combination of the design tokens' own values, and round-trips each", () => {
|
||||
for (const appearance of EKKLESIA_APPEARANCES) {
|
||||
for (const motion of EKKLESIA_MOTION_PREFERENCES) {
|
||||
const serialized = serializeAppearanceChoice({ appearance, motion });
|
||||
expect(parseAppearanceCache(serialized)).toEqual({ appearance, motion });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses anything else, so a stale or tampered value is no cache at all", () => {
|
||||
for (const value of [
|
||||
null,
|
||||
undefined,
|
||||
"",
|
||||
"dark",
|
||||
"dark reduce extra",
|
||||
"Dark reduce",
|
||||
"dark reduce",
|
||||
"sepia reduce",
|
||||
"dark none",
|
||||
'{"appearance":"dark","motion":"reduce"}',
|
||||
42,
|
||||
]) {
|
||||
expect(parseAppearanceCache(value), String(value)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("holds two words under one namespaced key, and nothing about the account", () => {
|
||||
const storage = memoryStorage();
|
||||
const cache = createLocalAppearanceCache(storage);
|
||||
|
||||
cache.write({ appearance: "dark", motion: "reduce" });
|
||||
|
||||
expect([...storage.values.entries()]).toEqual([[APPEARANCE_CACHE_KEY, "dark reduce"]]);
|
||||
expect(APPEARANCE_CACHE_KEY).toBe("ekklesia.dashboard.appearance");
|
||||
expect(cache.read()).toEqual({ appearance: "dark", motion: "reduce" });
|
||||
cache.clear();
|
||||
expect(storage.values.size).toBe(0);
|
||||
expect(cache.read()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never breaks when storage is missing or throws", () => {
|
||||
for (const storage of [undefined, throwingStorage()]) {
|
||||
const cache = createLocalAppearanceCache(storage);
|
||||
expect(() => cache.write({ appearance: "dark", motion: "allow" })).not.toThrow();
|
||||
expect(cache.read()).toBeUndefined();
|
||||
expect(() => cache.clear()).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the appearance preference", () => {
|
||||
function build(initial?: {
|
||||
appearance: "system" | "light" | "dark";
|
||||
motion: "system" | "reduce" | "allow";
|
||||
}) {
|
||||
const root = recordingTarget();
|
||||
const meta = recordingTarget();
|
||||
const cache = createMemoryAppearanceCache(initial);
|
||||
const theme = createEkklesiaThemeController({
|
||||
appearance: initial?.appearance ?? "system",
|
||||
motion: initial?.motion ?? "system",
|
||||
target: root,
|
||||
});
|
||||
const preference = createAppearancePreference({ cache, colorSchemeMeta: meta, theme });
|
||||
return { cache, meta, preference, root, theme };
|
||||
}
|
||||
|
||||
it("drives the ADR 0060 theme controller: the root's two attributes are the whole effect", () => {
|
||||
const { preference, root, theme } = build();
|
||||
|
||||
preference.apply({ appearance: "dark", motion: "reduce" });
|
||||
|
||||
expect(root.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark");
|
||||
expect(root.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce");
|
||||
expect(theme.colorScheme()).toBe("dark");
|
||||
expect(theme.reducedMotion()).toBe(true);
|
||||
});
|
||||
|
||||
it("remembers what it applied for the next first paint, and narrows the colour scheme with it", () => {
|
||||
const { cache, meta, preference } = build();
|
||||
|
||||
preference.apply({ appearance: "light", motion: "allow" });
|
||||
expect(cache.read()).toEqual({ appearance: "light", motion: "allow" });
|
||||
expect(meta.attributes.get("content")).toBe("light");
|
||||
|
||||
preference.apply({ appearance: "system", motion: "allow" });
|
||||
expect(meta.attributes.get("content")).toBe("light dark");
|
||||
});
|
||||
|
||||
it("gives a stable snapshot between changes, and notifies only on a real change", () => {
|
||||
const { preference } = build();
|
||||
const listener = vi.fn();
|
||||
preference.subscribe(listener);
|
||||
const before = preference.current();
|
||||
|
||||
expect(preference.current()).toBe(before);
|
||||
preference.apply({ appearance: "system", motion: "system" });
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(preference.current()).toBe(before);
|
||||
|
||||
preference.apply({ appearance: "dark", motion: "system" });
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(preference.current()).toEqual({ appearance: "dark", motion: "system" });
|
||||
});
|
||||
|
||||
it("follows another tab through the cache, without writing it back", () => {
|
||||
const { cache, preference } = build({ appearance: "light", motion: "system" });
|
||||
const write = vi.spyOn(cache, "write");
|
||||
const listener = vi.fn();
|
||||
preference.subscribe(listener);
|
||||
|
||||
cache.write({ appearance: "dark", motion: "reduce" });
|
||||
write.mockClear();
|
||||
preference.syncFromCache();
|
||||
|
||||
expect(preference.current()).toEqual({ appearance: "dark", motion: "reduce" });
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
expect(write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("follows another tab's sign-out back to the system", () => {
|
||||
const { cache, preference } = build({ appearance: "dark", motion: "reduce" });
|
||||
|
||||
cache.clear();
|
||||
preference.syncFromCache();
|
||||
|
||||
expect(preference.current()).toEqual({ appearance: "system", motion: "system" });
|
||||
});
|
||||
|
||||
it("forgets on sign-out: the system again, and no cached choice for the next person", () => {
|
||||
const { cache, meta, preference, root } = build({ appearance: "dark", motion: "reduce" });
|
||||
|
||||
preference.forget();
|
||||
|
||||
expect(cache.read()).toBeUndefined();
|
||||
expect(root.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system");
|
||||
expect(root.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("system");
|
||||
expect(meta.attributes.get("content")).toBe("light dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the pre-paint appearance script", () => {
|
||||
let meta: HTMLMetaElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
window.localStorage.clear();
|
||||
document.documentElement.removeAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE);
|
||||
document.documentElement.removeAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE);
|
||||
meta = document.createElement("meta");
|
||||
meta.setAttribute("name", "color-scheme");
|
||||
meta.setAttribute("content", "light dark");
|
||||
document.head.append(meta);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
meta.remove();
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function runBootScript(): Promise<void> {
|
||||
await import("../src/browser/appearance-boot.js");
|
||||
}
|
||||
|
||||
it("puts a cached explicit choice on the root and narrows the colour scheme, before anything renders", async () => {
|
||||
window.localStorage.setItem(APPEARANCE_CACHE_KEY, "dark reduce");
|
||||
|
||||
await runBootScript();
|
||||
|
||||
expect(document.documentElement.getAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark");
|
||||
expect(document.documentElement.getAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce");
|
||||
expect(meta.getAttribute("content")).toBe("dark");
|
||||
});
|
||||
|
||||
it("leaves the colour scheme following the system for a cached System choice", async () => {
|
||||
window.localStorage.setItem(APPEARANCE_CACHE_KEY, "system allow");
|
||||
|
||||
await runBootScript();
|
||||
|
||||
expect(document.documentElement.getAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system");
|
||||
expect(document.documentElement.getAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("allow");
|
||||
expect(meta.getAttribute("content")).toBe("light dark");
|
||||
});
|
||||
|
||||
it("does nothing without a valid cache", async () => {
|
||||
window.localStorage.setItem(APPEARANCE_CACHE_KEY, "neon reduce");
|
||||
|
||||
await runBootScript();
|
||||
|
||||
expect(document.documentElement.hasAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(false);
|
||||
expect(meta.getAttribute("content")).toBe("light dark");
|
||||
});
|
||||
|
||||
it("does nothing, and throws nothing, where storage is blocked", async () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("SecurityError");
|
||||
});
|
||||
|
||||
await expect(runBootScript()).resolves.toBeUndefined();
|
||||
expect(document.documentElement.hasAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(false);
|
||||
});
|
||||
|
||||
it("names exactly the token stylesheet's attributes, and reads storage without ever writing it", async () => {
|
||||
const source = await readFile(
|
||||
path.join(packageDirectory, "src", "browser", "appearance-boot.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain(`"${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}"`);
|
||||
expect(source).toContain(`"${DESIGN_TOKEN_MOTION_ATTRIBUTE}"`);
|
||||
expect(source).not.toMatch(/setItem|removeItem|fetch|cookie/u);
|
||||
// Types only from the token package, so the render-blocking script stays tiny.
|
||||
const cacheSource = await readFile(
|
||||
path.join(packageDirectory, "src", "browser", "appearance-cache.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(cacheSource).toMatch(/^import type \{[^}]*\} from "@ekklesia\/design-tokens";$/mu);
|
||||
});
|
||||
});
|
||||
|
||||
describe("account initials", () => {
|
||||
it.each([
|
||||
[{ email: "[email protected]", name: "Ada Lovelace" }, "AL"],
|
||||
[{ email: "[email protected]", name: "Ada King Lovelace" }, "AL"],
|
||||
[{ email: "[email protected]", name: " ada lovelace " }, "AL"],
|
||||
[{ email: "[email protected]", name: "Cher" }, "C"],
|
||||
[{ email: "[email protected]", name: "Émile Zola" }, "ÉZ"],
|
||||
[{ email: "[email protected]", name: " " }, "B"],
|
||||
])("draws %o as %s", (account, initials) => {
|
||||
expect(accountInitials(account)).toBe(initials);
|
||||
});
|
||||
|
||||
it("shows the email as the name when the name is blank", () => {
|
||||
expect(accountDisplayName({ email: "[email protected]", name: " " })).toBe(
|
||||
"[email protected]",
|
||||
);
|
||||
expect(accountDisplayName({ email: "[email protected]", name: "Ada" })).toBe("Ada");
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,13 @@ import { dashboardHostAssetUrls } from "../src/dashboard-assets.js";
|
||||
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
describe("Dashboard host asset locator", () => {
|
||||
it("exports only frozen immutable href strings for the two exact built files, never the source map", () => {
|
||||
it("exports only frozen immutable href strings for the three exact built files, never the source map", () => {
|
||||
expect(Object.isFrozen(dashboardHostAssetUrls)).toBe(true);
|
||||
expect(dashboardHostAssetUrls).toEqual({
|
||||
appearance: new URL(
|
||||
"dist/browser/assets/ekklesia-dashboard/appearance.js",
|
||||
`file://${packageDirectory}/`,
|
||||
).href,
|
||||
html: new URL("dist/browser/index.html", `file://${packageDirectory}/`).href,
|
||||
script: new URL(
|
||||
"dist/browser/assets/ekklesia-dashboard/app.js",
|
||||
@@ -43,14 +47,28 @@ describe("Dashboard host asset locator", () => {
|
||||
expect(html).toContain("color: var(--ekklesia-text-primary);");
|
||||
});
|
||||
|
||||
it("loads exactly one fixed, absolute, same-origin script and no inline script", async () => {
|
||||
it("loads exactly two fixed, absolute, same-origin scripts and no inline script", async () => {
|
||||
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
|
||||
|
||||
expect(html).toContain('id="ekklesia-host"');
|
||||
expect(html.match(/<script\b/gu)).toHaveLength(1);
|
||||
expect(html.match(/<script\b/gu)).toHaveLength(2);
|
||||
expect(html).toContain(
|
||||
'<script src="/assets/ekklesia-dashboard/app.js" type="module"></script>',
|
||||
);
|
||||
expect(html).toContain('<script src="/assets/ekklesia-dashboard/appearance.js"></script>');
|
||||
// Every script names a source: there is no inline script for a strict script-src to excuse.
|
||||
expect(html.match(/<script(?![^>]*\bsrc=")/gu)).toBeNull();
|
||||
});
|
||||
|
||||
it("applies the cached appearance before first paint: a classic script in <head>, after the colour-scheme element it narrows (ADR 0062)", async () => {
|
||||
const html = await readFile(path.join(packageDirectory, "index.html"), "utf8");
|
||||
const head = html.slice(html.indexOf("<head>"), html.indexOf("</head>"));
|
||||
const boot = head.indexOf('<script src="/assets/ekklesia-dashboard/appearance.js"></script>');
|
||||
|
||||
// Classic (no `type="module"`, no `defer`, no `async`), so it blocks rendering until it ran.
|
||||
expect(boot).toBeGreaterThan(-1);
|
||||
expect(head.indexOf('name="color-scheme"')).toBeLessThan(boot);
|
||||
expect(head.slice(boot)).not.toMatch(/appearance\.js"[^>]*(?:type=|defer|async)/u);
|
||||
});
|
||||
|
||||
it("carries exactly one empty API-origin meta element for the serving API to fill (ADR 0045)", async () => {
|
||||
@@ -76,5 +94,6 @@ describe("Dashboard host asset locator", () => {
|
||||
);
|
||||
|
||||
expect(buildScript).toContain('"assets/ekklesia-dashboard/app": path.join(');
|
||||
expect(buildScript).toContain('"assets/ekklesia-dashboard/appearance": path.join(');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACCOUNT_SECTIONS,
|
||||
DASHBOARD_PLATFORM_PAGES,
|
||||
dashboardPath,
|
||||
parseDashboardPath,
|
||||
@@ -37,8 +38,35 @@ describe("parseDashboardPath", () => {
|
||||
|
||||
it("never reads a platform page as a module page", () => {
|
||||
expect(parseDashboardPath("/settings")).toEqual({ kind: "settings" });
|
||||
for (const pathname of ["/account", "/account/appearance"]) {
|
||||
expect(parseDashboardPath(pathname), pathname).toEqual({ kind: "not-found" });
|
||||
expect(parseDashboardPath("/account")).toEqual({ kind: "account" });
|
||||
});
|
||||
|
||||
it("reads the account area: the account page and its personal settings pages (ADR 0062)", () => {
|
||||
expect(parseDashboardPath("/account")).toEqual({ kind: "account" });
|
||||
expect(parseDashboardPath("/account/")).toEqual({ kind: "account" });
|
||||
expect(parseDashboardPath("/account/appearance")).toEqual({
|
||||
kind: "account-section",
|
||||
section: "appearance",
|
||||
});
|
||||
expect(parseDashboardPath("/account/appearance/")).toEqual({
|
||||
kind: "account-section",
|
||||
section: "appearance",
|
||||
});
|
||||
expect(dashboardPath({ kind: "account" })).toBe("/account");
|
||||
expect(dashboardPath({ kind: "account-section", section: "appearance" })).toBe(
|
||||
"/account/appearance",
|
||||
);
|
||||
expect(ACCOUNT_SECTIONS).toEqual(["appearance"]);
|
||||
for (const pathname of [
|
||||
"/account/profile",
|
||||
"/account/appearance/extra",
|
||||
"/account/Appearance",
|
||||
"/account//appearance",
|
||||
"/accounts",
|
||||
]) {
|
||||
expect(parseDashboardPath(pathname), pathname).toEqual(
|
||||
pathname === "/accounts" ? { kind: "module", moduleId: "accounts" } : { kind: "not-found" },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -484,6 +484,8 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
"/api/v1/dashboard/navigation",
|
||||
// Which Settings destinations the person may see, asked alongside the navigation (ADR 0061).
|
||||
"/api/v1/dashboard/settings",
|
||||
// Who is signed in and their own preferences, asked alongside them too (ADR 0062).
|
||||
"/api/v1/account",
|
||||
// The host-owned device panel loads with the rest of the shell for an administrator.
|
||||
"/api/v1/devices",
|
||||
]);
|
||||
|
||||
@@ -4,11 +4,15 @@ import {
|
||||
type HostModuleUiDescriptor,
|
||||
HostProtocolError,
|
||||
loadDashboardNavigation,
|
||||
loadHostAccount,
|
||||
loadHostPrincipal,
|
||||
parseDashboardNavigation,
|
||||
parseDashboardSettings,
|
||||
parseHostAccount,
|
||||
parseHostAccountPreferences,
|
||||
parseHostPrincipal,
|
||||
resolveApiOrigin,
|
||||
saveHostAccountPreferences,
|
||||
} from "../src/host-protocol.js";
|
||||
import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js";
|
||||
|
||||
@@ -37,6 +41,12 @@ function stubFetch(routes: Readonly<Record<string, () => Response>>) {
|
||||
return { calls, implementation: implementation as unknown as typeof fetch };
|
||||
}
|
||||
|
||||
const accountPayload = {
|
||||
email: "[email protected]",
|
||||
name: "Ada Lovelace",
|
||||
preferences: { appearance: "dark", motion: "reduce" },
|
||||
};
|
||||
|
||||
const inventoryPayload = {
|
||||
modules: [
|
||||
{
|
||||
@@ -304,6 +314,7 @@ describe("host protocol — bootstrap", () => {
|
||||
sections: ["displays"],
|
||||
}),
|
||||
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
|
||||
"/api/v1/account": () => jsonResponse(accountPayload),
|
||||
});
|
||||
|
||||
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
||||
@@ -312,7 +323,9 @@ describe("host protocol — bootstrap", () => {
|
||||
`${apiBaseUrl}/api/v1/principal`,
|
||||
`${apiBaseUrl}/api/v1/dashboard/navigation`,
|
||||
`${apiBaseUrl}/api/v1/dashboard/settings`,
|
||||
`${apiBaseUrl}/api/v1/account`,
|
||||
]);
|
||||
expect(bootstrap.account).toEqual(accountPayload);
|
||||
expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
|
||||
expect(bootstrap.settings).toEqual({
|
||||
@@ -571,3 +584,106 @@ describe("module UI host context", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host protocol — the signed-in person's account (ADR 0062)", () => {
|
||||
it("reads the account strictly: exactly name, email, and two known preferences", () => {
|
||||
expect(parseHostAccount(accountPayload)).toEqual(accountPayload);
|
||||
for (const payload of [
|
||||
null,
|
||||
{},
|
||||
{ ...accountPayload, image: "https://tracker.example.test/me.png" },
|
||||
{ ...accountPayload, userId: "user-1" },
|
||||
{ ...accountPayload, email: "" },
|
||||
{ ...accountPayload, email: 1 },
|
||||
{ ...accountPayload, name: null },
|
||||
{ ...accountPayload, preferences: { appearance: "sepia", motion: "reduce" } },
|
||||
{ ...accountPayload, preferences: { appearance: "dark", motion: "none" } },
|
||||
{ ...accountPayload, preferences: { appearance: "dark" } },
|
||||
{ ...accountPayload, preferences: { appearance: "dark", motion: "allow", radius: "lg" } },
|
||||
]) {
|
||||
expect(() => parseHostAccount(payload), JSON.stringify(payload)).toThrowError(
|
||||
expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["system", "system"],
|
||||
["light", "reduce"],
|
||||
["dark", "allow"],
|
||||
] as const)("accepts appearance %s with motion %s", (appearance, motion) => {
|
||||
expect(parseHostAccountPreferences({ appearance, motion })).toEqual({ appearance, motion });
|
||||
});
|
||||
|
||||
it("loads the account from its one platform path, with credentials, naming nobody", async () => {
|
||||
const { calls, implementation } = stubFetch({
|
||||
"/api/v1/account": () => jsonResponse(accountPayload),
|
||||
});
|
||||
|
||||
expect(await loadHostAccount({ apiBaseUrl, fetch: implementation })).toEqual(accountPayload);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/account`);
|
||||
expect(calls[0]?.init?.credentials).toBe("include");
|
||||
});
|
||||
|
||||
it("saves a change with one credentialed PATCH of exactly the change, and returns the server's answer", async () => {
|
||||
const { calls, implementation } = stubFetch({
|
||||
"/api/v1/account/preferences": () => jsonResponse({ appearance: "light", motion: "reduce" }),
|
||||
});
|
||||
|
||||
const saved = await saveHostAccountPreferences(
|
||||
{ apiBaseUrl, fetch: implementation },
|
||||
{ appearance: "light" },
|
||||
);
|
||||
|
||||
expect(saved).toEqual({ appearance: "light", motion: "reduce" });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/account/preferences`);
|
||||
expect(calls[0]?.init?.method).toBe("PATCH");
|
||||
expect(calls[0]?.init?.credentials).toBe("include");
|
||||
expect(calls[0]?.init?.redirect).toBe("error");
|
||||
expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ appearance: "light" });
|
||||
});
|
||||
|
||||
it("reports a refused save as signed out, and a failed one as unreachable", async () => {
|
||||
for (const [status, code] of [
|
||||
[403, "HOST_UNAUTHENTICATED"],
|
||||
[503, "HOST_REQUEST_FAILED"],
|
||||
[400, "HOST_REQUEST_FAILED"],
|
||||
] as const) {
|
||||
const { implementation } = stubFetch({
|
||||
"/api/v1/account/preferences": () => jsonResponse({ code: "X" }, status),
|
||||
});
|
||||
await expect(
|
||||
saveHostAccountPreferences({ apiBaseUrl, fetch: implementation }, { motion: "allow" }),
|
||||
).rejects.toMatchObject({ code });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the Dashboard working when the account cannot be loaded", async () => {
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
const { implementation } = stubFetch({
|
||||
"/api/v1/account": () => jsonResponse({ code: "ACCOUNT_READ_FAILED" }, 503),
|
||||
"/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload),
|
||||
"/api/v1/dashboard/settings": () => jsonResponse({ modules: [], sections: [] }),
|
||||
"/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }),
|
||||
});
|
||||
|
||||
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
||||
|
||||
expect(bootstrap.account).toBe("unavailable");
|
||||
expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor);
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("never asks for an account on behalf of an anonymous viewer", async () => {
|
||||
const { calls, implementation } = stubFetch({
|
||||
"/api/v1/principal": () => jsonResponse({ kind: "anonymous" }),
|
||||
});
|
||||
|
||||
const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation });
|
||||
|
||||
expect(bootstrap.account).toBe("unavailable");
|
||||
expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ describe("the Dashboard opens on the page its URL names", () => {
|
||||
});
|
||||
|
||||
it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => {
|
||||
for (const path of ["/settings/displays/add", "/calendar/events", "/account"]) {
|
||||
for (const path of ["/settings/displays/add", "/calendar/events", "/account/profile"]) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -384,12 +384,13 @@ describe("the account area", () => {
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger.textContent).toContain("Your account");
|
||||
// Only the opaque user id is known to the shell, and it is never shown.
|
||||
// This harness serves no account, so the control names the area; the opaque user id is never
|
||||
// shown either way. With an account it names the person (account-appearance.test.tsx).
|
||||
expect(container.textContent).not.toContain("user-1");
|
||||
expect(query("host-account-menu-list")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens on activation with focus on its first item, and offers only actions that work today", async () => {
|
||||
it("opens on activation with focus on its first item, and offers only destinations that work", async () => {
|
||||
await render();
|
||||
const trigger = required("host-account-menu");
|
||||
|
||||
@@ -400,9 +401,14 @@ describe("the account area", () => {
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(trigger.getAttribute("aria-controls")).toBe(menu.id);
|
||||
const items = [...menu.querySelectorAll('[role="menuitem"]')];
|
||||
expect(items.map((item) => item.textContent)).toEqual(["Sign out"]);
|
||||
expect(items.map((item) => item.textContent)).toEqual(["Account", "Appearance", "Sign out"]);
|
||||
expect(document.activeElement).toBe(items[0]);
|
||||
expect(menu.querySelector("a")).toBeNull();
|
||||
// Account and Appearance are real links to real pages (ADR 0062); Sign out is an action.
|
||||
expect([...menu.querySelectorAll("a")].map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/account",
|
||||
"/account/appearance",
|
||||
]);
|
||||
expect(items[2]?.tagName).toBe("BUTTON");
|
||||
});
|
||||
|
||||
it("opens from the keyboard with ArrowDown, and Escape closes it and returns focus", async () => {
|
||||
@@ -411,6 +417,8 @@ describe("the account area", () => {
|
||||
await focus(trigger);
|
||||
|
||||
await key(trigger, "ArrowDown");
|
||||
expect(document.activeElement).toBe(required("host-account-link"));
|
||||
await key(required("host-account-link"), "ArrowUp");
|
||||
expect(document.activeElement).toBe(required("host-logout"));
|
||||
|
||||
await key(required("host-logout"), "Escape");
|
||||
|
||||
Reference in New Issue
Block a user