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.
- package/lib/scanner.js +313 -108
- package/lib/test-runner.js +679 -187
- package/package.json +1 -1
package/lib/test-runner.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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 =>
|
|
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
|
-
// ──
|
|
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
|
-
// ──
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
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)];
|
|
108
98
|
}
|
|
109
99
|
},
|
|
110
100
|
|
|
111
|
-
// ── Go
|
|
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 [
|
|
117
|
-
|
|
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
|
-
// ──
|
|
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 [
|
|
131
|
-
|
|
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
|
-
// ──
|
|
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
|
|
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
|
-
// ──
|
|
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
|
|
158
|
-
|
|
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
|
-
// ──
|
|
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
|
|
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
|
|
173
|
-
|
|
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
|
-
// ──
|
|
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
|
|
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
|
|
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
|
|
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
|
-
// ──
|
|
189
|
+
// ── dotnet test ─────────────────────────────────────────────────────────────
|
|
203
190
|
{
|
|
204
191
|
name: 'dotnet test',
|
|
205
192
|
detect(root) {
|
|
206
|
-
try {
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
278
|
-
const passed =
|
|
279
|
-
const failed =
|
|
280
|
-
const skipped =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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: `
|
|
311
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
const
|
|
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
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
|
|
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 &&
|
|
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 ?
|
|
378
|
-
const passed = m ?
|
|
379
|
-
|
|
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
|
|
386
|
-
|
|
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
|
|
392
|
-
const
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
const
|
|
397
|
-
|
|
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
|
|
402
|
-
const
|
|
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:
|
|
412
|
-
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) };
|
|
413
847
|
}
|
|
414
848
|
|
|
415
849
|
function parseRSpecOutput(output, exitOk) {
|
|
416
850
|
const m = output.match(/(\d+)\s+examples?,\s*(\d+)\s+failures?/);
|
|
417
|
-
|
|
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
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
|
|
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:
|
|
431
|
-
const f = output.match(/Failed:\s*(\d+)/);
|
|
432
|
-
|
|
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
|
|
439
|
-
|
|
440
|
-
function
|
|
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 })
|