@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,286 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ createBooleanField,
6
+ createEntity,
7
+ createTextField,
8
+ defineFeature,
9
+ } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { sql } from "drizzle-orm";
11
+ import { afterEach, describe, expect, test } from "vitest";
12
+ import { createKumikoServer, type KumikoServerHandle } from "../create-kumiko-server";
13
+
14
+ // Integration-Test: bootet createKumikoServer mit echtem Postgres,
15
+ // echtem Redis, echter Kumiko-Pipeline. Treibt den fetch-Handler
16
+ // direkt an (nicht über Bun.serve + Socket), weil vitest unter Node
17
+ // läuft. Unter Bun würde derselbe Handler identisch antworten —
18
+ // das Routing ist runtime-neutral.
19
+
20
+ const probeEntity = createEntity({
21
+ fields: {
22
+ title: createTextField({ required: true }),
23
+ done: createBooleanField(),
24
+ },
25
+ table: "kumiko_server_probe",
26
+ });
27
+
28
+ const probeFeature = defineFeature("dev-server-probe", (r) => {
29
+ r.entity("probe", probeEntity);
30
+ });
31
+
32
+ let handle: KumikoServerHandle | undefined;
33
+
34
+ afterEach(async () => {
35
+ if (handle) {
36
+ await handle.stop();
37
+ handle = undefined;
38
+ }
39
+ });
40
+
41
+ async function boot(): Promise<KumikoServerHandle> {
42
+ handle = await createKumikoServer({
43
+ features: [probeFeature],
44
+ port: 0,
45
+ installSignalHandlers: false,
46
+ });
47
+ return handle;
48
+ }
49
+
50
+ describe("createKumikoServer", () => {
51
+ test("bootet den Kumiko-Stack + legt die Feature-Tables an", async () => {
52
+ const h = await boot();
53
+ const rows = await h.stack.db.execute<{ exists: boolean }>(
54
+ sql`SELECT to_regclass('public.kumiko_server_probe') IS NOT NULL AS exists`,
55
+ );
56
+ expect(rows[0]?.exists).toBe(true);
57
+ });
58
+
59
+ test("GET / → HTML + kumiko_auth/kumiko_csrf Set-Cookie", async () => {
60
+ const h = await boot();
61
+ const res = await h.fetch(new Request("http://localhost/"));
62
+ expect(res.status).toBe(200);
63
+ expect(res.headers.get("content-type")).toMatch(/text\/html/);
64
+ const setCookie = res.headers.get("set-cookie") ?? "";
65
+ expect(setCookie).toMatch(/kumiko_auth=/);
66
+ expect(setCookie).toMatch(/kumiko_csrf=/);
67
+ const body = await res.text();
68
+ expect(body).toMatch(/<div id="root">/);
69
+ // Reload-Snippet wurde in </body> injiziert.
70
+ expect(body).toMatch(/EventSource\("\/_reload"\)/);
71
+ });
72
+
73
+ test("GET /client.js → 404 wenn clientEntry fehlt (Route nicht registriert)", async () => {
74
+ // Pre-multi-entry-Refactor lieferte das eine 200 mit leerem Body —
75
+ // war eine Quirky Backwards-Compat. Mit der Multi-Entry-Engine wird
76
+ // /client.js erst registriert wenn ein Entry vorhanden ist (kein
77
+ // entries → kein Bundle-Asset-Path → 404 ist korrekt).
78
+ const h = await boot();
79
+ const res = await h.fetch(new Request("http://localhost/client.js"));
80
+ expect(res.status).toBe(404);
81
+ });
82
+
83
+ test("Single-Entry: clientEntry='client.tsx' liefert Bundle unter /client.js", async () => {
84
+ // Backwards-Compat-Smoke: existierende Samples (designer, ui-walkthrough,
85
+ // beammycar, …) nutzen clientEntry — der normalize-Pfad muss daraus
86
+ // `/client.js` als Asset-Path ableiten. Test injiziert via _buildBundle
87
+ // einen Stub damit der Body deterministisch geprüft werden kann (kein
88
+ // Bun.build unter Node).
89
+ const tmpFile = mkdtempSync(join(tmpdir(), "kumiko-single-it-"));
90
+ const entry = join(tmpFile, "client.tsx");
91
+ writeFileSync(entry, "// noop");
92
+ try {
93
+ handle = await createKumikoServer({
94
+ features: [probeFeature],
95
+ port: 0,
96
+ installSignalHandlers: false,
97
+ clientEntry: entry,
98
+ _buildBundle: async () => ({ js: "// SINGLE-ENTRY-STUB", map: "" }),
99
+ });
100
+ const res = await handle.fetch(new Request("http://localhost/client.js"));
101
+ expect(res.status).toBe(200);
102
+ expect(res.headers.get("content-type")).toMatch(/application\/javascript/);
103
+ expect(await res.text()).toBe("// SINGLE-ENTRY-STUB");
104
+ } finally {
105
+ rmSync(tmpFile, { recursive: true, force: true });
106
+ }
107
+ });
108
+
109
+ test("GET /client.js.map → 404 wenn kein Sourcemap vorhanden", async () => {
110
+ const h = await boot();
111
+ const res = await h.fetch(new Request("http://localhost/client.js.map"));
112
+ expect(res.status).toBe(404);
113
+ });
114
+
115
+ test("GET /_reload → text/event-stream mit connected-Komment", async () => {
116
+ const h = await boot();
117
+ const res = await h.fetch(new Request("http://localhost/_reload"));
118
+ expect(res.status).toBe(200);
119
+ expect(res.headers.get("content-type")).toMatch(/text\/event-stream/);
120
+ const reader = res.body?.getReader();
121
+ expect(reader).toBeDefined();
122
+ if (!reader) return;
123
+ const { value } = await reader.read();
124
+ const chunk = new TextDecoder().decode(value);
125
+ expect(chunk).toMatch(/connected/);
126
+ await reader.cancel();
127
+ });
128
+
129
+ test("unbekannter Pfad → forwarded an den Hono-Stack", async () => {
130
+ const h = await boot();
131
+ // /api/ghost existiert nicht → Hono liefert 404. Der entscheidende
132
+ // Punkt: die Response kommt VOM Stack, nicht vom Dev-Server-Layer.
133
+ // Wir unterscheiden, indem wir prüfen, dass es KEINE HTML- oder
134
+ // event-stream-Response ist — die gehören zum Dev-Server-Layer.
135
+ const res = await h.fetch(new Request("http://localhost/api/ghost"));
136
+ expect(res.headers.get("content-type") ?? "").not.toMatch(/text\/html/);
137
+ expect(res.headers.get("content-type") ?? "").not.toMatch(/text\/event-stream/);
138
+ });
139
+
140
+ test("Server-Instanz ist undefined unter Node (vitest)", async () => {
141
+ // Sanity check: unter Bun wäre .server gesetzt, unter Node
142
+ // (wo dieser Test läuft) muss er undefined sein, sonst hätten
143
+ // wir eine falsche Bun-Detection.
144
+ const h = await boot();
145
+ expect(h.server).toBeUndefined();
146
+ });
147
+ });
148
+
149
+ // Multi-Entry-Mode — exerciert clientEntries + hostDispatch (Discriminated-
150
+ // Union). Bun.build wird via _buildBundle gestubbt; der Routing-Pfad
151
+ // (HTML-Dispatch pro Host + Bundle-Routing pro Asset-Path) ist runtime-
152
+ // neutral und wird hier vollständig getrieben. Echte Bundle-Produktion
153
+ // deckt die `kumiko-build CLI`-Suite (build-prod-bundle.integration.ts) ab.
154
+ describe("createKumikoServer (Multi-Entry)", () => {
155
+ let tmpDir = "";
156
+
157
+ afterEach(() => {
158
+ if (tmpDir) {
159
+ rmSync(tmpDir, { recursive: true, force: true });
160
+ tmpDir = "";
161
+ }
162
+ });
163
+
164
+ async function bootMultiEntry(): Promise<KumikoServerHandle> {
165
+ tmpDir = mkdtempSync(join(tmpdir(), "kumiko-multi-it-"));
166
+ const publicEntry = join(tmpDir, "client-public.tsx");
167
+ const adminEntry = join(tmpDir, "client-admin.tsx");
168
+ const publicHtml = join(tmpDir, "index.html");
169
+ const adminHtml = join(tmpDir, "admin.html");
170
+ writeFileSync(publicEntry, "// public");
171
+ writeFileSync(adminEntry, "// admin");
172
+ writeFileSync(
173
+ publicHtml,
174
+ `<!doctype html><html><body><div id="root"></div><script src="/client-public.js"></script>PUBLIC-HTML</body></html>`,
175
+ );
176
+ writeFileSync(
177
+ adminHtml,
178
+ `<!doctype html><html><body><div id="root"></div><script src="/client-admin.js"></script>ADMIN-HTML</body></html>`,
179
+ );
180
+
181
+ return createKumikoServer({
182
+ features: [probeFeature],
183
+ port: 0,
184
+ installSignalHandlers: false,
185
+ clientEntries: [
186
+ { name: "public", sourceFile: publicEntry, htmlPath: publicHtml },
187
+ { name: "admin", sourceFile: adminEntry, htmlPath: adminHtml },
188
+ ],
189
+ // Stub: der Bundle-Inhalt enthält den Entry-Namen damit der Test
190
+ // beweisen kann dass /client-public.js ≠ /client-admin.js. Echtes
191
+ // Bun.build würde unterschiedlich-gehashte Bundles produzieren —
192
+ // wir simulieren das mit deterministischen Markern.
193
+ _buildBundle: async (sourceFile) => {
194
+ if (sourceFile === publicEntry) {
195
+ return { js: "// PUBLIC-BUNDLE", map: "" };
196
+ }
197
+ if (sourceFile === adminEntry) {
198
+ return { js: "// ADMIN-BUNDLE", map: "" };
199
+ }
200
+ throw new Error(`unexpected entry: ${sourceFile}`);
201
+ },
202
+ hostDispatch: (req) => {
203
+ const host = (req.headers.get("host") ?? "").split(":")[0]?.toLowerCase() ?? "";
204
+ if (host === "apex.test") return { kind: "not-found" };
205
+ if (host === "old.test") return { kind: "redirect", to: "https://new.test/" };
206
+ if (host.startsWith("admin.")) {
207
+ return { kind: "html", entryName: "admin", injectSchema: true };
208
+ }
209
+ return { kind: "html", entryName: "public", injectSchema: false };
210
+ },
211
+ });
212
+ }
213
+
214
+ test("HTML-Dispatch: admin-Host bekommt admin.html, sonst index.html", async () => {
215
+ handle = await bootMultiEntry();
216
+
217
+ const publicRes = await handle.fetch(
218
+ new Request("http://status.localhost/", { headers: { host: "status.localhost" } }),
219
+ );
220
+ const publicBody = await publicRes.text();
221
+ expect(publicBody).toMatch(/PUBLIC-HTML/);
222
+ expect(publicBody).not.toMatch(/ADMIN-HTML/);
223
+
224
+ const adminRes = await handle.fetch(
225
+ new Request("http://admin.localhost/", { headers: { host: "admin.localhost" } }),
226
+ );
227
+ const adminBody = await adminRes.text();
228
+ expect(adminBody).toMatch(/ADMIN-HTML/);
229
+ expect(adminBody).not.toMatch(/PUBLIC-HTML/);
230
+ });
231
+
232
+ test("Bundle-Routing: /client-public.js ≠ /client-admin.js", async () => {
233
+ handle = await bootMultiEntry();
234
+
235
+ const publicJs = await handle.fetch(new Request("http://status.localhost/client-public.js"));
236
+ expect(publicJs.status).toBe(200);
237
+ expect(publicJs.headers.get("content-type")).toMatch(/application\/javascript/);
238
+ expect(await publicJs.text()).toBe("// PUBLIC-BUNDLE");
239
+
240
+ const adminJs = await handle.fetch(new Request("http://admin.localhost/client-admin.js"));
241
+ expect(adminJs.status).toBe(200);
242
+ expect(await adminJs.text()).toBe("// ADMIN-BUNDLE");
243
+
244
+ // Cross-routing existiert NICHT — /client.js (Single-Entry-Pfad)
245
+ // ist im Multi-Mode kein registrierter Asset-Path.
246
+ const noFallback = await handle.fetch(new Request("http://localhost/client.js"));
247
+ expect(noFallback.status).toBe(404);
248
+ });
249
+
250
+ test("Schema-Inject: admin → injected, public → NICHT injected", async () => {
251
+ handle = await bootMultiEntry();
252
+
253
+ const publicHtml = await (
254
+ await handle.fetch(
255
+ new Request("http://status.localhost/", { headers: { host: "status.localhost" } }),
256
+ )
257
+ ).text();
258
+ expect(publicHtml).not.toMatch(/__KUMIKO_SCHEMA__/);
259
+
260
+ const adminHtml = await (
261
+ await handle.fetch(
262
+ new Request("http://admin.localhost/", { headers: { host: "admin.localhost" } }),
263
+ )
264
+ ).text();
265
+ expect(adminHtml).toMatch(/__KUMIKO_SCHEMA__/);
266
+ });
267
+
268
+ test("hostDispatch redirect: liefert 302 mit Location-Header", async () => {
269
+ handle = await bootMultiEntry();
270
+
271
+ const res = await handle.fetch(
272
+ new Request("http://old.test/", { headers: { host: "old.test" } }),
273
+ );
274
+ expect(res.status).toBe(302);
275
+ expect(res.headers.get("location")).toBe("https://new.test/");
276
+ });
277
+
278
+ test("hostDispatch not-found: liefert 404", async () => {
279
+ handle = await bootMultiEntry();
280
+
281
+ const res = await handle.fetch(
282
+ new Request("http://apex.test/", { headers: { host: "apex.test" } }),
283
+ );
284
+ expect(res.status).toBe(404);
285
+ });
286
+ });
@@ -0,0 +1,311 @@
1
+ // Few-Shot-Corpus tests:
2
+ // 1. Smoke — buildFewShotCorpus produces a structurally valid corpus
3
+ // against a tmp-dir mini-repo (canonical multi-kind feature, broken
4
+ // legacy feature, and a non-feature file).
5
+ // 2. Output shape — id stable, paths repo-relative, authoring-style
6
+ // flags match the parse-error count.
7
+ // 3. Warnings — duplicate-id collisions and parser-throw don't get
8
+ // swallowed; they show up in the corpus.warnings array.
9
+ // 4. pathToId — pure-function unit tests for the id derivation.
10
+ //
11
+ // Note: The drift-check against the checked-in `docs/few-shot-corpus.json`
12
+ // has been moved to `kumiko-enterprise/packages/ai-foundation` (Phase O.6)
13
+ // because the corpus JSON now lives there — framework is public and must
14
+ // not carry the eval baseline.
15
+
16
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { buildFewShotCorpus, pathToId } from "@cosmicdrift/kumiko-dev-server";
20
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
21
+
22
+ let workdir: string;
23
+
24
+ beforeEach(() => {
25
+ workdir = mkdtempSync(join(tmpdir(), "kumiko-corpus-"));
26
+ });
27
+
28
+ afterEach(() => {
29
+ rmSync(workdir, { recursive: true, force: true });
30
+ });
31
+
32
+ // =============================================================================
33
+ // Smoke test on a tmp-dir mini-repo
34
+ // =============================================================================
35
+
36
+ describe("buildFewShotCorpus — smoke", () => {
37
+ test("returns one entry per discovered feature-file with parsed shape", () => {
38
+ // Plant two feature-files inside `samples/recipes/` so the default
39
+ // scan-roots pick them up.
40
+ plantPackage(workdir, "samples/recipes/canonical-demo", {
41
+ pkgName: "@cosmicdrift/kumiko-sample-canonical-demo",
42
+ pkgDescription: "Canonical-form demo feature.",
43
+ featureSource: buildCanonicalMultiKindFeature("canonicalDemo"),
44
+ });
45
+ plantPackage(workdir, "samples/recipes/legacy-demo", {
46
+ pkgName: "@cosmicdrift/kumiko-sample-legacy-demo",
47
+ pkgDescription: "Identifier-ref-style legacy feature.",
48
+ featureSource: buildLegacyFeature("legacyDemo"),
49
+ });
50
+
51
+ const corpus = buildFewShotCorpus({ repoRoot: workdir });
52
+
53
+ expect(corpus.entries).toHaveLength(2);
54
+ expect(corpus.totals.all).toBe(2);
55
+ expect(corpus.totals.canonical).toBe(1);
56
+ expect(corpus.totals.legacy).toBe(1);
57
+ expect(corpus.warnings).toEqual([]);
58
+ });
59
+
60
+ test("multi-kind canonical entry exposes tags and kind-counts across all kinds", () => {
61
+ plantPackage(workdir, "samples/recipes/multi-kind", {
62
+ pkgName: "@cosmicdrift/kumiko-sample-multi-kind",
63
+ pkgDescription: "Demo feature spanning entity, writeHandler, nav.",
64
+ featureSource: buildCanonicalMultiKindFeature("multiKind"),
65
+ });
66
+
67
+ const corpus = buildFewShotCorpus({ repoRoot: workdir });
68
+ const entry = corpus.entries[0];
69
+ if (!entry) throw new Error("expected one entry");
70
+
71
+ // Tags collected from PATTERN_LIBRARY.category for each kind. The
72
+ // exact category names live in the library — assert the union has
73
+ // at least the categories we expect to see for these kinds, instead
74
+ // of pinning the full set (so adding categories doesn't break this
75
+ // test). Why this matters: `tags` is the retrieval key for L2.
76
+ expect(entry.tags.length).toBeGreaterThanOrEqual(2);
77
+
78
+ expect(entry.patternsByKind).toMatchObject({
79
+ entity: 1,
80
+ writeHandler: 1,
81
+ nav: 1,
82
+ });
83
+ expect(entry.authoringStyle).toBe("canonical");
84
+ expect(entry.parseErrors).toEqual([]);
85
+ });
86
+
87
+ test("entries carry id, repo-relative paths, description, tags", () => {
88
+ plantPackage(workdir, "samples/recipes/demo", {
89
+ pkgName: "@cosmicdrift/kumiko-sample-demo",
90
+ pkgDescription: "Demo.",
91
+ featureSource: buildCanonicalMultiKindFeature("demo"),
92
+ });
93
+
94
+ const corpus = buildFewShotCorpus({ repoRoot: workdir });
95
+ const entry = corpus.entries[0];
96
+ if (!entry) throw new Error("expected one entry");
97
+
98
+ expect(entry.id).toBe("recipes/demo");
99
+ expect(entry.sourcePath).toBe("samples/recipes/demo/src/feature.ts");
100
+ expect(entry.packageJsonPath).toBe("samples/recipes/demo/package.json");
101
+ expect(entry.packageName).toBe("@cosmicdrift/kumiko-sample-demo");
102
+ expect(entry.description).toBe("Demo.");
103
+ expect(entry.featureName).toBe("demo");
104
+ expect(entry.authoringStyle).toBe("canonical");
105
+ expect(entry.parseErrors).toEqual([]);
106
+ });
107
+
108
+ test("source paths inside parsed patterns are repo-relative (no absolute paths)", () => {
109
+ plantPackage(workdir, "samples/recipes/demo", {
110
+ pkgName: "x",
111
+ pkgDescription: "x",
112
+ featureSource: buildCanonicalMultiKindFeature("demo"),
113
+ });
114
+
115
+ const corpus = buildFewShotCorpus({ repoRoot: workdir });
116
+ const entry = corpus.entries[0];
117
+ if (!entry) throw new Error("expected one entry");
118
+
119
+ // Walk the patterns recursively and assert every `file` field is
120
+ // relative — no absolute paths leaking into the JSON.
121
+ const json = JSON.stringify(entry.patterns);
122
+ expect(json).not.toContain(workdir);
123
+ });
124
+
125
+ test("non-feature .ts files are ignored", () => {
126
+ const recipesDir = join(workdir, "samples", "recipes", "demo", "src");
127
+ mkdirSync(recipesDir, { recursive: true });
128
+ writeFileSync(
129
+ join(recipesDir, "..", "package.json"),
130
+ JSON.stringify({ name: "x", description: "x" }),
131
+ );
132
+ writeFileSync(join(recipesDir, "feature.ts"), buildCanonicalMultiKindFeature("demo"));
133
+ writeFileSync(join(recipesDir, "helpers.ts"), "export const x = 1;\n");
134
+ writeFileSync(join(recipesDir, "feature.test.ts"), "// not a feature\n");
135
+
136
+ const corpus = buildFewShotCorpus({ repoRoot: workdir });
137
+ expect(corpus.entries).toHaveLength(1);
138
+ expect(corpus.entries[0]?.sourcePath.endsWith("feature.ts")).toBe(true);
139
+ });
140
+ });
141
+
142
+ // =============================================================================
143
+ // Warnings — silent skips are gone; parser-throw / duplicate-id surface
144
+ // =============================================================================
145
+
146
+ describe("buildFewShotCorpus — warnings", () => {
147
+ test("duplicate id between two scan roots produces a warning, not an overwrite", () => {
148
+ // Both paths collapse to the same id under pathToId:
149
+ // samples/foo/src/feature.ts → strip "samples/" + "/src/feature.ts" → "foo"
150
+ // packages/foo/src/feature.ts → strip "packages/" + "/src/feature.ts" → "foo"
151
+ // Without the duplicate-id check the second entry would silently
152
+ // overwrite the first in any consumer that built `Map<id, entry>`.
153
+ plantPackage(workdir, "samples/foo", {
154
+ pkgName: "@cosmicdrift/kumiko-sample-foo-a",
155
+ pkgDescription: "First foo.",
156
+ featureSource: buildCanonicalMultiKindFeature("fooA"),
157
+ });
158
+ plantPackage(workdir, "packages/foo", {
159
+ pkgName: "@cosmicdrift/kumiko-sample-foo-b",
160
+ pkgDescription: "Second foo.",
161
+ featureSource: buildCanonicalMultiKindFeature("fooB"),
162
+ });
163
+
164
+ // Scan both roots so both feature-files get picked up.
165
+ const corpus = buildFewShotCorpus({
166
+ repoRoot: workdir,
167
+ scanRoots: ["samples", "packages"],
168
+ });
169
+
170
+ expect(corpus.entries).toHaveLength(1);
171
+ expect(corpus.warnings).toHaveLength(1);
172
+ expect(corpus.warnings[0]?.reason).toMatch(/^duplicate-id:/);
173
+ });
174
+
175
+ test("parser-throw surfaces as a warning instead of silent skip", () => {
176
+ // A file that ts-morph can read syntactically but parseFeatureFile's
177
+ // top-level invariants reject. Easiest: a file that matches the
178
+ // FEATURE_FILE_PATTERN regex but contains code that crashes the
179
+ // parser (e.g. raw `import` from a non-resolvable module isn't
180
+ // enough — the parser falls back to ParseError, not a throw). Use
181
+ // outright invalid TypeScript.
182
+ const recipesDir = join(workdir, "samples", "recipes", "broken", "src");
183
+ mkdirSync(recipesDir, { recursive: true });
184
+ writeFileSync(
185
+ join(recipesDir, "..", "package.json"),
186
+ JSON.stringify({ name: "x", description: "x" }),
187
+ );
188
+ // Garbage that ts-morph still parses but as a script with errors.
189
+ // The parser may either return a corpus with errors (legacy entry)
190
+ // or throw — both branches are valid. We assert: if it *throws*,
191
+ // the warning is present; if it doesn't, no warning is needed.
192
+ writeFileSync(join(recipesDir, "feature.ts"), "this is not typescript {{{");
193
+
194
+ const corpus = buildFewShotCorpus({ repoRoot: workdir });
195
+ // Either path is acceptable, but the corpus must not silently
196
+ // disappear the file:
197
+ const handled =
198
+ corpus.entries.length === 1 ||
199
+ corpus.warnings.some((w) => w.sourcePath.endsWith("feature.ts"));
200
+ expect(handled).toBe(true);
201
+ });
202
+ });
203
+
204
+ // =============================================================================
205
+ // pathToId — pure function unit tests
206
+ // =============================================================================
207
+
208
+ describe("pathToId", () => {
209
+ test("strips samples/ prefix and /src/feature.ts suffix", () => {
210
+ expect(pathToId("samples/recipes/basic-entity/src/feature.ts")).toBe("recipes/basic-entity");
211
+ });
212
+
213
+ test("strips packages/ prefix", () => {
214
+ expect(pathToId("packages/bundled-features/src/auth-email-password/feature.ts")).toBe(
215
+ "bundled-features/src/auth-email-password",
216
+ );
217
+ });
218
+
219
+ test("strips plain /feature.ts suffix when no /src/ in the path", () => {
220
+ expect(pathToId("samples/apps/showcase/src/features/demos/feature.ts")).toBe(
221
+ "apps/showcase/src/features/demos",
222
+ );
223
+ });
224
+
225
+ test("returns the path unchanged when no prefix or suffix matches", () => {
226
+ expect(pathToId("misc/random.ts")).toBe("misc/random.ts");
227
+ });
228
+
229
+ test("only strips the leading samples|packages prefix once (no greedy)", () => {
230
+ // packages/samples/foo/feature.ts: only the first prefix gets dropped.
231
+ expect(pathToId("packages/samples/foo/feature.ts")).toBe("samples/foo");
232
+ });
233
+ });
234
+
235
+ // =============================================================================
236
+ // Helpers — feature-file content + workspace planting
237
+ // =============================================================================
238
+
239
+ function plantPackage(
240
+ root: string,
241
+ relPath: string,
242
+ opts: {
243
+ pkgName: string;
244
+ pkgDescription: string;
245
+ featureSource: string;
246
+ },
247
+ ): void {
248
+ const dir = join(root, relPath, "src");
249
+ mkdirSync(dir, { recursive: true });
250
+ writeFileSync(
251
+ join(root, relPath, "package.json"),
252
+ JSON.stringify({ name: opts.pkgName, description: opts.pkgDescription }),
253
+ );
254
+ writeFileSync(join(dir, "feature.ts"), opts.featureSource);
255
+ }
256
+
257
+ /**
258
+ * Hand-rolled canonical-form feature-file with three kinds (entity,
259
+ * writeHandler, nav) so the smoke-test exercises tags + counts across
260
+ * categories. Mirrors `samples/recipes/designer-demo/src/feature.ts`
261
+ * but inlined here so the test stays self-contained.
262
+ */
263
+ function buildCanonicalMultiKindFeature(featureName: string): string {
264
+ return `// kumiko-feature-version: 1
265
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
266
+ import { z } from "zod";
267
+
268
+ defineFeature("${featureName}", (r) => {
269
+ r.entity({
270
+ name: "task",
271
+ fields: {
272
+ title: { type: "text", required: true },
273
+ done: { type: "boolean", default: false },
274
+ },
275
+ });
276
+
277
+ r.writeHandler({
278
+ name: "task:create",
279
+ schema: z.object({ title: z.string() }),
280
+ handler: async (_event, _ctx) => {
281
+ return { isSuccess: true, data: { id: "x" } };
282
+ },
283
+ access: { roles: ["user"] },
284
+ });
285
+
286
+ r.nav({
287
+ id: "tasks",
288
+ label: "Tasks",
289
+ screen: "${featureName}:screen:task-list",
290
+ });
291
+ });
292
+ `;
293
+ }
294
+
295
+ function buildLegacyFeature(featureName: string): string {
296
+ // Identifier-ref style — the parser refuses (entity definition is a
297
+ // captured const), produces a ParseError, and the corpus marks the
298
+ // entry as authoringStyle: "legacy".
299
+ return `
300
+ import { defineFeature, createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
301
+
302
+ const itemEntity = createEntity({
303
+ table: "items",
304
+ fields: { title: createTextField({ required: true }) },
305
+ });
306
+
307
+ defineFeature("${featureName}", (r) => {
308
+ r.entity("item", itemEntity);
309
+ });
310
+ `;
311
+ }
@@ -0,0 +1,62 @@
1
+ // injectSchema teilt sich dev-server (every HTML response) und prod-
2
+ // server (static-fallback index.html) Pfad. Bug hier wäre stiller
3
+ // Production-Fail: createKumikoApp findet `window.__KUMIKO_SCHEMA__`
4
+ // nicht und mountet leer. Tests pinnen die Idempotenz + die zwei
5
+ // Insertion-Punkte (vor /client.js-Tag oder vor </body>).
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { injectSchema } from "../inject-schema";
9
+
10
+ const SCHEMA = '{"features":[]}';
11
+ const TAG = `<script>window.__KUMIKO_SCHEMA__=${SCHEMA};</script>`;
12
+
13
+ describe("injectSchema", () => {
14
+ test("HTML mit /client.js-Tag: Schema-Tag wird DAVOR eingefügt", () => {
15
+ const html = '<html><body><script src="/client.js" defer></script></body></html>';
16
+ const out = injectSchema(html, SCHEMA);
17
+ expect(out).toContain(TAG);
18
+ // Schema MUSS vor dem Client-Bundle stehen — sonst läuft
19
+ // createKumikoApp() bevor window.__KUMIKO_SCHEMA__ gesetzt ist.
20
+ expect(out.indexOf(TAG)).toBeLessThan(out.indexOf('<script src="/client.js"'));
21
+ });
22
+
23
+ test("HTML ohne /client.js-Tag: Schema-Tag wird vor </body> eingefügt", () => {
24
+ const html = "<html><body><div id=root></div></body></html>";
25
+ const out = injectSchema(html, SCHEMA);
26
+ expect(out).toContain(TAG);
27
+ expect(out.indexOf(TAG)).toBeLessThan(out.indexOf("</body>"));
28
+ });
29
+
30
+ test("HTML ohne </body>: Schema-Tag wird angehängt (defensiver Fallback)", () => {
31
+ const html = "<div>fragment</div>";
32
+ const out = injectSchema(html, SCHEMA);
33
+ expect(out.endsWith(TAG)).toBe(true);
34
+ });
35
+
36
+ test("Idempotent: bei bereits injectem Schema kein zweiter Tag", () => {
37
+ const html = `<html><body>${TAG}</body></html>`;
38
+ const out = injectSchema(html, '{"features":[{"differentSchema":true}]}');
39
+ // Original-Tag bleibt, kein zweiter Tag hinzugefügt — der Marker-
40
+ // Check verhindert sonst stacking-Tags bei repeated reads.
41
+ expect(out).toBe(html);
42
+ });
43
+
44
+ test("Schema mit Komplex-Daten (entities + screens) bleibt valides JS", () => {
45
+ const complex = JSON.stringify({
46
+ features: [
47
+ {
48
+ featureName: "items",
49
+ entities: { item: { fields: { title: { type: "text" } } } },
50
+ screens: [{ id: "list", type: "entityList", entity: "item", columns: ["title"] }],
51
+ },
52
+ ],
53
+ });
54
+ const html = '<html><body><script src="/client.js"></script></body></html>';
55
+ const out = injectSchema(html, complex);
56
+ // Sanity: das injected Skript muss valid JS sein (Object-Literal-
57
+ // Syntax, keine HTML-Reserved-Chars im JSON die den <script>-Block
58
+ // brechen würden — JSON.stringify entkommt /, < usw. nicht, aber
59
+ // die Standard-Chars die wir hier nutzen sind unkritisch).
60
+ expect(out).toContain(`window.__KUMIKO_SCHEMA__=${complex}`);
61
+ });
62
+ });