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 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: 5, id: "example", name: "Example", version: "0.1.0", compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js", ...(options.ui === undefined ? {} : { ui: options.ui }), }, }); 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 }); expect(result.outputFile).toBe(path.join(directory, "dist", "ui", "index.js")); const artifact = await readFile(result.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.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.outputFile}.map`, "utf8")).resolves.toContain("sources"); }); it("reports a backend-only module as having no UI to build rather than failing silently", async () => { const directory = await createModule({ files: { "src/ui/index.tsx": plainUiSource } }); await expect(buildModuleUi({ moduleDirectory: directory })).rejects.toMatchObject({ code: "MODULE_UI_ENTRYPOINT_NOT_DECLARED", }); }); 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", }); }); 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", }); }); 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("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.outputFile, "utf8"); expect(artifact).toContain('"production"'); expect(artifact).not.toContain("process.env.NODE_ENV"); }); });