Files
Ekklesia/packages/module-ui-host/tests/real-artifact.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

163 lines
6.2 KiB
TypeScript

// @vitest-environment happy-dom
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildModuleUi } from "@ekklesia/module-ui-build";
import { type ModuleUiHostContext, NO_MODULE_UI_CONTRIBUTIONS } from "@ekklesia/module-ui-sdk";
import { beforeAll, describe, expect, it } from "vitest";
import {
loadModuleUi,
MODULE_UI_ISOLATION_ATTRIBUTE,
MODULE_UI_ISOLATION_ROOT_ATTRIBUTE,
} from "../src/index.js";
/** What the artifact rendered, reached through the open shadow root the loader created (ADR 0040). */
function artifactText(container: Element): string {
const shadowHost = container.querySelector(`[${MODULE_UI_ISOLATION_ATTRIBUTE}]`);
return (
shadowHost?.shadowRoot?.querySelector(`[${MODULE_UI_ISOLATION_ROOT_ATTRIBUTE}]`)?.textContent ??
""
);
}
const fixtureDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"fixtures",
"artifact-module",
);
let artifactUrl: string;
let artifactSource: string;
/** An Ekklesia-shaped host: module routes live under a Core-owned prefix. */
function ekklesiaContext(requested: string[]): ModuleUiHostContext {
return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId: "example",
api: {
async request(pathname) {
requested.push(`/api/v1/modules/example/${pathname.replace(/^\//u, "")}`);
return new Response(null, { status: 200 });
},
},
principal: { kind: "authenticated", userId: "user-123" },
permissions: { has: () => true },
realtime: { subscribe: () => ({ close: () => undefined }) },
};
}
/** A standalone host: one module, a different origin, a completely different URL shape, no Core. */
function standaloneContext(requested: string[]): ModuleUiHostContext {
return {
contributions: NO_MODULE_UI_CONTRIBUTIONS,
moduleId: "example",
api: {
async request(pathname) {
requested.push(`https://example.test/api/${pathname.replace(/^\//u, "")}`);
return new Response(null, { status: 200 });
},
},
principal: { kind: "anonymous" },
permissions: { has: () => false },
realtime: { subscribe: () => ({ close: () => undefined }) },
};
}
/**
* Proves the loader consumes exactly the browser ESM artifact `@ekklesia/module-ui-build` produces
* (ADR 0023) — no second output format, no checked-in build output. The artifact is rebuilt from
* source each run and served through a `data:` URL, a real module URL rather than a filesystem path.
*/
describe("loading a real compiled artifact", () => {
beforeAll(async () => {
const result = await buildModuleUi({ moduleDirectory: fixtureDirectory });
const primaryArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
if (!primaryArtifact) {
throw new Error("The real-artifact fixture must declare a primary UI artifact.");
}
artifactSource = await readFile(primaryArtifact.outputFile, "utf8");
artifactUrl = `data:text/javascript;base64,${Buffer.from(artifactSource, "utf8").toString("base64")}`;
}, 60_000);
it("produces a self-contained artifact with no bare imports for a host to resolve", () => {
expect(artifactSource).not.toMatch(/^import\s.*from\s*["'][^.]/mu);
expect(artifactSource).toContain("export {");
expect(artifactSource).not.toContain("/api/v1/modules");
});
it("mounts the real artifact through the generic loader and unmounts it", async () => {
const container = document.createElement("div");
const requested: string[] = [];
const mounted = await loadModuleUi({
source: { moduleId: "example", version: "0.1.0", url: artifactUrl },
container,
context: ekklesiaContext(requested),
});
expect(artifactText(container)).toBe("example:200:user-123");
await mounted.unmount();
expect(container.children).toHaveLength(0);
expect(mounted.mounted).toBe(false);
});
it("compiles the fixture's stylesheet into the artifact and installs it in the isolated root", async () => {
const container = document.createElement("div");
// No sidecar: the whole stylesheet travels inside the one declared JavaScript artifact.
expect(artifactSource).toContain(".artifact-label");
const mounted = await loadModuleUi({
source: { moduleId: "example", version: "0.1.0", url: artifactUrl },
container,
context: ekklesiaContext([]),
});
const stylesheet = mounted.shadowRoot.querySelector("style")?.textContent ?? "";
expect(stylesheet).toContain(".artifact-label");
expect(stylesheet).toContain("var(--ekklesia-text-primary)");
expect(document.head.textContent ?? "").not.toContain(".artifact-label");
await mounted.unmount();
});
it("still rejects the real artifact when the host expects a different version", async () => {
await expect(
loadModuleUi({
source: { moduleId: "example", version: "0.2.0", url: artifactUrl },
container: document.createElement("div"),
context: ekklesiaContext([]),
}),
).rejects.toMatchObject({ code: "MODULE_UI_IDENTITY_MISMATCH" });
});
it("runs the same artifact under an Ekklesia host and a standalone host", async () => {
const ekklesiaRequests: string[] = [];
const standaloneRequests: string[] = [];
const ekklesiaContainer = document.createElement("div");
const standaloneContainer = document.createElement("div");
const source = { moduleId: "example", version: "0.1.0", url: artifactUrl };
const inEkklesia = await loadModuleUi({
source,
container: ekklesiaContainer,
context: ekklesiaContext(ekklesiaRequests),
});
const inStandalone = await loadModuleUi({
source,
container: standaloneContainer,
context: standaloneContext(standaloneRequests),
});
// One artifact, one loader, two hosts. Every difference lives in the context the host supplied:
// the module asked for "records" both times and resolved nothing itself.
expect(ekklesiaRequests).toEqual(["/api/v1/modules/example/records"]);
expect(standaloneRequests).toEqual(["https://example.test/api/records"]);
expect(artifactText(ekklesiaContainer)).toBe("example:200:user-123");
expect(artifactText(standaloneContainer)).toBe("example:200:guest");
await inEkklesia.unmount();
await inStandalone.unmount();
});
});