import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it } from "vitest"; import { discoverModules, ModuleDiscoveryError, type ModuleDiscoveryIssueCode, } from "../src/modules/index.js"; interface ModuleFixtureOptions { directory?: string; manifest?: Record; packageJson?: Record; serverSource?: string; } 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-modules-")); temporaryDirectories.push(directory); return directory; } function manifestFor(id: string): Record { return { manifestVersion: 1, id, name: `${id} module`, version: "0.1.0", compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0", }, capabilities: [`${id}.records`], permissions: [`${id}.records.read`], entrypoints: { server: "./src/server/index.ts", }, }; } async function createModule( modulesDirectory: string, id: string, options: ModuleFixtureOptions = {}, ): Promise { const directoryName = options.directory ?? id; const moduleDirectory = path.join(modulesDirectory, directoryName); const manifest = options.manifest ?? manifestFor(id); const packageJson = options.packageJson ?? { name: `@test/module-${id}`, version: "0.1.0", type: "module", ekklesia: { layer: "module" }, }; await mkdir(path.join(moduleDirectory, "src/server"), { recursive: true }); await Promise.all([ writeFile(path.join(moduleDirectory, "package.json"), JSON.stringify(packageJson)), writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)), writeFile( path.join(moduleDirectory, "src/server/index.ts"), options.serverSource ?? `export const moduleId = ${JSON.stringify(id)};`, ), ]); } async function discoveryError( modulesDirectory: string, versions: { moduleSdkVersion?: string; platformVersion?: string } = {}, ): Promise { try { await discoverModules({ moduleSdkVersion: versions.moduleSdkVersion ?? "0.1.0", modulesDirectory, platformVersion: versions.platformVersion ?? "0.1.0", }); } catch (error) { if (error instanceof ModuleDiscoveryError) { return error; } throw error; } throw new Error("Expected module discovery to fail."); } function issueCodes(error: ModuleDiscoveryError): ModuleDiscoveryIssueCode[] { return error.issues.map((issue) => issue.code); } describe("module discovery", () => { it("treats a missing or empty modules directory as zero installed modules", async () => { const parent = await createModulesDirectory(); const missing = path.join(parent, "missing"); await expect( discoverModules({ moduleSdkVersion: "0.1.0", modulesDirectory: missing, platformVersion: "0.1.0", }), ).resolves.toEqual([]); await expect( discoverModules({ moduleSdkVersion: "0.1.0", modulesDirectory: parent, platformVersion: "0.1.0", }), ).resolves.toEqual([]); }); it("discovers static manifests and orders dependencies deterministically", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "calendar", { manifest: { ...manifestFor("calendar"), dependencies: { modules: { required: { people: "^0.1.0" } }, capabilities: { required: ["people.records"] }, }, storage: { buckets: [{ id: "assets" }, { id: "exports" }] }, }, serverSource: 'throw new Error("discovery must never execute server code");', }); await createModule(modulesDirectory, "people"); const modules = await discoverModules({ moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0", }); expect(modules.map((module) => module.manifest.id)).toEqual(["people", "calendar"]); expect(modules[1]?.manifest.storage.buckets.map((bucket) => bucket.id)).toEqual([ "assets", "exports", ]); expect(Object.isFrozen(modules[1]?.manifest)).toBe(true); const mutableBuckets = modules[1]?.manifest.storage.buckets as unknown as Array<{ id: string }>; expect(() => mutableBuckets.push({ id: "tampered" })).toThrow(TypeError); }); it("accepts the repository's cloneable module template", async () => { const testDirectory = path.dirname(fileURLToPath(import.meta.url)); const templatesDirectory = path.resolve(testDirectory, "../../templates"); const modules = await discoverModules({ moduleSdkVersion: "0.1.0", modulesDirectory: templatesDirectory, platformVersion: "0.1.0", }); expect(modules.map((module) => module.manifest.id)).toEqual(["example"]); }); it("aggregates malformed packages and manifests without partial discovery", async () => { const modulesDirectory = await createModulesDirectory(); await mkdir(path.join(modulesDirectory, "missing")); await createModule(modulesDirectory, "invalid", { manifest: { ...manifestFor("invalid"), renderers: ["not-allowed"] }, packageJson: { name: "@test/module-invalid", version: "not-semver", type: "commonjs", }, }); const error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toEqual( expect.arrayContaining([ "MODULE_MANIFEST_INVALID", "MODULE_MANIFEST_MISSING", "MODULE_PACKAGE_INVALID", "MODULE_PACKAGE_MISSING", ]), ); }); it("aggregates malformed JSON without exposing machine-specific paths", async () => { const modulesDirectory = await createModulesDirectory(); const moduleDirectory = path.join(modulesDirectory, "broken-json"); await mkdir(moduleDirectory); await Promise.all([ writeFile(path.join(moduleDirectory, "package.json"), "{"), writeFile(path.join(moduleDirectory, "ekklesia.module.json"), "["), ]); const error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toEqual( expect.arrayContaining(["MODULE_MANIFEST_INVALID", "MODULE_PACKAGE_INVALID"]), ); expect(error.issues.every((issue) => !issue.message.includes(modulesDirectory))).toBe(true); }); it("requires package and manifest versions to match", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "mismatch", { packageJson: { name: "@test/module-mismatch", version: "0.2.0", type: "module", ekklesia: { layer: "module" }, }, }); const error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toContain("MODULE_VERSION_MISMATCH"); }); it("rejects duplicate module IDs and package names", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "one", { manifest: { ...manifestFor("one"), id: "shared", capabilities: ["shared.records"], permissions: ["shared.records.read"], }, packageJson: { name: "@test/duplicate", version: "0.1.0", type: "module", ekklesia: { layer: "module" }, }, }); await createModule(modulesDirectory, "two", { manifest: { ...manifestFor("two"), id: "shared", capabilities: ["shared.records"], permissions: ["shared.records.read"], }, packageJson: { name: "@test/duplicate", version: "0.1.0", type: "module", ekklesia: { layer: "module" }, }, }); const error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toEqual( expect.arrayContaining(["MODULE_ID_DUPLICATE", "MODULE_PACKAGE_NAME_DUPLICATE"]), ); }); it("enforces platform and Module SDK compatibility", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "future", { manifest: { ...manifestFor("future"), compatibility: { platform: ">=1.0.0", moduleSdk: ">=2.0.0" }, }, }); const error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toEqual( expect.arrayContaining(["MODULE_PLATFORM_INCOMPATIBLE", "MODULE_SDK_INCOMPATIBLE"]), ); }); it("allows missing optional dependencies but rejects missing required dependencies", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "optional-consumer", { manifest: { ...manifestFor("optional-consumer"), dependencies: { modules: { optional: { absent: "^1.0.0" } } }, }, }); await expect( discoverModules({ moduleSdkVersion: "0.1.0", modulesDirectory, platformVersion: "0.1.0", }), ).resolves.toHaveLength(1); await createModule(modulesDirectory, "required-consumer", { manifest: { ...manifestFor("required-consumer"), dependencies: { modules: { required: { absent: "^1.0.0" } } }, }, }); const error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_MISSING"); }); it("rejects installed dependencies with incompatible versions", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "people"); await createModule(modulesDirectory, "calendar", { manifest: { ...manifestFor("calendar"), dependencies: { modules: { optional: { people: "^2.0.0" } } }, }, }); const error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_INCOMPATIBLE"); }); it("rejects missing required capabilities", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "consumer", { manifest: { ...manifestFor("consumer"), dependencies: { capabilities: { required: ["people.records"] } }, }, }); const error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toContain("MODULE_CAPABILITY_MISSING"); }); it("detects dependency cycles", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "one", { manifest: { ...manifestFor("one"), dependencies: { modules: { required: { two: "^0.1.0" } } }, }, }); await createModule(modulesDirectory, "two", { manifest: { ...manifestFor("two"), dependencies: { modules: { required: { one: "^0.1.0" } } }, }, }); await createModule(modulesDirectory, "three", { manifest: { ...manifestFor("three"), dependencies: { modules: { required: { two: "^0.1.0" } } }, }, }); const error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toContain("MODULE_DEPENDENCY_CYCLE"); expect(error.issues.find((issue) => issue.code === "MODULE_DEPENDENCY_CYCLE")?.message).toBe( "Module dependency cycle contains: one, two.", ); }); it("rejects missing entrypoint targets and invalid caller versions", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "missing-server", { manifest: { ...manifestFor("missing-server"), entrypoints: { server: "./src/server/missing.ts" }, }, }); let error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING"); error = await discoveryError(modulesDirectory, { platformVersion: "invalid" }); expect(issueCodes(error)).toContain("DISCOVERY_CONFIGURATION_INVALID"); }); it("requires executable entrypoints to be files and migrations to be a directory", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "wrong-entrypoint-kind", { manifest: { ...manifestFor("wrong-entrypoint-kind"), entrypoints: { server: "./src/server", migrations: "./src/server/index.ts", }, }, }); const error = await discoveryError(modulesDirectory); expect(error.issues.filter((issue) => issue.code === "MODULE_ENTRYPOINT_INVALID")).toHaveLength( 2, ); }); it("rejects package metadata links that escape the module directory", async () => { const modulesDirectory = await createModulesDirectory(); await createModule(modulesDirectory, "linked"); const externalManifest = path.join(modulesDirectory, "external-manifest.json"); const linkedManifest = path.join(modulesDirectory, "linked", "ekklesia.module.json"); await writeFile(externalManifest, JSON.stringify(manifestFor("linked"))); await rm(linkedManifest); await symlink("../external-manifest.json", linkedManifest); const error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toContain("MODULE_MANIFEST_INVALID"); }); it("rejects symbolic links masquerading as installed module directories", async () => { const modulesDirectory = await createModulesDirectory(); const externalModules = await createModulesDirectory(); await createModule(externalModules, "outside"); await symlink( path.join(externalModules, "outside"), path.join(modulesDirectory, "linked-module"), "dir", ); const error = await discoveryError(modulesDirectory); expect(issueCodes(error)).toContain("MODULE_DIRECTORY_INVALID"); }); it("produces stable diagnostics independent of the absolute modules path", async () => { const firstModulesDirectory = await createModulesDirectory(); const secondModulesDirectory = await createModulesDirectory(); await mkdir(path.join(firstModulesDirectory, "broken")); await mkdir(path.join(secondModulesDirectory, "broken")); const [firstError, secondError] = await Promise.all([ discoveryError(firstModulesDirectory), discoveryError(secondModulesDirectory), ]); expect(firstError.issues).toEqual(secondError.issues); }); });