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 { 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: "" }], ["an icon as a URL", { icon: "https://cdn.example.test/icon.svg" }], ["an icon as an object", { icon: { svg: "" } }], ])("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; 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); }); });