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 { createMemoryHostHistory, type MemoryHostHistory } from "../src/browser/host-history.js"; import { createMemoryHostViewport, type MemoryHostViewport } from "../src/browser/host-viewport.js"; import { createLocalSidebarPreference, createMemorySidebarPreference, type SidebarPreference, } from "../src/browser/sidebar-preference.js"; import type { EkklesiaHostConfig } from "../src/host-protocol.js"; import { isolatedText } from "./support/isolated-dom.js"; declare global { // eslint-disable-next-line no-var var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; } /** * The Dashboard shell's navigation (ADR 0058): the collapsible desktop sidebar, its account area, * and the phone drawer. Every test drives the real `HostApp` with in-memory history, viewport, and * sidebar preference, so routing, resizing, and persistence are all observable. */ 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(); window.localStorage.clear(); }); function entry(id: string, name: string, version = "0.1.0") { 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}/${version}` }, version, }, name, navigation: { icon: null, pages: [] }, }; } // What the server's navigation returns: the eligible modules only. A disabled module (`retired`) is // simply absent from it (ADR 0028), which is the whole of "hidden from navigation". const NAVIGATION = { groups: [{ id: "media", label: "Media", modules: [entry("signage", "Signage")], order: 10 }], ungrouped: [entry("calendar", "Calendar", "2.0.0")], }; interface Platform { readonly fetch: typeof fetch; readonly calls: string[]; } function platform(): Platform { let authenticated = true; const calls: string[] = []; const implementation = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); calls.push(url); if (url.endsWith("/api/auth/sign-out")) { authenticated = false; return Response.json({ success: true }); } if (url.endsWith("/api/v1/principal")) { return Response.json( authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" }, ); } if (url.endsWith("/api/v1/dashboard/navigation")) { return authenticated ? Response.json(NAVIGATION) : Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); } if (url.endsWith("/api/v1/devices")) { return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); } return Response.json({ code: "NOT_FOUND" }, { status: 404 }); }) as unknown as typeof fetch; return { calls, fetch: implementation }; } function artifactFor(url: string) { const id = url.includes("/calendar/") ? "calendar" : "signage"; return { default: { abi: MODULE_UI_ABI_VERSION, id, mount(element: Element, context: ModuleUiHostContext) { element.textContent = `${context.moduleId} mounted`; return () => { element.textContent = ""; }; }, version: id === "calendar" ? "2.0.0" : "0.1.0", }, }; } interface Rendered { readonly history: MemoryHostHistory; readonly viewport: MemoryHostViewport; readonly preference: SidebarPreference; readonly platform: Platform; } async function render( options: { readonly path?: string; readonly compact?: boolean; readonly preference?: SidebarPreference; readonly history?: MemoryHostHistory; readonly viewport?: MemoryHostViewport; } = {}, ): Promise { const history = options.history ?? createMemoryHostHistory(options.path ?? "/"); const viewport = options.viewport ?? createMemoryHostViewport(options.compact ?? false); const preference = options.preference ?? createMemorySidebarPreference(); const stub = platform(); const config: EkklesiaHostConfig = { apiBaseUrl, fetch: stub.fetch }; await act(async () => { root.render( artifactFor(url)} sidebarPreference={preference} viewport={viewport} />, ); }); return { history, platform: stub, preference, viewport }; } async function remount(rendered: Rendered, preference = rendered.preference): Promise { await act(async () => { root.unmount(); }); root = createRoot(container); await render({ history: rendered.history, preference, viewport: rendered.viewport }); } 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 { const event = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init }); await act(async () => { element.dispatchEvent(event); }); return event; } async function key(element: Element, keyName: string, init: KeyboardEventInit = {}) { const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: keyName, ...init, }); await act(async () => { element.dispatchEvent(event); }); return event; } async function focus(element: HTMLElement): Promise { await act(async () => { element.focus(); }); } const sidebar = (): HTMLElement => required("host-sidebar"); const navLink = (id: string): HTMLElement => required(`select-${id}`); describe("desktop sidebar", () => { it("is expanded by default: icons and labels, Overview first, then the modules in the server's order", async () => { await render(); expect(sidebar().dataset.sidebar).toBe("expanded"); const links = [...sidebar().querySelectorAll("nav a")]; expect(links.map((link) => link.textContent)).toEqual(["Overview", "Signage", "Calendar"]); for (const link of links) { const icon = link.querySelector("svg"); expect(icon?.getAttribute("aria-hidden"), link.textContent ?? "").toBe("true"); } expect(query("nav-tooltip")).toBeNull(); }); it("draws the Overview with its own icon and every module with the one generic module icon", async () => { await render(); expect(navLink("overview").querySelector("svg")?.dataset.icon).toBe("overview"); for (const id of ["signage", "calendar"]) { expect(navLink(id).querySelector("svg")?.dataset.icon, id).toBe("module"); expect(navLink(id).querySelector("svg")?.classList.contains("lucide-blocks"), id).toBe(true); } }); it("marks the open page, the Overview or a module, with aria-current", async () => { await render({ path: "/calendar" }); expect(navLink("calendar").getAttribute("aria-current")).toBe("page"); expect(navLink("overview").getAttribute("aria-current")).toBeNull(); await click(navLink("overview")); expect(navLink("overview").getAttribute("aria-current")).toBe("page"); expect(navLink("calendar").getAttribute("aria-current")).toBeNull(); }); it("does not list a disabled module, whose address still says it is unavailable", async () => { await render({ path: "/retired" }); expect(query("select-retired")).toBeNull(); expect(query("host-module-unavailable")).not.toBeNull(); expect(container.querySelector('nav [aria-current="page"]')).toBeNull(); }); it("collapses to icons only, through a labelled toggle that says what it controls", async () => { const { preference } = await render(); const toggle = required("sidebar-toggle"); const nav = sidebar().querySelector("nav"); expect(toggle.tagName).toBe("BUTTON"); expect(toggle.getAttribute("aria-label")).toBe("Collapse navigation"); expect(toggle.getAttribute("aria-expanded")).toBe("true"); expect(toggle.getAttribute("aria-controls")).toBe(nav?.id); expect(nav?.id).toBeTruthy(); await click(toggle); expect(sidebar().dataset.sidebar).toBe("collapsed"); expect(toggle.getAttribute("aria-label")).toBe("Expand navigation"); expect(toggle.getAttribute("aria-expanded")).toBe("false"); expect(preference.read()).toBe("collapsed"); // Icon-only to the eye, still named to assistive technology. for (const id of ["overview", "signage", "calendar"]) { const label = navLink(id).querySelector("[data-nav-label]"); expect(label?.classList.contains("ek-shell-visually-hidden"), id).toBe(true); expect(navLink(id).textContent, id).not.toBe(""); } await click(toggle); expect(sidebar().dataset.sidebar).toBe("expanded"); expect(preference.read()).toBe("expanded"); }); it("keeps the chosen mode across a remount, as across a reload", async () => { const rendered = await render(); await click(required("sidebar-toggle")); await remount(rendered); expect(sidebar().dataset.sidebar).toBe("collapsed"); }); it("keeps it in this browser's own storage, so a reload with a fresh page still finds it", async () => { const rendered = await render({ preference: createLocalSidebarPreference(window.localStorage), }); await click(required("sidebar-toggle")); await remount(rendered, createLocalSidebarPreference(window.localStorage)); expect(sidebar().dataset.sidebar).toBe("collapsed"); }); it("names a collapsed item in a tooltip on keyboard focus and on hover, and Escape dismisses it", async () => { await render({ preference: createMemorySidebarPreference("collapsed") }); await focus(navLink("signage")); const tooltip = required("nav-tooltip"); expect(tooltip.textContent).toBe("Signage"); // A visual duplicate of the link's own accessible name, so it is not announced twice. expect(tooltip.getAttribute("aria-hidden")).toBe("true"); await key(navLink("signage"), "Escape"); expect(query("nav-tooltip")).toBeNull(); // Escape hides the tooltip without moving focus. expect(document.activeElement).toBe(navLink("signage")); await act(async () => { navLink("signage").blur(); }); await act(async () => { navLink("calendar").dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); }); expect(required("nav-tooltip").textContent).toBe("Calendar"); }); it("shows no tooltip while expanded, where the label is already visible", async () => { await render(); await focus(navLink("signage")); expect(query("nav-tooltip")).toBeNull(); }); it("names the collapse toggle and the account control in tooltips when collapsed", async () => { await render({ preference: createMemorySidebarPreference("collapsed") }); await focus(required("sidebar-toggle")); expect(required("nav-tooltip").textContent).toBe("Expand navigation"); await focus(required("host-account-menu")); expect(required("nav-tooltip").textContent).toBe("Your account"); }); it("is the only navigation: no application top bar", async () => { await render(); expect(container.querySelector('[data-testid="host-shell"] > header')).toBeNull(); expect(query("mobile-navigation-trigger")).toBeNull(); // The neutral identity placeholder sits at the top of the sidebar, and is not a heading: each // page's own title is. const brand = required("shell-brand"); expect(sidebar().contains(brand)).toBe(true); expect(brand.querySelector("h1, h2")).toBeNull(); }); it("orders keyboard focus top to bottom: toggle, Overview, modules, then the account control", async () => { await render(); const focusable = [ ...sidebar().querySelectorAll("a[href], button:not([disabled])"), ].map((element) => element.dataset.testid); expect(focusable).toEqual([ "sidebar-toggle", "select-overview", "select-signage", "select-calendar", "host-account-menu", ]); }); it("gives every navigation item a pointer and touch target of at least the medium control height", async () => { await render(); for (const id of ["overview", "signage", "calendar"]) { expect(navLink(id).style.minHeight, id).toBe("var(--ekklesia-control-height-md)"); } }); }); describe("the account area", () => { it("is anchored at the bottom of the sidebar as a product-owned menu button", async () => { await render(); const trigger = required("host-account-menu"); expect(sidebar().lastElementChild?.contains(trigger)).toBe(true); expect(trigger.tagName).toBe("BUTTON"); expect(trigger.getAttribute("aria-haspopup")).toBe("menu"); expect(trigger.getAttribute("aria-expanded")).toBe("false"); expect(trigger.textContent).toContain("Your account"); // This harness serves no account, so the control names the area; the opaque user id is never // shown either way. With an account it names the person (account-appearance.test.tsx). expect(container.textContent).not.toContain("user-1"); expect(query("host-account-menu-list")).toBeNull(); }); it("opens on activation with focus on its first item, and offers only destinations that work", async () => { await render(); const trigger = required("host-account-menu"); await click(trigger); const menu = required("host-account-menu-list"); expect(menu.getAttribute("role")).toBe("menu"); expect(trigger.getAttribute("aria-expanded")).toBe("true"); expect(trigger.getAttribute("aria-controls")).toBe(menu.id); const items = [...menu.querySelectorAll('[role="menuitem"]')]; expect(items.map((item) => item.textContent)).toEqual(["Account", "Appearance", "Sign out"]); expect(document.activeElement).toBe(items[0]); // Account and Appearance are real links to real pages (ADR 0062); Sign out is an action. expect([...menu.querySelectorAll("a")].map((link) => link.getAttribute("href"))).toEqual([ "/account", "/account/appearance", ]); expect(items[2]?.tagName).toBe("BUTTON"); }); it("opens from the keyboard with ArrowDown, and Escape closes it and returns focus", async () => { await render(); const trigger = required("host-account-menu"); await focus(trigger); await key(trigger, "ArrowDown"); expect(document.activeElement).toBe(required("host-account-link")); await key(required("host-account-link"), "ArrowUp"); expect(document.activeElement).toBe(required("host-logout")); await key(required("host-logout"), "Escape"); expect(query("host-account-menu-list")).toBeNull(); expect(document.activeElement).toBe(trigger); }); it("closes when focus or a pointer leaves it, without signing anyone out", async () => { const { platform: stub } = await render(); await click(required("host-account-menu")); await act(async () => { document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true })); }); expect(query("host-account-menu-list")).toBeNull(); await click(required("host-account-menu")); await key(required("host-logout"), "Tab"); expect(query("host-account-menu-list")).toBeNull(); expect(stub.calls.some((url) => url.endsWith("/api/auth/sign-out"))).toBe(false); }); it("signs out from the menu, back to the sign-in form", async () => { const { platform: stub } = await render({ path: "/calendar" }); await click(required("host-account-menu")); await click(required("host-logout")); expect(query("host-login")).not.toBeNull(); expect(query("host-sidebar")).toBeNull(); expect(stub.calls.some((url) => url.endsWith("/api/auth/sign-out"))).toBe(true); }); it("never uses a browser-native dialog", async () => { const alert = vi.fn(); const confirm = vi.fn(() => true); const prompt = vi.fn(() => null); vi.stubGlobal("alert", alert); vi.stubGlobal("confirm", confirm); vi.stubGlobal("prompt", prompt); try { await render({ compact: true }); await click(required("mobile-navigation-trigger")); await key(navLink("overview"), "Escape"); await click(required("mobile-navigation-trigger")); await click(required("host-account-menu")); await click(required("host-logout")); expect(alert).not.toHaveBeenCalled(); expect(confirm).not.toHaveBeenCalled(); expect(prompt).not.toHaveBeenCalled(); } finally { vi.unstubAllGlobals(); } }); }); describe("phone navigation drawer", () => { it("replaces the sidebar with one labelled menu trigger, and renders no navigation until opened", async () => { await render({ compact: true }); expect(query("host-sidebar")).toBeNull(); const trigger = required("mobile-navigation-trigger"); expect(trigger.tagName).toBe("BUTTON"); expect(trigger.getAttribute("aria-label")).toBe("Open navigation"); expect(trigger.getAttribute("aria-expanded")).toBe("false"); expect(trigger.getAttribute("aria-controls")).toBe("ekklesia-navigation-drawer"); expect(query("navigation-drawer")).toBeNull(); expect(query("host-overview")).not.toBeNull(); }); it("opens as a modal dialog with the same navigation, focus inside, and the page inert", async () => { await render({ compact: true, path: "/calendar" }); const trigger = required("mobile-navigation-trigger"); await click(trigger); const drawer = required("navigation-drawer"); expect(drawer.id).toBe("ekklesia-navigation-drawer"); expect(drawer.getAttribute("role")).toBe("dialog"); expect(drawer.getAttribute("aria-modal")).toBe("true"); expect(drawer.getAttribute("aria-label")).toBe("Navigation"); expect(trigger.getAttribute("aria-expanded")).toBe("true"); expect(document.activeElement).toBe(required("navigation-drawer-close")); expect([...drawer.querySelectorAll("nav a")].map((link) => link.textContent)).toEqual([ "Overview", "Signage", "Calendar", ]); // The same hierarchy and the same current page as on desktop, always with labels. expect(navLink("calendar").getAttribute("aria-current")).toBe("page"); expect(drawer.querySelector(".ek-shell-visually-hidden[data-nav-label]")).toBeNull(); expect(required("host-main-region").hasAttribute("inert")).toBe(true); expect(drawer.contains(required("host-account-menu"))).toBe(true); }); it("closes on Escape and returns focus to the trigger", async () => { await render({ compact: true }); const trigger = required("mobile-navigation-trigger"); await click(trigger); await key(navLink("signage"), "Escape"); expect(query("navigation-drawer")).toBeNull(); expect(document.activeElement).toBe(trigger); expect(trigger.getAttribute("aria-expanded")).toBe("false"); expect(required("host-main-region").hasAttribute("inert")).toBe(false); }); it("closes from its close button and from the backdrop, returning focus each time", async () => { await render({ compact: true }); const trigger = required("mobile-navigation-trigger"); await click(trigger); await click(required("navigation-drawer-close")); expect(query("navigation-drawer")).toBeNull(); expect(document.activeElement).toBe(trigger); await click(trigger); await click(required("navigation-drawer-backdrop")); expect(query("navigation-drawer")).toBeNull(); expect(document.activeElement).toBe(trigger); }); it("traps Tab and Shift+Tab inside itself while open", async () => { await render({ compact: true }); await click(required("mobile-navigation-trigger")); const drawer = required("navigation-drawer"); const focusable = [...drawer.querySelectorAll("a[href], button:not([disabled])")]; const first = focusable[0] as HTMLElement; const last = focusable.at(-1) as HTMLElement; expect(first).toBe(required("navigation-drawer-close")); expect(last).toBe(required("host-account-menu")); await focus(last); const forward = await key(last, "Tab"); expect(forward.defaultPrevented).toBe(true); expect(document.activeElement).toBe(first); const backward = await key(first, "Tab", { shiftKey: true }); expect(backward.defaultPrevented).toBe(true); expect(document.activeElement).toBe(last); // Tab between two inner items is left to the browser. await focus(navLink("overview")); expect((await key(navLink("overview"), "Tab")).defaultPrevented).toBe(false); }); it("closes when a destination is chosen, opens that page, and puts focus on the page", async () => { const { history } = await render({ compact: true }); await click(required("mobile-navigation-trigger")); await click(navLink("signage")); expect(query("navigation-drawer")).toBeNull(); expect(history.entries).toEqual(["/", "/signage"]); expect(isolatedText(query("module-container"))).toBe("signage mounted"); expect(document.activeElement).toBe(required("host-main-region")); }); it("closes when the page already open is chosen, and still moves focus to the page", async () => { const { history } = await render({ compact: true, path: "/calendar" }); await click(required("mobile-navigation-trigger")); await click(navLink("calendar")); expect(query("navigation-drawer")).toBeNull(); expect(history.entries).toEqual(["/calendar"]); expect(document.activeElement).toBe(required("host-main-region")); }); it("closes on Back and Forward, which keep working with the drawer", async () => { const { history } = await render({ compact: true }); await click(required("mobile-navigation-trigger")); await click(navLink("calendar")); await click(required("mobile-navigation-trigger")); await act(async () => { history.go(-1); }); expect(query("navigation-drawer")).toBeNull(); expect(query("host-overview")).not.toBeNull(); await act(async () => { history.go(1); }); expect(isolatedText(query("module-container"))).toBe("calendar mounted"); }); it("signs out from the account menu inside the drawer", async () => { await render({ compact: true }); await click(required("mobile-navigation-trigger")); await click(required("host-account-menu")); await click(required("host-logout")); expect(query("host-login")).not.toBeNull(); }); it("gives drawer items a touch target of at least the large control height", async () => { await render({ compact: true }); await click(required("mobile-navigation-trigger")); for (const id of ["overview", "signage", "calendar"]) { expect(navLink(id).style.minHeight, id).toBe("var(--ekklesia-control-height-lg)"); } expect(required("mobile-navigation-trigger").style.minHeight).toBe( "var(--ekklesia-control-height-lg)", ); }); }); describe("moving between phone and desktop layouts", () => { it("keeps the open page and the desktop mode, and drops an open drawer cleanly", async () => { const { viewport, history } = await render({ compact: true, path: "/signage", preference: createMemorySidebarPreference("collapsed"), }); await click(required("mobile-navigation-trigger")); await focus(navLink("calendar")); await act(async () => { viewport.setCompact(false); }); expect(query("navigation-drawer")).toBeNull(); expect(sidebar().dataset.sidebar).toBe("collapsed"); expect(navLink("signage").getAttribute("aria-current")).toBe("page"); expect(history.entries).toEqual(["/signage"]); expect(isolatedText(query("module-container"))).toBe("signage mounted"); expect(required("host-main-region").hasAttribute("inert")).toBe(false); // Focus was inside the drawer that went away; it lands on the page rather than nowhere. expect(document.activeElement).toBe(required("host-main-region")); await act(async () => { viewport.setCompact(true); }); expect(query("host-sidebar")).toBeNull(); expect(query("navigation-drawer")).toBeNull(); expect(required("mobile-navigation-trigger").getAttribute("aria-expanded")).toBe("false"); }); it("does not remount the open module when the layout changes", async () => { const mounts: string[] = []; const viewport = createMemoryHostViewport(false); const history = createMemoryHostHistory("/signage"); const stub = platform(); await act(async () => { root.render( { mounts.push(url); return artifactFor(url); }} sidebarPreference={createMemorySidebarPreference()} viewport={viewport} />, ); }); await act(async () => { viewport.setCompact(true); }); await act(async () => { viewport.setCompact(false); }); expect(mounts).toHaveLength(1); expect(isolatedText(query("module-container"))).toBe("signage mounted"); }); });