// @vitest-environment happy-dom import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { parseModuleManifest } from "@ekklesia/module-sdk"; import { buildModuleUi } from "@ekklesia/module-ui-build"; import { type ModuleUiApiClient, type ModuleUiHostContext, NO_MODULE_UI_CONTRIBUTIONS, parseModuleContributionExport, parseModuleUiExport, } from "@ekklesia/module-ui-sdk"; import { beforeAll, describe, expect, it } from "vitest"; /** * The end-to-end proof for the module UI build pipeline (ADR 0023): * * React/TSX source -> `pnpm build` -> compiled artifacts at the manifest's UI entrypoints * -> `parseModuleUiExport` -> `mount(container, context)` -> `unmount()` * * The artifact is rebuilt from source here rather than read from a checked-in file, so the test can * never pass against stale output. `dist/` is gitignored; nothing generated is committed. */ // `happy-dom` replaces the global `URL` with the DOM implementation, which `node:fs` rejects, so // this test works in plain filesystem paths and converts to a file URL only for dynamic import. const moduleDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url))); let artifactHref: string; let artifactPath: string; let displayArtifactHref: string; let displayArtifactPath: string; let manifestUi: string; let manifestDisplay: string; let manifestIdentity: { id: string; version: string }; let contributionArtifactHref: string; let contributionArtifactPath: string; let manifestContribution: { id: string; version: string; artifact: string }; /** Records what the host was asked for, so the test can prove the UI never builds a URL itself. */ function recordingHost(options: { readonly canRead: boolean; readonly records: readonly unknown[]; readonly requested: string[]; readonly resolve: (path: string) => void; }): ModuleUiHostContext { const api: ModuleUiApiClient = { async request(path) { options.requested.push(path); options.resolve(path); return new Response(JSON.stringify(options.records), { headers: { "content-type": "application/json" }, status: 200, }); }, }; return { contributions: NO_MODULE_UI_CONTRIBUTIONS, display: undefined, moduleId: "example", api, principal: { kind: "anonymous" }, permissions: { has: () => options.canRead }, realtime: { subscribe: () => ({ close: () => undefined }) }, }; } async function flush(): Promise { // Let React commit and the awaited fetch microtasks settle. for (let index = 0; index < 5; index += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); } } describe("module UI build pipeline", () => { beforeAll(async () => { const manifest = parseModuleManifest( JSON.parse( await readFile(path.join(moduleDirectory, "ekklesia.module.json"), "utf8"), ) as unknown, ); if (!manifest.entrypoints.ui) { throw new Error("The template manifest must declare entrypoints.ui for this test."); } if (!manifest.entrypoints.display) { throw new Error("The template manifest must declare entrypoints.display for this test."); } manifestUi = manifest.entrypoints.ui; manifestDisplay = manifest.entrypoints.display.artifact; manifestIdentity = { id: manifest.id, version: manifest.version }; const declaredContribution = manifest.contributions[0]; if (!declaredContribution) { throw new Error("The template manifest must declare a browser contribution for this test."); } manifestContribution = { artifact: declaredContribution.artifact, id: declaredContribution.id, version: declaredContribution.version, }; const result = await buildModuleUi({ moduleDirectory }); const uiArtifact = result.artifacts.find((artifact) => artifact.kind === "ui"); const displayArtifact = result.artifacts.find((artifact) => artifact.kind === "display"); const contributionArtifact = result.artifacts.find( (artifact) => artifact.contributionId === declaredContribution.id, ); if (!uiArtifact || !displayArtifact || !contributionArtifact) { throw new Error("The template build must produce every declared browser artifact."); } expect(result.artifacts.map((artifact) => artifact.kind)).toEqual([ "ui", "display", "contribution", ]); artifactPath = uiArtifact.outputFile; artifactHref = pathToFileURL(uiArtifact.outputFile).href; displayArtifactPath = displayArtifact.outputFile; displayArtifactHref = pathToFileURL(displayArtifact.outputFile).href; contributionArtifactPath = contributionArtifact.outputFile; contributionArtifactHref = pathToFileURL(contributionArtifact.outputFile).href; }, 60_000); it("writes both artifacts exactly where the manifest declares them", () => { expect(manifestUi).toBe("./dist/ui/index.js"); expect(artifactPath.endsWith(path.join("dist", "ui", "index.js"))).toBe(true); expect(manifestDisplay).toBe("./dist/display/index.js"); expect(displayArtifactPath.endsWith(path.join("dist", "display", "index.js"))).toBe(true); }); it("produces browser-safe ESM artifacts that need no host-provided React", async () => { const artifacts = await Promise.all([ readFile(artifactPath, "utf8"), readFile(displayArtifactPath, "utf8"), ]); for (const artifact of artifacts) { // React is bundled, so each artifact resolves nothing at run time (ADR 0023). A bare // top-level import would mean the host had to supply that module. expect(artifact).not.toMatch(/^import\s.*from\s*["'][^.]/mu); expect(artifact).toContain("export {"); // No Ekklesia deployment detail may be baked into a portable artifact. expect(artifact).not.toContain("/api/v1/modules"); // `process.env` must be substituted at build time; a browser has no `process`. expect(artifact).not.toContain("process.env.NODE_ENV"); } }); it("satisfies parseModuleUiExport and matches the manifest identity", async () => { const namespace = (await import(artifactHref)) as unknown; const definition = parseModuleUiExport(namespace, manifestIdentity); expect(definition.id).toBe("example"); expect(definition.version).toBe("0.1.0"); expect(typeof definition.mount).toBe("function"); }); it("mounts real React into a container, then unmounts and cleans up", async () => { const namespace = (await import(artifactHref)) as unknown; const definition = parseModuleUiExport(namespace, manifestIdentity); const container = document.createElement("div"); document.body.append(container); const requested: string[] = []; const unmount = await definition.mount( container, recordingHost({ canRead: true, records: [ { id: "a", value: "one" }, { id: "b", value: "two" }, ], requested, resolve: () => undefined, }), ); await flush(); expect(container.textContent).toContain("Example module UI"); expect(container.querySelector("[data-testid='record-count']")?.textContent).toBe( "2 record(s)", ); expect(typeof unmount).toBe("function"); await (unmount as () => Promise)(); expect(container.textContent).toBe(""); container.remove(); }); it("mounts the display artifact through the same ABI, then unmounts it", async () => { const namespace = (await import(displayArtifactHref)) as unknown; const definition = parseModuleUiExport(namespace, manifestIdentity); const container = document.createElement("div"); const requested: string[] = []; const unmount = await definition.mount( container, recordingHost({ canRead: true, records: [], requested, resolve: () => undefined }), ); await flush(); expect(container.querySelector("[data-testid='example-display']")?.textContent).toContain( "Example full-screen display (example)", ); expect(requested).toEqual([]); await (unmount as () => Promise)(); expect(container.textContent).toBe(""); }); it("asks its host for a relative path, never Ekklesia's module API prefix", async () => { const namespace = (await import(artifactHref)) as unknown; const definition = parseModuleUiExport(namespace, manifestIdentity); const container = document.createElement("div"); const requested: string[] = []; const resolvedUrls: string[] = []; const unmount = await definition.mount( container, recordingHost({ canRead: true, records: [], requested, // A standalone host resolving the same relative path to a completely different URL. resolve: (path) => { resolvedUrls.push(`https://example.test/api/${path}`); }, }), ); await flush(); expect(requested).toEqual(["records"]); expect(resolvedUrls).toEqual(["https://example.test/api/records"]); await (unmount as () => Promise)(); }); it("declares its compiled stylesheet on the artifact rather than beside it", async () => { const namespace = (await import(artifactHref)) as unknown; const definition = parseModuleUiExport(namespace, manifestIdentity); // The module's own CSS *and* the shared elements' rules travel inside the one declared // JavaScript artifact, as text (ADR 0040). A host installs them; nothing fetches a second file. expect(definition.styles).toContain(".example-summary"); expect(definition.styles).toContain(".ek-panel"); expect(definition.styles).toContain("var(--ekklesia-text-secondary)"); // A module references the global theme and never declares part of it (ADR 0039). expect(definition.styles).not.toMatch(/--ekklesia-[a-z0-9-]+\s*:/u); await expect(readFile(path.join(path.dirname(artifactPath), "index.css"))).rejects.toThrow(); const displayNamespace = (await import(displayArtifactHref)) as unknown; const displayDefinition = parseModuleUiExport(displayNamespace, manifestIdentity); // Two artifacts of one module: separate roots, separate stylesheets. expect(displayDefinition.styles).toContain(".example-display"); expect(displayDefinition.styles).not.toContain(".example-summary"); }); it("renders a denied state from the host's presentation-only permission hint", async () => { const namespace = (await import(artifactHref)) as unknown; const definition = parseModuleUiExport(namespace, manifestIdentity); const container = document.createElement("div"); const requested: string[] = []; const unmount = await definition.mount( container, recordingHost({ canRead: false, records: [], requested, resolve: () => undefined }), ); await flush(); expect(container.textContent).toContain("No access to example records"); // A hidden control is a usability choice, not enforcement: the module simply did not ask. // Server-side authorization is what actually protects the route. expect(requested).toEqual([]); await (unmount as () => Promise)(); }); }); /** * The contribution half of the same loop (ADR 0041): React/TSX source -> `pnpm build` -> the * compiled artifact the manifest declares -> `parseModuleContributionExport` -> * `mount(container, { contributionId, input })` -> `update(input)` -> `unmount()`. * * The consumer here is the test, standing in for whichever module provides the targeted capability. * That is faithful: a consumer receives identity and calls the host's mount API, and this module * neither imports it nor learns anything about it. */ describe("module contribution build pipeline", () => { it("writes the contribution artifact exactly where the manifest declares it", () => { expect(manifestContribution.artifact).toBe("./dist/contributions/summary-card/index.js"); expect( contributionArtifactPath.endsWith( path.join("dist", "contributions", "summary-card", "index.js"), ), ).toBe(true); }); it("produces a self-contained artifact carrying its own identity and stylesheet", async () => { const namespace = (await import(contributionArtifactHref)) as unknown; const definition = parseModuleContributionExport(namespace, manifestContribution); const source = await readFile(contributionArtifactPath, "utf8"); expect(definition.id).toBe("example.summary-card"); expect(definition.version).toBe("1.0.0"); expect(definition.styles).toContain(".summary-card"); // Self-contained: React bundled, nothing resolved at run time, no Ekklesia URL baked in. expect(source).not.toMatch(/^import\s.*from\s*["'][^.]/mu); expect(source).not.toContain("/api/v1/module-ui"); }); it("rejects the artifact when the consumer expects a different contribution version", async () => { const namespace = (await import(contributionArtifactHref)) as unknown; expect(() => parseModuleContributionExport(namespace, { id: "example.summary-card", version: "9.9.9" }), ).toThrowError(expect.objectContaining({ code: "MODULE_CONTRIBUTION_IDENTITY_MISMATCH" })); }); it("mounts, updates in place, and unmounts from the consumer's own input", async () => { const namespace = (await import(contributionArtifactHref)) as unknown; const definition = parseModuleContributionExport(namespace, manifestContribution); const container = document.createElement("div"); const instance = await definition.mount(container, { contributionId: definition.id, input: { count: 2, heading: "Upcoming" }, }); await flush(); expect(container.querySelector("[data-testid='summary-card']")?.textContent).toContain( "Upcoming", ); expect(container.textContent).toContain("2 record(s)"); // The whole point of `update`: new content without destroying and rebuilding the DOM. const mountedNode = container.querySelector("[data-testid='summary-card']"); await instance?.update?.({ count: 5, heading: "Later today" }); await flush(); expect(container.textContent).toContain("Later today"); expect(container.textContent).toContain("5 record(s)"); expect(container.querySelector("[data-testid='summary-card']")).toBe(mountedNode); await instance?.unmount?.(); await flush(); expect(container.textContent).toBe(""); }); it("renders a defensive default rather than trusting the consumer's input shape", async () => { const namespace = (await import(contributionArtifactHref)) as unknown; const definition = parseModuleContributionExport(namespace, manifestContribution); const container = document.createElement("div"); // The platform carries the input as opaque JSON and validates nothing about its shape, so a // renderer that assumed a field's type would break on a consumer's older or newer contract. const instance = await definition.mount(container, { contributionId: definition.id, input: { count: "many", heading: 7 }, }); await flush(); expect(container.textContent).toContain("Example"); expect(container.textContent).toContain("0 record(s)"); await instance?.unmount?.(); }); });