Module discovery gated the compiled-ESM rule on an allowlist, `["schema", "server", "ui"]`, so `entrypoints.jobs` accepted `./src/jobs/index.ts`. Every other executable entrypoint required a built `.js` or `.mjs`; `jobs` did not, purely because it was never added to that list. It is the second entrypoint to slip through the same way — `ui` did too, until Phase 6A added it explicitly — which is the argument against the allowlist rather than against `jobs`. The rule is now expressed as "every entrypoint except a directory entrypoint", with `migrations` the only member of that set. A new manifest entrypoint therefore defaults to the stricter validation instead of silently escaping it. The same constant replaces the two existing `=== "migrations"` special cases for the file/directory kind check and its message, so the one thing that distinguishes `migrations` is stated once. The directory message is now templated and produces byte-identical text. Every other entrypoint guarantee is untouched: package-relative paths, no traversal, inside-package before and after symlink resolution, the artifact must exist, and it must be a file. No runtime was added. `entrypoints.jobs` still has no consumer — it is declared, validated, and never executed — exactly as it has been since ADR 0006, and as `ui` was before its host loader existed. The manifest schema is unchanged and this stays within v4: the field, its type, and its optionality are all the same, and only discovery's validation of the declared path tightened. Compatibility impact is nil in first-party code: no module, template, or fixture declares a `jobs` entrypoint at all. A third-party module declaring a source-TypeScript path would now fail discovery, which is the intended correction. Tests cover accepted `.js` and `.mjs`, rejected `.ts`/`.tsx`/`.cjs`, a missing artifact, a directory, a symlink escaping the package, and that discovery still never evaluates the artifact. The three rejection cases were confirmed to fail against the previous implementation. Co-Authored-By: Claude Opus 5 <[email protected]>
825 lines
30 KiB
TypeScript
825 lines
30 KiB
TypeScript
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
discoverModules,
|
|
ModuleDiscoveryError,
|
|
type ModuleDiscoveryIssueCode,
|
|
} from "../src/modules/index.js";
|
|
|
|
interface ModuleFixtureOptions {
|
|
directory?: string;
|
|
manifest?: Record<string, unknown>;
|
|
packageJson?: Record<string, unknown>;
|
|
serverSource?: string;
|
|
/** Extra package-relative files to write before discovery runs, so a fixture can publish a
|
|
* compiled UI artifact (or a deliberately uncompiled one) alongside its server entrypoint. */
|
|
extraFiles?: Record<string, string>;
|
|
}
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(
|
|
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
|
|
);
|
|
});
|
|
|
|
async function createModulesDirectory(): Promise<string> {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-modules-"));
|
|
temporaryDirectories.push(directory);
|
|
return directory;
|
|
}
|
|
|
|
function manifestFor(id: string): Record<string, unknown> {
|
|
return {
|
|
manifestVersion: 4,
|
|
id,
|
|
name: `${id} module`,
|
|
version: "0.1.0",
|
|
compatibility: {
|
|
platform: "^0.1.0",
|
|
moduleSdk: "^0.1.0",
|
|
},
|
|
capabilities: [{ id: `${id}.records`, version: "1.0.0" }],
|
|
permissions: [
|
|
{ id: `${id}.records.read`, baseline: "restricted", description: "Read records." },
|
|
],
|
|
entrypoints: {
|
|
server: "./dist/server/index.js",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function createModule(
|
|
modulesDirectory: string,
|
|
id: string,
|
|
options: ModuleFixtureOptions = {},
|
|
): Promise<void> {
|
|
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, "dist/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, "dist/server/index.js"),
|
|
options.serverSource ?? `export default { id: ${JSON.stringify(id)}, version: "0.1.0" };`,
|
|
),
|
|
]);
|
|
|
|
for (const [relativePath, contents] of Object.entries(options.extraFiles ?? {})) {
|
|
const target = path.join(moduleDirectory, relativePath);
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
await writeFile(target, contents);
|
|
}
|
|
}
|
|
|
|
async function discoveryError(
|
|
modulesDirectory: string,
|
|
versions: { moduleSdkVersion?: string; platformVersion?: string } = {},
|
|
): Promise<ModuleDiscoveryError> {
|
|
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": "^2.0.0" } },
|
|
},
|
|
storage: { buckets: [{ id: "assets" }, { id: "exports" }] },
|
|
},
|
|
serverSource: 'throw new Error("discovery must never execute server code");',
|
|
});
|
|
await createModule(modulesDirectory, "people", {
|
|
manifest: {
|
|
...manifestFor("people"),
|
|
capabilities: [{ id: "people.records", version: "2.1.0" }],
|
|
},
|
|
});
|
|
|
|
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);
|
|
|
|
expect(modules[1]?.manifest.permissions).toEqual([
|
|
{ id: "calendar.records.read", baseline: "restricted", description: "Read records." },
|
|
]);
|
|
expect(Object.isFrozen(modules[1]?.manifest.permissions)).toBe(true);
|
|
expect(Object.isFrozen(modules[1]?.manifest.permissions[0])).toBe(true);
|
|
const mutablePermissions = modules[1]?.manifest.permissions as unknown as Array<{
|
|
id: string;
|
|
}>;
|
|
expect(() => mutablePermissions.push({ id: "tampered" })).toThrow(TypeError);
|
|
});
|
|
|
|
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: [{ id: "shared.records", version: "1.0.0" }],
|
|
permissions: [
|
|
{ id: "shared.records.read", baseline: "restricted", description: "Read records." },
|
|
],
|
|
},
|
|
packageJson: {
|
|
name: "@test/duplicate",
|
|
version: "0.1.0",
|
|
type: "module",
|
|
ekklesia: { layer: "module" },
|
|
},
|
|
});
|
|
await createModule(modulesDirectory, "two", {
|
|
manifest: {
|
|
...manifestFor("two"),
|
|
id: "shared",
|
|
capabilities: [{ id: "shared.records", version: "1.0.0" }],
|
|
permissions: [
|
|
{ id: "shared.records.read", baseline: "restricted", description: "Read records." },
|
|
],
|
|
},
|
|
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("allows absent optional capabilities but rejects missing required capabilities", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "optional-consumer", {
|
|
manifest: {
|
|
...manifestFor("optional-consumer"),
|
|
dependencies: { capabilities: { optional: { "people.records": "^1.0.0" } } },
|
|
},
|
|
});
|
|
await expect(
|
|
discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
}),
|
|
).resolves.toHaveLength(1);
|
|
|
|
await createModule(modulesDirectory, "consumer", {
|
|
manifest: {
|
|
...manifestFor("consumer"),
|
|
dependencies: { capabilities: { required: { "people.records": "^1.0.0" } } },
|
|
},
|
|
});
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(issueCodes(error)).toContain("MODULE_CAPABILITY_MISSING");
|
|
});
|
|
|
|
it("rejects incompatible installed required and optional capabilities", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "people");
|
|
await createModule(modulesDirectory, "required-consumer", {
|
|
manifest: {
|
|
...manifestFor("required-consumer"),
|
|
dependencies: { capabilities: { required: { "people.records": "^2.0.0" } } },
|
|
},
|
|
});
|
|
await createModule(modulesDirectory, "optional-consumer", {
|
|
manifest: {
|
|
...manifestFor("optional-consumer"),
|
|
dependencies: { capabilities: { optional: { "people.records": "^3.0.0" } } },
|
|
},
|
|
});
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
const incompatibilities = error.issues.filter(
|
|
(issue) => issue.code === "MODULE_CAPABILITY_INCOMPATIBLE",
|
|
);
|
|
expect(incompatibilities).toHaveLength(2);
|
|
expect(incompatibilities).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
message: "Capability people.records 1.0.0 does not satisfy ^2.0.0.",
|
|
path: "dependencies.capabilities.required.people.records",
|
|
}),
|
|
expect.objectContaining({
|
|
message: "Capability people.records 1.0.0 does not satisfy ^3.0.0.",
|
|
path: "dependencies.capabilities.optional.people.records",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
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: "./dist/server/missing.js",
|
|
schema: "./dist/schema/missing.js",
|
|
},
|
|
},
|
|
});
|
|
|
|
let error = await discoveryError(modulesDirectory);
|
|
expect(error.issues.filter((issue) => issue.code === "MODULE_ENTRYPOINT_MISSING")).toHaveLength(
|
|
2,
|
|
);
|
|
|
|
error = await discoveryError(modulesDirectory, { platformVersion: "invalid" });
|
|
expect(issueCodes(error)).toContain("DISCOVERY_CONFIGURATION_INVALID");
|
|
});
|
|
|
|
it("rejects source TypeScript server entrypoints", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "source-server", {
|
|
manifest: {
|
|
...manifestFor("source-server"),
|
|
entrypoints: { server: "./src/server/index.ts" },
|
|
},
|
|
});
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(error.issues).toContainEqual(
|
|
expect.objectContaining({
|
|
code: "MODULE_ENTRYPOINT_INVALID",
|
|
message: "server must reference compiled ESM JavaScript with a .js or .mjs extension.",
|
|
path: "entrypoints.server",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
"./src/schema/index.ts",
|
|
"./dist/schema/index.cjs",
|
|
])("rejects non-ESM schema entrypoint %s", async (schemaEntrypoint) => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "schema-source", {
|
|
manifest: {
|
|
...manifestFor("schema-source"),
|
|
entrypoints: {
|
|
server: "./dist/server/index.js",
|
|
schema: schemaEntrypoint,
|
|
},
|
|
},
|
|
});
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(error.issues).toContainEqual(
|
|
expect.objectContaining({
|
|
code: "MODULE_ENTRYPOINT_INVALID",
|
|
message: "schema must reference compiled ESM JavaScript with a .js or .mjs extension.",
|
|
path: "entrypoints.schema",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("accepts an optional compiled ESM schema entrypoint", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "compiled-schema", {
|
|
manifest: {
|
|
...manifestFor("compiled-schema"),
|
|
entrypoints: {
|
|
server: "./dist/server/index.js",
|
|
schema: "./dist/schema/index.mjs",
|
|
},
|
|
},
|
|
});
|
|
const schemaDirectory = path.join(modulesDirectory, "compiled-schema", "dist/schema");
|
|
await mkdir(schemaDirectory, { recursive: true });
|
|
await writeFile(path.join(schemaDirectory, "index.mjs"), "export default {};\n");
|
|
|
|
const modules = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
expect(modules[0]?.entrypoints.schema).toBe(path.join(schemaDirectory, "index.mjs"));
|
|
});
|
|
|
|
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: "./dist/server.js",
|
|
schema: "./dist/schema.js",
|
|
migrations: "./dist/server/index.js",
|
|
},
|
|
},
|
|
});
|
|
await Promise.all([
|
|
mkdir(path.join(modulesDirectory, "wrong-entrypoint-kind", "dist/server.js")),
|
|
mkdir(path.join(modulesDirectory, "wrong-entrypoint-kind", "dist/schema.js")),
|
|
]);
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(error.issues.filter((issue) => issue.code === "MODULE_ENTRYPOINT_INVALID")).toHaveLength(
|
|
3,
|
|
);
|
|
});
|
|
|
|
it("rejects schema entrypoint links that escape the module directory", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "linked-schema", {
|
|
manifest: {
|
|
...manifestFor("linked-schema"),
|
|
entrypoints: {
|
|
server: "./dist/server/index.js",
|
|
schema: "./dist/schema/index.js",
|
|
},
|
|
},
|
|
});
|
|
const schemaDirectory = path.join(modulesDirectory, "linked-schema", "dist/schema");
|
|
const externalSchema = path.join(modulesDirectory, "external-schema.js");
|
|
await mkdir(schemaDirectory, { recursive: true });
|
|
await writeFile(externalSchema, "export default {};\n");
|
|
await symlink("../../../external-schema.js", path.join(schemaDirectory, "index.js"));
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(error.issues).toContainEqual(
|
|
expect.objectContaining({
|
|
code: "MODULE_ENTRYPOINT_INVALID",
|
|
path: "entrypoints.schema",
|
|
}),
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
describe("compiled jobs entrypoint", () => {
|
|
function manifestWithJobs(id: string, jobs: string): Record<string, unknown> {
|
|
const manifest = manifestFor(id);
|
|
manifest.entrypoints = { server: "./dist/server/index.js", jobs };
|
|
return manifest;
|
|
}
|
|
|
|
it.each([
|
|
["./dist/jobs/index.js", "index.js"],
|
|
["./dist/jobs/index.mjs", "index.mjs"],
|
|
])("accepts the compiled jobs artifact %s", async (jobs, filename) => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "jobs-compiled", {
|
|
manifest: manifestWithJobs("jobs-compiled", jobs),
|
|
extraFiles: { [`dist/jobs/${filename}`]: "export default {};\n" },
|
|
});
|
|
|
|
const [discovered] = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
|
|
expect(discovered?.entrypoints.jobs).toBe(
|
|
path.join(discovered?.directory ?? "", "dist/jobs", filename),
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
"./src/jobs/index.ts",
|
|
"./src/jobs/index.tsx",
|
|
"./dist/jobs/index.cjs",
|
|
])("rejects the uncompiled jobs entrypoint %s", async (jobs) => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "jobs-source", {
|
|
manifest: manifestWithJobs("jobs-source", jobs),
|
|
extraFiles: { [jobs.slice(2)]: "export default {};\n" },
|
|
});
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(error.issues).toContainEqual(
|
|
expect.objectContaining({
|
|
code: "MODULE_ENTRYPOINT_INVALID",
|
|
message: "jobs must reference compiled ESM JavaScript with a .js or .mjs extension.",
|
|
path: "entrypoints.jobs",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects a declared jobs artifact that does not exist", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "jobs-missing", {
|
|
manifest: manifestWithJobs("jobs-missing", "./dist/jobs/index.js"),
|
|
});
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING");
|
|
});
|
|
|
|
it("rejects a jobs entrypoint that resolves to a directory", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "jobs-directory", {
|
|
manifest: manifestWithJobs("jobs-directory", "./dist/jobs.js"),
|
|
extraFiles: { "dist/jobs.js/placeholder.txt": "not a file entrypoint" },
|
|
});
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(error.issues).toContainEqual(
|
|
expect.objectContaining({
|
|
code: "MODULE_ENTRYPOINT_INVALID",
|
|
message: "jobs must resolve to a file.",
|
|
path: "entrypoints.jobs",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("rejects a jobs entrypoint linked outside its own module package", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const externalDirectory = await createModulesDirectory();
|
|
const externalArtifact = path.join(externalDirectory, "foreign-jobs.js");
|
|
await writeFile(externalArtifact, "export default {};\n");
|
|
await createModule(modulesDirectory, "jobs-linked", {
|
|
manifest: manifestWithJobs("jobs-linked", "./dist/jobs/index.js"),
|
|
});
|
|
await mkdir(path.join(modulesDirectory, "jobs-linked/dist/jobs"), { recursive: true });
|
|
await symlink(
|
|
externalArtifact,
|
|
path.join(modulesDirectory, "jobs-linked/dist/jobs/index.js"),
|
|
);
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
|
|
});
|
|
|
|
it("never imports a jobs artifact merely to discover it", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "jobs-inert", {
|
|
manifest: manifestWithJobs("jobs-inert", "./dist/jobs/index.js"),
|
|
// Discovery is inert. If it ever imported a jobs entrypoint this would reject rather than
|
|
// discover successfully — `jobs` still has no runtime consumer.
|
|
extraFiles: {
|
|
"dist/jobs/index.js": "throw new Error('a jobs entrypoint must never be evaluated');",
|
|
},
|
|
});
|
|
|
|
const discovered = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
|
|
expect(discovered).toHaveLength(1);
|
|
expect(discovered[0]?.entrypoints.jobs).toContain("dist/jobs/index.js");
|
|
});
|
|
});
|
|
|
|
describe("module-owned UI entrypoint (ADR 0021)", () => {
|
|
function manifestWithUi(id: string, ui: string): Record<string, unknown> {
|
|
const manifest = manifestFor(id);
|
|
manifest.entrypoints = { server: "./dist/server/index.js", ui };
|
|
return manifest;
|
|
}
|
|
|
|
it("resolves a declared compiled UI entrypoint to a canonical path inside the package", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "announcements", {
|
|
manifest: manifestWithUi("announcements", "./dist/ui/index.js"),
|
|
extraFiles: { "dist/ui/index.js": "export default {};" },
|
|
});
|
|
|
|
const [discovered] = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
|
|
expect(discovered?.entrypoints.ui).toBe(
|
|
path.join(discovered?.directory ?? "", "dist/ui/index.js"),
|
|
);
|
|
expect(discovered?.entrypoints.server).toBeDefined();
|
|
});
|
|
|
|
it("omits the resolved UI entrypoint entirely for a backend-only module", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "roster");
|
|
|
|
const [discovered] = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
|
|
expect(discovered?.entrypoints.ui).toBeUndefined();
|
|
expect(Object.hasOwn(discovered?.entrypoints ?? {}, "ui")).toBe(false);
|
|
});
|
|
|
|
it("rejects a source TypeScript UI entrypoint where compiled output is required", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "signage", {
|
|
manifest: manifestWithUi("signage", "./src/ui/index.ts"),
|
|
extraFiles: { "src/ui/index.ts": "export default {};" },
|
|
});
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
|
|
expect(error.issues.map((issue) => issue.path)).toContain("entrypoints.ui");
|
|
});
|
|
|
|
it("rejects a missing UI artifact rather than silently ignoring the declaration", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "video", {
|
|
manifest: manifestWithUi("video", "./dist/ui/index.js"),
|
|
});
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_MISSING");
|
|
});
|
|
|
|
it("rejects a UI entrypoint that resolves to a directory instead of a file", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "livestream", {
|
|
manifest: manifestWithUi("livestream", "./dist/ui.js"),
|
|
extraFiles: { "dist/ui.js/placeholder.txt": "not a file entrypoint" },
|
|
});
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
|
|
});
|
|
|
|
it("rejects a UI entrypoint linked outside its own module package", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
const externalDirectory = await createModulesDirectory();
|
|
const externalArtifact = path.join(externalDirectory, "foreign-ui.js");
|
|
await writeFile(externalArtifact, "export default {};");
|
|
await createModule(modulesDirectory, "calendar", {
|
|
manifest: manifestWithUi("calendar", "./dist/ui/index.js"),
|
|
});
|
|
await mkdir(path.join(modulesDirectory, "calendar/dist/ui"), { recursive: true });
|
|
await symlink(externalArtifact, path.join(modulesDirectory, "calendar/dist/ui/index.js"));
|
|
|
|
const error = await discoveryError(modulesDirectory);
|
|
expect(issueCodes(error)).toContain("MODULE_ENTRYPOINT_INVALID");
|
|
});
|
|
|
|
it("never imports a UI artifact merely to discover it", async () => {
|
|
const modulesDirectory = await createModulesDirectory();
|
|
await createModule(modulesDirectory, "announcements", {
|
|
manifest: manifestWithUi("announcements", "./dist/ui/index.js"),
|
|
// Discovery is inert: a throwing artifact must not be evaluated. If discovery ever
|
|
// imported a UI entrypoint, this would surface as a rejected promise rather than a
|
|
// successfully discovered module.
|
|
extraFiles: {
|
|
"dist/ui/index.js": "throw new Error('a UI entrypoint must never be evaluated');",
|
|
},
|
|
});
|
|
|
|
const discovered = await discoverModules({
|
|
moduleSdkVersion: "0.1.0",
|
|
modulesDirectory,
|
|
platformVersion: "0.1.0",
|
|
});
|
|
|
|
expect(discovered).toHaveLength(1);
|
|
expect(discovered[0]?.entrypoints.ui).toContain("dist/ui/index.js");
|
|
});
|
|
});
|
|
});
|