project-scan 1.0.1 → 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,28 +2,38 @@
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');
9
22
  const fs = require('fs');
10
23
  const path = require('path');
11
24
 
12
- // ─── Per-language runner configs ─────────────────────────────────────────────
25
+ const IS_WIN = process.platform === 'win32';
26
+
27
+ // ─── Runners ──────────────────────────────────────────────────────────────────
13
28
 
14
- /**
15
- * Each runner: { name, detect(rootDir), run(rootDir) → RunResult }
16
- * RunResult: { suite, passed, failed, skipped, duration, status, output, error? }
17
- */
18
29
  const RUNNERS = [
19
30
 
20
- // ── Node.js: Jest ──────────────────────────────────────────────────────────
31
+ // ── Jest ────────────────────────────────────────────────────────────────────
21
32
  {
22
33
  name: 'Jest',
23
34
  detect(root) {
24
35
  const pkg = tryReadJSON(path.join(root, 'package.json'));
25
36
  if (pkg && (allDeps(pkg).includes('jest') || pkg.jest)) return true;
26
- // subdirectory package.jsons
27
37
  return findSubPackages(root).some(p => {
28
38
  const sp = tryReadJSON(p);
29
39
  return sp && (allDeps(sp).includes('jest') || sp.jest || sp.scripts?.test?.includes('jest'));
@@ -34,62 +44,41 @@ const RUNNERS = [
34
44
  const pkg = tryReadJSON(path.join(dir, 'package.json'));
35
45
  return pkg && (allDeps(pkg).includes('jest') || pkg.jest || pkg.scripts?.test?.includes('jest'));
36
46
  });
37
- const results = [];
38
- for (const dir of targets) {
39
- const label = dir === root ? '(root)' : path.relative(root, dir);
40
- results.push(runJest(dir, label));
41
- }
42
- return results;
47
+ return targets.map(dir => runJest(dir, dir === root ? '(root)' : path.relative(root, dir)));
43
48
  }
44
49
  },
45
50
 
46
- // ── Node.js: Vitest ────────────────────────────────────────────────────────
51
+ // ── Vitest ──────────────────────────────────────────────────────────────────
47
52
  {
48
53
  name: 'Vitest',
49
54
  detect(root) {
50
55
  const pkg = tryReadJSON(path.join(root, 'package.json'));
51
56
  if (pkg && allDeps(pkg).includes('vitest')) return true;
52
- return findSubPackages(root).some(p => {
53
- const sp = tryReadJSON(p);
54
- return sp && allDeps(sp).includes('vitest');
55
- });
57
+ return findSubPackages(root).some(p => { const sp = tryReadJSON(p); return sp && allDeps(sp).includes('vitest'); });
56
58
  },
57
59
  run(root) {
58
60
  const targets = [root, ...findSubDirs(root)].filter(dir => {
59
61
  const pkg = tryReadJSON(path.join(dir, 'package.json'));
60
62
  return pkg && allDeps(pkg).includes('vitest');
61
63
  });
62
- return targets.map(dir => runCommand({
63
- dir, label: dir === root ? '(root)' : path.relative(root, dir),
64
- cmd: hasBin(dir, 'vitest') ? 'npx vitest run --reporter=verbose' : null,
65
- suite: 'Vitest',
66
- parseOutput: parseVitestOutput,
67
- }));
64
+ return targets.map(dir => runVitest(dir, dir === root ? '(root)' : path.relative(root, dir)));
68
65
  }
69
66
  },
70
67
 
71
- // ── Node.js: Mocha ─────────────────────────────────────────────────────────
68
+ // ── Mocha ───────────────────────────────────────────────────────────────────
72
69
  {
73
70
  name: 'Mocha',
74
71
  detect(root) {
75
72
  const pkg = tryReadJSON(path.join(root, 'package.json'));
76
73
  if (pkg && allDeps(pkg).includes('mocha')) return true;
77
- return findSubPackages(root).some(p => {
78
- const sp = tryReadJSON(p);
79
- return sp && allDeps(sp).includes('mocha');
80
- });
74
+ return findSubPackages(root).some(p => { const sp = tryReadJSON(p); return sp && allDeps(sp).includes('mocha'); });
81
75
  },
82
76
  run(root) {
83
- return [runCommand({
84
- dir: root, label: '(root)',
85
- cmd: 'npx mocha --reporter spec',
86
- suite: 'Mocha',
87
- parseOutput: parseMochaOutput,
88
- })];
77
+ return [runCommand({ dir: root, label: '(root)', cmd: 'npx mocha --reporter spec', suite: 'Mocha', parseOutput: parseMochaOutput })];
89
78
  }
90
79
  },
91
80
 
92
- // ── Python: pytest ─────────────────────────────────────────────────────────
81
+ // ── pytest ──────────────────────────────────────────────────────────────────
93
82
  {
94
83
  name: 'pytest',
95
84
  detect(root) {
@@ -100,53 +89,46 @@ const RUNNERS = [
100
89
  hasSourceExt(root, '.py');
101
90
  },
102
91
  run(root) {
103
- const cmd = commandExists('pytest') ? 'pytest -v --tb=short 2>&1'
104
- : commandExists('python3') ? 'python3 -m pytest -v --tb=short 2>&1'
105
- : commandExists('python') ? 'python -m pytest -v --tb=short 2>&1'
106
- : null;
107
- 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)];
108
98
  }
109
99
  },
110
100
 
111
- // ── Go: go test ────────────────────────────────────────────────────────────
101
+ // ── Go test ─────────────────────────────────────────────────────────────────
112
102
  {
113
103
  name: 'Go test',
114
104
  detect(root) { return fs.existsSync(path.join(root, 'go.mod')); },
115
105
  run(root) {
116
- return [runCommand({
117
- dir: root, label: '(root)',
118
- cmd: commandExists('go') ? 'go test ./... -v 2>&1' : null,
119
- suite: 'Go test',
120
- parseOutput: parseGoTestOutput,
121
- })];
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)];
122
108
  }
123
109
  },
124
110
 
125
- // ── Rust: cargo test ───────────────────────────────────────────────────────
111
+ // ── Cargo test ──────────────────────────────────────────────────────────────
126
112
  {
127
113
  name: 'Cargo test',
128
114
  detect(root) { return fs.existsSync(path.join(root, 'Cargo.toml')); },
129
115
  run(root) {
130
- return [runCommand({
131
- dir: root, label: '(root)',
132
- cmd: commandExists('cargo') ? 'cargo test 2>&1' : null,
133
- suite: 'Cargo test',
134
- parseOutput: parseCargoOutput,
135
- })];
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)];
136
118
  }
137
119
  },
138
120
 
139
- // ── Java/Kotlin: Maven ─────────────────────────────────────────────────────
121
+ // ── Maven ───────────────────────────────────────────────────────────────────
140
122
  {
141
123
  name: 'Maven',
142
124
  detect(root) { return fs.existsSync(path.join(root, 'pom.xml')); },
143
125
  run(root) {
144
- const cmd = commandExists('mvn') ? 'mvn test -B 2>&1' : null;
126
+ const cmd = commandExists('mvn') ? 'mvn test -B' : null;
145
127
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'Maven', parseOutput: parseMavenOutput })];
146
128
  }
147
129
  },
148
130
 
149
- // ── Java/Kotlin: Gradle ────────────────────────────────────────────────────
131
+ // ── Gradle ──────────────────────────────────────────────────────────────────
150
132
  {
151
133
  name: 'Gradle',
152
134
  detect(root) {
@@ -154,210 +136,661 @@ const RUNNERS = [
154
136
  fs.existsSync(path.join(root, 'build.gradle.kts'));
155
137
  },
156
138
  run(root) {
157
- const gradleCmd = fs.existsSync(path.join(root, 'gradlew')) ? './gradlew' : (commandExists('gradle') ? 'gradle' : null);
158
- return [runCommand({ dir: root, label: '(root)', cmd: gradleCmd ? `${gradleCmd} test 2>&1` : null, suite: 'Gradle', parseOutput: parseGradleOutput })];
139
+ const wrapperWin = path.join(root, 'gradlew.bat');
140
+ const wrapperUnix = path.join(root, 'gradlew');
141
+ const gradleCmd = fs.existsSync(IS_WIN ? wrapperWin : wrapperUnix)
142
+ ? (IS_WIN ? 'gradlew.bat' : './gradlew')
143
+ : (commandExists('gradle') ? 'gradle' : null);
144
+ return [runCommand({ dir: root, label: '(root)', cmd: gradleCmd ? `${gradleCmd} test` : null, suite: 'Gradle', parseOutput: parseGradleOutput })];
159
145
  }
160
146
  },
161
147
 
162
- // ── PHP: PHPUnit ───────────────────────────────────────────────────────────
148
+ // ── PHPUnit ─────────────────────────────────────────────────────────────────
163
149
  {
164
150
  name: 'PHPUnit',
165
151
  detect(root) {
166
- return fs.existsSync(path.join(root, 'phpunit.xml')) ||
167
- fs.existsSync(path.join(root, 'phpunit.xml.dist')) ||
168
- 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')) ||
169
155
  hasSourceExt(root, '.php');
170
156
  },
171
157
  run(root) {
172
- const cmd = fs.existsSync(path.join(root, 'vendor/bin/phpunit')) ? './vendor/bin/phpunit --testdox 2>&1'
173
- : commandExists('phpunit') ? 'phpunit --testdox 2>&1' : null;
158
+ const vendorBin = path.join(root, 'vendor', 'bin', 'phpunit');
159
+ const cmd = fs.existsSync(vendorBin) ? `"${vendorBin}" --testdox`
160
+ : commandExists('phpunit') ? 'phpunit --testdox' : null;
174
161
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'PHPUnit', parseOutput: parsePHPUnitOutput })];
175
162
  }
176
163
  },
177
164
 
178
- // ── Ruby: RSpec ────────────────────────────────────────────────────────────
165
+ // ── RSpec ───────────────────────────────────────────────────────────────────
179
166
  {
180
167
  name: 'RSpec',
181
168
  detect(root) {
182
- return fs.existsSync(path.join(root, 'Gemfile')) ||
183
- fs.existsSync(path.join(root, '.rspec')) ||
169
+ return fs.existsSync(path.join(root, 'Gemfile')) ||
170
+ fs.existsSync(path.join(root, '.rspec')) ||
184
171
  fs.existsSync(path.join(root, 'spec'));
185
172
  },
186
173
  run(root) {
187
- const cmd = commandExists('rspec') ? 'rspec --format progress 2>&1' : (commandExists('bundle') ? 'bundle exec rspec --format progress 2>&1' : null);
174
+ const cmd = commandExists('rspec') ? 'rspec --format documentation' : (commandExists('bundle') ? 'bundle exec rspec --format documentation' : null);
188
175
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'RSpec', parseOutput: parseRSpecOutput })];
189
176
  }
190
177
  },
191
178
 
192
- // ── Dart/Flutter ───────────────────────────────────────────────────────────
179
+ // ── Flutter/Dart ────────────────────────────────────────────────────────────
193
180
  {
194
181
  name: 'Flutter/Dart test',
195
182
  detect(root) { return fs.existsSync(path.join(root, 'pubspec.yaml')); },
196
183
  run(root) {
197
- const cmd = commandExists('flutter') ? 'flutter test 2>&1' : (commandExists('dart') ? 'dart test 2>&1' : null);
184
+ const cmd = commandExists('flutter') ? 'flutter test --reporter expanded' : (commandExists('dart') ? 'dart test --reporter expanded' : null);
198
185
  return [runCommand({ dir: root, label: '(root)', cmd, suite: 'Flutter/Dart', parseOutput: parseDartOutput })];
199
186
  }
200
187
  },
201
188
 
202
- // ── .NET: dotnet test ──────────────────────────────────────────────────────
189
+ // ── dotnet test ─────────────────────────────────────────────────────────────
203
190
  {
204
191
  name: 'dotnet test',
205
192
  detect(root) {
206
- try {
207
- return fs.readdirSync(root).some(f => f.endsWith('.csproj') || f.endsWith('.sln'));
208
- } catch { return false; }
193
+ try { return fs.readdirSync(root).some(f => f.endsWith('.csproj') || f.endsWith('.sln')); }
194
+ catch { return false; }
195
+ },
196
+ run(root) {
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));
209
236
  },
210
237
  run(root) {
211
- const cmd = commandExists('dotnet') ? 'dotnet test --logger "console;verbosity=normal" 2>&1' : null;
212
- return [runCommand({ dir: root, label: '(root)', cmd, suite: 'dotnet test', parseOutput: parseDotnetOutput })];
238
+ return runDSATests(root);
213
239
  }
214
240
  },
215
241
  ];
216
242
 
217
- // ─── Core runner ─────────────────────────────────────────────────────────────
243
+ // ─── Core ─────────────────────────────────────────────────────────────────────
218
244
 
219
245
  function runAllTests(rootDir) {
220
246
  const results = [];
221
- const activeRunners = RUNNERS.filter(r => r.detect(rootDir));
247
+ const activeRunners = RUNNERS.filter(r => { try { return r.detect(rootDir); } catch { return false; } });
222
248
 
223
249
  if (activeRunners.length === 0) {
224
250
  return [{
225
251
  suite: 'No test framework detected',
226
- status: 'skipped',
227
- passed: 0, failed: 0, skipped: 0,
228
- 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.',
229
254
  duration: 0,
230
255
  }];
231
256
  }
232
257
 
233
258
  for (const runner of activeRunners) {
234
259
  try {
235
- const suiteResults = runner.run(rootDir);
236
- results.push(...suiteResults);
260
+ results.push(...runner.run(rootDir));
237
261
  } catch (err) {
238
- results.push({
239
- suite: runner.name,
240
- status: 'error',
241
- passed: 0, failed: 0, skipped: 0,
242
- output: '',
243
- error: err.message,
244
- duration: 0,
245
- });
262
+ results.push({ suite: runner.name, status: 'error', passed: 0, failed: 0, skipped: 0, output: '', error: err.message, duration: 0 });
246
263
  }
247
264
  }
248
265
  return results;
249
266
  }
250
267
 
251
- // ─── Jest runner (special: uses JSON reporter) ────────────────────────────────
268
+ // ─── Jest (JSON reporter → structured testCases) ──────────────────────────────
252
269
 
253
270
  function runJest(dir, label) {
254
271
  const start = Date.now();
255
- if (!fs.existsSync(path.join(dir, 'node_modules', '.bin', 'jest')) &&
272
+ if (!fs.existsSync(path.join(dir, 'node_modules', '.bin', 'jest')) &&
273
+ !fs.existsSync(path.join(dir, 'node_modules', '.bin', 'jest.cmd')) &&
256
274
  !fs.existsSync(path.join(dir, 'node_modules', 'jest'))) {
257
- // Try npm test fallback
258
275
  const pkg = tryReadJSON(path.join(dir, 'package.json'));
259
- if (!pkg?.scripts?.test) {
260
- return { suite: `Jest (${label})`, status: 'skipped', passed: 0, failed: 0, skipped: 0, output: 'Jest not installed. Run npm install first.', duration: 0 };
261
- }
276
+ if (!pkg?.scripts?.test) return { suite: `Jest (${label})`, status: 'skipped', passed: 0, failed: 0, skipped: 0, output: 'Jest not installed.', duration: 0 };
262
277
  }
263
278
 
264
279
  const result = spawnSync('npx', ['jest', '--no-coverage', '--json', '--forceExit'], {
265
- cwd: dir, encoding: 'utf8', timeout: 120000,
280
+ cwd: dir, encoding: 'utf8', timeout: 120000, shell: true,
266
281
  env: { ...process.env, FORCE_COLOR: '0' }
267
282
  });
268
283
 
269
284
  const duration = Date.now() - start;
270
285
  const rawOutput = (result.stdout || '') + (result.stderr || '');
271
286
 
272
- // Try to parse JSON output
273
287
  const jsonStart = rawOutput.indexOf('{');
274
288
  const jsonEnd = rawOutput.lastIndexOf('}');
275
289
  if (jsonStart !== -1 && jsonEnd !== -1) {
276
290
  try {
277
- const jResult = JSON.parse(rawOutput.slice(jsonStart, jsonEnd + 1));
278
- const passed = jResult.numPassedTests || 0;
279
- const failed = jResult.numFailedTests || 0;
280
- const skipped = jResult.numPendingTests || 0;
281
- const status = failed > 0 ? 'failed' : (passed > 0 ? 'passed' : 'skipped');
282
-
283
- // Build structured per-test case list (LeetCode style)
284
- // 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;
285
295
  const testCases = [];
286
- const lines = [];
287
- for (const suite of (jResult.testResults || [])) {
296
+ for (const suite of (jr.testResults || [])) {
288
297
  const relPath = path.relative(dir, suite.testFilePath || suite.name || '');
289
- lines.push(`\n 📄 ${relPath}`);
290
- const assertions = suite.assertionResults || suite.testResults || [];
291
- for (const t of assertions) {
292
- const icon = t.status === 'passed' ? ' ✔' : t.status === 'failed' ? ' ✖' : ' ○';
293
- lines.push(`${icon} ${t.fullName || t.title}`);
294
- if (t.status === 'failed' && t.failureMessages?.length) {
295
- lines.push(` ${t.failureMessages[0].split('\n').slice(0, 3).join('\n ')}`);
296
- }
298
+ for (const t of (suite.assertionResults || suite.testResults || [])) {
297
299
  testCases.push({
298
300
  file: relPath,
299
301
  name: t.fullName || t.title || '(unnamed)',
300
- status: t.status, // 'passed' | 'failed' | 'pending'
302
+ status: t.status,
301
303
  duration: t.duration || 0,
302
304
  error: t.status === 'failed' && t.failureMessages?.length
303
- ? t.failureMessages[0].split('\n').slice(0, 6).join('\n')
304
- : null,
305
+ ? t.failureMessages[0].split('\n').slice(0, 6).join('\n') : null,
305
306
  });
306
307
  }
307
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 || '');
308
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;
309
351
  return {
310
- suite: `Jest (${label})`,
311
- status,
312
- passed, failed, skipped,
313
- duration,
314
- output: lines.join('\n'),
315
- suites: jResult.numPassedTestSuites + '/' + jResult.numTotalTestSuites + ' suites passed',
316
- testCases,
352
+ suite: `Vitest (${label})`, status: failed > 0 ? 'failed' : (passed > 0 ? 'passed' : 'skipped'),
353
+ passed, failed, skipped, duration, testCases, output: buildTextOutput(testCases),
317
354
  };
318
- } catch { /* fall through to text parse */ }
355
+ } catch {}
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) ────────────────────────────
362
+
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
+ };
421
+ }
422
+
423
+ // ─── Go test (JSON output → structured testCases) ────────────────────────────
424
+
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) };
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) {
490
+ const start = Date.now();
491
+ let output = '';
492
+ let exitOk = false;
493
+ try {
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' }
498
+ });
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); }
319
641
  }
320
642
 
321
- return parseJestTextOutput(rawOutput, label, duration);
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;
322
755
  }
323
756
 
324
757
  // ─── Generic command runner ───────────────────────────────────────────────────
325
758
 
326
759
  function runCommand({ dir, label, cmd, suite, parseOutput }) {
327
- if (!cmd) {
328
- return { suite: `${suite} (${label})`, status: 'skipped', passed: 0, failed: 0, skipped: 0, output: `${suite} not installed or not found in PATH.`, duration: 0 };
329
- }
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 };
330
761
  const start = Date.now();
331
762
  try {
332
- const output = execSync(cmd, { cwd: dir, encoding: 'utf8', timeout: 120000, env: { ...process.env, FORCE_COLOR: '0' }, stdio: 'pipe' });
333
- const duration = Date.now() - start;
334
- return { suite: `${suite} (${label})`, duration, ...parseOutput(output, true) };
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) };
335
765
  } catch (err) {
336
- const duration = Date.now() - start;
337
766
  const output = (err.stdout || '') + (err.stderr || '') + (err.message || '');
338
- return { suite: `${suite} (${label})`, duration, ...parseOutput(output, false) };
767
+ return { suite: `${suite} (${label})`, duration: Date.now() - start, ...parseOutput(output, false) };
339
768
  }
340
769
  }
341
770
 
342
- // ─── 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
+ }
343
781
 
344
- function parseJestTextOutput(output, label, duration) {
345
- const passMatch = output.match(/(\d+)\s+passed/);
346
- const failMatch = output.match(/(\d+)\s+failed/);
347
- const skipMatch = output.match(/(\d+)\s+(?:skipped|pending)/);
348
- const passed = passMatch ? parseInt(passMatch[1]) : 0;
349
- const failed = failMatch ? parseInt(failMatch[1]) : 0;
350
- 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);
351
786
  return { suite: `Jest (${label})`, status: failed > 0 ? 'failed' : (passed > 0 ? 'passed' : 'unknown'), passed, failed, skipped, duration, output: output.slice(-3000) };
352
787
  }
353
788
 
354
789
  function parsePytestOutput(output, exitOk) {
355
- const m = output.match(/(\d+)\s+passed(?:,\s*(\d+)\s+failed)?(?:,\s*(\d+)\s+(?:warning|skipped))?/);
356
- const failed = output.match(/(\d+)\s+failed/)?.[1];
357
- const passed = m?.[1] || '0';
358
- const skipped = output.match(/(\d+)\s+(?:skipped|deselected)/)?.[1] || '0';
359
- const status = parseInt(failed || '0') > 0 ? 'failed' : (exitOk ? 'passed' : 'failed');
360
- 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) };
361
794
  }
362
795
 
363
796
  function parseGoTestOutput(output, exitOk) {
@@ -369,78 +802,136 @@ function parseGoTestOutput(output, exitOk) {
369
802
 
370
803
  function parseCargoOutput(output, exitOk) {
371
804
  const m = output.match(/test result:.*?(\d+)\s+passed;\s*(\d+)\s+failed;\s*(\d+)\s+ignored/);
372
- 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) };
373
806
  }
374
807
 
375
808
  function parseMavenOutput(output, exitOk) {
376
809
  const m = output.match(/Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)/);
377
- const failed = m ? parseInt(m[2]) + parseInt(m[3]) : 0;
378
- const passed = m ? parseInt(m[1]) - failed - parseInt(m[4]) : 0;
379
- const skipped = m ? parseInt(m[4]) : 0;
380
- 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) };
381
813
  }
382
814
 
383
815
  function parseGradleOutput(output, exitOk) {
384
816
  const m = output.match(/(\d+)\s+tests?\s+completed(?:,\s*(\d+)\s+failed)?/);
385
- const failed = m?.[2] ? parseInt(m[2]) : 0;
386
- const total = m ? parseInt(m[1]) : 0;
387
- 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) };
388
819
  }
389
820
 
390
821
  function parseMochaOutput(output, exitOk) {
391
- const passMatch = output.match(/(\d+)\s+passing/);
392
- const failMatch = output.match(/(\d+)\s+failing/);
393
- const pending = output.match(/(\d+)\s+pending/);
394
- const passed = passMatch ? parseInt(passMatch[1]) : 0;
395
- const failed = failMatch ? parseInt(failMatch[1]) : 0;
396
- const skipped = pending ? parseInt(pending[1]) : 0;
397
- 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) };
398
834
  }
399
835
 
400
836
  function parseVitestOutput(output, exitOk) {
401
- const passMatch = output.match(/(\d+)\s+(?:test[s]?\s+)?passed/i);
402
- const failMatch = output.match(/(\d+)\s+(?:test[s]?\s+)?failed/i);
403
- const passed = passMatch ? parseInt(passMatch[1]) : 0;
404
- 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);
405
839
  return { status: failed > 0 ? 'failed' : (exitOk ? 'passed' : 'failed'), passed, failed, skipped: 0, output: output.slice(-3000) };
406
840
  }
407
841
 
408
842
  function parsePHPUnitOutput(output, exitOk) {
409
843
  const m = output.match(/OK\s+\((\d+)\s+tests?/);
410
844
  const f = output.match(/FAILURES!\s*\nTests:\s*(\d+),.*?Failures:\s*(\d+)/s);
411
- if (f) return { status: 'failed', passed: parseInt(f[1]) - parseInt(f[2]), failed: parseInt(f[2]), skipped: 0, output: output.slice(-3000) };
412
- 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) };
413
847
  }
414
848
 
415
849
  function parseRSpecOutput(output, exitOk) {
416
850
  const m = output.match(/(\d+)\s+examples?,\s*(\d+)\s+failures?/);
417
- 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) };
418
864
  }
419
865
 
420
866
  function parseDartOutput(output, exitOk) {
421
- const passMatch = output.match(/(\d+) tests? passed/);
422
- const failMatch = output.match(/(\d+) tests? failed/);
423
- const passed = passMatch ? parseInt(passMatch[1]) : 0;
424
- const failed = failMatch ? parseInt(failMatch[1]) : 0;
425
- 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) };
426
877
  }
427
878
 
428
879
  function parseDotnetOutput(output, exitOk) {
429
880
  const m = output.match(/Passed!\s+-\s+Failed:\s*(\d+),\s*Passed:\s*(\d+),\s*Skipped:\s*(\d+)/);
430
- 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) };
431
- const f = output.match(/Failed:\s*(\d+)/); const p = output.match(/Passed:\s*(\d+)/);
432
- 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) };
433
885
  }
434
886
 
435
887
  // ─── Utility helpers ──────────────────────────────────────────────────────────
436
888
 
437
889
  function tryReadJSON(p) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } }
438
- function allDeps(pkg) { return [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.devDependencies || {}), ...Object.keys(pkg.peerDependencies || {})]; }
439
- function commandExists(cmd) { try { execSync(`which ${cmd} 2>/dev/null || command -v ${cmd} 2>/dev/null`, { encoding: 'utf8' }).trim(); return true; } catch { return false; } }
440
- function hasBin(dir, bin) { return fs.existsSync(path.join(dir, 'node_modules', '.bin', bin)); }
890
+ function allDeps(pkg) { return [...Object.keys(pkg.dependencies||{}), ...Object.keys(pkg.devDependencies||{}), ...Object.keys(pkg.peerDependencies||{})]; }
891
+
892
+ function commandExists(cmd) {
893
+ try {
894
+ if (IS_WIN) {
895
+ execSync(`where ${cmd}`, { encoding: 'utf8', stdio: 'pipe' });
896
+ } else {
897
+ execSync(`command -v ${cmd}`, { encoding: 'utf8', stdio: 'pipe', shell: '/bin/sh' });
898
+ }
899
+ return true;
900
+ } catch { return false; }
901
+ }
902
+
903
+ function hasBin(dir, bin) {
904
+ return fs.existsSync(path.join(dir, 'node_modules', '.bin', bin)) ||
905
+ fs.existsSync(path.join(dir, 'node_modules', '.bin', bin + '.cmd'));
906
+ }
907
+
441
908
  function hasSourceExt(root, ext) {
442
909
  try { return fs.readdirSync(root).some(f => f.endsWith(ext)); } catch { return false; }
443
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
+
444
935
  function findSubPackages(root) {
445
936
  try {
446
937
  return fs.readdirSync(root, { withFileTypes: true })
@@ -449,6 +940,7 @@ function findSubPackages(root) {
449
940
  .filter(p => fs.existsSync(p));
450
941
  } catch { return []; }
451
942
  }
943
+
452
944
  function findSubDirs(root) {
453
945
  try {
454
946
  return fs.readdirSync(root, { withFileTypes: true })