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 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's URL is the source of truth for which page is open (ADR 0057). Every test drives * the real shell through an in-memory history, so it can assert on the exact entries a person's * Back and Forward buttons would walk. */ 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, 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, }; } const NAVIGATION = { groups: [{ id: "media", label: "Media", modules: [entry("signage", "Signage")], order: 10 }], ungrouped: [entry("calendar", "Calendar", "2.0.0")], }; function platform(options: { authenticated?: boolean } = {}) { let authenticated = options.authenticated ?? true; const fetchImplementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.endsWith("/api/auth/sign-in/email")) { void init; authenticated = true; return Response.json({ ok: 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 }); } return Response.json({ code: "NOT_FOUND" }, { status: 404 }); }) as unknown as typeof fetch; return fetchImplementation; } 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 imported: string[]; readonly titles: string[]; } async function render( path: string, options: { authenticated?: boolean; history?: MemoryHostHistory } = {}, ): Promise { const history = options.history ?? createMemoryHostHistory(path); const imported: string[] = []; const titles: string[] = []; const config: EkklesiaHostConfig = { apiBaseUrl, fetch: platform(options) }; await act(async () => { root.render( { imported.push(url); return artifactFor(url); }} setDocumentTitle={(title) => titles.push(title)} />, ); }); return { history, imported, titles }; } function query(testId: string): HTMLElement | null { return container.querySelector(`[data-testid="${testId}"]`); } async function click(element: Element | null, init: MouseEventInit = {}): Promise { if (!element) { throw new Error("missing element"); } const event = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true, ...init }); await act(async () => { element.dispatchEvent(event); }); return event; } async function go(history: MemoryHostHistory, delta: number): Promise { await act(async () => { history.go(delta); }); } describe("the Dashboard opens on the page its URL names", () => { it("lands on the Overview at the root, and mounts no module", async () => { const { imported, titles } = await render("/"); expect(query("host-overview")).not.toBeNull(); expect(query("host-overview")?.querySelector("h1")?.textContent).toBe("Overview"); expect(query("module-container")).toBeNull(); expect(imported).toEqual([]); expect(query("select-overview")?.getAttribute("aria-current")).toBe("page"); expect(titles.at(-1)).toBe("Overview"); }); it("opens a module's page directly from its deep link — what a refresh or a bookmark does", async () => { const { imported, titles } = await render("/modules/calendar"); expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]); expect(isolatedText(query("module-container"))).toBe("calendar mounted"); expect(query("select-calendar")?.getAttribute("aria-current")).toBe("page"); expect(query("select-overview")?.getAttribute("aria-current")).toBeNull(); expect(query("host-overview")).toBeNull(); expect(titles.at(-1)).toBe("Calendar"); }); it("opens a module from inside a group exactly as one outside it", async () => { const { imported } = await render("/modules/signage"); expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`]); expect(query("select-signage")?.getAttribute("aria-current")).toBe("page"); }); it("says a module page is unavailable when no navigable module has that id, and loads nothing", async () => { const { imported, titles } = await render("/modules/disabled-module"); expect(query("host-module-unavailable")).not.toBeNull(); expect(query("module-container")).toBeNull(); expect(imported).toEqual([]); // The address is never echoed back into the page. expect(container.textContent).not.toContain("disabled-module"); expect(titles.at(-1)).toBe("This page isn’t available"); expect(query("host-go-to-overview")?.getAttribute("href")).toBe("/"); }); it("shows a host-owned not-found page for an address inside the Dashboard it does not know", async () => { for (const path of ["/settings/displays", "/modules/calendar/events", "/account"]) { await act(async () => { root.unmount(); }); root = createRoot(container); const { imported, titles } = await render(path); expect(query("host-not-found"), path).not.toBeNull(); expect(imported, path).toEqual([]); expect(titles.at(-1), path).toBe("Page not found"); // Nothing in the navigation claims to be the open page. expect(container.querySelector('[aria-current="page"]'), path).toBeNull(); } }); it("corrects a trailing slash to the canonical address in place, adding no history entry", async () => { const { history, imported } = await render("/modules/calendar/"); expect(history.entries).toEqual(["/modules/calendar"]); expect(imported).toHaveLength(1); }); it("opens the deep link it was given once the person signs in, with no redirect", async () => { const history = createMemoryHostHistory("/modules/calendar"); const { imported } = await render("/modules/calendar", { authenticated: false, history }); expect(query("host-login")).not.toBeNull(); const email = query("login-email") as HTMLInputElement; const password = query("login-password") as HTMLInputElement; const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; await act(async () => { setter?.call(email, "owner@example.test"); email.dispatchEvent(new Event("input", { bubbles: true })); setter?.call(password, "correct password"); password.dispatchEvent(new Event("input", { bubbles: true })); }); await act(async () => { query("host-login") ?.querySelector("form") ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); }); expect(isolatedText(query("module-container"))).toBe("calendar mounted"); expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]); expect(history.entries).toEqual(["/modules/calendar"]); }); }); describe("moving between pages", () => { it("renders navigation as real links to canonical addresses", async () => { await render("/"); for (const [testId, href] of [ ["select-overview", "/"], ["select-signage", "/modules/signage"], ["select-calendar", "/modules/calendar"], ] as const) { const link = query(testId); expect(link?.tagName, testId).toBe("A"); expect(link?.getAttribute("href"), testId).toBe(href); } expect(container.querySelector('nav[aria-label="Dashboard"]')).not.toBeNull(); }); it("opens a page in place on an ordinary click, adding one history entry", async () => { const { history, imported } = await render("/"); const event = await click(query("select-calendar")); expect(event.defaultPrevented).toBe(true); expect(history.entries).toEqual(["/", "/modules/calendar"]); expect(imported).toEqual([`${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`]); expect(isolatedText(query("module-container"))).toBe("calendar mounted"); }); it("leaves a modified or middle click to the browser, so a page can open in a new tab", async () => { const { history } = await render("/"); for (const init of [ { ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { altKey: true }, { button: 1 }, ]) { const event = await click(query("select-calendar"), init); expect(event.defaultPrevented, JSON.stringify(init)).toBe(false); } expect(history.entries).toEqual(["/"]); }); it("adds no entry when the open page's own link is clicked again", async () => { const { history } = await render("/modules/calendar"); await click(query("select-calendar")); expect(history.entries).toEqual(["/modules/calendar"]); }); it("walks Back and Forward through exactly the pages that were open", async () => { const { history, imported } = await render("/"); await click(query("select-signage")); await click(query("select-calendar")); await go(history, -1); expect(isolatedText(query("module-container"))).toBe("signage mounted"); expect(query("select-signage")?.getAttribute("aria-current")).toBe("page"); await go(history, -1); expect(query("host-overview")).not.toBeNull(); expect(query("module-container")).toBeNull(); await go(history, 1); expect(isolatedText(query("module-container"))).toBe("signage mounted"); expect(imported).toEqual([ `${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`, `${apiBaseUrl}/api/v1/module-ui/calendar/2.0.0`, `${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`, `${apiBaseUrl}/api/v1/module-ui/signage/0.1.0`, ]); }); it("returns to the Overview from an unavailable page through its own link", async () => { const { history } = await render("/modules/missing"); await click(query("host-go-to-overview")); expect(history.entries).toEqual(["/modules/missing", "/"]); expect(query("host-overview")).not.toBeNull(); }); it("moves focus to the new page's content after a page change, but not on first load", async () => { await render("/"); const main = container.querySelector("main"); expect(main?.getAttribute("tabindex")).toBe("-1"); expect(document.activeElement).not.toBe(main); await click(query("select-calendar")); expect(document.activeElement).toBe(main); expect(main?.getAttribute("aria-label")).toBe("Calendar"); }); it("names the browser tab after each page as it opens", async () => { const { titles } = await render("/"); await click(query("select-signage")); await click(query("select-overview")); expect(titles).toEqual(["Overview", "Signage", "Overview"]); }); });