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