@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.
- package/dist/index.d.ts +148 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +290 -0
- package/dist/index.js.map +1 -0
- package/package.json +42 -0
- package/src/env.d.ts +40 -0
- package/src/index.d.ts +135 -0
- package/src/index.test.ts +878 -0
- package/src/index.ts +437 -0
- package/src/lib.rs +388 -0
- package/src/openapi_generator.rs +525 -0
- package/src/output_pipeline.rs +234 -0
- package/src/parser.rs +105 -0
- package/src/route_compiler.rs +615 -0
- package/src/schema_differ.rs +393 -0
- package/src/test_stub_generator.rs +318 -0
- package/src/type_extractor.rs +370 -0
- package/src/typia_bridge.rs +179 -0
|
@@ -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
|
+
});
|