feat: add display kiosk host
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user