pi-lens 2.0.7 → 2.0.8
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/clients/ast-grep-client.test.ts +146 -116
- package/clients/ast-grep-client.ts +645 -551
- package/clients/biome-client.test.ts +154 -137
- package/clients/biome-client.ts +397 -337
- package/clients/complexity-client.test.ts +188 -200
- package/clients/complexity-client.ts +815 -667
- package/clients/dependency-checker.test.ts +55 -55
- package/clients/dependency-checker.ts +358 -333
- package/clients/go-client.test.ts +121 -111
- package/clients/go-client.ts +218 -216
- package/clients/jscpd-client.test.ts +132 -132
- package/clients/jscpd-client.ts +155 -118
- package/clients/knip-client.test.ts +123 -133
- package/clients/knip-client.ts +231 -218
- package/clients/metrics-client.test.ts +171 -167
- package/clients/metrics-client.ts +283 -252
- package/clients/ruff-client.test.ts +128 -117
- package/clients/ruff-client.ts +300 -269
- package/clients/rust-client.test.ts +104 -85
- package/clients/rust-client.ts +241 -234
- package/clients/subprocess-client.ts +1 -1
- package/clients/test-runner-client.test.ts +248 -215
- package/clients/test-runner-client.ts +728 -608
- package/clients/test-utils.ts +10 -3
- package/clients/todo-scanner.test.ts +288 -202
- package/clients/todo-scanner.ts +225 -187
- package/clients/type-coverage-client.test.ts +119 -119
- package/clients/type-coverage-client.ts +142 -115
- package/clients/types.ts +28 -28
- package/clients/typescript-client.test.ts +99 -93
- package/clients/typescript-client.ts +527 -502
- package/index.ts +662 -212
- package/package.json +1 -1
- package/tsconfig.json +12 -12
|
@@ -11,641 +11,761 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { spawnSync } from "node:child_process";
|
|
14
|
-
import * as path from "node:path";
|
|
15
14
|
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
16
|
|
|
17
17
|
// --- Types ---
|
|
18
18
|
|
|
19
19
|
export interface TestResult {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
file: string; // test file that was run
|
|
21
|
+
sourceFile: string; // the file the agent edited
|
|
22
|
+
runner: string; // "vitest", "jest", "pytest"
|
|
23
|
+
passed: number;
|
|
24
|
+
failed: number;
|
|
25
|
+
skipped: number;
|
|
26
|
+
failures: TestFailure[];
|
|
27
|
+
duration: number; // ms
|
|
28
|
+
error?: string; // if runner itself failed
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
export interface TestFailure {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
name: string; // test name
|
|
33
|
+
message: string; // failure message
|
|
34
|
+
location?: string; // "file.ts:42"
|
|
35
|
+
stack?: string; // abbreviated stack trace
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Runner detection: config file → runner name
|
|
39
39
|
interface RunnerConfig {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
configFiles: string[];
|
|
41
|
+
command: string;
|
|
42
|
+
args: (testFile: string, cwd: string) => string[];
|
|
43
|
+
parseJson: boolean;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// --- Test File Patterns ---
|
|
47
47
|
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
/^(.+?)test_(.+)\.py$/,
|
|
70
|
-
],
|
|
71
|
-
},
|
|
48
|
+
const _TEST_FILE_PATTERNS: Array<{ lang: string; patterns: RegExp[] }> = [
|
|
49
|
+
{
|
|
50
|
+
lang: "typescript",
|
|
51
|
+
patterns: [
|
|
52
|
+
/^(.+)\.test\.tsx?$/,
|
|
53
|
+
/^(.+)\.spec\.tsx?$/,
|
|
54
|
+
/^(.+?)__tests__\/(.+)\.tsx?$/,
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
lang: "javascript",
|
|
59
|
+
patterns: [
|
|
60
|
+
/^(.+)\.test\.jsx?$/,
|
|
61
|
+
/^(.+)\.spec\.jsx?$/,
|
|
62
|
+
/^(.+?)__tests__\/(.+)\.jsx?$/,
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
lang: "python",
|
|
67
|
+
patterns: [/^(.+)\.py$/, /^(.+?)test_(.+)\.py$/],
|
|
68
|
+
},
|
|
72
69
|
];
|
|
73
70
|
|
|
74
71
|
// Source file → test file patterns (reverse lookup)
|
|
75
|
-
const SOURCE_TO_TEST_PATTERNS: Array<{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
72
|
+
const SOURCE_TO_TEST_PATTERNS: Array<{
|
|
73
|
+
ext: string;
|
|
74
|
+
testExts: string[];
|
|
75
|
+
dirs: string[];
|
|
76
|
+
}> = [
|
|
77
|
+
{
|
|
78
|
+
ext: ".ts",
|
|
79
|
+
testExts: [".test.ts", ".spec.ts"],
|
|
80
|
+
dirs: ["__tests__", "tests", ".", "__tests__"],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
ext: ".tsx",
|
|
84
|
+
testExts: [".test.tsx", ".spec.tsx"],
|
|
85
|
+
dirs: ["__tests__", "tests", ".", "__tests__"],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
ext: ".js",
|
|
89
|
+
testExts: [".test.js", ".spec.js"],
|
|
90
|
+
dirs: ["__tests__", "tests", ".", "__tests__"],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
ext: ".jsx",
|
|
94
|
+
testExts: [".test.jsx", ".spec.jsx"],
|
|
95
|
+
dirs: ["__tests__", "tests", ".", "__tests__"],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
ext: ".py",
|
|
99
|
+
testExts: ["test_*.py", "*_test.py"],
|
|
100
|
+
dirs: ["tests", "test", ".", "."],
|
|
101
|
+
},
|
|
102
|
+
{ ext: ".go", testExts: ["_test.go"], dirs: [".", ".", ".", "."] }, // Go tests are co-located
|
|
103
|
+
{ ext: ".rs", testExts: [".rs"], dirs: ["tests", "tests", "src", "."] }, // Rust: tests/ or #[test] in src
|
|
83
104
|
];
|
|
84
105
|
|
|
85
106
|
// --- Runner Detection ---
|
|
86
107
|
|
|
87
108
|
const RUNNERS: Record<string, RunnerConfig> = {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
109
|
+
vitest: {
|
|
110
|
+
configFiles: [
|
|
111
|
+
"vitest.config.ts",
|
|
112
|
+
"vitest.config.js",
|
|
113
|
+
"vitest.config.mjs",
|
|
114
|
+
"vite.config.ts",
|
|
115
|
+
],
|
|
116
|
+
command: "npx",
|
|
117
|
+
args: (testFile, _cwd) => [
|
|
118
|
+
"vitest",
|
|
119
|
+
"run",
|
|
120
|
+
testFile,
|
|
121
|
+
"--reporter=json",
|
|
122
|
+
"--passWithNoTests",
|
|
123
|
+
],
|
|
124
|
+
parseJson: true,
|
|
125
|
+
},
|
|
126
|
+
jest: {
|
|
127
|
+
configFiles: [
|
|
128
|
+
"jest.config.ts",
|
|
129
|
+
"jest.config.js",
|
|
130
|
+
"jest.config.json",
|
|
131
|
+
".jestrc.js",
|
|
132
|
+
],
|
|
133
|
+
command: "npx",
|
|
134
|
+
args: (testFile, _cwd) => [
|
|
135
|
+
"jest",
|
|
136
|
+
testFile,
|
|
137
|
+
"--json",
|
|
138
|
+
"--passWithNoTests",
|
|
139
|
+
"--forceExit",
|
|
140
|
+
],
|
|
141
|
+
parseJson: true,
|
|
142
|
+
},
|
|
143
|
+
pytest: {
|
|
144
|
+
configFiles: ["pytest.ini", "pyproject.toml", "setup.cfg", "tox.ini"],
|
|
145
|
+
command: "python",
|
|
146
|
+
args: (testFile, _cwd) => ["-m", "pytest", testFile, "--tb=short", "-q"],
|
|
147
|
+
parseJson: false, // pytest JSON requires plugin, use text parsing
|
|
148
|
+
},
|
|
149
|
+
go: {
|
|
150
|
+
configFiles: ["go.mod"],
|
|
151
|
+
command: "go",
|
|
152
|
+
args: (testFile, cwd) => {
|
|
153
|
+
// Convert file path to package path
|
|
154
|
+
const relPath = path.relative(cwd, testFile);
|
|
155
|
+
const pkgDir = path.dirname(relPath);
|
|
156
|
+
return ["test", `-run`, ".", `./${pkgDir === "." ? "." : pkgDir}`];
|
|
157
|
+
},
|
|
158
|
+
parseJson: false, // Go test output is text-based
|
|
159
|
+
},
|
|
160
|
+
cargo: {
|
|
161
|
+
configFiles: ["Cargo.toml"],
|
|
162
|
+
command: "cargo",
|
|
163
|
+
args: (_testFile, _cwd) => ["test", "--no-fail-fast"],
|
|
164
|
+
parseJson: false, // cargo test output is text-based
|
|
165
|
+
},
|
|
166
|
+
dotnet: {
|
|
167
|
+
configFiles: ["*.csproj", "*.sln"],
|
|
168
|
+
command: "dotnet",
|
|
169
|
+
args: (_testFile, _cwd) => ["test", "--no-build"],
|
|
170
|
+
parseJson: false,
|
|
171
|
+
},
|
|
172
|
+
gradle: {
|
|
173
|
+
configFiles: ["build.gradle", "build.gradle.kts", "settings.gradle"],
|
|
174
|
+
command: "./gradlew",
|
|
175
|
+
args: (_testFile, _cwd) => ["test", "--no-daemon"],
|
|
176
|
+
parseJson: false,
|
|
177
|
+
},
|
|
178
|
+
maven: {
|
|
179
|
+
configFiles: ["pom.xml"],
|
|
180
|
+
command: "mvn",
|
|
181
|
+
args: (_testFile, _cwd) => ["test", "-q"],
|
|
182
|
+
parseJson: false,
|
|
183
|
+
},
|
|
184
|
+
rspec: {
|
|
185
|
+
configFiles: [".rspec", "spec/spec_helper.rb"],
|
|
186
|
+
command: "bundle",
|
|
187
|
+
args: (testFile, _cwd) => ["exec", "rspec", testFile],
|
|
188
|
+
parseJson: false,
|
|
189
|
+
},
|
|
190
|
+
minitest: {
|
|
191
|
+
configFiles: ["Gemfile"],
|
|
192
|
+
command: "ruby",
|
|
193
|
+
args: (testFile, _cwd) => ["-Itest", testFile],
|
|
194
|
+
parseJson: false,
|
|
195
|
+
},
|
|
166
196
|
};
|
|
167
197
|
|
|
168
198
|
// --- Client ---
|
|
169
199
|
|
|
170
200
|
export class TestRunnerClient {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
201
|
+
private log: (msg: string) => void;
|
|
202
|
+
private availableRunners: Map<string, boolean> = new Map();
|
|
203
|
+
|
|
204
|
+
constructor(verbose = false) {
|
|
205
|
+
this.log = verbose
|
|
206
|
+
? (msg: string) => console.log(`[test-runner] ${msg}`)
|
|
207
|
+
: () => {};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Check if a test runner is available in the project
|
|
212
|
+
* Detection order:
|
|
213
|
+
* 1. Config files (vitest.config.ts, jest.config.js, etc.)
|
|
214
|
+
* 2. package.json dependencies
|
|
215
|
+
* 3. node_modules presence
|
|
216
|
+
*/
|
|
217
|
+
detectRunner(cwd: string): { runner: string; config: RunnerConfig } | null {
|
|
218
|
+
// Priority 1: Config files
|
|
219
|
+
for (const [name, config] of Object.entries(RUNNERS)) {
|
|
220
|
+
const cacheKey = `${cwd}:${name}:config`;
|
|
221
|
+
if (this.availableRunners.has(cacheKey)) {
|
|
222
|
+
if (this.availableRunners.get(cacheKey)) {
|
|
223
|
+
return { runner: name, config };
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const found = config.configFiles.some((cf) =>
|
|
229
|
+
fs.existsSync(path.join(cwd, cf)),
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
this.availableRunners.set(cacheKey, found);
|
|
233
|
+
if (found) {
|
|
234
|
+
this.log(`Detected runner via config: ${name}`);
|
|
235
|
+
return { runner: name, config };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Priority 2: package.json dependencies
|
|
240
|
+
const packageJsonPath = path.join(cwd, "package.json");
|
|
241
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
242
|
+
try {
|
|
243
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
244
|
+
const allDeps = {
|
|
245
|
+
...pkg.dependencies,
|
|
246
|
+
...pkg.devDependencies,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Check for vitest first (more specific than jest)
|
|
250
|
+
if (allDeps.vitest) {
|
|
251
|
+
this.log("Detected vitest in package.json");
|
|
252
|
+
this.availableRunners.set(`${cwd}:vitest:config`, true);
|
|
253
|
+
return { runner: "vitest", config: RUNNERS.vitest };
|
|
254
|
+
}
|
|
255
|
+
if (allDeps.jest) {
|
|
256
|
+
this.log("Detected jest in package.json");
|
|
257
|
+
this.availableRunners.set(`${cwd}:jest:config`, true);
|
|
258
|
+
return { runner: "jest", config: RUNNERS.jest };
|
|
259
|
+
}
|
|
260
|
+
if (allDeps.pytest || allDeps["pytest-cov"]) {
|
|
261
|
+
this.log("Detected pytest in package.json (unusual)");
|
|
262
|
+
this.availableRunners.set(`${cwd}:pytest:config`, true);
|
|
263
|
+
return { runner: "pytest", config: RUNNERS.pytest };
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// package.json parse error
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Priority 3: Check node_modules for installed packages
|
|
271
|
+
const nodeModulesPath = path.join(cwd, "node_modules");
|
|
272
|
+
if (fs.existsSync(nodeModulesPath)) {
|
|
273
|
+
if (fs.existsSync(path.join(nodeModulesPath, "vitest"))) {
|
|
274
|
+
this.log("Detected vitest in node_modules");
|
|
275
|
+
return { runner: "vitest", config: RUNNERS.vitest };
|
|
276
|
+
}
|
|
277
|
+
if (fs.existsSync(path.join(nodeModulesPath, "jest"))) {
|
|
278
|
+
this.log("Detected jest in node_modules");
|
|
279
|
+
return { runner: "jest", config: RUNNERS.jest };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Priority 4: Non-JS/Python runners (check config files that don't need package.json)
|
|
284
|
+
for (const name of ["go", "cargo", "dotnet", "gradle", "maven"]) {
|
|
285
|
+
const config = RUNNERS[name];
|
|
286
|
+
const found = config.configFiles.some((cf) => {
|
|
287
|
+
// Handle glob patterns like *.csproj
|
|
288
|
+
if (cf.includes("*")) {
|
|
289
|
+
try {
|
|
290
|
+
const files = fs.readdirSync(cwd);
|
|
291
|
+
return files.some((f) =>
|
|
292
|
+
new RegExp(cf.replace(/\*/g, ".*")).test(f),
|
|
293
|
+
);
|
|
294
|
+
} catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return fs.existsSync(path.join(cwd, cf));
|
|
299
|
+
});
|
|
300
|
+
if (found) {
|
|
301
|
+
this.log(`Detected ${name} from config file`);
|
|
302
|
+
return { runner: name, config };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Priority 5: Check if pytest is available globally (for Python)
|
|
307
|
+
try {
|
|
308
|
+
const whichCmd = process.platform === "win32" ? "where" : "which";
|
|
309
|
+
const result = spawnSync(whichCmd, ["pytest"], {
|
|
310
|
+
encoding: "utf-8",
|
|
311
|
+
timeout: 2000,
|
|
312
|
+
shell: true,
|
|
313
|
+
});
|
|
314
|
+
if (result.status === 0) {
|
|
315
|
+
this.log("Detected pytest globally");
|
|
316
|
+
return { runner: "pytest", config: RUNNERS.pytest };
|
|
317
|
+
}
|
|
318
|
+
} catch (err) { void err; }
|
|
319
|
+
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Find test file for a given source file
|
|
325
|
+
* Returns the test file path if it exists, null otherwise
|
|
326
|
+
*/
|
|
327
|
+
findTestFile(
|
|
328
|
+
sourceFilePath: string,
|
|
329
|
+
cwd: string,
|
|
330
|
+
): { testFile: string; runner: string } | null {
|
|
331
|
+
const ext = path.extname(sourceFilePath);
|
|
332
|
+
const basename = path.basename(sourceFilePath, ext);
|
|
333
|
+
const dir = path.dirname(sourceFilePath);
|
|
334
|
+
const _relativeDir = path.relative(cwd, dir);
|
|
335
|
+
|
|
336
|
+
const patterns = SOURCE_TO_TEST_PATTERNS.find((p) => p.ext === ext);
|
|
337
|
+
if (!patterns) return null;
|
|
338
|
+
|
|
339
|
+
const detected = this.detectRunner(cwd);
|
|
340
|
+
if (!detected) return null;
|
|
341
|
+
|
|
342
|
+
// Check each potential test file location
|
|
343
|
+
for (let i = 0; i < patterns.testExts.length; i++) {
|
|
344
|
+
const testExt = patterns.testExts[i];
|
|
345
|
+
const testDir = patterns.dirs[i];
|
|
346
|
+
|
|
347
|
+
// Handle glob patterns (pytest style: test_*.py)
|
|
348
|
+
if (testExt.includes("*")) {
|
|
349
|
+
const pattern = testExt.replace("*", basename);
|
|
350
|
+
const searchDir = testDir === "." ? dir : path.join(cwd, testDir);
|
|
351
|
+
|
|
352
|
+
if (fs.existsSync(searchDir)) {
|
|
353
|
+
try {
|
|
354
|
+
const files = fs.readdirSync(searchDir);
|
|
355
|
+
const match = files.find(
|
|
356
|
+
(f) =>
|
|
357
|
+
f === pattern ||
|
|
358
|
+
(f.startsWith("test_") &&
|
|
359
|
+
f.endsWith(".py") &&
|
|
360
|
+
f.includes(basename)),
|
|
361
|
+
);
|
|
362
|
+
if (match) {
|
|
363
|
+
const testPath = path.join(searchDir, match);
|
|
364
|
+
this.log(`Found test file: ${testPath}`);
|
|
365
|
+
return { testFile: testPath, runner: detected.runner };
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
// Directory not readable
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
// Exact pattern match (jest/vitest style)
|
|
373
|
+
const testFilename = basename + testExt;
|
|
374
|
+
const searchPaths = [
|
|
375
|
+
path.join(dir, testFilename), // same directory
|
|
376
|
+
path.join(dir, "__tests__", testFilename), // __tests__ subdirectory
|
|
377
|
+
path.join(cwd, "tests", testFilename), // top-level tests/
|
|
378
|
+
path.join(cwd, "__tests__", testFilename), // top-level __tests__/
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
for (const testPath of searchPaths) {
|
|
382
|
+
if (fs.existsSync(testPath)) {
|
|
383
|
+
this.log(`Found test file: ${testPath}`);
|
|
384
|
+
return { testFile: testPath, runner: detected.runner };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Run tests for a specific file
|
|
395
|
+
*/
|
|
396
|
+
runTestFile(
|
|
397
|
+
testFile: string,
|
|
398
|
+
cwd: string,
|
|
399
|
+
runner: string,
|
|
400
|
+
config: RunnerConfig,
|
|
401
|
+
): TestResult {
|
|
402
|
+
const absoluteTestFile = path.resolve(testFile);
|
|
403
|
+
if (!fs.existsSync(absoluteTestFile)) {
|
|
404
|
+
return this.emptyResult(
|
|
405
|
+
absoluteTestFile,
|
|
406
|
+
"",
|
|
407
|
+
runner,
|
|
408
|
+
"Test file not found",
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const args = config.args(absoluteTestFile, cwd);
|
|
414
|
+
this.log(`Running: ${config.command} ${args.join(" ")}`);
|
|
415
|
+
|
|
416
|
+
const result = spawnSync(config.command, args, {
|
|
417
|
+
encoding: "utf-8",
|
|
418
|
+
cwd,
|
|
419
|
+
timeout: 60000, // 60s timeout
|
|
420
|
+
shell: true,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const stdout = result.stdout || "";
|
|
424
|
+
const stderr = result.stderr || "";
|
|
425
|
+
|
|
426
|
+
// Check for runner errors (not test failures)
|
|
427
|
+
if (result.error) {
|
|
428
|
+
this.log(`Runner error: ${result.error.message}`);
|
|
429
|
+
return this.emptyResult(
|
|
430
|
+
absoluteTestFile,
|
|
431
|
+
"",
|
|
432
|
+
runner,
|
|
433
|
+
`Runner error: ${result.error.message}`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Parse output based on runner
|
|
438
|
+
switch (runner) {
|
|
439
|
+
case "vitest":
|
|
440
|
+
return this.parseVitestOutput(
|
|
441
|
+
stdout,
|
|
442
|
+
stderr,
|
|
443
|
+
absoluteTestFile,
|
|
444
|
+
cwd,
|
|
445
|
+
runner,
|
|
446
|
+
);
|
|
447
|
+
case "jest":
|
|
448
|
+
return this.parseJestOutput(
|
|
449
|
+
stdout,
|
|
450
|
+
stderr,
|
|
451
|
+
absoluteTestFile,
|
|
452
|
+
cwd,
|
|
453
|
+
runner,
|
|
454
|
+
);
|
|
455
|
+
case "pytest":
|
|
456
|
+
return this.parsePytestOutput(
|
|
457
|
+
stdout,
|
|
458
|
+
stderr,
|
|
459
|
+
result.status ?? 0,
|
|
460
|
+
absoluteTestFile,
|
|
461
|
+
cwd,
|
|
462
|
+
runner,
|
|
463
|
+
);
|
|
464
|
+
default:
|
|
465
|
+
return this.emptyResult(
|
|
466
|
+
absoluteTestFile,
|
|
467
|
+
"",
|
|
468
|
+
runner,
|
|
469
|
+
"Unknown runner",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
} catch (err: any) {
|
|
473
|
+
this.log(`Run error: ${err.message}`);
|
|
474
|
+
return this.emptyResult(absoluteTestFile, "", runner, err.message);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Check if a source file has corresponding tests (without running them)
|
|
480
|
+
*/
|
|
481
|
+
hasTestFile(sourceFilePath: string, cwd: string): boolean {
|
|
482
|
+
return this.findTestFile(sourceFilePath, cwd) !== null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// --- Vitest Parser ---
|
|
486
|
+
|
|
487
|
+
private parseVitestOutput(
|
|
488
|
+
stdout: string,
|
|
489
|
+
stderr: string,
|
|
490
|
+
testFile: string,
|
|
491
|
+
cwd: string,
|
|
492
|
+
runner: string,
|
|
493
|
+
): TestResult {
|
|
494
|
+
// Vitest JSON output structure
|
|
495
|
+
interface VitestResult {
|
|
496
|
+
numTotalTestSuites: number;
|
|
497
|
+
numPassedTestSuites: number;
|
|
498
|
+
numFailedTestSuites: number;
|
|
499
|
+
numTotalTests: number;
|
|
500
|
+
numPassedTests: number;
|
|
501
|
+
numFailedTests: number;
|
|
502
|
+
numSkippedTests: number;
|
|
503
|
+
testResults: Array<{
|
|
504
|
+
name: string;
|
|
505
|
+
status: "passed" | "failed" | "skipped";
|
|
506
|
+
message?: string;
|
|
507
|
+
assertionResults?: Array<{
|
|
508
|
+
status: "passed" | "failed" | "skipped";
|
|
509
|
+
title: string;
|
|
510
|
+
failureMessages?: string[];
|
|
511
|
+
location?: { line: number; column: number };
|
|
512
|
+
}>;
|
|
513
|
+
}>;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
const json: VitestResult = JSON.parse(stdout);
|
|
518
|
+
const failures: TestFailure[] = [];
|
|
519
|
+
|
|
520
|
+
for (const suite of json.testResults || []) {
|
|
521
|
+
if (suite.status === "failed" && suite.assertionResults) {
|
|
522
|
+
for (const test of suite.assertionResults) {
|
|
523
|
+
if (test.status === "failed") {
|
|
524
|
+
failures.push({
|
|
525
|
+
name: test.title,
|
|
526
|
+
message:
|
|
527
|
+
test.failureMessages?.[0] || suite.message || "Test failed",
|
|
528
|
+
location: test.location
|
|
529
|
+
? `${path.relative(cwd, testFile)}:${test.location.line}`
|
|
530
|
+
: undefined,
|
|
531
|
+
stack: this.truncateStack(test.failureMessages?.join("\n")),
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return {
|
|
539
|
+
file: testFile,
|
|
540
|
+
sourceFile: "",
|
|
541
|
+
runner,
|
|
542
|
+
passed: json.numPassedTests || 0,
|
|
543
|
+
failed: json.numFailedTests || 0,
|
|
544
|
+
skipped: json.numSkippedTests || 0,
|
|
545
|
+
failures,
|
|
546
|
+
duration: 0, // Vitest JSON doesn't include duration in this format
|
|
547
|
+
};
|
|
548
|
+
} catch {
|
|
549
|
+
// If JSON parsing fails, check for basic pass/fail indicators
|
|
550
|
+
const failed = stdout.includes("FAIL") || stderr.includes("FAIL");
|
|
551
|
+
return this.emptyResult(
|
|
552
|
+
testFile,
|
|
553
|
+
"",
|
|
554
|
+
runner,
|
|
555
|
+
failed ? "Tests failed (could not parse output)" : undefined,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// --- Jest Parser ---
|
|
561
|
+
|
|
562
|
+
private parseJestOutput(
|
|
563
|
+
stdout: string,
|
|
564
|
+
stderr: string,
|
|
565
|
+
testFile: string,
|
|
566
|
+
cwd: string,
|
|
567
|
+
runner: string,
|
|
568
|
+
): TestResult {
|
|
569
|
+
interface JestResult {
|
|
570
|
+
numFailedTestSuites: number;
|
|
571
|
+
numFailedTests: number;
|
|
572
|
+
numPassedTests: number;
|
|
573
|
+
numPassedTestSuites: number;
|
|
574
|
+
numSkippedTests?: number;
|
|
575
|
+
testResults: Array<{
|
|
576
|
+
name: string;
|
|
577
|
+
status: "passed" | "failed";
|
|
578
|
+
message?: string;
|
|
579
|
+
assertionResults?: Array<{
|
|
580
|
+
status: "passed" | "failed" | "skipped";
|
|
581
|
+
title: string;
|
|
582
|
+
failureMessages?: string[];
|
|
583
|
+
location?: { line: number; column: number };
|
|
584
|
+
}>;
|
|
585
|
+
}>;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
const json: JestResult = JSON.parse(stdout);
|
|
590
|
+
const failures: TestFailure[] = [];
|
|
591
|
+
|
|
592
|
+
for (const suite of json.testResults || []) {
|
|
593
|
+
if (suite.status === "failed" && suite.assertionResults) {
|
|
594
|
+
for (const test of suite.assertionResults) {
|
|
595
|
+
if (test.status === "failed") {
|
|
596
|
+
failures.push({
|
|
597
|
+
name: test.title,
|
|
598
|
+
message:
|
|
599
|
+
test.failureMessages?.[0] || suite.message || "Test failed",
|
|
600
|
+
location: test.location
|
|
601
|
+
? `${path.relative(cwd, testFile)}:${test.location.line}`
|
|
602
|
+
: undefined,
|
|
603
|
+
stack: this.truncateStack(test.failureMessages?.join("\n")),
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
file: testFile,
|
|
612
|
+
sourceFile: "",
|
|
613
|
+
runner,
|
|
614
|
+
passed: json.numPassedTests || 0,
|
|
615
|
+
failed: json.numFailedTests || 0,
|
|
616
|
+
skipped: json.numSkippedTests || 0,
|
|
617
|
+
failures,
|
|
618
|
+
duration: 0,
|
|
619
|
+
};
|
|
620
|
+
} catch {
|
|
621
|
+
const failed = stdout.includes("FAIL") || stderr.includes("FAIL");
|
|
622
|
+
return this.emptyResult(
|
|
623
|
+
testFile,
|
|
624
|
+
"",
|
|
625
|
+
runner,
|
|
626
|
+
failed ? "Tests failed (could not parse output)" : undefined,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// --- Pytest Parser (text-based, no JSON dependency) ---
|
|
632
|
+
|
|
633
|
+
private parsePytestOutput(
|
|
634
|
+
stdout: string,
|
|
635
|
+
stderr: string,
|
|
636
|
+
exitCode: number,
|
|
637
|
+
testFile: string,
|
|
638
|
+
_cwd: string,
|
|
639
|
+
runner: string,
|
|
640
|
+
): TestResult {
|
|
641
|
+
const failures: TestFailure[] = [];
|
|
642
|
+
const output = `${stdout}\n${stderr}`;
|
|
643
|
+
|
|
644
|
+
// Parse summary line: "5 passed, 2 failed, 1 skipped in 0.23s"
|
|
645
|
+
const summaryMatch =
|
|
646
|
+
output.match(/(\d+)\s+passed?.*?(\d+)\s+failed.*?in\s+([\d.]+)s/i) ||
|
|
647
|
+
output.match(/(\d+)\s+passed.*?in\s+([\d.]+)s/i);
|
|
648
|
+
|
|
649
|
+
let passed = 0;
|
|
650
|
+
let failed = 0;
|
|
651
|
+
let skipped = 0;
|
|
652
|
+
let duration = 0;
|
|
653
|
+
|
|
654
|
+
if (summaryMatch) {
|
|
655
|
+
// Extract numbers from various patterns
|
|
656
|
+
const passedMatch = output.match(/(\d+)\s+passed/);
|
|
657
|
+
const failedMatch = output.match(/(\d+)\s+failed/);
|
|
658
|
+
const skippedMatch = output.match(/(\d+)\s+skipped/);
|
|
659
|
+
const durationMatch = output.match(/in\s+([\d.]+)s/);
|
|
660
|
+
|
|
661
|
+
passed = passedMatch ? parseInt(passedMatch[1], 10) : 0;
|
|
662
|
+
failed = failedMatch ? parseInt(failedMatch[1], 10) : 0;
|
|
663
|
+
skipped = skippedMatch ? parseInt(skippedMatch[1], 10) : 0;
|
|
664
|
+
duration = durationMatch ? parseFloat(durationMatch[1]) * 1000 : 0;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Parse individual failures: "FAILED tests/test_foo.py::test_something - AssertionError: ..."
|
|
668
|
+
const failureRegex = /FAILED\s+(\S+::\S+)\s*-\s*(.+?)(?:\n|$)/g;
|
|
669
|
+
let match;
|
|
670
|
+
while ((match = failureRegex.exec(output)) !== null) {
|
|
671
|
+
failures.push({
|
|
672
|
+
name: match[1],
|
|
673
|
+
message: match[2].trim().slice(0, 500),
|
|
674
|
+
location: match[1].replace("::", ":"),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Also look for assertion errors with traceback
|
|
679
|
+
const tracebackRegex = /_{10,}\s*\n\s*(\w+Error:\s*.+?)(?:\n|$)/gs;
|
|
680
|
+
while ((match = tracebackRegex.exec(output)) !== null) {
|
|
681
|
+
// Add to last failure if exists, or create generic
|
|
682
|
+
if (failures.length > 0 && !failures[failures.length - 1].stack) {
|
|
683
|
+
failures[failures.length - 1].stack = match[1].trim().slice(0, 1000);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
file: testFile,
|
|
689
|
+
sourceFile: "",
|
|
690
|
+
runner,
|
|
691
|
+
passed,
|
|
692
|
+
failed,
|
|
693
|
+
skipped,
|
|
694
|
+
failures,
|
|
695
|
+
duration,
|
|
696
|
+
error: exitCode === 2 ? "Pytest configuration error" : undefined,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// --- Formatting ---
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Format test result for LLM consumption
|
|
704
|
+
*/
|
|
705
|
+
formatResult(result: TestResult): string {
|
|
706
|
+
if (result.error && result.passed === 0 && result.failed === 0) {
|
|
707
|
+
// Runner error, not test failure
|
|
708
|
+
return `[Tests] ⚠ Could not run tests: ${result.error}`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const total = result.passed + result.failed + result.skipped;
|
|
712
|
+
if (total === 0) {
|
|
713
|
+
return ""; // No tests to report
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const durationStr =
|
|
717
|
+
result.duration > 0 ? ` (${(result.duration / 1000).toFixed(2)}s)` : "";
|
|
718
|
+
|
|
719
|
+
if (result.failed === 0) {
|
|
720
|
+
return `[Tests] ✓ ${result.passed}/${total} passed${durationStr} — ${result.runner}`;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Has failures
|
|
724
|
+
let output = `[Tests] ✗ ${result.failed}/${total} failed, ${result.passed} passed${durationStr} — ${result.runner}\n`;
|
|
725
|
+
|
|
726
|
+
for (const failure of result.failures.slice(0, 5)) {
|
|
727
|
+
output += ` ✗ ${failure.name}\n`;
|
|
728
|
+
const msg = failure.message.split("\n")[0].slice(0, 200); // First line, truncated
|
|
729
|
+
output += ` ${msg}\n`;
|
|
730
|
+
if (failure.location) {
|
|
731
|
+
output += ` at ${failure.location}\n`;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if (result.failures.length > 5) {
|
|
736
|
+
output += ` ... and ${result.failures.length - 5} more failure(s)\n`;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
output += ` → Fix failing tests before proceeding\n`;
|
|
740
|
+
|
|
741
|
+
return output.trimEnd();
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// --- Helpers ---
|
|
745
|
+
|
|
746
|
+
private emptyResult(
|
|
747
|
+
testFile: string,
|
|
748
|
+
sourceFile: string,
|
|
749
|
+
runner: string,
|
|
750
|
+
error?: string,
|
|
751
|
+
): TestResult {
|
|
752
|
+
return {
|
|
753
|
+
file: testFile,
|
|
754
|
+
sourceFile,
|
|
755
|
+
runner,
|
|
756
|
+
passed: 0,
|
|
757
|
+
failed: 0,
|
|
758
|
+
skipped: 0,
|
|
759
|
+
failures: [],
|
|
760
|
+
duration: 0,
|
|
761
|
+
error,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private truncateStack(stack?: string): string | undefined {
|
|
766
|
+
if (!stack) return undefined;
|
|
767
|
+
// Keep first 3 lines of stack trace
|
|
768
|
+
const lines = stack.split("\n").slice(0, 3);
|
|
769
|
+
return lines.join("\n").slice(0, 500);
|
|
770
|
+
}
|
|
651
771
|
}
|