Files
Ekklesia/surfaces/shell/tests/displays-settings.test.tsx
T
ekklesiaandClaude Opus 5.5 906c015ca8
CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
Explain display setup in Settings, stop iOS form zoom, lock the page behind the drawer
A second real-iPhone review of the Stage 1 Dashboard found three
defects. This fixes them, and ADR 0065 records the decisions.

- Settings -> Displays now sets up a screen in one place. A "Set up a
  new screen" panel shows the platform display page to open on the
  screen, a Copy link button, and Add display directly beneath it; the
  step-by-step version is folded into a <details>. The addresses come
  from a new GET /api/v1/devices/setup, which requires
  core.devices.manage and lists each running display module's
  /display/<module-id> page. Nothing names Signage. With no display
  module installed, the panel says so and offers no Add display.
- iOS Safari zoomed into the pairing field because the shared
  .ek-control rendered at 14px. On phones and touch screens it now
  renders at max(1rem, 16px). The viewport is untouched, so zoom stays
  available, and a test guards that. The shared focus trap now returns
  focus with preventScroll, so closing a dialog no longer moves the page
  under the phone header.
- While the phone drawer is open, the page behind it no longer scrolls.
  The root's overflow is hidden, a non-passive touchmove listener
  cancels drags outside the drawer's list and overscroll past its ends,
  and root overscroll is turned off. The list itself still scrolls.
  Closing restores the exact scroll position unless another page opened.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 22:56:22 -05:00

343 lines
13 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): the device surface that used to sit in the sidebar, moved into
* Settings — the paired displays, a deliberate Add display flow, and revocation in a product dialog.
*/
const apiBaseUrl = "https://ekklesia.example.test";
const NOW = new Date("2026-10-15T12:00:00.000Z");
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 ServerOptions {
readonly devices?: unknown[];
readonly denied?: boolean;
/** What `GET /api/v1/devices/setup` answers: the display pages, or a failure. */
readonly setup?: unknown[] | "fails";
}
function server(options: ServerOptions = {}) {
let devices: unknown[] = 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 requests: { path: string; body: unknown }[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = new URL(String(input)).pathname;
const body = init?.body === undefined ? undefined : JSON.parse(String(init.body));
requests.push({ body, path });
if (path === "/api/v1/devices/setup") {
if (options.setup === "fails") {
return json({ code: "DEVICE_LOOKUP_UNAVAILABLE" }, 503);
}
return json({
displays: options.setup ?? [
{ moduleId: "signage", name: "Signage", url: "/display/signage" },
],
});
}
if (path === "/api/v1/devices") {
return options.denied ? json({ code: "AUTHORIZATION_DENIED" }, 403) : json({ devices });
}
if (path === "/api/v1/devices/device-1/revoke") {
devices = devices.map((device) => ({ ...(device as object), revoked: true }));
return new Response(null, { status: 204 });
}
if (path === "/api/v1/device-pairing/lookups") {
return (body as { code: string }).code === "123456"
? json({
expiresAt: "2026-10-15T12:10:00.000Z",
moduleId: "signage",
permissionId: "signage.display",
requestedAt: "2026-10-15T11:59:00.000Z",
})
: json({ code: "DEVICE_PAIRING_NOT_FOUND" }, 404);
}
if (path === "/api/v1/device-pairing/approvals") {
devices = [
...devices,
{
createdAt: "2026-10-15T12:00:00.000Z",
id: "device-2",
name: (body as { deviceName: string }).deviceName,
permissions: [],
revoked: false,
},
];
return json({ moduleId: "signage", permissionId: "signage.display" });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { 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));
}
}
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();
});
}
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}
/>,
);
await settle();
});
return platform;
}
describe("Settings → Displays", () => {
it("lists the paired displays with what is known about each, and takes no focus", async () => {
const before = document.activeElement;
await renderPage();
const card = required("device-device-1");
expect(card.textContent).toContain("Foyer TV");
expect(card.textContent).toContain("Paired");
expect(card.textContent).toContain("Last seen within the last hour");
expect(container.querySelector("h1")?.textContent).toBe("Displays");
// How to set up a new screen comes first, in one place: the platform display page on the API
// origin, then Add display right under it, with the step-by-step version folded away.
const setup = required("display-setup");
expect(setup.textContent).toContain("Open this address on the new screen");
expect(required("display-setup-url").textContent).toBe(`${apiBaseUrl}/display/signage`);
expect(setup.contains(required("add-display"))).toBe(true);
const steps = required("display-setup-steps");
expect(steps.tagName).toBe("DETAILS");
expect((steps as HTMLDetailsElement).open).toBe(false);
expect(steps.querySelector("summary")?.textContent).toBe("Step-by-step instructions");
expect(steps.textContent).toContain("six-digit pairing code");
// Not a second Add display up in the header.
expect(container.querySelectorAll('[data-testid="add-display"]')).toHaveLength(1);
// No pairing form until someone asks for one, and nothing grabbed focus.
expect(container.querySelector("input")).toBeNull();
expect(document.activeElement).toBe(before);
});
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("pairs a display through a deliberate Add display flow, focusing each next step", async () => {
const platform = await renderPage();
await click("add-display");
const dialog = required("add-display-dialog");
expect(dialog.getAttribute("role")).toBe("dialog");
// Typing the code is now plainly the next thing to do, so the field has focus.
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", "123456");
await submit("device-pairing-lookup");
expect(required("device-pairing-found").textContent).toContain("Signage");
expect(document.activeElement).toBe(required("device-pairing-name"));
await type("device-pairing-name", "Sanctuary left");
await submit("device-pairing-approve");
expect(query("add-display-dialog")).toBeNull();
expect(
platform.requests.find((r) => r.path === "/api/v1/device-pairing/approvals")?.body,
).toEqual({ code: "123456", deviceName: "Sanctuary left" });
// The new display appears without a page refresh, and the page says so.
expect(required("device-device-2").textContent).toContain("Sanctuary left");
expect(required("device-pairing-success").textContent).toContain("Sanctuary left is paired");
// Focus is back on the control that opened the flow.
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/setup",
]);
expect(document.activeElement).toBe(required("add-display"));
});
});
describe("setting up a new screen (ADR 0065)", () => {
it("copies the 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-signage");
expect(writeText).toHaveBeenCalledWith(`${apiBaseUrl}/display/signage`);
expect(required("display-setup").textContent).toContain("Link copied.");
Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: undefined });
await click("display-setup-copy-signage");
expect(required("display-setup").textContent).toContain("couldn't copy it");
});
it("names each module's page when more than one module uses displays", async () => {
await renderPage({
setup: [
{ moduleId: "signage", name: "Signage", url: "/display/signage" },
{ moduleId: "parent-call", name: "Parent Call", url: "/display/parent-call" },
],
});
expect(required("display-setup-signage").textContent).toContain("For Signage");
expect(required("display-setup-parent-call").textContent).toContain(
`For Parent Call${apiBaseUrl}/display/parent-call`,
);
});
it("makes sense with no module that uses displays installed, Signage included", async () => {
await renderPage({ setup: [] });
expect(required("display-setup-none").textContent).toContain(
"No installed module shows content on screens yet",
);
expect(query("display-setup-url")).toBeNull();
// No Add display that could only fail, and nothing names Signage.
expect(query("add-display")).toBeNull();
expect(required("display-setup").textContent).not.toContain("Signage");
});
it("keeps the rest of the page working when the setup address cannot be read", async () => {
await renderPage({ setup: "fails" });
expect(required("display-setup-failed").textContent).toContain("couldn't be loaded");
expect(required("device-device-1").textContent).toContain("Foyer TV");
await click("add-display");
expect(query("add-display-dialog")).not.toBeNull();
});
it("refuses a setup answer that is not a plain root-relative page", async () => {
await renderPage({
setup: [{ moduleId: "signage", name: "Signage", url: "https://elsewhere.example/display" }],
});
expect(query("display-setup-failed")).not.toBeNull();
expect(query("display-setup-url")).toBeNull();
});
});
/**
* Revoking a display 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).
*/
describe("display revocation", () => {
it("asks in a product dialog before revoking, 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"]');
expect(dialog?.textContent).toContain("Revoke Foyer TV?");
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("revokes only after the destructive action is confirmed", async () => {
const platform = await renderPage();
await click("device-revoke-device-1");
await click("device-revoke-confirm");
expect(platform.requests.filter((r) => r.path.endsWith("/revoke"))).toHaveLength(1);
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(required("device-device-1").textContent).toContain("Revoked");
expect(query("device-revoke-device-1")).toBeNull();
});
});