Files
Ekklesia/surfaces/shell/tests/module-navigation.test.tsx
T
ekklesiaandClaude Opus 5.5 4671622b9d Add the module navigation contract: manifest v9, module routes, return-to-origin
Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard.

- Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated
  MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe
  label, kebab-case path relative to the module root; no traversal, schemes,
  queries, duplicates, or nesting). Validated before activation.
- Version 8 stays accepted beside version 9, each read strictly as itself
  (discriminated union); no released manifest is rewritten. The template
  moves to v9; the released Signage 0.1.5 manifest is a test fixture.
- Real module routes: /<id> is the landing view, /<id>/<page> a declared
  page, anything beneath a page a module-owned detail route; anything else
  is not-found and mounts nothing. api/assets/display are never pages.
- Sidebar: declared icons, pages nested under an open module, one
  aria-current, collapsed and drawer behaviour; v8 modules unchanged.
- module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe,
  navigate, link, settings), refusing anything outside the module's routes;
  undefined on the display host. Mount ABI unchanged.
- Return-to-origin: one `?return=` carrier on Settings URLs, validated by
  parseReturnTarget and against running modules' declared routes; Settings
  Back returns to exactly the originating module page, across refreshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:21:50 -05:00

591 lines
22 KiB
TypeScript

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<typeof vi.spyOn>;
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<string, ModuleUiHostContext>;
}
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(
<HostApp
config={{ apiBaseUrl, fetch: fetchImplementation }}
history={history}
importModule={async (url: string) => {
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<HTMLElement>(`[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<void> {
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<HTMLAnchorElement>(".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<string, unknown>;
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();
}
});
});