feat: add display kiosk host
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
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 { buildModuleUi } from "@ekklesia/module-ui-build";
|
||||
import {
|
||||
type ModuleUiApiClient,
|
||||
type ModuleUiHostContext,
|
||||
@@ -14,7 +14,7 @@ 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`
|
||||
* 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
|
||||
@@ -26,7 +26,10 @@ 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 };
|
||||
|
||||
/** Records what the host was asked for, so the test can prove the UI never builds a URL itself. */
|
||||
@@ -72,30 +75,49 @@ describe("module UI build pipeline", () => {
|
||||
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 result = await buildModuleUi({ moduleDirectory });
|
||||
artifactPath = result.outputFile;
|
||||
artifactHref = pathToFileURL(result.outputFile).href;
|
||||
const uiArtifact = result.artifacts.find((artifact) => artifact.kind === "ui");
|
||||
const displayArtifact = result.artifacts.find((artifact) => artifact.kind === "display");
|
||||
if (!uiArtifact || !displayArtifact) {
|
||||
throw new Error("The template build must produce both declared UI roles.");
|
||||
}
|
||||
expect(result.artifacts.map((artifact) => artifact.kind)).toEqual(["ui", "display"]);
|
||||
artifactPath = uiArtifact.outputFile;
|
||||
artifactHref = pathToFileURL(uiArtifact.outputFile).href;
|
||||
displayArtifactPath = displayArtifact.outputFile;
|
||||
displayArtifactHref = pathToFileURL(displayArtifact.outputFile).href;
|
||||
}, 60_000);
|
||||
|
||||
it("writes the artifact exactly where the manifest declares it", () => {
|
||||
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 a browser-safe ESM artifact that needs no host-provided React", async () => {
|
||||
const artifact = await readFile(artifactPath, "utf8");
|
||||
it("produces browser-safe ESM artifacts that need no host-provided React", async () => {
|
||||
const artifacts = await Promise.all([
|
||||
readFile(artifactPath, "utf8"),
|
||||
readFile(displayArtifactPath, "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");
|
||||
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 () => {
|
||||
@@ -139,6 +161,26 @@ describe("module UI build pipeline", () => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user