import { MODULE_UI_ABI_VERSION, 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"; import { 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; beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; window.history.replaceState({}, "", "/"); 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: { contributions: [], 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: { abi: MODULE_UI_ABI_VERSION, 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}"]`); } async function setInput(testId: string, value: string): Promise { const input = query(testId) as HTMLInputElement | null; if (!input) { throw new Error(`missing input ${testId}`); } await act(async () => { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; setter?.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); }); } async function submitLogin(): Promise { const form = query("host-login")?.querySelector("form"); if (!form) { throw new Error("missing login form"); } await act(async () => { form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); await Promise.resolve(); }); } interface StatefulAuthPlatform { readonly calls: Array<{ readonly init: RequestInit | undefined; readonly url: string }>; readonly fetch: typeof fetch; authenticated(): boolean; setAuthenticated(value: boolean): void; } function statefulAuthPlatform( options: { readonly initiallyAuthenticated?: boolean; readonly moduleStatus?: () => number; readonly onModuleRequest?: () => Promise; readonly onPrincipalRequest?: (authenticated: boolean) => Promise; readonly onSignIn?: () => Promise; readonly onSignOut?: () => Promise; } = {}, ): StatefulAuthPlatform { let authenticated = options.initiallyAuthenticated ?? false; const calls: StatefulAuthPlatform["calls"] = []; const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); calls.push({ init, url }); if (url.endsWith("/api/auth/sign-in/email")) { await options.onSignIn?.(); const body = JSON.parse(String(init?.body)) as { email?: unknown; password?: unknown }; if (body.email !== "owner@example.test" || body.password !== "correct password") { return Response.json({ code: "INVALID_EMAIL_OR_PASSWORD" }, { status: 401 }); } authenticated = true; return Response.json({ ok: true }); } if (url.endsWith("/api/auth/sign-out")) { await options.onSignOut?.(); authenticated = false; return Response.json({ success: true }); } if (url.endsWith("/api/v1/principal")) { await options.onPrincipalRequest?.(authenticated); return Response.json( authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" }, ); } if (url.endsWith("/api/v1/dashboard/navigation")) { return authenticated ? Response.json({ groups: [], ungrouped: [entry("example", "Example Module")] }) : Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); } if (url.includes("/api/v1/modules/example/")) { await options.onModuleRequest?.(); return new Response("{}", { status: options.moduleStatus?.() ?? 200 }); } return Response.json({ code: "NOT_FOUND" }, { status: 404 }); }) as unknown as typeof fetch; return { calls, fetch: implementation, authenticated: () => authenticated, setAuthenticated: (value) => { authenticated = value; }, }; } 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("renders the host login for an anonymous viewer, and mounts nothing", async () => { await render({ apiBaseUrl, fetch: platformFetch({ principal: { kind: "anonymous" } }) }); expect(query("host-login")).not.toBeNull(); expect(query("module-container")).toBeNull(); }); it("keeps an authenticated navigation denial distinct from sign-out", async () => { await render({ apiBaseUrl, fetch: platformFetch({ navigationStatus: 403 }) }); expect(query("host-access-denied")).not.toBeNull(); expect(query("host-login")).toBeNull(); expect(query("host-logout")).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 — browser authentication", () => { it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => { let releasePrincipal: (() => void) | undefined; const principalPending = new Promise((resolve) => { releasePrincipal = resolve; }); const calls: string[] = []; const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); calls.push(url); if (url.endsWith("/api/v1/principal")) { await principalPending; return Response.json({ kind: "anonymous" }); } return Response.json({ groups: [], ungrouped: [] }); }) as unknown as typeof fetch; await render({ apiBaseUrl, fetch: fetchImplementation }); expect(query("host-loading")).not.toBeNull(); expect(query("host-login")).toBeNull(); expect(query("host-logout")).toBeNull(); expect(calls).toEqual([`${apiBaseUrl}/api/v1/principal`]); await act(async () => { releasePrincipal?.(); await principalPending; }); expect(query("host-login")).not.toBeNull(); expect(calls).toEqual([`${apiBaseUrl}/api/v1/principal`]); }); it("renders an accessible semantic email/password form with a real reset action and no signup", async () => { const platform = statefulAuthPlatform(); await render({ apiBaseUrl, fetch: platform.fetch }); const email = query("login-email") as HTMLInputElement; const password = query("login-password") as HTMLInputElement; const form = query("host-login")?.querySelector("form"); const submit = form?.querySelector('button[type="submit"]') as HTMLButtonElement; expect(email.type).toBe("email"); expect(email.autocomplete).toBe("username"); expect(email.labels?.[0]?.textContent).toContain("Email"); expect(password.type).toBe("password"); expect(password.autocomplete).toBe("current-password"); expect(password.labels?.[0]?.textContent).toContain("Password"); expect(submit.textContent).toBe("Sign in"); expect(form?.tagName).toBe("FORM"); // Public registration remains disabled, so the shell still offers no way to ask for an account. expect(text()).not.toContain("Create account"); expect(text()).not.toContain("Sign up"); // Password recovery is no longer a dead action: Phase 7C Pass 2 gave it a real backend flow, so // the control exists and is a real button rather than decorative text. const forgot = query("login-forgot-password") as HTMLButtonElement; expect(forgot.tagName).toBe("BUTTON"); expect(forgot.type).toBe("button"); expect(forgot.textContent).toBe("Forgot password?"); }); it("fails invalid credentials generically, clears the password, and never puts it in a URL", async () => { const platform = statefulAuthPlatform(); await render({ apiBaseUrl, fetch: platform.fetch }); await setInput("login-email", "owner@example.test"); await setInput("login-password", "wrong and secret"); await submitLogin(); const error = query("host-login")?.querySelector('[role="alert"]'); expect(error?.textContent).toBe("Email or password is incorrect."); expect((query("login-email") as HTMLInputElement).value).toBe("owner@example.test"); expect((query("login-password") as HTMLInputElement).value).toBe(""); expect(document.activeElement).toBe(query("login-password")); expect(platform.calls.map((call) => call.url).join("\n")).not.toContain("wrong and secret"); expect(consoleError).not.toHaveBeenCalled(); }); it("prevents duplicate submissions while one sign-in is pending", async () => { let releaseSignIn: (() => void) | undefined; const signInPending = new Promise((resolve) => { releaseSignIn = resolve; }); const platform = statefulAuthPlatform({ onSignIn: () => signInPending }); await render({ apiBaseUrl, fetch: platform.fetch }); await setInput("login-email", "owner@example.test"); await setInput("login-password", "correct password"); await submitLogin(); await submitLogin(); const signInCalls = platform.calls.filter((call) => call.url.endsWith("/api/auth/sign-in/email"), ); expect(signInCalls).toHaveLength(1); expect( query("host-login")?.querySelector('button[type="submit"]')?.hasAttribute("disabled"), ).toBe(true); await act(async () => { releaseSignIn?.(); await signInPending; await Promise.resolve(); }); expect(query("host-logout")).not.toBeNull(); }); it("clears the password while post-sign-in principal resolution is still pending", async () => { let releasePrincipal: (() => void) | undefined; const principalPending = new Promise((resolve) => { releasePrincipal = resolve; }); const platform = statefulAuthPlatform({ onPrincipalRequest: (authenticated) => (authenticated ? principalPending : Promise.resolve()), }); await render({ apiBaseUrl, fetch: platform.fetch }); await setInput("login-email", "owner@example.test"); await setInput("login-password", "correct password"); await submitLogin(); expect(platform.authenticated()).toBe(true); expect((query("login-password") as HTMLInputElement).value).toBe(""); expect( JSON.parse( String( platform.calls.find((call) => call.url.endsWith("/api/auth/sign-in/email"))?.init?.body, ), ), ).toEqual({ email: "owner@example.test", password: "correct password" }); expect(query("host-login")?.querySelector("form")?.getAttribute("aria-busy")).toBe("true"); await act(async () => { releasePrincipal?.(); await principalPending; await Promise.resolve(); }); expect(query("host-logout")).not.toBeNull(); }); it("submits from the password field with the Enter key", async () => { let releaseSignIn: (() => void) | undefined; const signInPending = new Promise((resolve) => { releaseSignIn = resolve; }); const platform = statefulAuthPlatform({ onSignIn: () => signInPending }); await render({ apiBaseUrl, fetch: platform.fetch }); await setInput("login-email", "owner@example.test"); await setInput("login-password", "correct password"); await act(async () => { query("login-password")?.dispatchEvent( new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }), ); await Promise.resolve(); }); expect( platform.calls.filter((call) => call.url.endsWith("/api/auth/sign-in/email")), ).toHaveLength(1); expect((query("login-password") as HTMLInputElement).value).toBe(""); await act(async () => { releaseSignIn?.(); await signInPending; await Promise.resolve(); }); expect(query("host-logout")).not.toBeNull(); }); it("re-resolves principal and navigation after login without a page reload", async () => { const platform = statefulAuthPlatform(); const importer = vi.fn(async () => artifact("example", "0.1.0")); await render({ apiBaseUrl, fetch: platform.fetch }, importer); await setInput("login-email", "owner@example.test"); await setInput("login-password", "correct password"); await submitLogin(); expect(query("host-login")).toBeNull(); expect(query("host-logout")).not.toBeNull(); expect(isolatedText(query("module-container"))).toBe("example mounted"); expect(importer).toHaveBeenCalledOnce(); expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([ "/api/v1/principal", "/api/auth/sign-in/email", "/api/v1/principal", "/api/v1/dashboard/navigation", // The host-owned device panel loads with the rest of the shell for an administrator. "/api/v1/devices", ]); }); it("keeps the current internal URL in place and ignores external or protocol-relative return inputs", async () => { window.history.replaceState( {}, "", "/?returnTo=%2F%2Fevil.example.test%2Fsteal&next=https%3A%2F%2Fevil.example.test&backslash=%5C%5Cevil.example.test&scheme=javascript%3Aalert%281%29", ); const expectedLocation = window.location.href; const platform = statefulAuthPlatform(); await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0")); await setInput("login-email", "owner@example.test"); await setInput("login-password", "correct password"); await submitLogin(); expect(window.location.href).toBe(expectedLocation); expect(platform.calls.every((call) => new URL(call.url).origin === apiBaseUrl)).toBe(true); expect(platform.calls.map((call) => call.url).join("\n")).not.toContain("evil.example.test"); expect(platform.calls.map((call) => call.url).join("\n")).not.toContain("javascript"); }); it("shows a safe retry when principal resolution is malformed or unavailable", async () => { let healthy = false; const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/api/v1/principal")) { return healthy ? Response.json({ kind: "authenticated", userId: "user-1" }) : new Response("database failed at /private/path", { status: 503 }); } return Response.json({ groups: [], ungrouped: [entry("example", "Example Module")] }); }) as unknown as typeof fetch; await render({ apiBaseUrl, fetch: fetchImplementation }, async () => artifact("example", "0.1.0"), ); expect(query("host-error")).not.toBeNull(); expect(text()).not.toContain("private"); healthy = true; const retry = query("host-error")?.querySelector("button"); expect(retry).not.toBeNull(); await act(async () => { (retry as HTMLButtonElement).click(); }); expect(query("host-logout")).not.toBeNull(); }); it("fails safely on a malformed successful principal response and recovers on retry", async () => { let healthy = false; const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/api/v1/principal")) { return Response.json( healthy ? { kind: "authenticated", userId: "user-1" } : { kind: "authenticated", privatePath: "/sessions/private" }, ); } return Response.json({ groups: [], ungrouped: [entry("example", "Example Module")] }); }) as unknown as typeof fetch; await render({ apiBaseUrl, fetch: fetchImplementation }, async () => artifact("example", "0.1.0"), ); expect(query("host-error")).not.toBeNull(); expect(text()).not.toContain("private"); expect(query("host-login")).toBeNull(); healthy = true; const retry = query("host-error")?.querySelector("button"); expect(retry).not.toBeNull(); await act(async () => { (retry as HTMLButtonElement).click(); await Promise.resolve(); }); expect(query("host-logout")).not.toBeNull(); }); }); describe("Ekklesia shell — logout and session expiration", () => { it("unmounts module UI and closes realtime before completing server-side logout", async () => { let releaseSignOut: (() => void) | undefined; const signOutPending = new Promise((resolve) => { releaseSignOut = resolve; }); const platform = statefulAuthPlatform({ initiallyAuthenticated: true, onSignOut: () => signOutPending, }); let unmountCalls = 0; let closeCalls = 0; const source = { CLOSED: 2, CONNECTING: 0, OPEN: 1, addEventListener: vi.fn(), close: () => { closeCalls += 1; }, readyState: 0, removeEventListener: vi.fn(), } as unknown as EventSource; await render( { apiBaseUrl, eventSourceFactory: () => source, fetch: platform.fetch, }, async () => ({ default: { abi: MODULE_UI_ABI_VERSION, id: "example", mount(element: Element, context: ModuleUiHostContext) { element.textContent = "example mounted"; const subscription = context.realtime.subscribe("state", {}); return () => { unmountCalls += 1; subscription.close(); }; }, version: "0.1.0", }, }), ); await act(async () => { (query("host-logout") as HTMLButtonElement).click(); await Promise.resolve(); }); expect(query("host-signing-out")).not.toBeNull(); expect(query("module-container")).toBeNull(); expect(unmountCalls).toBe(1); expect(closeCalls).toBe(1); expect(platform.authenticated()).toBe(true); await act(async () => { releaseSignOut?.(); await signOutPending; await Promise.resolve(); }); expect(platform.authenticated()).toBe(false); expect(query("host-login")).not.toBeNull(); // A new shell resolves the invalidated server session as anonymous too. await act(async () => root.unmount()); root = createRoot(container); await render({ apiBaseUrl, fetch: platform.fetch }); expect(query("host-login")).not.toBeNull(); }); it("ignores a retained module request that resolves after logout unmounts its frame", async () => { let authenticatedPrincipalRequests = 0; let releaseModuleRequest: (() => void) | undefined; const moduleRequestPending = new Promise((resolve) => { releaseModuleRequest = resolve; }); let releaseSignOut: (() => void) | undefined; const signOutPending = new Promise((resolve) => { releaseSignOut = resolve; }); const platform = statefulAuthPlatform({ initiallyAuthenticated: true, moduleStatus: () => 401, onModuleRequest: () => moduleRequestPending, onPrincipalRequest: (authenticated) => { if (!authenticated) { return Promise.resolve(); } authenticatedPrincipalRequests += 1; return authenticatedPrincipalRequests === 1 ? Promise.resolve() : Promise.reject(new Error("stale revalidation must never run")); }, onSignOut: () => signOutPending, }); const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 }; await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture)); const lateRequest = capture.context?.api.request("retained-after-unmount"); if (!lateRequest) { throw new Error("module context was not captured"); } await act(async () => { (query("host-logout") as HTMLButtonElement).click(); await Promise.resolve(); }); expect(query("host-signing-out")).not.toBeNull(); expect(capture.unmountCalls).toBe(1); expect(platform.authenticated()).toBe(true); await act(async () => { releaseModuleRequest?.(); await lateRequest; await Promise.resolve(); }); // The 401 belongs to a request retained by an already-unmounted module. It must not abort or // replace the newer sign-out transition while the server still owns an authenticated session. expect(query("host-signing-out")).not.toBeNull(); expect(query("host-login")).toBeNull(); expect(platform.authenticated()).toBe(true); expect(authenticatedPrincipalRequests).toBe(1); await act(async () => { releaseSignOut?.(); await signOutPending; await Promise.resolve(); }); expect(platform.authenticated()).toBe(false); expect(query("host-login")).not.toBeNull(); }); it("does not claim logout succeeded when session invalidation fails", async () => { const platform = statefulAuthPlatform({ initiallyAuthenticated: true, onSignOut: () => Promise.reject(new Error("private session failure")), }); await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0")); await act(async () => { (query("host-logout") as HTMLButtonElement).click(); await Promise.resolve(); }); expect(query("host-sign-out-error")).not.toBeNull(); expect(query("host-login")).toBeNull(); expect(query("module-container")).toBeNull(); expect(text()).not.toContain("private session failure"); }); function sessionAwareArtifact( capture: { context?: ModuleUiHostContext; unmountCalls: number }, options: { readonly subscribeToRealtime?: boolean } = {}, ) { return { default: { abi: MODULE_UI_ABI_VERSION, id: "example", mount(element: Element, context: ModuleUiHostContext) { capture.context = context; element.textContent = "example mounted"; const subscription = options.subscribeToRealtime ? context.realtime.subscribe("state", {}) : undefined; return () => { capture.unmountCalls += 1; subscription?.close(); element.textContent = ""; }; }, version: "0.1.0", }, }; } it("returns to login when a module API 401 recheck proves the session became anonymous", async () => { const platform = statefulAuthPlatform({ initiallyAuthenticated: true, moduleStatus: () => 401, }); let closeCalls = 0; const source = { CLOSED: 2, CONNECTING: 0, OPEN: 1, addEventListener: vi.fn(), close: () => { closeCalls += 1; }, readyState: 0, removeEventListener: vi.fn(), } as unknown as EventSource; const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 }; await render( { apiBaseUrl, eventSourceFactory: () => source, fetch: platform.fetch, }, async () => sessionAwareArtifact(capture, { subscribeToRealtime: true }), ); platform.setAuthenticated(false); const principalCallsBefore = platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"), ).length; await act(async () => { await capture.context?.api.request("expired"); }); expect(query("host-login")).not.toBeNull(); expect(query("module-container")).toBeNull(); expect(capture.unmountCalls).toBe(1); expect(closeCalls).toBe(1); expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength( principalCallsBefore + 1, ); }); it("preserves an authenticated session after an ordinary module API 401", async () => { const platform = statefulAuthPlatform({ initiallyAuthenticated: true, moduleStatus: () => 401, }); const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 }; await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture)); await act(async () => { const response = await capture.context?.api.request("module-authentication-required"); expect(response?.status).toBe(401); await Promise.resolve(); }); expect(query("host-login")).toBeNull(); expect(isolatedText(query("module-container"))).toBe("example mounted"); expect(capture.unmountCalls).toBe(0); expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2); }); it("rechecks but does not log out an authenticated principal after an ordinary 403", async () => { const platform = statefulAuthPlatform({ initiallyAuthenticated: true, moduleStatus: () => 403, }); const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 }; await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture)); await act(async () => { const response = await capture.context?.api.request("forbidden"); expect(response?.status).toBe(403); await Promise.resolve(); }); expect(query("host-login")).toBeNull(); expect(isolatedText(query("module-container"))).toBe("example mounted"); expect(capture.unmountCalls).toBe(0); expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2); }); it("returns to login when a 403 recheck proves the session became anonymous", async () => { const platform = statefulAuthPlatform({ initiallyAuthenticated: true, moduleStatus: () => 403, }); const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 }; await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture)); platform.setAuthenticated(false); await act(async () => { await capture.context?.api.request("forbidden-after-expiry"); await Promise.resolve(); }); expect(query("host-login")).not.toBeNull(); expect(query("module-container")).toBeNull(); expect(capture.unmountCalls).toBe(1); }); }); 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("nav 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(isolatedText(query("module-container"))).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("keeps fixed shell columns while module content changes width", async () => { await render({ apiBaseUrl, fetch: platformFetch() }, async () => artifact("example", "0.1.0"), ); const shell = query("host-shell") as HTMLElement; const aside = shell.querySelector("aside") as HTMLElement; const main = shell.querySelector("main") as HTMLElement; const frame = query("module-container") as HTMLElement; expect(shell.style.gridTemplateColumns).toBe("13rem minmax(0, 1fr)"); expect(aside.style.width).toBe("13rem"); expect(main.style.minWidth).toBe("0"); expect(main.style.overflowX).toBe("auto"); expect(frame.style.minWidth).toBe("0"); }); 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(isolatedText(query("module-container"))).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. // The artifact receives the loader's isolated root, never the shell's own element (ADR 0040). expect(mountedInto[0]).toBe(isolatedMountTarget(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(isolatedText(query("module-container"))).toBe(""); await act(async () => { release?.(); await pending; }); expect(query("module-loading")).toBeNull(); expect(isolatedText(query("module-container"))).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: { abi: MODULE_UI_ABI_VERSION, 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(isolatedText(query("module-container"))).toBe("example mounted"); await act(async () => { (query("select-calendar") as HTMLButtonElement).click(); }); expect(unmountedContainers).toHaveLength(1); expect(isolatedText(query("module-container"))).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: { abi: MODULE_UI_ABI_VERSION, 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"); const liveMountTarget = isolatedMountTarget(liveContainer); expect(isolatedText(liveContainer)).toBe("calendar mounted"); // Only now does the stale `example` import resolve. await act(async () => { releaseExample?.(); await examplePending; }); // It mounted into its own, already-discarded isolated root — never the live one — and was torn // down immediately, so the newer selection is untouched. expect(isolatedText(liveContainer)).toBe("calendar mounted"); const staleContainer = mountedInto.find((element) => element !== liveMountTarget); 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(isolatedText(query("module-container"))).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(isolatedText(query("module-container"))).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: { abi: MODULE_UI_ABI_VERSION, 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(isolatedText(query("module-container"))).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(isolatedText(query("module-container"))).toBe("calendar mounted"); }); });