import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { type DashboardConfiguration, resolveDashboardNavigation } from "../src/dashboard/index.js"; import { createModuleUiPublicationRegistry, type DiscoveredModule, discoverModules, } from "../src/modules/index.js"; /** * The executable form of the architecture's central rule: **Dashboard grouping is presentation * configuration and can never become module structure** (ADR 0021, ADR 0027). * * These run real discovery over real fixtures, including a genuine module dependency, and then * arrange those modules into folders in several different ways. Everything discovery decides — * which modules exist, what depends on what, and the order they must start in — has to come out * byte-identical every time. A future change that let a group influence any of that fails here. */ const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories .splice(0) .map((directory) => rm(directory, { force: true, recursive: true })), ); }); interface ModuleFixture { readonly id: string; readonly name: string; /** A *real* technical dependency — one module calling another's functionality — expressed through * the existing module dependency contract, which is the only kind of module relationship that * exists. */ readonly dependsOn?: string; } async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { const moduleDirectory = path.join(modulesDirectory, fixture.id); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await writeFile( path.join(moduleDirectory, "package.json"), JSON.stringify({ ekklesia: { layer: "module" }, name: `@test/module-${fixture.id}`, type: "module", version: "1.0.0", }), ); await writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, ...(fixture.dependsOn ? { dependencies: { modules: { required: { [fixture.dependsOn]: "^1.0.0" } } } } : {}), entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, id: fixture.id, manifestVersion: 4, name: fixture.name, version: "1.0.0", }), ); await writeFile( path.join(moduleDirectory, "dist/server/index.js"), `export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`, ); await writeFile( path.join(moduleDirectory, "dist/ui/index.js"), `export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`, ); } /** Livestream genuinely depends on Video — it calls Video's functionality — so discovery must order * Video first. Calendar depends on nothing. This is the *only* kind of relationship modules have. */ async function discoverFixtures(): Promise { const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-flat-modules-")); temporaryDirectories.push(modulesDirectory); await createModule(modulesDirectory, { id: "video", name: "Video" }); await createModule(modulesDirectory, { dependsOn: "video", id: "livestream", name: "Livestream", }); await createModule(modulesDirectory, { id: "calendar", name: "Calendar" }); return discoverModules({ moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0", }); } /** * Four arrangements of the same three modules, including the one the architecture warns about: * Livestream and Video together under "Media", which must **not** create a Media module or a * dependency between them. */ const arrangements: ReadonlyArray = [ ["nothing configured", { groups: [], placements: [] }], [ "Media over Planning", { groups: [ { id: "media", label: "Media", sortOrder: 10 }, { id: "planning", label: "Planning", sortOrder: 20 }, ], placements: [ { groupId: "media", moduleId: "livestream", sortOrder: 10 }, { groupId: "media", moduleId: "video", sortOrder: 20 }, { groupId: "planning", moduleId: "calendar", sortOrder: 10 }, ], }, ], [ "Livestream moved to Sunday Production, away from Video", { groups: [ { id: "media", label: "Media", sortOrder: 10 }, { id: "sunday-production", label: "Sunday Production", sortOrder: 5 }, ], placements: [ { groupId: "sunday-production", moduleId: "livestream", sortOrder: 10 }, { groupId: "media", moduleId: "video", sortOrder: 10 }, ], }, ], [ "the dependency inverted in presentation: Video grouped under Livestream's folder alone", { groups: [{ id: "streaming", label: "Streaming", sortOrder: 1 }], placements: [{ groupId: "streaming", moduleId: "video", sortOrder: 10 }], }, ], ]; describe("Dashboard grouping never becomes module structure", () => { it("orders discovery by real dependencies, which is the only module relationship", async () => { const discovered = await discoverFixtures(); // Video before Livestream because Livestream *depends* on it — not because of any folder. expect(discovered.map((module) => module.manifest.id)).toEqual([ "calendar", "video", "livestream", ]); expect( discovered.find((module) => module.manifest.id === "livestream")?.manifest.dependencies, ).toMatchObject({ modules: { required: { video: "^1.0.0" } } }); }); it("leaves discovery, dependencies, and start order identical under every arrangement", async () => { const discovered = await discoverFixtures(); const registry = createModuleUiPublicationRegistry(discovered); // The exact facts the runtime consumes, captured before any Dashboard configuration exists. const baseline = JSON.stringify( discovered.map((module) => ({ capabilities: module.manifest.capabilities, dependencies: module.manifest.dependencies, id: module.manifest.id, permissions: module.manifest.permissions, version: module.manifest.version, })), ); for (const [label, configuration] of arrangements) { const view = resolveDashboardNavigation( configuration, registry.list().map((entry) => ({ id: entry.moduleId, name: entry.name })), ); // Discovery order — which is module *startup* order — is untouched by presentation. expect( discovered.map((module) => module.manifest.id), label, ).toEqual(["calendar", "video", "livestream"]); // So is every fact the runtime reads. expect( JSON.stringify( discovered.map((module) => ({ capabilities: module.manifest.capabilities, dependencies: module.manifest.dependencies, id: module.manifest.id, permissions: module.manifest.permissions, version: module.manifest.version, })), ), label, ).toBe(baseline); // And every module is still rendered exactly once, whatever the folders say. expect( [...view.groups.flatMap((group) => group.modules), ...view.ungrouped] .map((entry) => entry.id) .sort(), label, ).toEqual(["calendar", "livestream", "video"]); } }); it("never invents a module for a group, however the group is named", async () => { const discovered = await discoverFixtures(); const registry = createModuleUiPublicationRegistry(discovered); const [, mediaArrangement] = arrangements[1] as readonly [string, DashboardConfiguration]; const view = resolveDashboardNavigation( mediaArrangement, registry.list().map((entry) => ({ id: entry.moduleId, name: entry.name })), ); // "Media" and "Planning" are group labels. There is no `media` module, no manifest for one, no // artifact, no API root, no permission, and nothing depends on one. const moduleIds = discovered.map((module) => module.manifest.id); expect(moduleIds).toEqual(["calendar", "video", "livestream"]); for (const groupId of view.groups.map((group) => group.id)) { expect(moduleIds).not.toContain(groupId); expect(registry.resolve(groupId, "1.0.0")).toBeUndefined(); } }); it("keeps the publication record free of any grouping, ordering, or navigation field", async () => { const registry = createModuleUiPublicationRegistry(await discoverFixtures()); const published = registry.resolve("livestream", "1.0.0"); // The record a host mounts from carries identity, the validated artifact, declared permissions, // and the module's own name — never a group, an order, a parent, or a folder. expect(Object.keys(published ?? {}).sort()).toEqual([ "artifactPath", "moduleId", "name", "permissions", "version", ]); }); it("takes the display name from the module's own manifest, not from any folder", async () => { const registry = createModuleUiPublicationRegistry(await discoverFixtures()); expect(registry.list().map((entry) => [entry.moduleId, entry.name])).toEqual([ ["calendar", "Calendar"], ["video", "Video"], ["livestream", "Livestream"], ]); }); });