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
@@ -17,6 +17,12 @@ afterEach(async () => {
interface ModuleFixtureOptions {
/** Omit to leave `entrypoints.ui` undeclared, as a backend-only module does. */
readonly ui?: string;
readonly schema?: string;
readonly migrations?: string;
readonly jobs?: string;
readonly server?: string;
/** The complete inert `entrypoints.display` value, including its permission policy. */
readonly display?: unknown;
readonly manifest?: string;
readonly files?: Record<string, string>;
}
@@ -28,14 +34,18 @@ async function createModule(options: ModuleFixtureOptions = {}): Promise<string>
const manifest =
options.manifest ??
JSON.stringify({
manifestVersion: 5,
manifestVersion: 6,
id: "example",
name: "Example",
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
entrypoints: {
server: "./dist/server/index.js",
server: options.server ?? "./dist/server/index.js",
...(options.schema === undefined ? {} : { schema: options.schema }),
...(options.migrations === undefined ? {} : { migrations: options.migrations }),
...(options.jobs === undefined ? {} : { jobs: options.jobs }),
...(options.ui === undefined ? {} : { ui: options.ui }),
...(options.display === undefined ? {} : { display: options.display }),
},
});
await writeFile(path.join(directory, "ekklesia.module.json"), manifest);
@@ -73,9 +83,14 @@ describe("module UI build", () => {
});
const result = await buildModuleUi({ moduleDirectory: directory });
const artifactResult = result.artifacts[0];
expect(result.outputFile).toBe(path.join(directory, "dist", "ui", "index.js"));
const artifact = await readFile(result.outputFile, "utf8");
expect(result.artifacts).toHaveLength(1);
expect(artifactResult).toMatchObject({
kind: "ui",
outputFile: path.join(directory, "dist", "ui", "index.js"),
});
const artifact = await readFile(artifactResult?.outputFile ?? "", "utf8");
expect(artifact).toContain("export {");
expect(artifact).toContain("as default");
});
@@ -88,7 +103,9 @@ describe("module UI build", () => {
const result = await buildModuleUi({ moduleDirectory: directory });
expect(result.outputFile).toBe(path.join(directory, "build", "browser", "ui.mjs"));
expect(result.artifacts[0]?.outputFile).toBe(
path.join(directory, "build", "browser", "ui.mjs"),
);
});
it("emits a source map alongside the artifact", async () => {
@@ -99,15 +116,15 @@ describe("module UI build", () => {
const result = await buildModuleUi({ moduleDirectory: directory });
await expect(readFile(`${result.outputFile}.map`, "utf8")).resolves.toContain("sources");
await expect(readFile(`${result.artifacts[0]?.outputFile}.map`, "utf8")).resolves.toContain(
"sources",
);
});
it("reports a backend-only module as having no UI to build rather than failing silently", async () => {
it("returns an explicit empty result for a backend-only module", async () => {
const directory = await createModule({ files: { "src/ui/index.tsx": plainUiSource } });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_NOT_DECLARED",
});
await expect(buildModuleUi({ moduleDirectory: directory })).resolves.toEqual({ artifacts: [] });
});
it("fails when the declared UI has no source entrypoint", async () => {
@@ -115,9 +132,134 @@ describe("module UI build", () => {
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_SOURCE_MISSING",
kind: "ui",
});
});
it("builds a display-only module from the fixed display source", async () => {
const directory = await createModule({
display: {
artifact: "./dist/display/index.js",
permission: "example.display.view",
},
files: { "src/display/index.tsx": plainUiSource },
});
const result = await buildModuleUi({ moduleDirectory: directory });
expect(result.artifacts).toEqual([
{
kind: "display",
outputFile: path.join(directory, "dist/display/index.js"),
sourceFile: path.join(directory, "src/display/index.tsx"),
},
]);
await expect(readFile(result.artifacts[0]?.outputFile ?? "", "utf8")).resolves.toContain(
"as default",
);
});
it("builds primary and display artifacts in deterministic role order", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
display: {
artifact: "./dist/display/index.mjs",
permission: "example.display.view",
},
files: {
"src/ui/index.tsx": plainUiSource.replaceAll("example", "primary"),
"src/display/index.tsx": plainUiSource.replaceAll("example", "display"),
},
});
const result = await buildModuleUi({ moduleDirectory: directory });
expect(result.artifacts.map((artifact) => artifact.kind)).toEqual(["ui", "display"]);
await expect(readFile(path.join(directory, "dist/ui/index.js"), "utf8")).resolves.toContain(
'"primary"',
);
await expect(
readFile(path.join(directory, "dist/display/index.mjs"), "utf8"),
).resolves.toContain('"display"');
});
it("preflights every declared source before writing either artifact", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
display: {
artifact: "./dist/display/index.js",
permission: "example.display.view",
},
files: { "src/ui/index.tsx": plainUiSource },
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_SOURCE_MISSING",
kind: "display",
});
await expect(readFile(path.join(directory, "dist/ui/index.js"))).rejects.toThrow();
});
it("writes neither role when the second declared bundle fails to compile", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
display: {
artifact: "./dist/display/index.js",
permission: "example.display.view",
},
files: {
"src/ui/index.tsx": plainUiSource,
"src/display/index.tsx": "export default { broken:",
},
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_BUILD_FAILED",
kind: "display",
});
await expect(readFile(path.join(directory, "dist/ui/index.js"))).rejects.toThrow();
await expect(readFile(path.join(directory, "dist/display/index.js"))).rejects.toThrow();
});
it("rejects colliding primary and display output paths before building", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
display: {
artifact: "./dist/ui/index.js",
permission: "example.display.view",
},
files: {
"src/ui/index.tsx": plainUiSource,
"src/display/index.tsx": plainUiSource,
},
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_COLLISION",
kind: "display",
});
});
for (const entrypointName of ["server", "schema", "migrations", "jobs"] as const) {
it(`rejects a display output that would overwrite the declared ${entrypointName} artifact before building`, async () => {
const sharedPath = "./dist/display/index.js";
const directory = await createModule({
[entrypointName]: sharedPath,
display: {
artifact: sharedPath,
permission: "example.display.view",
},
files: { "src/display/index.tsx": plainUiSource },
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_COLLISION",
kind: "display",
});
await expect(readFile(path.join(directory, "dist/display/index.js"))).rejects.toThrow();
});
}
it("fails when the manifest cannot be read", async () => {
const directory = await createModule({ manifest: "{ not json" });
@@ -138,9 +280,38 @@ describe("module UI build", () => {
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_UNSAFE",
kind: "ui",
});
});
it("applies the same safe compiled-output rules to display artifacts", async () => {
const directory = await createModule({
display: {
artifact: "./src/display/index.tsx",
permission: "example.display.view",
},
files: { "src/display/index.tsx": plainUiSource },
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_ENTRYPOINT_UNSAFE",
kind: "display",
});
});
it("does not reinterpret a bare display path as the version 6 display declaration", async () => {
const directory = await createModule({
display: "./dist/display/index.js",
files: { "src/display/index.tsx": plainUiSource },
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_MANIFEST_UNREADABLE",
kind: "display",
});
await expect(readFile(path.join(directory, "dist/display/index.js"))).rejects.toThrow();
});
it("surfaces a build failure with esbuild's diagnostics attached", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
@@ -212,6 +383,25 @@ describe("module UI build", () => {
).rejects.toThrow();
});
it("enforces the stylesheet boundary for display UI too", async () => {
const directory = await createModule({
display: {
artifact: "./dist/display/index.js",
permission: "example.display.view",
},
files: {
"src/display/index.tsx": 'import "./styles.css";\nexport default {};',
"src/display/styles.css": ".display { position: fixed; }",
},
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_BUILD_FAILED",
kind: "display",
});
await expect(readFile(path.join(directory, "dist/display/index.css"))).rejects.toThrow();
});
it("substitutes NODE_ENV so the artifact never touches a Node global in a browser", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
@@ -222,7 +412,7 @@ describe("module UI build", () => {
});
const result = await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(result.outputFile, "utf8");
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
expect(artifact).toContain('"production"');
expect(artifact).not.toContain("process.env.NODE_ENV");