trickle-cli 0.1.208 → 0.1.209

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