import { MODULE_UI_ABI_VERSION, type ModuleUiHostContext, type ModuleUiNavigation, ModuleUiNavigationError, } from "@ekklesia/module-ui-sdk"; import * as lucide from "lucide-react"; 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 { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js"; import { createMemoryHostViewport } from "../src/browser/host-viewport.js"; import { MODULE_ICON_COMPONENTS } from "../src/browser/module-icon.js"; import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js"; import type { DashboardSettings } from "../src/host-protocol.js"; import { DASHBOARD_MODULE_ICON_NAMES } from "../src/module-icons.js"; declare global { // eslint-disable-next-line no-var var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; } /** * Module navigation (manifest v9, ADR 0063): a module's own icon and primary pages in the global * sidebar, its real nested routes, the navigation capability a mounted module is handed through the * real mounting boundary, and module → Settings → back to exactly where it was — beside a * manifest v8 module that keeps behaving as it always has. */ 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 entry( id: string, name: string, navigation: { icon: string | null; pages: { id: string; label: string; path: string }[] } = { icon: null, pages: [], }, ) { return { module: { api: { baseUrl: `/api/v1/modules/${id}` }, contributions: [], display: null, id, permissions: [], realtime: { baseUrl: `/api/v1/module-realtime/${id}` }, ui: { url: `/api/v1/module-ui/${id}/0.1.0` }, version: "0.1.0", }, name, navigation, }; } const SIGNAGE_PAGES = [ { id: "playback", label: "Playback", path: "playback" }, { id: "timelines", label: "Timelines", path: "timelines" }, { id: "media", label: "Media", path: "media" }, ]; /** A manifest v9 module with an icon and three pages, and a manifest v8 module with neither. */ const NAVIGATION = { groups: [], ungrouped: [ entry("signage", "Signage", { icon: "monitor-play", pages: SIGNAGE_PAGES }), entry("calendar", "Calendar"), ], }; /** Signage declares configuration and the viewer may change it; Calendar has no settings; Archive * is installed but turned off, so it is in Settings but not in the navigation. */ const SETTINGS: DashboardSettings = { modules: [ { available: false, id: "archive", name: "Archive" }, { available: true, id: "signage", name: "Signage" }, ], sections: [], }; interface Mounts { imported: string[]; mounted: string[]; unmounted: string[]; contexts: Map; } async function render( path: string, options: { readonly history?: MemoryHostHistory; readonly compact?: boolean } = {}, ) { const history = options.history ?? createMemoryHostHistory(path); const titles: string[] = []; const mounts: Mounts = { contexts: new Map(), imported: [], mounted: [], unmounted: [] }; const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/api/v1/principal")) { return Response.json({ kind: "authenticated", userId: "user-1" }); } if (url.endsWith("/api/v1/dashboard/navigation")) { return Response.json(NAVIGATION); } if (url.endsWith("/api/v1/dashboard/settings")) { return Response.json(SETTINGS); } return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); }) as unknown as typeof fetch; await act(async () => { root.render( { const moduleId = /module-ui\/([a-z-]+)\//u.exec(url)?.[1] ?? "unknown"; mounts.imported.push(moduleId); return { default: { abi: MODULE_UI_ABI_VERSION, id: moduleId, mount(element: Element, context: ModuleUiHostContext) { mounts.contexts.set(moduleId, context); const show = (): void => { element.textContent = `${moduleId}:${context.navigation?.location().path ?? "-"}`; }; show(); const unsubscribe = context.navigation?.subscribe(show); mounts.mounted.push(moduleId); return () => { unsubscribe?.(); mounts.unmounted.push(moduleId); }; }, version: "0.1.0", }, }; }} setDocumentTitle={(title) => titles.push(title)} sidebarPreference={createMemorySidebarPreference()} viewport={createMemoryHostViewport(options.compact ?? false)} />, ); }); return { history, mounts, titles }; } function query(testId: string): HTMLElement | null { return container.querySelector(`[data-testid="${testId}"]`); } function required(testId: string): HTMLElement { const element = query(testId); if (!element) { throw new Error(`missing ${testId}`); } return element; } async function click(element: Element, init: MouseEventInit = {}): Promise { await act(async () => { element.dispatchEvent( new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init }), ); }); } /** What the mounted module rendered, read through its isolated (Shadow DOM) root. */ function moduleText(): string { const host = container.querySelector("[data-ekklesia-module-ui]"); return host?.shadowRoot?.querySelector("[data-ekklesia-module-ui-root]")?.textContent ?? ""; } const current = () => [...container.querySelectorAll('nav [aria-current="page"]')].map( (element) => (element as HTMLElement).dataset.testid, ); function navigationOf(mounts: Mounts, moduleId = "signage"): ModuleUiNavigation { const navigation = mounts.contexts.get(moduleId)?.navigation; if (!navigation) { throw new Error(`no navigation for ${moduleId}`); } return navigation; } describe("a manifest v9 module's routes", () => { it("opens the module root as its landing view, with its pages beneath it in the sidebar", async () => { const { mounts, titles } = await render("/signage"); expect(mounts.mounted).toEqual(["signage"]); expect(moduleText()).toBe("signage:"); expect(titles.at(-1)).toBe("Signage"); expect(current()).toEqual(["select-signage"]); const pages = [...required("module-subnavigation-signage").querySelectorAll("a")]; expect(pages.map((link) => link.textContent)).toEqual(["Playback", "Timelines", "Media"]); expect(pages.map((link) => link.getAttribute("href"))).toEqual([ "/signage/playback", "/signage/timelines", "/signage/media", ]); expect(required("module-subnavigation-signage").getAttribute("aria-label")).toBe("Signage"); }); it("opens a declared page directly from its deep link, as a refresh or a bookmark does", async () => { const { mounts, titles } = await render("/signage/timelines"); expect(mounts.mounted).toEqual(["signage"]); expect(navigationOf(mounts).location()).toEqual({ page: "timelines", path: "timelines" }); expect(moduleText()).toBe("signage:timelines"); expect(current()).toEqual(["select-signage-timelines"]); expect(titles.at(-1)).toBe("Timelines · Signage"); }); it("opens a route beneath a declared page — a detail view — marking its page", async () => { const { mounts } = await render("/signage/media/sermons/2026-10-11"); expect(navigationOf(mounts).location()).toEqual({ page: "media", path: "media/sermons/2026-10-11", }); expect(current()).toEqual(["select-signage-media"]); }); it("is not found at a path no declared page owns, and never mounts the module for it", async () => { for (const path of ["/signage/nope", "/signage/timeline", "/signage/Timelines"]) { await act(async () => { root.unmount(); }); root = createRoot(container); const { mounts, titles } = await render(path); expect(query("host-not-found"), path).not.toBeNull(); expect(mounts.imported, path).toEqual([]); expect(titles.at(-1), path).toBe("Page not found"); expect(current(), path).toEqual([]); } }); it("moves between its own pages without remounting, and Back and Forward walk them", async () => { const { history, mounts } = await render("/signage"); await click(required("select-signage-timelines")); expect(history.entries).toEqual(["/signage", "/signage/timelines"]); expect(moduleText()).toBe("signage:timelines"); await click(required("select-signage-media")); await act(async () => history.go(-1)); expect(moduleText()).toBe("signage:timelines"); expect(current()).toEqual(["select-signage-timelines"]); await act(async () => history.go(-1)); expect(moduleText()).toBe("signage:"); await act(async () => history.go(2)); expect(moduleText()).toBe("signage:media"); // One import and one mount for the whole walk: the module was never torn down. expect(mounts.imported).toEqual(["signage"]); expect(mounts.mounted).toEqual(["signage"]); expect(mounts.unmounted).toEqual([]); }); it("moves focus to the page when the module moves to another of its pages", async () => { await render("/signage"); const main = required("host-main-region"); (document.activeElement as HTMLElement | null)?.blur(); await click(required("select-signage-playback")); expect(document.activeElement).toBe(main); }); it("keeps the exact page across a refresh", async () => { const history = createMemoryHostHistory("/signage"); await render("/signage", { history }); await click(required("select-signage-media")); await act(async () => { root.unmount(); }); root = createRoot(container); const { mounts } = await render("/signage/media"); expect(navigationOf(mounts).location().path).toBe("media"); expect(current()).toEqual(["select-signage-media"]); }); }); describe("a released manifest v8 module", () => { it("is one page, its root, with the generic icon and no sub-navigation", async () => { const { mounts } = await render("/calendar"); expect(mounts.mounted).toEqual(["calendar"]); expect(moduleText()).toBe("calendar:"); expect(current()).toEqual(["select-calendar"]); expect(query("module-subnavigation-calendar")).toBeNull(); expect(required("select-calendar").querySelector("svg")?.dataset.icon).toBe("module"); }); it("is not found beneath its root, exactly as before", async () => { const { mounts } = await render("/calendar/events/456"); expect(query("host-not-found")).not.toBeNull(); expect(mounts.imported).toEqual([]); }); it("still receives navigation — its root, and no Settings link when it has no settings", async () => { const { mounts } = await render("/calendar"); const navigation = navigationOf(mounts, "calendar"); expect(navigation.location()).toEqual({ page: undefined, path: "" }); expect(navigation.settings).toBeUndefined(); expect(() => navigation.navigate("events")).toThrowError( expect.objectContaining({ code: "MODULE_NAVIGATION_PATH_UNKNOWN" }), ); }); }); describe("modules that are not running", () => { it("says a turned-off module is unavailable at its root and beneath it, mounting nothing", async () => { for (const path of ["/archive", "/archive/timelines", "/archive/a/b"]) { await act(async () => { root.unmount(); }); root = createRoot(container); const { mounts } = await render(path); expect(query("host-module-unavailable"), path).not.toBeNull(); expect(mounts.imported, path).toEqual([]); // A module that is not running is absent from the navigation. expect(query("select-archive"), path).toBeNull(); } }); }); describe("the sidebar", () => { it("draws a module's declared Lucide icon, and the generic icon for one that declares none", async () => { await render("/"); expect(required("select-signage").querySelector("svg")?.dataset.icon).toBe( "module:monitor-play", ); expect(required("select-signage").querySelector("svg")?.getAttribute("aria-hidden")).toBe( "true", ); expect(required("select-calendar").querySelector("svg")?.dataset.icon).toBe("module"); }); it("shows a module's pages only while that module is open", async () => { await render("/"); expect(query("module-subnavigation-signage")).toBeNull(); await click(required("select-signage")); expect(query("module-subnavigation-signage")).not.toBeNull(); await click(required("select-calendar")); expect(query("module-subnavigation-signage")).toBeNull(); }); it("collapsed, hides the pages and marks the module itself, named for assistive technology", async () => { await render("/signage/timelines"); await click(container.querySelector('[aria-label="Collapse navigation"]') as Element); expect(query("module-subnavigation-signage")).toBeNull(); expect(current()).toEqual(["select-signage"]); const label = required("select-signage").querySelector("[data-nav-label]"); expect(label?.textContent).toBe("Signage"); expect(label?.className).toBe("ek-shell-visually-hidden"); }); it("uses the identical hierarchy in the phone drawer, which closes once a page is chosen", async () => { const { history } = await render("/signage", { compact: true }); await click(required("mobile-navigation-trigger")); const drawer = required("navigation-drawer"); const pages = [ ...drawer.querySelectorAll('[data-testid="module-subnavigation-signage"] a'), ] as HTMLElement[]; expect(pages.map((link) => link.textContent)).toEqual(["Playback", "Timelines", "Media"]); for (const link of pages) { expect(link.style.minHeight).toBe("var(--ekklesia-control-height-lg)"); } await click(pages[1] as HTMLElement); expect(history.pathname()).toBe("/signage/timelines"); expect(query("navigation-drawer")).toBeNull(); }); }); describe("the module navigation capability", () => { it("navigates to the module's own routes, adding or replacing a history entry", async () => { const { history, mounts } = await render("/signage"); const navigation = navigationOf(mounts); await act(async () => navigation.navigate("timelines")); expect(history.entries).toEqual(["/signage", "/signage/timelines"]); await act(async () => navigation.navigate("/media/sermons/9")); expect(history.pathname()).toBe("/signage/media/sermons/9"); expect(navigation.location()).toEqual({ page: "media", path: "media/sermons/9" }); await act(async () => navigation.navigate("media", { replace: true })); expect(history.entries).toEqual(["/signage", "/signage/timelines", "/signage/media"]); await act(async () => navigation.navigate("")); expect(navigation.location()).toEqual({ page: undefined, path: "" }); expect(mounts.mounted).toEqual(["signage"]); }); it.each([ ["../calendar", "MODULE_NAVIGATION_PATH_INVALID"], ["timelines/../../calendar", "MODULE_NAVIGATION_PATH_INVALID"], ["//evil.example.test", "MODULE_NAVIGATION_PATH_INVALID"], ["https://evil.example.test/", "MODULE_NAVIGATION_PATH_INVALID"], ["javascript:alert(1)", "MODULE_NAVIGATION_PATH_INVALID"], ["timelines?x=1", "MODULE_NAVIGATION_PATH_INVALID"], ["timelines#top", "MODULE_NAVIGATION_PATH_INVALID"], ["nope", "MODULE_NAVIGATION_PATH_UNKNOWN"], ["timeline", "MODULE_NAVIGATION_PATH_UNKNOWN"], ])("refuses %j — no other module, platform page, or undeclared page", async (path, code) => { const { history, mounts } = await render("/signage"); const navigation = navigationOf(mounts); expect(() => navigation.navigate(path)).toThrowError(expect.objectContaining({ code })); expect(() => navigation.link(path)).toThrowError(ModuleUiNavigationError); expect(history.entries).toEqual(["/signage"]); }); it("names nothing it refused in its error", async () => { const { mounts } = await render("/signage"); const failure = (() => { try { navigationOf(mounts).navigate("../calendar?token=secret"); } catch (error) { return error as Error; } return undefined; })(); expect(failure).toBeInstanceOf(ModuleUiNavigationError); expect(`${failure?.message}${failure?.stack}`).not.toMatch(/calendar|secret/u); }); it("makes real links: in place on an ordinary click, to the browser with a modifier", async () => { const { history, mounts } = await render("/signage"); const link = navigationOf(mounts).link("timelines"); expect(link.href).toBe("/signage/timelines"); const ordinary = { altKey: false, button: 0, ctrlKey: false, defaultPrevented: false, metaKey: false, preventDefault: vi.fn(), shiftKey: false, }; await act(async () => link.onClick(ordinary)); expect(ordinary.preventDefault).toHaveBeenCalledOnce(); expect(history.pathname()).toBe("/signage/timelines"); const newTab = { ...ordinary, metaKey: true, preventDefault: vi.fn() }; await act(async () => navigationOf(mounts).link("media").onClick(newTab)); expect(newTab.preventDefault).not.toHaveBeenCalled(); expect(history.pathname()).toBe("/signage/timelines"); }); it("reports moves made by the host — the sidebar, Back — to the mounted module", async () => { const { history, mounts } = await render("/signage"); const listener = vi.fn(); navigationOf(mounts).subscribe(listener); await click(required("select-signage-playback")); await act(async () => history.go(-1)); expect(listener).toHaveBeenCalledTimes(2); }); }); describe("module → Settings → back to exactly where it was", () => { it("opens the module's Settings page carrying its current page as the place to return to", async () => { const { history, mounts } = await render("/signage/timelines"); const settings = navigationOf(mounts).settings; expect(settings?.link().href).toBe("/settings/signage?return=%2Fsignage%2Ftimelines"); await act(async () => settings?.open()); expect(history.entries).toEqual([ "/signage/timelines", "/settings/signage?return=%2Fsignage%2Ftimelines", ]); expect(query("module-settings")).not.toBeNull(); const back = required("module-settings").querySelector(".ek-page-header__back"); expect(back?.getAttribute("href")).toBe("/signage/timelines"); expect(back?.textContent).toContain("Back to Signage"); await click(back as HTMLAnchorElement); expect(history.pathname()).toBe("/signage/timelines"); expect(current()).toEqual(["select-signage-timelines"]); }); it("returns to where the module is now, even if it never read its location since moving", async () => { const { history, mounts } = await render("/signage"); const settings = navigationOf(mounts).settings; mounts.contexts.get("signage")?.navigation?.subscribe(() => undefined)(); await act(async () => history.push("/signage/media")); expect(settings?.link().href).toBe("/settings/signage?return=%2Fsignage%2Fmedia"); }); it("keeps the place to return to across a refresh of the Settings page", async () => { await render("/settings/signage?return=%2Fsignage%2Fmedia%2Fsermons%2F9"); const back = required("module-settings").querySelector(".ek-page-header__back"); expect(back?.getAttribute("href")).toBe("/signage/media/sermons/9"); expect(back?.textContent).toContain("Back to Signage"); }); it("keeps it when the Settings address is respelled", async () => { const { history } = await render("/settings/signage/?return=%2Fsignage%2Fmedia"); expect(history.entries).toEqual(["/settings/signage?return=%2Fsignage%2Fmedia"]); }); it.each([ ["another origin", "https://evil.example.test/signage"], ["a protocol-relative URL", "//evil.example.test"], ["a script", "javascript:alert(1)"], ["the API", "/api/v1/principal"], ["a page the module does not declare", "/signage/nope"], ["a module that is not running", "/archive"], ["a module that does not exist", "/nowhere"], ["traversal", "/signage/../api"], ["a nested query", "/signage?return=https://evil.example.test"], ])("ignores a return target naming %s, going back to Settings instead", async (_label, target) => { await render(`/settings/signage?return=${encodeURIComponent(target)}`); const back = required("module-settings").querySelector(".ek-page-header__back"); expect(back?.getAttribute("href")).toBe("/settings"); expect(back?.textContent).toContain("Back to Settings"); }); it("offers no Settings link to a module whose settings this viewer cannot open", async () => { const { mounts } = await render("/calendar"); expect(navigationOf(mounts, "calendar").settings).toBeUndefined(); }); }); describe("module icons", () => { it("maps exactly the names the platform accepts, each to a real Lucide component", () => { expect(Object.keys(MODULE_ICON_COMPONENTS).sort()).toEqual( [...DASHBOARD_MODULE_ICON_NAMES].sort(), ); const exports = lucide as unknown as Record; for (const name of DASHBOARD_MODULE_ICON_NAMES) { const pascal = name .split("-") .map((part) => `${part[0]?.toUpperCase()}${part.slice(1)}`) .join(""); expect(MODULE_ICON_COMPONENTS[name], name).toBe(exports[pascal]); } }); it("draws the generic icon for a name this Dashboard cannot draw", async () => { NAVIGATION.ungrouped.push(entry("press", "Press", { icon: "not-yet-an-icon", pages: [] })); try { await render("/"); expect(required("select-press").querySelector("svg")?.dataset.icon).toBe("module"); } finally { NAVIGATION.ungrouped.pop(); } }); });