Files
Ekklesia/templates/module/tests/module-ui.test.ts
T
ekklesiaandClaude Opus 5 1e5866504e
CI / verify (pull_request) Successful in 32s
CI / infrastructure-smoke (pull_request) Successful in 26s
CI / migration-smoke (pull_request) Successful in 50s
feat: build module UI into its declared browser artifact
ADR 0022 defined what a module UI is and selected React and TypeScript
as the authoring model, but closed by recording that it was not buildable
end to end: `browser.json` preserves JSX, bundler plugins are denied, and
nothing turned a module's `.tsx` into the artifact its manifest declares.
This closes exactly that gap.

esbuild produces the artifact. It was already the right answer before it
was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest
today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`,
so the pinned dependency adds no second copy and no new trust decision.
It is also the smallest tool that covers TSX, ESM, browser platform,
bundling, source maps, and a define-time constant with no plugin
ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which
the module boundary denies on purpose; `tsc` alone cannot bundle, so its
output would carry bare `react/jsx-runtime` imports no browser resolves.

React is bundled, not externalized. That follows from ADR 0022 rather
than from convenience: `mount()` was made the ABI precisely so React
would not be part of the host contract, and externalizing it would
reverse that through the build system — every host would have to supply
a compatible React, and a standalone host might not use React at all.
The cost is real and accepted: duplicate runtimes and a ~570 KB
unminified artifact. Sharing one runtime later is a build optimization
that cannot change the contract, because the contract is a mount
function.

Output is browser ESM at `es2022`, unminified so an operator can review
what they install, with `process.env.NODE_ENV` substituted — React reads
it at module scope and would otherwise touch a Node global that does not
exist in a browser. The output path is read from the module's own
manifest rather than a build convention, so declared and produced
artifacts cannot drift, and it is validated with the same
`packageRelativePathSchema` discovery enforces, so a build can never
write where discovery would later reject.

`@ekklesia/module-ui-build` owns every option; modules own none. It uses
the existing `tooling` role that modules could already depend on, so this
pass required no boundary relaxation at all. Its `bin` points at a
committed launcher rather than compiled output because pnpm links bins
during install, before Turborepo builds anything — a bin targeting
`dist/cli.js` fails to link on a fresh clone and breaks every module's UI
build in CI. A module with no `entrypoints.ui` is not an error; the CLI
reports nothing to build and exits zero, so one script serves both kinds
of module.

Styling is React inline style objects. A stylesheet import fails the
build rather than emitting a sidecar `.css` that no manifest declares and
no host loads — an undeclared asset would appear to work in tests and
silently do nothing in a real host.

`templates/module` is the canonical example and now ships optional UI.
Its tests build the artifact from source, validate it with
`parseModuleUiExport`, mount it into a real DOM, assert the rendered
output, and unmount it; the artifact is rebuilt per run so the proof can
never pass against stale output, and `dist/` stays gitignored. Because
verify runs both `pnpm test` and `pnpm build`, a UI that cannot be
produced fails CI through two independent paths.

One correction worth stating plainly: the UI TypeScript project declares
`"types": []`, and a UI file referencing `process` does fail in
isolation, but TypeScript still pulls Node globals in transitively — a
file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts`
and through its Zod and Drizzle types `process.env` typechecks again.
The docs and ADR describe this as the convention it is rather than the
guard it is not.

Core is untouched. Building is an authoring step; discovery still only
validates the artifact a module shipped and never executes or produces
one.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-21 21:24:18 -05:00

186 lines
6.8 KiB
TypeScript

// @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 },
};
}
async function flush(): Promise<void> {
// 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<void>)();
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<void>)();
});
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<void>)();
});
});