@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.
- package/bin/kumiko-build.ts +85 -0
- package/bin/kumiko-dev.ts +90 -0
- package/package.json +45 -0
- package/src/__tests__/build-prod-bundle.integration.ts +265 -0
- package/src/__tests__/build-prod-bundle.test.ts +262 -0
- package/src/__tests__/cache-headers.test.ts +70 -0
- package/src/__tests__/classify-change.test.ts +87 -0
- package/src/__tests__/compose-features-wiring.integration.ts +352 -0
- package/src/__tests__/compose-features.test.ts +81 -0
- package/src/__tests__/crash-tracker.test.ts +89 -0
- package/src/__tests__/create-kumiko-server.integration.ts +286 -0
- package/src/__tests__/few-shot-corpus.test.ts +311 -0
- package/src/__tests__/inject-schema.test.ts +62 -0
- package/src/__tests__/resolve-stylesheet.test.ts +90 -0
- package/src/__tests__/resolve-tailwind-cli.test.ts +49 -0
- package/src/__tests__/run-prod-app-spec.test.ts +57 -0
- package/src/__tests__/run-prod-app.integration.ts +535 -0
- package/src/__tests__/scaffold-feature.test.ts +143 -0
- package/src/__tests__/try-hono-first.test.ts +63 -0
- package/src/build-prod-bundle.ts +587 -0
- package/src/build-server-bundle.ts +308 -0
- package/src/build.ts +28 -0
- package/src/codegen/__tests__/run-codegen.test.ts +494 -0
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +467 -0
- package/src/codegen/__tests__/watch.test.ts +186 -0
- package/src/codegen/index.ts +17 -0
- package/src/codegen/render.ts +225 -0
- package/src/codegen/run-codegen.ts +157 -0
- package/src/codegen/scan-events.ts +574 -0
- package/src/codegen/watch.ts +127 -0
- package/src/compose-features.ts +128 -0
- package/src/crash-tracker.ts +56 -0
- package/src/create-kumiko-server.ts +1010 -0
- package/src/drizzle-config.ts +44 -0
- package/src/drizzle-tables-auth-mode.ts +32 -0
- package/src/drizzle-tables-minimal.ts +22 -0
- package/src/few-shot-corpus.ts +369 -0
- package/src/index.ts +57 -0
- package/src/inject-schema.ts +24 -0
- package/src/resolve-tailwind-cli.ts +28 -0
- package/src/run-dev-app.ts +290 -0
- package/src/run-prod-app.ts +892 -0
- package/src/scaffold-feature.ts +226 -0
- 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
|
+
});
|