feat: add display kiosk host
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 59s

This commit is contained in:
ekklesia
2026-08-23 20:50:56 -05:00
parent be5ed7853d
commit 2e0fe059c3
80 changed files with 4868 additions and 397 deletions
+171 -71
View File
@@ -1,17 +1,18 @@
import { readFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { packageRelativePathSchema } from "@ekklesia/contracts";
import { moduleDisplayEntrypointSchema, 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).
* The one supported way to turn a module's React/TSX UI sources into the compiled browser artifacts
* its manifest declares as `entrypoints.ui` and `entrypoints.display`.
*
* 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
* the fixed role-specific source locations below, declare either or both artifacts, and run one
* 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_DISPLAY_SOURCE_ENTRYPOINT = "src/display/index.tsx";
export const MODULE_MANIFEST_FILENAME = "ekklesia.module.json";
/** Matches `browser.json`'s `target`, so what TypeScript typechecks and what esbuild emits agree. */
@@ -19,17 +20,21 @@ 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_ENTRYPOINT_COLLISION"
| "MODULE_UI_SOURCE_MISSING"
| "MODULE_UI_BUILD_FAILED";
export type ModuleUiArtifactKind = "ui" | "display";
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.",
MODULE_UI_ENTRYPOINT_UNSAFE:
"A module UI entrypoint must be a package-relative path to a compiled .js or .mjs artifact inside the module.",
MODULE_UI_ENTRYPOINT_COLLISION:
"A display UI entrypoint must not overwrite another declared artifact.",
MODULE_UI_SOURCE_MISSING: "A declared module UI source entrypoint was not found.",
MODULE_UI_BUILD_FAILED: "A module UI bundle could not be produced.",
});
/** Carries a stable code and a fixed message. esbuild's own diagnostics are printed to stderr by
@@ -37,11 +42,17 @@ const errorMessages: Readonly<Record<ModuleUiBuildErrorCode, string>> = Object.f
* other Ekklesia error boundary. */
export class ModuleUiBuildError extends Error {
readonly code: ModuleUiBuildErrorCode;
/** Safe role vocabulary that lets a developer identify which declared artifact failed without
* exposing a filesystem path or raw manifest value. */
readonly kind?: ModuleUiArtifactKind;
constructor(code: ModuleUiBuildErrorCode) {
constructor(code: ModuleUiBuildErrorCode, kind?: ModuleUiArtifactKind) {
super(errorMessages[code]);
this.name = "ModuleUiBuildError";
this.code = code;
if (kind !== undefined) {
this.kind = kind;
}
this.stack = `${this.name}: ${this.message}`;
}
}
@@ -83,9 +94,16 @@ export interface BuildModuleUiOptions {
}
export interface ModuleUiBuildResult {
/** Absolute path of the artifact actually written, which is exactly what the manifest declares. */
/** Built artifacts in stable role order: primary UI first, then display. Empty for backend-only
* modules, which makes the canonical command usable unchanged by every module package. */
readonly artifacts: readonly ModuleUiBuildArtifactResult[];
}
export interface ModuleUiBuildArtifactResult {
readonly kind: ModuleUiArtifactKind;
/** Absolute path of the artifact actually written, exactly as the manifest declares. */
readonly outputFile: string;
/** Absolute path of the source entrypoint that produced it. */
/** Absolute path of the fixed source entrypoint that produced it. */
readonly sourceFile: string;
}
@@ -93,9 +111,12 @@ 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> {
type DeclaredModuleUiArtifact = ModuleUiBuildArtifactResult;
/** Reads the inert manifest and returns every declared UI artifact in deterministic role order. */
async function resolveDeclaredArtifacts(
moduleDirectory: string,
): Promise<readonly DeclaredModuleUiArtifact[]> {
let manifest: unknown;
try {
manifest = JSON.parse(
@@ -106,20 +127,81 @@ async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string>
}
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");
if (!isRecord(entrypoints)) {
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE");
}
// 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");
const display = entrypoints.display;
const declarations: readonly {
readonly declared: unknown;
readonly kind: ModuleUiArtifactKind;
readonly present: boolean;
readonly source: string;
}[] = [
{
declared: entrypoints.ui,
kind: "ui",
present: entrypoints.ui !== undefined,
source: MODULE_UI_SOURCE_ENTRYPOINT,
},
{
declared: isRecord(display) ? display.artifact : display,
kind: "display",
present: display !== undefined,
source: MODULE_DISPLAY_SOURCE_ENTRYPOINT,
},
];
const artifacts: DeclaredModuleUiArtifact[] = [];
for (const declaration of declarations) {
if (!declaration.present) {
continue;
}
const declared = declaration.declared;
// The path grammar is Core's, not this tool's: reusing the contracts schema means the build can
// never write somewhere discovery would later reject, even if that grammar changes.
if (
typeof declared !== "string" ||
!packageRelativePathSchema.safeParse(declared).success ||
![".js", ".mjs"].includes(path.extname(declared).toLowerCase())
) {
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_UNSAFE", declaration.kind);
}
artifacts.push({
kind: declaration.kind,
outputFile: path.resolve(moduleDirectory, declared),
sourceFile: path.join(moduleDirectory, declaration.source),
});
}
return path.resolve(moduleDirectory, declared);
if (display !== undefined && !moduleDisplayEntrypointSchema.safeParse(display).success) {
throw new ModuleUiBuildError("MODULE_UI_MANIFEST_UNREADABLE", "display");
}
const displayArtifact = artifacts.find((artifact) => artifact.kind === "display");
if (displayArtifact !== undefined) {
for (const entrypointName of ["server", "schema", "migrations", "jobs", "ui"] as const) {
const configured = entrypoints[entrypointName];
if (
typeof configured === "string" &&
path.resolve(moduleDirectory, configured) === displayArtifact.outputFile
) {
throw new ModuleUiBuildError("MODULE_UI_ENTRYPOINT_COLLISION", "display");
}
}
}
// Complete preflight before esbuild starts: a missing second source must not leave the first role's
// output looking like a successful package build.
for (const artifact of artifacts) {
try {
await readFile(artifact.sourceFile);
} catch {
throw new ModuleUiBuildError("MODULE_UI_SOURCE_MISSING", artifact.kind);
}
}
return artifacts;
}
/**
@@ -130,52 +212,70 @@ async function resolveDeclaredArtifact(moduleDirectory: string): Promise<string>
*/
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);
const artifacts = await resolveDeclaredArtifacts(moduleDirectory);
const pendingWrites: Array<readonly { path: string; contents: Uint8Array }[]> = [];
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,
for (const artifact of artifacts) {
try {
const result = 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: [artifact.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: artifact.outputFile,
platform: "browser",
plugins: [rejectStylesheetImports()],
sourcemap: true,
target: MODULE_UI_BUILD_TARGET,
// Fail rather than silently shipping a partial bundle.
logLevel: "silent",
// Compile every role successfully before writing any role. This makes a two-artifact build
// atomic with respect to parse/resolve/bundle failures rather than leaving a stale partial set.
write: false,
});
pendingWrites.push(
result.outputFiles.map((outputFile) => ({
contents: outputFile.contents,
path: outputFile.path,
})),
);
} 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", artifact.kind), {
diagnostics: error.errors,
});
}
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifact.kind);
}
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED");
}
return { outputFile, sourceFile };
for (const [index, outputs] of pendingWrites.entries()) {
try {
for (const output of outputs) {
await mkdir(path.dirname(output.path), { recursive: true });
await writeFile(output.path, output.contents);
}
} catch {
throw new ModuleUiBuildError("MODULE_UI_BUILD_FAILED", artifacts[index]?.kind);
}
}
return Object.freeze({
artifacts: Object.freeze(artifacts.map((artifact) => Object.freeze({ ...artifact }))),
});
}
+13 -9
View File
@@ -11,24 +11,28 @@ function isRecord(value: unknown): value is Record<string, unknown> {
/**
* `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.
* A module with neither UI role is not an error: backend-only modules stay first-class and this
* command reports that there was nothing to build and exits successfully. When one or both roles
* are declared, this one command builds exactly that set.
*/
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`);
if (result.artifacts.length === 0) {
process.stdout.write("No module UI entrypoints declared; skipping the module UI build.\n");
return 0;
}
for (const artifact of result.artifacts) {
process.stdout.write(
`Built module UI (${artifact.kind}): ${path.relative(moduleDirectory, artifact.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`);
process.stderr.write(`${error.kind ? `${error.kind}: ` : ""}${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;
@@ -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");