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([ 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/` * 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//`. */ 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 { // 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)(); 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"]); }); });