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]>
242 lines
9.3 KiB
TypeScript
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);
|
|
});
|
|
});
|