@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,262 @@
1
+ // Unit-Tests für die Pure-Logic-Helpers von build-prod-bundle.
2
+ //
3
+ // Bun.build und Tailwind-CLI brauchen einen Bun-Runtime, deshalb
4
+ // werden die hier nicht aufgerufen — nur Discovery + HTML-Injection
5
+ // die unter Node funktionieren. End-to-End-Tests (mit echtem Bun.build)
6
+ // laufen im CI als `yarn build` auf der Showcase-App; das ist der
7
+ // ehrlichere Smoke-Test.
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
14
+ import {
15
+ type ClientEntry,
16
+ discoverClientEntries,
17
+ discoverClientEntry,
18
+ discoverHtmlTemplate,
19
+ injectAssetTags,
20
+ } from "../build-prod-bundle";
21
+
22
+ // Synthetic single-entry helper. Realer Build erzeugt das via
23
+ // discoverClientEntries; hier reicht die Form für injectAssetTags-Tests.
24
+ function clientEntry(): ClientEntry {
25
+ return {
26
+ name: "client",
27
+ sourceFile: "src/client.tsx",
28
+ manifestKey: "client.js",
29
+ htmlPath: "index.html",
30
+ };
31
+ }
32
+
33
+ function namedEntry(name: string): ClientEntry {
34
+ return {
35
+ name,
36
+ sourceFile: `src/client-${name}.tsx`,
37
+ manifestKey: `client-${name}.js`,
38
+ htmlPath: name === "public" ? "index.html" : `${name}.html`,
39
+ };
40
+ }
41
+
42
+ describe("build-prod-bundle/discovery", () => {
43
+ let workDir = "";
44
+
45
+ beforeEach(async () => {
46
+ workDir = await mkdtemp(join(tmpdir(), "kumiko-build-test-"));
47
+ });
48
+
49
+ afterEach(async () => {
50
+ await rm(workDir, { recursive: true, force: true });
51
+ });
52
+
53
+ test("discoverClientEntry findet src/client.tsx wenn vorhanden", async () => {
54
+ await mkdir(join(workDir, "src"), { recursive: true });
55
+ await writeFile(join(workDir, "src/client.tsx"), "// noop");
56
+
57
+ const entry = discoverClientEntry(workDir);
58
+
59
+ expect(entry).toBe(join(workDir, "src/client.tsx"));
60
+ });
61
+
62
+ test("discoverClientEntry findet src/client.ts als Fallback", async () => {
63
+ await mkdir(join(workDir, "src"), { recursive: true });
64
+ await writeFile(join(workDir, "src/client.ts"), "// noop");
65
+
66
+ const entry = discoverClientEntry(workDir);
67
+
68
+ expect(entry).toBe(join(workDir, "src/client.ts"));
69
+ });
70
+
71
+ test("discoverClientEntry bevorzugt .tsx über .ts wenn beide existieren", async () => {
72
+ await mkdir(join(workDir, "src"), { recursive: true });
73
+ await writeFile(join(workDir, "src/client.tsx"), "// jsx");
74
+ await writeFile(join(workDir, "src/client.ts"), "// ts");
75
+
76
+ const entry = discoverClientEntry(workDir);
77
+
78
+ expect(entry).toBe(join(workDir, "src/client.tsx"));
79
+ });
80
+
81
+ test("discoverClientEntry gibt undefined zurück wenn nichts da ist", () => {
82
+ expect(discoverClientEntry(workDir)).toBeUndefined();
83
+ });
84
+
85
+ test("discoverClientEntries findet single-mode src/client.tsx", async () => {
86
+ await mkdir(join(workDir, "src"), { recursive: true });
87
+ await writeFile(join(workDir, "src/client.tsx"), "// single");
88
+
89
+ const entries = discoverClientEntries(workDir);
90
+
91
+ expect(entries).toHaveLength(1);
92
+ expect(entries[0]?.name).toBe("client");
93
+ expect(entries[0]?.manifestKey).toBe("client.js");
94
+ expect(entries[0]?.htmlPath).toBe("index.html");
95
+ });
96
+
97
+ test("discoverClientEntries findet multi-mode client-public + client-admin", async () => {
98
+ await mkdir(join(workDir, "src"), { recursive: true });
99
+ await writeFile(join(workDir, "src/client-public.tsx"), "// public");
100
+ await writeFile(join(workDir, "src/client-admin.tsx"), "// admin");
101
+
102
+ const entries = discoverClientEntries(workDir);
103
+
104
+ // Sortiert nach name.
105
+ expect(entries.map((e) => e.name)).toEqual(["admin", "public"]);
106
+
107
+ const admin = entries.find((e) => e.name === "admin");
108
+ expect(admin?.manifestKey).toBe("client-admin.js");
109
+ expect(admin?.htmlPath).toBe("admin.html");
110
+
111
+ const pub = entries.find((e) => e.name === "public");
112
+ expect(pub?.manifestKey).toBe("client-public.js");
113
+ // "public" mappt auf das Default-Template (index.html), nicht
114
+ // public.html — das ist Convention damit das default-served-Template
115
+ // den vom-User-erwarteten Namen behält.
116
+ expect(pub?.htmlPath).toBe("index.html");
117
+ });
118
+
119
+ test("discoverClientEntries multi-mode dominiert über single-mode wenn beide da", async () => {
120
+ await mkdir(join(workDir, "src"), { recursive: true });
121
+ await writeFile(join(workDir, "src/client.tsx"), "// single");
122
+ await writeFile(join(workDir, "src/client-admin.tsx"), "// admin");
123
+
124
+ const entries = discoverClientEntries(workDir);
125
+
126
+ // Multi-mode aktiv → "client" wird ignoriert.
127
+ expect(entries.map((e) => e.name)).toEqual(["admin"]);
128
+ });
129
+
130
+ test("discoverClientEntries gibt leeres Array zurück wenn nichts da", () => {
131
+ expect(discoverClientEntries(workDir)).toEqual([]);
132
+ });
133
+
134
+ test("discoverHtmlTemplate findet index.html im cwd", async () => {
135
+ await writeFile(join(workDir, "index.html"), "<html></html>");
136
+
137
+ expect(discoverHtmlTemplate(workDir)).toBe(join(workDir, "index.html"));
138
+ });
139
+
140
+ test("discoverHtmlTemplate findet public/index.html als Fallback", async () => {
141
+ await mkdir(join(workDir, "public"), { recursive: true });
142
+ await writeFile(join(workDir, "public/index.html"), "<html></html>");
143
+
144
+ expect(discoverHtmlTemplate(workDir)).toBe(join(workDir, "public/index.html"));
145
+ });
146
+
147
+ test("discoverHtmlTemplate bevorzugt cwd-index.html über public/index.html", async () => {
148
+ await mkdir(join(workDir, "public"), { recursive: true });
149
+ await writeFile(join(workDir, "index.html"), "<!-- root -->");
150
+ await writeFile(join(workDir, "public/index.html"), "<!-- public -->");
151
+
152
+ expect(discoverHtmlTemplate(workDir)).toBe(join(workDir, "index.html"));
153
+ });
154
+
155
+ test("discoverHtmlTemplate gibt undefined zurück wenn nichts da ist", () => {
156
+ expect(existsSync(workDir)).toBe(true);
157
+ expect(discoverHtmlTemplate(workDir)).toBeUndefined();
158
+ });
159
+ });
160
+
161
+ describe("build-prod-bundle/injectAssetTags", () => {
162
+ test("ersetzt /client.js durch hashed URL im script-tag", () => {
163
+ const html = `<html><body><script type="module" src="/client.js"></script></body></html>`;
164
+ const result = injectAssetTags(
165
+ html,
166
+ { "client.js": "/assets/client-abc123.js" },
167
+ clientEntry(),
168
+ );
169
+
170
+ expect(result).toContain('src="/assets/client-abc123.js"');
171
+ expect(result).not.toContain('src="/client.js"');
172
+ });
173
+
174
+ test("ersetzt /styles.css durch hashed URL im link-tag", () => {
175
+ const html = `<html><head><link rel="stylesheet" href="/styles.css" /></head><body></body></html>`;
176
+ const result = injectAssetTags(
177
+ html,
178
+ { "styles.css": "/assets/styles-def456.css" },
179
+ clientEntry(),
180
+ );
181
+
182
+ expect(result).toContain('href="/assets/styles-def456.css"');
183
+ expect(result).not.toContain('href="/styles.css"');
184
+ });
185
+
186
+ test("wirft mit Anweisung wenn /client.js Placeholder fehlt", () => {
187
+ const html = `<html><body><div id="root"></div></body></html>`;
188
+
189
+ expect(() =>
190
+ injectAssetTags(html, { "client.js": "/assets/client-abc.js" }, clientEntry()),
191
+ ).toThrow(/keinen Entry-Tag für \/client\.js/);
192
+ expect(() =>
193
+ injectAssetTags(html, { "client.js": "/assets/client-abc.js" }, clientEntry()),
194
+ ).toThrow(/<script type="module" src="\/client\.js"><\/script>/);
195
+ });
196
+
197
+ test("wirft mit Anweisung wenn /styles.css Placeholder fehlt", () => {
198
+ const html = `<html><head><title>App</title></head><body></body></html>`;
199
+
200
+ expect(() =>
201
+ injectAssetTags(html, { "styles.css": "/assets/styles-xyz.css" }, clientEntry()),
202
+ ).toThrow(/keinen Entry-Tag für \/styles\.css/);
203
+ expect(() =>
204
+ injectAssetTags(html, { "styles.css": "/assets/styles-xyz.css" }, clientEntry()),
205
+ ).toThrow(/<link rel="stylesheet" href="\/styles\.css" \/>/);
206
+ });
207
+
208
+ test("ist idempotent — zweite Injection auf bereits ersetztem HTML ändert nichts", () => {
209
+ const html = `<html><body><script type="module" src="/client.js"></script></body></html>`;
210
+ const manifest = { "client.js": "/assets/client-abc.js" };
211
+ const first = injectAssetTags(html, manifest, clientEntry());
212
+ const second = injectAssetTags(first, manifest, clientEntry());
213
+
214
+ expect(first).toBe(second);
215
+ expect(first).toContain('src="/assets/client-abc.js"');
216
+ });
217
+
218
+ test("ändert template ohne client/styles im manifest nicht", () => {
219
+ const html = `<html><body>Hello</body></html>`;
220
+ const result = injectAssetTags(html, {}, clientEntry());
221
+
222
+ expect(result).toBe(html);
223
+ });
224
+
225
+ test("verträgt mehrere script-tags und ersetzt nur den /client.js", () => {
226
+ const html = `<html><body>
227
+ <script>console.log("inline");</script>
228
+ <script type="module" src="/client.js"></script>
229
+ </body></html>`;
230
+ const result = injectAssetTags(html, { "client.js": "/assets/client-x.js" }, clientEntry());
231
+
232
+ expect(result).toContain('console.log("inline")');
233
+ expect(result).toContain('src="/assets/client-x.js"');
234
+ expect(result).not.toContain('src="/client.js"');
235
+ });
236
+
237
+ test("multi-mode: admin-entry ersetzt nur /client-admin.js, lässt /client-public.js liegen", () => {
238
+ const html = `<html><body>
239
+ <script type="module" src="/client-admin.js"></script>
240
+ <script type="module" src="/client-public.js"></script>
241
+ </body></html>`;
242
+ const manifest = {
243
+ "client-admin.js": "/assets/client-admin-aaa.js",
244
+ "client-public.js": "/assets/client-public-bbb.js",
245
+ };
246
+ const result = injectAssetTags(html, manifest, namedEntry("admin"));
247
+
248
+ expect(result).toContain('src="/assets/client-admin-aaa.js"');
249
+ // public-bundle bleibt unangetastet — admin.html lädt nur sein eigenes Bundle.
250
+ expect(result).toContain('src="/client-public.js"');
251
+ expect(result).not.toContain('src="/client-admin.js"');
252
+ });
253
+
254
+ test("multi-mode: error-message nennt den richtigen Template-Namen", () => {
255
+ const html = `<html><body>no admin script</body></html>`;
256
+ const manifest = { "client-admin.js": "/assets/client-admin-x.js" };
257
+
258
+ expect(() => injectAssetTags(html, manifest, namedEntry("admin"))).toThrow(
259
+ /admin\.html hat keinen Entry-Tag für \/client-admin\.js/,
260
+ );
261
+ });
262
+ });
@@ -0,0 +1,70 @@
1
+ // Unit-Tests für cacheHeadersFor — die Cache-Strategie hinter
2
+ // runProdApp's static-fallback. Pure function, leicht zu testen.
3
+ //
4
+ // Strategie (siehe run-prod-app.ts):
5
+ // /assets/* → max-age=31536000, immutable
6
+ // /, /index.html → no-cache, must-revalidate
7
+ // /manifest.json, /sw.js → no-cache
8
+ // alles andere → kein expliziter Header
9
+
10
+ import { describe, expect, test } from "vitest";
11
+ import { cacheHeadersFor } from "../run-prod-app";
12
+
13
+ describe("cacheHeadersFor", () => {
14
+ test("hashed asset → immutable + 1 Jahr", () => {
15
+ expect(cacheHeadersFor("/assets/client-abc123.js")).toEqual({
16
+ "cache-control": "public, max-age=31536000, immutable",
17
+ });
18
+ });
19
+
20
+ test("hashed CSS asset → immutable + 1 Jahr", () => {
21
+ expect(cacheHeadersFor("/assets/styles-def456.css")).toEqual({
22
+ "cache-control": "public, max-age=31536000, immutable",
23
+ });
24
+ });
25
+
26
+ test("nested asset path → immutable", () => {
27
+ expect(cacheHeadersFor("/assets/chunks/foo-789.js")).toEqual({
28
+ "cache-control": "public, max-age=31536000, immutable",
29
+ });
30
+ });
31
+
32
+ test("/ → no-cache, must-revalidate", () => {
33
+ expect(cacheHeadersFor("/")).toEqual({
34
+ "cache-control": "no-cache, must-revalidate",
35
+ });
36
+ });
37
+
38
+ test("/index.html → no-cache, must-revalidate", () => {
39
+ expect(cacheHeadersFor("/index.html")).toEqual({
40
+ "cache-control": "no-cache, must-revalidate",
41
+ });
42
+ });
43
+
44
+ test("/manifest.json → no-cache", () => {
45
+ expect(cacheHeadersFor("/manifest.json")).toEqual({
46
+ "cache-control": "no-cache",
47
+ });
48
+ });
49
+
50
+ test("/sw.js → no-cache", () => {
51
+ expect(cacheHeadersFor("/sw.js")).toEqual({
52
+ "cache-control": "no-cache",
53
+ });
54
+ });
55
+
56
+ test("public-folder file (favicon) → kein expliziter Header", () => {
57
+ expect(cacheHeadersFor("/favicon.ico")).toEqual({});
58
+ });
59
+
60
+ test("public-folder file (og-image) → kein expliziter Header", () => {
61
+ expect(cacheHeadersFor("/og-image.png")).toEqual({});
62
+ });
63
+
64
+ test("path mit assets als Substring (kein /assets/ prefix) → kein immutable", () => {
65
+ // Schutz: /myassets/foo.js soll NICHT immutable kriegen — wäre ein Bug
66
+ // weil die nicht gehashed sind.
67
+ expect(cacheHeadersFor("/myassets/foo.js")).toEqual({});
68
+ expect(cacheHeadersFor("/foo/assets/bar.js")).toEqual({});
69
+ });
70
+ });
@@ -0,0 +1,87 @@
1
+ // Heuristik für die Watcher-Loop: server-side Änderungen brauchen
2
+ // Process-Restart (Bun cached Module-Imports), Client-Side reicht
3
+ // Hot-Reload, Tests/non-TS sollen den Watcher gar nicht triggern.
4
+ // Wenn die Klassifikation drift bekommt, kommen entweder unnötige
5
+ // Restarts (DX schlecht) oder echte Schema-Änderungen schlagen
6
+ // nicht durch (UX broken). Beide sind teuer — daher pinnen wir.
7
+
8
+ import { describe, expect, test } from "vitest";
9
+ import { classifyChange } from "../create-kumiko-server";
10
+
11
+ describe("classifyChange", () => {
12
+ test("server-side feature.ts → restart", () => {
13
+ expect(classifyChange("/abs/samples/foo/src/features/items/feature.ts")).toBe("restart");
14
+ });
15
+
16
+ test("server-side schema-Datei → restart", () => {
17
+ expect(classifyChange("/abs/samples/foo/src/features/items/schema/item.ts")).toBe("restart");
18
+ });
19
+
20
+ test("server-side bin/server.ts → restart", () => {
21
+ expect(classifyChange("/abs/samples/foo/bin/server.ts")).toBe("restart");
22
+ });
23
+
24
+ test("client-side web/index.ts → hot-reload", () => {
25
+ expect(classifyChange("/abs/samples/foo/src/features/items/web/index.ts")).toBe("hot-reload");
26
+ });
27
+
28
+ test("client-side web/page.tsx → hot-reload", () => {
29
+ expect(classifyChange("/abs/samples/foo/src/features/items/web/page.tsx")).toBe("hot-reload");
30
+ });
31
+
32
+ test("client.tsx Entry → hot-reload", () => {
33
+ expect(classifyChange("/abs/samples/foo/src/app/client.tsx")).toBe("hot-reload");
34
+ });
35
+
36
+ test("client.ts Entry → hot-reload", () => {
37
+ expect(classifyChange("/abs/samples/foo/src/app/client.ts")).toBe("hot-reload");
38
+ });
39
+
40
+ test("Test-Datei *.test.ts → ignore", () => {
41
+ expect(classifyChange("/abs/samples/foo/src/feature.test.ts")).toBe("ignore");
42
+ });
43
+
44
+ test("Test-Datei *.test.tsx → ignore", () => {
45
+ expect(classifyChange("/abs/samples/foo/src/component.test.tsx")).toBe("ignore");
46
+ });
47
+
48
+ test("__tests__/ Subdir → ignore (auch wenn die Datei selbst nicht *.test.ts heißt)", () => {
49
+ expect(classifyChange("/abs/samples/foo/src/__tests__/test-utils.ts")).toBe("ignore");
50
+ });
51
+
52
+ test("Integration-Test → ignore (würde sonst Schema-Restart auslösen)", () => {
53
+ expect(classifyChange("/abs/samples/foo/src/feature.integration.ts")).toBe("ignore");
54
+ });
55
+
56
+ test("E2E-Test → ignore", () => {
57
+ expect(classifyChange("/abs/samples/foo/src/something.e2e.ts")).toBe("ignore");
58
+ });
59
+
60
+ test("CSS/JSON/sonstiges → ignore", () => {
61
+ expect(classifyChange("/abs/samples/foo/src/styles.css")).toBe("ignore");
62
+ expect(classifyChange("/abs/samples/foo/src/data.json")).toBe("ignore");
63
+ expect(classifyChange("/abs/samples/foo/public/index.html")).toBe("ignore");
64
+ });
65
+
66
+ test("Windows-Pfad-Trenner: web\\ → hot-reload", () => {
67
+ expect(classifyChange("C:\\abs\\samples\\foo\\src\\features\\items\\web\\page.tsx")).toBe(
68
+ "hot-reload",
69
+ );
70
+ });
71
+
72
+ // Sample-Conventions: publicstatus splittet `/admin/` (Admin-Bundle)
73
+ // und `/public/` (Public-Bundle). Beide sind client-side, müssen
74
+ // hot-reload sein — sonst kostet jeder Bridge-Edit einen Restart +
75
+ // DB-Reset. Andere Samples machen das eventuell auch so.
76
+ test("admin/-Subdir → hot-reload", () => {
77
+ expect(classifyChange("/abs/samples/foo/src/admin/branding-settings.tsx")).toBe("hot-reload");
78
+ });
79
+
80
+ test("public/-Subdir → hot-reload", () => {
81
+ expect(classifyChange("/abs/samples/foo/src/public/StatusPage.tsx")).toBe("hot-reload");
82
+ });
83
+
84
+ test("client/-Subdir → hot-reload", () => {
85
+ expect(classifyChange("/abs/samples/foo/src/client/widgets.tsx")).toBe("hot-reload");
86
+ });
87
+ });