// @vitest-environment happy-dom import { ModuleUiContractError, type ModuleUiHostContext } from "@ekklesia/module-ui-sdk"; import { describe, expect, it } from "vitest"; import { loadModuleUi, ModuleUiHostError, type ModuleUiImporter } from "../src/index.js"; /** * A browser-loadable URL for a module built from source text. * * `data:` URLs are genuine module URLs that both browsers and Node import natively, so these tests * exercise the loader's **default** `import(url)` against a real URL rather than a filesystem path. * That matters: Core resolves `entrypoints.ui` to a server filesystem path, and a loader proven only * against such a path would not prove anything a browser host could rely on (ADR 0024). */ function moduleUrl(source: string): string { return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`; } const validArtifact = (options: { id?: string; version?: string; cleanup?: boolean } = {}) => ` export default { id: ${JSON.stringify(options.id ?? "announcements")}, version: ${JSON.stringify(options.version ?? "1.2.0")}, mount(container, context) { globalThis.__mounts ??= []; globalThis.__mounts.push({ moduleId: context.moduleId, container }); container.textContent = "mounted:" + context.moduleId; void context.api.request("posts"); ${ options.cleanup === false ? "" : `return () => { globalThis.__unmounts ??= []; globalThis.__unmounts.push(context.moduleId); container.textContent = ""; };` } }, }; `; function hostContext(requested: string[], moduleId = "announcements"): ModuleUiHostContext { return { moduleId, api: { async request(path) { requested.push(path); return new Response(null, { status: 200 }); }, }, principal: { kind: "anonymous" }, permissions: { has: () => true }, }; } function container(): Element { const element = document.createElement("div"); document.body.append(element); return element; } const source = { moduleId: "announcements", version: "1.2.0" }; describe("generic module UI host loader", () => { it("imports a browser URL with the default importer, mounts, and reports identity", async () => { const element = container(); const requested: string[] = []; const mounted = await loadModuleUi({ source: { ...source, url: moduleUrl(validArtifact()) }, container: element, context: hostContext(requested), }); expect(mounted.moduleId).toBe("announcements"); expect(mounted.version).toBe("1.2.0"); expect(mounted.mounted).toBe(true); expect(element.textContent).toBe("mounted:announcements"); }); it("passes the host's own container and context straight through", async () => { const element = container(); const requested: string[] = []; const context = hostContext(requested); await loadModuleUi({ source: { ...source, url: moduleUrl(validArtifact()) }, container: element, context, }); const mounts = (globalThis as { __mounts?: { moduleId: string; container: Element }[] }) .__mounts; expect(mounts?.at(-1)?.container).toBe(element); expect(mounts?.at(-1)?.moduleId).toBe("announcements"); }); it("keeps the module's API calls relative — the loader adds no URL knowledge", async () => { const element = container(); const requested: string[] = []; await loadModuleUi({ source: { ...source, url: moduleUrl(validArtifact()) }, container: element, context: hostContext(requested), }); expect(requested).toEqual(["posts"]); expect(requested.join(" ")).not.toContain("/api/v1/modules"); }); it("runs the module's cleanup on unmount without touching the host container", async () => { const element = container(); const parent = element.parentElement; const mounted = await loadModuleUi({ source: { ...source, url: moduleUrl(validArtifact()) }, container: element, context: hostContext([]), }); await mounted.unmount(); expect(mounted.mounted).toBe(false); expect(element.textContent).toBe(""); // The host owns the container; unmounting a module never removes it. expect(element.parentElement).toBe(parent); expect(document.body.contains(element)).toBe(true); }); it("is idempotent: a second unmount does nothing", async () => { const element = container(); const before = ((globalThis as { __unmounts?: string[] }).__unmounts ?? []).length; const mounted = await loadModuleUi({ source: { ...source, url: moduleUrl(validArtifact()) }, container: element, context: hostContext([]), }); await mounted.unmount(); await mounted.unmount(); await mounted.unmount(); const after = ((globalThis as { __unmounts?: string[] }).__unmounts ?? []).length; expect(after - before).toBe(1); expect(mounted.mounted).toBe(false); }); it("unmounts safely when the module returned no cleanup function", async () => { const element = container(); const mounted = await loadModuleUi({ source: { ...source, url: moduleUrl(validArtifact({ cleanup: false })) }, container: element, context: hostContext([]), }); await expect(mounted.unmount()).resolves.toBeUndefined(); expect(mounted.mounted).toBe(false); }); it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => { const element = container(); const artifact = ` export default { id: "announcements", version: "1.2.0", mount() { return () => { globalThis.__cleanupCalls = (globalThis.__cleanupCalls ?? 0) + 1; throw new Error("cleanup exploded"); }; }, };`; const mounted = await loadModuleUi({ source: { ...source, url: moduleUrl(artifact) }, container: element, context: hostContext([]), }); await expect(mounted.unmount()).rejects.toThrow("cleanup exploded"); expect(mounted.mounted).toBe(false); await expect(mounted.unmount()).resolves.toBeUndefined(); expect((globalThis as { __cleanupCalls?: number }).__cleanupCalls).toBe(1); }); it("supports two independent instances in two containers", async () => { const first = container(); const second = container(); const url = moduleUrl(validArtifact()); const a = await loadModuleUi({ source: { ...source, url }, container: first, context: hostContext([]), }); const b = await loadModuleUi({ source: { ...source, url }, container: second, context: hostContext([]), }); expect(first.textContent).toBe("mounted:announcements"); expect(second.textContent).toBe("mounted:announcements"); await a.unmount(); // Each handle owns its own cleanup state; unmounting one leaves the other alone. expect(a.mounted).toBe(false); expect(b.mounted).toBe(true); expect(first.textContent).toBe(""); expect(second.textContent).toBe("mounted:announcements"); }); describe("rejects an artifact before mounting it", () => { it("when the module id does not match the manifest", async () => { const element = container(); const url = moduleUrl(validArtifact({ id: "calendar" })); await expect( loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }), ).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" }); expect(element.textContent).toBe(""); }); it("when the version does not match the manifest", async () => { const element = container(); const url = moduleUrl(validArtifact({ version: "1.1.0" })); await expect( loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }), ).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" }); expect(element.textContent).toBe(""); }); it("when there is no default export", async () => { const element = container(); const url = moduleUrl("export const ui = {};"); await expect( loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }), ).rejects.toMatchObject({ code: "MODULE_UI_EXPORT_MISSING" }); }); it("when the default export is not a module UI definition", async () => { const element = container(); const url = moduleUrl('export default { id: "announcements", version: "1.2.0" };'); const failure = loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]), }); await expect(failure).rejects.toBeInstanceOf(ModuleUiContractError); await expect(failure).rejects.toMatchObject({ code: "MODULE_UI_DEFINITION_INVALID" }); }); }); it("reports an unreachable artifact as an import failure, with the cause attached", async () => { const element = container(); try { await loadModuleUi({ source: { ...source, url: "data:text/javascript,this is not(valid javascript" }, container: element, context: hostContext([]), }); expect.unreachable("expected the import to fail"); } catch (error) { expect(error).toBeInstanceOf(ModuleUiHostError); expect((error as ModuleUiHostError).code).toBe("MODULE_UI_IMPORT_FAILED"); expect((error as ModuleUiHostError).message).not.toContain("data:"); expect((error as ModuleUiHostError).cause).toBeDefined(); } }); it("reports a throwing mount as a mount failure and registers no cleanup", async () => { const element = container(); const url = moduleUrl(` export default { id: "announcements", version: "1.2.0", mount(container) { container.textContent = "partial"; throw new Error("mount exploded"); }, };`); try { await loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]), }); expect.unreachable("expected mount to fail"); } catch (error) { expect(error).toBeInstanceOf(ModuleUiHostError); expect((error as ModuleUiHostError).code).toBe("MODULE_UI_MOUNT_FAILED"); expect((error as ModuleUiHostError).message).not.toContain("exploded"); expect((error as { cause?: Error }).cause?.message).toBe("mount exploded"); } // No handle exists, so nothing can claim the module is mounted. The container is left exactly // as the module left it: the host owns it and decides what a failed mount looks like. expect(element.textContent).toBe("partial"); }); it("accepts a host-supplied importer for bundlers that need their own escape hatch", async () => { const element = container(); const seen: string[] = []; const importModule: ModuleUiImporter = async (url) => { seen.push(url); return { default: { id: "announcements", version: "1.2.0", mount: () => undefined } }; }; const mounted = await loadModuleUi({ source: { ...source, url: "https://assets.example.test/modules/announcements/ui.js" }, container: element, context: hostContext([]), importModule, }); expect(seen).toEqual(["https://assets.example.test/modules/announcements/ui.js"]); expect(mounted.mounted).toBe(true); }); });