// Integration-Tests für buildProdBundle — End-to-End-Pfad gegen das
// echte Filesystem.
//
// Zwei Szenarien:
//
// 1. Vanilla-Pipeline (kein client.tsx, nur public/ + html-template):
// läuft unter Node-Vitest direkt — kein Bun.build, keine
// Subprocess-Akrobatik. Beweist Discovery + html-rendering +
// public-copy + manifest-format.
//
// 2. Volle Pipeline (client.tsx + Bun.build + Hash + Manifest):
// braucht Bun. Spawnen kumiko-build als subprocess via PATH.
// Skipped wenn `bun` nicht erreichbar — selten, aber sauber.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { buildProdBundle } from "@cosmicdrift/kumiko-server-runtime/build-prod-bundle";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const KUMIKO_BUILD_BIN = resolve(__dirname, "../../bin/kumiko-build.ts");
// Each of these tests spawns kumiko-build, which runs the real Bun.build +
// tailwind pipeline; bun's 5s default kills it mid-build on a loaded CI runner.
const FULL_PIPELINE_TIMEOUT_MS = 60_000;
function bunAvailable(): boolean {
try {
execFileSync("bun", ["--version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
describe("buildProdBundle (vanilla pipeline)", () => {
let tmp = "";
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), "kumiko-build-it-"));
});
afterEach(async () => {
await rm(tmp, { recursive: true, force: true });
});
test("kopiert public/ 1:1 nach dist/", async () => {
await mkdir(join(tmp, "public"), { recursive: true });
await writeFile(join(tmp, "public/favicon.txt"), "fav-content");
await writeFile(join(tmp, "public/robots.txt"), "User-agent: *\nDisallow:\n");
await writeFile(
join(tmp, "public/index.html"),
`
Hello`,
);
const result = await buildProdBundle({ cwd: tmp });
expect(result.manifest).toEqual({});
expect(await readFile(join(tmp, "dist/favicon.txt"), "utf8")).toBe("fav-content");
expect(await readFile(join(tmp, "dist/robots.txt"), "utf8")).toContain("User-agent");
});
test("rendert public/index.html als Template (Inhalt erhalten)", async () => {
await mkdir(join(tmp, "public"), { recursive: true });
await writeFile(
join(tmp, "public/index.html"),
`Custom TitleCustom Body`,
);
await buildProdBundle({ cwd: tmp });
const html = await readFile(join(tmp, "dist/index.html"), "utf8");
expect(html).toContain("Custom Title");
expect(html).toContain("Custom Body");
});
test("schreibt manifest.json auch wenn leer", async () => {
await mkdir(join(tmp, "public"), { recursive: true });
await writeFile(join(tmp, "public/index.html"), ``);
await buildProdBundle({ cwd: tmp });
const manifest = JSON.parse(await readFile(join(tmp, "dist/manifest.json"), "utf8"));
expect(manifest).toEqual({});
});
test("public/index.html landet NICHT als rohes File in dist/ (wird als Template behandelt)", async () => {
await mkdir(join(tmp, "public"), { recursive: true });
const original = `SOURCE`;
await writeFile(join(tmp, "public/index.html"), original);
await writeFile(join(tmp, "public/other.txt"), "ABC");
await buildProdBundle({ cwd: tmp });
// dist/index.html existiert (gerendert), public/other.txt wurde kopiert
expect(existsSync(join(tmp, "dist/index.html"))).toBe(true);
expect(existsSync(join(tmp, "dist/other.txt"))).toBe(true);
// Inhalt: was im Template stand, kommt im Output an (kein Doppel-Pfad
// wo public/index.html und dist/index.html sich gegenseitig überschreiben).
expect(await readFile(join(tmp, "dist/index.html"), "utf8")).toContain("SOURCE");
});
test("clean wipe: vorhandenes dist/-Junk wird entfernt", async () => {
await mkdir(join(tmp, "public"), { recursive: true });
await writeFile(join(tmp, "public/index.html"), ``);
// Junk-File aus altem Build
await mkdir(join(tmp, "dist/old"), { recursive: true });
await writeFile(join(tmp, "dist/old/stale.js"), "old content");
await buildProdBundle({ cwd: tmp });
expect(existsSync(join(tmp, "dist/old/stale.js"))).toBe(false);
});
test("wirft mit klarer Message wenn weder public/ noch index.html noch client da", async () => {
// tmp ist leer
await expect(buildProdBundle({ cwd: tmp })).rejects.toThrow(/nothing to build/);
});
test("wirft mit Anweisung wenn HTML-Template ohne /client.js Placeholder vorliegt", async () => {
// Simuliert: User hat bereits manifest-entry (faken über option, in
// realer Pipeline kommt's aus Bun.build). Wir testen renderHtml /
// injectAssetTags-Verhalten via lower-level Pfad: HTML ohne Tag,
// public-folder simuliert dass Build was zu tun hat. Erwartete
// Error-Message zitiert das exakte Snippet.
//
// Dieser Pfad wird im realen Build durch buildClientBundle ausgelöst
// und im Vanilla-Test können wir ihn nicht direkt triggern (kein
// Bun.build). Der CLI-Subprocess-Test deckt das ab.
await mkdir(join(tmp, "public"), { recursive: true });
await writeFile(
join(tmp, "public/index.html"),
`no script tag here`,
);
// Ohne client.tsx läuft Bun.build nicht, manifest bleibt {} →
// injectAssetTags wirft NICHT. Das ist gewollt: wenn nichts zu
// injizieren da ist, ist's auch kein Fehler.
const result = await buildProdBundle({ cwd: tmp });
expect(result.manifest).toEqual({});
});
});
// Voller Pipeline-Test gegen das echte CLI-bin via subprocess. Bringt
// echtes Bun.build + Tailwind ins Spiel — der Pfad den die anderen
// Tests bewusst nicht laufen können (Vitest unter Node).
describe.skipIf(!bunAvailable())("kumiko-build CLI (full pipeline with bun)", () => {
let tmp = "";
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), "kumiko-build-cli-"));
});
afterEach(async () => {
await rm(tmp, { recursive: true, force: true });
});
test(
"client.ts → hashed bundle, manifest, html mit injected script-tag",
async () => {
// Minimal client ohne externe Deps — Bun.build resolvt nichts.
await mkdir(join(tmp, "src"), { recursive: true });
await writeFile(
join(tmp, "src/client.ts"),
`const root = document.getElementById("root"); if (root) root.textContent = "hi";`,
);
await mkdir(join(tmp, "public"), { recursive: true });
await writeFile(
join(tmp, "public/index.html"),
``,
);
// package.json mit stylesheet:false äquivalent — wir setzen kein
// src/styles.css und sind außerhalb des monorepos, sodass der
// renderer-web-Fallback fehlschlägt und gracefully undefined liefert.
await writeFile(join(tmp, "package.json"), `{"name":"build-it-fixture","private":true}`);
execFileSync("bun", [KUMIKO_BUILD_BIN, tmp], { stdio: "pipe" });
const manifest = JSON.parse(
await readFile(join(tmp, "dist/manifest.json"), "utf8"),
) as Record;
expect(manifest["client.js"]).toMatch(/^\/assets\/client-[a-z0-9]+\.js$/);
const html = await readFile(join(tmp, "dist/index.html"), "utf8");
expect(html).toContain(`src="${manifest["client.js"]}"`);
expect(html).toContain('id="root"');
// Hashed asset existiert im dist/
const assetPath = join(tmp, "dist", manifest["client.js"] ?? "");
expect(existsSync(assetPath)).toBe(true);
// Bundle enthält den User-Code (minified — also Identifier-Namen
// mangled, aber der String-Literal "hi" überlebt).
expect(await readFile(assetPath, "utf8")).toContain('"hi"');
},
FULL_PIPELINE_TIMEOUT_MS,
);
test(
"client.ts ohne index.html → klarer Error mit Template-Vorschlag",
async () => {
await mkdir(join(tmp, "src"), { recursive: true });
await writeFile(join(tmp, "src/client.ts"), `console.log("hi");`);
await writeFile(join(tmp, "package.json"), `{"name":"no-html","private":true}`);
let stderr = "";
expect(() => {
try {
execFileSync("bun", [KUMIKO_BUILD_BIN, tmp], { stdio: "pipe" });
} catch (err) {
const e = err as { stderr?: Buffer };
stderr = e.stderr?.toString() ?? "";
throw err;
}
}).toThrow();
expect(stderr).toContain("kein index.html gefunden");
expect(stderr).toContain(``);
},
FULL_PIPELINE_TIMEOUT_MS,
);
test(
"client.ts + index.html ohne /client.js Placeholder → klarer Error",
async () => {
await mkdir(join(tmp, "src"), { recursive: true });
await writeFile(join(tmp, "src/client.ts"), `console.log("hi");`);
await mkdir(join(tmp, "public"), { recursive: true });
await writeFile(
join(tmp, "public/index.html"),
`no script`,
);
await writeFile(join(tmp, "package.json"), `{"name":"no-placeholder","private":true}`);
let stderr = "";
expect(() => {
try {
execFileSync("bun", [KUMIKO_BUILD_BIN, tmp], { stdio: "pipe" });
} catch (err) {
const e = err as { stderr?: Buffer };
stderr = e.stderr?.toString() ?? "";
throw err;
}
}).toThrow();
expect(stderr).toContain("keinen Entry-Tag für /client.js");
expect(stderr).toContain(``);
},
FULL_PIPELINE_TIMEOUT_MS,
);
test(
"Re-Build mit unverändertem Source produziert identischen Hash (reproducibility)",
async () => {
await mkdir(join(tmp, "src"), { recursive: true });
await writeFile(join(tmp, "src/client.ts"), `console.log("stable");`);
await mkdir(join(tmp, "public"), { recursive: true });
await writeFile(
join(tmp, "public/index.html"),
``,
);
await writeFile(join(tmp, "package.json"), `{"name":"hash-stability","private":true}`);
execFileSync("bun", [KUMIKO_BUILD_BIN, tmp], { stdio: "pipe" });
const manifest1 = JSON.parse(
await readFile(join(tmp, "dist/manifest.json"), "utf8"),
) as Record;
execFileSync("bun", [KUMIKO_BUILD_BIN, tmp], { stdio: "pipe" });
const manifest2 = JSON.parse(
await readFile(join(tmp, "dist/manifest.json"), "utf8"),
) as Record;
expect(manifest2["client.js"]).toBe(manifest1["client.js"]);
},
FULL_PIPELINE_TIMEOUT_MS,
);
});