trickle-cli 0.1.208 → 0.1.210

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.
@@ -1,57 +1,42 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import chalk from "chalk";
4
- import { fetchMockConfig, MockRoute } from "../api-client";
4
+ import { fetchMockConfig, fetchFunctionSamples, listFunctions, listTypes, MockRoute, FunctionRow, TypeSnapshot } from "../api-client";
5
5
 
6
6
  export interface TestGenOptions {
7
7
  out?: string;
8
8
  framework?: string;
9
9
  baseUrl?: string;
10
+ unit?: boolean;
11
+ function?: string;
12
+ module?: string;
10
13
  }
11
14
 
12
15
  /**
13
- * `trickle test --generate` — Generate API test files from runtime observations.
16
+ * `trickle test --generate` — Generate test files from runtime observations.
14
17
  *
15
- * Uses real sample request/response data captured at runtime to generate
16
- * ready-to-run test files with correct endpoints, request bodies, and
17
- * response shape assertions.
18
+ * Two modes:
19
+ * Default: API route tests (HTTP endpoint integration tests)
20
+ * --unit: Function-level unit tests from observed inputs/outputs
21
+ *
22
+ * Frameworks: vitest, jest, pytest
18
23
  */
19
24
  export async function testGenCommand(opts: TestGenOptions): Promise<void> {
20
- const framework = opts.framework || "vitest";
25
+ const framework = opts.framework || (opts.unit ? "vitest" : "vitest");
21
26
  const baseUrl = opts.baseUrl || "http://localhost:3000";
22
27
 
23
- if (framework !== "vitest" && framework !== "jest") {
28
+ const supportedFrameworks = ["vitest", "jest", "pytest"];
29
+ if (!supportedFrameworks.includes(framework)) {
24
30
  console.error(chalk.red(`\n Unsupported framework: ${framework}`));
25
- console.error(chalk.gray(" Supported: vitest, jest\n"));
31
+ console.error(chalk.gray(` Supported: ${supportedFrameworks.join(", ")}\n`));
26
32
  process.exit(1);
27
33
  }
28
34
 
29
35
  try {
30
- const { routes } = await fetchMockConfig();
31
-
32
- if (routes.length === 0) {
33
- console.error(chalk.yellow("\n No API routes observed yet."));
34
- console.error(chalk.gray(" Instrument your app and make some requests first.\n"));
35
- process.exit(1);
36
- }
37
-
38
- const testCode = generateTestFile(routes, framework, baseUrl);
39
-
40
- if (opts.out) {
41
- const resolvedPath = path.resolve(opts.out);
42
- const dir = path.dirname(resolvedPath);
43
- if (!fs.existsSync(dir)) {
44
- fs.mkdirSync(dir, { recursive: true });
45
- }
46
- fs.writeFileSync(resolvedPath, testCode, "utf-8");
47
- console.log("");
48
- console.log(chalk.green(` Tests written to ${chalk.bold(opts.out)}`));
49
- console.log(chalk.gray(` ${routes.length} route tests generated (${framework})`));
50
- console.log(chalk.gray(` Run with: npx ${framework === "vitest" ? "vitest run" : "jest"} ${opts.out}`));
51
- console.log("");
36
+ if (opts.unit) {
37
+ await generateUnitTests(opts, framework);
52
38
  } else {
53
- console.log("");
54
- console.log(testCode);
39
+ await generateRouteTests(opts, framework, baseUrl);
55
40
  }
56
41
  } catch (err: unknown) {
57
42
  if (err instanceof Error) {
@@ -61,7 +46,288 @@ export async function testGenCommand(opts: TestGenOptions): Promise<void> {
61
46
  }
62
47
  }
63
48
 
64
- function generateTestFile(routes: MockRoute[], framework: string, baseUrl: string): string {
49
+ // ── Route tests (existing behavior) ──
50
+
51
+ async function generateRouteTests(opts: TestGenOptions, framework: string, baseUrl: string): Promise<void> {
52
+ if (framework === "pytest") {
53
+ console.error(chalk.red("\n pytest is only supported with --unit mode"));
54
+ console.error(chalk.gray(" For API route tests, use vitest or jest\n"));
55
+ process.exit(1);
56
+ }
57
+
58
+ const { routes } = await fetchMockConfig();
59
+
60
+ if (routes.length === 0) {
61
+ console.error(chalk.yellow("\n No API routes observed yet."));
62
+ console.error(chalk.gray(" Instrument your app and make some requests first.\n"));
63
+ process.exit(1);
64
+ }
65
+
66
+ const testCode = generateRouteTestFile(routes, framework, baseUrl);
67
+ outputTestCode(testCode, opts, routes.length, "route", framework);
68
+ }
69
+
70
+ // ── Unit tests (new) ──
71
+
72
+ interface FunctionSampleData {
73
+ functionName: string;
74
+ module: string;
75
+ language: string;
76
+ samples: Array<{ input: unknown; output: unknown }>;
77
+ }
78
+
79
+ async function generateUnitTests(opts: TestGenOptions, framework: string): Promise<void> {
80
+ // Fetch all functions with their sample data
81
+ const { functions } = await listFunctions({ limit: 500 });
82
+
83
+ if (functions.length === 0) {
84
+ console.error(chalk.yellow("\n No functions observed yet."));
85
+ console.error(chalk.gray(" Run your app with trickle first: trickle run <command>\n"));
86
+ process.exit(1);
87
+ }
88
+
89
+ // Filter by function name or module if specified
90
+ let filtered = functions;
91
+ if (opts.function) {
92
+ const searchTerm = opts.function.toLowerCase();
93
+ filtered = functions.filter(f => f.function_name.toLowerCase().includes(searchTerm));
94
+ }
95
+ if (opts.module) {
96
+ const searchTerm = opts.module.toLowerCase();
97
+ filtered = filtered.filter(f => f.module.toLowerCase().includes(searchTerm));
98
+ }
99
+
100
+ // Skip route handlers (GET /api/..., POST /api/...) — those are covered by route tests
101
+ filtered = filtered.filter(f => !isRouteHandler(f.function_name));
102
+
103
+ if (filtered.length === 0) {
104
+ console.error(chalk.yellow("\n No matching functions found."));
105
+ if (opts.function || opts.module) {
106
+ console.error(chalk.gray(` Try without --function or --module filters\n`));
107
+ } else {
108
+ console.error(chalk.gray(" Only route handlers were found. Try without --unit for API route tests.\n"));
109
+ }
110
+ process.exit(1);
111
+ }
112
+
113
+ // Collect sample data for each function
114
+ const functionSamples: FunctionSampleData[] = [];
115
+
116
+ for (const fn of filtered) {
117
+ try {
118
+ const { snapshots } = await listTypes(fn.id, { limit: 10 });
119
+ const samples: Array<{ input: unknown; output: unknown }> = [];
120
+
121
+ for (const snap of snapshots) {
122
+ if (snap.sample_input !== undefined || snap.sample_output !== undefined) {
123
+ samples.push({
124
+ input: snap.sample_input,
125
+ output: snap.sample_output,
126
+ });
127
+ }
128
+ }
129
+
130
+ if (samples.length > 0) {
131
+ functionSamples.push({
132
+ functionName: fn.function_name,
133
+ module: fn.module,
134
+ language: fn.language,
135
+ samples,
136
+ });
137
+ }
138
+ } catch {
139
+ // Skip functions with no snapshot data
140
+ }
141
+ }
142
+
143
+ if (functionSamples.length === 0) {
144
+ console.error(chalk.yellow("\n No functions with sample data found."));
145
+ console.error(chalk.gray(" Run your app with trickle to capture function inputs/outputs first.\n"));
146
+ process.exit(1);
147
+ }
148
+
149
+ // Auto-detect language if framework doesn't specify
150
+ const isPython = framework === "pytest";
151
+ const isJS = framework === "vitest" || framework === "jest";
152
+
153
+ // Filter samples by language
154
+ const languageFiltered = functionSamples.filter(f =>
155
+ isPython ? f.language === "python" : f.language !== "python"
156
+ );
157
+
158
+ if (languageFiltered.length === 0) {
159
+ const lang = isPython ? "Python" : "JavaScript/TypeScript";
160
+ console.error(chalk.yellow(`\n No ${lang} functions with sample data found.`));
161
+ console.error(chalk.gray(` Try --framework ${isPython ? "vitest" : "pytest"} for the other language\n`));
162
+ process.exit(1);
163
+ }
164
+
165
+ const testCode = isPython
166
+ ? generatePytestFile(languageFiltered)
167
+ : generateUnitTestFile(languageFiltered, framework);
168
+
169
+ outputTestCode(testCode, opts, languageFiltered.length, "function", framework);
170
+ }
171
+
172
+ function outputTestCode(
173
+ testCode: string,
174
+ opts: TestGenOptions,
175
+ count: number,
176
+ kind: string,
177
+ framework: string,
178
+ ): void {
179
+ if (opts.out) {
180
+ const resolvedPath = path.resolve(opts.out);
181
+ const dir = path.dirname(resolvedPath);
182
+ if (!fs.existsSync(dir)) {
183
+ fs.mkdirSync(dir, { recursive: true });
184
+ }
185
+ fs.writeFileSync(resolvedPath, testCode, "utf-8");
186
+ console.log("");
187
+ console.log(chalk.green(` Tests written to ${chalk.bold(opts.out)}`));
188
+ console.log(chalk.gray(` ${count} ${kind} tests generated (${framework})`));
189
+
190
+ const runCmd = framework === "pytest"
191
+ ? `pytest ${opts.out} -v`
192
+ : `npx ${framework === "vitest" ? "vitest run" : "jest"} ${opts.out}`;
193
+ console.log(chalk.gray(` Run with: ${runCmd}`));
194
+ console.log("");
195
+ } else {
196
+ console.log("");
197
+ console.log(testCode);
198
+ }
199
+ }
200
+
201
+ // ── JS/TS unit test generation ──
202
+
203
+ function generateUnitTestFile(functions: FunctionSampleData[], framework: string): string {
204
+ const lines: string[] = [];
205
+
206
+ lines.push("// Auto-generated unit tests by trickle");
207
+ lines.push(`// Generated at ${new Date().toISOString()}`);
208
+ lines.push("// Based on observed runtime behavior — re-run `trickle test --generate --unit` to update");
209
+ lines.push("");
210
+
211
+ if (framework === "vitest") {
212
+ lines.push('import { describe, it, expect } from "vitest";');
213
+ lines.push("");
214
+ }
215
+
216
+ // Group functions by module for import organization
217
+ const byModule = groupByModule(functions);
218
+
219
+ // Generate import statements
220
+ for (const [mod, fns] of Object.entries(byModule)) {
221
+ const importPath = normalizeImportPath(mod);
222
+ const fnNames = fns.map(f => sanitizeFnName(f.functionName));
223
+ lines.push(`import { ${fnNames.join(", ")} } from "${importPath}";`);
224
+ }
225
+ lines.push("");
226
+
227
+ // Generate test blocks
228
+ for (const [mod, fns] of Object.entries(byModule)) {
229
+ for (const fn of fns) {
230
+ const safeName = sanitizeFnName(fn.functionName);
231
+ lines.push(`describe("${safeName}", () => {`);
232
+
233
+ for (let i = 0; i < fn.samples.length; i++) {
234
+ const sample = fn.samples[i];
235
+ const testName = describeTestCase(sample.input, sample.output, i);
236
+ const isAsync = isPromiseOutput(sample.output);
237
+
238
+ lines.push(` it("${testName}", ${isAsync ? "async " : ""}() => {`);
239
+
240
+ // Build function call
241
+ const argsStr = formatArgs(sample.input);
242
+ const resultVar = isAsync ? `await ${safeName}(${argsStr})` : `${safeName}(${argsStr})`;
243
+
244
+ lines.push(` const result = ${resultVar};`);
245
+
246
+ // Generate assertions based on output
247
+ if (sample.output !== undefined && sample.output !== null) {
248
+ const assertions = generateOutputAssertions(sample.output, "result");
249
+ for (const assertion of assertions) {
250
+ lines.push(` ${assertion}`);
251
+ }
252
+ } else if (sample.output === null) {
253
+ lines.push(" expect(result).toBeNull();");
254
+ } else {
255
+ lines.push(" expect(result).toBeDefined();");
256
+ }
257
+
258
+ lines.push(" });");
259
+ lines.push("");
260
+ }
261
+
262
+ lines.push("});");
263
+ lines.push("");
264
+ }
265
+ }
266
+
267
+ return lines.join("\n").trimEnd() + "\n";
268
+ }
269
+
270
+ // ── Python pytest generation ──
271
+
272
+ function generatePytestFile(functions: FunctionSampleData[]): string {
273
+ const lines: string[] = [];
274
+
275
+ lines.push("# Auto-generated unit tests by trickle");
276
+ lines.push(`# Generated at ${new Date().toISOString()}`);
277
+ lines.push("# Based on observed runtime behavior — re-run `trickle test --generate --unit --framework pytest` to update");
278
+ lines.push("");
279
+
280
+ // Group by module for imports
281
+ const byModule = groupByModule(functions);
282
+
283
+ // Generate import statements
284
+ for (const [mod, fns] of Object.entries(byModule)) {
285
+ const importModule = normalizePythonImport(mod);
286
+ const fnNames = fns.map(f => sanitizePythonName(f.functionName));
287
+ lines.push(`from ${importModule} import ${fnNames.join(", ")}`);
288
+ }
289
+ lines.push("");
290
+ lines.push("");
291
+
292
+ // Generate test functions
293
+ for (const fn of functions) {
294
+ const safeName = sanitizePythonName(fn.functionName);
295
+
296
+ for (let i = 0; i < fn.samples.length; i++) {
297
+ const sample = fn.samples[i];
298
+ const testSuffix = fn.samples.length > 1 ? `_case_${i + 1}` : "";
299
+ const testFnName = `test_${safeName}${testSuffix}`;
300
+
301
+ lines.push(`def ${testFnName}():`);
302
+ lines.push(` """Test ${safeName} with observed runtime data."""`);
303
+
304
+ // Format input args
305
+ const argsStr = formatPythonArgs(sample.input);
306
+ lines.push(` result = ${safeName}(${argsStr})`);
307
+
308
+ // Generate assertions
309
+ if (sample.output !== undefined && sample.output !== null) {
310
+ const assertions = generatePythonAssertions(sample.output, "result");
311
+ for (const assertion of assertions) {
312
+ lines.push(` ${assertion}`);
313
+ }
314
+ } else if (sample.output === null) {
315
+ lines.push(" assert result is None");
316
+ } else {
317
+ lines.push(" assert result is not None");
318
+ }
319
+
320
+ lines.push("");
321
+ lines.push("");
322
+ }
323
+ }
324
+
325
+ return lines.join("\n").trimEnd() + "\n";
326
+ }
327
+
328
+ // ── Route test generation (existing logic, preserved) ──
329
+
330
+ function generateRouteTestFile(routes: MockRoute[], framework: string, baseUrl: string): string {
65
331
  const lines: string[] = [];
66
332
 
67
333
  lines.push("// Auto-generated API tests by trickle");
@@ -69,17 +335,14 @@ function generateTestFile(routes: MockRoute[], framework: string, baseUrl: strin
69
335
  lines.push("// Do not edit manually — re-run `trickle test --generate` to update");
70
336
  lines.push("");
71
337
 
72
- // Import block
73
338
  if (framework === "vitest") {
74
339
  lines.push('import { describe, it, expect } from "vitest";');
75
340
  }
76
- // jest needs no import — globals are available
77
341
  lines.push("");
78
342
 
79
343
  lines.push(`const BASE_URL = process.env.TEST_API_URL || "${baseUrl}";`);
80
344
  lines.push("");
81
345
 
82
- // Group routes by resource path prefix
83
346
  const groups = groupByResource(routes);
84
347
 
85
348
  for (const [resource, resourceRoutes] of Object.entries(groups)) {
@@ -91,9 +354,7 @@ function generateTestFile(routes: MockRoute[], framework: string, baseUrl: strin
91
354
 
92
355
  lines.push(` it("${testName} — returns expected shape", async () => {`);
93
356
 
94
- // Build fetch call
95
357
  const fetchPath = route.path.replace(/:(\w+)/g, (_, param) => {
96
- // Use sample data to get a real param value if available
97
358
  const sampleValue = extractParamFromSample(route.sampleInput, param);
98
359
  return sampleValue || `test-${param}`;
99
360
  });
@@ -110,12 +371,16 @@ function generateTestFile(routes: MockRoute[], framework: string, baseUrl: strin
110
371
  lines.push(" });");
111
372
  lines.push("");
112
373
 
113
- // Status assertion
374
+ // POST typically returns 201, others return 200
375
+ // Use expect(res.ok) which covers 200-299 range
114
376
  lines.push(" expect(res.ok).toBe(true);");
115
- lines.push(` expect(res.status).toBe(200);`);
377
+ if (hasBody) {
378
+ lines.push(" expect(res.status === 200 || res.status === 201).toBe(true);");
379
+ } else {
380
+ lines.push(` expect(res.status).toBe(200);`);
381
+ }
116
382
  lines.push("");
117
383
 
118
- // Response body assertions
119
384
  lines.push(" const body = await res.json();");
120
385
 
121
386
  if (route.sampleOutput && typeof route.sampleOutput === "object") {
@@ -136,15 +401,336 @@ function generateTestFile(routes: MockRoute[], framework: string, baseUrl: strin
136
401
  return lines.join("\n").trimEnd() + "\n";
137
402
  }
138
403
 
139
- /**
140
- * Group routes by their first meaningful path segment.
141
- */
404
+ // ── Helpers: grouping & naming ──
405
+
406
+ function isRouteHandler(name: string): boolean {
407
+ return /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+\//.test(name);
408
+ }
409
+
410
+ function groupByModule(functions: FunctionSampleData[]): Record<string, FunctionSampleData[]> {
411
+ const groups: Record<string, FunctionSampleData[]> = {};
412
+ for (const fn of functions) {
413
+ const mod = fn.module || "unknown";
414
+ if (!groups[mod]) groups[mod] = [];
415
+ groups[mod].push(fn);
416
+ }
417
+ return groups;
418
+ }
419
+
420
+ function sanitizeFnName(name: string): string {
421
+ // Handle names like "MyClass.method" → "method" (keep class context in describe)
422
+ // Handle names with special chars
423
+ return name
424
+ .replace(/[^a-zA-Z0-9_$]/g, "_")
425
+ .replace(/^_+|_+$/g, "")
426
+ .replace(/_+/g, "_");
427
+ }
428
+
429
+ function sanitizePythonName(name: string): string {
430
+ return name
431
+ .replace(/[^a-zA-Z0-9_]/g, "_")
432
+ .replace(/^_+|_+$/g, "")
433
+ .replace(/_+/g, "_");
434
+ }
435
+
436
+ function normalizeImportPath(mod: string): string {
437
+ // Convert file paths to relative import paths
438
+ // e.g., "/Users/.../src/utils.ts" → "./src/utils"
439
+ // e.g., "src/helpers/math.js" → "./src/helpers/math"
440
+ let p = mod;
441
+
442
+ // Strip file extension
443
+ p = p.replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, "");
444
+
445
+ // If absolute path, try to make it relative to CWD
446
+ if (path.isAbsolute(p)) {
447
+ const cwd = process.cwd();
448
+ p = path.relative(cwd, p);
449
+ }
450
+
451
+ // Ensure starts with ./ or ../
452
+ if (!p.startsWith(".") && !p.startsWith("/")) {
453
+ p = "./" + p;
454
+ }
455
+
456
+ return p;
457
+ }
458
+
459
+ function normalizePythonImport(mod: string): string {
460
+ // Convert file paths to Python module paths
461
+ // e.g., "app/utils.py" → "app.utils"
462
+ // e.g., "/abs/path/app/models.py" → "app.models"
463
+ let p = mod;
464
+
465
+ // Strip .py extension
466
+ p = p.replace(/\.py$/, "");
467
+
468
+ // If absolute path, try to make relative
469
+ if (path.isAbsolute(p)) {
470
+ const cwd = process.cwd();
471
+ p = path.relative(cwd, p);
472
+ }
473
+
474
+ // Convert path separators to dots
475
+ p = p.replace(/[/\\]/g, ".");
476
+
477
+ // Remove leading dots
478
+ p = p.replace(/^\.+/, "");
479
+
480
+ // Handle __init__ modules
481
+ p = p.replace(/\.__init__$/, "");
482
+
483
+ return p || "app";
484
+ }
485
+
486
+ function describeTestCase(input: unknown, output: unknown, index: number): string {
487
+ // Generate a human-readable test name from the input/output
488
+ if (input === undefined && output === undefined) {
489
+ return `case ${index + 1}`;
490
+ }
491
+
492
+ const parts: string[] = [];
493
+
494
+ // Describe input
495
+ if (input !== undefined) {
496
+ if (input === null) {
497
+ parts.push("given null input");
498
+ } else if (Array.isArray(input)) {
499
+ if (input.length === 0) {
500
+ parts.push("given empty args");
501
+ } else {
502
+ const argSummaries = input.slice(0, 3).map(summarizeValue);
503
+ parts.push(`given ${argSummaries.join(", ")}`);
504
+ }
505
+ } else if (typeof input === "object") {
506
+ // Named args or single object arg
507
+ const keys = Object.keys(input as Record<string, unknown>);
508
+ if (keys.length <= 3) {
509
+ parts.push(`given ${keys.join(", ")}`);
510
+ } else {
511
+ parts.push(`given ${keys.length} params`);
512
+ }
513
+ } else {
514
+ parts.push(`given ${summarizeValue(input)}`);
515
+ }
516
+ }
517
+
518
+ // Describe expected output briefly
519
+ if (output !== undefined) {
520
+ if (output === null) {
521
+ parts.push("returns null");
522
+ } else if (Array.isArray(output)) {
523
+ parts.push(`returns array(${output.length})`);
524
+ } else if (typeof output === "object") {
525
+ const keys = Object.keys(output as Record<string, unknown>);
526
+ parts.push(`returns {${keys.slice(0, 3).join(", ")}${keys.length > 3 ? ", ..." : ""}}`);
527
+ } else {
528
+ parts.push(`returns ${summarizeValue(output)}`);
529
+ }
530
+ }
531
+
532
+ const name = parts.join(", ") || `case ${index + 1}`;
533
+ // Escape double quotes for use inside it("...") strings
534
+ return name.replace(/"/g, '\\"');
535
+ }
536
+
537
+ function summarizeValue(val: unknown): string {
538
+ if (val === null) return "null";
539
+ if (val === undefined) return "undefined";
540
+ if (typeof val === "string") {
541
+ const escaped = val.replace(/"/g, '\\"');
542
+ return escaped.length > 20 ? `"${escaped.slice(0, 17)}..."` : `"${escaped}"`;
543
+ }
544
+ if (typeof val === "number") return String(val);
545
+ if (typeof val === "boolean") return String(val);
546
+ if (Array.isArray(val)) return `[${val.length} items]`;
547
+ if (typeof val === "object") {
548
+ const keys = Object.keys(val as Record<string, unknown>);
549
+ return `{${keys.length} keys}`;
550
+ }
551
+ return String(val);
552
+ }
553
+
554
+ function isPromiseOutput(output: unknown): boolean {
555
+ // Heuristic: if the type node says "Promise" it's async
556
+ if (output && typeof output === "object" && "type" in (output as any)) {
557
+ const t = (output as any).type;
558
+ if (typeof t === "string" && t.includes("Promise")) return true;
559
+ }
560
+ return false;
561
+ }
562
+
563
+ // ── Helpers: argument formatting ──
564
+
565
+ function formatArgs(input: unknown): string {
566
+ if (input === undefined || input === null) return "";
567
+
568
+ // If input is an array, it's positional args
569
+ if (Array.isArray(input)) {
570
+ return input.map(v => formatValue(v)).join(", ");
571
+ }
572
+
573
+ // If input is an object, check if it looks like named args or a single object arg
574
+ if (typeof input === "object") {
575
+ const obj = input as Record<string, unknown>;
576
+ const keys = Object.keys(obj);
577
+
578
+ // If it has typical Express-like keys (params, body, query), it's a route handler — skip
579
+ if (keys.some(k => ["params", "body", "query", "headers"].includes(k))) {
580
+ return formatValue(input);
581
+ }
582
+
583
+ // Treat as a single object argument
584
+ return formatValue(input);
585
+ }
586
+
587
+ return formatValue(input);
588
+ }
589
+
590
+ function formatValue(val: unknown): string {
591
+ if (val === undefined) return "undefined";
592
+ if (val === null) return "null";
593
+ if (typeof val === "string") return JSON.stringify(val);
594
+ if (typeof val === "number" || typeof val === "boolean") return String(val);
595
+ if (Array.isArray(val)) {
596
+ if (val.length === 0) return "[]";
597
+ if (val.length <= 5) {
598
+ return `[${val.map(v => formatValue(v)).join(", ")}]`;
599
+ }
600
+ // For large arrays, use JSON.stringify with formatting
601
+ return JSON.stringify(val);
602
+ }
603
+ if (typeof val === "object") {
604
+ return JSON.stringify(val);
605
+ }
606
+ return String(val);
607
+ }
608
+
609
+ function formatPythonArgs(input: unknown): string {
610
+ if (input === undefined || input === null) return "";
611
+
612
+ if (Array.isArray(input)) {
613
+ return input.map(v => formatPythonValue(v)).join(", ");
614
+ }
615
+
616
+ if (typeof input === "object") {
617
+ return formatPythonValue(input);
618
+ }
619
+
620
+ return formatPythonValue(input);
621
+ }
622
+
623
+ function formatPythonValue(val: unknown): string {
624
+ if (val === undefined) return "None";
625
+ if (val === null) return "None";
626
+ if (typeof val === "boolean") return val ? "True" : "False";
627
+ if (typeof val === "string") return JSON.stringify(val); // JSON string syntax works in Python
628
+ if (typeof val === "number") return String(val);
629
+ if (Array.isArray(val)) {
630
+ if (val.length === 0) return "[]";
631
+ if (val.length <= 5) {
632
+ return `[${val.map(v => formatPythonValue(v)).join(", ")}]`;
633
+ }
634
+ return toPythonLiteral(val);
635
+ }
636
+ if (typeof val === "object") {
637
+ return toPythonLiteral(val);
638
+ }
639
+ return String(val);
640
+ }
641
+
642
+ function toPythonLiteral(val: unknown): string {
643
+ // Convert JS objects/arrays to Python dict/list syntax
644
+ const json = JSON.stringify(val);
645
+ return json
646
+ .replace(/\bnull\b/g, "None")
647
+ .replace(/\btrue\b/g, "True")
648
+ .replace(/\bfalse\b/g, "False");
649
+ }
650
+
651
+ // ── Helpers: assertion generation ──
652
+
653
+ function generateOutputAssertions(output: unknown, varName: string): string[] {
654
+ if (output === null) return [`expect(${varName}).toBeNull();`];
655
+ if (output === undefined) return [`expect(${varName}).toBeDefined();`];
656
+
657
+ if (typeof output === "string") {
658
+ return [`expect(typeof ${varName}).toBe("string");`];
659
+ }
660
+ if (typeof output === "number") {
661
+ return [`expect(typeof ${varName}).toBe("number");`];
662
+ }
663
+ if (typeof output === "boolean") {
664
+ return [`expect(typeof ${varName}).toBe("boolean");`];
665
+ }
666
+
667
+ if (Array.isArray(output)) {
668
+ const assertions = [`expect(Array.isArray(${varName})).toBe(true);`];
669
+ if (output.length > 0 && typeof output[0] === "object" && output[0] !== null) {
670
+ assertions.push(`expect(${varName}.length).toBeGreaterThan(0);`);
671
+ const itemAssertions = generateOutputAssertions(output[0], `${varName}[0]`);
672
+ assertions.push(...itemAssertions);
673
+ }
674
+ return assertions;
675
+ }
676
+
677
+ if (typeof output === "object") {
678
+ return generateAssertions(output as Record<string, unknown>, varName);
679
+ }
680
+
681
+ return [`expect(${varName}).toBeDefined();`];
682
+ }
683
+
684
+ function generatePythonAssertions(output: unknown, varName: string, depth = 0): string[] {
685
+ if (depth > 3) return [];
686
+ if (output === null) return [`assert ${varName} is None`];
687
+ if (output === undefined) return [`assert ${varName} is not None`];
688
+
689
+ if (typeof output === "string") {
690
+ return [`assert isinstance(${varName}, str)`];
691
+ }
692
+ if (typeof output === "number") {
693
+ return [`assert isinstance(${varName}, (int, float))`];
694
+ }
695
+ if (typeof output === "boolean") {
696
+ return [`assert isinstance(${varName}, bool)`];
697
+ }
698
+
699
+ if (Array.isArray(output)) {
700
+ const assertions = [`assert isinstance(${varName}, list)`];
701
+ if (output.length > 0 && typeof output[0] === "object" && output[0] !== null) {
702
+ assertions.push(`assert len(${varName}) > 0`);
703
+ const itemAssertions = generatePythonAssertions(output[0], `${varName}[0]`, depth + 1);
704
+ assertions.push(...itemAssertions);
705
+ }
706
+ return assertions;
707
+ }
708
+
709
+ if (typeof output === "object") {
710
+ const obj = output as Record<string, unknown>;
711
+ const assertions = [`assert isinstance(${varName}, dict)`];
712
+ for (const [key, value] of Object.entries(obj)) {
713
+ if (depth < 2) {
714
+ assertions.push(`assert "${key}" in ${varName}`);
715
+ if (value !== null && value !== undefined && typeof value !== "object") {
716
+ const typeAssertions = generatePythonAssertions(value, `${varName}["${key}"]`, depth + 1);
717
+ assertions.push(...typeAssertions);
718
+ }
719
+ }
720
+ }
721
+ return assertions;
722
+ }
723
+
724
+ return [`assert ${varName} is not None`];
725
+ }
726
+
727
+ // ── Route test helpers (preserved from original) ──
728
+
142
729
  function groupByResource(routes: MockRoute[]): Record<string, MockRoute[]> {
143
730
  const groups: Record<string, MockRoute[]> = {};
144
731
 
145
732
  for (const route of routes) {
146
733
  const parts = route.path.split("/").filter(Boolean);
147
- // /api/users → "api/users", /users → "users"
148
734
  let resource: string;
149
735
  if (parts[0] === "api" && parts.length >= 2) {
150
736
  resource = `/api/${parts[1]}`;
@@ -159,14 +745,10 @@ function groupByResource(routes: MockRoute[]): Record<string, MockRoute[]> {
159
745
  return groups;
160
746
  }
161
747
 
162
- /**
163
- * Try to extract a path param value from sample input.
164
- */
165
748
  function extractParamFromSample(sampleInput: unknown, param: string): string | null {
166
749
  if (!sampleInput || typeof sampleInput !== "object") return null;
167
750
  const input = sampleInput as Record<string, unknown>;
168
751
 
169
- // Check params object
170
752
  if (input.params && typeof input.params === "object") {
171
753
  const params = input.params as Record<string, unknown>;
172
754
  if (params[param] !== undefined) return String(params[param]);
@@ -175,9 +757,6 @@ function extractParamFromSample(sampleInput: unknown, param: string): string | n
175
757
  return null;
176
758
  }
177
759
 
178
- /**
179
- * Extract request body from sample input.
180
- */
181
760
  function extractBodyFromSample(sampleInput: unknown): Record<string, unknown> | null {
182
761
  if (!sampleInput || typeof sampleInput !== "object") return null;
183
762
  const input = sampleInput as Record<string, unknown>;
@@ -189,31 +768,37 @@ function extractBodyFromSample(sampleInput: unknown): Record<string, unknown> |
189
768
  return null;
190
769
  }
191
770
 
192
- /**
193
- * Generate expect() assertions for a sample response object.
194
- * Checks structure (property existence and types), not exact values.
195
- */
196
771
  function generateAssertions(obj: Record<string, unknown>, path: string, depth = 0): string[] {
197
- if (depth > 3) return []; // Prevent deeply nested assertions
772
+ if (depth > 3) return [];
198
773
 
199
774
  const assertions: string[] = [];
200
775
 
201
776
  for (const [key, value] of Object.entries(obj)) {
202
777
  const propPath = `${path}.${key}`;
203
778
 
779
+ // Skip truncated values from sanitizeSample — they have wrong types
780
+ if (value === "[truncated]") {
781
+ assertions.push(`expect(${propPath}).toBeDefined();`);
782
+ continue;
783
+ }
784
+
204
785
  if (value === null) {
205
786
  assertions.push(`expect(${propPath}).toBeNull();`);
206
787
  } else if (Array.isArray(value)) {
207
788
  assertions.push(`expect(Array.isArray(${propPath})).toBe(true);`);
208
- // If array has items, assert shape of first element
209
- if (value.length > 0 && typeof value[0] === "object" && value[0] !== null) {
210
- assertions.push(`expect(${propPath}.length).toBeGreaterThan(0);`);
211
- const itemAssertions = generateAssertions(
212
- value[0] as Record<string, unknown>,
213
- `${propPath}[0]`,
214
- depth + 1,
215
- );
216
- assertions.push(...itemAssertions);
789
+ if (value.length > 0) {
790
+ // Skip if first item is truncated
791
+ if (value[0] === "[truncated]") {
792
+ assertions.push(`expect(${propPath}.length).toBeGreaterThan(0);`);
793
+ } else if (typeof value[0] === "object" && value[0] !== null) {
794
+ assertions.push(`expect(${propPath}.length).toBeGreaterThan(0);`);
795
+ const itemAssertions = generateAssertions(
796
+ value[0] as Record<string, unknown>,
797
+ `${propPath}[0]`,
798
+ depth + 1,
799
+ );
800
+ assertions.push(...itemAssertions);
801
+ }
217
802
  }
218
803
  } else if (typeof value === "object") {
219
804
  assertions.push(`expect(typeof ${propPath}).toBe("object");`);
@@ -224,7 +809,12 @@ function generateAssertions(obj: Record<string, unknown>, path: string, depth =
224
809
  );
225
810
  assertions.push(...nestedAssertions);
226
811
  } else if (typeof value === "string") {
227
- assertions.push(`expect(typeof ${propPath}).toBe("string");`);
812
+ // Check if this looks like a truncated string from sanitizeSample
813
+ if (typeof value === "string" && (value as string).endsWith("...")) {
814
+ assertions.push(`expect(typeof ${propPath}).toBe("string");`);
815
+ } else {
816
+ assertions.push(`expect(typeof ${propPath}).toBe("string");`);
817
+ }
228
818
  } else if (typeof value === "number") {
229
819
  assertions.push(`expect(typeof ${propPath}).toBe("number");`);
230
820
  } else if (typeof value === "boolean") {