Files
Ekklesia/surfaces/shell/tests/display-app.test.tsx
T
ekklesiaandClaude Opus 5.5 d9dafcb0f7
CI / verify (pull_request) Successful in 1m14s
CI / infrastructure-smoke (pull_request) Successful in 37s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m28s
CI / image-build (pull_request) Successful in 1m9s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 8m58s
Manage paired displays: themed select, reassignment, live list, no revoked clutter
Real-device review of the platform display flow found four defects in
display management:

- Shared SelectMenu in @ekklesia/ui (additive): an Ekklesia-drawn
  single choice following the WAI-ARIA select-only combobox pattern,
  replacing the native select for "Show on this screen". It has a
  themed list, a chevron inside the control, keyboard support (arrows,
  Home/End, type-ahead, Enter/Space/Tab, and Escape that closes only
  the list), pointer and touch support, and focus that stays on the
  control. It is an ek-control, so it keeps the 16px phone size.
- Newly paired displays appear without a reload. Approval doesn't
  create the display; the screen does when it claims its credential a
  few seconds later. The page now shows a "Waiting for the screen to
  connect" placeholder and refetches every second until the new display
  appears (for up to a minute), and refreshes the list every 30
  seconds.
- What a display shows can change after pairing. In the single-target
  Stage 1 model, a display's current target is its one active display
  permission, so no migration is needed.
  PUT /api/v1/devices/:id/display, with core.devices.manage, a trusted
  origin and a bounded body, revokes its other active permission rows
  (kept as history) and grants the chosen module's declared display
  permission, in one transaction on the same device. The screen notices
  within about 30 seconds: a paired device on a module page that is no
  longer set to show it goes to /display/setup instead of re-pairing,
  and /display/setup opens the new target.
- Revoked displays leave the normal list at once. Their rows stay in
  the database as history.

Each display card now shows its name, Online/Offline/Not connected yet,
"Showing: ...", last seen and paired dates, and Change and Revoke.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 18:29:15 -05:00

1372 lines
48 KiB
TypeScript

import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk";
import { act, Component, type ReactElement, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { HostApp } from "../src/browser/HostApp.js";
import { createMemoryHostHistory } from "../src/browser/host-history.js";
import { DisplayApp } from "../src/display/DisplayApp.js";
import type {
EkklesiaHostConfig,
HostEventSourceFactory,
HostEventSourceOptions,
} from "../src/host-protocol.js";
import type { DisplayConnectivitySource } from "../src/display/offline-status.js";
import { deepQuery, isolatedMountTarget, isolatedText } from "./support/isolated-dom.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
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();
});
function descriptorPayload(
id = "announcements",
version = "1.2.0",
viewer: unknown = {
kind: "authenticated",
userId: "user-1",
},
) {
return {
viewer,
module: {
contributions: [],
api: { baseUrl: `/api/v1/modules/${id}` },
display: { url: `/api/v1/module-ui/${id}/${version}/display` },
id,
page: { url: `/display/${id}` },
permissions: [`${id}.slideshow.view`],
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
version,
},
};
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
status,
});
}
interface PlatformOptions {
readonly descriptorStatus?: number;
readonly principal?: unknown;
}
function platform(options: PlatformOptions = {}) {
const calls: Array<{ readonly init: RequestInit | undefined; readonly url: string }> = [];
const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ init, url });
if (url.endsWith("/api/v1/principal")) {
return json(options.principal ?? { kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/module-displays/announcements")) {
return json(descriptorPayload(), options.descriptorStatus ?? 200);
}
if (url.endsWith("/api/v1/device-pairing/requests")) {
return json(
{ code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 },
201,
);
}
if (url.endsWith("/api/v1/device-pairing/claims")) {
return json({ status: "pending" });
}
if (url.endsWith("/api/v1/modules/announcements/slides")) {
return json([{ id: "slide-1" }]);
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { calls, fetch };
}
class FakeEventSource {
readonly CLOSED = 2 as const;
readonly CONNECTING = 0 as const;
readonly OPEN = 1 as const;
readonly listeners = new Map<string, Set<EventListener>>();
closeCalls = 0;
readyState: number = this.CONNECTING;
addEventListener(type: string, listener: EventListener): void {
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(type: string, listener: EventListener): void {
this.listeners.get(type)?.delete(listener);
}
close(): void {
this.closeCalls += 1;
this.readyState = this.CLOSED;
}
emit(type: string, data?: string): void {
if (type === "open") {
this.readyState = this.OPEN;
}
const event = Object.freeze({ data, type }) as unknown as Event;
for (const listener of this.listeners.get(type) ?? []) {
listener(event);
}
}
}
function eventSources() {
const calls: Array<{
readonly options: HostEventSourceOptions;
readonly source: FakeEventSource;
readonly url: string;
}> = [];
const factory = ((url: string, options: HostEventSourceOptions): EventSource => {
const source = new FakeEventSource();
calls.push({ options, source, url });
return source as unknown as EventSource;
}) as HostEventSourceFactory;
return { calls, factory };
}
function artifact(
id = "announcements",
version = "1.2.0",
options: {
readonly cleanup?: () => void;
readonly mount?: (container: Element, context: ModuleUiHostContext) => undefined | (() => void);
} = {},
) {
return {
default: {
abi: MODULE_UI_ABI_VERSION,
id,
mount(element: Element, context: ModuleUiHostContext) {
if (options.mount) {
return options.mount(element, context);
}
element.textContent = `${context.moduleId}:display`;
return () => {
options.cleanup?.();
element.replaceChildren();
};
},
version,
},
};
}
async function render(
config: EkklesiaHostConfig,
importModule?: (url: string) => Promise<unknown>,
moduleId = "announcements",
connectivity?: DisplayConnectivitySource,
timing: {
readonly offlineIndicatorDelayMs?: number;
readonly revalidationIntervalMs?: number;
} = {
offlineIndicatorDelayMs: 0,
},
): Promise<void> {
await act(async () => {
root.render(
<DisplayApp
config={config}
{...(connectivity ? { connectivity } : {})}
{...(importModule ? { importModule } : {})}
{...timing}
moduleId={moduleId}
/>,
);
});
}
function query(testId: string): HTMLElement | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
function moduleUrl(source: string): string {
return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`;
}
describe("display host — full-viewport boundary", () => {
it("mounts only the display artifact with no Dashboard chrome", async () => {
const server = platform();
const imported: string[] = [];
await render({ apiBaseUrl, fetch: server.fetch }, async (url) => {
imported.push(url);
return artifact();
});
expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`]);
expect(isolatedText(query("display-module-container"))).toBe("announcements:display");
expect(container.querySelector("header")).toBeNull();
expect(container.querySelector("nav")).toBeNull();
expect(container.querySelector("aside")).toBeNull();
expect(container.textContent).not.toContain("Ekklesia");
expect(query("display-host")?.style.height).toBe("100%");
expect(query("display-host")?.style.minHeight).toBe("100dvh");
});
it("constructs the same browser-safe API, principal, permission, and realtime context", async () => {
const server = platform();
const live = eventSources();
let seenContext: ModuleUiHostContext | undefined;
await render({ apiBaseUrl, eventSourceFactory: live.factory, fetch: server.fetch }, async () =>
artifact("announcements", "1.2.0", {
mount(element, context) {
seenContext = context;
void context.api.request("slides");
const subscription = context.realtime.subscribe("state", {});
element.textContent = context.permissions.has("announcements.slideshow.view")
? "permitted"
: "denied";
return () => subscription.close();
},
}),
);
expect(Object.keys(seenContext ?? {}).sort()).toEqual([
"api",
"contributions",
"display",
"moduleId",
"permissions",
"principal",
"realtime",
]);
// A display is one screen with no pages: the display host omits navigation (ADR 0063).
expect(seenContext && "navigation" in seenContext).toBe(false);
expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" });
expect(isolatedText(query("display-module-container"))).toBe("permitted");
expect(
server.calls.some((call) => call.url.endsWith("/api/v1/modules/announcements/slides")),
).toBe(true);
expect(live.calls[0]).toMatchObject({
options: { withCredentials: true },
url: `${apiBaseUrl}/api/v1/module-realtime/announcements/state`,
});
});
it("uses a genuine dynamic import and renders an Announcements snapshot with its direct object URL unchanged", async () => {
const server = platform();
const live = eventSources();
const requestedArtifacts: string[] = [];
const source = `
export default {
abi: ${MODULE_UI_ABI_VERSION},
id: "announcements",
version: "1.2.0",
mount(container, context) {
const image = document.createElement("img");
image.setAttribute("data-testid", "announcement-slide");
container.append(image);
const subscription = context.realtime.subscribe("state", {
onSnapshot(state) {
image.src = state.mode === "on" ? state.slides[state.currentSlide].url : "";
},
onUpdate(state) {
image.src = state.mode === "on" ? state.slides[state.currentSlide].url : "";
},
});
return () => { subscription.close(); container.replaceChildren(); };
},
};
`;
await render(
{ apiBaseUrl, eventSourceFactory: live.factory, fetch: server.fetch },
async (url) => {
requestedArtifacts.push(url);
return import(moduleUrl(source));
},
);
const deliveryUrl =
"https://objects.example.test/ekklesia-announcements-assets/slides%2Fwelcome.jpg?X-Amz-Signature=opaque";
await act(async () => {
live.calls[0]?.source.emit("open");
live.calls[0]?.source.emit(
"snapshot",
JSON.stringify({
payload: {
currentSlide: 0,
mode: "on",
slideSeconds: 7,
slides: [{ url: deliveryUrl }],
},
sequence: 1,
}),
);
});
expect(requestedArtifacts).toEqual([
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
]);
expect(deepQuery(container, '[data-testid="announcement-slide"]')?.getAttribute("src")).toBe(
deliveryUrl,
);
expect(container.innerHTML).not.toContain("socket.io");
expect(container.innerHTML).not.toContain("mqtt");
});
it("keeps one Announcements-shaped module's management and slideshow roots distinct", async () => {
const live = eventSources();
const requestedArtifacts: string[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return json({
groups: [],
ungrouped: [
{
module: {
contributions: [],
api: { baseUrl: "/api/v1/modules/announcements" },
display: { url: "/display/announcements" },
id: "announcements",
permissions: ["announcements.slideshow.view"],
realtime: { baseUrl: "/api/v1/module-realtime/announcements" },
ui: { url: "/api/v1/module-ui/announcements/1.2.0" },
version: "1.2.0",
},
name: "Announcements",
navigation: { icon: null, pages: [] },
},
],
});
}
if (url.endsWith("/api/v1/module-displays/announcements")) {
return json(descriptorPayload());
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
const config = { apiBaseUrl, eventSourceFactory: live.factory, fetch };
const importer = async (url: string): Promise<unknown> => {
requestedArtifacts.push(url);
if (!url.endsWith("/display")) {
return artifact("announcements", "1.2.0", {
mount(element) {
element.textContent = "announcement management";
return () => element.replaceChildren();
},
});
}
return artifact("announcements", "1.2.0", {
mount(element, context) {
const image = document.createElement("img");
image.dataset.testid = "both-role-slide";
element.append(image);
const subscription = context.realtime.subscribe<{
readonly currentSlide: number;
readonly mode: "off" | "on";
readonly slides: readonly { readonly url: string }[];
}>("state", {
onSnapshot(state) {
image.src = state.mode === "on" ? (state.slides[state.currentSlide]?.url ?? "") : "";
},
});
return () => {
subscription.close();
element.replaceChildren();
};
},
});
};
await act(async () =>
root.render(
<HostApp
config={config}
history={createMemoryHostHistory("/announcements")}
importModule={importer}
/>,
),
);
// The management artifact rendered inside its own isolated root, not the shell's own tree.
expect(isolatedText(container.querySelector('[data-testid="module-container"]'))).toContain(
"announcement management",
);
expect(container.querySelector("nav")).not.toBeNull();
expect(requestedArtifacts).toEqual([`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`]);
await act(async () => root.unmount());
root = createRoot(container);
await act(async () =>
root.render(<DisplayApp config={config} importModule={importer} moduleId="announcements" />),
);
const deliveryUrl =
"https://objects.example.test/module-assets/slide.jpg?X-Amz-Signature=opaque";
await act(async () => {
live.calls[0]?.source.emit("open");
live.calls[0]?.source.emit(
"snapshot",
JSON.stringify({
payload: { currentSlide: 0, mode: "on", slides: [{ url: deliveryUrl }] },
sequence: 1,
}),
);
});
expect(requestedArtifacts).toEqual([
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0`,
`${apiBaseUrl}/api/v1/module-ui/announcements/1.2.0/display`,
]);
expect(deepQuery(container, '[data-testid="both-role-slide"]')?.getAttribute("src")).toBe(
deliveryUrl,
);
expect(container.querySelector("nav")).toBeNull();
expect(container.querySelector("header")).toBeNull();
expect(JSON.stringify(requestedArtifacts)).not.toMatch(/socket\.io|mqtt|10\.|localhost/u);
});
});
describe("display host — authentication and controlled failures", () => {
it("shows a pairing code instead of a human login when it has no identity", async () => {
const server = platform({ descriptorStatus: 403 });
const importer = vi.fn(async () => artifact());
await render({ apiBaseUrl, fetch: server.fetch }, importer);
// An unattended screen must never be sent to an email/password form: nobody is standing at it.
expect(query("display-host-signed-out")).toBeNull();
expect(query("display-pairing")).not.toBeNull();
expect(query("display-pairing-code")?.textContent).toBe("482731");
// The protected artifact is still not fetched, which is the invariant that matters.
expect(importer).not.toHaveBeenCalled();
// And the display never asks the human principal endpoint at all.
expect(server.calls.some((call) => call.url.endsWith("/api/v1/principal"))).toBe(false);
});
it("puts no request id, secret, or permission on a screen anyone can read", async () => {
const server = platform({ descriptorStatus: 403 });
await render(
{ apiBaseUrl, fetch: server.fetch },
vi.fn(async () => artifact()),
);
const rendered = container.textContent ?? "";
expect(rendered).toContain("482731");
expect(rendered).not.toMatch(/announcements\.slideshow\.view|device-pairing|claim|token/u);
});
it("does not import protected display code when the display is unavailable", async () => {
const server = platform({ descriptorStatus: 404 });
const importer = vi.fn(async () => artifact());
await render({ apiBaseUrl, fetch: server.fetch }, importer);
// 404 is "there is no such display", which pairing cannot fix — so it stays a plain message.
expect(query("display-host-unavailable")).not.toBeNull();
expect(query("display-pairing")).toBeNull();
expect(importer).not.toHaveBeenCalled();
});
it("clears partial mount DOM, renders no caught detail, and retries in a fresh container", async () => {
const server = platform();
let attempts = 0;
const containers: Element[] = [];
const importer = vi.fn(async () => {
attempts += 1;
return artifact("announcements", "1.2.0", {
mount(element) {
containers.push(element);
if (attempts === 1) {
element.textContent = "secret at /srv/modules/announcements";
throw new Error("mount exploded with token=secret");
}
element.textContent = "recovered";
return () => element.replaceChildren();
},
});
});
await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(query("display-error")).not.toBeNull();
expect(container.textContent).not.toContain("/srv/");
expect(container.textContent).not.toContain("secret");
await act(async () => query("display-retry")?.click());
expect(query("display-error")).toBeNull();
expect(isolatedText(query("display-module-container"))).toBe("recovered");
expect(containers).toHaveLength(2);
expect(containers[0]).not.toBe(containers[1]);
expect(containers[0]?.isConnected).toBe(false);
expect(consoleError).toHaveBeenCalledWith(
"[ekklesia] display UI failure module=announcements code=MODULE_UI_MOUNT_FAILED",
);
});
it.each([
"error",
"unhandledrejection",
] as const)("turns a post-mount page %s into one sanitized failure with manual retry", async (eventType) => {
const server = platform();
const cleanups: Element[] = [];
const mountedInto: Element[] = [];
const importer = vi.fn(async () =>
artifact("announcements", "1.2.0", {
mount(element) {
mountedInto.push(element);
element.textContent = "private render at /srv/modules/announcements";
return () => {
cleanups.push(element);
element.replaceChildren();
};
},
}),
);
await render({ apiBaseUrl, fetch: server.fetch }, importer);
expect(isolatedText(query("display-module-container"))).toContain("/srv/modules");
consoleError.mockClear();
await act(async () => {
const event =
eventType === "error"
? new ErrorEvent("error", {
error: new Error("token=secret-render-error"),
message: "token=secret-render-error",
})
: new Event("unhandledrejection");
window.dispatchEvent(event);
// A second signal from the same failed render must not run cleanup or transition twice.
window.dispatchEvent(new Event(eventType));
});
expect(query("display-error")).not.toBeNull();
expect(isolatedText(query("display-module-container"))).toBe("");
expect(container.textContent).toBe("This display could not be loaded.Retry");
expect(container.textContent).not.toMatch(/secret|\/srv\//u);
expect(importer).toHaveBeenCalledTimes(1);
expect(cleanups).toEqual([mountedInto[0]]);
expect(consoleError).toHaveBeenCalledTimes(1);
expect(consoleError).toHaveBeenCalledWith(
`[ekklesia] display UI failure module=announcements code=${
eventType === "error" ? "DISPLAY_UI_RUNTIME_ERROR" : "DISPLAY_UI_UNHANDLED_REJECTION"
}`,
);
await act(async () => query("display-retry")?.click());
expect(importer).toHaveBeenCalledTimes(2);
expect(query("display-error")).toBeNull();
expect(isolatedText(query("display-module-container"))).toContain("/srv/modules");
expect(mountedInto[0]?.isConnected).toBe(false);
expect(mountedInto[1]?.isConnected).toBe(true);
});
it("catches a React render failure reported after the artifact mount has returned", async () => {
const server = platform();
const cleanup = vi.fn();
await render({ apiBaseUrl, fetch: server.fetch }, async () =>
artifact("announcements", "1.2.0", {
mount(element) {
const moduleRoot = createRoot(element);
class ReportRenderFailure extends Component<{ readonly children: ReactNode }> {
override componentDidCatch(error: unknown): void {
// React's browser default reports an uncaught render through the page's global error
// facility. This test boundary makes that report deterministic under happy-dom,
// whose test runner otherwise intercepts the global exception first.
window.dispatchEvent(
new ErrorEvent("error", {
cancelable: true,
error,
message: error instanceof Error ? error.message : "display render failure",
}),
);
}
override render(): ReactNode {
return this.props.children;
}
}
const BrokenDisplay = (): ReactElement => {
throw new Error("late component secret at /srv/modules/announcements");
};
moduleRoot.render(
<ReportRenderFailure>
<BrokenDisplay />
</ReportRenderFailure>,
);
return () => {
cleanup();
moduleRoot.unmount();
};
},
}),
);
await act(async () => undefined);
expect(query("display-error")).not.toBeNull();
expect(container.textContent).toBe("This display could not be loaded.Retry");
expect(container.textContent).not.toMatch(/secret|\/srv\//u);
expect(cleanup).toHaveBeenCalledTimes(1);
});
it("does not treat a module media/resource error as a crashed display runtime", async () => {
const server = platform();
const cleanup = vi.fn();
await render({ apiBaseUrl, fetch: server.fetch }, async () =>
artifact("announcements", "1.2.0", {
mount(element) {
const image = document.createElement("img");
image.dataset.testid = "display-media";
element.append(image);
return () => cleanup();
},
}),
);
await act(async () => {
deepQuery(container, '[data-testid="display-media"]')?.dispatchEvent(
new ErrorEvent("error", {
bubbles: true,
cancelable: true,
message: "a signed media URL expired",
}),
);
});
expect(query("display-error")).toBeNull();
expect(deepQuery(container, '[data-testid="display-media"]')).not.toBeNull();
expect(cleanup).not.toHaveBeenCalled();
});
it("removes the page failure listeners when a display attempt is torn down", async () => {
const server = platform();
const cleanup = vi.fn();
await render({ apiBaseUrl, fetch: server.fetch }, async () =>
artifact("announcements", "1.2.0", { cleanup }),
);
await act(async () => root.render(null));
expect(cleanup).toHaveBeenCalledTimes(1);
consoleError.mockClear();
window.dispatchEvent(new ErrorEvent("error", { message: "after teardown" }));
window.dispatchEvent(new Event("unhandledrejection"));
expect(cleanup).toHaveBeenCalledTimes(1);
expect(consoleError).not.toHaveBeenCalled();
});
});
describe("display host — independent instances and stale work", () => {
it("mounts and unmounts two browser instances independently", async () => {
const server = platform();
const cleanups: string[] = [];
const importer = async () =>
artifact("announcements", "1.2.0", {
cleanup: () => cleanups.push("cleanup"),
});
const config = { apiBaseUrl, fetch: server.fetch };
await act(async () => {
root.render(
<>
<DisplayApp
config={config}
importModule={importer}
key="first"
moduleId="announcements"
/>
<DisplayApp
config={config}
importModule={importer}
key="second"
moduleId="announcements"
/>
</>,
);
});
expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(2);
await act(async () => {
root.render(
<DisplayApp
config={config}
importModule={importer}
key="second"
moduleId="announcements"
/>,
);
});
expect(cleanups).toHaveLength(1);
expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(1);
expect(isolatedText(query("display-module-container"))).toBe("announcements:display");
});
it("discards a late import into its detached container without disturbing the newer route", async () => {
let releaseAnnouncements: (() => void) | undefined;
const pendingAnnouncements = new Promise<void>((resolve) => {
releaseAnnouncements = resolve;
});
const mountedInto: Element[] = [];
const staleUnmounts: Element[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/module-displays/calendar")) {
return json(descriptorPayload("calendar", "2.0.0"));
}
return json(descriptorPayload());
}) as unknown as typeof globalThis.fetch;
const config = { apiBaseUrl, fetch };
const importer = async (url: string): Promise<unknown> => {
const isCalendar = url.includes("/calendar/");
if (!isCalendar) {
await pendingAnnouncements;
}
return artifact(isCalendar ? "calendar" : "announcements", isCalendar ? "2.0.0" : "1.2.0", {
mount(element) {
mountedInto.push(element);
element.textContent = isCalendar ? "calendar-live" : "announcements-stale";
return () => {
if (!isCalendar) {
staleUnmounts.push(element);
}
element.replaceChildren();
};
},
});
};
await render(config, importer);
await render(config, importer, "calendar");
expect(isolatedText(query("display-module-container"))).toBe("calendar-live");
const liveContainer = query("display-module-container");
await act(async () => {
releaseAnnouncements?.();
await pendingAnnouncements;
});
expect(query("display-module-container")).toBe(liveContainer);
expect(isolatedText(liveContainer)).toBe("calendar-live");
const liveMountTarget = isolatedMountTarget(liveContainer);
const staleContainer = mountedInto.find((element) => element !== liveMountTarget);
expect(staleContainer?.isConnected).toBe(false);
expect(staleUnmounts).toEqual([staleContainer]);
});
});
/**
* How an unattended screen behaves when its network comes and goes (ADR 0043).
*
* The caching itself is proven directly against the worker's logic in `display-offline-cache.test.ts`.
* What is proven here is the *page's* half: it says so honestly, it recovers without anyone touching
* it, and it never keeps showing content a revoked device is no longer entitled to.
*/
describe("display host — offline behaviour", () => {
/** A controllable stand-in for the browser's own connectivity events. */
function connectivitySource(initialOnline: boolean) {
const listeners = new Map<string, Set<() => void>>();
const workerListeners = new Set<(event: MessageEvent) => void>();
return {
source: {
addEventListener(type: "online" | "offline", listener: () => void) {
const existing = listeners.get(type) ?? new Set();
existing.add(listener);
listeners.set(type, existing);
},
initialOnline,
removeEventListener(type: "online" | "offline", listener: () => void) {
listeners.get(type)?.delete(listener);
},
serviceWorkerMessages: {
addEventListener(_type: "message", listener: (event: MessageEvent) => void) {
workerListeners.add(listener);
},
removeEventListener(_type: "message", listener: (event: MessageEvent) => void) {
workerListeners.delete(listener);
},
},
} satisfies DisplayConnectivitySource,
async emit(type: "online" | "offline") {
await act(async () => {
for (const listener of listeners.get(type) ?? []) {
listener();
}
});
},
async emitFallback(kind: "shell" | "artifact") {
await act(async () => {
for (const listener of workerListeners) {
listener({
data: { ekklesiaDisplayOffline: { kind, usingFallback: true } },
} as MessageEvent);
}
});
},
};
}
it("shows no indicator at all while the display is genuinely online", async () => {
const server = platform();
const connectivity = connectivitySource(true);
await render(
{ apiBaseUrl, fetch: server.fetch },
async () => artifact(),
"announcements",
connectivity.source,
);
expect(query("display-offline")).toBeNull();
});
it("says so, quietly and honestly, when the browser goes offline", async () => {
const server = platform();
const connectivity = connectivitySource(true);
await render(
{ apiBaseUrl, fetch: server.fetch },
async () => artifact(),
"announcements",
connectivity.source,
);
await connectivity.emit("offline");
// A corner indicator, not a full-page failure: a screen whose switch rebooted must not blank a
// lobby wall.
const indicator = query("display-offline");
expect(indicator?.getAttribute("role")).toBe("status");
expect(indicator?.textContent).toContain("Offline");
expect(query("display-module-container")).not.toBeNull();
});
it("reports a cache fallback even while the browser still claims to be online", async () => {
const server = platform();
const connectivity = connectivitySource(true);
await render(
{ apiBaseUrl, fetch: server.fetch },
async () => artifact(),
"announcements",
connectivity.source,
);
await connectivity.emitFallback("artifact");
// A captive portal reports `online: true`. Only the worker knows this application actually
// served code from its cache.
expect(query("display-offline")?.textContent).toContain("last downloaded display code");
});
it("resolves again on its own when the network returns — nobody presses Retry on a lobby TV", async () => {
const server = platform();
const connectivity = connectivitySource(false);
await render(
{ apiBaseUrl, fetch: server.fetch },
async () => artifact(),
"announcements",
connectivity.source,
);
const descriptorCalls = () =>
server.calls.filter((call) => call.url.endsWith("/api/v1/module-displays/announcements"))
.length;
const before = descriptorCalls();
await connectivity.emit("online");
// A fresh descriptor is exactly what stops a screen sitting on cached code after the server
// returns: it names the current version, and the network-first worker fetches it.
expect(descriptorCalls()).toBeGreaterThan(before);
});
it("drops a revoked device back to pairing the moment connectivity returns", async () => {
// The descriptor is never cached, so a device whose access was revoked while it was offline
// cannot keep showing content: the first successful resolution after reconnecting is the
// server's refusal.
let revoked = false;
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/module-displays/announcements")) {
return revoked
? json({ code: "AUTHORIZATION_DENIED" }, 403)
: json(descriptorPayload(), 200);
}
if (url.endsWith("/api/v1/device-pairing/requests")) {
return json(
{ code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 },
201,
);
}
if (url.endsWith("/api/v1/device-pairing/claims")) {
return json({ status: "pending" });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
const connectivity = connectivitySource(true);
await render(
{ apiBaseUrl, fetch },
async () => artifact(),
"announcements",
connectivity.source,
);
expect(query("display-module-container")).not.toBeNull();
revoked = true;
await connectivity.emit("offline");
await connectivity.emit("online");
expect(query("display-module-container")).toBeNull();
expect(query("display-pairing")).not.toBeNull();
});
});
/**
* Stage 0 regressions: a running display survives its network, and a revoked one returns to pairing.
*
* Before these, every browser `online` event tore the module down and mounted it again (a fresh
* viewer session, a fresh stage, every cached object URL released), and a device revoked while its
* display was open kept playing until someone reloaded the page.
*/
describe("display host — continuity across reconnects and revocation", () => {
function controllableConnectivity() {
const listeners = new Map<string, Set<() => void>>();
return {
source: {
addEventListener(type: "online" | "offline", listener: () => void) {
const existing = listeners.get(type) ?? new Set();
existing.add(listener);
listeners.set(type, existing);
},
initialOnline: true,
removeEventListener(type: "online" | "offline", listener: () => void) {
listeners.get(type)?.delete(listener);
},
} satisfies DisplayConnectivitySource,
async emit(type: "online" | "offline") {
await act(async () => {
for (const listener of listeners.get(type) ?? []) {
listener();
}
});
},
};
}
async function flush(): Promise<void> {
await act(async () => {
for (let index = 0; index < 5; index += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
}
/** A server whose descriptor answer can be changed mid-test, counting descriptor requests. */
function changingServer() {
let descriptor: { status: number; body: unknown } = { body: descriptorPayload(), status: 200 };
let descriptorCalls = 0;
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/module-displays/announcements")) {
descriptorCalls += 1;
return json(descriptor.body, descriptor.status);
}
if (url.endsWith("/api/v1/device-pairing/requests")) {
return json(
{ code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 },
201,
);
}
if (url.endsWith("/api/v1/device-pairing/claims")) {
return json({ status: "pending" });
}
if (url.endsWith("/api/v1/modules/announcements/refused")) {
return json({ code: "AUTHORIZATION_DENIED" }, 403);
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return {
descriptorCalls: () => descriptorCalls,
fetch,
set(status: number, body: unknown) {
descriptor = { body, status };
},
};
}
function countingArtifact(version = "1.2.0") {
const counts = { mounts: 0, unmounts: 0 };
let context: ModuleUiHostContext | undefined;
const importModule = async (url: string) =>
artifact("announcements", url.includes("/2.0.0/") ? "2.0.0" : version, {
mount(element, mounted) {
counts.mounts += 1;
context = mounted;
element.textContent = "live";
return () => {
counts.unmounts += 1;
};
},
});
return { context: () => context, counts, importModule };
}
it("does not remount a live display when the network drops and returns", async () => {
const server = changingServer();
const connectivity = controllableConnectivity();
const display = countingArtifact();
await render(
{ apiBaseUrl, fetch: server.fetch },
display.importModule,
"announcements",
connectivity.source,
);
await flush();
expect(display.counts.mounts).toBe(1);
const before = server.descriptorCalls();
await connectivity.emit("offline");
await connectivity.emit("online");
await flush();
// The descriptor is still resolved again (ADR 0043) — but the same answer changes nothing.
expect(server.descriptorCalls()).toBeGreaterThan(before);
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
expect(query("display-host-loading")).toBeNull();
});
it("keeps the running display when the descriptor cannot be reached yet", async () => {
const server = changingServer();
const connectivity = controllableConnectivity();
const display = countingArtifact();
await render(
{ apiBaseUrl, fetch: server.fetch },
display.importModule,
"announcements",
connectivity.source,
);
await flush();
server.set(503, { code: "UNAVAILABLE" });
await connectivity.emit("offline");
await connectivity.emit("online");
await flush();
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
expect(query("display-host-error")).toBeNull();
});
it("replaces the display when the server now names a new version", async () => {
const server = changingServer();
const connectivity = controllableConnectivity();
const display = countingArtifact();
await render(
{ apiBaseUrl, fetch: server.fetch },
display.importModule,
"announcements",
connectivity.source,
);
await flush();
server.set(200, descriptorPayload("announcements", "2.0.0"));
await connectivity.emit("offline");
await connectivity.emit("online");
await flush();
expect(display.counts.unmounts).toBe(1);
expect(display.counts.mounts).toBe(2);
});
it("returns a device revoked while its display is open to pairing, without a reload", async () => {
const server = changingServer();
const display = countingArtifact();
await render({ apiBaseUrl, fetch: server.fetch }, display.importModule, "announcements");
await flush();
expect(query("display-module-container")).not.toBeNull();
// The administrator revokes the device. Its next module request is refused.
server.set(403, { code: "AUTHORIZATION_DENIED" });
await act(async () => {
await display.context()?.api.request("refused");
});
await flush();
expect(display.counts.unmounts).toBe(1);
expect(query("display-pairing")).not.toBeNull();
});
it("does not remount when a refused request turns out not to be a revocation", async () => {
const server = changingServer();
const display = countingArtifact();
await render(
{ apiBaseUrl, fetch: server.fetch },
display.importModule,
"announcements",
undefined,
{
offlineIndicatorDelayMs: 0,
revalidationIntervalMs: 60_000,
},
);
await flush();
const before = server.descriptorCalls();
await act(async () => {
await display.context()?.api.request("refused");
await display.context()?.api.request("refused");
await display.context()?.api.request("refused");
});
await flush();
// One revalidation for the burst, and the display stays exactly where it was.
expect(server.descriptorCalls()).toBe(before + 1);
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
});
it("does not show the offline indicator for a momentary blip", async () => {
const server = changingServer();
const connectivity = controllableConnectivity();
await render(
{ apiBaseUrl, fetch: server.fetch },
countingArtifact().importModule,
"announcements",
connectivity.source,
{ offlineIndicatorDelayMs: 60 },
);
await flush();
await connectivity.emit("offline");
await connectivity.emit("online");
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 120));
});
expect(query("display-offline")).toBeNull();
await connectivity.emit("offline");
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 120));
});
expect(query("display-offline")?.textContent).toBe("Offline — reconnecting");
});
it("starts on its own when the server comes up after the screen did", async () => {
// A kiosk that boots before its server: the network is up, so no `online` event will ever come.
let up = false;
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/module-displays/announcements")) {
return up ? json(descriptorPayload()) : json({ code: "UNAVAILABLE" }, 503);
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
const display = countingArtifact();
await act(async () => {
root.render(
<DisplayApp
config={{ apiBaseUrl, fetch }}
importModule={display.importModule}
moduleId="announcements"
offlineIndicatorDelayMs={0}
retryBaseMs={20}
/>,
);
});
await flush();
expect(query("display-host-error")).not.toBeNull();
up = true;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
await flush();
expect(display.counts.mounts).toBe(1);
expect(query("display-host-error")).toBeNull();
});
it("re-resolves a running display periodically, remounting only for a new version", async () => {
const server = changingServer();
const display = countingArtifact();
await act(async () => {
root.render(
<DisplayApp
config={{ apiBaseUrl, fetch: server.fetch }}
importModule={display.importModule}
moduleId="announcements"
offlineIndicatorDelayMs={0}
periodicRevalidationMs={40}
/>,
);
});
await flush();
const before = server.descriptorCalls();
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
await flush();
expect(server.descriptorCalls()).toBeGreaterThan(before);
expect(display.counts).toEqual({ mounts: 1, unmounts: 0 });
server.set(200, descriptorPayload("announcements", "2.0.0"));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
});
await flush();
expect(display.counts.mounts).toBe(2);
});
});
describe("display host — following what the screen is set to show (ADR 0065)", () => {
async function wait(ms: number): Promise<void> {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, ms));
});
}
/**
* A paired display (`viewer.kind === "device"`) on the announcements page. `shows` is what the
* platform says it may show now; `null` means it is not a paired display at all (403).
*/
function assignedServer(initial: { shows: string[] | null; descriptor?: number }) {
const state = { descriptor: initial.descriptor ?? 200, shows: initial.shows };
const calls: string[] = [];
const fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input)).pathname;
calls.push(url);
if (url === "/api/v1/module-displays/announcements") {
return state.descriptor === 200
? json(
descriptorPayload("announcements", "1.2.0", { deviceId: "device-1", kind: "device" }),
)
: json({ code: "AUTHORIZATION_DENIED" }, state.descriptor);
}
if (url === "/api/v1/device-pairing/self") {
return state.shows === null
? json({ code: "AUTHORIZATION_DENIED" }, 403)
: json({
displays: state.shows.map((moduleId) => ({ moduleId, url: `/display/${moduleId}` })),
name: "Foyer TV",
});
}
if (url === "/api/v1/device-pairing/requests") {
return json(
{ code: "482731", expiresAt: new Date().toISOString(), pollIntervalMs: 60_000 },
201,
);
}
if (url === "/api/v1/device-pairing/claims") {
return json({ status: "pending" });
}
return json({ code: "NOT_FOUND" }, 404);
}) as unknown as typeof globalThis.fetch;
return { calls, fetch, state };
}
async function renderAssigned(server: ReturnType<typeof assignedServer>) {
const openDisplay = vi.fn();
await act(async () => {
root.render(
<DisplayApp
assignmentCheckMs={20}
config={{ apiBaseUrl, fetch: server.fetch }}
importModule={async () => artifact()}
moduleId="announcements"
offlineIndicatorDelayMs={0}
openDisplay={openDisplay}
/>,
);
});
await wait(0);
return openDisplay;
}
it("goes to the platform setup page when a running screen is set to show something else", async () => {
const server = assignedServer({ shows: ["announcements"] });
const openDisplay = await renderAssigned(server);
expect(query("display-module-container")).not.toBeNull();
// Still this module: nothing happens, however often it asks.
await wait(60);
expect(openDisplay).not.toHaveBeenCalled();
// Settings → Displays moves it to another module (or to nothing).
server.state.shows = ["bulletin"];
await wait(60);
expect(openDisplay).toHaveBeenCalledWith("/display/setup");
expect(query("display-host-moving")).not.toBeNull();
// It was never asked to pair again.
expect(server.calls).not.toContain("/api/v1/device-pairing/requests");
});
it("goes to setup, not to a new pairing code, when its descriptor is refused but it is still paired", async () => {
const server = assignedServer({ descriptor: 403, shows: [] });
const openDisplay = await renderAssigned(server);
await wait(10);
expect(openDisplay).toHaveBeenCalledWith("/display/setup");
expect(query("display-pairing")).toBeNull();
expect(server.calls).not.toContain("/api/v1/device-pairing/requests");
});
it("still shows a pairing code when it is not a paired display at all", async () => {
const server = assignedServer({ descriptor: 403, shows: null });
const openDisplay = await renderAssigned(server);
await wait(10);
expect(openDisplay).not.toHaveBeenCalled();
expect(query("display-pairing")).not.toBeNull();
});
it("never moves a person previewing the page", async () => {
const server = assignedServer({ shows: [] });
const fetch = server.fetch;
// The descriptor reports a person, not a device.
const personFetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith("/api/v1/module-displays/announcements")) {
return json(descriptorPayload());
}
return fetch(input, init);
}) as unknown as typeof globalThis.fetch;
const openDisplay = vi.fn();
await act(async () => {
root.render(
<DisplayApp
assignmentCheckMs={20}
config={{ apiBaseUrl, fetch: personFetch }}
importModule={async () => artifact()}
moduleId="announcements"
offlineIndicatorDelayMs={0}
openDisplay={openDisplay}
/>,
);
});
await wait(80);
expect(openDisplay).not.toHaveBeenCalled();
expect(server.calls).not.toContain("/api/v1/device-pairing/self");
});
});