project-scan 1.0.3 → 1.0.4

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.
@@ -2,7 +2,20 @@
2
2
  /**
3
3
  * test-runner.js — Universal test runner
4
4
  * Detects and executes test frameworks across all major languages.
5
- * Returns structured results: suite name, status, passed/failed/skipped counts, output.
5
+ * Returns structured results with per-test-case breakdown (LeetCode style).
6
+ *
7
+ * Supported:
8
+ * Web: Jest, Vitest, Mocha
9
+ * Python: pytest
10
+ * Go: go test
11
+ * Rust: cargo test
12
+ * Java: Maven (JUnit), Gradle
13
+ * PHP: PHPUnit
14
+ * Ruby: RSpec
15
+ * Dart: flutter test / dart test
16
+ * C#: dotnet test
17
+ * C++: GoogleTest (via CTest), Catch2
18
+ * DSA: Standalone Python/JS/TS/C++ scripts with inline test functions
6
19
  */
7
20
 
8
21
  const { execSync, spawnSync } = require('child_process');
@@ -11,21 +24,16 @@ const path = require('path');
11
24
 
12
25
  const IS_WIN = process.platform === 'win32';
13
26
 
14
- // ─── Per-language runner configs ─────────────────────────────────────────────
27
+ // ─── Runners ──────────────────────────────────────────────────────────────────
15
28
 
16
- /**
17
- * Each runner: { name, detect(rootDir), run(rootDir) → RunResult }
18
- * RunResult: { suite, passed, failed, skipped, duration, status, output, error? }
19
- */
20
29
  const RUNNERS = [
21
30
 
22
- // ── Node.js: Jest ──────────────────────────────────────────────────────────
31
+ // ── Jest ────────────────────────────────────────────────────────────────────
23
32
  {
24
33
  name: 'Jest',
25
34
  detect(root) {
26
35
  const pkg = tryReadJSON(path.join(root, 'package.json'));
27
36
  if (pkg && (allDeps(pkg).includes('jest') || pkg.jest)) return true;
28
- // subdirectory package.jsons
29
37
  return findSubPackages(root).some(p => {
30
38
  const sp = tryReadJSON(p);
31
39
  return sp && (allDeps(sp).includes('jest') || sp.jest || sp.scripts?.test?.includes('jest'));
@@ -36,62 +44,41 @@ const RUNNERS = [
36
44
  const pkg = tryReadJSON(path.join(dir, 'package.json'));
37
45
  return pkg && (allDeps(pkg).includes('jest') || pkg.jest || pkg.scripts?.test?.includes('jest'));
38
46
  });
39
- const results = [];
40
- for (const dir of targets) {
41
- const label = dir === root ? '(root)' : path.relative(root, dir);
42
- results.push(runJest(dir, label));
43
- }
44
- return results;
47
+ return targets.map(dir => runJest(dir, dir === root ? '(root)' : path.relative(root, dir)));
45
48
  }
46
49
  },
47
50
 
48
- // ── Node.js: Vitest ────────────────────────────────────────────────────────
51
+ // ── Vitest ──────────────────────────────────────────────────────────────────
49
52
  {
50
53
  name: 'Vitest',
51
54
  detect(root) {
52
55
  const pkg = tryReadJSON(path.join(root, 'package.json'));
53
56
  if (pkg && allDeps(pkg).includes('vitest')) return true;
54
- return findSubPackages(root).some(p => {
55
- const sp = tryReadJSON(p);
56
- return sp && allDeps(sp).includes('vitest');
57
- });
57
+ return findSubPackages(root).some(p => { const sp = tryReadJSON(p); return sp && allDeps(sp).includes('vitest'); });
58
58
  },
59
59
  run(root) {
60
60
  const targets = [root, ...findSubDirs(root)].filter(dir => {
61
61
  const pkg = tryReadJSON(path.join(dir, 'package.json'));
62
62
  return pkg && allDeps(pkg).includes('vitest');
63
63
  });
64
- return targets.map(dir => runCommand({
65
- dir, label: dir === root ? '(root)' : path.relative(root, dir),
66
- cmd: hasBin(dir, 'vitest') ? 'npx vitest run --reporter=verbose' : null,
67
- suite: 'Vitest',
68
- parseOutput: parseVitestOutput,
69
- }));
64
+ return targets.map(dir => runVitest(dir, dir === root ? '(root)' : path.relative(root, dir)));
70
65
  }
71
66
  },
72
67
 
73
- // ── Node.js: Mocha ─────────────────────────────────────────────────────────
68
+ // ── Mocha ───────────────────────────────────────────────────────────────────
74
69
  {
75
70
  name: 'Mocha',
76
71
  detect(root) {
77
72
  const pkg = tryReadJSON(path.join(root, 'package.json'));
78
73
  if (pkg && allDeps(pkg).includes('mocha')) return true;
79
- return findSubPackages(root).some(p => {
80
- const sp = tryReadJSON(p);
81
- return sp && allDeps(sp).includes('mocha');
82
- });
74
+ return findSubPackages(root).some(p => { const sp = tryReadJSON(p); return sp && allDeps(sp).includes('mocha'); });
83
75
  },
84
76
  run(root) {
85
- return [runCommand({
86
- dir: root, label: '(root)',
87
- cmd: 'npx mocha --reporter spec',
88
- suite: 'Mocha',
89
- parseOutput: parseMochaOutput,
90
- })];
77
+ return [runCommand({ dir: root, label: '(root)', cmd: 'npx mocha --reporter spec', suite: 'Mocha', parseOutput: parseMochaOutput })];
91
78
  }
92
79
  },
93
80
 
94
- // ── Python: pytest ─────────────────────────────────────────────────────────
81
+ // ── pytest ──────────────────────────────────────────────────────────────────
95
82
  {
96
83
  name: 'pytest',
97
84
  detect(root) {
@@ -102,43 +89,36 @@ const RUNNERS = [
102
89
  hasSourceExt(root, '.py');
103
90
  },
104
91
  run(root) {
105
- const cmd = commandExists('pytest') ? 'pytest -v --tb=short'
106
- : commandExists('python3') ? 'python3 -m pytest -v --tb=short'
107
- : commandExists('python') ? 'python -m pytest -v --tb=short'
108
- : null;
109
- return [runCommand({ dir: root, label: '(root)', cmd, suite: 'pytest', parseOutput: parsePytestOutput })];
92
+ const base = commandExists('pytest') ? 'pytest'
93
+ : commandExists('python3') ? 'python3 -m pytest'
94
+ : commandExists('python') ? 'python -m pytest'
95
+ : null;
96
+ if (!base) return [{ suite: 'pytest (root)', status: 'skipped', passed: 0, failed: 0, skipped: 0, output: 'pytest not found in PATH.', duration: 0 }];
97
+ return [runPytest(root, base)];
110
98
  }
111
99
  },
112
100
 
113
- // ── Go: go test ────────────────────────────────────────────────────────────
101
+ // ── Go test ─────────────────────────────────────────────────────────────────
114
102
  {
115
103
  name: 'Go test',
116
104
  detect(root) { return fs.existsSync(path.join(root, 'go.mod')); },
117
105
  run(root) {
118
- return [runCommand({
119
- dir: root, label: '(root)',
120
- cmd: commandExists('go') ? 'go test ./... -v' : null,
121
- suite: 'Go test',
122
- parseOutput: parseGoTestOutput,
123
- })];
106
+ if (!commandExists('go')) return [{ suite: 'Go test (root)', status: 'skipped', passed: 0, failed: 0, skipped: 0, output: 'go not found in PATH.', duration: 0 }];
107
+ return [runGoTest(root)];
124
108
  }
125
109
  },
126
110
 
127
- // ── Rust: cargo test ───────────────────────────────────────────────────────
111
+ // ── Cargo test ──────────────────────────────────────────────────────────────
128
112
  {
129
113
  name: 'Cargo test',
130
114
  detect(root) { return fs.existsSync(path.join(root, 'Cargo.toml')); },
131
115
  run(root) {
132
- return [runCommand({
133
- dir: root, label: '(root)',
134
- cmd: commandExists('cargo') ? 'cargo test' : null,
135
- suite: 'Cargo test',
136
- parseOutput: parseCargoOutput,
137
- })];
116
+ if (!commandExists('cargo')) return [{ suite: 'Cargo test (root)', status: 'skipped', passed: 0, failed: 0, skipped: 0, output: 'cargo not found in PATH.', duration: 0 }];
117
+ return [runCargoTest(root)];
138
118
  }
139
119
  },
140
120
 
141
- // ── Java/Kotlin: Maven ─────────────────────────────────────────────────────
121
+ // ── Maven ───────────────────────────────────────────────────────────────────
142
122
  {
143
123
  name: 'Maven',
144
124
  detect(root) { return fs.existsSync(path.join(root, 'pom.xml')); },
@@ -148,7 +128,7 @@ const RUNNERS = [
148
128
  }
149
129
  },
150
130
 
151
- // ── Java/Kotlin: Gradle ────────────────────────────────────────────────────
131
+ // ── Gradle ──────────────────────────────────────────────────────────────────
152
132
  {
153
133
  name: 'Gradle',
154
134
  detect(root) {
@@ -156,7 +136,6 @@ const RUNNERS = [
156
136
  fs.existsSync(path.join(root, 'build.gradle.kts'));
157
137
  },
158
138
  run(root) {
159
- // Windows uses gradlew.bat; Unix uses ./gradlew
160
139
  const wrapperWin = path.join(root, 'gradlew.bat');
161
140
  const wrapperUnix = path.join(root, 'gradlew');
162
141
  const gradleCmd = fs.existsSync(IS_WIN ? wrapperWin : wrapperUnix)
@@ -166,98 +145,127 @@ const RUNNERS = [
166
145
  }
167
146
  },
168
147
 
169
- // ── PHP: PHPUnit ───────────────────────────────────────────────────────────
148
+ // ── PHPUnit ─────────────────────────────────────────────────────────────────
170
149
  {
171
150
  name: 'PHPUnit',
172
151
  detect(root) {
173
- return fs.existsSync(path.join(root, 'phpunit.xml')) ||
174
- fs.existsSync(path.join(root, 'phpunit.xml.dist')) ||
175
- fs.existsSync(path.join(root, 'vendor', 'bin', 'phpunit')) ||
152
+ return fs.existsSync(path.join(root, 'phpunit.xml')) ||
153
+ fs.existsSync(path.join(root, 'phpunit.xml.dist')) ||
154
+ fs.existsSync(path.join(root, 'vendor', 'bin', 'phpunit')) ||
176
155
  hasSourceExt(root, '.php');
177
156
  },
178
157
  run(root) {
179
158
  const vendorBin = path.join(root, 'vendor', 'bin', 'phpunit');
180
- const cmd = fs.existsSync(vendorBin)
181
- ? `"${vendorBin}" --testdox`
182
- : commandExists('phpunit') ? 'phpunit --testdox' : null;
159
+ const cmd = fs.existsSync(vendorBin) ? `"${vendorBin}" --testdox`
160
+ : commandExists('phpunit') ? 'phpunit --testdox' : null;
183
161
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'PHPUnit', parseOutput: parsePHPUnitOutput })];
184
162
  }
185
163
  },
186
164
 
187
- // ── Ruby: RSpec ────────────────────────────────────────────────────────────
165
+ // ── RSpec ───────────────────────────────────────────────────────────────────
188
166
  {
189
167
  name: 'RSpec',
190
168
  detect(root) {
191
- return fs.existsSync(path.join(root, 'Gemfile')) ||
192
- fs.existsSync(path.join(root, '.rspec')) ||
169
+ return fs.existsSync(path.join(root, 'Gemfile')) ||
170
+ fs.existsSync(path.join(root, '.rspec')) ||
193
171
  fs.existsSync(path.join(root, 'spec'));
194
172
  },
195
173
  run(root) {
196
- const cmd = commandExists('rspec') ? 'rspec --format progress' : (commandExists('bundle') ? 'bundle exec rspec --format progress' : null);
174
+ const cmd = commandExists('rspec') ? 'rspec --format documentation' : (commandExists('bundle') ? 'bundle exec rspec --format documentation' : null);
197
175
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'RSpec', parseOutput: parseRSpecOutput })];
198
176
  }
199
177
  },
200
178
 
201
- // ── Dart/Flutter ───────────────────────────────────────────────────────────
179
+ // ── Flutter/Dart ────────────────────────────────────────────────────────────
202
180
  {
203
181
  name: 'Flutter/Dart test',
204
182
  detect(root) { return fs.existsSync(path.join(root, 'pubspec.yaml')); },
205
183
  run(root) {
206
- const cmd = commandExists('flutter') ? 'flutter test' : (commandExists('dart') ? 'dart test' : null);
184
+ const cmd = commandExists('flutter') ? 'flutter test --reporter expanded' : (commandExists('dart') ? 'dart test --reporter expanded' : null);
207
185
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'Flutter/Dart', parseOutput: parseDartOutput })];
208
186
  }
209
187
  },
210
188
 
211
- // ── .NET: dotnet test ──────────────────────────────────────────────────────
189
+ // ── dotnet test ─────────────────────────────────────────────────────────────
212
190
  {
213
191
  name: 'dotnet test',
214
192
  detect(root) {
215
- try {
216
- return fs.readdirSync(root).some(f => f.endsWith('.csproj') || f.endsWith('.sln'));
217
- } catch { return false; }
193
+ try { return fs.readdirSync(root).some(f => f.endsWith('.csproj') || f.endsWith('.sln')); }
194
+ catch { return false; }
218
195
  },
219
196
  run(root) {
220
- const cmd = commandExists('dotnet') ? 'dotnet test --logger "console;verbosity=normal"' : null;
221
- return [runCommand({ dir: root, label: '(root)', cmd, suite: 'dotnet test', parseOutput: parseDotnetOutput })];
197
+ if (!commandExists('dotnet')) return [{ suite: 'dotnet test (root)', status: 'skipped', passed: 0, failed: 0, skipped: 0, output: 'dotnet not found in PATH.', duration: 0 }];
198
+ return [runDotnetTest(root)];
199
+ }
200
+ },
201
+
202
+ // ── C++ / CTest (GoogleTest / Catch2) ───────────────────────────────────────
203
+ {
204
+ name: 'CTest (C++)',
205
+ detect(root) {
206
+ return (fs.existsSync(path.join(root, 'CMakeLists.txt')) ||
207
+ fs.existsSync(path.join(root, 'build', 'CTestTestfile.cmake'))) &&
208
+ hasSourceExt(root, '.cpp');
209
+ },
210
+ run(root) {
211
+ return [runCTest(root)];
212
+ }
213
+ },
214
+
215
+ // ── DSA / standalone script runner ──────────────────────────────────────────
216
+ // For projects that have no test framework but have Python/JS/TS files
217
+ // with assert-based tests, doctest, or unittest.
218
+ {
219
+ name: 'DSA / Standalone Tests',
220
+ detect(root) {
221
+ // Only kick in if no other test framework is present
222
+ const hasOtherFramework =
223
+ fs.existsSync(path.join(root, 'package.json')) ||
224
+ fs.existsSync(path.join(root, 'go.mod')) ||
225
+ fs.existsSync(path.join(root, 'Cargo.toml')) ||
226
+ fs.existsSync(path.join(root, 'pom.xml')) ||
227
+ fs.existsSync(path.join(root, 'build.gradle')) ||
228
+ fs.existsSync(path.join(root, 'Gemfile')) ||
229
+ fs.existsSync(path.join(root, 'pubspec.yaml'));
230
+ if (hasOtherFramework) return false;
231
+
232
+ // Has Python or JS/TS files with test-like patterns
233
+ const pyFiles = findFilesWithExt(root, '.py');
234
+ const jsFiles = [...findFilesWithExt(root, '.js'), ...findFilesWithExt(root, '.ts')];
235
+ return pyFiles.some(f => hasTestPatterns(f)) || jsFiles.some(f => hasTestPatterns(f));
236
+ },
237
+ run(root) {
238
+ return runDSATests(root);
222
239
  }
223
240
  },
224
241
  ];
225
242
 
226
- // ─── Core runner ─────────────────────────────────────────────────────────────
243
+ // ─── Core ─────────────────────────────────────────────────────────────────────
227
244
 
228
245
  function runAllTests(rootDir) {
229
246
  const results = [];
230
- const activeRunners = RUNNERS.filter(r => r.detect(rootDir));
247
+ const activeRunners = RUNNERS.filter(r => { try { return r.detect(rootDir); } catch { return false; } });
231
248
 
232
249
  if (activeRunners.length === 0) {
233
250
  return [{
234
251
  suite: 'No test framework detected',
235
- status: 'skipped',
236
- passed: 0, failed: 0, skipped: 0,
237
- output: 'No recognized test framework was found in this project.',
252
+ status: 'skipped', passed: 0, failed: 0, skipped: 0,
253
+ output: 'No recognized test framework found. Add tests using pytest, Jest, go test, cargo test, JUnit, RSpec, etc.',
238
254
  duration: 0,
239
255
  }];
240
256
  }
241
257
 
242
258
  for (const runner of activeRunners) {
243
259
  try {
244
- const suiteResults = runner.run(rootDir);
245
- results.push(...suiteResults);
260
+ results.push(...runner.run(rootDir));
246
261
  } catch (err) {
247
- results.push({
248
- suite: runner.name,
249
- status: 'error',
250
- passed: 0, failed: 0, skipped: 0,
251
- output: '',
252
- error: err.message,
253
- duration: 0,
254
- });
262
+ results.push({ suite: runner.name, status: 'error', passed: 0, failed: 0, skipped: 0, output: '', error: err.message, duration: 0 });
255
263
  }
256
264
  }
257
265
  return results;
258
266
  }
259
267
 
260
- // ─── Jest runner (special: uses JSON reporter) ────────────────────────────────
268
+ // ─── Jest (JSON reporter → structured testCases) ──────────────────────────────
261
269
 
262
270
  function runJest(dir, label) {
263
271
  const start = Date.now();
@@ -265,12 +273,9 @@ function runJest(dir, label) {
265
273
  !fs.existsSync(path.join(dir, 'node_modules', '.bin', 'jest.cmd')) &&
266
274
  !fs.existsSync(path.join(dir, 'node_modules', 'jest'))) {
267
275
  const pkg = tryReadJSON(path.join(dir, 'package.json'));
268
- if (!pkg?.scripts?.test) {
269
- return { suite: `Jest (${label})`, status: 'skipped', passed: 0, failed: 0, skipped: 0, output: 'Jest not installed. Run npm install first.', duration: 0 };
270
- }
276
+ if (!pkg?.scripts?.test) return { suite: `Jest (${label})`, status: 'skipped', passed: 0, failed: 0, skipped: 0, output: 'Jest not installed.', duration: 0 };
271
277
  }
272
278
 
273
- // shell:true is required on Windows so npx.cmd is resolved correctly
274
279
  const result = spawnSync('npx', ['jest', '--no-coverage', '--json', '--forceExit'], {
275
280
  cwd: dir, encoding: 'utf8', timeout: 120000, shell: true,
276
281
  env: { ...process.env, FORCE_COLOR: '0' }
@@ -279,99 +284,513 @@ function runJest(dir, label) {
279
284
  const duration = Date.now() - start;
280
285
  const rawOutput = (result.stdout || '') + (result.stderr || '');
281
286
 
282
- // Try to parse JSON output
283
287
  const jsonStart = rawOutput.indexOf('{');
284
288
  const jsonEnd = rawOutput.lastIndexOf('}');
285
289
  if (jsonStart !== -1 && jsonEnd !== -1) {
286
290
  try {
287
- const jResult = JSON.parse(rawOutput.slice(jsonStart, jsonEnd + 1));
288
- const passed = jResult.numPassedTests || 0;
289
- const failed = jResult.numFailedTests || 0;
290
- const skipped = jResult.numPendingTests || 0;
291
- const status = failed > 0 ? 'failed' : (passed > 0 ? 'passed' : 'skipped');
292
-
293
- // Build structured per-test case list (LeetCode style)
294
- // Jest uses 'assertionResults' per suite (not 'testResults')
291
+ const jr = JSON.parse(rawOutput.slice(jsonStart, jsonEnd + 1));
292
+ const passed = jr.numPassedTests || 0;
293
+ const failed = jr.numFailedTests || 0;
294
+ const skipped = jr.numPendingTests || 0;
295
295
  const testCases = [];
296
- const lines = [];
297
- for (const suite of (jResult.testResults || [])) {
296
+ for (const suite of (jr.testResults || [])) {
298
297
  const relPath = path.relative(dir, suite.testFilePath || suite.name || '');
299
- lines.push(`\n 📄 ${relPath}`);
300
- const assertions = suite.assertionResults || suite.testResults || [];
301
- for (const t of assertions) {
302
- const icon = t.status === 'passed' ? ' ✔' : t.status === 'failed' ? ' ✖' : ' ○';
303
- lines.push(`${icon} ${t.fullName || t.title}`);
304
- if (t.status === 'failed' && t.failureMessages?.length) {
305
- lines.push(` ${t.failureMessages[0].split('\n').slice(0, 3).join('\n ')}`);
306
- }
298
+ for (const t of (suite.assertionResults || suite.testResults || [])) {
307
299
  testCases.push({
308
300
  file: relPath,
309
301
  name: t.fullName || t.title || '(unnamed)',
310
- status: t.status, // 'passed' | 'failed' | 'pending'
302
+ status: t.status,
311
303
  duration: t.duration || 0,
312
304
  error: t.status === 'failed' && t.failureMessages?.length
313
- ? t.failureMessages[0].split('\n').slice(0, 6).join('\n')
314
- : null,
305
+ ? t.failureMessages[0].split('\n').slice(0, 6).join('\n') : null,
315
306
  });
316
307
  }
317
308
  }
309
+ return {
310
+ suite: `Jest (${label})`, status: failed > 0 ? 'failed' : (passed > 0 ? 'passed' : 'skipped'),
311
+ passed, failed, skipped, duration, testCases,
312
+ suites: `${jr.numPassedTestSuites}/${jr.numTotalTestSuites} suites passed`,
313
+ output: buildTextOutput(testCases),
314
+ };
315
+ } catch {}
316
+ }
317
+ return parseJestTextFallback(rawOutput, label, duration);
318
+ }
319
+
320
+ // ─── Vitest (JSON reporter → structured testCases) ────────────────────────────
321
+
322
+ function runVitest(dir, label) {
323
+ const start = Date.now();
324
+ const result = spawnSync('npx', ['vitest', 'run', '--reporter=json'], {
325
+ cwd: dir, encoding: 'utf8', timeout: 120000, shell: true,
326
+ env: { ...process.env, FORCE_COLOR: '0' }
327
+ });
328
+ const duration = Date.now() - start;
329
+ const rawOutput = (result.stdout || '') + (result.stderr || '');
318
330
 
331
+ // Vitest JSON output
332
+ const jsonStart = rawOutput.indexOf('{');
333
+ const jsonEnd = rawOutput.lastIndexOf('}');
334
+ if (jsonStart !== -1 && jsonEnd !== -1) {
335
+ try {
336
+ const vr = JSON.parse(rawOutput.slice(jsonStart, jsonEnd + 1));
337
+ const testCases = [];
338
+ for (const file of (vr.testResults || [])) {
339
+ const relPath = path.relative(dir, file.name || '');
340
+ for (const t of (file.assertionResults || [])) {
341
+ testCases.push({
342
+ file: relPath, name: t.fullName || t.title || '(unnamed)',
343
+ status: t.status, duration: t.duration || 0,
344
+ error: t.status === 'failed' && t.failureMessages?.length ? t.failureMessages[0].split('\n').slice(0, 6).join('\n') : null,
345
+ });
346
+ }
347
+ }
348
+ const passed = testCases.filter(t => t.status === 'passed').length;
349
+ const failed = testCases.filter(t => t.status === 'failed').length;
350
+ const skipped = testCases.filter(t => t.status === 'pending').length;
319
351
  return {
320
- suite: `Jest (${label})`,
321
- status,
322
- passed, failed, skipped,
323
- duration,
324
- output: lines.join('\n'),
325
- suites: jResult.numPassedTestSuites + '/' + jResult.numTotalTestSuites + ' suites passed',
326
- testCases,
352
+ suite: `Vitest (${label})`, status: failed > 0 ? 'failed' : (passed > 0 ? 'passed' : 'skipped'),
353
+ passed, failed, skipped, duration, testCases, output: buildTextOutput(testCases),
327
354
  };
328
- } catch { /* fall through to text parse */ }
355
+ } catch {}
329
356
  }
357
+ // Fallback: verbose text
358
+ return { suite: `Vitest (${label})`, duration, ...parseVitestOutput(rawOutput, result.status === 0) };
359
+ }
360
+
361
+ // ─── pytest (--tb=line -v → structured testCases) ────────────────────────────
330
362
 
331
- return parseJestTextOutput(rawOutput, label, duration);
363
+ function runPytest(root, base) {
364
+ const start = Date.now();
365
+ let output = '';
366
+ let exitOk = false;
367
+ try {
368
+ output = execSync(`${base} -v --tb=short --no-header -rN`, {
369
+ cwd: root, encoding: 'utf8', timeout: 120000, shell: true,
370
+ env: { ...process.env, PYTHONUNBUFFERED: '1' }, stdio: 'pipe'
371
+ });
372
+ exitOk = true;
373
+ } catch (err) {
374
+ output = (err.stdout || '') + (err.stderr || '') + (err.message || '');
375
+ }
376
+ const duration = Date.now() - start;
377
+
378
+ // Parse individual test lines: "path/test_foo.py::test_bar PASSED"
379
+ const testCases = [];
380
+ const lines = output.split('\n');
381
+ for (const line of lines) {
382
+ const m = line.match(/^([\w/\\.\-:]+)\s+(PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)/);
383
+ if (!m) continue;
384
+ const rawId = m[1]; // e.g. "tests/test_algo.py::TestSort::test_bubble"
385
+ const verdict = m[2];
386
+ const parts = rawId.split('::');
387
+ const file = parts[0];
388
+ const name = parts.slice(1).join(' > ') || path.basename(file);
389
+ const status = verdict === 'PASSED' || verdict === 'XPASS' ? 'passed'
390
+ : verdict === 'SKIPPED' || verdict === 'XFAIL' ? 'pending'
391
+ : 'failed';
392
+ // Grab error block for failures
393
+ let error = null;
394
+ if (status === 'failed') {
395
+ const errLines = [];
396
+ let inBlock = false;
397
+ for (const l of lines) {
398
+ if (l.includes('FAILED') && l.includes(rawId.split('::')[1] || '')) inBlock = true;
399
+ if (inBlock && (l.startsWith('E ') || l.startsWith(' E '))) errLines.push(l.trim());
400
+ if (errLines.length >= 5) break;
401
+ }
402
+ if (errLines.length) error = errLines.join('\n');
403
+ }
404
+ testCases.push({ file, name, status, duration: 0, error });
405
+ }
406
+
407
+ const passed = testCases.filter(t => t.status === 'passed').length;
408
+ const failed = testCases.filter(t => t.status === 'failed').length;
409
+ const skipped = testCases.filter(t => t.status === 'pending').length;
410
+
411
+ // If we couldn't parse individual tests, fall back to summary line
412
+ if (testCases.length === 0) {
413
+ return { suite: 'pytest (root)', duration, ...parsePytestOutput(output, exitOk) };
414
+ }
415
+
416
+ return {
417
+ suite: 'pytest (root)',
418
+ status: failed > 0 ? 'failed' : (passed > 0 ? 'passed' : 'skipped'),
419
+ passed, failed, skipped, duration, testCases, output: buildTextOutput(testCases),
420
+ };
332
421
  }
333
422
 
334
- // ─── Generic command runner ───────────────────────────────────────────────────
423
+ // ─── Go test (JSON output → structured testCases) ────────────────────────────
335
424
 
336
- function runCommand({ dir, label, cmd, suite, parseOutput }) {
337
- if (!cmd) {
338
- return { suite: `${suite} (${label})`, status: 'skipped', passed: 0, failed: 0, skipped: 0, output: `${suite} not installed or not found in PATH.`, duration: 0 };
425
+ function runGoTest(root) {
426
+ const start = Date.now();
427
+ let output = '';
428
+ let exitOk = false;
429
+ try {
430
+ output = execSync('go test ./... -v -json', {
431
+ cwd: root, encoding: 'utf8', timeout: 120000, shell: true,
432
+ env: { ...process.env }, stdio: 'pipe'
433
+ });
434
+ exitOk = true;
435
+ } catch (err) {
436
+ output = (err.stdout || '') + (err.stderr || '') + (err.message || '');
437
+ }
438
+ const duration = Date.now() - start;
439
+
440
+ const testCases = [];
441
+ const testMap = {}; // test name → {file, status, output[]}
442
+
443
+ for (const line of output.split('\n')) {
444
+ if (!line.trim().startsWith('{')) continue;
445
+ try {
446
+ const ev = JSON.parse(line);
447
+ if (!ev.Test) continue;
448
+ const key = `${ev.Package}::${ev.Test}`;
449
+ if (ev.Action === 'run') {
450
+ testMap[key] = { file: ev.Package || '', name: ev.Test, status: 'pending', errLines: [] };
451
+ } else if (ev.Action === 'pass') {
452
+ if (testMap[key]) testMap[key].status = 'passed';
453
+ } else if (ev.Action === 'fail') {
454
+ if (testMap[key]) testMap[key].status = 'failed';
455
+ } else if (ev.Action === 'skip') {
456
+ if (testMap[key]) testMap[key].status = 'pending';
457
+ } else if (ev.Action === 'output' && ev.Output) {
458
+ if (testMap[key]) testMap[key].errLines.push(ev.Output.trim());
459
+ }
460
+ } catch {}
461
+ }
462
+
463
+ for (const tc of Object.values(testMap)) {
464
+ if (tc.status === 'pending') tc.status = 'failed'; // ran but no result = fail
465
+ const errorLines = tc.errLines.filter(l => l.startsWith('---') || l.includes('FAIL') || l.includes('Error') || l.includes('got ') || l.includes('want '));
466
+ testCases.push({
467
+ file: tc.file, name: tc.name, status: tc.status, duration: 0,
468
+ error: tc.status === 'failed' && errorLines.length ? errorLines.slice(0, 5).join('\n') : null,
469
+ });
470
+ }
471
+
472
+ if (testCases.length === 0) {
473
+ return { suite: 'Go test (root)', duration, ...parseGoTestOutput(output, exitOk) };
339
474
  }
475
+
476
+ const passed = testCases.filter(t => t.status === 'passed').length;
477
+ const failed = testCases.filter(t => t.status === 'failed').length;
478
+ const skipped = testCases.filter(t => t.status === 'pending').length;
479
+
480
+ return {
481
+ suite: 'Go test (root)',
482
+ status: failed > 0 ? 'failed' : (passed > 0 ? 'passed' : 'skipped'),
483
+ passed, failed, skipped, duration, testCases, output: buildTextOutput(testCases),
484
+ };
485
+ }
486
+
487
+ // ─── Cargo test (--format json → structured testCases) ───────────────────────
488
+
489
+ function runCargoTest(root) {
340
490
  const start = Date.now();
491
+ let output = '';
492
+ let exitOk = false;
341
493
  try {
342
- // shell:true is required on Windows; harmless on Unix
343
- const output = execSync(cmd, {
344
- cwd: dir, encoding: 'utf8', timeout: 120000, shell: true,
345
- env: { ...process.env, FORCE_COLOR: '0' }, stdio: 'pipe'
494
+ // unstable-options needed for JSON; fall back gracefully
495
+ output = execSync('cargo test -- -Z unstable-options --format json 2>&1 || cargo test -- --nocapture', {
496
+ cwd: root, encoding: 'utf8', timeout: 180000, shell: true, stdio: 'pipe',
497
+ env: { ...process.env, RUST_BACKTRACE: '0' }
346
498
  });
347
- const duration = Date.now() - start;
348
- return { suite: `${suite} (${label})`, duration, ...parseOutput(output, true) };
499
+ exitOk = true;
500
+ } catch (err) {
501
+ output = (err.stdout || '') + (err.stderr || '') + (err.message || '');
502
+ }
503
+ const duration = Date.now() - start;
504
+
505
+ const testCases = [];
506
+ for (const line of output.split('\n')) {
507
+ // JSON format: {"type":"test","event":"ok","name":"tests::test_add"}
508
+ if (line.trim().startsWith('{')) {
509
+ try {
510
+ const ev = JSON.parse(line);
511
+ if (ev.type === 'test') {
512
+ testCases.push({
513
+ file: '', name: ev.name || '(unnamed)',
514
+ status: ev.event === 'ok' ? 'passed' : ev.event === 'ignored' ? 'pending' : 'failed',
515
+ duration: ev.exec_time ? Math.round(ev.exec_time * 1000) : 0,
516
+ error: ev.event === 'failed' && ev.stdout ? ev.stdout.split('\n').slice(0, 4).join('\n') : null,
517
+ });
518
+ }
519
+ } catch {}
520
+ } else {
521
+ // Text fallback: "test tests::add ... ok" or "test tests::add ... FAILED"
522
+ const m = line.match(/^test (.+?) \.\.\. (ok|FAILED|ignored)/);
523
+ if (m && !testCases.find(t => t.name === m[1])) {
524
+ testCases.push({
525
+ file: '', name: m[1],
526
+ status: m[2] === 'ok' ? 'passed' : m[2] === 'ignored' ? 'pending' : 'failed',
527
+ duration: 0, error: null,
528
+ });
529
+ }
530
+ }
531
+ }
532
+
533
+ if (testCases.length === 0) {
534
+ return { suite: 'Cargo test (root)', duration, ...parseCargoOutput(output, exitOk) };
535
+ }
536
+
537
+ const passed = testCases.filter(t => t.status === 'passed').length;
538
+ const failed = testCases.filter(t => t.status === 'failed').length;
539
+ const skipped = testCases.filter(t => t.status === 'pending').length;
540
+
541
+ return {
542
+ suite: 'Cargo test (root)',
543
+ status: failed > 0 ? 'failed' : (passed > 0 ? 'passed' : 'skipped'),
544
+ passed, failed, skipped, duration, testCases, output: buildTextOutput(testCases),
545
+ };
546
+ }
547
+
548
+ // ─── dotnet test (TRX/verbose → structured testCases) ────────────────────────
549
+
550
+ function runDotnetTest(root) {
551
+ const start = Date.now();
552
+ let output = '';
553
+ let exitOk = false;
554
+ try {
555
+ output = execSync('dotnet test --logger "console;verbosity=detailed"', {
556
+ cwd: root, encoding: 'utf8', timeout: 120000, shell: true, stdio: 'pipe'
557
+ });
558
+ exitOk = true;
559
+ } catch (err) {
560
+ output = (err.stdout || '') + (err.stderr || '') + (err.message || '');
561
+ }
562
+ const duration = Date.now() - start;
563
+
564
+ const testCases = [];
565
+ for (const line of output.split('\n')) {
566
+ // " Passed TestAdd [< 1 ms]" or " Failed TestAdd [...]"
567
+ const m = line.match(/^\s+(Passed|Failed|Skipped)\s+(.+?)(?:\s+\[.*\])?$/);
568
+ if (m) {
569
+ testCases.push({
570
+ file: '', name: m[2].trim(),
571
+ status: m[1] === 'Passed' ? 'passed' : m[1] === 'Skipped' ? 'pending' : 'failed',
572
+ duration: 0, error: null,
573
+ });
574
+ }
575
+ }
576
+
577
+ if (testCases.length === 0) {
578
+ return { suite: 'dotnet test (root)', duration, ...parseDotnetOutput(output, exitOk) };
579
+ }
580
+
581
+ const passed = testCases.filter(t => t.status === 'passed').length;
582
+ const failed = testCases.filter(t => t.status === 'failed').length;
583
+ const skipped = testCases.filter(t => t.status === 'pending').length;
584
+
585
+ return {
586
+ suite: 'dotnet test (root)', status: failed > 0 ? 'failed' : (passed > 0 ? 'passed' : 'skipped'),
587
+ passed, failed, skipped, duration, testCases, output: buildTextOutput(testCases),
588
+ };
589
+ }
590
+
591
+ // ─── CTest / GoogleTest ───────────────────────────────────────────────────────
592
+
593
+ function runCTest(root) {
594
+ const buildDir = fs.existsSync(path.join(root, 'build')) ? path.join(root, 'build') : root;
595
+ if (!commandExists('ctest')) {
596
+ return { suite: 'CTest (root)', status: 'skipped', passed: 0, failed: 0, skipped: 0, output: 'ctest not found. Install CMake.', duration: 0 };
597
+ }
598
+ const start = Date.now();
599
+ let output = '';
600
+ let exitOk = false;
601
+ try {
602
+ output = execSync('ctest --output-on-failure', {
603
+ cwd: buildDir, encoding: 'utf8', timeout: 120000, shell: true, stdio: 'pipe'
604
+ });
605
+ exitOk = true;
606
+ } catch (err) {
607
+ output = (err.stdout || '') + (err.stderr || '') + (err.message || '');
608
+ }
609
+ const duration = Date.now() - start;
610
+
611
+ const testCases = [];
612
+ for (const line of output.split('\n')) {
613
+ // CTest: " 1/3 Test #1: TestAdd ................. Passed 0.01 sec"
614
+ const m = line.match(/\s*\d+\/\d+\s+Test\s+#\d+:\s+(.+?)\s+\.+\s+(Passed|Failed|Not Run)/i);
615
+ if (m) {
616
+ testCases.push({
617
+ file: '', name: m[1].trim(),
618
+ status: m[2].toLowerCase() === 'passed' ? 'passed' : m[2].toLowerCase() === 'not run' ? 'pending' : 'failed',
619
+ duration: 0, error: null,
620
+ });
621
+ }
622
+ // GoogleTest inline: "[ RUN ] SortTest.BubbleSort" / "[ OK ]" / "[ FAILED ]"
623
+ const gRun = line.match(/\[ RUN\s+\] (.+)/);
624
+ const gOk = line.match(/\[\s+OK\s+\] (.+?)(?:\s+\(|$)/);
625
+ const gFail = line.match(/\[\s+FAILED\s+\] (.+?)(?:\s+\(|$)/);
626
+ if (gRun) {
627
+ testCases.push({ file: '', name: gRun[1].trim(), status: 'pending', duration: 0, error: null });
628
+ } else if (gOk) {
629
+ const tc = testCases.findLast?.(t => t.name === gOk[1].trim()) || testCases.find(t => t.name === gOk[1].trim());
630
+ if (tc) tc.status = 'passed';
631
+ } else if (gFail) {
632
+ const tc = testCases.findLast?.(t => t.name === gFail[1].trim()) || testCases.find(t => t.name === gFail[1].trim());
633
+ if (tc) tc.status = 'failed';
634
+ }
635
+ }
636
+
637
+ const deduped = [];
638
+ const seen = new Set();
639
+ for (const tc of testCases) {
640
+ if (!seen.has(tc.name)) { seen.add(tc.name); deduped.push(tc); }
641
+ }
642
+
643
+ if (deduped.length === 0) {
644
+ const passMatch = output.match(/(\d+) tests? passed/i);
645
+ const failMatch = output.match(/(\d+) tests? failed/i);
646
+ return { suite: 'CTest (root)', status: exitOk ? 'passed' : 'failed', passed: passMatch ? +passMatch[1] : 0, failed: failMatch ? +failMatch[1] : 0, skipped: 0, duration, output: output.slice(-2000) };
647
+ }
648
+
649
+ const passed = deduped.filter(t => t.status === 'passed').length;
650
+ const failed = deduped.filter(t => t.status === 'failed').length;
651
+ const skipped = deduped.filter(t => t.status === 'pending').length;
652
+
653
+ return { suite: 'CTest (root)', status: failed > 0 ? 'failed' : 'passed', passed, failed, skipped, duration, testCases: deduped, output: buildTextOutput(deduped) };
654
+ }
655
+
656
+ // ─── DSA / Standalone test runner ────────────────────────────────────────────
657
+ // Runs Python files that use unittest / doctest / bare assert,
658
+ // and JS/TS files that use console.assert or a simple assert pattern.
659
+
660
+ function runDSATests(root) {
661
+ const results = [];
662
+ const pyFiles = findFilesWithExt(root, '.py').filter(f => hasTestPatterns(f));
663
+ const jsFiles = [...findFilesWithExt(root, '.js'), ...findFilesWithExt(root, '.ts')].filter(f => hasTestPatterns(f));
664
+
665
+ // Python: run each file with unittest discover or directly
666
+ if (pyFiles.length > 0) {
667
+ const pyCmd = commandExists('python3') ? 'python3' : commandExists('python') ? 'python' : null;
668
+ for (const f of pyFiles.slice(0, 10)) { // cap at 10 to avoid infinite loops
669
+ const start = Date.now();
670
+ let output = '';
671
+ let exitOk = false;
672
+ try {
673
+ output = execSync(`${pyCmd} -m pytest "${f}" -v --tb=short --no-header 2>&1 || ${pyCmd} -m doctest "${f}" -v 2>&1 || ${pyCmd} "${f}" 2>&1`, {
674
+ cwd: root, encoding: 'utf8', timeout: 30000, shell: true, stdio: 'pipe'
675
+ });
676
+ exitOk = true;
677
+ } catch (err) {
678
+ output = (err.stdout || '') + (err.stderr || '') + (err.message || '');
679
+ }
680
+ const duration = Date.now() - start;
681
+ const relF = path.relative(root, f);
682
+ const testCases = parsePythonFileOutput(output, relF);
683
+ const passed = testCases.filter(t => t.status === 'passed').length;
684
+ const failed = testCases.filter(t => t.status === 'failed').length;
685
+ if (testCases.length > 0) {
686
+ results.push({ suite: `Python: ${relF}`, status: failed > 0 ? 'failed' : 'passed', passed, failed, skipped: 0, duration, testCases, output: buildTextOutput(testCases) });
687
+ } else {
688
+ const ok = exitOk && !output.toLowerCase().includes('error') && !output.toLowerCase().includes('failed');
689
+ results.push({ suite: `Python: ${relF}`, status: ok ? 'passed' : 'failed', passed: ok ? 1 : 0, failed: ok ? 0 : 1, skipped: 0, duration, output: output.slice(-1000) });
690
+ }
691
+ }
692
+ }
693
+
694
+ // JS: run each file with node directly
695
+ if (jsFiles.length > 0 && commandExists('node')) {
696
+ for (const f of jsFiles.slice(0, 10)) {
697
+ const start = Date.now();
698
+ let output = '';
699
+ let exitOk = false;
700
+ try {
701
+ output = execSync(`node "${f}"`, { cwd: root, encoding: 'utf8', timeout: 30000, shell: true, stdio: 'pipe' });
702
+ exitOk = true;
703
+ } catch (err) {
704
+ output = (err.stdout || '') + (err.stderr || '') + (err.message || '');
705
+ }
706
+ const duration = Date.now() - start;
707
+ const relF = path.relative(root, f);
708
+ const testCases = parseJsAssertOutput(output, relF, exitOk);
709
+ const passed = testCases.filter(t => t.status === 'passed').length;
710
+ const failed = testCases.filter(t => t.status === 'failed').length;
711
+ if (testCases.length > 0) {
712
+ results.push({ suite: `Node: ${relF}`, status: failed > 0 ? 'failed' : 'passed', passed, failed, skipped: 0, duration, testCases, output: buildTextOutput(testCases) });
713
+ } else {
714
+ const ok = exitOk && !output.toLowerCase().includes('assertionerror') && !output.toLowerCase().includes('error:');
715
+ results.push({ suite: `Node: ${relF}`, status: ok ? 'passed' : 'failed', passed: ok ? 1 : 0, failed: ok ? 0 : 1, skipped: 0, duration, output: output.slice(-1000) });
716
+ }
717
+ }
718
+ }
719
+
720
+ if (results.length === 0) {
721
+ return [{ suite: 'DSA / Standalone Tests', status: 'skipped', passed: 0, failed: 0, skipped: 0, output: 'No runnable standalone test files found.', duration: 0 }];
722
+ }
723
+ return results;
724
+ }
725
+
726
+ function parsePythonFileOutput(output, filePath) {
727
+ const testCases = [];
728
+ for (const line of output.split('\n')) {
729
+ // pytest: "tests/test_sort.py::test_bubble PASSED"
730
+ const pm = line.match(/^(.+?::(.+?))\s+(PASSED|FAILED|ERROR|SKIPPED)/);
731
+ if (pm) {
732
+ testCases.push({ file: filePath, name: pm[2].trim(), status: pm[3] === 'PASSED' ? 'passed' : pm[3] === 'SKIPPED' ? 'pending' : 'failed', duration: 0, error: null });
733
+ continue;
734
+ }
735
+ // doctest: "Trying: ... ok" or "Failed example:"
736
+ const dm = line.match(/^Trying:\s+(.+)/);
737
+ if (dm) { testCases.push({ file: filePath, name: `doctest: ${dm[1].slice(0, 60)}`, status: 'passed', duration: 0, error: null }); }
738
+ // unittest: "test_add (TestMath) ... ok"
739
+ const um = line.match(/^(test\w+)\s+\(.+?\)\s+\.\.\.\s+(ok|FAIL|ERROR)/);
740
+ if (um) { testCases.push({ file: filePath, name: um[1], status: um[2] === 'ok' ? 'passed' : 'failed', duration: 0, error: null }); }
741
+ }
742
+ return testCases;
743
+ }
744
+
745
+ function parseJsAssertOutput(output, filePath, exitOk) {
746
+ const testCases = [];
747
+ for (const line of output.split('\n')) {
748
+ // console.assert or custom: "✔ test_add passed" / "✖ test_add failed"
749
+ const m1 = line.match(/[✔✓]\s+(.+)/);
750
+ const m2 = line.match(/[✖✗✘×]\s+(.+)/);
751
+ if (m1) testCases.push({ file: filePath, name: m1[1].trim(), status: 'passed', duration: 0, error: null });
752
+ if (m2) testCases.push({ file: filePath, name: m2[1].trim(), status: 'failed', duration: 0, error: null });
753
+ }
754
+ return testCases;
755
+ }
756
+
757
+ // ─── Generic command runner ───────────────────────────────────────────────────
758
+
759
+ function runCommand({ dir, label, cmd, suite, parseOutput }) {
760
+ if (!cmd) return { suite: `${suite} (${label})`, status: 'skipped', passed: 0, failed: 0, skipped: 0, output: `${suite} not installed or not found in PATH.`, duration: 0 };
761
+ const start = Date.now();
762
+ try {
763
+ const output = execSync(cmd, { cwd: dir, encoding: 'utf8', timeout: 120000, shell: true, env: { ...process.env, FORCE_COLOR: '0' }, stdio: 'pipe' });
764
+ return { suite: `${suite} (${label})`, duration: Date.now() - start, ...parseOutput(output, true) };
349
765
  } catch (err) {
350
- const duration = Date.now() - start;
351
766
  const output = (err.stdout || '') + (err.stderr || '') + (err.message || '');
352
- return { suite: `${suite} (${label})`, duration, ...parseOutput(output, false) };
767
+ return { suite: `${suite} (${label})`, duration: Date.now() - start, ...parseOutput(output, false) };
353
768
  }
354
769
  }
355
770
 
356
- // ─── Output parsers ───────────────────────────────────────────────────────────
771
+ // ─── Output parsers (text fallback for non-JSON runners) ─────────────────────
772
+
773
+ function buildTextOutput(testCases) {
774
+ const byFile = {};
775
+ for (const tc of testCases) { (byFile[tc.file || ''] = byFile[tc.file || ''] || []).push(tc); }
776
+ return Object.entries(byFile).map(([file, cases]) =>
777
+ (file ? `\n 📄 ${file}` : '') + '\n' +
778
+ cases.map(t => ` ${t.status === 'passed' ? '✔' : t.status === 'failed' ? '✖' : '○'} ${t.name}${t.duration > 0 ? ` (${t.duration}ms)` : ''}`).join('\n')
779
+ ).join('\n');
780
+ }
357
781
 
358
- function parseJestTextOutput(output, label, duration) {
359
- const passMatch = output.match(/(\d+)\s+passed/);
360
- const failMatch = output.match(/(\d+)\s+failed/);
361
- const skipMatch = output.match(/(\d+)\s+(?:skipped|pending)/);
362
- const passed = passMatch ? parseInt(passMatch[1]) : 0;
363
- const failed = failMatch ? parseInt(failMatch[1]) : 0;
364
- const skipped = skipMatch ? parseInt(skipMatch[1]) : 0;
782
+ function parseJestTextFallback(output, label, duration) {
783
+ const passed = +(output.match(/(\d+)\s+passed/)?.[1] || 0);
784
+ const failed = +(output.match(/(\d+)\s+failed/)?.[1] || 0);
785
+ const skipped = +(output.match(/(\d+)\s+(?:skipped|pending)/)?.[1] || 0);
365
786
  return { suite: `Jest (${label})`, status: failed > 0 ? 'failed' : (passed > 0 ? 'passed' : 'unknown'), passed, failed, skipped, duration, output: output.slice(-3000) };
366
787
  }
367
788
 
368
789
  function parsePytestOutput(output, exitOk) {
369
- const m = output.match(/(\d+)\s+passed(?:,\s*(\d+)\s+failed)?(?:,\s*(\d+)\s+(?:warning|skipped))?/);
370
- const failed = output.match(/(\d+)\s+failed/)?.[1];
371
- const passed = m?.[1] || '0';
372
- const skipped = output.match(/(\d+)\s+(?:skipped|deselected)/)?.[1] || '0';
373
- const status = parseInt(failed || '0') > 0 ? 'failed' : (exitOk ? 'passed' : 'failed');
374
- return { status, passed: parseInt(passed), failed: parseInt(failed || '0'), skipped: parseInt(skipped), output: output.slice(-3000) };
790
+ const failed = +(output.match(/(\d+)\s+failed/)?.[1] || 0);
791
+ const passed = +(output.match(/(\d+)\s+passed/)?.[1] || 0);
792
+ const skipped = +(output.match(/(\d+)\s+(?:skipped|deselected)/)?.[1] || 0);
793
+ return { status: failed > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed, failed, skipped, output: output.slice(-3000) };
375
794
  }
376
795
 
377
796
  function parseGoTestOutput(output, exitOk) {
@@ -383,75 +802,93 @@ function parseGoTestOutput(output, exitOk) {
383
802
 
384
803
  function parseCargoOutput(output, exitOk) {
385
804
  const m = output.match(/test result:.*?(\d+)\s+passed;\s*(\d+)\s+failed;\s*(\d+)\s+ignored/);
386
- return { status: m && parseInt(m[2]) > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed: parseInt(m?.[1] || '0'), failed: parseInt(m?.[2] || '0'), skipped: parseInt(m?.[3] || '0'), output: output.slice(-3000) };
805
+ return { status: m && +m[2] > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed: +(m?.[1]||0), failed: +(m?.[2]||0), skipped: +(m?.[3]||0), output: output.slice(-3000) };
387
806
  }
388
807
 
389
808
  function parseMavenOutput(output, exitOk) {
390
809
  const m = output.match(/Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)/);
391
- const failed = m ? parseInt(m[2]) + parseInt(m[3]) : 0;
392
- const passed = m ? parseInt(m[1]) - failed - parseInt(m[4]) : 0;
393
- const skipped = m ? parseInt(m[4]) : 0;
394
- return { status: failed > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed, failed, skipped, output: output.slice(-3000) };
810
+ const failed = m ? +m[2] + +m[3] : 0;
811
+ const passed = m ? +m[1] - failed - +m[4] : 0;
812
+ return { status: failed > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed, failed, skipped: m ? +m[4] : 0, output: output.slice(-3000) };
395
813
  }
396
814
 
397
815
  function parseGradleOutput(output, exitOk) {
398
816
  const m = output.match(/(\d+)\s+tests?\s+completed(?:,\s*(\d+)\s+failed)?/);
399
- const failed = m?.[2] ? parseInt(m[2]) : 0;
400
- const total = m ? parseInt(m[1]) : 0;
401
- return { status: failed > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed: total - failed, failed, skipped: 0, output: output.slice(-3000) };
817
+ const failed = m?.[2] ? +m[2] : 0;
818
+ return { status: failed > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed: m ? +m[1] - failed : 0, failed, skipped: 0, output: output.slice(-3000) };
402
819
  }
403
820
 
404
821
  function parseMochaOutput(output, exitOk) {
405
- const passMatch = output.match(/(\d+)\s+passing/);
406
- const failMatch = output.match(/(\d+)\s+failing/);
407
- const pending = output.match(/(\d+)\s+pending/);
408
- const passed = passMatch ? parseInt(passMatch[1]) : 0;
409
- const failed = failMatch ? parseInt(failMatch[1]) : 0;
410
- const skipped = pending ? parseInt(pending[1]) : 0;
411
- return { status: failed > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed, failed, skipped, output: output.slice(-3000) };
822
+ const passed = +(output.match(/(\d+)\s+passing/)?.[1] || 0);
823
+ const failed = +(output.match(/(\d+)\s+failing/)?.[1] || 0);
824
+ const skipped = +(output.match(/(\d+)\s+pending/)?.[1] || 0);
825
+ // Per-test from mocha spec reporter: " ✓ should add two numbers" or " 1) should fail"
826
+ const testCases = [];
827
+ for (const line of output.split('\n')) {
828
+ const pass = line.match(/^\s+(?:✓|✔|passing)\s+(.+)/);
829
+ const fail = line.match(/^\s+\d+\)\s+(.+)/);
830
+ if (pass) testCases.push({ file: '', name: pass[1].trim(), status: 'passed', duration: 0, error: null });
831
+ if (fail) testCases.push({ file: '', name: fail[1].trim(), status: 'failed', duration: 0, error: null });
832
+ }
833
+ return { status: failed > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed, failed, skipped, testCases: testCases.length ? testCases : undefined, output: output.slice(-3000) };
412
834
  }
413
835
 
414
836
  function parseVitestOutput(output, exitOk) {
415
- const passMatch = output.match(/(\d+)\s+(?:test[s]?\s+)?passed/i);
416
- const failMatch = output.match(/(\d+)\s+(?:test[s]?\s+)?failed/i);
417
- const passed = passMatch ? parseInt(passMatch[1]) : 0;
418
- const failed = failMatch ? parseInt(failMatch[1]) : 0;
837
+ const passed = +(output.match(/(\d+)\s+(?:tests?\s+)?passed/i)?.[1] || 0);
838
+ const failed = +(output.match(/(\d+)\s+(?:tests?\s+)?failed/i)?.[1] || 0);
419
839
  return { status: failed > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed, failed, skipped: 0, output: output.slice(-3000) };
420
840
  }
421
841
 
422
842
  function parsePHPUnitOutput(output, exitOk) {
423
843
  const m = output.match(/OK\s+\((\d+)\s+tests?/);
424
844
  const f = output.match(/FAILURES!\s*\nTests:\s*(\d+),.*?Failures:\s*(\d+)/s);
425
- if (f) return { status: 'failed', passed: parseInt(f[1]) - parseInt(f[2]), failed: parseInt(f[2]), skipped: 0, output: output.slice(-3000) };
426
- return { status: exitOk ? 'passed' : 'failed', passed: m ? parseInt(m[1]) : 0, failed: 0, skipped: 0, output: output.slice(-3000) };
845
+ if (f) return { status: 'failed', passed: +f[1] - +f[2], failed: +f[2], skipped: 0, output: output.slice(-3000) };
846
+ return { status: exitOk ? 'passed' : 'failed', passed: m ? +m[1] : 0, failed: 0, skipped: 0, output: output.slice(-3000) };
427
847
  }
428
848
 
429
849
  function parseRSpecOutput(output, exitOk) {
430
850
  const m = output.match(/(\d+)\s+examples?,\s*(\d+)\s+failures?/);
431
- return { status: m && parseInt(m[2]) > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed: m ? parseInt(m[1]) - parseInt(m[2]) : 0, failed: m ? parseInt(m[2]) : 0, skipped: 0, output: output.slice(-3000) };
851
+ // Per-test from RSpec documentation format:
852
+ // " TestClass" / " ✓ does something" / " ✗ does something (FAILED)"
853
+ const testCases = [];
854
+ let currentGroup = '';
855
+ for (const line of output.split('\n')) {
856
+ const group = line.match(/^\s{2}(\S.+)$/);
857
+ const pass = line.match(/^\s{4}(?:✓|\+)\s+(.+)/);
858
+ const fail = line.match(/^\s{4}(?:✗|×|-)\s+(.+)/);
859
+ if (group && !pass && !fail) currentGroup = group[1].trim();
860
+ if (pass) testCases.push({ file: '', name: `${currentGroup ? currentGroup + ' ' : ''}${pass[1].trim()}`, status: 'passed', duration: 0, error: null });
861
+ if (fail) testCases.push({ file: '', name: `${currentGroup ? currentGroup + ' ' : ''}${fail[1].trim()}`, status: 'failed', duration: 0, error: null });
862
+ }
863
+ return { status: m && +m[2] > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed: m ? +m[1] - +m[2] : 0, failed: m ? +m[2] : 0, skipped: 0, testCases: testCases.length ? testCases : undefined, output: output.slice(-3000) };
432
864
  }
433
865
 
434
866
  function parseDartOutput(output, exitOk) {
435
- const passMatch = output.match(/(\d+) tests? passed/);
436
- const failMatch = output.match(/(\d+) tests? failed/);
437
- const passed = passMatch ? parseInt(passMatch[1]) : 0;
438
- const failed = failMatch ? parseInt(failMatch[1]) : 0;
439
- return { status: failed > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed, failed, skipped: 0, output: output.slice(-3000) };
867
+ const passed = +(output.match(/(\d+) tests? passed/)?.[1] || 0);
868
+ const failed = +(output.match(/(\d+) tests? failed/)?.[1] || 0);
869
+ const testCases = [];
870
+ for (const line of output.split('\n')) {
871
+ const pass = line.match(/^\s*(?:✓|\+)\s+(.+)/);
872
+ const fail = line.match(/^\s*(?:✗|-)\s+(.+)/);
873
+ if (pass) testCases.push({ file: '', name: pass[1].trim(), status: 'passed', duration: 0, error: null });
874
+ if (fail) testCases.push({ file: '', name: fail[1].trim(), status: 'failed', duration: 0, error: null });
875
+ }
876
+ return { status: failed > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed, failed, skipped: 0, testCases: testCases.length ? testCases : undefined, output: output.slice(-3000) };
440
877
  }
441
878
 
442
879
  function parseDotnetOutput(output, exitOk) {
443
880
  const m = output.match(/Passed!\s+-\s+Failed:\s*(\d+),\s*Passed:\s*(\d+),\s*Skipped:\s*(\d+)/);
444
- if (m) return { status: parseInt(m[1]) > 0 ? 'failed' : 'passed', passed: parseInt(m[2]), failed: parseInt(m[1]), skipped: parseInt(m[3]), output: output.slice(-3000) };
445
- const f = output.match(/Failed:\s*(\d+)/); const p = output.match(/Passed:\s*(\d+)/);
446
- return { status: exitOk ? 'passed' : 'failed', passed: p ? parseInt(p[1]) : 0, failed: f ? parseInt(f[1]) : 0, skipped: 0, output: output.slice(-3000) };
881
+ if (m) return { status: +m[1] > 0 ? 'failed' : 'passed', passed: +m[2], failed: +m[1], skipped: +m[3], output: output.slice(-3000) };
882
+ const f = output.match(/Failed:\s*(\d+)/);
883
+ const p = output.match(/Passed:\s*(\d+)/);
884
+ return { status: exitOk ? 'passed' : 'failed', passed: p ? +p[1] : 0, failed: f ? +f[1] : 0, skipped: 0, output: output.slice(-3000) };
447
885
  }
448
886
 
449
- // ─── Utility helpers ─────────────────────────────────────────────────────────
887
+ // ─── Utility helpers ──────────────────────────────────────────────────────────
450
888
 
451
889
  function tryReadJSON(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } }
452
- function allDeps(pkg) { return [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {}), ...Object.keys(pkg.peerDependencies || {})]; }
890
+ function allDeps(pkg) { return [...Object.keys(pkg.dependencies||{}), ...Object.keys(pkg.devDependencies||{}), ...Object.keys(pkg.peerDependencies||{})]; }
453
891
 
454
- /** Works on Windows (where and Get-Command) and Unix (which / command -v). */
455
892
  function commandExists(cmd) {
456
893
  try {
457
894
  if (IS_WIN) {
@@ -464,13 +901,37 @@ function commandExists(cmd) {
464
901
  }
465
902
 
466
903
  function hasBin(dir, bin) {
467
- // On Windows the bin is a .cmd wrapper
468
904
  return fs.existsSync(path.join(dir, 'node_modules', '.bin', bin)) ||
469
905
  fs.existsSync(path.join(dir, 'node_modules', '.bin', bin + '.cmd'));
470
906
  }
907
+
471
908
  function hasSourceExt(root, ext) {
472
909
  try { return fs.readdirSync(root).some(f => f.endsWith(ext)); } catch { return false; }
473
910
  }
911
+
912
+ function findFilesWithExt(root, ext) {
913
+ const results = [];
914
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'venv', '.venv', '__pycache__']);
915
+ function walk(dir, depth) {
916
+ if (depth > 5) return;
917
+ let entries;
918
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
919
+ for (const e of entries) {
920
+ if (e.isDirectory() && !SKIP.has(e.name)) walk(path.join(dir, e.name), depth + 1);
921
+ else if (e.isFile() && e.name.endsWith(ext)) results.push(path.join(dir, e.name));
922
+ }
923
+ }
924
+ walk(root, 0);
925
+ return results;
926
+ }
927
+
928
+ function hasTestPatterns(filePath) {
929
+ try {
930
+ const src = fs.readFileSync(filePath, 'utf8');
931
+ return /\b(unittest|pytest|doctest|assert|describe|it\(|test\(|expect\()\b/.test(src);
932
+ } catch { return false; }
933
+ }
934
+
474
935
  function findSubPackages(root) {
475
936
  try {
476
937
  return fs.readdirSync(root, { withFileTypes: true })
@@ -479,6 +940,7 @@ function findSubPackages(root) {
479
940
  .filter(p => fs.existsSync(p));
480
941
  } catch { return []; }
481
942
  }
943
+
482
944
  function findSubDirs(root) {
483
945
  try {
484
946
  return fs.readdirSync(root, { withFileTypes: true })