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 { 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; } async function createModule(options: ModuleFixtureOptions = {}): Promise { const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-ui-build-")); temporaryDirectories.push(directory); const manifest = options.manifest ?? JSON.stringify({ manifestVersion: 6, 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([ "styles.css", "theme.scss", "layout.less", ])("rejects a %s import rather than emitting an undeclared sidecar asset", 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_BUILD_FAILED"); const diagnostics = (error as { diagnostics?: { text?: string }[] }).diagnostics ?? []; expect(diagnostics.some((entry) => entry.text?.includes("cannot import a stylesheet"))).toBe( true, ); } }); it("never leaves a stylesheet sidecar next to the artifact", async () => { const directory = await createModule({ ui: "./dist/ui/index.js", files: { "src/ui/index.tsx": 'import "./styles.css";\nexport default {};', "src/ui/styles.css": ".example { color: red; }", }, }); await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toBeInstanceOf( ModuleUiBuildError, ); await expect( readFile(path.join(directory, "dist", "ui", "index.css"), "utf8"), ).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", 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"); }); });