421 lines
15 KiB
TypeScript
421 lines
15 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 { 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: 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");
|
|
});
|
|
});
|