Files
Ekklesia/surfaces/shell/tests/displays-settings.test.tsx
T
ekklesiaandClaude Opus 5.5 0009f62fbd
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 42s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m30s
CI / image-build (pull_request) Successful in 1m8s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 8m55s
Say Remove for displays, and make the default accent purple (#210050)
Administrators now remove a display: the card's Remove, "Remove display?",
and "Display removed". The platform still revokes the device's credential;
the endpoint, fields, and security history keep revoke/revoked (ADR 0065).

The default accent is a deep purple anchored on #210050, set in the shared
design tokens only (ADR 0066, superseding ADR 0060's teal in part). The
anchor is the light primary action with white on it; the light accent text,
focus ring, and selection, and the dark theme's lavender accent, are
variants on its hue chosen for contrast. The teal-tinted neutrals move to a
faint violet-grey at the same lightness, so every contrast pairing holds.
The contrast test gains six pairings the accent depends on, and pins the
anchor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 19:14:46 -05:00

551 lines
22 KiB
TypeScript

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DisplaysSettingsPage } from "../src/browser/DisplaysSettingsPage.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
/**
* Settings → Displays (ADR 0064, ADR 0065): the active platform displays, setting up a new screen,
* pairing it, changing what it shows, and revoking it.
*/
const apiBaseUrl = "https://ekklesia.example.test";
const NOW = new Date("2026-10-15T12:00:00.000Z");
/** A screen that asked from Signage's own display page. */
const MODULE_CODE = "123456";
/** A screen that asked from the platform's `/display/setup`, with no module. */
const PLATFORM_CODE = "654321";
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
interface Device {
id: string;
name: string;
createdAt: string;
lastSeenAt?: string;
permissions: string[];
revoked: boolean;
}
interface ServerOptions {
readonly devices?: Device[];
readonly denied?: boolean;
/** What `GET /api/v1/devices/display-modules` answers. */
readonly modules?: unknown[] | "fails";
}
const SIGNAGE = { moduleId: "signage", name: "Signage", permissionId: "signage.display" };
const BULLETIN = { moduleId: "bulletin", name: "Bulletin", permissionId: "bulletin.display" };
/**
* A fake API that behaves like the real one where it matters: an approved display does not exist
* until its screen collects its credential, which here happens a couple of list requests later.
*/
function server(options: ServerOptions = {}) {
const devices: Device[] = options.devices ?? [
{
createdAt: "2026-10-01T09:00:00.000Z",
id: "device-1",
lastSeenAt: "2026-10-15T11:50:00.000Z",
name: "Foyer TV",
permissions: [],
revoked: false,
},
];
const modules =
options.modules === "fails" ? [] : ((options.modules ?? [SIGNAGE]) as (typeof SIGNAGE)[]);
let pending: { device: Device; listsUntilClaimed: number } | undefined;
const requests: { path: string; method: string; body: unknown }[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = new URL(String(input)).pathname;
const method = init?.method ?? "GET";
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
requests.push({ body, method, path });
if (path === "/api/v1/devices/display-modules") {
return options.modules === "fails"
? json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503)
: json({ modules });
}
if (path === "/api/v1/devices") {
if (options.denied) {
return json({ code: "AUTHORIZATION_DENIED" }, 403);
}
if (pending !== undefined) {
pending.listsUntilClaimed -= 1;
if (pending.listsUntilClaimed <= 0) {
devices.push(pending.device);
pending = undefined;
}
}
return json({ devices });
}
const display = /^\/api\/v1\/devices\/([^/]+)\/display$/u.exec(path);
if (display && method === "PUT") {
const device = devices.find((candidate) => candidate.id === display[1]);
const moduleId = (body as { moduleId: string | null }).moduleId;
const module = modules.find((candidate) => candidate.moduleId === moduleId);
if (!device || device.revoked || (moduleId !== null && !module)) {
return json({ code: "NOT_FOUND" }, 404);
}
device.permissions = module ? [module.permissionId] : [];
return json({ moduleId });
}
const revoke = /^\/api\/v1\/devices\/([^/]+)\/revoke$/u.exec(path);
if (revoke) {
for (const device of devices) {
if (device.id === revoke[1]) {
device.revoked = true;
device.permissions = [];
}
}
return new Response(null, { status: 204 });
}
if (path === "/api/v1/device-pairing/lookups") {
const code = (body as { code: string }).code;
if (code !== MODULE_CODE && code !== PLATFORM_CODE) {
return json({ code: "DEVICE_PAIRING_NOT_FOUND" }, 404);
}
const module = code === MODULE_CODE;
return json({
expiresAt: "2026-10-15T12:10:00.000Z",
moduleId: module ? "signage" : null,
permissionId: module ? "signage.display" : null,
requestedAt: "2026-10-15T11:59:00.000Z",
});
}
if (path === "/api/v1/device-pairing/approvals") {
const approval = body as { code: string; deviceName: string; moduleId?: string };
const shows = approval.code === MODULE_CODE ? "signage" : approval.moduleId;
const module = modules.find((candidate) => candidate.moduleId === shows);
pending = {
device: {
createdAt: "2026-10-15T12:00:00.000Z",
id: `device-${devices.length + 1}`,
name: approval.deviceName,
permissions: module ? [module.permissionId] : [],
revoked: false,
},
listsUntilClaimed: 2,
};
return json({ moduleId: shows ?? null, permissionId: module?.permissionId ?? null });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { devices, fetch, requests };
}
function query(testId: string): HTMLElement | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
function required(testId: string): HTMLElement {
const element = query(testId);
if (!element) {
throw new Error(`missing ${testId}`);
}
return element;
}
async function settle(): Promise<void> {
for (let index = 0; index < 6; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
/** Waits, in real time, for something the page does on its own (a revalidation). */
async function eventually(condition: () => boolean, timeoutMs = 2000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!condition()) {
if (Date.now() > deadline) {
throw new Error("timed out");
}
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10));
});
}
}
async function click(testId: string): Promise<void> {
await act(async () => {
required(testId).click();
await settle();
});
}
async function type(testId: string, value: string): Promise<void> {
const input = required(testId) as HTMLInputElement;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function submit(testId: string): Promise<void> {
const form = (required(testId) as HTMLButtonElement).form;
await act(async () => {
form?.requestSubmit();
await settle();
});
}
/** Opens a SelectMenu and chooses an option by its label, as a pointer would. */
async function choose(testId: string, label: string): Promise<void> {
await click(testId);
const listId = required(testId).getAttribute("aria-controls") ?? "";
const option = [...(container.querySelector(`#${CSS.escape(listId)}`)?.children ?? [])].find(
(candidate) => candidate.textContent === label,
) as HTMLElement | undefined;
if (!option) {
throw new Error(`no option ${label}`);
}
await act(async () => {
option.click();
await settle();
});
}
async function renderPage(options: ServerOptions = {}) {
const platform = server(options);
await act(async () => {
root.render(
<DisplaysSettingsPage
back={{ href: "/settings", label: "Back to Settings", onBack: () => undefined }}
config={{ apiBaseUrl, fetch: platform.fetch }}
moduleName={(moduleId) => (moduleId === "signage" ? "Signage" : undefined)}
now={() => NOW}
pollMs={5}
refreshMs={0}
/>,
);
await settle();
});
return platform;
}
describe("Settings → Displays", () => {
it("lists each active display with its status, what it shows, and its actions", async () => {
const before = document.activeElement;
await renderPage();
const card = required("device-device-1");
expect(card.textContent).toContain("Foyer TV");
expect(card.textContent).toContain("Online");
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
expect(card.textContent).toContain("Last seen within the last hour");
expect(query("device-change-device-1")?.textContent).toBe("Change");
expect(query("device-revoke-device-1")?.textContent).toBe("Remove");
expect(container.querySelector("h1")?.textContent).toBe("Displays");
// Setting up a new screen comes first, in one place, at the platform's own setup address.
const setup = required("display-setup");
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
expect(setup.contains(required("add-display"))).toBe(true);
expect((required("display-setup-steps") as HTMLDetailsElement).open).toBe(false);
// No pairing form until someone asks for one, and nothing grabbed focus.
expect(container.querySelector("input")).toBeNull();
expect(document.activeElement).toBe(before);
});
it("shows only active displays: removed ones are history, not the normal list", async () => {
await renderPage({
devices: [
{
createdAt: "2026-10-01T09:00:00.000Z",
id: "a",
name: "Hall",
permissions: [],
revoked: false,
},
{
createdAt: "2026-09-01T09:00:00.000Z",
id: "b",
name: "Old test screen",
permissions: [],
revoked: true,
},
{
createdAt: "2026-09-02T09:00:00.000Z",
id: "c",
name: "Another old one",
permissions: [],
revoked: true,
},
],
});
expect(query("device-a")).not.toBeNull();
expect(query("device-b")).toBeNull();
expect(query("device-c")).toBeNull();
// Removed displays are never described as "revoked" in the normal interface.
expect(container.textContent).not.toMatch(/revok/iu);
expect(required("device-a").textContent).toContain("Not connected yet");
});
it("says when nothing is paired yet, and when the list is not this person's to see", async () => {
await renderPage({ devices: [] });
expect(required("device-list-empty").textContent).toContain("No displays are paired yet");
await act(async () => root.unmount());
root = createRoot(container);
await renderPage({ denied: true });
expect(container.textContent).toContain("Displays aren't available to you");
expect(query("add-display")).toBeNull();
});
it("shows a newly paired display without a page refresh, once its screen connects", async () => {
const platform = await renderPage();
await click("add-display");
expect(document.activeElement).toBe(required("device-pairing-code-input"));
await type("device-pairing-code-input", "999999");
await submit("device-pairing-lookup");
expect(required("device-pairing-error").textContent).toContain("not waiting for approval");
await type("device-pairing-code-input", MODULE_CODE);
await submit("device-pairing-lookup");
expect(required("device-pairing-found").textContent).toContain("Signage");
expect(query("device-pairing-shows")).toBeNull();
expect(document.activeElement).toBe(required("device-pairing-name"));
await type("device-pairing-name", "Sanctuary left");
await submit("device-pairing-approve");
// The dialog closes and the page says so at once; the screen has not connected yet.
expect(query("add-display-dialog")).toBeNull();
expect(required("device-pairing-success").textContent).toContain(
"Sanctuary left is paired and shows Signage.",
);
expect(required("device-awaiting").textContent).toContain("Waiting for the screen to connect");
// The page checks again on its own until the display exists — no reload.
await eventually(() => query("device-device-2") !== null);
expect(query("device-awaiting")).toBeNull();
expect(required("device-device-2-showing").textContent).toBe("Showing: Signage");
// Never twice, however many times it is fetched again.
expect(container.querySelectorAll('[data-testid="device-device-2"]')).toHaveLength(1);
expect(platform.requests.filter((r) => r.path === "/api/v1/devices").length).toBeGreaterThan(2);
expect(document.activeElement).toBe(required("add-display"));
});
it("closes the Add display flow on Cancel without pairing anything", async () => {
const platform = await renderPage();
await click("add-display");
await click("add-display-cancel");
expect(query("add-display-dialog")).toBeNull();
expect(platform.requests.map((r) => r.path).sort()).toEqual([
"/api/v1/devices",
"/api/v1/devices/display-modules",
]);
expect(document.activeElement).toBe(required("add-display"));
});
});
describe("setting up a new screen (ADR 0065)", () => {
it("copies the platform setup address, and says so when the browser cannot", async () => {
const writeText = vi.fn(async () => undefined);
Object.defineProperty(window.navigator, "clipboard", {
configurable: true,
value: { writeText },
});
await renderPage();
await click("display-setup-copy");
expect(writeText).toHaveBeenCalledWith(`${apiBaseUrl}/display/setup`);
expect(required("display-setup").textContent).toContain("Link copied.");
Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: undefined });
await click("display-setup-copy");
expect(required("display-setup").textContent).toContain("couldn't copy it");
});
it("pairs a platform display with zero display modules installed", async () => {
const platform = await renderPage({ modules: [] });
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/setup`);
expect(required("display-setup").textContent).not.toContain("Signage");
await click("add-display");
await type("device-pairing-code-input", PLATFORM_CODE);
await submit("device-pairing-lookup");
expect(required("device-pairing-found").textContent).toContain(
"A new screen is waiting to be paired",
);
expect(query("device-pairing-shows")).toBeNull();
expect(required("device-pairing-no-modules").textContent).toContain(
"No installed module shows content on screens yet",
);
await type("device-pairing-name", "Foyer TV 2");
await submit("device-pairing-approve");
expect(
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
).toEqual({ code: PLATFORM_CODE, deviceName: "Foyer TV 2" });
await eventually(() => query("device-device-2") !== null);
expect(required("device-device-2-showing").textContent).toBe("Showing: Nothing yet");
});
it("chooses what a new platform display shows with the themed select, not a native one", async () => {
const platform = await renderPage();
await click("add-display");
await type("device-pairing-code-input", PLATFORM_CODE);
await submit("device-pairing-lookup");
const dialog = required("add-display-dialog");
expect(dialog.querySelector("select")).toBeNull();
const shows = required("device-pairing-shows");
expect(shows.getAttribute("role")).toBe("combobox");
expect(shows.textContent).toBe("Nothing yet");
await choose("device-pairing-shows", "Signage");
expect(shows.textContent).toBe("Signage");
await type("device-pairing-name", "Lobby screen");
await submit("device-pairing-approve");
expect(
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
).toEqual({ code: PLATFORM_CODE, deviceName: "Lobby screen", moduleId: "signage" });
await eventually(() => query("device-device-2") !== null);
expect(required("device-device-2-showing").textContent).toBe("Showing: Signage");
});
it("still pairs, showing nothing yet, when the display modules cannot be read", async () => {
const platform = await renderPage({ modules: "fails" });
await click("add-display");
await type("device-pairing-code-input", PLATFORM_CODE);
await submit("device-pairing-lookup");
expect(query("device-pairing-shows")).toBeNull();
await type("device-pairing-name", "Hall");
await submit("device-pairing-approve");
expect(
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
).toEqual({ code: PLATFORM_CODE, deviceName: "Hall" });
// Without the module list the card neither guesses what it shows nor offers to change it.
expect(query("device-device-1-showing")).toBeNull();
expect(query("device-change-device-1")).toBeNull();
});
});
describe("changing what a display shows (ADR 0065)", () => {
it("moves a display from nothing to a module, between modules, and back, as the same display", async () => {
const platform = await renderPage({ modules: [SIGNAGE, BULLETIN] });
const puts = () =>
platform.requests.filter((r) => r.method === "PUT").map((r) => [r.path, r.body]);
// Nothing yet → Signage.
await click("device-change-device-1");
const dialog = required("device-change-dialog");
expect(dialog.textContent).toContain("Change what Foyer TV shows");
expect(dialog.querySelector("select")).toBeNull();
expect(required("device-change-shows").textContent).toBe("Nothing yet");
// Nothing to save until something changes.
expect((required("device-change-save") as HTMLButtonElement).disabled).toBe(true);
await choose("device-change-shows", "Signage");
await click("device-change-save");
expect(query("device-change-dialog")).toBeNull();
expect(required("device-device-1-showing").textContent).toBe("Showing: Signage");
expect(required("device-change-success").textContent).toBe("Foyer TV now shows Signage.");
expect(document.activeElement).toBe(required("device-change-device-1"));
// Signage → Bulletin.
await click("device-change-device-1");
expect(required("device-change-shows").textContent).toBe("Signage");
await choose("device-change-shows", "Bulletin");
await click("device-change-save");
expect(required("device-device-1-showing").textContent).toBe("Showing: Bulletin");
// Bulletin → nothing.
await click("device-change-device-1");
await choose("device-change-shows", "Nothing yet");
await click("device-change-save");
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
expect(required("device-change-success").textContent).toContain("Foyer TV now shows nothing");
expect(puts()).toEqual([
["/api/v1/devices/device-1/display", { moduleId: "signage" }],
["/api/v1/devices/device-1/display", { moduleId: "bulletin" }],
["/api/v1/devices/device-1/display", { moduleId: null }],
]);
// Always the one display: no new identity, and the same name.
expect(platform.devices.map((device) => device.id)).toEqual(["device-1"]);
expect(container.querySelectorAll('[data-testid^="device-device-"]').length).toBeGreaterThan(0);
expect(container.querySelectorAll('[data-testid="device-device-1"]')).toHaveLength(1);
});
it("closes on Cancel or Escape without changing anything", async () => {
const platform = await renderPage();
await click("device-change-device-1");
await choose("device-change-shows", "Signage");
await click("device-change-cancel");
expect(query("device-change-dialog")).toBeNull();
expect(platform.requests.some((r) => r.method === "PUT")).toBe(false);
expect(required("device-device-1-showing").textContent).toBe("Showing: Nothing yet");
});
});
/**
* Removing a display (revoking its credential) strands that screen until someone re-pairs it in
* person, so the page explains the consequence in its own dialog first — never with a
* browser-native confirm (DESIGN §21). The interface says "remove"; only the platform says "revoke".
*/
describe("removing a display", () => {
it("asks in a product dialog before removing, and does nothing on cancel", async () => {
const confirm = vi.fn(() => true);
Object.assign(window, { confirm });
const platform = await renderPage();
await click("device-revoke-device-1");
const dialog = container.querySelector('[role="dialog"]');
// Administrators remove a display; revoking its credential is the platform's word, not theirs.
expect(dialog?.textContent).toContain("Remove display?");
expect(dialog?.textContent).toContain("Foyer TV stops showing content");
expect(dialog?.textContent).not.toMatch(/revok/iu);
expect(dialog?.textContent).toContain("pairing code");
await click("device-revoke-cancel");
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(platform.requests.some((r) => r.path.endsWith("/revoke"))).toBe(false);
expect(confirm).not.toHaveBeenCalled();
});
it("takes a removed display off the list at once, and says what happened", async () => {
const platform = await renderPage();
await click("device-revoke-device-1");
await click("device-revoke-confirm");
await act(async () => settle());
expect(platform.requests.filter((r) => r.path.endsWith("/revoke"))).toHaveLength(1);
expect(container.querySelector('[role="dialog"]')).toBeNull();
// Gone from the active list without a reload, though the server keeps it as history.
expect(query("device-device-1")).toBeNull();
expect(platform.devices[0]?.revoked).toBe(true);
expect(required("device-list-empty")).not.toBeNull();
expect(required("device-outcome").textContent).toContain("Display removed");
expect(required("device-revoke-success").textContent).toContain("Foyer TV was removed");
expect(container.textContent).not.toMatch(/revok/iu);
// Its card is gone, so focus lands on what happened rather than on the page.
expect(document.activeElement).toBe(required("device-outcome"));
});
});