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 } from "../src/browser/host-viewport.js"; import { createMemorySidebarPreference } from "../src/browser/sidebar-preference.js"; import type { DashboardSettings } from "../src/host-protocol.js"; declare global { // eslint-disable-next-line no-var var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; } /** * The Settings shell (ADR 0061): its landing page, the platform sections and Discover, each * module's settings, and which of them the server said this person may see. */ 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) { 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: { icon: null, pages: [] }, }; } const NAVIGATION = { groups: [], ungrouped: [entry("signage", "Signage"), entry("calendar", "Calendar")], }; /** An administrator who may see everything: every platform section, two modules' settings, and a * disabled module (`archive`) whose settings exist but are not navigable. Calendar declares no * settings, so it is a known module with no settings page. */ const ADMINISTRATOR: DashboardSettings = { modules: [ { available: false, id: "archive", name: "Archive" }, { available: true, id: "signage", name: "Signage" }, ], sections: ["general", "branding", "displays", "discover", "system"], }; interface Rendered { readonly history: MemoryHostHistory; readonly calls: string[]; readonly titles: string[]; } async function render( path: string, options: { readonly settings?: DashboardSettings | "fails"; readonly compact?: boolean; readonly history?: MemoryHostHistory; } = {}, ): Promise { const history = options.history ?? createMemoryHostHistory(path); const calls: string[] = []; const titles: string[] = []; let settingsAttempts = 0; const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); calls.push(new URL(url).pathname); 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")) { settingsAttempts += 1; const settings = options.settings ?? ADMINISTRATOR; return settings === "fails" && settingsAttempts === 1 ? Response.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, { status: 503 }) : Response.json(settings === "fails" ? ADMINISTRATOR : settings); } return Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 }); }) as unknown as typeof fetch; await act(async () => { root.render( ({ default: { abi: MODULE_UI_ABI_VERSION, id: "signage", mount(element: Element, context: ModuleUiHostContext) { element.textContent = `${context.moduleId} mounted`; }, version: "0.1.0", }, })} setDocumentTitle={(title) => titles.push(title)} sidebarPreference={createMemorySidebarPreference()} viewport={createMemoryHostViewport(options.compact ?? false)} />, ); }); return { calls, history, 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): Promise { await act(async () => { element.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, cancelable: true })); }); } const current = () => [...container.querySelectorAll('nav [aria-current="page"]')].map( (element) => (element as HTMLElement).dataset.testid, ); describe("the Settings landing page", () => { it("is a real page, not a redirect, listing what this person may open", async () => { const { history, titles } = await render("/settings"); expect(history.entries).toEqual(["/settings"]); expect(query("settings-home")?.querySelector("h1")?.textContent).toBe("Settings"); expect( [...container.querySelectorAll('[data-testid^="settings-home-"]')].map((link) => link.getAttribute("href"), ), ).toEqual([ "/settings/general", "/settings/branding", "/settings/displays", "/settings/discover/browse", "/settings/system", "/settings/signage", ]); // A disabled module's settings are not offered. expect(query("settings-home-module-archive")).toBeNull(); // Each group is a section of the page, below its one h1. expect( [...container.querySelectorAll("main h2")].map((heading) => heading.textContent), ).toEqual(["Platform", "Modules"]); expect(titles.at(-1)).toBe("Settings"); expect(current()).toEqual(["select-settings"]); }); it("says plainly when there is nothing for this person to change", async () => { await render("/settings", { settings: { modules: [], sections: [] } }); expect(query("settings-home")?.textContent).toContain( "There are no settings for you to change", ); }); }); describe("the platform sections", () => { it("gives each a page of its own, with Back to Settings and an honest placeholder", async () => { for (const section of ["general", "branding", "displays", "system"] as const) { await act(async () => { root.unmount(); }); root = createRoot(container); const { titles } = await render(`/settings/${section}`); const page = required(`settings-section-${section}`); expect(page.querySelector("h1")?.textContent, section).toBeTruthy(); expect(page.textContent, section).toContain("Not available yet"); // No fake controls. expect(page.querySelector("input, select, textarea, form"), section).toBeNull(); const back = page.querySelector(".ek-page-header__back"); expect(back?.getAttribute("href"), section).toBe("/settings"); expect(back?.textContent, section).toContain("Back to Settings"); expect(current(), section).toEqual([`select-settings-${section}`]); expect(titles.at(-1), section).toMatch(/ settings$/u); } }); it("enters Discover at Browse, its one canonical address, without adding a history entry", async () => { const { history, titles } = await render("/settings/discover"); expect(history.entries).toEqual(["/settings/discover/browse"]); expect(query("settings-discover-browse")?.querySelector("h1")?.textContent).toBe( "Discover modules", ); expect(query("settings-discover-browse")?.textContent).toContain( "Modules add capabilities to your church's Dashboard. Install and manage the modules your church uses.", ); expect(titles.at(-1)).toBe("Discover modules"); expect(current()).toEqual(["select-discover-browse"]); expect(query("select-settings-discover")?.getAttribute("href")).toBe( "/settings/discover/browse", ); }); it("has Browse, Installed, and Updates beneath Discover, each speaking of modules", async () => { const { history } = await render("/settings/discover/browse"); const views = [...required("discover-subnavigation").querySelectorAll("a")]; expect(views.map((view) => view.textContent)).toEqual(["Browse", "Installed", "Updates"]); expect(views.map((view) => view.getAttribute("href"))).toEqual([ "/settings/discover/browse", "/settings/discover/installed", "/settings/discover/updates", ]); await click(required("select-discover-installed")); expect(query("settings-discover-installed")?.querySelector("h1")?.textContent).toBe( "Installed modules", ); await click(required("select-discover-updates")); expect(query("settings-discover-updates")?.querySelector("h1")?.textContent).toBe( "Module updates", ); expect(history.entries).toEqual([ "/settings/discover/browse", "/settings/discover/installed", "/settings/discover/updates", ]); }); it("says a section is unavailable to someone who may not see it, rather than showing it", async () => { await render("/settings/discover/installed", { settings: { modules: [], sections: ["displays"] }, }); expect(query("settings-no-access")).not.toBeNull(); expect(query("settings-discover-installed")).toBeNull(); expect(query("select-settings-discover")).toBeNull(); }); }); describe("module settings", () => { it("opens a running module's settings at /settings/, as a placeholder", async () => { const { titles } = await render("/settings/signage"); const page = required("module-settings"); expect(page.querySelector("h1")?.textContent).toBe("Signage"); expect(page.textContent).toContain("Not available yet"); expect(page.querySelector("input, select, textarea, form")).toBeNull(); expect(titles.at(-1)).toBe("Signage settings"); expect(current()).toEqual(["select-module-settings-signage"]); }); it("says a disabled module's settings are turned off, rather than redirecting", async () => { const { history } = await render("/settings/archive"); expect(query("settings-module-unavailable")?.textContent).toContain( "This module is turned off", ); expect(history.entries).toEqual(["/settings/archive"]); expect(query("select-module-settings-archive")).toBeNull(); }); it("says a known module's settings aren't available when there are none for this person", async () => { await render("/settings/calendar"); expect(query("settings-no-access")).not.toBeNull(); }); it("is not found for a name that is no module", async () => { for (const path of ["/settings/definitely-not-a-module", "/settings/modules"]) { await act(async () => { root.unmount(); }); root = createRoot(container); await render(path); expect(query("host-not-found"), path).not.toBeNull(); } }); }); describe("Settings in the navigation", () => { it("is one item after the modules, expanding into Platform and Modules only inside Settings", async () => { const { history } = await render("/signage"); const settingsLink = required("select-settings"); expect(settingsLink.getAttribute("href")).toBe("/settings"); expect(query("settings-subnavigation")).toBeNull(); const links = [...container.querySelectorAll("nav a")].map((link) => link.textContent); expect(links).toEqual(["Overview", "Signage", "Calendar", "Settings"]); await click(settingsLink); expect(history.entries).toEqual(["/signage", "/settings"]); const sub = required("settings-subnavigation"); expect([...sub.querySelectorAll("h3")].map((heading) => heading.textContent)).toEqual([ "Platform", "Modules", ]); expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ "General", "Branding", "Displays", "Discover", "System", "Signage", ]); // The rest of the navigation stays, so leaving Settings is one click. await click(required("select-signage")); expect(query("settings-subnavigation")).toBeNull(); expect(history.entries).toEqual(["/signage", "/settings", "/signage"]); }); it("shows only the sections and module settings this person may see", async () => { await render("/settings", { settings: { modules: [{ available: true, id: "signage", name: "Signage" }], sections: ["displays"], }, }); const sub = required("settings-subnavigation"); expect([...sub.querySelectorAll("a")].map((link) => link.textContent)).toEqual([ "Displays", "Signage", ]); }); it("is not offered at all to someone with nothing to change", async () => { await render("/", { settings: { modules: [], sections: [] } }); expect(query("select-settings")).toBeNull(); }); it("is withheld when Settings cannot be loaded, whose pages say so and retry", async () => { const { calls } = await render("/settings", { settings: "fails" }); expect(query("select-settings")).toBeNull(); expect(query("settings-load-failed")).not.toBeNull(); // The rest of the Dashboard still works. expect(query("select-signage")).not.toBeNull(); const retry = [...required("settings-load-failed").querySelectorAll("button")].find( (button) => button.textContent === "Retry", ) as HTMLButtonElement; await act(async () => { retry.click(); }); expect(query("settings-home")).not.toBeNull(); expect(calls.filter((path) => path === "/api/v1/dashboard/settings")).toHaveLength(2); }); it("keeps the exact Settings page across a refresh, and Back and Forward walk it", async () => { const history = createMemoryHostHistory("/settings"); await render("/settings", { history }); await click(required("select-settings-displays")); await click(required("select-module-settings-signage")); await act(async () => { history.go(-1); }); expect(query("settings-section-displays")).not.toBeNull(); await act(async () => { history.go(1); }); expect(query("module-settings")).not.toBeNull(); // A refresh is the same address, rendered from scratch. await act(async () => { root.unmount(); }); root = createRoot(container); await render("/settings/signage", { history: createMemoryHostHistory(history.pathname()) }); expect(query("module-settings")).not.toBeNull(); expect(current()).toEqual(["select-module-settings-signage"]); }); it("moves focus to the page when moving between two Settings pages", async () => { await render("/settings/general"); const main = required("host-main-region"); await click(required("select-settings-branding")); expect(document.activeElement).toBe(main); expect(main.getAttribute("aria-label")).toBe("Branding settings"); }); it("uses the same hierarchy in the phone drawer, which closes once a page is chosen", async () => { const { history } = await render("/settings", { compact: true }); await click(required("mobile-navigation-trigger")); const drawer = required("navigation-drawer"); expect( [...drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')].map( (link) => link.textContent, ), ).toEqual(["General", "Branding", "Displays", "Discover", "System", "Signage"]); for (const link of drawer.querySelectorAll('[data-testid="settings-subnavigation"] a')) { expect((link as HTMLElement).style.minHeight).toBe("var(--ekklesia-control-height-lg)"); } await click(required("select-settings-system")); expect(query("navigation-drawer")).toBeNull(); expect(history.entries).toEqual(["/settings", "/settings/system"]); expect(query("settings-section-system")).not.toBeNull(); }); });