Files
Ekklesia/packages/contracts/tests/module-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

242 lines
9.3 KiB
TypeScript

import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
EMPTY_MODULE_NAVIGATION,
isModuleNavigationIcon,
isModuleNavigationPagePath,
MODULE_NAVIGATION_ICONS,
MODULE_NAVIGATION_PAGES_MAX,
moduleManifestNavigation,
parseModuleManifest,
safeParseModuleManifest,
} from "../src/index.js";
/**
* Manifest v9's Dashboard navigation (ADR 0063), and the guarantee that released manifest v8
* modules keep working unchanged.
*/
const fixtures = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures");
function v9Manifest(navigation?: unknown): Record<string, unknown> {
return {
manifestVersion: 9,
id: "signage",
name: "Signage",
version: "0.2.0",
compatibility: { moduleSdk: "^0.2.0", platform: "^0.2.0" },
entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" },
...(navigation === undefined ? {} : { navigation }),
};
}
const pages = [
{ id: "playback", label: "Playback", path: "playback" },
{ id: "timelines", label: "Timelines", path: "timelines" },
{ id: "media", label: "Media", path: "media" },
];
function issues(input: unknown): string[] {
const parsed = safeParseModuleManifest(input);
return parsed.success
? []
: parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
}
describe("manifest v9 navigation", () => {
it("accepts a single-page v9 module with no navigation at all: the generic icon, one root page", () => {
const manifest = parseModuleManifest(v9Manifest());
expect(manifest.manifestVersion).toBe(9);
expect(moduleManifestNavigation(manifest)).toEqual({ pages: [] });
expect(moduleManifestNavigation(manifest).icon).toBeUndefined();
});
it("accepts a multi-page module with a Lucide icon, keeping pages in declared order", () => {
const manifest = parseModuleManifest(v9Manifest({ icon: "monitor-play", pages }));
const navigation = moduleManifestNavigation(manifest);
expect(navigation.icon).toBe("monitor-play");
expect(navigation.pages.map((page) => page.id)).toEqual(["playback", "timelines", "media"]);
// Reversed in the manifest, reversed in the result: order is the author's, never re-sorted.
const reversed = moduleManifestNavigation(
parseModuleManifest(v9Manifest({ pages: [...pages].reverse() })),
);
expect(reversed.pages.map((page) => page.id)).toEqual(["media", "timelines", "playback"]);
});
it("accepts an icon on its own, and pages on their own", () => {
expect(moduleManifestNavigation(parseModuleManifest(v9Manifest({ icon: "calendar" })))).toEqual(
{
icon: "calendar",
pages: [],
},
);
expect(
moduleManifestNavigation(parseModuleManifest(v9Manifest({ pages }))).icon,
).toBeUndefined();
});
it("accepts multi-segment page paths", () => {
expect(
issues(v9Manifest({ pages: [{ id: "library", label: "Library", path: "media/library" }] })),
).toEqual([]);
});
it.each([
["an unknown icon", { icon: "not-an-icon" }],
["a PascalCase component name", { icon: "MonitorPlay" }],
["an icon as markup", { icon: "<svg onload=alert(1)>" }],
["an icon as a URL", { icon: "https://cdn.example.test/icon.svg" }],
["an icon as an object", { icon: { svg: "<path/>" } }],
])("refuses %s", (_label, navigation) => {
expect(issues(v9Manifest(navigation))).not.toEqual([]);
});
it.each([
["an empty path", ""],
["the root as a page", "/"],
["a leading slash", "/timelines"],
["a trailing slash", "timelines/"],
["an empty segment", "media//library"],
["traversal", "../calendar"],
["an inner traversal", "media/../../calendar"],
["a dot segment", "./timelines"],
["an absolute URL", "https://evil.example.test/x"],
["a protocol-relative URL", "//evil.example.test"],
["a scheme", "javascript:alert(1)"],
["a query", "timelines?x=1"],
["a fragment", "timelines#top"],
["percent-encoding", "media%2Flibrary"],
["a backslash", "media\\library"],
["uppercase", "Timelines"],
["whitespace", "time lines"],
["too many segments", "a/b/c/d/e"],
])("refuses a page path with %s", (_label, pagePath) => {
expect(isModuleNavigationPagePath(pagePath)).toBe(false);
expect(
issues(v9Manifest({ pages: [{ id: "page", label: "Page", path: pagePath }] })),
).not.toEqual([]);
});
it.each([
["an uppercase id", { id: "Playback", label: "Playback", path: "playback" }],
["an id with a slash", { id: "media/library", label: "Library", path: "library" }],
["an empty label", { id: "playback", label: " ", path: "playback" }],
["an overlong label", { id: "playback", label: "x".repeat(61), path: "playback" }],
[
"a control character in a label",
{ id: "playback", label: "Play\u0007back", path: "playback" },
],
["a line separator in a label", { id: "playback", label: "Play\u2028back", path: "playback" }],
["an unknown page key", { id: "playback", label: "Playback", path: "playback", icon: "tv" }],
["an executable field", { id: "playback", label: "Playback", path: "playback", onClick: "x" }],
])("refuses a page with %s", (_label, page) => {
expect(issues(v9Manifest({ pages: [page] }))).not.toEqual([]);
});
it("refuses duplicate page ids and duplicate page paths, naming the duplicate", () => {
expect(
issues(
v9Manifest({
pages: [
{ id: "media", label: "Media", path: "media" },
{ id: "media", label: "More media", path: "more-media" },
],
}),
),
).toEqual(["navigation.pages.1.id: media is declared more than once."]);
expect(
issues(
v9Manifest({
pages: [
{ id: "media", label: "Media", path: "media" },
{ id: "library", label: "Library", path: "media" },
],
}),
),
).toEqual(["navigation.pages.1.path: Two pages must not declare the same path."]);
});
it("refuses a page beneath another page, which would make the open page ambiguous", () => {
expect(
issues(
v9Manifest({
pages: [
{ id: "media", label: "Media", path: "media" },
{ id: "library", label: "Library", path: "media/library" },
],
}),
),
).toEqual([
"navigation.pages.1.path: A page path must not lie beneath another declared page's path.",
]);
// A shared prefix that is not a parent segment is unrelated.
expect(
issues(
v9Manifest({
pages: [
{ id: "media", label: "Media", path: "media" },
{ id: "media-library", label: "Library", path: "media-library" },
],
}),
),
).toEqual([]);
});
it("bounds the number of pages", () => {
const many = Array.from({ length: MODULE_NAVIGATION_PAGES_MAX + 1 }, (_, index) => ({
id: `page-${index}`,
label: `Page ${index}`,
path: `page-${index}`,
}));
expect(issues(v9Manifest({ pages: many }))).not.toEqual([]);
expect(issues(v9Manifest({ pages: many.slice(0, MODULE_NAVIGATION_PAGES_MAX) }))).toEqual([]);
});
it("refuses navigation for a module with no primary UI, and unknown navigation keys", () => {
const noUi = {
...v9Manifest({ icon: "calendar" }),
entrypoints: { server: "./dist/server/index.js" },
};
expect(issues(noUi)).toEqual([
"navigation: Navigation can only be declared by a module with a primary UI (entrypoints.ui).",
]);
expect(issues(v9Manifest({ groups: ["media"] }))).not.toEqual([]);
expect(issues(v9Manifest({ widgets: [] }))).not.toEqual([]);
});
it("offers a curated, sorted, duplicate-free set of Lucide names, all in Lucide's kebab-case form", () => {
expect([...MODULE_NAVIGATION_ICONS]).toEqual([...MODULE_NAVIGATION_ICONS].sort());
expect(new Set(MODULE_NAVIGATION_ICONS).size).toBe(MODULE_NAVIGATION_ICONS.length);
for (const icon of MODULE_NAVIGATION_ICONS) {
expect(icon).toMatch(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u);
expect(isModuleNavigationIcon(icon)).toBe(true);
}
expect(isModuleNavigationIcon("signage")).toBe(false);
expect(Object.isFrozen(MODULE_NAVIGATION_ICONS)).toBe(true);
});
});
describe("released manifest v8 modules", () => {
it("still accepts the released Signage 0.1.5 manifest exactly as published", async () => {
const published = JSON.parse(
await readFile(path.join(fixtures, "released-v8-signage-0.1.5.module.json"), "utf8"),
) as Record<string, unknown>;
const manifest = parseModuleManifest(published);
expect(manifest.manifestVersion).toBe(8);
expect(manifest.id).toBe("signage");
// The settings Signage declares are what Settings reads (ADR 0061), unchanged by v9.
expect(manifest.configuration.length).toBeGreaterThan(0);
// A v8 module declares no navigation: the generic icon and one root page.
expect(moduleManifestNavigation(manifest)).toBe(EMPTY_MODULE_NAVIGATION);
expect("navigation" in manifest).toBe(false);
});
it("never reads navigation into a version 8 document: one that names it is refused, not upgraded", () => {
const v8WithNavigation = { ...v9Manifest({ icon: "tv" }), manifestVersion: 8 };
expect(safeParseModuleManifest(v8WithNavigation).success).toBe(false);
});
});