feat: build module UI into its declared browser artifact
ADR 0022 defined what a module UI is and selected React and TypeScript as the authoring model, but closed by recording that it was not buildable end to end: `browser.json` preserves JSX, bundler plugins are denied, and nothing turned a module's `.tsx` into the artifact its manifest declares. This closes exactly that gap. esbuild produces the artifact. It was already the right answer before it was chosen: this workspace resolves esbuild 0.28.1 through Vite/Vitest today and `pnpm-workspace.yaml` already carries `allowBuilds: esbuild`, so the pinned dependency adds no second copy and no new trust decision. It is also the smallest tool that covers TSX, ESM, browser platform, bundling, source maps, and a define-time constant with no plugin ecosystem. Vite library mode would drag in `@vitejs/plugin-react`, which the module boundary denies on purpose; `tsc` alone cannot bundle, so its output would carry bare `react/jsx-runtime` imports no browser resolves. React is bundled, not externalized. That follows from ADR 0022 rather than from convenience: `mount()` was made the ABI precisely so React would not be part of the host contract, and externalizing it would reverse that through the build system — every host would have to supply a compatible React, and a standalone host might not use React at all. The cost is real and accepted: duplicate runtimes and a ~570 KB unminified artifact. Sharing one runtime later is a build optimization that cannot change the contract, because the contract is a mount function. Output is browser ESM at `es2022`, unminified so an operator can review what they install, with `process.env.NODE_ENV` substituted — React reads it at module scope and would otherwise touch a Node global that does not exist in a browser. The output path is read from the module's own manifest rather than a build convention, so declared and produced artifacts cannot drift, and it is validated with the same `packageRelativePathSchema` discovery enforces, so a build can never write where discovery would later reject. `@ekklesia/module-ui-build` owns every option; modules own none. It uses the existing `tooling` role that modules could already depend on, so this pass required no boundary relaxation at all. Its `bin` points at a committed launcher rather than compiled output because pnpm links bins during install, before Turborepo builds anything — a bin targeting `dist/cli.js` fails to link on a fresh clone and breaks every module's UI build in CI. A module with no `entrypoints.ui` is not an error; the CLI reports nothing to build and exits zero, so one script serves both kinds of module. Styling is React inline style objects. A stylesheet import fails the build rather than emitting a sidecar `.css` that no manifest declares and no host loads — an undeclared asset would appear to work in tests and silently do nothing in a real host. `templates/module` is the canonical example and now ships optional UI. Its tests build the artifact from source, validate it with `parseModuleUiExport`, mount it into a real DOM, assert the rendered output, and unmount it; the artifact is rebuilt per run so the proof can never pass against stale output, and `dist/` stays gitignored. Because verify runs both `pnpm test` and `pnpm build`, a UI that cannot be produced fails CI through two independent paths. One correction worth stating plainly: the UI TypeScript project declares `"types": []`, and a UI file referencing `process` does fail in isolation, but TypeScript still pulls Node globals in transitively — a file importing `@ekklesia/module-ui-sdk` reaches `@ekklesia/contracts` and through its Zod and Drizzle types `process.env` typechecks again. The docs and ADR describe this as the convention it is rather than the guard it is not. Core is untouched. Building is an authoring step; discovery still only validates the artifact a module shipped and never executes or produces one. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f9c1108527
commit
1e5866504e
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env node
|
||||
// Committed launcher, deliberately not generated.
|
||||
//
|
||||
// pnpm creates `.bin` links during `pnpm install`, which runs before Turborepo builds anything. A
|
||||
// `bin` pointing straight at `dist/cli.js` therefore fails to link on a fresh clone — the compiled
|
||||
// file does not exist yet — and every module's UI build would break in CI. This file always exists,
|
||||
// so the link always succeeds; it defers to the compiled CLI at run time, by which point
|
||||
// `dependsOn: ["^build"]` has produced it.
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const compiledCli = new URL("../dist/cli.js", import.meta.url);
|
||||
|
||||
try {
|
||||
await import(compiledCli.href);
|
||||
} catch (error) {
|
||||
if (error?.code === "ERR_MODULE_NOT_FOUND") {
|
||||
process.stderr.write(
|
||||
`@ekklesia/module-ui-build is not built yet (${fileURLToPath(compiledCli)}).\n` +
|
||||
"Run `pnpm build` from the repository root first.\n",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@ekklesia/module-ui-build",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"bin",
|
||||
"dist"
|
||||
],
|
||||
"bin": {
|
||||
"ekklesia-build-module-ui": "./bin/ekklesia-build-module-ui.mjs"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --project tsconfig.build.json",
|
||||
"lint": "biome lint .",
|
||||
"prepack": "pnpm --workspace-root exec turbo run build --filter=@ekklesia/module-ui-build",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ekklesia/contracts": "workspace:*",
|
||||
"esbuild": "0.28.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.3",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"ekklesia": {
|
||||
"layer": "tooling"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { packageRelativePathSchema } from "@ekklesia/contracts";
|
||||
import { build, type Plugin } from "esbuild";
|
||||
|
||||
/**
|
||||
* The one supported way to turn a module's React/TSX UI source into the compiled browser artifact
|
||||
* its manifest declares as `entrypoints.ui` (ADR 0021, ADR 0022, ADR 0023).
|
||||
*
|
||||
* Module authors do not write bundler configuration. They put their UI at
|
||||
* {@link MODULE_UI_SOURCE_ENTRYPOINT}, declare `entrypoints.ui` in their manifest, and run the
|
||||
* build; every option below is owned here so one decision applies to every module.
|
||||
*/
|
||||
export const MODULE_UI_SOURCE_ENTRYPOINT = "src/ui/index.tsx";
|
||||
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
|
||||
|
||||
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
|
||||
export const MODULE_UI_BUILD_TARGET = "es2022";
|
||||
|
||||
export type ModuleUiBuildErrorCode =
|
||||
| "MODULE_UI_MANIFEST_UNREADABLE"
|
||||
| "MODULE_UI_ENTRYPOINT_NOT_DECLARED"
|
||||
| "MODULE_UI_ENTRYPOINT_UNSAFE"
|
||||
| "MODULE_UI_SOURCE_MISSING"
|
||||
| "MODULE_UI_BUILD_FAILED";
|
||||
|
||||
const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.freeze({
|
||||
MODULE_UI_MANIFEST_UNREADABLE: `${MODULE_MANIFEST_FILENAME} could not be read or parsed.`,
|
||||
MODULE_UI_ENTRYPOINT_NOT_DECLARED: `${MODULE_MANIFEST_FILENAME} declares no entrypoints.ui, so there is no module UI to build.`,
|
||||
MODULE_UI_ENTRYPOINT_UNSAFE: `entrypoints.ui must be a package-relative path to a compiled .js or .mjs artifact inside the module.`,
|
||||
MODULE_UI_SOURCE_MISSING: `Module UI source was not found at ${MODULE_UI_SOURCE_ENTRYPOINT}.`,
|
||||
MODULE_UI_BUILD_FAILED: "The module UI bundle could not be produced.",
|
||||
});
|
||||
|
||||
/** Carries a stable code and a fixed message. esbuild's own diagnostics are printed to stderr by
|
||||
* the CLI, where a developer wants them; the thrown error itself stays sanitized, matching every
|
||||
* other Ekklesia error boundary. */
|
||||
export class ModuleUiBuildError extends Error {
|
||||
readonly code: ModuleUiBuildErrorCode;
|
||||
|
||||
constructor(code: ModuleUiBuildErrorCode) {
|
||||
super(errorMessages[code]);
|
||||
this.name = "ModuleUiBuildError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a stylesheet import instead of quietly emitting one.
|
||||
*
|
||||
* esbuild would happily bundle `import "./styles.css"` into a sidecar `index.css` next to the
|
||||
* artifact. Nothing declares that file, no host loads it, and the manifest has no field for it — so
|
||||
* it would work in a local test and silently do nothing in a real host. Module styling has no
|
||||
* public loading contract yet (ADR 0023), so the build fails loudly rather than producing an
|
||||
* undeclared asset. `scripts/check-boundaries.ts` rejects a `.css` file authored *inside* a module;
|
||||
* this additionally covers a stylesheet reached through a dependency.
|
||||
*/
|
||||
function rejectStylesheetImports(): Plugin {
|
||||
return {
|
||||
name: "ekklesia-reject-stylesheet-imports",
|
||||
setup(pluginBuild) {
|
||||
// No `u` flag, deliberately: esbuild compiles a plugin filter to a Go RE2 expression, which
|
||||
// rejects `(?iu)` outright ("filter is not a valid Go regular expression"). This pattern is
|
||||
// pure ASCII, so the flag would change nothing anyway.
|
||||
pluginBuild.onResolve({ filter: /\.(css|s[ac]ss|less|styl|pcss|postcss)$/i }, (args) => ({
|
||||
errors: [
|
||||
{
|
||||
text:
|
||||
"Module UI cannot import a stylesheet yet: there is no declared way for a host to " +
|
||||
"load one. Use React inline style objects for now (ADR 0023).",
|
||||
location: { file: args.importer },
|
||||
},
|
||||
],
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildModuleUiOptions {
|
||||
/** The module package directory containing `ekklesia.module.json`. */
|
||||
readonly moduleDirectory: string;
|
||||
}
|
||||
|
||||
export interface ModuleUiBuildResult {
|
||||
/** Absolute path of the artifact actually written, which is exactly what the manifest declares. */
|
||||
readonly outputFile: string;
|
||||
/** Absolute path of the source entrypoint that produced it. */
|
||||
readonly sourceFile: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** The declared artifact path, validated the same way module discovery validates it, so the build
|
||||
* can never write somewhere discovery would later reject. */
|
||||
async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string> {
|
||||
let manifest: unknown;
|
||||
try {
|
||||
manifest = JSON.parse(
|
||||
await readFile(path.join(moduleDirectory, MODULE_MANIFEST_FILENAME), "utf8"),
|
||||
) as unknown;
|
||||
} catch {
|
||||
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE");
|
||||
}
|
||||
|
||||
const entrypoints = isRecord(manifest) ? manifest.entrypoints : undefined;
|
||||
const declared = isRecord(entrypoints) ? entrypoints.ui : undefined;
|
||||
if (declared === undefined) {
|
||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_NOT_DECLARED");
|
||||
}
|
||||
// The path grammar is Core's, not this tool's: reusing the contracts schema means the build can
|
||||
// never write to a location module discovery would later reject, even if that grammar changes.
|
||||
if (
|
||||
!packageRelativePathSchema.safeParse(declared).success ||
|
||||
typeof declared !== "string" ||
|
||||
![".js", ".mjs"].includes(path.extname(declared).toLowerCase())
|
||||
) {
|
||||
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE");
|
||||
}
|
||||
return path.resolve(moduleDirectory, declared);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundles a module's UI into the artifact its manifest declares.
|
||||
*
|
||||
* Never runs during discovery, startup, or install: this is an authoring-time and packaging-time
|
||||
* step only. Core validates the artifact a module ships; it never produces one (ADR 0021).
|
||||
*/
|
||||
export async function buildModuleUi(options: BuildModuleUiOptions): Promise<ModuleUiBuildResult> {
|
||||
const moduleDirectory = path.resolve(options.moduleDirectory);
|
||||
const outputFile = await resolveDeclaredArtifact(moduleDirectory);
|
||||
const sourceFile = path.join(moduleDirectory, MODULE_UI_SOURCE_ENTRYPOINT);
|
||||
|
||||
try {
|
||||
await readFile(sourceFile);
|
||||
} catch {
|
||||
throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING");
|
||||
}
|
||||
|
||||
try {
|
||||
await build({
|
||||
absWorkingDir: moduleDirectory,
|
||||
// Every dependency, React included, is bundled. The artifact must run in any host, including
|
||||
// one that uses no React at all, so nothing is externalized (ADR 0023).
|
||||
bundle: true,
|
||||
define: {
|
||||
// React reads this at module scope. Without it the bundle would reference `process`, a
|
||||
// Node global that does not exist in a browser, and crash on load.
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
},
|
||||
entryPoints: [sourceFile],
|
||||
// ESM only: the artifact is imported by a host with a dynamic `import()`, and the contract
|
||||
// requires a real default export.
|
||||
format: "esm",
|
||||
jsx: "automatic",
|
||||
// Unminified deliberately: an operator is expected to review the artifact they install
|
||||
// (see docs/development/security.md), and nothing else in this repository minifies.
|
||||
minify: false,
|
||||
outfile: outputFile,
|
||||
platform: "browser",
|
||||
plugins: [rejectStylesheetImports()],
|
||||
sourcemap: true,
|
||||
target: MODULE_UI_BUILD_TARGET,
|
||||
// Fail rather than silently shipping a partial bundle.
|
||||
logLevel: "silent",
|
||||
});
|
||||
} catch (error) {
|
||||
// esbuild's structured diagnostics are surfaced by the CLI; rethrow a sanitized error so a
|
||||
// programmatic caller never receives raw paths or internals.
|
||||
if (isRecord(error) && Array.isArray(error.errors)) {
|
||||
throw Object.assign(new ModuleUiBuildError("MODULE_UI_BUILD_FAILED"), {
|
||||
diagnostics: error.errors,
|
||||
});
|
||||
}
|
||||
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED");
|
||||
}
|
||||
|
||||
return { outputFile, sourceFile };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env node
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { formatMessages } from "esbuild";
|
||||
import { buildModuleUi, ModuleUiBuildError } from "./build-module-ui.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* `ekklesia-build-module-ui` — the canonical module UI build command, run from a module directory.
|
||||
*
|
||||
* A module with no `entrypoints.ui` is not an error: backend-only modules stay first-class and this
|
||||
* command reports that there was nothing to build and exits successfully, so one `build` script
|
||||
* works for every module.
|
||||
*/
|
||||
async function main(): Promise<number> {
|
||||
const moduleDirectory = process.argv[2] ?? process.cwd();
|
||||
|
||||
try {
|
||||
const result = await buildModuleUi({ moduleDirectory });
|
||||
process.stdout.write(`Built module UI: ${path.relative(moduleDirectory, result.outputFile)}\n`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (error instanceof ModuleUiBuildError) {
|
||||
if (error.code === "MODULE_UI_ENTRYPOINT_NOT_DECLARED") {
|
||||
process.stdout.write("No entrypoints.ui declared; skipping the module UI build.\n");
|
||||
return 0;
|
||||
}
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
// esbuild's own diagnostics carry the file, line, and reason a developer needs. They are
|
||||
// printed here, never folded into the sanitized error message itself.
|
||||
const diagnostics = isRecord(error) ? error.diagnostics : undefined;
|
||||
if (Array.isArray(diagnostics) && diagnostics.length > 0) {
|
||||
const formatted = await formatMessages(diagnostics, { kind: "error", color: true });
|
||||
process.stderr.write(formatted.join(""));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
process.stderr.write("The module UI build failed unexpectedly.\n");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
process.exitCode = await main();
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./build-module-ui.js";
|
||||
@@ -0,0 +1,230 @@
|
||||
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<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: 4,
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../typescript-config/node.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user