feat: add module contracts and discovery
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isVersionCompatible,
|
||||
MODULE_LIFECYCLE_STAGES,
|
||||
packageRelativePathSchema,
|
||||
parseModuleManifest,
|
||||
safeParseModuleManifest,
|
||||
} from "../src/index.js";
|
||||
|
||||
function validManifest(): Record<string, unknown> {
|
||||
return {
|
||||
manifestVersion: 1,
|
||||
id: "calendar",
|
||||
name: "Calendar",
|
||||
version: "0.1.0",
|
||||
compatibility: {
|
||||
platform: "^0.1.0",
|
||||
moduleSdk: "^0.1.0",
|
||||
},
|
||||
capabilities: ["calendar.events"],
|
||||
permissions: ["calendar.events.read"],
|
||||
storage: {
|
||||
buckets: [{ id: "assets", description: "Calendar-owned event assets." }],
|
||||
},
|
||||
entrypoints: {
|
||||
server: "./src/server/index.ts",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("module manifest contract", () => {
|
||||
it("normalizes a valid manifest with safe defaults", () => {
|
||||
const manifest = parseModuleManifest(validManifest());
|
||||
|
||||
expect(manifest.id).toBe("calendar");
|
||||
expect(manifest.dependencies).toEqual({
|
||||
capabilities: { optional: [], required: [] },
|
||||
modules: { optional: {}, required: {} },
|
||||
});
|
||||
expect(manifest.storage.buckets).toEqual([
|
||||
{ id: "assets", description: "Calendar-owned event assets." },
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates isolated default collections for every parsed manifest", () => {
|
||||
const input = validManifest();
|
||||
delete input.storage;
|
||||
const first = parseModuleManifest(input);
|
||||
const second = parseModuleManifest(input);
|
||||
|
||||
first.dependencies.modules.required.people = "^0.1.0";
|
||||
first.dependencies.capabilities.required.push("people.records");
|
||||
first.storage.buckets.push({ id: "exports" });
|
||||
|
||||
expect(second.dependencies.modules.required).toEqual({});
|
||||
expect(second.dependencies.capabilities.required).toEqual([]);
|
||||
expect(second.storage.buckets).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects unknown and presentation-oriented manifest properties", () => {
|
||||
const manifest = { ...validManifest(), renderers: ["calendar-month"] };
|
||||
const result = safeParseModuleManifest(manifest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires module-owned capability and permission namespaces", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
capabilities: ["media.items"],
|
||||
permissions: ["media.items.read"],
|
||||
};
|
||||
const result = safeParseModuleManifest(manifest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.map((issue) => issue.path.join("."))).toEqual(
|
||||
expect.arrayContaining(["capabilities.0", "permissions.0"]),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate declarations and dependency overlap", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
dependencies: {
|
||||
modules: {
|
||||
required: { people: "^1.0.0" },
|
||||
optional: { people: "^1.0.0" },
|
||||
},
|
||||
capabilities: {
|
||||
required: ["people.records"],
|
||||
optional: ["people.records"],
|
||||
},
|
||||
},
|
||||
capabilities: ["calendar.events", "calendar.events"],
|
||||
storage: { buckets: [{ id: "assets" }, { id: "assets" }] },
|
||||
};
|
||||
const result = safeParseModuleManifest(manifest);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues).toHaveLength(4);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects module and capability self-dependencies", () => {
|
||||
const manifest = {
|
||||
...validManifest(),
|
||||
dependencies: {
|
||||
modules: { required: { calendar: "^0.1.0" } },
|
||||
capabilities: { required: ["calendar.events"] },
|
||||
},
|
||||
};
|
||||
|
||||
expect(safeParseModuleManifest(manifest).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"../outside.ts",
|
||||
"/absolute.ts",
|
||||
"file:///server.ts",
|
||||
"./src/../server.ts",
|
||||
"./src\\server.ts",
|
||||
"./src/server.ts?raw",
|
||||
])("rejects unsafe package entrypoint %s", (entrypoint) => {
|
||||
expect(packageRelativePathSchema.safeParse(entrypoint).success).toBe(false);
|
||||
});
|
||||
|
||||
it("allows package directories whose names merely begin with two dots", () => {
|
||||
expect(packageRelativePathSchema.safeParse("./..data/server.ts").success).toBe(true);
|
||||
});
|
||||
|
||||
it("uses full semantic-version compatibility ranges", () => {
|
||||
expect(isVersionCompatible("0.1.5", "^0.1.0")).toBe(true);
|
||||
expect(isVersionCompatible("0.2.0", "^0.1.0")).toBe(false);
|
||||
expect(isVersionCompatible("not-a-version", "*")).toBe(false);
|
||||
});
|
||||
|
||||
it("publishes the complete lifecycle vocabulary", () => {
|
||||
expect(MODULE_LIFECYCLE_STAGES).toContain("discovered");
|
||||
expect(MODULE_LIFECYCLE_STAGES).toContain("failed");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user