297 lines
12 KiB
TypeScript
297 lines
12 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;
|
|
}
|
|
|
|
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,
|
|
},
|
|
}),
|
|
},
|
|
id: fixture.id,
|
|
manifestVersion: 6,
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** 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());
|
|
});
|
|
});
|