A display is the one surface expected to outlive its network: a television on a wall in a building whose uplink is a consumer router, with nobody standing in front of it. When that uplink drops and the screen reboots, the browser shows its own error page — not the display shell, not an Ekklesia message, nothing that recovers on its own, because there is no Ekklesia code left running to recover. Cache the shell and the declared code, in a service worker scoped to `/display/`: the page, the host script, the module's display artifact, and every contributed renderer that display actually mounted. All of them are code whose bytes already confer no authority, and all of them are addressed by a URL carrying an exact version. Cache nothing else, ever. Not the descriptor, not `/api/v1/principal`, not a module or platform API response, not the realtime stream, not a pairing exchange, and not any cross-origin request — which is what a signed object-delivery URL is. It is an allowlist rather than a deny list, so the cost of forgetting a new endpoint is that code goes uncached rather than that a permission decision gets stored. The consequence is stated rather than papered over: a display cannot render module content while offline. Mounting needs a descriptor, a descriptor is authorization-bearing, and it is never cached — so a revoked device stays revoked and cached code can never manufacture current authorization. What the cache buys is that a kiosk rebooting mid-outage boots into Ekklesia's own offline state and keeps retrying, that reconnecting mounts with nothing left to download, and that a hiccup fetching 600 KB of artifact after the tiny descriptor already resolved falls back to identical bytes instead of failing the display. Network-first everywhere, so a screen picks up new code the moment the server returns and can never be trapped on an obsolete build — the failure mode a cache-first worker produces on a screen nobody visits. Only a complete same-origin 200 is stored. Cache names carry a policy version and superseded generations are deleted; artifacts are pruned to the current version per module, keeping every artifact belonging to that version because a display artifact and its contributed renderers share one. The worker never caches itself. The page gets a small signal built from two sources, because either alone lies: `navigator.onLine` is optimistic behind a captive portal, and only the worker knows it actually served from cache. A display shows a quiet corner indicator rather than blanking a lobby wall, and resolves its descriptor again by itself when the network returns — nobody presses Retry on a lobby TV. Every caching decision lives in a module that touches no global, so the behaviour is exercised directly rather than through a worker a test runner cannot host. Registration is an enhancement: no service workers, plain HTTP, or a refusing policy all leave a display that works exactly as before. Co-Authored-By: Claude Opus 5 <[email protected]>
926 lines
32 KiB
TypeScript
926 lines
32 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 { 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,
|
|
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,
|
|
): Promise<void> {
|
|
await act(async () => {
|
|
root.render(
|
|
<DisplayApp
|
|
config={config}
|
|
{...(connectivity ? { connectivity } : {})}
|
|
{...(importModule ? { importModule } : {})}
|
|
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",
|
|
"moduleId",
|
|
"permissions",
|
|
"principal",
|
|
"realtime",
|
|
]);
|
|
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" },
|
|
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",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
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} 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();
|
|
});
|
|
});
|