Files
Ekklesia/templates/module/tests/module-ui.test.ts
T

362 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,
display: undefined,
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?.();
});
});