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 { createModuleUiPublicationRegistry, type DiscoveredModule, discoverModules, } from "../src/modules/index.js"; const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), ); }); async function createModulesDirectory(): Promise { const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-ui-publication-")); temporaryDirectories.push(directory); return directory; } interface ModuleFixture { readonly id: string; readonly version?: string; /** Omitted entirely for a backend-only module, so the manifest declares no `entrypoints.ui`. */ readonly ui?: string; } async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { const version = fixture.version ?? "0.1.0"; const moduleDirectory = path.join(modulesDirectory, fixture.id); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await writeFile( path.join(moduleDirectory, "package.json"), JSON.stringify({ ekklesia: { layer: "module" }, name: `@test/module-${fixture.id}`, type: "module", version, }), ); await writeFile( path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify({ compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js", ...(fixture.ui === undefined ? {} : { ui: "./dist/ui/index.js" }), }, id: fixture.id, manifestVersion: 5, name: `${fixture.id} module`, version, }), ); await writeFile( path.join(moduleDirectory, "dist/server/index.js"), `export default { id: ${JSON.stringify(fixture.id)}, version: ${JSON.stringify(version)} };`, ); if (fixture.ui !== undefined) { await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); await writeFile(path.join(moduleDirectory, "dist/ui/index.js"), fixture.ui); } } /** Real discovery, never a hand-built `DiscoveredModule`: the whole point of the registry is that * it consumes only records that already passed manifest validation, compiled-artifact validation, * and package-containment/symlink resolution. */ async function discover(fixtures: readonly ModuleFixture[]): Promise { const modulesDirectory = await createModulesDirectory(); for (const fixture of fixtures) { await createModule(modulesDirectory, fixture); } return discoverModules({ moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0", }); } describe("module UI publication registry", () => { it("publishes a discovered module that declares entrypoints.ui", async () => { const modules = await discover([{ id: "announcements", ui: "export default {};" }]); const registry = createModuleUiPublicationRegistry(modules); const published = registry.resolve("announcements", "0.1.0"); expect(published).toMatchObject({ moduleId: "announcements", version: "0.1.0" }); expect(registry.list()).toHaveLength(1); }); it("exposes exactly the canonical artifact path discovery already validated", async () => { const modules = await discover([{ id: "announcements", ui: "export default {};" }]); const discovered = modules[0] as DiscoveredModule; const registry = createModuleUiPublicationRegistry(modules); // Identical to `entrypoints.ui` — not recomposed from the module directory plus a convention, // which is the failure mode a publisher must never reintroduce. expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBe( discovered.entrypoints.ui, ); expect(path.isAbsolute(discovered.entrypoints.ui as string)).toBe(true); }); it("omits a discovered module that declares no UI entrypoint", async () => { const modules = await discover([{ id: "calendar" }]); const registry = createModuleUiPublicationRegistry(modules); expect(registry.list()).toEqual([]); expect(registry.resolve("calendar", "0.1.0")).toBeUndefined(); }); it("publishes only the modules that declare UI, from a mixed installation", async () => { const modules = await discover([ { id: "announcements", ui: "export default {};" }, { id: "calendar" }, { id: "signage", ui: "export default {};" }, ]); const registry = createModuleUiPublicationRegistry(modules); expect(registry.list().map((entry) => entry.moduleId)).toEqual(["announcements", "signage"]); }); it("never resolves a module that is not part of the discovered set", async () => { const modules = await discover([{ id: "announcements", ui: "export default {};" }]); const registry = createModuleUiPublicationRegistry(modules); expect(registry.resolve("calendar", "0.1.0")).toBeUndefined(); expect(registry.resolve("", "0.1.0")).toBeUndefined(); }); it("refuses a version other than the exact installed one", async () => { const modules = await discover([ { id: "announcements", ui: "export default {};", version: "1.4.2" }, ]); const registry = createModuleUiPublicationRegistry(modules); expect(registry.resolve("announcements", "1.4.2")).toBeDefined(); for (const version of ["1.4.1", "1.4.3", "1.4", "^1.4.2", "latest", "1.4.2 ", ""]) { expect(registry.resolve("announcements", version)).toBeUndefined(); } }); it("fails closed on non-string lookup input rather than trusting its caller", async () => { const modules = await discover([{ id: "announcements", ui: "export default {};" }]); const registry = createModuleUiPublicationRegistry(modules); for (const value of [undefined, null, 0, {}, [], Symbol("announcements")]) { expect(registry.resolve(value, "0.1.0")).toBeUndefined(); expect(registry.resolve("announcements", value)).toBeUndefined(); } }); it("is an empty, usable registry when nothing is installed", () => { const registry = createModuleUiPublicationRegistry([]); expect(registry.list()).toEqual([]); expect(registry.resolve("announcements", "0.1.0")).toBeUndefined(); }); it("publishes an immutable snapshot that no caller can extend", async () => { const modules = await discover([{ id: "announcements", ui: "export default {};" }]); const registry = createModuleUiPublicationRegistry(modules); expect(Object.isFrozen(registry)).toBe(true); expect(Object.isFrozen(registry.list())).toBe(true); expect(Object.isFrozen(registry.list()[0])).toBe(true); // The same array identity every time: nothing recomputes or re-reads the filesystem per call. expect(registry.list()).toBe(registry.list()); }); it("never reads or evaluates the artifact it publishes", async () => { // A top-level throw would surface immediately if anything imported this file. Registry // construction and resolution are pure metadata operations. const modules = await discover([ { id: "announcements", ui: 'throw new Error("module UI must never run on the server");' }, ]); const registry = createModuleUiPublicationRegistry(modules); expect(registry.resolve("announcements", "0.1.0")?.artifactPath).toBeDefined(); }); });