feat: add display kiosk host
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s

This commit is contained in:
ekklesia
2026-08-23 20:50:56 -05:00
parent be5ed7853d
commit 2e0fe059c3
80 changed files with 4868 additions and 397 deletions
+11 -1
View File
@@ -59,7 +59,7 @@ async function registerAndCaptureRoutes(): Promise<{
}
describe("module template", () => {
it("ships a valid backend-only manifest", async () => {
it("ships a valid product manifest with independently declared primary and display UI", async () => {
const manifestUrl = new URL("../ekklesia.module.json", import.meta.url);
const manifestInput = JSON.parse(await readFile(manifestUrl, "utf8")) as unknown;
const manifest = parseModuleManifest(manifestInput);
@@ -72,6 +72,11 @@ describe("module template", () => {
baseline: "restricted",
description: "Read example records.",
},
{
id: "example.display.view",
baseline: "restricted",
description: "View the example full-screen display.",
},
]);
// The template declares **no** logical storage bucket, deliberately. Since ADR 0030 a declared
// bucket is a real requirement: a module that declares one is blocked on an installation with no
@@ -80,6 +85,11 @@ describe("module template", () => {
expect(manifest.storage.buckets).toEqual([]);
expect(manifest.entrypoints.schema).toBe("./dist/schema/index.js");
expect(manifest.entrypoints.migrations).toBe("./drizzle");
expect(manifest.entrypoints.ui).toBe("./dist/ui/index.js");
expect(manifest.entrypoints.display).toEqual({
artifact: "./dist/display/index.js",
permission: "example.display.view",
});
});
it("packages a generated stable Drizzle migration and matching policy", async () => {
+57 -15
View File
@@ -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);