import type { ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import { act } 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 type { EkklesiaHostConfig } 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); // The shell deliberately reports failures to the browser console; silence them so a passing run // is quiet, and so a test can assert *that* it reported without the noise. consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); }); afterEach(async () => { await act(async () => { root.unmount(); }); container.remove(); consoleError.mockRestore(); }); /** One navigation entry, in the wire shape the Dashboard navigation endpoint produces: the module's * display name beside the untouched mount descriptor. */ function entry(id: string, name: string, version = "0.1.0", permissions: readonly string[] = []) { return { module: { api: { baseUrl: `/api/v1/modules/${id}` }, id, permissions, realtime: { baseUrl: `/api/v1/module-realtime/${id}` }, ui: { url: `/api/v1/module-ui/${id}/${version}` }, version, }, name, }; } function group(id: string, label: string, modules: ReturnType[], order = 10) { return { id, label, modules, order }; } interface PlatformStub { readonly groups?: ReadonlyArray>; readonly ungrouped?: ReadonlyArray>; readonly principal?: unknown; readonly navigationStatus?: number; readonly navigationThrows?: boolean; } function platformFetch(stub: PlatformStub = {}): typeof fetch { const groups = stub.groups ?? []; const ungrouped = stub.ungrouped ?? (stub.groups ? [] : [entry("example", "Example Module")]); return vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/api/v1/principal")) { return new Response( JSON.stringify(stub.principal ?? { kind: "authenticated", userId: "user-1" }), { headers: { "content-type": "application/json" }, status: 200 }, ); } if (url.endsWith("/api/v1/dashboard/navigation")) { if (stub.navigationThrows) { throw new TypeError("network down"); } return new Response(JSON.stringify({ groups, ungrouped }), { headers: { "content-type": "application/json" }, status: stub.navigationStatus ?? 200, }); } return new Response("{}", { status: 404 }); }) as unknown as typeof fetch; } interface ArtifactOptions { readonly mountThrows?: boolean; readonly unmountThrows?: boolean; /** Records the container each mount received, so a test can prove a stale mount never touched the * newer selection's element. */ readonly mountedInto?: Element[]; } /** A module UI artifact namespace, exactly the shape the generic loader parses. */ function artifact(id: string, version: string, options: ArtifactOptions = {}) { return { default: { id, mount(element: Element, context: ModuleUiHostContext) { options.mountedInto?.push(element); if (options.mountThrows) { throw new Error("module mount failed"); } element.textContent = `${context.moduleId} mounted`; return () => { if (options.unmountThrows) { throw new Error("module cleanup failed"); } element.textContent = ""; }; }, version, }, }; } async function render( config: EkklesiaHostConfig, importModule?: (url: string) => Promise, ): Promise { await act(async () => { root.render(); }); } function text(): string { return container.textContent ?? ""; } function query(testId: string): Element | null { return container.querySelector(`[data-testid="${testId}"]`); } describe("Ekklesia shell — bootstrap", () => { it("renders the module list the navigation returned", async () => { await render({ apiBaseUrl, fetch: platformFetch({ ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")], }), }); expect(query("select-example")).not.toBeNull(); expect(query("select-calendar")).not.toBeNull(); }); it("asks an anonymous viewer to sign in, and mounts nothing", async () => { await render({ apiBaseUrl, fetch: platformFetch({ principal: { kind: "anonymous" } }) }); expect(query("host-signed-out")).not.toBeNull(); expect(query("module-container")).toBeNull(); }); it("treats refused navigation as signed out rather than as a crash", async () => { await render({ apiBaseUrl, fetch: platformFetch({ navigationStatus: 403 }) }); expect(query("host-signed-out")).not.toBeNull(); }); it("renders a host-owned error when the platform cannot be reached", async () => { await render({ apiBaseUrl, fetch: platformFetch({ navigationThrows: true }) }); expect(query("host-error")).not.toBeNull(); expect(text()).not.toContain("network down"); }); it("says so plainly when no installed module provides a UI", async () => { await render({ apiBaseUrl, fetch: platformFetch({ groups: [], ungrouped: [] }) }); expect(query("host-empty")).not.toBeNull(); }); }); describe("Ekklesia shell — Dashboard navigation", () => { const mediaGroup = () => group("media", "Media", [entry("video", "Video"), entry("livestream", "Livestream", "2.0.0")]); const planningGroup = () => group("planning", "Planning", [entry("calendar", "Calendar")], 20); it("renders groups with their labels and the modules' display names", async () => { await render({ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }), }); expect(query("group-media")?.textContent).toBe("MediaVideoLivestream"); expect(query("group-planning")?.textContent).toBe("PlanningCalendar"); // The module id is never shown; the manifest name is. expect(text()).not.toContain("livestream"); }); it("renders groups and modules in exactly the server's order", async () => { // The shell sorts nothing: whatever order arrives is the order shown, so the server stays the // single authority on arrangement. await render({ apiBaseUrl, fetch: platformFetch({ groups: [ group("planning", "Planning", [entry("calendar", "Calendar")]), group("media", "Media", [ entry("livestream", "Livestream", "2.0.0"), entry("video", "Video"), ]), ], }), }); const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent); expect(labels).toEqual(["Planning", "Media"]); const buttons = [...container.querySelectorAll("button")].map((button) => button.textContent); expect(buttons).toEqual(["Calendar", "Livestream", "Video"]); }); it("renders ungrouped modules alongside groups, under a heading", async () => { await render({ apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup()], ungrouped: [entry("roster", "Roster")], }), }); expect(query("group-ungrouped")?.textContent).toBe("OtherRoster"); }); it("omits the heading when nothing is grouped, so an unconfigured install reads as a list", async () => { await render({ apiBaseUrl, fetch: platformFetch({ ungrouped: [entry("roster", "Roster"), entry("video", "Video")] }), }); expect(query("group-ungrouped")?.textContent).toBe("RosterVideo"); expect(container.querySelectorAll("h2")).toHaveLength(0); }); it("keeps an empty group visible", async () => { await render({ apiBaseUrl, fetch: platformFetch({ groups: [group("media", "Media", [])], ungrouped: [entry("video", "Video")], }), }); // The administrator created it; hiding it would look like it had been deleted. expect(query("group-media")?.textContent).toBe("Media"); }); it("selects the first module in the arranged order and marks it active", async () => { await render( { apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) }, async (url) => artifact(url.includes("/video/") ? "video" : "other", "0.1.0"), ); expect(query("select-video")?.getAttribute("aria-current")).toBe("page"); expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull(); }); it("mounts a module selected from inside a group, using the descriptor the group carried", async () => { const requested: string[] = []; await render( { apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) }, async (url) => { requested.push(url); return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0"); }, ); await act(async () => { (query("select-calendar") as HTMLButtonElement).click(); }); // The nested descriptor is used unchanged: grouping altered nothing about the artifact URL. expect(requested).toEqual([ `${apiBaseUrl}/api/v1/module-ui/video/0.1.0`, `${apiBaseUrl}/api/v1/module-ui/calendar/0.1.0`, ]); expect(query("module-container")?.textContent).toBe("calendar mounted"); }); it("mounts the identical artifact whichever group a module is presented in", async () => { const requestedWhenGrouped: string[] = []; await render( { apiBaseUrl, fetch: platformFetch({ groups: [group("media", "Media", [entry("livestream", "Livestream")])], }), }, async (url) => { requestedWhenGrouped.push(url); return artifact("livestream", "0.1.0"); }, ); await act(async () => { root.unmount(); }); // The same module, presented somewhere else entirely. root = createRoot(container); const requestedWhenMoved: string[] = []; await render( { apiBaseUrl, fetch: platformFetch({ groups: [ group("sunday-production", "Sunday Production", [entry("livestream", "Livestream")], 5), ], }), }, async (url) => { requestedWhenMoved.push(url); return artifact("livestream", "0.1.0"); }, ); // Presentation changed; the module did not. expect(requestedWhenMoved).toEqual(requestedWhenGrouped); expect(query("group-sunday-production")?.textContent).toBe("Sunday ProductionLivestream"); }); }); describe("Ekklesia shell — mount lifecycle", () => { it("mounts the first module into the host's own container", async () => { const mountedInto: Element[] = []; await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0", { mountedInto }), ); expect(query("module-container")?.textContent).toBe("example mounted"); expect(query("module-loading")).toBeNull(); // The module was handed the shell's container, never the page root or the shell's own layout. expect(mountedInto[0]).toBe(query("module-container")); expect(mountedInto[0]).not.toBe(container); expect(mountedInto[0]).not.toBe(document.body); }); it("imports exactly the URL the navigation published, resolved against the API origin", async () => { const requested: string[] = []; await render({ apiBaseUrl, fetch: platformFetch() }, async (url) => { requested.push(url); return artifact("example", "0.1.0"); }); expect(requested).toEqual([`${apiBaseUrl}/api/v1/module-ui/example/0.1.0`]); }); it("shows a host-owned loading state until the module mounts", async () => { let release: (() => void) | undefined; const pending = new Promise((resolve) => { release = resolve; }); await render({ apiBaseUrl, fetch: platformFetch() }, async () => { await pending; return artifact("example", "0.1.0"); }); expect(query("module-loading")).not.toBeNull(); expect(query("module-container")?.textContent).toBe(""); await act(async () => { release?.(); await pending; }); expect(query("module-loading")).toBeNull(); expect(query("module-container")?.textContent).toBe("example mounted"); }); it("unmounts the previous module when the selection changes", async () => { const unmountedContainers: Element[] = []; const importModule = async (url: string) => { const id = url.includes("/calendar/") ? "calendar" : "example"; return { default: { id, mount(element: Element) { element.textContent = `${id} mounted`; return () => { unmountedContainers.push(element); element.textContent = ""; }; }, version: id === "calendar" ? "2.0.0" : "0.1.0", }, }; }; await render( { apiBaseUrl, fetch: platformFetch({ ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")], }), }, importModule, ); expect(query("module-container")?.textContent).toBe("example mounted"); await act(async () => { (query("select-calendar") as HTMLButtonElement).click(); }); expect(unmountedContainers).toHaveLength(1); expect(query("module-container")?.textContent).toBe("calendar mounted"); }); it("keeps a stale load from ever reaching the newer selection's container", async () => { const mountedInto: Element[] = []; const unmountedContainers: Element[] = []; let releaseExample: (() => void) | undefined; const examplePending = new Promise((resolve) => { releaseExample = resolve; }); const importModule = async (url: string) => { const id = url.includes("/calendar/") ? "calendar" : "example"; if (id === "example") { await examplePending; } return { default: { id, mount(element: Element) { mountedInto.push(element); element.textContent = `${id} mounted`; return () => { unmountedContainers.push(element); element.textContent = ""; }; }, version: id === "calendar" ? "2.0.0" : "0.1.0", }, }; }; await render( { apiBaseUrl, fetch: platformFetch({ ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")], }), }, importModule, ); // `example` is still importing; nothing has mounted yet. expect(mountedInto).toHaveLength(0); // Switch to `calendar`, which resolves immediately and mounts. await act(async () => { (query("select-calendar") as HTMLButtonElement).click(); }); const liveContainer = query("module-container"); expect(liveContainer?.textContent).toBe("calendar mounted"); // Only now does the stale `example` import resolve. await act(async () => { releaseExample?.(); await examplePending; }); // It mounted into its own, already-discarded container — never the live one — and was torn // down immediately, so the newer selection is untouched. expect(liveContainer?.textContent).toBe("calendar mounted"); const staleContainer = mountedInto.find((element) => element !== liveContainer); expect(staleContainer).toBeDefined(); expect(staleContainer?.isConnected).toBe(false); expect(unmountedContainers).toContain(staleContainer); }); }); describe("Ekklesia shell — failure is host-owned", () => { it("renders its own error when the artifact cannot be imported", async () => { await render({ apiBaseUrl, fetch: platformFetch() }, async () => { throw new Error("/srv/ekklesia/modules/example/dist/ui/index.js is missing"); }); expect(query("module-error")).not.toBeNull(); // Not a path, not a stack, not the caught message. expect(text()).not.toContain("/srv/ekklesia"); expect(text()).not.toContain("index.js"); // The shell itself is still rendered around the failure. expect(query("select-example")).not.toBeNull(); // The real error still reaches the browser console for a developer. expect(consoleError).toHaveBeenCalled(); }); it("renders its own error when the artifact is not a valid module UI", async () => { await render({ apiBaseUrl, fetch: platformFetch() }, async () => ({ notADefault: true })); expect(query("module-error")).not.toBeNull(); }); it("renders its own error when the artifact claims a different identity", async () => { await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "9.9.9")); expect(query("module-error")).not.toBeNull(); expect(query("module-container")?.textContent).toBe(""); }); it("renders its own error when the module's mount throws", async () => { await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0", { mountThrows: true }), ); expect(query("module-error")).not.toBeNull(); }); it("stays usable after a failure: another module still mounts", async () => { const importModule = async (url: string) => { if (url.includes("/example/")) { throw new Error("boom"); } return artifact("calendar", "2.0.0"); }; await render( { apiBaseUrl, fetch: platformFetch({ ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")], }), }, importModule, ); expect(query("module-error")).not.toBeNull(); await act(async () => { (query("select-calendar") as HTMLButtonElement).click(); }); expect(query("module-error")).toBeNull(); expect(query("module-container")?.textContent).toBe("calendar mounted"); }); it("survives a module whose cleanup throws, and never re-runs it", async () => { let cleanupCalls = 0; const importModule = async (url: string) => { if (url.includes("/calendar/")) { return artifact("calendar", "2.0.0"); } return { default: { id: "example", mount(element: Element) { element.textContent = "example mounted"; return () => { cleanupCalls += 1; throw new Error("module cleanup failed"); }; }, version: "0.1.0", }, }; }; await render( { apiBaseUrl, fetch: platformFetch({ ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")], }), }, importModule, ); await act(async () => { (query("select-calendar") as HTMLButtonElement).click(); }); expect(cleanupCalls).toBe(1); expect(query("module-container")?.textContent).toBe("calendar mounted"); // Switch back and away again: the throwing cleanup ran once per mount, never re-invoked for an // instance already torn down. await act(async () => { (query("select-example") as HTMLButtonElement).click(); }); await act(async () => { (query("select-calendar") as HTMLButtonElement).click(); }); expect(cleanupCalls).toBe(2); expect(query("module-container")?.textContent).toBe("calendar mounted"); }); });