project-scan 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/scanner.js +313 -108
- package/lib/test-runner.js +651 -189
- package/package.json +1 -1
package/lib/test-runner.js
CHANGED
|
@@ -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
|
|
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
|
-
// ───
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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 =>
|
|
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
|
-
// ──
|
|
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
|
-
// ──
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return [
|
|
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
|
|
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 [
|
|
119
|
-
|
|
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
|
-
// ──
|
|
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 [
|
|
133
|
-
|
|
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
|
-
// ──
|
|
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
|
-
// ──
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
|
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
|
|
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
|
-
// ──
|
|
189
|
+
// ── dotnet test ─────────────────────────────────────────────────────────────
|
|
212
190
|
{
|
|
213
191
|
name: 'dotnet test',
|
|
214
192
|
detect(root) {
|
|
215
|
-
try {
|
|
216
|
-
|
|
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
|
-
|
|
221
|
-
return [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
288
|
-
const passed =
|
|
289
|
-
const failed =
|
|
290
|
-
const skipped =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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: `
|
|
321
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
// ───
|
|
423
|
+
// ─── Go test (JSON output → structured testCases) ────────────────────────────
|
|
335
424
|
|
|
336
|
-
function
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
//
|
|
343
|
-
|
|
344
|
-
cwd:
|
|
345
|
-
env: { ...process.env,
|
|
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
|
-
|
|
348
|
-
|
|
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
|
|
359
|
-
const
|
|
360
|
-
const
|
|
361
|
-
const
|
|
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
|
|
370
|
-
const
|
|
371
|
-
const
|
|
372
|
-
|
|
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 &&
|
|
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 ?
|
|
392
|
-
const passed = m ?
|
|
393
|
-
|
|
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
|
|
400
|
-
|
|
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
|
|
406
|
-
const
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
const
|
|
411
|
-
|
|
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
|
|
416
|
-
const
|
|
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:
|
|
426
|
-
return { status: exitOk ? 'passed' : 'failed', passed: m ?
|
|
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
|
-
|
|
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
|
|
436
|
-
const
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
|
|
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:
|
|
445
|
-
const f = output.match(/Failed:\s*(\d+)/);
|
|
446
|
-
|
|
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
|
|
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 })
|