// @vitest-environment happy-dom import { MODULE_CONTRIBUTION_ABI_VERSION, MODULE_UI_ABI_VERSION, type ModuleContributionContext, type ModuleContributionInput, type ModuleUiHostContext, type MountedModuleContribution, } from "@ekklesia/module-ui-sdk"; import { act, type ReactElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { beforeEach, describe, expect, it } from "vitest"; import type { EkklesiaHostConfig, HostModuleUiDescriptor } from "../src/host-protocol.js"; import { parseDashboardNavigation } from "../src/host-protocol.js"; import { createModuleUiContext } from "../src/module-context.js"; import { isolatedText } from "./support/isolated-dom.js"; declare global { // eslint-disable-next-line no-var var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; } const apiBaseUrl = "https://ekklesia.example.test"; /** * The generic browser-contribution mechanism (ADR 0041), driven through exactly the relationship it * exists for: * * ```text * signage provides the capability signage.slide-renderer, and owns the input contract * announcements contributes a React renderer for it * signage mounts that renderer in a slot it owns, updates it, and unmounts it * ``` * * Neither module imports the other, and neither imports the platform's publication routing. The * consumer asks the **host** what was contributed and asks the host to mount it; the URL is the * server's business and never reaches module code. Nothing in this file teaches Ekklesia what a * "slide" is: the payload is opaque JSON that travels between the two modules. */ /** The consuming module's own published input contract — its business, never the platform's. */ interface SlideInput extends Record { readonly headline: string; readonly seconds: number; } interface Recorder { /** Everything the consumer's artifact was told about the contribution. */ readonly seen: unknown[]; /** Every URL the *host* imported. The consumer never sees these. */ readonly imported: string[]; readonly renders: SlideInput[]; } /** * The contributed artifact: a real React renderer, exactly as `defineModuleContribution` produces * (the compiled path through the canonical builder is proven in the module template's own suite). */ function contributedArtifact(recorder: Recorder, options: { readonly version?: string } = {}) { function Slide({ input }: { readonly input: SlideInput }): ReactElement { return (

{input.headline}

{`${input.seconds}s`}

); } return { default: { abi: MODULE_CONTRIBUTION_ABI_VERSION, id: "announcements.signage-slide", version: options.version ?? "1.0.0", styles: ".slide{color:var(--ekklesia-text-primary)}", mount(container: Element, context: ModuleContributionContext) { const root: Root = createRoot(container); const render = (input: ModuleContributionInput): void => { recorder.renders.push(input as SlideInput); root.render(); }; render(context.input); return { update(input: ModuleContributionInput) { render(input); }, unmount() { root.unmount(); }, }; }, }, }; } /** The consuming module's own UI: it discovers, mounts, updates, and unmounts through the host. */ function consumerArtifact(recorder: Recorder) { return { default: { abi: MODULE_UI_ABI_VERSION, id: "signage", version: "2.0.0", async mount(container: Element, context: ModuleUiHostContext) { const slot = container.ownerDocument.createElement("div"); slot.dataset.testid = "signage-slot"; container.append(slot); // Discovery is a question to the host about this module's *own* capability. There is no // URL, no registry query, and no other module's name involved. const available = context.contributions.list("signage.slide-renderer"); recorder.seen.push(...available.map((entry) => ({ ...entry }))); const first = available[0]; if (!first) { return; } const mounted: MountedModuleContribution = await context.contributions.mount({ container: slot, contributionId: first.id, input: { headline: "Welcome", seconds: 7 } satisfies SlideInput, }); (globalThis as { __mountedSlide?: MountedModuleContribution }).__mountedSlide = mounted; return () => { void mounted.unmount(); slot.remove(); }; }, }, }; } function descriptor(overrides: Partial = {}): HostModuleUiDescriptor { return { apiBaseUrl: "/api/v1/modules/signage", contributions: [ { capability: "signage.slide-renderer", id: "announcements.signage-slide", url: "/api/v1/module-ui/announcements/0.4.0/contributions/announcements.signage-slide", version: "1.0.0", }, ], id: "signage", permissions: [], realtimeBaseUrl: "/api/v1/module-realtime/signage", uiUrl: "/api/v1/module-ui/signage/2.0.0", version: "2.0.0", ...overrides, }; } let container: HTMLDivElement; let recorder: Recorder; beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true; container = document.createElement("div"); document.body.append(container); recorder = { imported: [], renders: [], seen: [] }; }); function hostConfig(): EkklesiaHostConfig { return { apiBaseUrl }; } /** The host's importer, standing in for a browser `import()` of a published URL. */ function importer(): (url: string) => Promise { return async (url: string) => { recorder.imported.push(url); return contributedArtifact(recorder); }; } async function mountConsumer( contributionsOverride?: HostModuleUiDescriptor["contributions"], ): Promise<{ unmount: () => void | Promise }> { const context = createModuleUiContext({ config: hostConfig(), descriptor: contributionsOverride === undefined ? descriptor() : descriptor({ contributions: contributionsOverride }), importModule: importer(), principal: { kind: "device", deviceId: "screen-1" }, }); const consumer = consumerArtifact(recorder).default; let cleanup: (() => void | Promise) | undefined; await act(async () => { cleanup = (await consumer.mount(container, context)) ?? undefined; }); return { unmount: () => cleanup?.() }; } function mountedSlide(): MountedModuleContribution { const mounted = (globalThis as { __mountedSlide?: MountedModuleContribution }).__mountedSlide; if (!mounted) { throw new Error("the consumer did not mount a contribution"); } return mounted; } describe("a consuming module mounts another module's contributed renderer", () => { it("discovers, loads, and mounts it inside the consumer's own slot", async () => { const handle = await mountConsumer(); // Discovery gave the consumer identity only — no URL, no filesystem path, no module internals. expect(recorder.seen).toEqual([ { capability: "signage.slide-renderer", id: "announcements.signage-slide", version: "1.0.0", }, ]); expect(JSON.stringify(recorder.seen)).not.toContain("/api/"); const slot = container.querySelector('[data-testid="signage-slot"]'); expect(isolatedText(slot)).toContain("Welcome"); expect(isolatedText(slot)).toContain("7s"); await act(async () => handle.unmount()); }); it("resolves the publication URL in the host, never in module code", async () => { const handle = await mountConsumer(); // The host turned the server-produced descriptor path into an absolute URL. A module that built // this itself would be welded to Ekklesia's private deployment detail. expect(recorder.imported).toEqual([ `${apiBaseUrl}/api/v1/module-ui/announcements/0.4.0/contributions/announcements.signage-slide`, ]); await act(async () => handle.unmount()); }); it("updates the renderer in place, without remounting it", async () => { const handle = await mountConsumer(); const slot = container.querySelector('[data-testid="signage-slot"]'); const beforeNode = slot?.querySelector?.('[data-testid="contributed-slide"]'); await act(async () => { await mountedSlide().update({ headline: "Second slide", seconds: 12 }); }); expect(isolatedText(slot)).toContain("Second slide"); expect(isolatedText(slot)).toContain("12s"); // One mount, two renders — the property a long-running screen depends on. expect(recorder.renders).toHaveLength(2); expect(recorder.imported).toHaveLength(1); expect(slot?.querySelector?.('[data-testid="contributed-slide"]')).toBe(beforeNode); await act(async () => handle.unmount()); }); it("isolates the contributed renderer's styles from the consumer's own screen", async () => { const handle = await mountConsumer(); const slot = container.querySelector('[data-testid="signage-slot"]') as Element; const shadowRoot = slot.querySelector("[data-ekklesia-module-ui]")?.shadowRoot; expect(shadowRoot?.querySelector("style")?.textContent).toContain(".slide{"); // Neither the consumer's own subtree nor the host document received the renderer's rules. expect(container.textContent).not.toContain(".slide{"); expect(document.head.textContent ?? "").not.toContain(".slide{"); await act(async () => handle.unmount()); }); it("tears the renderer down when the consumer unmounts, leaving its slot empty", async () => { const handle = await mountConsumer(); const slot = container.querySelector('[data-testid="signage-slot"]') as Element; await act(async () => handle.unmount()); expect(mountedSlide().mounted).toBe(false); expect(slot.children).toHaveLength(0); expect(container.querySelector('[data-testid="signage-slot"]')).toBeNull(); }); it("refuses a contribution id the server did not offer this module", async () => { const context = createModuleUiContext({ config: hostConfig(), descriptor: descriptor(), importModule: importer(), principal: { kind: "device", deviceId: "screen-1" }, }); await expect( context.contributions.mount({ container, contributionId: "calendar.week-view", input: null, }), ).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_UNKNOWN" }); // Nothing was fetched: a consumer cannot name its way to an artifact the server withheld. expect(recorder.imported).toEqual([]); }); it("finds nothing for a capability nothing was contributed to", async () => { const context = createModuleUiContext({ config: hostConfig(), descriptor: descriptor(), importModule: importer(), principal: { kind: "device", deviceId: "screen-1" }, }); expect(context.contributions.list("signage.ticker")).toEqual([]); }); it("mounts nothing when the platform published no compatible contribution", async () => { // Exactly what a consumer sees when no module contributes, or when every contributor is outside // the declared compatible range: an empty list, and a screen that renders its own fallback. const handle = await mountConsumer([]); expect(recorder.seen).toEqual([]); expect(recorder.imported).toEqual([]); await act(async () => handle.unmount()); }); it("refuses an artifact whose declared identity does not match the descriptor", async () => { const context = createModuleUiContext({ config: hostConfig(), descriptor: descriptor(), importModule: async () => contributedArtifact(recorder, { version: "9.9.9" }), principal: { kind: "device", deviceId: "screen-1" }, }); await expect( context.contributions.mount({ container, contributionId: "announcements.signage-slide", input: { headline: "x", seconds: 1 }, }), ).rejects.toMatchObject({ code: "MODULE_CONTRIBUTION_IDENTITY_MISMATCH" }); expect(container.children).toHaveLength(0); }); it("keeps a contribution out of a descriptor the shell refuses to parse", () => { // The wire is validated on arrival, exactly like every other server-produced URL: a contribution // URL is something the browser will `import()`, so it gets no weaker treatment. for (const url of [ "https://evil.example.test/x.js", "//evil.example.test/x.js", "../../x.js", ]) { expect(() => parseDashboardNavigation({ groups: [], ungrouped: [ { descriptor: { ...descriptor({ contributions: [ { capability: "signage.slide-renderer", id: "announcements.signage-slide", url, version: "1.0.0", }, ], }), api: { baseUrl: "/api/v1/modules/signage" }, realtime: { baseUrl: "/api/v1/module-realtime/signage" }, ui: { url: "/api/v1/module-ui/signage/2.0.0" }, }, name: "Signage", }, ], }), ).toThrowError(expect.objectContaining({ code: "HOST_RESPONSE_INVALID" })); } }); });