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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7447381c8f
commit
4671622b9d
@@ -0,0 +1,241 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user