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.
- package/dist/commands/test-gen.d.ts +9 -4
- package/dist/commands/test-gen.js +574 -59
- package/dist/index.js +4 -1
- package/package.json +1 -1
- package/src/commands/test-gen.ts +657 -67
- package/src/index.ts +4 -1
- package/dist/commands/rn.test.d.ts +0 -1
- package/dist/commands/rn.test.js +0 -138
package/src/commands/test-gen.ts
CHANGED
|
@@ -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
|
|
16
|
+
* `trickle test --generate` — Generate test files from runtime observations.
|
|
14
17
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
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(
|
|
31
|
+
console.error(chalk.gray(` Supported: ${supportedFrameworks.join(", ")}\n`));
|
|
26
32
|
process.exit(1);
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
try {
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 [];
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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") {
|