Files
Ekklesia/packages/module-ui-sdk/tests/navigation.test.ts
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

41 lines
1.7 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
type ModuleUiHostContext,
type ModuleUiNavigation,
ModuleUiNavigationError,
} from "../src/index.js";
/** The navigation capability's public surface (ADR 0063). Its behaviour is the host's: the
* Dashboard's implementation is proven in `surfaces/shell/tests/module-navigation.test.tsx`. */
describe("module UI navigation contract", () => {
it("reports a refused path with a stable code and a fixed message that never echoes the path", () => {
for (const code of [
"MODULE_NAVIGATION_PATH_INVALID",
"MODULE_NAVIGATION_PATH_UNKNOWN",
] as const) {
const error = new ModuleUiNavigationError(code);
expect(error).toBeInstanceOf(Error);
expect(error.name).toBe("ModuleUiNavigationError");
expect(error.code).toBe(code);
expect(error.stack).toBe(`ModuleUiNavigationError: ${error.message}`);
expect(error.message).not.toMatch(/[/:?#]/u);
}
});
it("makes navigation a context member a host without pages leaves undefined", () => {
// A type-level contract: a context may say it has no navigation, as the display host does, and
// a module reads it through an optional check.
const describe = (context: Pick<ModuleUiHostContext, "navigation">): string =>
context.navigation?.location().path ?? "no navigation";
const navigation: ModuleUiNavigation = {
link: () => ({ href: "/m/x", onClick: () => undefined }),
location: () => ({ page: "x", path: "x" }),
navigate: () => undefined,
settings: undefined,
subscribe: () => () => undefined,
};
expect(describe({ navigation: undefined })).toBe("no navigation");
expect(describe({ navigation })).toBe("x");
});
});