Files
Ekklesia/packages/module-ui-sdk/tests/module-ui-contract.test.ts
T

304 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import {
defineModuleUi,
isModuleUiDefinition,
MODULE_UI_ABI_VERSION,
ModuleUiContractError,
NO_MODULE_UI_CONTRIBUTIONS,
type ModuleUiApiClient,
type ModuleUiDefinition,
type ModuleUiDefinitionInput,
type ModuleUiHostContext,
type ModuleUiRealtimeClient,
parseModuleUiDefinition,
parseModuleUiExport,
} from "../src/index.js";
function definitionInput(): ModuleUiDefinitionInput {
return { id: "announcements", version: "0.1.0", mount: (): void => undefined };
}
/** What a compiled artifact actually default-exports: the author's declaration plus the ABI marker
* `defineModuleUi` stamps on it (ADR 0040). */
function validDefinition(): ModuleUiDefinition {
return { abi: MODULE_UI_ABI_VERSION, ...definitionInput() };
}
/** The contract treats the container as an opaque host-supplied mount target, so a plain object
* with the one property the fixture touches is a faithful stand-in. No DOM is required to prove the
* contract, and this package deliberately does not take a DOM dependency to obtain one. */
const noop = (): void => undefined;
const abi = MODULE_UI_ABI_VERSION;
const noopRealtime: ModuleUiRealtimeClient = Object.freeze({
subscribe: () => Object.freeze({ close: noop }),
});
function fakeContainer(): Element & { textContent: string } {
return { textContent: "" } as unknown as Element & { textContent: string };
}
describe("module UI definition contract", () => {
it("accepts, stamps the ABI on, and freezes a valid definition", () => {
const definition = defineModuleUi(definitionInput());
expect(definition.id).toBe("announcements");
expect(definition.version).toBe("0.1.0");
// Stamped by the SDK the artifact compiled against, never written by the author, so it can
// never drift from the contract the artifact was actually built for.
expect(definition.abi).toBe(MODULE_UI_ABI_VERSION);
expect(definition.styles).toBeUndefined();
expect(Object.isFrozen(definition)).toBe(true);
});
it("carries a declared stylesheet through as the artifact's own compiled CSS text", () => {
const definition = defineModuleUi({ ...definitionInput(), styles: ".a{color:red}" });
expect(definition.styles).toBe(".a{color:red}");
});
it("rejects an artifact built against a different mount ABI, with its own code", () => {
// The whole point of the marker: a version-1 artifact is reported as "rebuild your module",
// not as a malformed definition a developer would hunt for a typo in.
const versionOne = { id: "announcements", version: "0.1.0", mount: noop };
expect(isModuleUiDefinition(versionOne)).toBe(false);
try {
parseModuleUiDefinition(versionOne);
expect.unreachable("expected a contract error");
} catch (error) {
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_ABI_UNSUPPORTED");
}
expect(() =>
parseModuleUiDefinition({ ...versionOne, abi: MODULE_UI_ABI_VERSION + 1 }),
).toThrowError(expect.objectContaining({ code: "MODULE_UI_ABI_UNSUPPORTED" }));
});
it.each<[string, unknown]>([
["a blank stylesheet", " "],
["a non-string stylesheet", 7],
["an array of stylesheets", [".a{}"]],
])("rejects %s", (_label, styles) => {
const candidate = { ...validDefinition(), styles };
expect(isModuleUiDefinition(candidate)).toBe(false);
expect(() => parseModuleUiDefinition(candidate)).toThrowError(
expect.objectContaining({ code: "MODULE_UI_STYLES_INVALID" }),
);
});
it.each<[string, unknown]>([
["a missing id", { abi, version: "0.1.0", mount: noop }],
["a blank id", { abi, id: " ", version: "0.1.0", mount: noop }],
["a non-string id", { abi, id: 7, version: "0.1.0", mount: noop }],
["a missing version", { abi, id: "announcements", mount: noop }],
["a blank version", { abi, id: "announcements", version: "", mount: noop }],
["a missing mount", { abi, id: "announcements", version: "0.1.0" }],
["a non-function mount", { abi, id: "announcements", version: "0.1.0", mount: "render" }],
[
"an unknown property",
{ abi, id: "announcements", version: "0.1.0", mount: noop, routes: [] },
],
])("rejects a definition with %s", (_label, candidate) => {
expect(isModuleUiDefinition(candidate)).toBe(false);
expect(() => parseModuleUiDefinition(candidate)).toThrowError(ModuleUiContractError);
});
it.each<unknown>([
null,
undefined,
42,
"ui",
[],
noop,
])("rejects a non-object definition %s", (candidate) => {
expect(isModuleUiDefinition(candidate)).toBe(false);
});
it("reports a malformed definition with a stable, sanitized code", () => {
try {
parseModuleUiDefinition({ abi, id: "announcements" });
expect.unreachable("expected a contract error");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiContractError);
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_DEFINITION_INVALID");
expect((error as ModuleUiContractError).stack).not.toContain("announcements");
}
});
it("rejects an artifact namespace with no default export", () => {
expect(() =>
parseModuleUiExport({ ui: validDefinition() }, { id: "a", version: "1.0.0" }),
).toThrowError(ModuleUiContractError);
try {
parseModuleUiExport({}, { id: "a", version: "1.0.0" });
} catch (error) {
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_EXPORT_MISSING");
}
});
it.each([
["id", { id: "calendar", version: "0.1.0" }],
["version", { id: "announcements", version: "0.2.0" }],
])("rejects an artifact whose %s does not match the manifest", (_label, expected) => {
try {
parseModuleUiExport({ default: defineModuleUi(validDefinition()) }, expected);
expect.unreachable("expected an identity mismatch");
} catch (error) {
expect((error as ModuleUiContractError).code).toBe("MODULE_UI_IDENTITY_MISMATCH");
}
});
it("accepts an artifact whose identity matches the manifest exactly", () => {
const definition = parseModuleUiExport(
{ default: defineModuleUi(validDefinition()) },
{ id: "announcements", version: "0.1.0" },
);
expect(definition.id).toBe("announcements");
});
});
/**
* The portability proof. One artifact, imported once, mounted by two hosts that share nothing but
* this package's public interface. If module UI ever hardcoded Ekklesia's `/api/v1/modules/<id>`
* prefix, the standalone host below could not serve it.
*/
describe("the same module UI artifact hosted by two different hosts", () => {
interface RecordingHost {
readonly name: string;
readonly requestedUrls: string[];
readonly context: ModuleUiHostContext;
}
/** Mirrors how Ekklesia mounts module routes today: `/api/v1/modules/<module-id>/<path>`. */
function ekklesiaHost(moduleId: string, canPublish: boolean): RecordingHost {
const requestedUrls: string[] = [];
const api: ModuleUiApiClient = {
async request(path) {
const url = `/api/v1/modules/${moduleId}/${path.replace(/^\//u, "")}`;
requestedUrls.push(url);
return new Response(null, { status: 200 });
},
};
return {
name: "ekklesia",
requestedUrls,
context: {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: undefined,
moduleId,
api,
principal: { kind: "anonymous" },
permissions: { has: () => canPublish },
realtime: noopRealtime,
},
};
}
/** A standalone host that serves exactly one module from a different origin and a different
* path shape, and never runs Core at all. */
function standaloneHost(moduleId: string, canPublish: boolean): RecordingHost {
const requestedUrls: string[] = [];
const api: ModuleUiApiClient = {
async request(path) {
const url = `https://announcements.example.org/api/${path.replace(/^\//u, "")}`;
requestedUrls.push(url);
return new Response(null, { status: 200 });
},
};
return {
name: "standalone",
requestedUrls,
context: {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
display: { url: "https://announcements.example.org/display" },
moduleId,
api,
principal: { kind: "anonymous" },
permissions: { has: () => canPublish },
realtime: noopRealtime,
},
};
}
async function loadFixtureUi(): Promise<ModuleUiDefinition> {
// Each host performs its own dynamic import; this package never imports an artifact itself.
const namespace = (await import("./fixtures/announcements-ui.js")) as unknown;
return parseModuleUiExport(namespace, { id: "announcements", version: "0.1.0" });
}
beforeEach(async () => {
const { trace } = await import("./fixtures/announcements-ui.js");
trace.mounted.length = 0;
trace.unmounted.length = 0;
trace.requestedPaths.length = 0;
trace.canPublish.length = 0;
});
it("validates a real imported artifact's export against its manifest identity", async () => {
const definition = await loadFixtureUi();
expect(definition.id).toBe("announcements");
expect(definition.version).toBe("0.1.0");
expect(typeof definition.mount).toBe("function");
});
it("mounts and unmounts identically under both hosts, and resolves URLs only in the host", async () => {
const definition = await loadFixtureUi();
const { trace } = await import("./fixtures/announcements-ui.js");
for (const host of [
ekklesiaHost("announcements", true),
standaloneHost("announcements", true),
]) {
const container = fakeContainer();
const unmount = await definition.mount(container, host.context);
expect(container.textContent).toBe("announcements:200:editor");
expect(typeof unmount).toBe("function");
await (unmount as () => Promise<void>)();
expect(container.textContent).toBe("");
}
// The module asked for the same relative path both times and never built a URL itself.
expect(trace.requestedPaths).toEqual(["posts", "posts"]);
expect(trace.mounted).toEqual(["announcements", "announcements"]);
expect(trace.unmounted).toEqual(["announcements", "announcements"]);
});
it("lets each host resolve the module's relative path to its own URL shape", async () => {
const definition = await loadFixtureUi();
const ekklesia = ekklesiaHost("announcements", true);
const standalone = standaloneHost("announcements", true);
await definition.mount(fakeContainer(), ekklesia.context);
await definition.mount(fakeContainer(), standalone.context);
expect(ekklesia.requestedUrls).toEqual(["/api/v1/modules/announcements/posts"]);
expect(standalone.requestedUrls).toEqual(["https://announcements.example.org/api/posts"]);
});
it("renders a different presentation when the host reports a permission is absent", async () => {
const definition = await loadFixtureUi();
const container = fakeContainer();
await definition.mount(container, ekklesiaHost("announcements", false).context);
expect(container.textContent).toBe("announcements:200:reader");
});
it("treats a denied permission hint as presentation only, never as enforcement", async () => {
// The host reports the permission as absent. The module still reaches its API — nothing in this
// contract blocks the request, and nothing in it could. Server-side `requirePermission` remains
// the only authority, exactly as it is for any other caller.
const definition = await loadFixtureUi();
const host = ekklesiaHost("announcements", false);
await definition.mount(fakeContainer(), host.context);
expect(host.context.permissions.has("announcements.posts.publish")).toBe(false);
expect(host.requestedUrls).toEqual(["/api/v1/modules/announcements/posts"]);
});
});