@cosmicdrift/kumiko-dev-server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/bin/kumiko-build.ts +85 -0
  2. package/bin/kumiko-dev.ts +90 -0
  3. package/package.json +45 -0
  4. package/src/__tests__/build-prod-bundle.integration.ts +265 -0
  5. package/src/__tests__/build-prod-bundle.test.ts +262 -0
  6. package/src/__tests__/cache-headers.test.ts +70 -0
  7. package/src/__tests__/classify-change.test.ts +87 -0
  8. package/src/__tests__/compose-features-wiring.integration.ts +352 -0
  9. package/src/__tests__/compose-features.test.ts +81 -0
  10. package/src/__tests__/crash-tracker.test.ts +89 -0
  11. package/src/__tests__/create-kumiko-server.integration.ts +286 -0
  12. package/src/__tests__/few-shot-corpus.test.ts +311 -0
  13. package/src/__tests__/inject-schema.test.ts +62 -0
  14. package/src/__tests__/resolve-stylesheet.test.ts +90 -0
  15. package/src/__tests__/resolve-tailwind-cli.test.ts +49 -0
  16. package/src/__tests__/run-prod-app-spec.test.ts +57 -0
  17. package/src/__tests__/run-prod-app.integration.ts +535 -0
  18. package/src/__tests__/scaffold-feature.test.ts +143 -0
  19. package/src/__tests__/try-hono-first.test.ts +63 -0
  20. package/src/build-prod-bundle.ts +587 -0
  21. package/src/build-server-bundle.ts +308 -0
  22. package/src/build.ts +28 -0
  23. package/src/codegen/__tests__/run-codegen.test.ts +494 -0
  24. package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +467 -0
  25. package/src/codegen/__tests__/watch.test.ts +186 -0
  26. package/src/codegen/index.ts +17 -0
  27. package/src/codegen/render.ts +225 -0
  28. package/src/codegen/run-codegen.ts +157 -0
  29. package/src/codegen/scan-events.ts +574 -0
  30. package/src/codegen/watch.ts +127 -0
  31. package/src/compose-features.ts +128 -0
  32. package/src/crash-tracker.ts +56 -0
  33. package/src/create-kumiko-server.ts +1010 -0
  34. package/src/drizzle-config.ts +44 -0
  35. package/src/drizzle-tables-auth-mode.ts +32 -0
  36. package/src/drizzle-tables-minimal.ts +22 -0
  37. package/src/few-shot-corpus.ts +369 -0
  38. package/src/index.ts +57 -0
  39. package/src/inject-schema.ts +24 -0
  40. package/src/resolve-tailwind-cli.ts +28 -0
  41. package/src/run-dev-app.ts +290 -0
  42. package/src/run-prod-app.ts +892 -0
  43. package/src/scaffold-feature.ts +226 -0
  44. package/src/try-hono-first.ts +46 -0
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env bun
2
+ // kumiko-build — Production-Build für Kumiko-Apps. Convention-driven:
3
+ //
4
+ // src/client.tsx | src/styles.css | public/ → Client-Bundle (dist/)
5
+ // bin/main.ts → Server-Bundle (dist-server/)
6
+ //
7
+ // Beide werden gebaut wenn die jeweiligen Conventions getroffen sind, sonst
8
+ // übersprungen. Ein Workspace mit nur bin/main.ts kriegt nur den Server-
9
+ // Bundle, ein Browser-only-Sample nur den Client.
10
+ //
11
+ // {
12
+ // "scripts": { "build": "kumiko-build" }
13
+ // }
14
+ //
15
+ // Optionaler Pfad-Parameter überschreibt cwd:
16
+ // kumiko-build samples/apps/showcase
17
+
18
+ import { existsSync } from "node:fs";
19
+ import { join, resolve } from "node:path";
20
+ import {
21
+ buildProdBundle,
22
+ buildServerBundle,
23
+ discoverClientEntries,
24
+ discoverServerEntry,
25
+ formatBuildResult,
26
+ formatServerBuildResult,
27
+ } from "../src/build";
28
+ import { runCodegen } from "../src/codegen";
29
+
30
+ const explicit = process.argv[2];
31
+ const cwd = explicit ? resolve(process.cwd(), explicit) : process.cwd();
32
+
33
+ const red = "\x1b[31m";
34
+ const yellow = "\x1b[33m";
35
+ const reset = "\x1b[0m";
36
+
37
+ const hasClient =
38
+ discoverClientEntries(cwd).length > 0 ||
39
+ existsSync(join(cwd, "src/styles.css")) ||
40
+ existsSync(join(cwd, "public")) ||
41
+ existsSync(join(cwd, "index.html"));
42
+ const hasServer = discoverServerEntry(cwd) !== undefined;
43
+
44
+ if (!hasClient && !hasServer) {
45
+ // biome-ignore lint/suspicious/noConsole: CLI-Output, einziger Weg
46
+ console.error(
47
+ `\n ${yellow}!${reset} Nichts zu bauen in ${cwd}.\n` +
48
+ ` Convention: src/client.tsx oder bin/main.ts erwartet.\n`,
49
+ );
50
+ process.exit(1);
51
+ }
52
+
53
+ try {
54
+ // Codegen-Pass vor dem Build — sicherstellt dass `.kumiko/define.ts`
55
+ // und `types.generated.d.ts` synchron mit den r.defineEvent-Aufrufen
56
+ // sind. Ohne diesen Pass würde ein veraltetes Wrapper-File ein
57
+ // Build-Error oder schlimmer: einen Build mit stale Augmentation
58
+ // erzeugen, der zur Laufzeit die falschen Events erlaubt.
59
+ const cgResult = runCodegen({ appRoot: cwd });
60
+ if (cgResult.warnings.length > 0) {
61
+ for (const w of cgResult.warnings) {
62
+ // biome-ignore lint/suspicious/noConsole: CLI-Output
63
+ console.warn(`${yellow}!${reset} [codegen] ${w.file}:${w.line} — ${w.reason}`);
64
+ }
65
+ }
66
+
67
+ if (hasClient) {
68
+ const t0 = performance.now();
69
+ const result = await buildProdBundle({ cwd });
70
+ const ms = Math.round(performance.now() - t0);
71
+ // biome-ignore lint/suspicious/noConsole: CLI-Output, einziger Weg
72
+ console.log(formatBuildResult(result, ms));
73
+ }
74
+ if (hasServer) {
75
+ const t0 = performance.now();
76
+ const result = await buildServerBundle({ cwd });
77
+ const ms = Math.round(performance.now() - t0);
78
+ // biome-ignore lint/suspicious/noConsole: CLI-Output, einziger Weg
79
+ console.log(formatServerBuildResult(result, ms));
80
+ }
81
+ } catch (err) {
82
+ // biome-ignore lint/suspicious/noConsole: CLI-Output, einziger Weg
83
+ console.error(`\n ${red}✗${reset} ${err instanceof Error ? err.message : String(err)}\n`);
84
+ process.exit(1);
85
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bun
2
+ // kumiko-dev — Wrapper-Skript für sample dev-scripts. Spawnt den
3
+ // übergebenen Server-Entry und respawnt automatisch.
4
+ //
5
+ // Restart-Policy:
6
+ // - exit 75 (EX_TEMPFAIL, vom dev-server beim Schema-Change ausgelöst,
7
+ // weil Bun's Module-Cache einen Process-Restart erzwingt): sofortiger
8
+ // respawn ohne Crash-Loop-Anrechnung — das ist erwartetes Verhalten.
9
+ // - jeder andere non-zero exit: respawn mit kurzem Backoff, aber
10
+ // Crash-Loop-Schutz via createCrashTracker. Verhindert Endlos-Loop
11
+ // bei syntaktisch totem bin/main.ts und gibt dem User trotzdem
12
+ // "Live-Edit"-Feeling: ein Code-Fehler in einem Feature killt nicht
13
+ // die ganze yarn-dev-Session.
14
+ // - Signal-killed (SIGINT/SIGTERM): wir folgen dem Caller, exit 0.
15
+ //
16
+ // Nutzung: `kumiko-dev src/app/server.ts` in package.json:
17
+ //
18
+ // "scripts": { "dev": "kumiko-dev src/app/server.ts" }
19
+
20
+ import { spawn } from "node:child_process";
21
+ import process from "node:process";
22
+ import { createCrashTracker } from "../src/crash-tracker";
23
+
24
+ const SCHEMA_RESTART_EXIT_CODE = 75;
25
+ const MAX_CRASHES = 5;
26
+ const CRASH_WINDOW_MS = 10_000;
27
+ const CRASH_BACKOFF_MS = 500;
28
+
29
+ const entry = process.argv[2];
30
+ if (entry === undefined || entry === "") {
31
+ process.stderr.write("Usage: kumiko-dev <server-entry.ts>\n");
32
+ process.exit(2);
33
+ }
34
+
35
+ // Restliche Args (z.B. --port 4175) reichen wir an den Server durch.
36
+ const passthroughArgs = process.argv.slice(3);
37
+
38
+ // Bun's eigener Pfad steht in process.argv[0] — den nutzen wir, damit
39
+ // `bun --env-file=...` Flags vom Caller nicht verloren gehen. Wir
40
+ // reichen NICHT alle Bun-Flags durch (kompliziert mit shell-Escaping);
41
+ // dev-scripts in samples setzen Env-Vars direkt im Aufruf wenn nötig.
42
+ const bunPath = process.argv[0] ?? "bun";
43
+
44
+ const crashTracker = createCrashTracker({
45
+ maxCrashes: MAX_CRASHES,
46
+ windowMs: CRASH_WINDOW_MS,
47
+ });
48
+
49
+ const spawnServer = (): void => {
50
+ const child = spawn(bunPath, ["run", entry, ...passthroughArgs], {
51
+ stdio: "inherit",
52
+ env: process.env,
53
+ });
54
+
55
+ child.on("exit", (code, signal) => {
56
+ if (signal !== null) {
57
+ // Signal-killed (z.B. SIGINT): Caller wollte raus → wir auch.
58
+ process.exit(0);
59
+ return;
60
+ }
61
+ if (code === SCHEMA_RESTART_EXIT_CODE) {
62
+ process.stdout.write("[kumiko-dev] respawn after schema change…\n");
63
+ spawnServer();
64
+ return;
65
+ }
66
+ if (code === 0) {
67
+ process.exit(0);
68
+ return;
69
+ }
70
+ // Non-zero exit ohne Schema-Change: Code-Fehler oder transienter
71
+ // Crash. Respawn mit Backoff + Crash-Loop-Schutz.
72
+ const now = Date.now();
73
+ const allowed = crashTracker.noteCrash(now);
74
+ if (!allowed) {
75
+ process.stderr.write(
76
+ `[kumiko-dev] ${MAX_CRASHES} Crashes in ${CRASH_WINDOW_MS / 1000}s — aufgeben (exit ${code}). ` +
77
+ "Fehler oben fixen und yarn dev erneut starten.\n",
78
+ );
79
+ process.exit(code);
80
+ return;
81
+ }
82
+ process.stderr.write(
83
+ `[kumiko-dev] server exited with code ${code} — respawn in ${CRASH_BACKOFF_MS}ms ` +
84
+ `(${crashTracker.crashCountInWindow(now)}/${MAX_CRASHES} in window)\n`,
85
+ );
86
+ setTimeout(spawnServer, CRASH_BACKOFF_MS);
87
+ });
88
+ };
89
+
90
+ spawnServer();
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@cosmicdrift/kumiko-dev-server",
3
+ "version": "0.1.0",
4
+ "description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
5
+ "license": "BUSL-1.1",
6
+ "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/cosmicdriftgamestudio/kumiko-framework.git",
10
+ "directory": "packages/dev-server"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/cosmicdriftgamestudio/kumiko-framework/issues"
14
+ },
15
+ "homepage": "https://kumiko.so",
16
+ "type": "module",
17
+ "kumiko": {
18
+ "runtime": "dev"
19
+ },
20
+ "exports": {
21
+ ".": "./src/index.ts",
22
+ "./build": "./src/build.ts",
23
+ "./compose-features": "./src/compose-features.ts",
24
+ "./drizzle-config": "./src/drizzle-config.ts",
25
+ "./drizzle-tables-auth-mode": "./src/drizzle-tables-auth-mode.ts",
26
+ "./drizzle-tables-minimal": "./src/drizzle-tables-minimal.ts"
27
+ },
28
+ "bin": {
29
+ "kumiko-build": "./bin/kumiko-build.ts",
30
+ "kumiko-dev": "./bin/kumiko-dev.ts"
31
+ },
32
+ "dependencies": {
33
+ "@cosmicdrift/kumiko-bundled-features": "workspace:*",
34
+ "@cosmicdrift/kumiko-framework": "workspace:*"
35
+ },
36
+ "publishConfig": {
37
+ "registry": "https://registry.npmjs.org",
38
+ "access": "public"
39
+ },
40
+ "files": [
41
+ "src",
42
+ "README.md",
43
+ "LICENSE"
44
+ ]
45
+ }
@@ -0,0 +1,265 @@
1
+ // Integration-Tests für buildProdBundle — End-to-End-Pfad gegen das
2
+ // echte Filesystem.
3
+ //
4
+ // Zwei Szenarien:
5
+ //
6
+ // 1. Vanilla-Pipeline (kein client.tsx, nur public/ + html-template):
7
+ // läuft unter Node-Vitest direkt — kein Bun.build, keine
8
+ // Subprocess-Akrobatik. Beweist Discovery + html-rendering +
9
+ // public-copy + manifest-format.
10
+ //
11
+ // 2. Volle Pipeline (client.tsx + Bun.build + Hash + Manifest):
12
+ // braucht Bun. Spawnen kumiko-build als subprocess via PATH.
13
+ // Skipped wenn `bun` nicht erreichbar — selten, aber sauber.
14
+
15
+ import { execFileSync } from "node:child_process";
16
+ import { existsSync } from "node:fs";
17
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
18
+ import { tmpdir } from "node:os";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
22
+ import { buildProdBundle } from "../build-prod-bundle";
23
+
24
+ const __filename = fileURLToPath(import.meta.url);
25
+ const __dirname = dirname(__filename);
26
+ const KUMIKO_BUILD_BIN = resolve(__dirname, "../../bin/kumiko-build.ts");
27
+
28
+ function bunAvailable(): boolean {
29
+ try {
30
+ execFileSync("bun", ["--version"], { stdio: "ignore" });
31
+ return true;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ describe("buildProdBundle (vanilla pipeline)", () => {
38
+ let tmp = "";
39
+
40
+ beforeEach(async () => {
41
+ tmp = await mkdtemp(join(tmpdir(), "kumiko-build-it-"));
42
+ });
43
+
44
+ afterEach(async () => {
45
+ await rm(tmp, { recursive: true, force: true });
46
+ });
47
+
48
+ test("kopiert public/ 1:1 nach dist/", async () => {
49
+ await mkdir(join(tmp, "public"), { recursive: true });
50
+ await writeFile(join(tmp, "public/favicon.txt"), "fav-content");
51
+ await writeFile(join(tmp, "public/robots.txt"), "User-agent: *\nDisallow:\n");
52
+ await writeFile(
53
+ join(tmp, "public/index.html"),
54
+ `<!doctype html><html><body>Hello</body></html>`,
55
+ );
56
+
57
+ const result = await buildProdBundle({ cwd: tmp });
58
+
59
+ expect(result.manifest).toEqual({});
60
+ expect(await readFile(join(tmp, "dist/favicon.txt"), "utf8")).toBe("fav-content");
61
+ expect(await readFile(join(tmp, "dist/robots.txt"), "utf8")).toContain("User-agent");
62
+ });
63
+
64
+ test("rendert public/index.html als Template (Inhalt erhalten)", async () => {
65
+ await mkdir(join(tmp, "public"), { recursive: true });
66
+ await writeFile(
67
+ join(tmp, "public/index.html"),
68
+ `<!doctype html><html><head><title>Custom Title</title></head><body>Custom Body</body></html>`,
69
+ );
70
+
71
+ await buildProdBundle({ cwd: tmp });
72
+
73
+ const html = await readFile(join(tmp, "dist/index.html"), "utf8");
74
+ expect(html).toContain("Custom Title");
75
+ expect(html).toContain("Custom Body");
76
+ });
77
+
78
+ test("schreibt manifest.json auch wenn leer", async () => {
79
+ await mkdir(join(tmp, "public"), { recursive: true });
80
+ await writeFile(join(tmp, "public/index.html"), `<html></html>`);
81
+
82
+ await buildProdBundle({ cwd: tmp });
83
+
84
+ const manifest = JSON.parse(await readFile(join(tmp, "dist/manifest.json"), "utf8"));
85
+ expect(manifest).toEqual({});
86
+ });
87
+
88
+ test("public/index.html landet NICHT als rohes File in dist/ (wird als Template behandelt)", async () => {
89
+ await mkdir(join(tmp, "public"), { recursive: true });
90
+ const original = `<!doctype html><html><body>SOURCE</body></html>`;
91
+ await writeFile(join(tmp, "public/index.html"), original);
92
+ await writeFile(join(tmp, "public/other.txt"), "ABC");
93
+
94
+ await buildProdBundle({ cwd: tmp });
95
+
96
+ // dist/index.html existiert (gerendert), public/other.txt wurde kopiert
97
+ expect(existsSync(join(tmp, "dist/index.html"))).toBe(true);
98
+ expect(existsSync(join(tmp, "dist/other.txt"))).toBe(true);
99
+ // Inhalt: was im Template stand, kommt im Output an (kein Doppel-Pfad
100
+ // wo public/index.html und dist/index.html sich gegenseitig überschreiben).
101
+ expect(await readFile(join(tmp, "dist/index.html"), "utf8")).toContain("SOURCE");
102
+ });
103
+
104
+ test("clean wipe: vorhandenes dist/-Junk wird entfernt", async () => {
105
+ await mkdir(join(tmp, "public"), { recursive: true });
106
+ await writeFile(join(tmp, "public/index.html"), `<html></html>`);
107
+ // Junk-File aus altem Build
108
+ await mkdir(join(tmp, "dist/old"), { recursive: true });
109
+ await writeFile(join(tmp, "dist/old/stale.js"), "old content");
110
+
111
+ await buildProdBundle({ cwd: tmp });
112
+
113
+ expect(existsSync(join(tmp, "dist/old/stale.js"))).toBe(false);
114
+ });
115
+
116
+ test("wirft mit klarer Message wenn weder public/ noch index.html noch client da", async () => {
117
+ // tmp ist leer
118
+ await expect(buildProdBundle({ cwd: tmp })).rejects.toThrow(/nothing to build/);
119
+ });
120
+
121
+ test("wirft mit Anweisung wenn HTML-Template ohne /client.js Placeholder vorliegt", async () => {
122
+ // Simuliert: User hat bereits manifest-entry (faken über option, in
123
+ // realer Pipeline kommt's aus Bun.build). Wir testen renderHtml /
124
+ // injectAssetTags-Verhalten via lower-level Pfad: HTML ohne Tag,
125
+ // public-folder simuliert dass Build was zu tun hat. Erwartete
126
+ // Error-Message zitiert das exakte Snippet.
127
+ //
128
+ // Dieser Pfad wird im realen Build durch buildClientBundle ausgelöst
129
+ // und im Vanilla-Test können wir ihn nicht direkt triggern (kein
130
+ // Bun.build). Der CLI-Subprocess-Test deckt das ab.
131
+ await mkdir(join(tmp, "public"), { recursive: true });
132
+ await writeFile(
133
+ join(tmp, "public/index.html"),
134
+ `<!doctype html><html><body>no script tag here</body></html>`,
135
+ );
136
+ // Ohne client.tsx läuft Bun.build nicht, manifest bleibt {} →
137
+ // injectAssetTags wirft NICHT. Das ist gewollt: wenn nichts zu
138
+ // injizieren da ist, ist's auch kein Fehler.
139
+ const result = await buildProdBundle({ cwd: tmp });
140
+ expect(result.manifest).toEqual({});
141
+ });
142
+ });
143
+
144
+ // Voller Pipeline-Test gegen das echte CLI-bin via subprocess. Bringt
145
+ // echtes Bun.build + Tailwind ins Spiel — der Pfad den die anderen
146
+ // Tests bewusst nicht laufen können (Vitest unter Node).
147
+ describe.skipIf(!bunAvailable())("kumiko-build CLI (full pipeline with bun)", () => {
148
+ let tmp = "";
149
+
150
+ beforeEach(async () => {
151
+ tmp = await mkdtemp(join(tmpdir(), "kumiko-build-cli-"));
152
+ });
153
+
154
+ afterEach(async () => {
155
+ await rm(tmp, { recursive: true, force: true });
156
+ });
157
+
158
+ test("client.ts → hashed bundle, manifest, html mit injected script-tag", async () => {
159
+ // Minimal client ohne externe Deps — Bun.build resolvt nichts.
160
+ await mkdir(join(tmp, "src"), { recursive: true });
161
+ await writeFile(
162
+ join(tmp, "src/client.ts"),
163
+ `const root = document.getElementById("root"); if (root) root.textContent = "hi";`,
164
+ );
165
+ await mkdir(join(tmp, "public"), { recursive: true });
166
+ await writeFile(
167
+ join(tmp, "public/index.html"),
168
+ `<!doctype html><html><head></head><body><div id="root"></div><script type="module" src="/client.js"></script></body></html>`,
169
+ );
170
+ // package.json mit stylesheet:false äquivalent — wir setzen kein
171
+ // src/styles.css und sind außerhalb des monorepos, sodass der
172
+ // renderer-web-Fallback fehlschlägt und gracefully undefined liefert.
173
+ await writeFile(join(tmp, "package.json"), `{"name":"build-it-fixture","private":true}`);
174
+
175
+ execFileSync("bun", [KUMIKO_BUILD_BIN, tmp], { stdio: "pipe" });
176
+
177
+ const manifest = JSON.parse(await readFile(join(tmp, "dist/manifest.json"), "utf8")) as Record<
178
+ string,
179
+ string
180
+ >;
181
+ expect(manifest["client.js"]).toMatch(/^\/assets\/client-[a-z0-9]+\.js$/);
182
+
183
+ const html = await readFile(join(tmp, "dist/index.html"), "utf8");
184
+ expect(html).toContain(`src="${manifest["client.js"]}"`);
185
+ expect(html).toContain('id="root"');
186
+
187
+ // Hashed asset existiert im dist/
188
+ const assetPath = join(tmp, "dist", manifest["client.js"] ?? "");
189
+ expect(existsSync(assetPath)).toBe(true);
190
+
191
+ // Bundle enthält den User-Code (minified — also Identifier-Namen
192
+ // mangled, aber der String-Literal "hi" überlebt).
193
+ expect(await readFile(assetPath, "utf8")).toContain('"hi"');
194
+ });
195
+
196
+ test("client.ts ohne index.html → klarer Error mit Template-Vorschlag", async () => {
197
+ await mkdir(join(tmp, "src"), { recursive: true });
198
+ await writeFile(join(tmp, "src/client.ts"), `console.log("hi");`);
199
+ await writeFile(join(tmp, "package.json"), `{"name":"no-html","private":true}`);
200
+
201
+ let stderr = "";
202
+ expect(() => {
203
+ try {
204
+ execFileSync("bun", [KUMIKO_BUILD_BIN, tmp], { stdio: "pipe" });
205
+ } catch (err) {
206
+ const e = err as { stderr?: Buffer };
207
+ stderr = e.stderr?.toString() ?? "";
208
+ throw err;
209
+ }
210
+ }).toThrow();
211
+
212
+ expect(stderr).toContain("kein index.html gefunden");
213
+ expect(stderr).toContain(`<script type="module" src="/client.js"></script>`);
214
+ });
215
+
216
+ test("client.ts + index.html ohne /client.js Placeholder → klarer Error", async () => {
217
+ await mkdir(join(tmp, "src"), { recursive: true });
218
+ await writeFile(join(tmp, "src/client.ts"), `console.log("hi");`);
219
+ await mkdir(join(tmp, "public"), { recursive: true });
220
+ await writeFile(
221
+ join(tmp, "public/index.html"),
222
+ `<!doctype html><html><body>no script</body></html>`,
223
+ );
224
+ await writeFile(join(tmp, "package.json"), `{"name":"no-placeholder","private":true}`);
225
+
226
+ let stderr = "";
227
+ expect(() => {
228
+ try {
229
+ execFileSync("bun", [KUMIKO_BUILD_BIN, tmp], { stdio: "pipe" });
230
+ } catch (err) {
231
+ const e = err as { stderr?: Buffer };
232
+ stderr = e.stderr?.toString() ?? "";
233
+ throw err;
234
+ }
235
+ }).toThrow();
236
+
237
+ expect(stderr).toContain("keinen Entry-Tag für /client.js");
238
+ expect(stderr).toContain(`<script type="module" src="/client.js"></script>`);
239
+ });
240
+
241
+ test("Re-Build mit unverändertem Source produziert identischen Hash (reproducibility)", async () => {
242
+ await mkdir(join(tmp, "src"), { recursive: true });
243
+ await writeFile(join(tmp, "src/client.ts"), `console.log("stable");`);
244
+ await mkdir(join(tmp, "public"), { recursive: true });
245
+ await writeFile(
246
+ join(tmp, "public/index.html"),
247
+ `<!doctype html><html><body><script type="module" src="/client.js"></script></body></html>`,
248
+ );
249
+ await writeFile(join(tmp, "package.json"), `{"name":"hash-stability","private":true}`);
250
+
251
+ execFileSync("bun", [KUMIKO_BUILD_BIN, tmp], { stdio: "pipe" });
252
+ const manifest1 = JSON.parse(await readFile(join(tmp, "dist/manifest.json"), "utf8")) as Record<
253
+ string,
254
+ string
255
+ >;
256
+
257
+ execFileSync("bun", [KUMIKO_BUILD_BIN, tmp], { stdio: "pipe" });
258
+ const manifest2 = JSON.parse(await readFile(join(tmp, "dist/manifest.json"), "utf8")) as Record<
259
+ string,
260
+ string
261
+ >;
262
+
263
+ expect(manifest2["client.js"]).toBe(manifest1["client.js"]);
264
+ });
265
+ });