// @vitest-environment happy-dom import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { buildModuleUi } from "@ekklesia/module-ui-build"; import { parseModuleManifest } from "@ekklesia/module-sdk"; import { type ModuleUiApiClient, type ModuleUiHostContext, 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 artifact at the manifest's `entrypoints.ui` * -> `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 manifestUi: string; let manifestIdentity: { id: string; version: 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 { 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."); } manifestUi = manifest.entrypoints.ui; manifestIdentity = { id: manifest.id, version: manifest.version }; const result = await buildModuleUi({ moduleDirectory }); artifactPath = result.outputFile; artifactHref = pathToFileURL(result.outputFile).href; }, 60_000); it("writes the artifact exactly where the manifest declares it", () => { expect(manifestUi).toBe("./dist/ui/index.js"); expect(artifactPath.endsWith(path.join("dist", "ui", "index.js"))).toBe(true); }); it("produces a browser-safe ESM artifact that needs no host-provided React", async () => { const artifact = await readFile(artifactPath, "utf8"); // React is bundled, so the 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("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("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("do not have access"); // 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)(); }); });