Files
Ekklesia/templates/module/tests/module-ui.test.ts
T
ekklesiaandClaude Opus 5 c444ff570b feat: let one module contribute a browser renderer to another's capability
The platform has had a complete story for one module using another's
*backend* since ADR 0006 — capabilities, versioned ranges, provider
registration. It has had none at all for one module using another's *browser*
code, and every workaround is wrong in a way that matters: a consumer cannot
import a peer module, it cannot re-implement a renderer for a domain it does
not own, and a second `entrypoints` role would put the platform in charge of
deciding when a slot inside someone else's screen should be filled.

Add a generic browser contribution. `manifestVersion: 7` declares one:
namespaced id, its own version, a target capability, a compatible range, and
one artifact. That is the complete list, and the omissions are the decision —
no payload schema, slot, ordering, or rendering hint exists anywhere, so Core
cannot learn what a slide is because the declaration cannot express it.

Discovery resolves a contribution artifact through the *identical* loop every
other compiled entrypoint uses, inheriting the path, containment, symlink,
extension, and file-kind checks rather than getting a weaker copy of them. The
registry publishes one only when another eligible module provides the target
capability within the declared range; otherwise it is absent rather than
mounted and broken. `apps/api` serves it as a sibling of the artifact and
display routes, with the same policy: three lookup keys, one exact match, one
already-validated path, and one indistinguishable 404 for unknown, undeclared,
stale, and incompatible alike.

A consumer discovers through the host, never through a URL. Its descriptor
carries identity, version, and capability; the URL stays server-side and is
resolved exactly as `api.request("records")` is. A module can only ever see
contributions aimed at a capability it itself provides, and cannot name its
way to anything else — a mount for a withheld id fails without fetching.

The consumer owns the domain contract entirely. Input is opaque JSON, checked
structurally so a Date, a function, a cycle, or `undefined` fails at the
boundary rather than vanishing somewhere far from the mistake, and checked for
nothing else. A contributed renderer receives its own id and that input: no
API client, realtime, principal, configuration, database, storage, or
credential. Loading one grants its module nothing.

The contribution ABI is versioned separately from the module UI ABI, because
the two have different shapes and lifecycles and coupling them would force
every module to rebuild whenever either moved. It supports asynchronous
readiness, update-without-remount (a screen that remounted per tick would
restart animations and reload media), deterministic cleanup in a `finally`,
and sanitized failure that never carries the consumer's domain data. Each
renderer mounts in its own isolated root with its own stylesheet.

The template now declares a contribution, so `pnpm build` exercises the whole
authoring path, and the shell suite drives the full relationship — one module
discovering, mounting, updating, and unmounting another's React renderer with
neither importing the other.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-27 07:47:10 -05:00

361 lines
15 KiB
TypeScript

// @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,
moduleId: "example",
api,
principal: { kind: "anonymous" },
permissions: { has: () => options.canRead },
realtime: { subscribe: () => ({ close: () => undefined }) },
};
}
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.");
}
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<void>)();
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<void>)();
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<void>)();
});
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<void>)();
});
});
/**
* 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?.();
});
});