import 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"; 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; 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: { 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>(); closeCalls = 0; readyState: number = this.CONNECTING; addEventListener(type: string, listener: EventListener): void { const listeners = this.listeners.get(type) ?? new Set(); 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: { 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, moduleId = "announcements", ): Promise { await act(async () => { root.render( , ); }); } 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(query("display-module-container")?.textContent).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", "moduleId", "permissions", "principal", "realtime", ]); expect(seenContext?.principal).toEqual({ kind: "authenticated", userId: "user-1" }); expect(query("display-module-container")?.textContent).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 { 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(container.querySelector('[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: { 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 => { 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()); expect(container.textContent).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(), ); 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(container.querySelector('[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(query("display-module-container")?.textContent).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(query("display-module-container")?.textContent).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(query("display-module-container")?.textContent).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(query("display-module-container")?.textContent).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( , ); 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 () => { container.querySelector('[data-testid="display-media"]')?.dispatchEvent( new ErrorEvent("error", { bubbles: true, cancelable: true, message: "a signed media URL expired", }), ); }); expect(query("display-error")).toBeNull(); expect(query("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( <> , ); }); expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(2); await act(async () => { root.render( , ); }); expect(cleanups).toHaveLength(1); expect(container.querySelectorAll('[data-testid="display-module-container"]')).toHaveLength(1); expect(query("display-module-container")?.textContent).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((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 => { 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(query("display-module-container")?.textContent).toBe("calendar-live"); const liveContainer = query("display-module-container"); await act(async () => { releaseAnnouncements?.(); await pendingAnnouncements; }); expect(query("display-module-container")).toBe(liveContainer); expect(liveContainer?.textContent).toBe("calendar-live"); const staleContainer = mountedInto.find((element) => element !== liveContainer); expect(staleContainer?.isConnected).toBe(false); expect(staleUnmounts).toEqual([staleContainer]); }); });