187 lines
6.9 KiB
TypeScript
187 lines
6.9 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 },
|
|
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.");
|
|
}
|
|
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>)();
|
|
});
|
|
});
|