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]>
591 lines
22 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|