// @vitest-environment happy-dom import { MODULE_UI_ABI_VERSION, ModuleUiContractError, type ModuleUiHostContext, NO_MODULE_UI_CONTRIBUTIONS, } from "@ekklesia/module-ui-sdk"; import { describe, expect, it } from "vitest"; import { loadModuleUi, MODULE_UI_ISOLATION_ATTRIBUTE, MODULE_UI_ISOLATION_ROOT_ATTRIBUTE, MODULE_UI_ISOLATION_STYLE_ATTRIBUTE, 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; styles?: string } = {}, ) => ` export default { abi: ${MODULE_UI_ABI_VERSION}, id: ${JSON.stringify(options.id ?? "announcements")}, version: ${JSON.stringify(options.version ?? "1.2.0")}, ${options.styles === undefined ? "" : `styles: ${JSON.stringify(options.styles)},`} 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 { contributions: NO_MODULE_UI_CONTRIBUTIONS, moduleId, api: { async request(path) { requested.push(path); return new Response(null, { status: 200 }); }, }, principal: { kind: "anonymous" }, permissions: { has: () => true }, realtime: { subscribe: () => ({ close: () => undefined }) }, }; } function container(): Element { const element = document.createElement("div"); document.body.append(element); return element; } /** * What the artifact actually rendered, reached the way a host's own diagnostics would: through the * open shadow root the loader created. The host's container itself holds only the loader's one * wrapper element, which is exactly the isolation ADR 0040 introduced. */ function artifactRoot(element: Element): Element | null { const shadowHost = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`); return shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`) ?? null; } function artifactText(element: Element): string { return artifactRoot(element)?.textContent ?? ""; } 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(artifactText(element)).toBe("mounted:announcements"); }); it("mounts the artifact into an isolated root rather than the host's own container", async () => { const element = container(); const mounted = await loadModuleUi({ source: { ...source, url: moduleUrl(validArtifact()) }, container: element, context: hostContext([]), }); // The host's container gained exactly one child: the loader's shadow host. expect(element.children).toHaveLength(1); const shadowHost = element.firstElementChild as Element; expect(shadowHost.hasAttribute(MODULE_UI_ISOLATION_ATTRIBUTE)).toBe(true); expect(shadowHost.shadowRoot).toBe(mounted.shadowRoot); // Open, deliberately: this is a correctness boundary for styles, not a sandbox — an installed // module is trusted code in the host's own realm and could reach `document` regardless. expect(mounted.shadowRoot.mode).toBe("open"); // The artifact's DOM is not reachable from the host's own document tree. expect(element.textContent).toBe(""); }); it("installs a declared stylesheet inside the isolated root, exactly once", async () => { const element = container(); const mounted = await loadModuleUi({ source: { ...source, url: moduleUrl(validArtifact({ styles: ".posts{color:red}" })) }, container: element, context: hostContext([]), }); const styles = mounted.shadowRoot.querySelectorAll( `style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`, ); expect(styles).toHaveLength(1); expect(styles[0]?.textContent).toContain(".posts{color:red}"); // The host document never receives the artifact's rules. expect(document.head.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)).toBeNull(); }); it("installs the host's base rules before the artifact's own, so an artifact can override them", async () => { const element = container(); const mounted = await loadModuleUi({ source: { ...source, url: moduleUrl(validArtifact({ styles: ":host{display:block}" })) }, container: element, context: hostContext([]), }); const stylesheet = mounted.shadowRoot.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`) ?.textContent ?? ""; expect(stylesheet.indexOf(":host{display:contents}")).toBeLessThan( stylesheet.indexOf(":host{display:block}"), ); }); it("still mounts an artifact that declares no stylesheet", async () => { const element = container(); const mounted = await loadModuleUi({ source: { ...source, url: moduleUrl(validArtifact()) }, container: element, context: hostContext([]), }); expect(artifactText(element)).toBe("mounted:announcements"); expect( mounted.shadowRoot.querySelectorAll(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`), ).toHaveLength(1); }); it("never accumulates styles across repeated mounts of the same container", async () => { const element = container(); const url = moduleUrl(validArtifact({ styles: ".posts{color:red}" })); for (let attempt = 0; attempt < 3; attempt += 1) { const mounted = await loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]), }); expect( mounted.shadowRoot.querySelectorAll(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`), ).toHaveLength(1); await mounted.unmount(); expect(element.children).toHaveLength(0); } }); it("passes the isolated mount target and the host's 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; // The artifact receives the loader's isolated root, never the host's own element. expect(mounts?.at(-1)?.container).toBe(artifactRoot(element)); expect(mounts?.at(-1)?.container).not.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 and removes the isolated root 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); // The artifact's root, and with it its styles, are gone. expect(element.children).toHaveLength(0); // 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); expect(element.children).toHaveLength(0); }); it("marks an instance unmounted even when its cleanup throws, so it never re-runs", async () => { const element = container(); const artifact = ` export default { abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0", styles: ".x{color:red}", 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); // A module that fails to clean up must not leave its stylesheet installed in the page. expect(element.children).toHaveLength(0); 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(artifactText(first)).toBe("mounted:announcements"); expect(artifactText(second)).toBe("mounted:announcements"); expect(a.shadowRoot).not.toBe(b.shadowRoot); 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.children).toHaveLength(0); expect(artifactText(second)).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.children).toHaveLength(0); }); 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.children).toHaveLength(0); }); it("when it was built against the previous mount ABI", async () => { const element = container(); const url = moduleUrl( 'export default { id: "announcements", version: "1.2.0", mount() {} };', ); await expect( loadModuleUi({ source: { ...source, url }, container: element, context: hostContext([]) }), ).rejects.toMatchObject({ code: "MODULE_UI_ABI_UNSUPPORTED" }); expect(element.children).toHaveLength(0); }); 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 { abi: ${MODULE_UI_ABI_VERSION}, 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, registers no cleanup, and removes the root", async () => { const element = container(); const url = moduleUrl(` export default { abi: ${MODULE_UI_ABI_VERSION}, id: "announcements", version: "1.2.0", styles: ".x{color:red}", 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 — and a failed mount leaves // neither partial DOM nor an installed stylesheet behind in the host's container. expect(element.children).toHaveLength(0); }); it("reports an environment with no Shadow DOM as an isolation failure, never as an unisolated mount", async () => { const element = container(); const detached = { ownerDocument: document, appendChild: element.appendChild.bind(element), } as unknown as Element; // A container whose created element cannot take a shadow root: the loader must refuse rather // than fall back to mounting the artifact — and its styles — straight into the host's page. const noShadow = new Proxy(detached, { get(target, property, receiver) { if (property === "ownerDocument") { return { createElement: () => ({ setAttribute: () => undefined }), }; } return Reflect.get(target, property, receiver); }, }); await expect( loadModuleUi({ source: { ...source, url: moduleUrl(validArtifact()) }, container: noShadow, context: hostContext([]), }), ).rejects.toMatchObject({ code: "MODULE_UI_ISOLATION_UNAVAILABLE" }); expect(element.children).toHaveLength(0); }); 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: { abi: MODULE_UI_ABI_VERSION, 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); }); });