// @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, 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 }; /** 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."); } 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 result = await buildModuleUi({ moduleDirectory }); const uiArtifact = result.artifacts.find((artifact) => artifact.kind === "ui"); const displayArtifact = result.artifacts.find((artifact) => artifact.kind === "display"); if (!uiArtifact || !displayArtifact) { throw new Error("The template build must produce both declared UI roles."); } expect(result.artifacts.map((artifact) => artifact.kind)).toEqual(["ui", "display"]); artifactPath = uiArtifact.outputFile; artifactHref = pathToFileURL(uiArtifact.outputFile).href; displayArtifactPath = displayArtifact.outputFile; displayArtifactHref = pathToFileURL(displayArtifact.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)(); }); });