Files
ekklesia 49b4d4165d
CI / verify (pull_request) Successful in 1m0s
CI / infrastructure-smoke (pull_request) Successful in 36s
CI / migration-smoke (pull_request) Successful in 1m53s
CI / image-build (pull_request) Successful in 23s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / deploy-smoke (split) (pull_request) Successful in 1m30s
feat: deliver module database ABI by manifest
2026-10-07 17:29:32 -05:00

589 lines
22 KiB
TypeScript

import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { analyzeModuleUi, buildModuleUi, ModuleUiBuildError } from "../src/index.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});
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>;
}
async function createModule(options: ModuleFixtureOptions = {}): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-ui-build-"));
temporaryDirectories.push(directory);
const manifest =
options.manifest ??
JSON.stringify({
manifestVersion: 8,
id: "example",
name: "Example",
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
entrypoints: {
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);
for (const [relativePath, contents] of Object.entries(options.files ?? {})) {
const target = path.join(directory, relativePath);
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, contents);
}
return directory;
}
/** A UI entrypoint with no React, so the build tool's own tests exercise the pipeline mechanics
* without depending on a UI framework. The real React path is proven end to end by the module
* template's own test suite. */
const plainUiSource = `
const definition = {
id: "example",
version: "0.1.0",
mount(container) {
container.textContent = "example";
return () => {
container.textContent = "";
};
},
};
export default definition;
`;
describe("module UI build", () => {
it("bundles the declared entrypoint into the exact path the manifest names", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": plainUiSource },
});
const result = await buildModuleUi({ moduleDirectory: directory });
const artifactResult = result.artifacts[0];
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");
});
it("honours an alternative declared artifact path", async () => {
const directory = await createModule({
ui: "./build/browser/ui.mjs",
files: { "src/ui/index.tsx": plainUiSource },
});
const result = await buildModuleUi({ moduleDirectory: directory });
expect(result.artifacts[0]?.outputFile).toBe(
path.join(directory, "build", "browser", "ui.mjs"),
);
});
it("emits a source map alongside the artifact", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": plainUiSource },
});
const result = await buildModuleUi({ moduleDirectory: directory });
await expect(readFile(`${result.artifacts[0]?.outputFile}.map`, "utf8")).resolves.toContain(
"sources",
);
});
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 })).resolves.toEqual({ artifacts: [] });
});
it("fails when the declared UI has no source entrypoint", async () => {
const directory = await createModule({ ui: "./dist/ui/index.js" });
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" });
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_MANIFEST_UNREADABLE",
});
});
it.each([
["escapes the package", "../outside/ui.js"],
["is absolute", "/tmp/ui.js"],
["traverses upward", "./dist/../../ui.js"],
["is source TypeScript", "./src/ui/index.tsx"],
["carries a query", "./dist/ui/index.js?raw"],
["uses backslashes", ".\\dist\\ui\\index.js"],
])("refuses to write an artifact whose declared path %s", async (_label, ui) => {
const directory = await createModule({ ui, files: { "src/ui/index.tsx": plainUiSource } });
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",
files: { "src/ui/index.tsx": "export default { id: 'example', " },
});
try {
await buildModuleUi({ moduleDirectory: directory });
expect.unreachable("expected the build to fail");
} catch (error) {
expect(error).toBeInstanceOf(ModuleUiBuildError);
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_BUILD_FAILED");
// The sanitized message carries no path; the diagnostics a developer needs are attached.
expect((error as ModuleUiBuildError).message).not.toContain(directory);
expect((error as { diagnostics?: unknown[] }).diagnostics?.length).toBeGreaterThan(0);
}
});
it("fails on an unresolvable import instead of emitting a partial bundle", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": 'import "totally-not-installed";\nexport default {};' },
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_BUILD_FAILED",
});
});
it.each([
"theme.scss",
"layout.less",
"tokens.styl",
])("rejects a %s import: only plain CSS has a compilation contract", async (stylesheet) => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `import "./${stylesheet}";\nexport default {};`,
[`src/ui/${stylesheet}`]: ".example { color: red; }",
},
});
try {
await buildModuleUi({ moduleDirectory: directory });
expect.unreachable("expected the stylesheet import to be rejected");
} catch (error) {
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_STYLESHEET_INVALID");
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
expect(diagnostics.some((entry) => entry.text?.includes("plain .css only"))).toBe(true);
}
});
it("compiles a plain stylesheet into the artifact instead of emitting a sidecar", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/ui/styles.css": ".example { color: var(--ekklesia-text-primary); }",
},
});
const result = await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
// The whole stylesheet travels inside the one declared artifact.
expect(artifact).toContain(".example");
expect(artifact).toContain("--ekklesia-text-primary");
// And nothing was emitted beside it. A sidecar is the exact failure this design prevents: a
// file nothing declares, no host fetches, and every local test silently passes without.
await expect(
readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"),
).rejects.toThrow();
});
it("resolves a local @import so the compiled stylesheet is self-contained", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/ui/styles.css": '@import "./base.css";\n.example { color: red; }',
"src/ui/base.css": ".base { display: grid; }",
},
});
const result = await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
expect(artifact).toContain(".base");
expect(artifact).toContain(".example");
});
it("rejects a stylesheet that keeps an unresolved @import, so the artifact stays self-contained", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/ui/styles.css": '@import url("https://fonts.example.invalid/family.css");',
},
});
await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({
code: "MODULE_UI_STYLESHEET_INVALID",
});
});
it("rejects a stylesheet that declares a global Ekklesia theme variable", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
// Referencing a token is the contract; *declaring* one would invent a global theme variable
// outside the registry a future theme editor enumerates (ADR 0039).
"src/ui/styles.css": ":host { --ekklesia-text-primary: hotpink; }",
},
});
try {
await buildModuleUi({ moduleDirectory: directory });
expect.unreachable("expected the stylesheet to be rejected");
} catch (error) {
expect((error as ModuleUiBuildError).code).toBe("MODULE_UI_STYLESHEET_INVALID");
const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? [];
expect(diagnostics.some((entry) => entry.text?.includes("never declare one"))).toBe(true);
}
});
it("accepts a private alias derived from a registered token", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/ui/styles.css":
":host { --example-gap: var(--ekklesia-spacing-sm); }\n.example { gap: var(--example-gap); }",
},
});
const artifact = await readFile(
(await buildModuleUi({ moduleDirectory: directory })).artifacts[0]?.outputFile ?? "",
"utf8",
);
expect(artifact).toContain("--example-gap");
});
it("compiles a display artifact's stylesheet the same way", async () => {
const directory = await createModule({
display: {
artifact: "./dist/display/index.js",
permission: "example.display.view",
},
files: {
"src/display/index.tsx": `
import styles from "./styles.css";
export default { id: "example", version: "0.1.0", styles, mount: () => undefined };`,
"src/display/styles.css": ".display { position: fixed; }",
},
});
const result = await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
expect(artifact).toContain(".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",
files: {
"src/ui/index.tsx":
'const mode = process.env.NODE_ENV;\nexport default { id: "example", version: "0.1.0", mount: () => { void mode; } };',
},
});
const result = await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(result.artifacts[0]?.outputFile ?? "", "utf8");
expect(artifact).toContain('"production"');
expect(artifact).not.toContain("process.env.NODE_ENV");
});
});
describe("module UI build analysis", () => {
const styledSource = `import styles from "./styles.css";
${plainUiSource.replace("const definition = {", "const definition = {\n styles,")}`;
it("reports every source file behind each artifact, stylesheets included, and writes nothing", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": styledSource,
"src/ui/styles.css":
'@import "./base.css";\n.example { color: var(--ekklesia-color-text); }\n',
"src/ui/base.css": ".example-base { margin: 0; }\n",
},
});
await buildModuleUi({ moduleDirectory: directory });
const built = await readFile(path.join(directory, "dist/ui/index.js"));
const analysis = await analyzeModuleUi({ moduleDirectory: directory });
expect(analysis.artifacts).toHaveLength(1);
const [artifact] = analysis.artifacts;
expect(artifact?.kind).toBe("ui");
expect(artifact?.outputFile).toBe(path.join(directory, "dist/ui/index.js"));
expect(artifact?.builtArtifactCurrent).toBe(true);
expect(artifact?.inputs).toEqual(
expect.arrayContaining([
path.join(directory, "src/ui/index.tsx"),
path.join(directory, "src/ui/styles.css"),
path.join(directory, "src/ui/base.css"),
]),
);
expect([...(artifact?.inputs ?? [])]).toEqual([...new Set(artifact?.inputs)].sort());
// Nothing was written: the built artifact is byte-for-byte what it was.
expect(await readFile(path.join(directory, "dist/ui/index.js"))).toEqual(built);
});
it("compiles stylesheets relative to the module, whatever directory the build runs from", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: {
"src/ui/index.tsx": styledSource,
"src/ui/styles.css": '@import "./base.css";\n.example { margin: 0; }\n',
"src/ui/base.css": ".example-base { margin: 0; }\n",
},
});
// This test runs from the package directory, never from the module's own: the artifact must
// still name its stylesheets relative to the module, or its bytes would depend on the caller.
expect(process.cwd()).not.toBe(directory);
await buildModuleUi({ moduleDirectory: directory });
const artifact = await readFile(path.join(directory, "dist/ui/index.js"), "utf8");
expect(artifact).toContain("/* src/ui/styles.css */");
expect(artifact).toContain("/* src/ui/base.css */");
expect(artifact).not.toContain(path.basename(directory));
});
it("says when a built artifact no longer matches its sources", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": plainUiSource },
});
await buildModuleUi({ moduleDirectory: directory });
await writeFile(path.join(directory, "dist/ui/index.js"), "export default {};\n");
const analysis = await analyzeModuleUi({ moduleDirectory: directory });
expect(analysis.artifacts[0]?.builtArtifactCurrent).toBe(false);
});
it("says when a declared artifact was never built", async () => {
const directory = await createModule({
ui: "./dist/ui/index.js",
files: { "src/ui/index.tsx": plainUiSource },
});
const analysis = await analyzeModuleUi({ moduleDirectory: directory });
expect(analysis.artifacts[0]?.builtArtifactCurrent).toBe(false);
});
it("returns an empty analysis for a backend-only module", async () => {
const directory = await createModule();
await expect(analyzeModuleUi({ moduleDirectory: directory })).resolves.toEqual({
artifacts: [],
});
});
});