@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,467 @@
1
+ // Regression-Guard für die EIGENTLICHE Behauptung der Codegen-Pipeline:
2
+ // `ctx.appendEvent` wird via Lokal-Wrapper STRICT typgeprüft.
3
+ //
4
+ // Ohne diesen Test verifizieren die anderen 12 Codegen-Tests nur, dass
5
+ // die richtigen Strings ins File geschrieben werden. Wenn jemand später
6
+ // das `export *`-Shadowing kaputt macht, den `KumikoEventTypeMap`-
7
+ // Re-Export aus `engine/index.ts` entfernt, oder TS-Verhalten in einer
8
+ // neuen Version subtil bricht — die anderen Tests bleiben grün und der
9
+ // strict-mode stirbt schweigend. Genau das wäre der Fall den dieser
10
+ // Test fängt.
11
+ //
12
+ // Ablauf pro Test-Case:
13
+ // 1. tmp-App mit feature.ts + events.ts + bin/main.ts erzeugen.
14
+ // 2. `runCodegen` auf die tmp-App fahren — schreibt
15
+ // `.kumiko/types.generated.d.ts` + `define.ts`.
16
+ // 3. Eine synthetische Test-Datei mit den gewünschten Aufrufen schreiben.
17
+ // 4. ts.createProgram über die App + paths-alias zur framework-source.
18
+ // 5. Diagnostics auswerten — auf konkrete TS-Codes prüfen.
19
+ //
20
+ // `paths` zeigt direkt auf die framework-source (`packages/framework/src`),
21
+ // damit der TS-Type-Checker die Augmentation als Teil DESSELBEN Compiles
22
+ // sieht (Use-Site-Substitution funktioniert nur so — siehe
23
+ // project_x1_typemap_findings memory).
24
+
25
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
26
+ import { dirname, join } from "node:path";
27
+ import * as ts from "typescript";
28
+ import { afterAll, describe, expect, test } from "vitest";
29
+ import { runCodegen } from "../run-codegen";
30
+
31
+ const REPO_ROOT = join(__dirname, "../../../../..");
32
+ const FRAMEWORK_SRC = join(REPO_ROOT, "packages/framework/src");
33
+
34
+ // Test-Apps werden IM Repo-Tree angelegt (gitignored), damit Node's
35
+ // natürliches `node_modules`-Hochsuchen 'zod' & Co finden kann. tmpdir
36
+ // liegt außerhalb des Repo-Trees → keine node_modules-Sicht.
37
+ const TEST_FIXTURE_DIR = join(__dirname, ".tmp-fixtures");
38
+ const createdDirs: string[] = [];
39
+
40
+ function makeAppDir(): string {
41
+ mkdirSync(TEST_FIXTURE_DIR, { recursive: true });
42
+ const dir = mkdtempSync(join(TEST_FIXTURE_DIR, "app-"));
43
+ createdDirs.push(dir);
44
+ return dir;
45
+ }
46
+
47
+ afterAll(() => {
48
+ for (const d of createdDirs) {
49
+ try {
50
+ rmSync(d, { recursive: true, force: true });
51
+ } catch {
52
+ // best-effort cleanup
53
+ }
54
+ }
55
+ try {
56
+ rmSync(TEST_FIXTURE_DIR, { recursive: true, force: true });
57
+ } catch {
58
+ // ditto
59
+ }
60
+ });
61
+
62
+ function write(dir: string, relPath: string, content: string): string {
63
+ const full = join(dir, relPath);
64
+ mkdirSync(dirname(full), { recursive: true });
65
+ writeFileSync(full, content, "utf-8");
66
+ return full;
67
+ }
68
+
69
+ /**
70
+ * Baut ein TS-Program über die App + framework-source, gibt die
71
+ * semantischen Diagnostics zurück. Lib-files werden vom installierten
72
+ * typescript-Package geholt; sonst meckert TS über fehlende DOM-types.
73
+ */
74
+ function compileApp(appRoot: string): readonly ts.Diagnostic[] {
75
+ // Wir lassen ts node_modules vom REPO_ROOT auflösen (tmp-Dir hat kein
76
+ // eigenes node_modules). `baseUrl` zeigt auf repo, `paths` mappt
77
+ // framework + tmp-app explizit; rest fällt auf node_modules-Lookup
78
+ // im repo-Tree zurück.
79
+ const compilerOptions: ts.CompilerOptions = {
80
+ target: ts.ScriptTarget.ESNext,
81
+ module: ts.ModuleKind.ESNext,
82
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
83
+ strict: true,
84
+ skipLibCheck: true,
85
+ esModuleInterop: true,
86
+ noEmit: true,
87
+ baseUrl: REPO_ROOT,
88
+ paths: {
89
+ "@cosmicdrift/kumiko-framework/*": [join(FRAMEWORK_SRC, "*/index.ts")],
90
+ },
91
+ types: [],
92
+ };
93
+
94
+ // Sammle alle .ts-Files unter src/ + .kumiko/, plus die framework-
95
+ // source-tree die wir via paths erreichen wollen.
96
+ const program = ts.createProgram({
97
+ rootNames: collectFiles(appRoot),
98
+ options: compilerOptions,
99
+ });
100
+ return ts.getPreEmitDiagnostics(program);
101
+ }
102
+
103
+ function collectFiles(dir: string): string[] {
104
+ const out: string[] = [];
105
+ const fs = require("node:fs");
106
+ const walk = (d: string) => {
107
+ let entries: string[];
108
+ try {
109
+ entries = fs.readdirSync(d);
110
+ } catch {
111
+ return;
112
+ }
113
+ for (const e of entries) {
114
+ if (e.startsWith(".") && e !== ".kumiko") continue;
115
+ if (e === "node_modules") continue;
116
+ const full = join(d, e);
117
+ const stat = fs.statSync(full);
118
+ if (stat.isDirectory()) walk(full);
119
+ else if (stat.isFile() && (e.endsWith(".ts") || e.endsWith(".tsx")) && !e.endsWith(".d.ts"))
120
+ out.push(full);
121
+ }
122
+ };
123
+ walk(join(dir, "src"));
124
+ walk(join(dir, ".kumiko"));
125
+ return out;
126
+ }
127
+
128
+ // Default-shape feature: setup callback registers `placed` and returns
129
+ // nothing. Used by tests that exercise the standard `ctx.appendEvent({
130
+ // type: "orders:event:placed", ... })` literal-string path.
131
+ function setupApp(): string {
132
+ const appRoot = makeAppDir();
133
+ writeOrderPlacedSchema(appRoot);
134
+ write(
135
+ appRoot,
136
+ "src/feature/feature.ts",
137
+ `import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
138
+ import { orderPlacedSchema } from "./events";
139
+
140
+ export const ordersFeature = defineFeature("orders", (r) => {
141
+ r.defineEvent("placed", orderPlacedSchema);
142
+ });
143
+ `,
144
+ );
145
+ return appRoot;
146
+ }
147
+
148
+ // Exports-shape feature: setup callback returns `{ placed }` so handler
149
+ // modules can do `ordersFeature.exports.placed.name` and pick up the
150
+ // literal type. Used by the eventDef.name pattern test.
151
+ function setupAppWithExports(): string {
152
+ const appRoot = makeAppDir();
153
+ writeOrderPlacedSchema(appRoot);
154
+ write(
155
+ appRoot,
156
+ "src/feature/feature.ts",
157
+ `import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
158
+ import { orderPlacedSchema } from "./events";
159
+
160
+ export const ordersFeature = defineFeature("orders", (r) => ({
161
+ placed: r.defineEvent("placed", orderPlacedSchema),
162
+ }));
163
+ `,
164
+ );
165
+ return appRoot;
166
+ }
167
+
168
+ function writeOrderPlacedSchema(appRoot: string): void {
169
+ write(
170
+ appRoot,
171
+ "src/feature/events.ts",
172
+ `import { z } from "zod";
173
+ export const orderPlacedSchema = z.object({
174
+ orderId: z.string(),
175
+ customerId: z.string(),
176
+ amount: z.number(),
177
+ });
178
+ `,
179
+ );
180
+ }
181
+
182
+ // Each test compiles a tmp-app via ts.createProgram, which traverses
183
+ // the framework-source via paths-alias (~700 files). Lokal ~1.7s pro
184
+ // test; CI auf cdgs-runner (Hetzner CAX21 ARM64) braucht teilweise
185
+ // >60s — vermutlich kleinere Cores + weniger RAM-cache als M-Series-
186
+ // Mac. 120s gibt genug Puffer für den langsamsten Run-Path
187
+ // (eventDef.name pattern lädt full augmented map) ohne echte Hänger
188
+ // zu maskieren.
189
+ const STRICT_MODE_TIMEOUT_MS = 120_000;
190
+
191
+ describe("strict-mode diagnostics — the actual contract of the codegen", () => {
192
+ test("good ctx.appendEvent compiles cleanly", { timeout: STRICT_MODE_TIMEOUT_MS }, () => {
193
+ const appRoot = setupApp();
194
+ runCodegen({ appRoot });
195
+
196
+ write(
197
+ appRoot,
198
+ "src/feature/handler.ts",
199
+ `import { defineWriteHandler } from "../../.kumiko/define";
200
+ import { z } from "zod";
201
+
202
+ export const placeOrder = defineWriteHandler({
203
+ name: "orders.placeOrder",
204
+ schema: z.object({}),
205
+ access: { roles: ["Admin"] },
206
+ handler: async (_event, ctx) => {
207
+ await ctx.appendEvent({
208
+ aggregateId: "x",
209
+ aggregateType: "order",
210
+ type: "orders:event:placed",
211
+ payload: { orderId: "o1", customerId: "c1", amount: 99 },
212
+ });
213
+ return { isSuccess: true as const, data: { id: "o1" } };
214
+ },
215
+ });
216
+ `,
217
+ );
218
+
219
+ const diagnostics = compileApp(appRoot);
220
+ const handlerErrors = diagnostics.filter((d) =>
221
+ d.file?.fileName.endsWith("/feature/handler.ts"),
222
+ );
223
+ expect(handlerErrors).toHaveLength(0);
224
+ });
225
+
226
+ test("unknown event-type triggers TS2322 with augmented map in error message", {
227
+ timeout: STRICT_MODE_TIMEOUT_MS,
228
+ }, () => {
229
+ const appRoot = setupApp();
230
+ runCodegen({ appRoot });
231
+
232
+ write(
233
+ appRoot,
234
+ "src/feature/handler.ts",
235
+ `import { defineWriteHandler } from "../../.kumiko/define";
236
+ import { z } from "zod";
237
+
238
+ export const placeOrder = defineWriteHandler({
239
+ name: "orders.placeOrder",
240
+ schema: z.object({}),
241
+ access: { roles: ["Admin"] },
242
+ handler: async (_event, ctx) => {
243
+ await ctx.appendEvent({
244
+ aggregateId: "x",
245
+ aggregateType: "order",
246
+ type: "totally:made:up",
247
+ payload: { whatever: 1 },
248
+ });
249
+ return { isSuccess: true as const, data: { id: "x" } };
250
+ },
251
+ });
252
+ `,
253
+ );
254
+
255
+ const diagnostics = compileApp(appRoot);
256
+ const handlerErrors = diagnostics.filter((d) =>
257
+ d.file?.fileName.endsWith("/feature/handler.ts"),
258
+ );
259
+ // We expect at least one TS2322 ("not assignable") for the bogus
260
+ // type-string. The exact column may move with TS versions; the code
261
+ // + the type-name are the stable contract.
262
+ const ts2322 = handlerErrors.filter((d) => d.code === 2322);
263
+ expect(ts2322.length).toBeGreaterThan(0);
264
+ const flattened = ts2322
265
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"))
266
+ .join("\n");
267
+ expect(flattened).toMatch(/keyof KumikoEventTypeMap|"orders:event:placed"/);
268
+ });
269
+
270
+ test("payload-shape mismatch triggers a property-error", {
271
+ timeout: STRICT_MODE_TIMEOUT_MS,
272
+ }, () => {
273
+ const appRoot = setupApp();
274
+ runCodegen({ appRoot });
275
+
276
+ write(
277
+ appRoot,
278
+ "src/feature/handler.ts",
279
+ `import { defineWriteHandler } from "../../.kumiko/define";
280
+ import { z } from "zod";
281
+
282
+ export const placeOrder = defineWriteHandler({
283
+ name: "orders.placeOrder",
284
+ schema: z.object({}),
285
+ access: { roles: ["Admin"] },
286
+ handler: async (_event, ctx) => {
287
+ await ctx.appendEvent({
288
+ aggregateId: "x",
289
+ aggregateType: "order",
290
+ type: "orders:event:placed",
291
+ payload: { orderId: "o1", customerId: "c1", amount: 99, bogus: "extra" },
292
+ });
293
+ return { isSuccess: true as const, data: { id: "o1" } };
294
+ },
295
+ });
296
+ `,
297
+ );
298
+
299
+ const diagnostics = compileApp(appRoot);
300
+ const handlerErrors = diagnostics.filter((d) =>
301
+ d.file?.fileName.endsWith("/feature/handler.ts"),
302
+ );
303
+ // TS2353 = "Object literal may only specify known properties, and
304
+ // 'bogus' does not exist in type". This is the property-level
305
+ // strict-check we promised.
306
+ const propErrors = handlerErrors.filter((d) => d.code === 2353);
307
+ expect(propErrors.length).toBeGreaterThan(0);
308
+ const flattened = propErrors
309
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"))
310
+ .join("\n");
311
+ expect(flattened).toMatch(/'bogus'/);
312
+ });
313
+
314
+ test("direct framework-import + augmentation-included compiles strict too", {
315
+ timeout: STRICT_MODE_TIMEOUT_MS,
316
+ }, () => {
317
+ // Sanity-Check: in einem isolated app-tsc (tmp-fixture mit paths-
318
+ // mapping zur framework-source UND .kumiko/types.generated.d.ts im
319
+ // include-Glob) greift strict-mode auch beim direct framework-import.
320
+ // Generic-function-inference nimmt die augmentation am use-site wahr.
321
+ //
322
+ // Konsequenz: der Wrapper ist NICHT der einzige Weg zu strict —
323
+ // aber er ist DER ROBUSTE Weg. Er importiert `types.generated`
324
+ // explicit als side-effect, sodass die Augmentation auch in
325
+ // partial-builds / IDE-Sprachserver-stati garantiert visible ist.
326
+ // Direkter Import setzt voraus, dass das tsconfig-Setup stimmt.
327
+ //
328
+ // Die alte "K=never"-Beobachtung aus den 13 Probes war im
329
+ // bundled-features-Compile, wo das `.kumiko/`-Output nicht im
330
+ // include-Glob lag — die Augmentation aus inline `declare module`
331
+ // hatte einen anderen Resolution-Pfad. Der Wrapper bleibt der
332
+ // empfohlene Pfad für Apps, weil er diese Setup-Sensibilität wegabstrahiert.
333
+ const appRoot = setupApp();
334
+ runCodegen({ appRoot });
335
+
336
+ write(
337
+ appRoot,
338
+ "src/feature/handler-direct.ts",
339
+ `import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
340
+ import { z } from "zod";
341
+
342
+ export const placeOrder = defineWriteHandler({
343
+ name: "orders.placeOrder",
344
+ schema: z.object({}),
345
+ access: { roles: ["Admin"] },
346
+ handler: async (_event, ctx) => {
347
+ await ctx.appendEvent({
348
+ aggregateId: "x",
349
+ aggregateType: "order",
350
+ type: "orders:event:placed",
351
+ payload: { orderId: "o1", customerId: "c1", amount: 99 },
352
+ });
353
+ return { isSuccess: true as const, data: { id: "o1" } };
354
+ },
355
+ });
356
+ `,
357
+ );
358
+
359
+ const diagnostics = compileApp(appRoot);
360
+ const handlerErrors = diagnostics.filter((d) =>
361
+ d.file?.fileName.endsWith("/feature/handler-direct.ts"),
362
+ );
363
+ // Good call should compile — augmentation is visible via include of
364
+ // `.kumiko/types.generated.d.ts`.
365
+ expect(handlerErrors).toHaveLength(0);
366
+ });
367
+
368
+ test("eventDef.name pattern: literal-typed name resolves to correct payload-shape", {
369
+ timeout: STRICT_MODE_TIMEOUT_MS,
370
+ }, () => {
371
+ // Marten pattern: `const placed = r.defineEvent(...)`, then
372
+ // `type: placed.name` in appendEvent. This requires `EventDef.name`
373
+ // to be LITERAL-typed (`"orders:event:placed"`, NOT `string`) —
374
+ // otherwise the lookup collapses to `string` and the strict check
375
+ // silently disappears.
376
+ //
377
+ // This test catches regressions in `EventDef<TPayload, TName>` and
378
+ // the `<const TInner>` inference in `defineFeature`/`defineEvent`
379
+ // — both have to cooperate so that `placed.name` resolves as a
380
+ // literal into the `KumikoEventTypeMap` key.
381
+ //
382
+ // Setup: `setupAppWithExports` returns `{ placed }` from the
383
+ // defineFeature callback so handler modules can read it as
384
+ // `ordersFeature.exports.placed.name`.
385
+ const appRoot = setupAppWithExports();
386
+ runCodegen({ appRoot });
387
+
388
+ write(
389
+ appRoot,
390
+ "src/feature/handler-byname.ts",
391
+ `import { defineWriteHandler } from "../../.kumiko/define";
392
+ import { z } from "zod";
393
+ import { ordersFeature } from "./feature";
394
+
395
+ const { placed } = ordersFeature.exports;
396
+
397
+ export const placeOrder = defineWriteHandler({
398
+ name: "orders.placeOrder",
399
+ schema: z.object({}),
400
+ access: { roles: ["Admin"] },
401
+ handler: async (_event, ctx) => {
402
+ await ctx.appendEvent({
403
+ aggregateId: "x",
404
+ aggregateType: "order",
405
+ type: placed.name,
406
+ payload: { orderId: "o1", customerId: "c1", amount: 99 },
407
+ });
408
+ return { isSuccess: true as const, data: { id: "o1" } };
409
+ },
410
+ });
411
+ `,
412
+ );
413
+
414
+ const goodDiagnostics = compileApp(appRoot);
415
+ const goodErrors = goodDiagnostics.filter((d) =>
416
+ d.file?.fileName.endsWith("/feature/handler-byname.ts"),
417
+ );
418
+ if (goodErrors.length > 0) {
419
+ const msgs = goodErrors
420
+ .map((d) => ` TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`)
421
+ .join("\n");
422
+ throw new Error(`expected handler-byname.ts to compile cleanly, got:\n${msgs}`);
423
+ }
424
+
425
+ // Negative-Case: bad payload (extra property) → TS2353. Der
426
+ // entscheidende Punkt — wenn `placed.name` zu `string` kollabiert
427
+ // wäre, würde TS hier eine `Record<string, unknown>` annehmen und
428
+ // die extra property NICHT melden. TS2353 hier beweist die
429
+ // literal-typed Auflösung über `.name`.
430
+ write(
431
+ appRoot,
432
+ "src/feature/handler-byname.ts",
433
+ `import { defineWriteHandler } from "../../.kumiko/define";
434
+ import { z } from "zod";
435
+ import { ordersFeature } from "./feature";
436
+
437
+ const { placed } = ordersFeature.exports;
438
+
439
+ export const placeOrder = defineWriteHandler({
440
+ name: "orders.placeOrder",
441
+ schema: z.object({}),
442
+ access: { roles: ["Admin"] },
443
+ handler: async (_event, ctx) => {
444
+ await ctx.appendEvent({
445
+ aggregateId: "x",
446
+ aggregateType: "order",
447
+ type: placed.name,
448
+ payload: { orderId: "o1", customerId: "c1", amount: 99, bogus: "extra" },
449
+ });
450
+ return { isSuccess: true as const, data: { id: "o1" } };
451
+ },
452
+ });
453
+ `,
454
+ );
455
+
456
+ const badDiagnostics = compileApp(appRoot);
457
+ const badErrors = badDiagnostics.filter((d) =>
458
+ d.file?.fileName.endsWith("/feature/handler-byname.ts"),
459
+ );
460
+ const propErrors = badErrors.filter((d) => d.code === 2353);
461
+ expect(propErrors.length).toBeGreaterThan(0);
462
+ const flattened = propErrors
463
+ .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"))
464
+ .join("\n");
465
+ expect(flattened).toMatch(/'bogus'/);
466
+ });
467
+ });
@@ -0,0 +1,186 @@
1
+ // watchAndRegenerate — file-watcher-Tests. Verifiziert: initial-Pass
2
+ // läuft synchron, file-changes triggern einen erneuten Pass mit Debounce,
3
+ // close() ist idempotent.
4
+ //
5
+ // Fixtures liegen wie bei strict-mode-diagnostics.test.ts unter
6
+ // `__tests__/.tmp-fixtures/` (gitignored), damit Node's natürliches
7
+ // `node_modules`-Hochsuchen 'zod' findet — auch wenn watch-Tests
8
+ // 'zod' nicht direct nutzen, runCodegen scant feature-files die
9
+ // `import { z } from "zod"` haben.
10
+
11
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { afterAll, describe, expect, test } from "vitest";
14
+ import type { CodegenResult } from "../run-codegen";
15
+ import { watchAndRegenerate } from "../watch";
16
+
17
+ const TEST_FIXTURE_DIR = join(__dirname, ".tmp-fixtures");
18
+ const createdDirs: string[] = [];
19
+
20
+ function makeAppDir(): string {
21
+ mkdirSync(TEST_FIXTURE_DIR, { recursive: true });
22
+ const dir = mkdtempSync(join(TEST_FIXTURE_DIR, "watch-"));
23
+ createdDirs.push(dir);
24
+ return dir;
25
+ }
26
+
27
+ function writeFile(dir: string, relPath: string, content: string): string {
28
+ const full = join(dir, relPath);
29
+ mkdirSync(dirname(full), { recursive: true });
30
+ writeFileSync(full, content, "utf-8");
31
+ return full;
32
+ }
33
+
34
+ afterAll(() => {
35
+ for (const d of createdDirs) {
36
+ try {
37
+ rmSync(d, { recursive: true, force: true });
38
+ } catch {
39
+ /* best-effort */
40
+ }
41
+ }
42
+ });
43
+
44
+ /**
45
+ * Polls a predicate at `interval` ms until it returns true, or rejects
46
+ * after `timeout`. Replaces fixed `setTimeout(...)` waits — those
47
+ * implicitly assume "this many ms is enough", which is brittle on
48
+ * loaded CI runners. The polling form converges as fast as the system
49
+ * allows AND fails loudly with a useful message if the event never lands.
50
+ */
51
+ async function waitFor(
52
+ predicate: () => boolean,
53
+ opts: { timeout?: number; interval?: number; label?: string } = {},
54
+ ): Promise<void> {
55
+ const timeout = opts.timeout ?? 2000;
56
+ const interval = opts.interval ?? 25;
57
+ const deadline = Date.now() + timeout;
58
+ while (!predicate()) {
59
+ if (Date.now() > deadline) {
60
+ throw new Error(`waitFor: ${opts.label ?? "predicate"} not satisfied within ${timeout}ms`);
61
+ }
62
+ await new Promise((r) => setTimeout(r, interval));
63
+ }
64
+ }
65
+
66
+ const FEATURE_TEMPLATE = (featureName: string, eventName: string) => `
67
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
68
+ import { z } from "zod";
69
+
70
+ export default defineFeature("${featureName}", (r) => {
71
+ r.defineEvent("${eventName}", z.object({ id: z.string() }));
72
+ });
73
+ `;
74
+
75
+ describe("watchAndRegenerate", () => {
76
+ test("initial run produces output synchronously", () => {
77
+ const appRoot = makeAppDir();
78
+ writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("billing", "first-event"));
79
+
80
+ const results: CodegenResult[] = [];
81
+ const handle = watchAndRegenerate({
82
+ appRoot,
83
+ onResult: (r) => results.push(r),
84
+ });
85
+
86
+ expect(results).toHaveLength(1);
87
+ expect(results[0]?.eventCount).toBe(1);
88
+ handle.close();
89
+ });
90
+
91
+ test("file change triggers a re-run after debounce", async () => {
92
+ const appRoot = makeAppDir();
93
+ writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("orders", "first"));
94
+
95
+ const results: CodegenResult[] = [];
96
+ const handle = watchAndRegenerate({
97
+ appRoot,
98
+ debounceMs: 30,
99
+ onResult: (r) => results.push(r),
100
+ });
101
+
102
+ expect(results).toHaveLength(1);
103
+ expect(results[0]?.eventCount).toBe(1);
104
+
105
+ // Add a second event-definition by rewriting the feature.
106
+ writeFile(
107
+ appRoot,
108
+ "src/feature.ts",
109
+ `
110
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
111
+ import { z } from "zod";
112
+
113
+ export default defineFeature("orders", (r) => {
114
+ r.defineEvent("first", z.object({ id: z.string() }));
115
+ r.defineEvent("second", z.object({ tag: z.string() }));
116
+ });
117
+ `,
118
+ );
119
+
120
+ // Poll until the watcher's debounced re-run has landed. fs.watch
121
+ // events on macOS arrive in 1-5ms; CI runners can stretch that —
122
+ // polling adapts to the actual schedule instead of guessing a fixed
123
+ // sleep.
124
+ await waitFor(() => results.length >= 2, {
125
+ timeout: 2000,
126
+ label: "second codegen result",
127
+ });
128
+
129
+ expect(results.at(-1)?.eventCount).toBe(2);
130
+ handle.close();
131
+ });
132
+
133
+ test("close() is idempotent", () => {
134
+ const appRoot = makeAppDir();
135
+ writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("nope", "evt"));
136
+ const handle = watchAndRegenerate({ appRoot, onResult: () => {} });
137
+ handle.close();
138
+ handle.close(); // must not throw
139
+ expect(true).toBe(true);
140
+ });
141
+
142
+ test("non-ts file changes do not trigger codegen", async () => {
143
+ // Negative-assertion shape: prove that .css/.md changes do NOT add
144
+ // a codegen result. Naïve "sleep N ms then assert length stayed"
145
+ // is racy on macOS, where fs.watch can deliver stale events from
146
+ // pre-watcher writes after the watcher is attached. We sidestep
147
+ // that by anchoring on a POSITIVE control: a known-triggering .ts
148
+ // change at the end. waitFor proves the watcher is alive — so the
149
+ // pre-trigger count is trustworthy.
150
+ const appRoot = makeAppDir();
151
+ writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("ignore-css", "evt"));
152
+
153
+ const results: CodegenResult[] = [];
154
+ const handle = watchAndRegenerate({
155
+ appRoot,
156
+ debounceMs: 30,
157
+ onResult: (r) => results.push(r),
158
+ });
159
+ expect(results).toHaveLength(1);
160
+
161
+ // Drain any stale events from the pre-watcher feature.ts write —
162
+ // some platforms deliver these to a watcher attached after the
163
+ // write. Long enough to outlast debounce + scheduler jitter.
164
+ await new Promise((r) => setTimeout(r, 200));
165
+ const baseline = results.length;
166
+
167
+ // Non-ts writes — the regression we want to catch.
168
+ writeFile(appRoot, "src/styles.css", `body { color: red; }`);
169
+ writeFile(appRoot, "src/README.md", `# hi`);
170
+ await new Promise((r) => setTimeout(r, 200));
171
+ const afterNonTs = results.length;
172
+
173
+ // Positive control: a .ts change MUST trigger. waitFor exits as
174
+ // soon as the new result lands, confirming the watcher is alive.
175
+ writeFile(appRoot, "src/feature.ts", FEATURE_TEMPLATE("ignore-css", "after"));
176
+ await waitFor(() => results.length > afterNonTs, {
177
+ timeout: 2000,
178
+ label: "ts-change result after non-ts noise",
179
+ });
180
+
181
+ // The non-ts writes should not have advanced the count past the
182
+ // baseline. If they did, the watcher's filter is broken.
183
+ expect(afterNonTs).toBe(baseline);
184
+ handle.close();
185
+ });
186
+ });
@@ -0,0 +1,17 @@
1
+ export {
2
+ renderDefineFile,
3
+ renderInlineSchemasFile,
4
+ renderTypesAugmentation,
5
+ } from "./render";
6
+ export { type CodegenOptions, type CodegenResult, runCodegen } from "./run-codegen";
7
+ export {
8
+ qualifiedNameToConstName,
9
+ rewriteImportPath,
10
+ type ScannedEvent,
11
+ type ScanOptions,
12
+ type ScanResult,
13
+ type ScanWarning,
14
+ type SchemaSource,
15
+ scanEvents,
16
+ } from "./scan-events";
17
+ export { type WatchHandle, type WatchOptions, watchAndRegenerate } from "./watch";