@typokit/transform-native 0.1.4

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.
@@ -0,0 +1,878 @@
1
+ import { describe, it, expect } from "@rstest/core";
2
+ import {
3
+ parseAndExtractTypes,
4
+ compileRoutes,
5
+ generateOpenApi,
6
+ diffSchemas,
7
+ generateTestStubs,
8
+ prepareValidatorInputs,
9
+ collectValidatorOutputs,
10
+ computeContentHash,
11
+ buildPipeline,
12
+ } from "./index.js";
13
+ import * as path from "path";
14
+ import * as fs from "fs";
15
+ import * as os from "os";
16
+
17
+ // Skip all tests when the native binary is not available for the current platform
18
+ const triples: Record<string, Record<string, string>> = {
19
+ win32: { x64: "win32-x64-msvc" },
20
+ darwin: { x64: "darwin-x64", arm64: "darwin-arm64" },
21
+ linux: { x64: "linux-x64-gnu", arm64: "linux-arm64-gnu" },
22
+ };
23
+ const triple = triples[process.platform]?.[process.arch];
24
+ const hasNativeBinary =
25
+ !!triple &&
26
+ fs.existsSync(
27
+ path.resolve(import.meta.dirname, "..", `index.${triple}.node`),
28
+ );
29
+ const describeNative = describe.skipIf(!hasNativeBinary);
30
+
31
+ // Helper to create a temporary TypeScript file
32
+ function createTempTsFile(content: string): string {
33
+ const tmpDir = os.tmpdir();
34
+ const filePath = path.join(
35
+ tmpDir,
36
+ `typokit-test-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`,
37
+ );
38
+ fs.writeFileSync(filePath, content, "utf-8");
39
+ return filePath;
40
+ }
41
+
42
+ // Helper to clean up a temporary file
43
+ function cleanupFile(filePath: string): void {
44
+ try {
45
+ fs.unlinkSync(filePath);
46
+ } catch {
47
+ // ignore cleanup errors
48
+ }
49
+ }
50
+
51
+ describeNative("parseAndExtractTypes", () => {
52
+ it("should parse a simple User interface with JSDoc tags", async () => {
53
+ const source = `
54
+ /**
55
+ * @table users
56
+ */
57
+ interface User {
58
+ /** @id @generated */
59
+ id: string;
60
+ /** @format email @unique */
61
+ email: string;
62
+ /** @minLength 2 @maxLength 100 */
63
+ name: string;
64
+ age: number;
65
+ active: boolean;
66
+ bio?: string;
67
+ /** @default now() @onUpdate now() */
68
+ updatedAt: string;
69
+ }
70
+ `;
71
+ const filePath = createTempTsFile(source);
72
+ try {
73
+ const result = await parseAndExtractTypes([filePath]);
74
+
75
+ expect(result).toBeDefined();
76
+ expect(result["User"]).toBeDefined();
77
+ expect(result["User"].name).toBe("User");
78
+
79
+ // Check properties exist
80
+ const props = result["User"].properties;
81
+ expect(props["id"]).toBeDefined();
82
+ expect(props["email"]).toBeDefined();
83
+ expect(props["name"]).toBeDefined();
84
+ expect(props["age"]).toBeDefined();
85
+ expect(props["active"]).toBeDefined();
86
+ expect(props["bio"]).toBeDefined();
87
+ expect(props["updatedAt"]).toBeDefined();
88
+
89
+ // Check types
90
+ expect(props["id"].type).toBe("string");
91
+ expect(props["email"].type).toBe("string");
92
+ expect(props["name"].type).toBe("string");
93
+ expect(props["age"].type).toBe("number");
94
+ expect(props["active"].type).toBe("boolean");
95
+ expect(props["bio"].type).toBe("string");
96
+
97
+ // Check optionality
98
+ expect(props["id"].optional).toBe(false);
99
+ expect(props["bio"].optional).toBe(true);
100
+ } finally {
101
+ cleanupFile(filePath);
102
+ }
103
+ });
104
+
105
+ it("should parse exported interfaces", async () => {
106
+ const source = `
107
+ export interface Post {
108
+ id: string;
109
+ title: string;
110
+ content: string;
111
+ published: boolean;
112
+ }
113
+ `;
114
+ const filePath = createTempTsFile(source);
115
+ try {
116
+ const result = await parseAndExtractTypes([filePath]);
117
+
118
+ expect(result["Post"]).toBeDefined();
119
+ expect(result["Post"].name).toBe("Post");
120
+ expect(Object.keys(result["Post"].properties).length).toBe(4);
121
+ expect(result["Post"].properties["title"].type).toBe("string");
122
+ } finally {
123
+ cleanupFile(filePath);
124
+ }
125
+ });
126
+
127
+ it("should parse complex types including arrays and unions", async () => {
128
+ const source = `
129
+ interface Product {
130
+ id: string;
131
+ tags: string[];
132
+ status: "active" | "inactive" | "draft";
133
+ metadata?: Record<string, unknown>;
134
+ }
135
+ `;
136
+ const filePath = createTempTsFile(source);
137
+ try {
138
+ const result = await parseAndExtractTypes([filePath]);
139
+
140
+ expect(result["Product"]).toBeDefined();
141
+ const props = result["Product"].properties;
142
+ expect(props["tags"].type).toBe("string[]");
143
+ expect(props["status"].type).toBe('"active" | "inactive" | "draft"');
144
+ expect(props["metadata"].type).toBe("Record<string, unknown>");
145
+ expect(props["metadata"].optional).toBe(true);
146
+ } finally {
147
+ cleanupFile(filePath);
148
+ }
149
+ });
150
+
151
+ it("should parse multiple interfaces from a single file", async () => {
152
+ const source = `
153
+ interface User {
154
+ id: string;
155
+ name: string;
156
+ }
157
+
158
+ interface Post {
159
+ id: string;
160
+ authorId: string;
161
+ title: string;
162
+ }
163
+
164
+ interface Comment {
165
+ id: string;
166
+ postId: string;
167
+ body: string;
168
+ }
169
+ `;
170
+ const filePath = createTempTsFile(source);
171
+ try {
172
+ const result = await parseAndExtractTypes([filePath]);
173
+
174
+ expect(Object.keys(result).length).toBe(3);
175
+ expect(result["User"]).toBeDefined();
176
+ expect(result["Post"]).toBeDefined();
177
+ expect(result["Comment"]).toBeDefined();
178
+ } finally {
179
+ cleanupFile(filePath);
180
+ }
181
+ });
182
+
183
+ it("should parse multiple files", async () => {
184
+ const source1 = `
185
+ interface User {
186
+ id: string;
187
+ name: string;
188
+ }
189
+ `;
190
+ const source2 = `
191
+ interface Post {
192
+ id: string;
193
+ title: string;
194
+ }
195
+ `;
196
+ const file1 = createTempTsFile(source1);
197
+ const file2 = createTempTsFile(source2);
198
+ try {
199
+ const result = await parseAndExtractTypes([file1, file2]);
200
+
201
+ expect(result["User"]).toBeDefined();
202
+ expect(result["Post"]).toBeDefined();
203
+ } finally {
204
+ cleanupFile(file1);
205
+ cleanupFile(file2);
206
+ }
207
+ });
208
+
209
+ it("should return SchemaTypeMap-compatible shape", async () => {
210
+ const source = `
211
+ interface Task {
212
+ id: string;
213
+ title: string;
214
+ done: boolean;
215
+ }
216
+ `;
217
+ const filePath = createTempTsFile(source);
218
+ try {
219
+ const result = await parseAndExtractTypes([filePath]);
220
+
221
+ // Verify the result shape matches SchemaTypeMap = Record<string, TypeMetadata>
222
+ const task = result["Task"];
223
+ expect(typeof task.name).toBe("string");
224
+ expect(typeof task.properties).toBe("object");
225
+
226
+ // Verify property shape matches { type: string; optional: boolean }
227
+ const idProp = task.properties["id"];
228
+ expect(typeof idProp.type).toBe("string");
229
+ expect(typeof idProp.optional).toBe("boolean");
230
+ } finally {
231
+ cleanupFile(filePath);
232
+ }
233
+ });
234
+
235
+ it("should throw for nonexistent files", async () => {
236
+ await expect(
237
+ parseAndExtractTypes(["/nonexistent/path/test.ts"]),
238
+ ).rejects.toThrow();
239
+ });
240
+ });
241
+
242
+ describeNative("compileRoutes", () => {
243
+ it("should compile route contracts into a radix tree TypeScript file", async () => {
244
+ const source = `
245
+ interface UsersRoutes {
246
+ "GET /users": RouteContract<void, void, void, void>;
247
+ "POST /users": RouteContract<void, void, void, void>;
248
+ "GET /users/:id": RouteContract<{ id: string }, void, void, void>;
249
+ }
250
+ interface HealthRoutes {
251
+ "GET /health": RouteContract<void, void, void, void>;
252
+ }
253
+ `;
254
+ const filePath = createTempTsFile(source);
255
+ try {
256
+ const result = await compileRoutes([filePath]);
257
+
258
+ expect(result).toContain("AUTO-GENERATED");
259
+ expect(result).toContain("CompiledRouteTable");
260
+ expect(result).toContain("routeTree");
261
+ expect(result).toContain("users");
262
+ expect(result).toContain("health");
263
+ expect(result).toContain("paramName");
264
+ expect(result).toContain("id");
265
+ } finally {
266
+ cleanupFile(filePath);
267
+ }
268
+ });
269
+
270
+ it("should handle wildcard routes", async () => {
271
+ const source = `
272
+ interface FileRoutes {
273
+ "GET /files/*path": RouteContract<void, void, void, void>;
274
+ }
275
+ `;
276
+ const filePath = createTempTsFile(source);
277
+ try {
278
+ const result = await compileRoutes([filePath]);
279
+ expect(result).toContain("wildcardChild");
280
+ expect(result).toContain("path");
281
+ } finally {
282
+ cleanupFile(filePath);
283
+ }
284
+ });
285
+
286
+ it("should throw for nonexistent files", async () => {
287
+ await expect(
288
+ compileRoutes(["/nonexistent/path/test.ts"]),
289
+ ).rejects.toThrow();
290
+ });
291
+ });
292
+
293
+ describeNative("generateOpenApi", () => {
294
+ it("should generate a valid OpenAPI 3.1 spec", async () => {
295
+ const routeSource = `
296
+ interface UsersRoutes {
297
+ "GET /users": RouteContract<void, void, void, void>;
298
+ "POST /users": RouteContract<void, void, void, void>;
299
+ "GET /users/:id": RouteContract<{ id: string }, void, void, void>;
300
+ }
301
+ `;
302
+ const routeFile = createTempTsFile(routeSource);
303
+ try {
304
+ const result = await generateOpenApi([routeFile], []);
305
+ const spec = JSON.parse(result);
306
+
307
+ expect(spec.openapi).toBe("3.1.0");
308
+ expect(spec.info.title).toBeDefined();
309
+ expect(spec.info.version).toBeDefined();
310
+ expect(spec.paths["/users"]).toBeDefined();
311
+ expect(spec.paths["/users"]["get"]).toBeDefined();
312
+ expect(spec.paths["/users"]["post"]).toBeDefined();
313
+ expect(spec.paths["/users/{id}"]).toBeDefined();
314
+ expect(spec.paths["/users/{id}"]["get"]).toBeDefined();
315
+ } finally {
316
+ cleanupFile(routeFile);
317
+ }
318
+ });
319
+
320
+ it("should include path parameters in the spec", async () => {
321
+ const routeSource = `
322
+ interface UsersRoutes {
323
+ "GET /users/:id": RouteContract<{ id: string }, void, void, void>;
324
+ }
325
+ `;
326
+ const routeFile = createTempTsFile(routeSource);
327
+ try {
328
+ const result = await generateOpenApi([routeFile], []);
329
+ const spec = JSON.parse(result);
330
+
331
+ const params = spec.paths["/users/{id}"]["get"].parameters;
332
+ expect(params).toBeDefined();
333
+ expect(params.length).toBeGreaterThanOrEqual(1);
334
+ const idParam = params.find(
335
+ (p: Record<string, unknown>) => p.name === "id",
336
+ );
337
+ expect(idParam).toBeDefined();
338
+ expect(idParam.in).toBe("path");
339
+ expect(idParam.required).toBe(true);
340
+ } finally {
341
+ cleanupFile(routeFile);
342
+ }
343
+ });
344
+
345
+ it("should generate component schemas from type files", async () => {
346
+ const routeSource = `
347
+ interface UsersRoutes {
348
+ "GET /users": RouteContract<void, void, void, PublicUser>;
349
+ }
350
+ `;
351
+ const typeSource = `
352
+ interface PublicUser {
353
+ id: string;
354
+ name: string;
355
+ email: string;
356
+ }
357
+ `;
358
+ const routeFile = createTempTsFile(routeSource);
359
+ const typeFile = createTempTsFile(typeSource);
360
+ try {
361
+ const result = await generateOpenApi([routeFile], [typeFile]);
362
+ const spec = JSON.parse(result);
363
+
364
+ expect(spec.components).toBeDefined();
365
+ expect(spec.components.schemas).toBeDefined();
366
+ expect(spec.components.schemas["PublicUser"]).toBeDefined();
367
+ expect(spec.components.schemas["PublicUser"].type).toBe("object");
368
+ expect(spec.components.schemas["PublicUser"].properties.id).toBeDefined();
369
+ expect(
370
+ spec.components.schemas["PublicUser"].properties.name,
371
+ ).toBeDefined();
372
+ } finally {
373
+ cleanupFile(routeFile);
374
+ cleanupFile(typeFile);
375
+ }
376
+ });
377
+ });
378
+
379
+ describeNative("diffSchemas", () => {
380
+ it("should detect added entity", async () => {
381
+ const oldTypes = {};
382
+ const newTypes = {
383
+ User: {
384
+ name: "User",
385
+ properties: {
386
+ id: { type: "string", optional: false },
387
+ name: { type: "string", optional: false },
388
+ },
389
+ },
390
+ };
391
+
392
+ const draft = await diffSchemas(oldTypes, newTypes, "add_user");
393
+
394
+ expect(draft.name).toBe("add_user");
395
+ expect(draft.destructive).toBe(false);
396
+ expect(draft.changes.length).toBe(1);
397
+ expect(draft.changes[0].type).toBe("add");
398
+ expect(draft.changes[0].entity).toBe("User");
399
+ expect(draft.sql).toContain("CREATE TABLE");
400
+ });
401
+
402
+ it("should detect removed entity as destructive", async () => {
403
+ const oldTypes = {
404
+ User: {
405
+ name: "User",
406
+ properties: {
407
+ id: { type: "string", optional: false },
408
+ },
409
+ },
410
+ };
411
+ const newTypes = {};
412
+
413
+ const draft = await diffSchemas(oldTypes, newTypes, "remove_user");
414
+
415
+ expect(draft.destructive).toBe(true);
416
+ expect(draft.changes[0].type).toBe("remove");
417
+ expect(draft.sql).toContain("DROP TABLE");
418
+ expect(draft.sql).toContain("DESTRUCTIVE");
419
+ });
420
+
421
+ it("should detect added and modified fields", async () => {
422
+ const oldTypes = {
423
+ User: {
424
+ name: "User",
425
+ properties: {
426
+ id: { type: "string", optional: false },
427
+ age: { type: "string", optional: false },
428
+ },
429
+ },
430
+ };
431
+ const newTypes = {
432
+ User: {
433
+ name: "User",
434
+ properties: {
435
+ id: { type: "string", optional: false },
436
+ age: { type: "number", optional: false },
437
+ email: { type: "string", optional: false },
438
+ },
439
+ },
440
+ };
441
+
442
+ const draft = await diffSchemas(oldTypes, newTypes, "modify_user");
443
+
444
+ expect(draft.destructive).toBe(true);
445
+ const addChange = draft.changes.find(
446
+ (c) => c.type === "add" && c.field === "email",
447
+ );
448
+ expect(addChange).toBeDefined();
449
+ const modifyChange = draft.changes.find(
450
+ (c) => c.type === "modify" && c.field === "age",
451
+ );
452
+ expect(modifyChange).toBeDefined();
453
+ });
454
+
455
+ it("should report no changes for identical schemas", async () => {
456
+ const types = {
457
+ User: {
458
+ name: "User",
459
+ properties: {
460
+ id: { type: "string", optional: false },
461
+ },
462
+ },
463
+ };
464
+
465
+ const draft = await diffSchemas(types, types, "no_changes");
466
+
467
+ expect(draft.destructive).toBe(false);
468
+ expect(draft.changes.length).toBe(0);
469
+ expect(draft.sql).toContain("No changes");
470
+ });
471
+ });
472
+
473
+ describeNative("generateTestStubs", () => {
474
+ it("should generate test stubs from route contracts", async () => {
475
+ const source = `
476
+ interface UsersRoutes {
477
+ "GET /users": RouteContract<void, void, void, void>;
478
+ "POST /users": RouteContract<void, void, { email: string; name: string }, void>;
479
+ "GET /users/:id": RouteContract<{ id: string }, void, void, void>;
480
+ }
481
+ `;
482
+ const filePath = createTempTsFile(source);
483
+ try {
484
+ const result = await generateTestStubs([filePath]);
485
+
486
+ expect(result).toContain("AUTO-GENERATED");
487
+ expect(result).toContain('describe("GET /users"');
488
+ expect(result).toContain('describe("POST /users"');
489
+ expect(result).toContain('describe("GET /users/:id"');
490
+ expect(result).toContain("accepts valid request");
491
+ expect(result).toContain("rejects missing required fields");
492
+ expect(result).toContain("handles path parameters");
493
+ } finally {
494
+ cleanupFile(filePath);
495
+ }
496
+ });
497
+
498
+ it("should throw for nonexistent files", async () => {
499
+ await expect(
500
+ generateTestStubs(["/nonexistent/path/test.ts"]),
501
+ ).rejects.toThrow();
502
+ });
503
+ });
504
+
505
+ describeNative("prepareValidatorInputs", () => {
506
+ it("should prepare type metadata for Typia bridge", async () => {
507
+ const source = `
508
+ interface User {
509
+ id: string;
510
+ name: string;
511
+ age?: number;
512
+ }
513
+ interface Post {
514
+ id: string;
515
+ title: string;
516
+ }
517
+ `;
518
+ const filePath = createTempTsFile(source);
519
+ try {
520
+ const inputs = await prepareValidatorInputs([filePath]);
521
+
522
+ expect(inputs.length).toBe(2);
523
+ // Should be alphabetically sorted
524
+ const postInput = inputs.find((i) => i.name === "Post");
525
+ const userInput = inputs.find((i) => i.name === "User");
526
+ expect(postInput).toBeDefined();
527
+ expect(userInput).toBeDefined();
528
+ expect(
529
+ Object.keys(
530
+ (userInput as unknown as Record<string, Record<string, unknown>>)
531
+ .properties,
532
+ ).length,
533
+ ).toBe(3);
534
+ } finally {
535
+ cleanupFile(filePath);
536
+ }
537
+ });
538
+ });
539
+
540
+ describeNative("collectValidatorOutputs", () => {
541
+ it("should map type names to file paths", async () => {
542
+ const results: [string, string][] = [
543
+ ["User", "export function validateUser() {}"],
544
+ ["BlogPost", "export function validateBlogPost() {}"],
545
+ ];
546
+
547
+ const output = await collectValidatorOutputs(results);
548
+
549
+ expect(output[".typokit/validators/user.ts"]).toContain("validateUser");
550
+ expect(output[".typokit/validators/blog-post.ts"]).toContain(
551
+ "validateBlogPost",
552
+ );
553
+ });
554
+ });
555
+
556
+ describeNative("computeContentHash", () => {
557
+ it("should produce deterministic hash regardless of file order", async () => {
558
+ const f1 = createTempTsFile("interface A { id: string; }");
559
+ const f2 = createTempTsFile("interface B { id: string; }");
560
+ try {
561
+ const hash1 = await computeContentHash([f1, f2]);
562
+ const hash2 = await computeContentHash([f2, f1]);
563
+ expect(hash1).toBe(hash2);
564
+ expect(hash1.length).toBe(64); // SHA-256 hex
565
+ } finally {
566
+ cleanupFile(f1);
567
+ cleanupFile(f2);
568
+ }
569
+ });
570
+
571
+ it("should change when file content changes", async () => {
572
+ const filePath = createTempTsFile("interface A { id: string; }");
573
+ try {
574
+ const hash1 = await computeContentHash([filePath]);
575
+ fs.writeFileSync(
576
+ filePath,
577
+ "interface A { id: string; name: string; }",
578
+ "utf-8",
579
+ );
580
+ const hash2 = await computeContentHash([filePath]);
581
+ expect(hash1).not.toBe(hash2);
582
+ } finally {
583
+ cleanupFile(filePath);
584
+ }
585
+ });
586
+ });
587
+
588
+ describeNative("buildPipeline", () => {
589
+ function createTempDir(): string {
590
+ const tmpDir = os.tmpdir();
591
+ const dir = path.join(
592
+ tmpDir,
593
+ `typokit-pipeline-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
594
+ );
595
+ fs.mkdirSync(dir, { recursive: true });
596
+ return dir;
597
+ }
598
+
599
+ function cleanupDir(dir: string): void {
600
+ try {
601
+ fs.rmSync(dir, { recursive: true, force: true });
602
+ } catch {
603
+ // ignore cleanup errors
604
+ }
605
+ }
606
+
607
+ it("should generate all outputs to .typokit/ directory", async () => {
608
+ const typeSource = `
609
+ /**
610
+ * @table users
611
+ */
612
+ interface User {
613
+ /** @id @generated */
614
+ id: string;
615
+ /** @format email @unique */
616
+ email: string;
617
+ /** @minLength 2 @maxLength 100 */
618
+ name: string;
619
+ age?: number;
620
+ active: boolean;
621
+ }
622
+ `;
623
+ const routeSource = `
624
+ interface UsersRoutes {
625
+ "GET /users": RouteContract<void, void, void, void>;
626
+ "POST /users": RouteContract<void, void, { email: string; name: string }, void>;
627
+ "GET /users/:id": RouteContract<{ id: string }, void, void, void>;
628
+ "PUT /users/:id": RouteContract<{ id: string }, void, { name: string }, void>;
629
+ "DELETE /users/:id": RouteContract<{ id: string }, void, void, void>;
630
+ }
631
+ `;
632
+ const typeFile = createTempTsFile(typeSource);
633
+ const routeFile = createTempTsFile(routeSource);
634
+ const outputDir = createTempDir();
635
+ const typokitDir = path.join(outputDir, ".typokit");
636
+
637
+ try {
638
+ const result = await buildPipeline({
639
+ typeFiles: [typeFile],
640
+ routeFiles: [routeFile],
641
+ outputDir: typokitDir,
642
+ });
643
+
644
+ // Should have regenerated
645
+ expect(result.regenerated).toBe(true);
646
+ expect(result.contentHash.length).toBe(64);
647
+ expect(result.filesWritten.length).toBeGreaterThanOrEqual(3);
648
+
649
+ // Types should be extracted
650
+ expect(result.types["User"]).toBeDefined();
651
+ expect(result.types["User"].properties["id"]).toBeDefined();
652
+ expect(result.types["User"].properties["email"]).toBeDefined();
653
+
654
+ // Compiled routes should exist
655
+ const routesPath = path.join(typokitDir, "routes", "compiled-router.ts");
656
+ expect(fs.existsSync(routesPath)).toBe(true);
657
+ const routesContent = fs.readFileSync(routesPath, "utf-8");
658
+ expect(routesContent).toContain("routeTree");
659
+ expect(routesContent).toContain("users");
660
+
661
+ // OpenAPI spec should exist
662
+ const openapiPath = path.join(typokitDir, "schemas", "openapi.json");
663
+ expect(fs.existsSync(openapiPath)).toBe(true);
664
+ const openapiContent = fs.readFileSync(openapiPath, "utf-8");
665
+ const spec = JSON.parse(openapiContent);
666
+ expect(spec.openapi).toBe("3.1.0");
667
+ expect(spec.paths["/users"]).toBeDefined();
668
+ expect(spec.paths["/users/{id}"]).toBeDefined();
669
+
670
+ // Test stubs should exist
671
+ const testsPath = path.join(typokitDir, "tests", "contract.test.ts");
672
+ expect(fs.existsSync(testsPath)).toBe(true);
673
+ const testsContent = fs.readFileSync(testsPath, "utf-8");
674
+ expect(testsContent).toContain("GET /users");
675
+ expect(testsContent).toContain("POST /users");
676
+
677
+ // Cache hash should exist
678
+ const cachePath = path.join(typokitDir, ".cache-hash");
679
+ expect(fs.existsSync(cachePath)).toBe(true);
680
+ expect(fs.readFileSync(cachePath, "utf-8").trim()).toBe(
681
+ result.contentHash,
682
+ );
683
+
684
+ // Directories should be created
685
+ expect(fs.existsSync(path.join(typokitDir, "validators"))).toBe(true);
686
+ expect(fs.existsSync(path.join(typokitDir, "client"))).toBe(true);
687
+ } finally {
688
+ cleanupFile(typeFile);
689
+ cleanupFile(routeFile);
690
+ cleanupDir(outputDir);
691
+ }
692
+ });
693
+
694
+ it("should skip regeneration on cache hit", async () => {
695
+ const typeSource = `
696
+ interface Task {
697
+ id: string;
698
+ title: string;
699
+ done: boolean;
700
+ }
701
+ `;
702
+ const routeSource = `
703
+ interface TaskRoutes {
704
+ "GET /tasks": RouteContract<void, void, void, void>;
705
+ }
706
+ `;
707
+ const typeFile = createTempTsFile(typeSource);
708
+ const routeFile = createTempTsFile(routeSource);
709
+ const outputDir = createTempDir();
710
+ const typokitDir = path.join(outputDir, ".typokit");
711
+
712
+ try {
713
+ // First build
714
+ const result1 = await buildPipeline({
715
+ typeFiles: [typeFile],
716
+ routeFiles: [routeFile],
717
+ outputDir: typokitDir,
718
+ });
719
+ expect(result1.regenerated).toBe(true);
720
+
721
+ // Second build — same inputs, should hit cache
722
+ const result2 = await buildPipeline({
723
+ typeFiles: [typeFile],
724
+ routeFiles: [routeFile],
725
+ outputDir: typokitDir,
726
+ });
727
+ expect(result2.regenerated).toBe(false);
728
+ expect(result2.contentHash).toBe(result1.contentHash);
729
+ expect(result2.filesWritten.length).toBe(0);
730
+ } finally {
731
+ cleanupFile(typeFile);
732
+ cleanupFile(routeFile);
733
+ cleanupDir(outputDir);
734
+ }
735
+ });
736
+
737
+ it("should regenerate when source files change", async () => {
738
+ const typeSource1 = `
739
+ interface Task {
740
+ id: string;
741
+ title: string;
742
+ }
743
+ `;
744
+ const routeSource = `
745
+ interface TaskRoutes {
746
+ "GET /tasks": RouteContract<void, void, void, void>;
747
+ }
748
+ `;
749
+ const typeFile = createTempTsFile(typeSource1);
750
+ const routeFile = createTempTsFile(routeSource);
751
+ const outputDir = createTempDir();
752
+ const typokitDir = path.join(outputDir, ".typokit");
753
+
754
+ try {
755
+ // First build
756
+ const result1 = await buildPipeline({
757
+ typeFiles: [typeFile],
758
+ routeFiles: [routeFile],
759
+ outputDir: typokitDir,
760
+ });
761
+ expect(result1.regenerated).toBe(true);
762
+
763
+ // Modify source file
764
+ fs.writeFileSync(
765
+ typeFile,
766
+ `
767
+ interface Task {
768
+ id: string;
769
+ title: string;
770
+ done: boolean;
771
+ }
772
+ `,
773
+ "utf-8",
774
+ );
775
+
776
+ // Second build — should regenerate
777
+ const result2 = await buildPipeline({
778
+ typeFiles: [typeFile],
779
+ routeFiles: [routeFile],
780
+ outputDir: typokitDir,
781
+ });
782
+ expect(result2.regenerated).toBe(true);
783
+ expect(result2.contentHash).not.toBe(result1.contentHash);
784
+ } finally {
785
+ cleanupFile(typeFile);
786
+ cleanupFile(routeFile);
787
+ cleanupDir(outputDir);
788
+ }
789
+ });
790
+
791
+ it("should generate validators when callback is provided", async () => {
792
+ const typeSource = `
793
+ interface User {
794
+ id: string;
795
+ name: string;
796
+ }
797
+ `;
798
+ const typeFile = createTempTsFile(typeSource);
799
+ const outputDir = createTempDir();
800
+ const typokitDir = path.join(outputDir, ".typokit");
801
+
802
+ try {
803
+ const result = await buildPipeline({
804
+ typeFiles: [typeFile],
805
+ routeFiles: [],
806
+ outputDir: typokitDir,
807
+ validatorCallback: (inputs) => {
808
+ return inputs.map(
809
+ (input) =>
810
+ [
811
+ input.name,
812
+ `export function validate${input.name}(input: unknown) { return true; }`,
813
+ ] as [string, string],
814
+ );
815
+ },
816
+ });
817
+
818
+ expect(result.regenerated).toBe(true);
819
+ // Validator files should be written
820
+ const validatorsDir = path.join(typokitDir, "validators");
821
+ const validatorFiles = fs.readdirSync(validatorsDir);
822
+ expect(validatorFiles.length).toBeGreaterThanOrEqual(1);
823
+ expect(validatorFiles.some((f: string) => f.endsWith(".ts"))).toBe(true);
824
+ } finally {
825
+ cleanupFile(typeFile);
826
+ cleanupDir(outputDir);
827
+ }
828
+ });
829
+
830
+ it("should handle large schema efficiently (50 types + 20 routes < 500ms)", async () => {
831
+ // Generate 50 type interfaces
832
+ const typeLines: string[] = [];
833
+ for (let i = 0; i < 50; i++) {
834
+ typeLines.push(`interface Type${i} {
835
+ id: string;
836
+ name: string;
837
+ email: string;
838
+ age: number;
839
+ active: boolean;
840
+ createdAt: string;
841
+ updatedAt: string;
842
+ score?: number;
843
+ }`);
844
+ }
845
+ const typeFile = createTempTsFile(typeLines.join("\n\n"));
846
+
847
+ // Generate 20 route contracts
848
+ const routeLines: string[] = [];
849
+ for (let i = 0; i < 20; i++) {
850
+ routeLines.push(`interface Route${i} {
851
+ "GET /items${i}": RouteContract<void, void, void, void>;
852
+ "POST /items${i}": RouteContract<void, void, void, void>;
853
+ "GET /items${i}/:id": RouteContract<{ id: string }, void, void, void>;
854
+ }`);
855
+ }
856
+ const routeFile = createTempTsFile(routeLines.join("\n\n"));
857
+ const outputDir = createTempDir();
858
+ const typokitDir = path.join(outputDir, ".typokit");
859
+
860
+ try {
861
+ const start = Date.now();
862
+ const result = await buildPipeline({
863
+ typeFiles: [typeFile],
864
+ routeFiles: [routeFile],
865
+ outputDir: typokitDir,
866
+ });
867
+ const elapsed = Date.now() - start;
868
+
869
+ expect(result.regenerated).toBe(true);
870
+ expect(Object.keys(result.types).length).toBe(50);
871
+ expect(elapsed).toBeLessThan(500);
872
+ } finally {
873
+ cleanupFile(typeFile);
874
+ cleanupFile(routeFile);
875
+ cleanupDir(outputDir);
876
+ }
877
+ });
878
+ });