// @vitest-environment happy-dom import { MODULE_CONTRIBUTION_ABI_VERSION, ModuleContributionError } from "@ekklesia/module-ui-sdk"; import { describe, expect, it } from "vitest"; import { loadModuleContribution, MODULE_UI_ISOLATION_ATTRIBUTE, MODULE_UI_ISOLATION_ROOT_ATTRIBUTE, MODULE_UI_ISOLATION_STYLE_ATTRIBUTE, ModuleUiHostError, } from "../src/index.js"; function moduleUrl(source: string): string { return `data:text/javascript;base64,${Buffer.from(source, "utf8").toString("base64")}`; } const contributionArtifact = ( options: { id?: string; version?: string; styles?: string; updatable?: boolean; cleanup?: boolean; mountThrows?: boolean; updateThrows?: boolean; } = {}, ) => ` export default { abi: ${MODULE_CONTRIBUTION_ABI_VERSION}, id: ${JSON.stringify(options.id ?? "announcements.signage-slide")}, version: ${JSON.stringify(options.version ?? "1.0.0")}, ${options.styles === undefined ? "" : `styles: ${JSON.stringify(options.styles)},`} mount(container, context) { ${options.mountThrows ? 'throw new Error("mount exploded");' : ""} globalThis.__contributionMounts ??= []; globalThis.__contributionMounts.push({ container, contributionId: context.contributionId }); container.textContent = "slide:" + JSON.stringify(context.input); return { ${ options.updatable === false ? "" : `update(input) { ${options.updateThrows ? 'throw new Error("update exploded");' : ""} container.textContent = "slide:" + JSON.stringify(input); },` } ${ options.cleanup === false ? "" : `unmount() { globalThis.__contributionUnmounts = (globalThis.__contributionUnmounts ?? 0) + 1; container.textContent = ""; },` } }; }, }; `; function container(): Element { const element = document.createElement("div"); document.body.append(element); return element; } function renderedText(element: Element): string { const shadowHost = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`); return ( shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ?? "" ); } const source = { contributionId: "announcements.signage-slide", version: "1.0.0" }; describe("generic contribution loader", () => { it("mounts a contribution with the consumer's input, in its own isolated root", async () => { const element = container(); const mounted = await loadModuleContribution({ container: element, input: { slide: 1 }, source: { ...source, url: moduleUrl(contributionArtifact({ styles: ".slide{color:red}" })) }, }); expect(mounted.contributionId).toBe("announcements.signage-slide"); expect(mounted.version).toBe("1.0.0"); expect(mounted.mounted).toBe(true); expect(renderedText(element)).toBe('slide:{"slide":1}'); // Same isolation as every other browser artifact (ADR 0040): the renderer's styles cannot reach // the consuming module's own screen around it. const shadowRoot = element.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`)?.shadowRoot; expect( shadowRoot?.querySelector(`style[${MODULE_UI_ISOLATION_STYLE_ATTRIBUTE}]`)?.textContent, ).toContain(".slide{color:red}"); expect(element.textContent).toBe(""); }); it("gives the renderer its own id and nothing else from the platform", async () => { const element = container(); await loadModuleContribution({ container: element, input: null, source: { ...source, url: moduleUrl(contributionArtifact()) }, }); const mounts = ( globalThis as { __contributionMounts?: { contributionId: string; container: Element }[] } ).__contributionMounts; expect(mounts?.at(-1)?.contributionId).toBe("announcements.signage-slide"); // The isolated root, never the consumer's own element. expect(mounts?.at(-1)?.container).not.toBe(element); }); it("updates in place without remounting", async () => { const element = container(); const before = (globalThis as { __contributionMounts?: unknown[] }).__contributionMounts ?.length; const mounted = await loadModuleContribution({ container: element, input: { slide: 1 }, source: { ...source, url: moduleUrl(contributionArtifact()) }, }); await mounted.update({ slide: 2 }); expect(renderedText(element)).toBe('slide:{"slide":2}'); // One mount, two renders: exactly the property a long-running screen needs. expect( ((globalThis as { __contributionMounts?: unknown[] }).__contributionMounts?.length ?? 0) - (before ?? 0), ).toBe(1); }); it("tells a consumer to remount when the renderer cannot update in place", async () => { const element = container(); const mounted = await loadModuleContribution({ container: element, input: { slide: 1 }, source: { ...source, url: moduleUrl(contributionArtifact({ updatable: false })) }, }); await expect(mounted.update({ slide: 2 })).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_NOT_UPDATABLE", }); }); it("reports a throwing update as a sanitized failure", async () => { const element = container(); const mounted = await loadModuleContribution({ container: element, input: { slide: 1 }, source: { ...source, url: moduleUrl(contributionArtifact({ updateThrows: true })) }, }); try { await mounted.update({ slide: 2 }); expect.unreachable("expected the update to fail"); } catch (error) { expect(error).toBeInstanceOf(ModuleUiHostError); expect((error as ModuleUiHostError).code).toBe("MODULE_CONTRIBUTION_UPDATE_FAILED"); expect((error as ModuleUiHostError).message).not.toContain("exploded"); expect((error as { cause?: Error }).cause?.message).toBe("update exploded"); } }); it("unmounts deterministically, idempotently, and removes the isolated root", async () => { const element = container(); const before = (globalThis as { __contributionUnmounts?: number }).__contributionUnmounts ?? 0; const mounted = await loadModuleContribution({ container: element, input: { slide: 1 }, source: { ...source, url: moduleUrl(contributionArtifact({ styles: ".slide{}" })) }, }); await mounted.unmount(); await mounted.unmount(); expect(mounted.mounted).toBe(false); expect(element.children).toHaveLength(0); expect( ((globalThis as { __contributionUnmounts?: number }).__contributionUnmounts ?? 0) - before, ).toBe(1); await expect(mounted.update({ slide: 2 })).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_UNKNOWN", }); }); it("unmounts safely when the renderer registered no cleanup", async () => { const element = container(); const mounted = await loadModuleContribution({ container: element, input: null, source: { ...source, url: moduleUrl(contributionArtifact({ cleanup: false })) }, }); await expect(mounted.unmount()).resolves.toBeUndefined(); expect(element.children).toHaveLength(0); }); it("refuses input that is not genuinely JSON, before importing anything", async () => { const element = container(); let imported = false; for (const input of [ new Date() as unknown as null, (() => undefined) as unknown as null, Number.NaN as unknown as null, { nested: undefined } as unknown as null, ]) { await expect( loadModuleContribution({ container: element, input, importModule: async () => { imported = true; return {}; }, source: { ...source, url: "unused" }, }), ).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_INPUT_INVALID" }); } const cyclic: Record = {}; cyclic.self = cyclic; await expect( loadModuleContribution({ container: element, input: cyclic as never, source: { ...source, url: "unused" }, }), ).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_INPUT_INVALID" }); expect(imported).toBe(false); expect(element.children).toHaveLength(0); }); it("never echoes the consumer's domain data in a failure", async () => { const element = container(); try { await loadModuleContribution({ container: element, input: { secretHeading: new Date() } as unknown as null, source: { ...source, url: "unused" }, }); expect.unreachable("expected the input to be refused"); } catch (error) { expect(error).toBeInstanceOf(ModuleContributionError); expect(String((error as Error).message)).not.toContain("secretHeading"); expect(String((error as Error).stack)).not.toContain("secretHeading"); } }); it.each([ [ "a different contribution id", contributionArtifact({ id: "calendar.week" }), "MODULE_CONTRIBUTION_IDENTITY_MISMATCH", ], [ "a different version", contributionArtifact({ version: "2.0.0" }), "MODULE_CONTRIBUTION_IDENTITY_MISMATCH", ], [ "a different contribution ABI", 'export default { abi: 99, id: "announcements.signage-slide", version: "1.0.0", mount() {} };', "MODULE_CONTRIBUTION_ABI_UNSUPPORTED", ], ["no default export", "export const contribution = {};", "MODULE_CONTRIBUTION_EXPORT_MISSING"], [ "a malformed definition", `export default { abi: ${MODULE_CONTRIBUTION_ABI_VERSION}, id: "announcements.signage-slide" };`, "MODULE_CONTRIBUTION_DEFINITION_INVALID", ], ])("refuses %s before mounting", async (_label, artifact, code) => { const element = container(); await expect( loadModuleContribution({ container: element, input: null, source: { ...source, url: moduleUrl(artifact) }, }), ).rejects.toMatchObject({ code }); expect(element.children).toHaveLength(0); }); it("reports a throwing mount as a sanitized failure and leaves nothing behind", async () => { const element = container(); try { await loadModuleContribution({ container: element, input: null, source: { ...source, url: moduleUrl(contributionArtifact({ mountThrows: true })) }, }); expect.unreachable("expected the 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(element.children).toHaveLength(0); }); it("reports an unreachable artifact as an import failure with no URL in the message", async () => { const element = container(); try { await loadModuleContribution({ container: element, input: null, source: { ...source, url: "data:text/javascript,this is not(valid javascript" }, }); expect.unreachable("expected the import to fail"); } catch (error) { expect((error as ModuleUiHostError).code).toBe("MODULE_UI_IMPORT_FAILED"); expect((error as ModuleUiHostError).message).not.toContain("data:"); } }); it("keeps two mounted contributions independent", async () => { const first = container(); const second = container(); const url = moduleUrl(contributionArtifact()); const a = await loadModuleContribution({ container: first, input: { slide: "a" }, source: { ...source, url }, }); const b = await loadModuleContribution({ container: second, input: { slide: "b" }, source: { ...source, url }, }); await a.unmount(); expect(a.mounted).toBe(false); expect(b.mounted).toBe(true); expect(first.children).toHaveLength(0); expect(renderedText(second)).toBe('slide:{"slide":"b"}'); }); });