CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
448 lines
18 KiB
TypeScript
448 lines
18 KiB
TypeScript
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<string> {
|
|
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;
|
|
/** A dedicated full-viewport artifact, independent of the primary UI. */
|
|
readonly display?: string;
|
|
/** Capabilities this module provides — what another module's contributions can target. */
|
|
readonly capabilities?: readonly { readonly id: string; readonly version: string }[];
|
|
/** Browser renderers this module contributes to another module's capability. */
|
|
readonly contributions?: readonly {
|
|
readonly localName: string;
|
|
readonly version: string;
|
|
readonly capability: string;
|
|
readonly capabilityRange: string;
|
|
}[];
|
|
}
|
|
|
|
async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise<void> {
|
|
const version = fixture.version ?? "0.1.0";
|
|
const displayPermission = `${fixture.id}.display.view`;
|
|
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" }),
|
|
...(fixture.display === undefined
|
|
? {}
|
|
: {
|
|
display: {
|
|
artifact: "./dist/display/index.js",
|
|
permission: displayPermission,
|
|
},
|
|
}),
|
|
},
|
|
capabilities: fixture.capabilities ?? [],
|
|
contributions: (fixture.contributions ?? []).map((contribution) => ({
|
|
artifact: `./dist/contributions/${contribution.localName}/index.js`,
|
|
capability: contribution.capability,
|
|
capabilityRange: contribution.capabilityRange,
|
|
id: `${fixture.id}.${contribution.localName}`,
|
|
version: contribution.version,
|
|
})),
|
|
id: fixture.id,
|
|
manifestVersion: 8,
|
|
name: `${fixture.id} module`,
|
|
permissions:
|
|
fixture.display === undefined
|
|
? []
|
|
: [
|
|
{
|
|
baseline: "restricted",
|
|
description: `View ${fixture.id}'s display`,
|
|
id: displayPermission,
|
|
},
|
|
],
|
|
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);
|
|
}
|
|
if (fixture.display !== undefined) {
|
|
await mkdir(path.join(moduleDirectory, "dist/display"), { recursive: true });
|
|
await writeFile(path.join(moduleDirectory, "dist/display/index.js"), fixture.display);
|
|
}
|
|
for (const contribution of fixture.contributions ?? []) {
|
|
const contributionDirectory = path.join(
|
|
moduleDirectory,
|
|
"dist/contributions",
|
|
contribution.localName,
|
|
);
|
|
await mkdir(contributionDirectory, { recursive: true });
|
|
await writeFile(path.join(contributionDirectory, "index.js"), "export default {};");
|
|
}
|
|
}
|
|
|
|
/** 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<readonly DiscoveredModule[]> {
|
|
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.listDisplays()).toEqual([]);
|
|
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
|
|
expect(registry.resolveDisplay("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();
|
|
});
|
|
});
|
|
|
|
describe("module display UI publication registry", () => {
|
|
it("keeps primary UI and display publication independent for display-only, both, and neither", async () => {
|
|
const modules = await discover([
|
|
{ display: "export default { role: 'display' };", id: "announcements" },
|
|
{
|
|
display: "export default { role: 'display' };",
|
|
id: "signage",
|
|
ui: "export default { role: 'primary' };",
|
|
},
|
|
{ id: "calendar" },
|
|
]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(registry.list().map((entry) => entry.moduleId)).toEqual(["signage"]);
|
|
expect(registry.listDisplays().map((entry) => entry.moduleId)).toEqual([
|
|
"announcements",
|
|
"signage",
|
|
]);
|
|
expect(registry.resolve("announcements", "0.1.0")).toBeUndefined();
|
|
expect(registry.resolveDisplay("calendar", "0.1.0")).toBeUndefined();
|
|
|
|
const signage = registry.resolveDisplay("signage", "0.1.0");
|
|
expect(signage).toMatchObject({
|
|
displayPermission: "signage.display.view",
|
|
moduleId: "signage",
|
|
name: "signage module",
|
|
version: "0.1.0",
|
|
});
|
|
expect(signage?.artifactPath).not.toBe(registry.resolve("signage", "0.1.0")?.artifactPath);
|
|
});
|
|
|
|
it("carries only discovery's canonical display artifact and declared permission metadata", async () => {
|
|
const modules = await discover([
|
|
{ display: 'throw new Error("display must remain inert");', id: "announcements" },
|
|
]);
|
|
const discovered = modules[0] as DiscoveredModule;
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
const published = registry.resolveDisplay("announcements", "0.1.0");
|
|
|
|
expect(published?.artifactPath).toBe(discovered.entrypoints.display?.artifactPath);
|
|
expect(path.isAbsolute(published?.artifactPath as string)).toBe(true);
|
|
expect(published?.displayPermission).toBe(discovered.entrypoints.display?.permission);
|
|
expect(published?.permissions.map((permission) => permission.id)).toEqual([
|
|
"announcements.display.view",
|
|
]);
|
|
});
|
|
|
|
it("requires exact identity and fails closed on malformed lookup values", async () => {
|
|
const modules = await discover([
|
|
{ display: "export default {};", id: "announcements", version: "1.4.2" },
|
|
]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(registry.resolveDisplay("announcements", "1.4.2")).toBeDefined();
|
|
for (const version of ["1.4.1", "latest", "^1.4.2", "1.4.2 ", ""]) {
|
|
expect(registry.resolveDisplay("announcements", version)).toBeUndefined();
|
|
}
|
|
for (const value of [undefined, null, 0, {}, [], Symbol("announcements")]) {
|
|
expect(registry.resolveDisplay(value, "1.4.2")).toBeUndefined();
|
|
expect(registry.resolveDisplay("announcements", value)).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("publishes a frozen snapshot and cannot expose a module omitted from its eligible input", async () => {
|
|
const modules = await discover([
|
|
{ display: "export default {};", id: "announcements" },
|
|
{ display: "export default {};", id: "signage" },
|
|
]);
|
|
// Production passes Core's eligible/started set. Omitting a blocked module at this boundary is
|
|
// sufficient to make both its metadata and artifact structurally unresolvable.
|
|
const registry = createModuleUiPublicationRegistry(
|
|
modules.filter((module) => module.manifest.id === "signage"),
|
|
);
|
|
|
|
expect(registry.resolveDisplay("announcements", "0.1.0")).toBeUndefined();
|
|
expect(registry.listDisplays().map((entry) => entry.moduleId)).toEqual(["signage"]);
|
|
expect(Object.isFrozen(registry.listDisplays())).toBe(true);
|
|
expect(Object.isFrozen(registry.listDisplays()[0])).toBe(true);
|
|
expect(Object.isFrozen(registry.listDisplays()[0]?.permissions)).toBe(true);
|
|
expect(registry.listDisplays()).toBe(registry.listDisplays());
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The generic contribution mechanism (ADR 0041), exercised through the exact relationship it exists
|
|
* for and with no platform knowledge of what any of it means: `signage` provides a capability,
|
|
* `announcements` contributes a renderer to it, and neither module imports the other.
|
|
*/
|
|
describe("module contribution publication", () => {
|
|
const signage = {
|
|
capabilities: [{ id: "signage.slide-renderer", version: "1.2.0" }],
|
|
id: "signage",
|
|
};
|
|
const contributor = (capabilityRange: string) => ({
|
|
contributions: [
|
|
{
|
|
capability: "signage.slide-renderer",
|
|
capabilityRange,
|
|
localName: "signage-slide",
|
|
version: "1.0.0",
|
|
},
|
|
],
|
|
id: "announcements",
|
|
});
|
|
|
|
it("publishes a contribution whose target capability is provided within range", async () => {
|
|
const modules = await discover([signage, contributor("^1.0.0")]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(registry.listContributions()).toHaveLength(1);
|
|
expect(
|
|
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
|
|
).toMatchObject({
|
|
capability: "signage.slide-renderer",
|
|
// The provider's declared version, so the range was checked against something real.
|
|
capabilityVersion: "1.2.0",
|
|
contributionId: "announcements.signage-slide",
|
|
moduleId: "announcements",
|
|
version: "1.0.0",
|
|
});
|
|
});
|
|
|
|
it("serves exactly the canonical artifact path discovery validated", async () => {
|
|
const modules = await discover([signage, contributor("^1.0.0")]);
|
|
const announcements = modules.find((module) => module.manifest.id === "announcements");
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
const published = registry.resolveContribution(
|
|
"announcements",
|
|
"0.1.0",
|
|
"announcements.signage-slide",
|
|
);
|
|
expect(published?.artifactPath).toBe(announcements?.contributions[0]?.artifactPath);
|
|
expect(path.isAbsolute(published?.artifactPath ?? "")).toBe(true);
|
|
});
|
|
|
|
it("withholds a contribution whose capability no installed module provides", async () => {
|
|
const modules = await discover([contributor("^1.0.0")]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(registry.listContributions()).toEqual([]);
|
|
expect(
|
|
registry.resolveContribution("announcements", "0.1.0", "announcements.signage-slide"),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("withholds a contribution whose provider is outside its declared compatible range", async () => {
|
|
// Fails closed: an incompatible renderer is absent rather than mounted and broken.
|
|
const modules = await discover([signage, contributor("^2.0.0")]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(registry.listContributions()).toEqual([]);
|
|
expect(registry.contributionsForModule("signage")).toEqual([]);
|
|
});
|
|
|
|
it("gives a consuming module exactly the contributions aimed at its own capabilities", async () => {
|
|
const modules = await discover([
|
|
signage,
|
|
{ capabilities: [{ id: "calendar.week-view", version: "1.0.0" }], id: "calendar" },
|
|
contributor("^1.0.0"),
|
|
]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(registry.contributionsForModule("signage").map((entry) => entry.contributionId)).toEqual(
|
|
["announcements.signage-slide"],
|
|
);
|
|
// A module that provides a different capability sees nothing, and the *contributing* module
|
|
// does not see its own contribution: it provides no capability it was aimed at.
|
|
expect(registry.contributionsForModule("calendar")).toEqual([]);
|
|
expect(registry.contributionsForModule("announcements")).toEqual([]);
|
|
});
|
|
|
|
it("fails a lookup closed for an unknown, mismatched, or non-string identity", async () => {
|
|
const modules = await discover([signage, contributor("^1.0.0")]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
|
|
expect(
|
|
registry.resolveContribution("announcements", "9.9.9", "announcements.signage-slide"),
|
|
).toBeUndefined();
|
|
expect(
|
|
registry.resolveContribution("signage", "0.1.0", "announcements.signage-slide"),
|
|
).toBeUndefined();
|
|
expect(
|
|
registry.resolveContribution("announcements", "0.1.0", "announcements.nope"),
|
|
).toBeUndefined();
|
|
expect(registry.resolveContribution(7, "0.1.0", "announcements.signage-slide")).toBeUndefined();
|
|
expect(registry.contributionsForCapability(undefined)).toEqual([]);
|
|
});
|
|
|
|
it("carries no domain vocabulary at all", async () => {
|
|
const modules = await discover([signage, contributor("^1.0.0")]);
|
|
const registry = createModuleUiPublicationRegistry(modules);
|
|
const published = registry.listContributions()[0];
|
|
|
|
// Core knows identity, version, compatibility, and one path. There is deliberately no payload
|
|
// schema, slot, ordering, or rendering hint — what a renderer draws is not the platform's.
|
|
expect(Object.keys(published ?? {}).sort()).toEqual([
|
|
"artifactPath",
|
|
"capability",
|
|
"capabilityVersion",
|
|
"contributionId",
|
|
"moduleId",
|
|
"moduleVersion",
|
|
"version",
|
|
]);
|
|
});
|
|
});
|