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(); }); function descriptor(id: string, version = "0.1.0", permissions: readonly string[] = []) { return { api: { baseUrl: `/api/v1/modules/${id}` }, id, permissions, ui: { url: `/api/v1/module-ui/${id}/${version}` }, version, }; } interface PlatformStub { readonly modules?: ReadonlyArray>; readonly principal?: unknown; readonly inventoryStatus?: number; readonly inventoryThrows?: boolean; } function platformFetch(stub: PlatformStub = {}): typeof fetch { const modules = stub.modules ?? [descriptor("example")]; 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/module-ui")) { if (stub.inventoryThrows) { throw new TypeError("network down"); } return new Response(JSON.stringify({ modules }), { headers: { "content-type": "application/json" }, status: stub.inventoryStatus ?? 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 inventory returned", async () => { await render({ apiBaseUrl, fetch: platformFetch({ modules: [descriptor("example"), descriptor("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 a refused inventory as signed out rather than as a crash", async () => { await render({ apiBaseUrl, fetch: platformFetch({ inventoryStatus: 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({ inventoryThrows: 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({ modules: [] }) }); expect(query("host-empty")).not.toBeNull(); }); }); 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 inventory 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({ modules: [descriptor("example"), descriptor("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({ modules: [descriptor("example"), descriptor("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({ modules: [descriptor("example"), descriptor("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({ modules: [descriptor("example"), descriptor("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"); }); });