neuro-cli 4.1.0
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/LICENSE +21 -0
- package/README.md +510 -0
- package/dist/advisor/advisor.d.ts +50 -0
- package/dist/advisor/advisor.js +178 -0
- package/dist/agents/base.d.ts +62 -0
- package/dist/agents/base.js +215 -0
- package/dist/agents/orchestrator.d.ts +46 -0
- package/dist/agents/orchestrator.js +192 -0
- package/dist/agents/team.d.ts +51 -0
- package/dist/agents/team.js +210 -0
- package/dist/api/models.d.ts +23 -0
- package/dist/api/models.js +514 -0
- package/dist/api/ollama.d.ts +153 -0
- package/dist/api/ollama.js +751 -0
- package/dist/api/openrouter.d.ts +55 -0
- package/dist/api/openrouter.js +223 -0
- package/dist/commands/commands.d.ts +43 -0
- package/dist/commands/commands.js +308 -0
- package/dist/config/config.d.ts +8 -0
- package/dist/config/config.js +311 -0
- package/dist/context/compaction.d.ts +54 -0
- package/dist/context/compaction.js +251 -0
- package/dist/context/custom-agents.d.ts +107 -0
- package/dist/context/custom-agents.js +397 -0
- package/dist/context/custom-tools.d.ts +120 -0
- package/dist/context/custom-tools.js +564 -0
- package/dist/context/git-checkpoint.d.ts +52 -0
- package/dist/context/git-checkpoint.js +240 -0
- package/dist/context/neuro-md.d.ts +48 -0
- package/dist/context/neuro-md.js +202 -0
- package/dist/context/neuroignore.d.ts +102 -0
- package/dist/context/neuroignore.js +441 -0
- package/dist/context/repo-map.d.ts +38 -0
- package/dist/context/repo-map.js +220 -0
- package/dist/context/skill-standard.d.ts +262 -0
- package/dist/context/skill-standard.js +1156 -0
- package/dist/context/skill-system.d.ts +75 -0
- package/dist/context/skill-system.js +578 -0
- package/dist/context/tree-sitter.d.ts +99 -0
- package/dist/context/tree-sitter.js +1956 -0
- package/dist/core/acp.d.ts +325 -0
- package/dist/core/acp.js +1498 -0
- package/dist/core/api-server.d.ts +143 -0
- package/dist/core/api-server.js +550 -0
- package/dist/core/approval.d.ts +81 -0
- package/dist/core/approval.js +432 -0
- package/dist/core/auto-compact.d.ts +127 -0
- package/dist/core/auto-compact.js +436 -0
- package/dist/core/auto-mode.d.ts +232 -0
- package/dist/core/auto-mode.js +831 -0
- package/dist/core/background-session.d.ts +166 -0
- package/dist/core/background-session.js +696 -0
- package/dist/core/cicd.d.ts +257 -0
- package/dist/core/cicd.js +1443 -0
- package/dist/core/cloud-sync.d.ts +156 -0
- package/dist/core/cloud-sync.js +582 -0
- package/dist/core/code-review.d.ts +132 -0
- package/dist/core/code-review.js +1191 -0
- package/dist/core/completion.d.ts +49 -0
- package/dist/core/completion.js +384 -0
- package/dist/core/context.d.ts +38 -0
- package/dist/core/context.js +144 -0
- package/dist/core/diff-preview.d.ts +35 -0
- package/dist/core/diff-preview.js +173 -0
- package/dist/core/doom-loop.d.ts +51 -0
- package/dist/core/doom-loop.js +179 -0
- package/dist/core/engine.d.ts +183 -0
- package/dist/core/engine.js +942 -0
- package/dist/core/extended-thinking.d.ts +103 -0
- package/dist/core/extended-thinking.js +269 -0
- package/dist/core/fallback.d.ts +54 -0
- package/dist/core/fallback.js +104 -0
- package/dist/core/git-worktree.d.ts +139 -0
- package/dist/core/git-worktree.js +614 -0
- package/dist/core/headless.d.ts +30 -0
- package/dist/core/headless.js +101 -0
- package/dist/core/i18n.d.ts +83 -0
- package/dist/core/i18n.js +739 -0
- package/dist/core/linting.d.ts +129 -0
- package/dist/core/linting.js +699 -0
- package/dist/core/model-router.d.ts +109 -0
- package/dist/core/model-router.js +581 -0
- package/dist/core/multi-model.d.ts +243 -0
- package/dist/core/multi-model.js +1099 -0
- package/dist/core/multi-session.d.ts +144 -0
- package/dist/core/multi-session.js +442 -0
- package/dist/core/multimodal.d.ts +125 -0
- package/dist/core/multimodal.js +286 -0
- package/dist/core/observability.d.ts +93 -0
- package/dist/core/observability.js +737 -0
- package/dist/core/os-sandbox.d.ts +122 -0
- package/dist/core/os-sandbox.js +1193 -0
- package/dist/core/outcome-grading.d.ts +228 -0
- package/dist/core/outcome-grading.js +1123 -0
- package/dist/core/output-styles.d.ts +57 -0
- package/dist/core/output-styles.js +382 -0
- package/dist/core/parallel-agents.d.ts +183 -0
- package/dist/core/parallel-agents.js +563 -0
- package/dist/core/plugin-bundle.d.ts +236 -0
- package/dist/core/plugin-bundle.js +887 -0
- package/dist/core/plugin-sdk.d.ts +139 -0
- package/dist/core/plugin-sdk.js +273 -0
- package/dist/core/prompt-cache.d.ts +163 -0
- package/dist/core/prompt-cache.js +599 -0
- package/dist/core/sandbox.d.ts +127 -0
- package/dist/core/sandbox.js +369 -0
- package/dist/core/scheduled-tasks.d.ts +151 -0
- package/dist/core/scheduled-tasks.js +502 -0
- package/dist/core/security-scanner.d.ts +160 -0
- package/dist/core/security-scanner.js +1494 -0
- package/dist/core/session.d.ts +83 -0
- package/dist/core/session.js +269 -0
- package/dist/core/shell-completion.d.ts +51 -0
- package/dist/core/shell-completion.js +674 -0
- package/dist/core/smart-monitor.d.ts +146 -0
- package/dist/core/smart-monitor.js +1199 -0
- package/dist/core/spec-driven.d.ts +233 -0
- package/dist/core/spec-driven.js +1485 -0
- package/dist/core/spending-warnings.d.ts +123 -0
- package/dist/core/spending-warnings.js +456 -0
- package/dist/core/sub-agent.d.ts +298 -0
- package/dist/core/sub-agent.js +1023 -0
- package/dist/core/telemetry.d.ts +157 -0
- package/dist/core/telemetry.js +412 -0
- package/dist/core/terminal-ux.d.ts +134 -0
- package/dist/core/terminal-ux.js +649 -0
- package/dist/core/testing.d.ts +146 -0
- package/dist/core/testing.js +1200 -0
- package/dist/core/types.d.ts +439 -0
- package/dist/core/types.js +6 -0
- package/dist/core/undo-redo.d.ts +112 -0
- package/dist/core/undo-redo.js +290 -0
- package/dist/core/updater.d.ts +159 -0
- package/dist/core/updater.js +608 -0
- package/dist/core/vim-mode.d.ts +151 -0
- package/dist/core/vim-mode.js +771 -0
- package/dist/core/voice.d.ts +137 -0
- package/dist/core/voice.js +538 -0
- package/dist/core/web-dashboard.d.ts +109 -0
- package/dist/core/web-dashboard.js +484 -0
- package/dist/hooks/hooks.d.ts +74 -0
- package/dist/hooks/hooks.js +160 -0
- package/dist/hooks/llm-evaluator.d.ts +165 -0
- package/dist/hooks/llm-evaluator.js +560 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1186 -0
- package/dist/lsp/lsp-manager.d.ts +63 -0
- package/dist/lsp/lsp-manager.js +351 -0
- package/dist/mcp/client.d.ts +133 -0
- package/dist/mcp/client.js +684 -0
- package/dist/mcp/mcp-apps.d.ts +70 -0
- package/dist/mcp/mcp-apps.js +1007 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +195 -0
- package/dist/tools/browser.d.ts +92 -0
- package/dist/tools/browser.js +1570 -0
- package/dist/tools/extended.d.ts +6 -0
- package/dist/tools/extended.js +191 -0
- package/dist/tools/file.d.ts +10 -0
- package/dist/tools/file.js +382 -0
- package/dist/tools/github.d.ts +389 -0
- package/dist/tools/github.js +759 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +40 -0
- package/dist/tools/memory.d.ts +6 -0
- package/dist/tools/memory.js +197 -0
- package/dist/tools/registry.d.ts +29 -0
- package/dist/tools/registry.js +64 -0
- package/dist/tools/web.d.ts +6 -0
- package/dist/tools/web.js +150 -0
- package/dist/ui/renderer.d.ts +97 -0
- package/dist/ui/renderer.js +279 -0
- package/dist/ui/theme.d.ts +27 -0
- package/dist/ui/theme.js +106 -0
- package/package.json +83 -0
|
@@ -0,0 +1,1200 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// NeuroCLI - Testing Integration
|
|
3
|
+
// Automatic test framework detection, execution, and coverage
|
|
4
|
+
// Supports Jest, Vitest, pytest, Go test, cargo test,
|
|
5
|
+
// JUnit, Playwright, Cypress, and standard test runners
|
|
6
|
+
// ============================================================
|
|
7
|
+
import { join, resolve, dirname, extname, basename, relative } from 'path';
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
|
|
9
|
+
import { execSync, spawn } from 'child_process';
|
|
10
|
+
// -----------------------------------------------------------
|
|
11
|
+
// Test framework definitions
|
|
12
|
+
// -----------------------------------------------------------
|
|
13
|
+
const TEST_FRAMEWORKS = [
|
|
14
|
+
// Jest
|
|
15
|
+
{
|
|
16
|
+
name: 'jest',
|
|
17
|
+
language: 'javascript',
|
|
18
|
+
configFiles: [
|
|
19
|
+
'jest.config.js',
|
|
20
|
+
'jest.config.cjs',
|
|
21
|
+
'jest.config.mjs',
|
|
22
|
+
'jest.config.ts',
|
|
23
|
+
'jest.config.mts',
|
|
24
|
+
'jest.config.cts',
|
|
25
|
+
],
|
|
26
|
+
commands: {
|
|
27
|
+
run: ['npx', 'jest', '--json', '--outputFile=/dev/stdout'],
|
|
28
|
+
watch: ['npx', 'jest', '--watch'],
|
|
29
|
+
coverage: ['npx', 'jest', '--coverage', '--json', '--outputFile=/dev/stdout'],
|
|
30
|
+
},
|
|
31
|
+
parseOutput: (stdout, _stderr, rootDir) => {
|
|
32
|
+
const result = {
|
|
33
|
+
total: 0,
|
|
34
|
+
passed: 0,
|
|
35
|
+
failed: 0,
|
|
36
|
+
skipped: 0,
|
|
37
|
+
duration: 0,
|
|
38
|
+
failures: [],
|
|
39
|
+
};
|
|
40
|
+
try {
|
|
41
|
+
// Try to find JSON in the output (Jest mixes JSON with other output)
|
|
42
|
+
const jsonMatch = stdout.match(/\{[\s\S]*"numPassedTests"[\s\S]*\}/);
|
|
43
|
+
if (!jsonMatch)
|
|
44
|
+
return result;
|
|
45
|
+
const data = JSON.parse(jsonMatch[0]);
|
|
46
|
+
result.total = data.numTotalTests;
|
|
47
|
+
result.passed = data.numPassedTests;
|
|
48
|
+
result.failed = data.numFailedTests;
|
|
49
|
+
result.skipped = data.numPendingTests;
|
|
50
|
+
result.duration = Date.now();
|
|
51
|
+
for (const testFile of data.testResults) {
|
|
52
|
+
for (const assertion of testFile.assertionResults) {
|
|
53
|
+
if (assertion.status === 'failed') {
|
|
54
|
+
const errorBody = assertion.failureMessages[0] ?? 'Unknown error';
|
|
55
|
+
const { message, expected, actual, line } = parseJestError(errorBody);
|
|
56
|
+
result.failures.push({
|
|
57
|
+
testName: assertion.fullName,
|
|
58
|
+
file: relative(rootDir, testFile.name),
|
|
59
|
+
line: assertion.location?.line ?? line,
|
|
60
|
+
error: message,
|
|
61
|
+
expected,
|
|
62
|
+
actual,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Coverage
|
|
68
|
+
if (data.coverageMap) {
|
|
69
|
+
result.coverage = parseJestCoverage(data.coverageMap, rootDir);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Fallback: try to parse text output
|
|
74
|
+
result.total = (stdout.match(/\d+ test(s)? passed/g) ?? []).length > 0
|
|
75
|
+
? parseInt(stdout.match(/Tests:\s+(\d+)/)?.[1] ?? '0', 10)
|
|
76
|
+
: 0;
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
// Vitest
|
|
82
|
+
{
|
|
83
|
+
name: 'vitest',
|
|
84
|
+
language: 'javascript',
|
|
85
|
+
configFiles: [
|
|
86
|
+
'vitest.config.js',
|
|
87
|
+
'vitest.config.cjs',
|
|
88
|
+
'vitest.config.mjs',
|
|
89
|
+
'vitest.config.ts',
|
|
90
|
+
'vitest.config.mts',
|
|
91
|
+
],
|
|
92
|
+
commands: {
|
|
93
|
+
run: ['npx', 'vitest', 'run', '--reporter=json'],
|
|
94
|
+
watch: ['npx', 'vitest', '--watch'],
|
|
95
|
+
coverage: ['npx', 'vitest', 'run', '--coverage', '--reporter=json'],
|
|
96
|
+
},
|
|
97
|
+
parseOutput: (stdout, _stderr, rootDir) => {
|
|
98
|
+
const result = {
|
|
99
|
+
total: 0,
|
|
100
|
+
passed: 0,
|
|
101
|
+
failed: 0,
|
|
102
|
+
skipped: 0,
|
|
103
|
+
duration: 0,
|
|
104
|
+
failures: [],
|
|
105
|
+
};
|
|
106
|
+
try {
|
|
107
|
+
const data = JSON.parse(stdout);
|
|
108
|
+
result.total = data.numTotalTests;
|
|
109
|
+
result.passed = data.numPassedTests;
|
|
110
|
+
result.failed = data.numFailedTests;
|
|
111
|
+
result.skipped = data.numPendingTests;
|
|
112
|
+
result.duration = Date.now();
|
|
113
|
+
for (const testFile of data.testResults) {
|
|
114
|
+
for (const assertion of testFile.assertionResults) {
|
|
115
|
+
if (assertion.status === 'failed') {
|
|
116
|
+
result.failures.push({
|
|
117
|
+
testName: assertion.fullName,
|
|
118
|
+
file: relative(rootDir, testFile.name),
|
|
119
|
+
line: assertion.location?.line,
|
|
120
|
+
error: assertion.failureMessages[0] ?? 'Unknown error',
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Parse error
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
// pytest
|
|
133
|
+
{
|
|
134
|
+
name: 'pytest',
|
|
135
|
+
language: 'python',
|
|
136
|
+
configFiles: [
|
|
137
|
+
'pytest.ini',
|
|
138
|
+
'pyproject.toml',
|
|
139
|
+
'tox.ini',
|
|
140
|
+
'setup.cfg',
|
|
141
|
+
'conftest.py',
|
|
142
|
+
],
|
|
143
|
+
commands: {
|
|
144
|
+
run: ['pytest', '--json-report', '--json-report-file=/dev/stdout'],
|
|
145
|
+
coverage: ['pytest', '--cov', '--cov-report=term-missing'],
|
|
146
|
+
},
|
|
147
|
+
parseOutput: (stdout, _stderr, rootDir) => {
|
|
148
|
+
const result = {
|
|
149
|
+
total: 0,
|
|
150
|
+
passed: 0,
|
|
151
|
+
failed: 0,
|
|
152
|
+
skipped: 0,
|
|
153
|
+
duration: 0,
|
|
154
|
+
failures: [],
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
// Try JSON report first
|
|
158
|
+
const data = JSON.parse(stdout);
|
|
159
|
+
result.total = data.summary.total;
|
|
160
|
+
result.passed = data.summary.passed;
|
|
161
|
+
result.failed = data.summary.failed;
|
|
162
|
+
result.skipped = data.summary.skipped;
|
|
163
|
+
result.duration = Math.round(data.summary.duration * 1000);
|
|
164
|
+
if (data.tests) {
|
|
165
|
+
for (const test of data.tests) {
|
|
166
|
+
if (test.outcome === 'failed') {
|
|
167
|
+
result.failures.push({
|
|
168
|
+
testName: test.name,
|
|
169
|
+
file: relative(rootDir, test.filepath),
|
|
170
|
+
line: test.lineno,
|
|
171
|
+
error: test.call?.longrepr ?? 'Unknown error',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// Fallback: parse text output
|
|
179
|
+
const passedMatch = stdout.match(/(\d+) passed/);
|
|
180
|
+
const failedMatch = stdout.match(/(\d+) failed/);
|
|
181
|
+
const skippedMatch = stdout.match(/(\d+) skipped/);
|
|
182
|
+
result.passed = passedMatch ? parseInt(passedMatch[1], 10) : 0;
|
|
183
|
+
result.failed = failedMatch ? parseInt(failedMatch[1], 10) : 0;
|
|
184
|
+
result.skipped = skippedMatch ? parseInt(skippedMatch[1], 10) : 0;
|
|
185
|
+
result.total = result.passed + result.failed + result.skipped;
|
|
186
|
+
// Parse FAILED lines
|
|
187
|
+
const failedLinePattern = /FAILED\s+(\S+)/g;
|
|
188
|
+
let match;
|
|
189
|
+
while ((match = failedLinePattern.exec(stdout)) !== null) {
|
|
190
|
+
result.failures.push({
|
|
191
|
+
testName: match[1],
|
|
192
|
+
file: match[1],
|
|
193
|
+
error: 'Test failed',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
// Go test
|
|
201
|
+
{
|
|
202
|
+
name: 'go-test',
|
|
203
|
+
language: 'go',
|
|
204
|
+
configFiles: ['go.mod'],
|
|
205
|
+
commands: {
|
|
206
|
+
run: ['go', 'test', '-json', './...'],
|
|
207
|
+
coverage: ['go', 'test', '-coverprofile=coverage.out', './...'],
|
|
208
|
+
},
|
|
209
|
+
parseOutput: (stdout, _stderr, rootDir) => {
|
|
210
|
+
const result = {
|
|
211
|
+
total: 0,
|
|
212
|
+
passed: 0,
|
|
213
|
+
failed: 0,
|
|
214
|
+
skipped: 0,
|
|
215
|
+
duration: 0,
|
|
216
|
+
failures: [],
|
|
217
|
+
};
|
|
218
|
+
const lines = stdout.split('\n');
|
|
219
|
+
for (const line of lines) {
|
|
220
|
+
try {
|
|
221
|
+
const entry = JSON.parse(line);
|
|
222
|
+
if (entry.Action === 'pass' && entry.Test) {
|
|
223
|
+
result.total++;
|
|
224
|
+
result.passed++;
|
|
225
|
+
}
|
|
226
|
+
else if (entry.Action === 'fail' && entry.Test) {
|
|
227
|
+
result.total++;
|
|
228
|
+
result.failed++;
|
|
229
|
+
result.failures.push({
|
|
230
|
+
testName: entry.Test,
|
|
231
|
+
file: entry.Package,
|
|
232
|
+
error: 'Test failed',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
else if (entry.Action === 'skip' && entry.Test) {
|
|
236
|
+
result.total++;
|
|
237
|
+
result.skipped++;
|
|
238
|
+
}
|
|
239
|
+
if (entry.Elapsed) {
|
|
240
|
+
result.duration += Math.round(entry.Elapsed * 1000);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// Not a JSON line — skip
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
// cargo test (Rust)
|
|
251
|
+
{
|
|
252
|
+
name: 'cargo-test',
|
|
253
|
+
language: 'rust',
|
|
254
|
+
configFiles: ['Cargo.toml'],
|
|
255
|
+
commands: {
|
|
256
|
+
run: ['cargo', 'test', '--message-format=json'],
|
|
257
|
+
coverage: ['cargo', 'tarpaulin', '--out=Stdout'],
|
|
258
|
+
},
|
|
259
|
+
parseOutput: (stdout, _stderr, rootDir) => {
|
|
260
|
+
const result = {
|
|
261
|
+
total: 0,
|
|
262
|
+
passed: 0,
|
|
263
|
+
failed: 0,
|
|
264
|
+
skipped: 0,
|
|
265
|
+
duration: 0,
|
|
266
|
+
failures: [],
|
|
267
|
+
};
|
|
268
|
+
const lines = stdout.split('\n');
|
|
269
|
+
for (const line of lines) {
|
|
270
|
+
try {
|
|
271
|
+
const entry = JSON.parse(line);
|
|
272
|
+
if (entry.type === 'test') {
|
|
273
|
+
result.total++;
|
|
274
|
+
if (entry.event === 'ok') {
|
|
275
|
+
result.passed++;
|
|
276
|
+
}
|
|
277
|
+
else if (entry.event === 'failed') {
|
|
278
|
+
result.failed++;
|
|
279
|
+
result.failures.push({
|
|
280
|
+
testName: entry.name ?? 'unknown',
|
|
281
|
+
file: 'unknown',
|
|
282
|
+
error: 'Test failed',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
else if (entry.event === 'ignored') {
|
|
286
|
+
result.skipped++;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Not a JSON line — try text parsing
|
|
292
|
+
if (line.includes('test result:')) {
|
|
293
|
+
const passedMatch = line.match(/(\d+) passed/);
|
|
294
|
+
const failedMatch = line.match(/(\d+) failed/);
|
|
295
|
+
const ignoredMatch = line.match(/(\d+) ignored/);
|
|
296
|
+
result.passed += passedMatch ? parseInt(passedMatch[1], 10) : 0;
|
|
297
|
+
result.failed += failedMatch ? parseInt(failedMatch[1], 10) : 0;
|
|
298
|
+
result.skipped += ignoredMatch ? parseInt(ignoredMatch[1], 10) : 0;
|
|
299
|
+
result.total = result.passed + result.failed + result.skipped;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
// JUnit (Java)
|
|
307
|
+
{
|
|
308
|
+
name: 'junit',
|
|
309
|
+
language: 'java',
|
|
310
|
+
configFiles: ['pom.xml', 'build.gradle', 'build.gradle.kts'],
|
|
311
|
+
commands: {
|
|
312
|
+
run: ['mvn', 'test'],
|
|
313
|
+
coverage: ['mvn', 'test', 'jacoco:report'],
|
|
314
|
+
},
|
|
315
|
+
parseOutput: (stdout, _stderr, rootDir) => {
|
|
316
|
+
const result = {
|
|
317
|
+
total: 0,
|
|
318
|
+
passed: 0,
|
|
319
|
+
failed: 0,
|
|
320
|
+
skipped: 0,
|
|
321
|
+
duration: 0,
|
|
322
|
+
failures: [],
|
|
323
|
+
};
|
|
324
|
+
// Parse Maven test output
|
|
325
|
+
const testsRunMatch = stdout.match(/Tests run:\s*(\d+)/);
|
|
326
|
+
const failuresMatch = stdout.match(/Failures:\s*(\d+)/);
|
|
327
|
+
const errorsMatch = stdout.match(/Errors:\s*(\d+)/);
|
|
328
|
+
const skippedMatch = stdout.match(/Skipped:\s*(\d+)/);
|
|
329
|
+
if (testsRunMatch) {
|
|
330
|
+
result.total = parseInt(testsRunMatch[1], 10);
|
|
331
|
+
const failCount = failuresMatch ? parseInt(failuresMatch[1], 10) : 0;
|
|
332
|
+
const errCount = errorsMatch ? parseInt(errorsMatch[1], 10) : 0;
|
|
333
|
+
result.failed = failCount + errCount;
|
|
334
|
+
result.skipped = skippedMatch ? parseInt(skippedMatch[1], 10) : 0;
|
|
335
|
+
result.passed = result.total - result.failed - result.skipped;
|
|
336
|
+
}
|
|
337
|
+
// Parse failure details
|
|
338
|
+
const failurePattern = /Failed tests:\s*([\s\S]*?)(?=\n\n|Tests run:|BUILD)/;
|
|
339
|
+
const failureBlock = stdout.match(failurePattern);
|
|
340
|
+
if (failureBlock) {
|
|
341
|
+
const testFailurePattern = /\s*(\S+)\((\S+)\):\s*(.+)/g;
|
|
342
|
+
let match;
|
|
343
|
+
while ((match = testFailurePattern.exec(failureBlock[1])) !== null) {
|
|
344
|
+
result.failures.push({
|
|
345
|
+
testName: match[1],
|
|
346
|
+
file: match[2],
|
|
347
|
+
error: match[3],
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return result;
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
// Playwright (E2E)
|
|
355
|
+
{
|
|
356
|
+
name: 'playwright',
|
|
357
|
+
language: 'javascript',
|
|
358
|
+
configFiles: [
|
|
359
|
+
'playwright.config.js',
|
|
360
|
+
'playwright.config.cjs',
|
|
361
|
+
'playwright.config.mjs',
|
|
362
|
+
'playwright.config.ts',
|
|
363
|
+
'playwright.config.mts',
|
|
364
|
+
],
|
|
365
|
+
commands: {
|
|
366
|
+
run: ['npx', 'playwright', 'test', '--reporter=json'],
|
|
367
|
+
watch: ['npx', 'playwright', 'test', '--ui'],
|
|
368
|
+
coverage: ['npx', 'playwright', 'test', '--coverage'],
|
|
369
|
+
},
|
|
370
|
+
parseOutput: (stdout, _stderr, rootDir) => {
|
|
371
|
+
const result = {
|
|
372
|
+
total: 0,
|
|
373
|
+
passed: 0,
|
|
374
|
+
failed: 0,
|
|
375
|
+
skipped: 0,
|
|
376
|
+
duration: 0,
|
|
377
|
+
failures: [],
|
|
378
|
+
};
|
|
379
|
+
try {
|
|
380
|
+
const data = JSON.parse(stdout);
|
|
381
|
+
if (data.stats) {
|
|
382
|
+
result.total = data.stats.total;
|
|
383
|
+
result.passed = data.stats.expected;
|
|
384
|
+
result.failed = data.stats.unexpected;
|
|
385
|
+
result.skipped = data.stats.skipped;
|
|
386
|
+
result.duration = data.stats.duration;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
// Fallback text parse
|
|
391
|
+
const passedMatch = stdout.match(/(\d+) passed/);
|
|
392
|
+
const failedMatch = stdout.match(/(\d+) failed/);
|
|
393
|
+
const skippedMatch = stdout.match(/(\d+) skipped/);
|
|
394
|
+
result.passed = passedMatch ? parseInt(passedMatch[1], 10) : 0;
|
|
395
|
+
result.failed = failedMatch ? parseInt(failedMatch[1], 10) : 0;
|
|
396
|
+
result.skipped = skippedMatch ? parseInt(skippedMatch[1], 10) : 0;
|
|
397
|
+
result.total = result.passed + result.failed + result.skipped;
|
|
398
|
+
}
|
|
399
|
+
return result;
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
// Cypress (E2E)
|
|
403
|
+
{
|
|
404
|
+
name: 'cypress',
|
|
405
|
+
language: 'javascript',
|
|
406
|
+
configFiles: [
|
|
407
|
+
'cypress.config.js',
|
|
408
|
+
'cypress.config.cjs',
|
|
409
|
+
'cypress.config.mjs',
|
|
410
|
+
'cypress.config.ts',
|
|
411
|
+
'cypress.json',
|
|
412
|
+
],
|
|
413
|
+
commands: {
|
|
414
|
+
run: ['npx', 'cypress', 'run', '--reporter=json'],
|
|
415
|
+
watch: ['npx', 'cypress', 'open'],
|
|
416
|
+
coverage: ['npx', 'cypress', 'run', '--coverage'],
|
|
417
|
+
},
|
|
418
|
+
parseOutput: (stdout, _stderr, rootDir) => {
|
|
419
|
+
const result = {
|
|
420
|
+
total: 0,
|
|
421
|
+
passed: 0,
|
|
422
|
+
failed: 0,
|
|
423
|
+
skipped: 0,
|
|
424
|
+
duration: 0,
|
|
425
|
+
failures: [],
|
|
426
|
+
};
|
|
427
|
+
try {
|
|
428
|
+
const data = JSON.parse(stdout);
|
|
429
|
+
result.total = data.totalTests ?? 0;
|
|
430
|
+
result.passed = data.totalPassed ?? 0;
|
|
431
|
+
result.failed = data.totalFailed ?? 0;
|
|
432
|
+
result.skipped = data.totalPending ?? 0;
|
|
433
|
+
result.duration = data.totalDuration ?? 0;
|
|
434
|
+
if (data.runs) {
|
|
435
|
+
for (const run of data.runs) {
|
|
436
|
+
for (const test of run.tests) {
|
|
437
|
+
if (test.state === 'failed') {
|
|
438
|
+
result.failures.push({
|
|
439
|
+
testName: test.title.join(' > '),
|
|
440
|
+
file: run.spec.name,
|
|
441
|
+
error: test.displayError ?? 'Test failed',
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// Parse error
|
|
450
|
+
}
|
|
451
|
+
return result;
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
];
|
|
455
|
+
// Default configuration
|
|
456
|
+
const DEFAULT_TESTING_CONFIG = {
|
|
457
|
+
enabled: true,
|
|
458
|
+
autoRunOnChange: false,
|
|
459
|
+
runOnSave: false,
|
|
460
|
+
coverageThreshold: 80,
|
|
461
|
+
timeout: 120_000,
|
|
462
|
+
relatedTestsOnly: false,
|
|
463
|
+
};
|
|
464
|
+
// Test file naming conventions
|
|
465
|
+
const TEST_FILE_PATTERNS = {
|
|
466
|
+
javascript: [
|
|
467
|
+
/\.test\.(js|jsx|ts|tsx|mjs|cjs|mts|cts)$/,
|
|
468
|
+
/\.spec\.(js|jsx|ts|tsx|mjs|cjs|mts|cts)$/,
|
|
469
|
+
/__tests__\/.*\.(js|jsx|ts|tsx|mjs|cjs|mts|cts)$/,
|
|
470
|
+
],
|
|
471
|
+
python: [
|
|
472
|
+
/test_.*\.py$/,
|
|
473
|
+
/.*_test\.py$/,
|
|
474
|
+
],
|
|
475
|
+
go: [
|
|
476
|
+
/_test\.go$/,
|
|
477
|
+
],
|
|
478
|
+
rust: [], // Rust tests are inline
|
|
479
|
+
java: [
|
|
480
|
+
/Test\.java$/,
|
|
481
|
+
/Tests\.java$/,
|
|
482
|
+
/IT\.java$/, // Integration test
|
|
483
|
+
],
|
|
484
|
+
};
|
|
485
|
+
// Source file → test file mapping conventions
|
|
486
|
+
const SOURCE_TO_TEST_MAP = {
|
|
487
|
+
javascript: [
|
|
488
|
+
(src) => {
|
|
489
|
+
const base = src.replace(/\.(js|jsx|ts|tsx|mjs|cjs)$/, '');
|
|
490
|
+
return [
|
|
491
|
+
`${base}.test.${src.match(/\.(js|jsx|ts|tsx|mjs|cjs)$/)?.[1] ?? 'ts'}`,
|
|
492
|
+
`${base}.spec.${src.match(/\.(js|jsx|ts|tsx|mjs|cjs)$/)?.[1] ?? 'ts'}`,
|
|
493
|
+
];
|
|
494
|
+
},
|
|
495
|
+
(src) => {
|
|
496
|
+
const base = basename(src).replace(/\.(js|jsx|ts|tsx|mjs|cjs)$/, '');
|
|
497
|
+
const dir = dirname(src);
|
|
498
|
+
return [
|
|
499
|
+
join(dir, '__tests__', `${base}.test.ts`),
|
|
500
|
+
join(dir, '__tests__', `${base}.spec.ts`),
|
|
501
|
+
];
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
python: [
|
|
505
|
+
(src) => {
|
|
506
|
+
const base = src.replace(/\.py$/, '');
|
|
507
|
+
return [`test_${base}.py`, `${base}_test.py`];
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
go: [
|
|
511
|
+
(src) => {
|
|
512
|
+
const base = src.replace(/\.go$/, '');
|
|
513
|
+
return [`${base}_test.go`];
|
|
514
|
+
},
|
|
515
|
+
],
|
|
516
|
+
rust: [], // Inline tests
|
|
517
|
+
java: [
|
|
518
|
+
(src) => {
|
|
519
|
+
const base = src.replace(/\.java$/, '');
|
|
520
|
+
return [`${base}Test.java`, `${base}Tests.java`];
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
};
|
|
524
|
+
// -----------------------------------------------------------
|
|
525
|
+
// TestingIntegration class
|
|
526
|
+
// -----------------------------------------------------------
|
|
527
|
+
export class TestingIntegration {
|
|
528
|
+
config;
|
|
529
|
+
projectRoot;
|
|
530
|
+
detectedFramework = null;
|
|
531
|
+
frameworkDetected = false;
|
|
532
|
+
cachedResults = null;
|
|
533
|
+
callbacks = [];
|
|
534
|
+
watchProcess = null;
|
|
535
|
+
constructor(projectRoot, config) {
|
|
536
|
+
this.projectRoot = projectRoot ?? process.cwd();
|
|
537
|
+
this.config = { ...DEFAULT_TESTING_CONFIG, ...config };
|
|
538
|
+
}
|
|
539
|
+
// ----------------------------------------------------------
|
|
540
|
+
// Public API
|
|
541
|
+
// ----------------------------------------------------------
|
|
542
|
+
/**
|
|
543
|
+
* Run tests with auto-detection of the test framework.
|
|
544
|
+
* Optionally target a specific test path or pass run options.
|
|
545
|
+
*/
|
|
546
|
+
async runTests(testPath, options) {
|
|
547
|
+
if (!this.config.enabled) {
|
|
548
|
+
return this.emptyResult('disabled');
|
|
549
|
+
}
|
|
550
|
+
this.ensureDetected();
|
|
551
|
+
if (!this.detectedFramework) {
|
|
552
|
+
return this.emptyResult('none');
|
|
553
|
+
}
|
|
554
|
+
const framework = this.detectedFramework;
|
|
555
|
+
const timeout = options?.timeout ?? this.config.timeout;
|
|
556
|
+
const startTime = Date.now();
|
|
557
|
+
// Build command
|
|
558
|
+
let commandParts;
|
|
559
|
+
if (options?.coverage) {
|
|
560
|
+
commandParts = [...framework.commands.coverage];
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
commandParts = [...framework.commands.run];
|
|
564
|
+
}
|
|
565
|
+
if (testPath) {
|
|
566
|
+
commandParts.push(resolve(this.projectRoot, testPath));
|
|
567
|
+
}
|
|
568
|
+
if (options?.filter) {
|
|
569
|
+
commandParts.push(options.filter);
|
|
570
|
+
}
|
|
571
|
+
if (options?.verbose && framework.name === 'jest') {
|
|
572
|
+
commandParts.push('--verbose');
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
const { stdout, stderr } = await this.execCommand(commandParts, this.projectRoot, timeout);
|
|
576
|
+
const parsed = framework.parseOutput(stdout, stderr, this.projectRoot);
|
|
577
|
+
const duration = Date.now() - startTime;
|
|
578
|
+
const result = {
|
|
579
|
+
success: parsed.failed === 0,
|
|
580
|
+
totalTests: parsed.total,
|
|
581
|
+
passed: parsed.passed,
|
|
582
|
+
failed: parsed.failed,
|
|
583
|
+
skipped: parsed.skipped,
|
|
584
|
+
duration: duration || parsed.duration,
|
|
585
|
+
framework: framework.name,
|
|
586
|
+
failures: parsed.failures,
|
|
587
|
+
coverage: parsed.coverage,
|
|
588
|
+
};
|
|
589
|
+
// Check coverage threshold
|
|
590
|
+
if (result.coverage && result.coverage.lines < this.config.coverageThreshold) {
|
|
591
|
+
result.success = false;
|
|
592
|
+
}
|
|
593
|
+
this.cachedResults = result;
|
|
594
|
+
this.emit(result);
|
|
595
|
+
return result;
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
const duration = Date.now() - startTime;
|
|
599
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
600
|
+
const result = {
|
|
601
|
+
success: false,
|
|
602
|
+
totalTests: 0,
|
|
603
|
+
passed: 0,
|
|
604
|
+
failed: 0,
|
|
605
|
+
skipped: 0,
|
|
606
|
+
duration,
|
|
607
|
+
framework: framework.name,
|
|
608
|
+
failures: [{
|
|
609
|
+
testName: 'Framework execution',
|
|
610
|
+
file: '',
|
|
611
|
+
error: `Failed to run tests: ${errMsg}`,
|
|
612
|
+
}],
|
|
613
|
+
};
|
|
614
|
+
this.cachedResults = result;
|
|
615
|
+
this.emit(result);
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Detect which test framework is configured in the project.
|
|
621
|
+
* Returns the name of the detected framework, or null if none found.
|
|
622
|
+
*/
|
|
623
|
+
detectTestFramework(projectRoot) {
|
|
624
|
+
const root = projectRoot ?? this.projectRoot;
|
|
625
|
+
// Check package.json for test scripts
|
|
626
|
+
const pkgJsonPath = join(root, 'package.json');
|
|
627
|
+
if (existsSync(pkgJsonPath)) {
|
|
628
|
+
try {
|
|
629
|
+
const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
|
|
630
|
+
const allDeps = {
|
|
631
|
+
...pkgJson.dependencies,
|
|
632
|
+
...pkgJson.devDependencies,
|
|
633
|
+
};
|
|
634
|
+
// Check for vitest first (takes priority over jest if both present)
|
|
635
|
+
if (allDeps['vitest']) {
|
|
636
|
+
const vitestFramework = TEST_FRAMEWORKS.find(f => f.name === 'vitest');
|
|
637
|
+
this.detectedFramework = vitestFramework;
|
|
638
|
+
this.frameworkDetected = true;
|
|
639
|
+
return 'vitest';
|
|
640
|
+
}
|
|
641
|
+
if (allDeps['jest']) {
|
|
642
|
+
const jestFramework = TEST_FRAMEWORKS.find(f => f.name === 'jest');
|
|
643
|
+
this.detectedFramework = jestFramework;
|
|
644
|
+
this.frameworkDetected = true;
|
|
645
|
+
return 'jest';
|
|
646
|
+
}
|
|
647
|
+
// Check for Playwright / Cypress
|
|
648
|
+
if (allDeps['@playwright/test']) {
|
|
649
|
+
const pwFramework = TEST_FRAMEWORKS.find(f => f.name === 'playwright');
|
|
650
|
+
this.detectedFramework = pwFramework;
|
|
651
|
+
this.frameworkDetected = true;
|
|
652
|
+
return 'playwright';
|
|
653
|
+
}
|
|
654
|
+
if (allDeps['cypress']) {
|
|
655
|
+
const cypressFramework = TEST_FRAMEWORKS.find(f => f.name === 'cypress');
|
|
656
|
+
this.detectedFramework = cypressFramework;
|
|
657
|
+
this.frameworkDetected = true;
|
|
658
|
+
return 'cypress';
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
// Ignore parse errors
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
// Check config files for all frameworks
|
|
666
|
+
for (const framework of TEST_FRAMEWORKS) {
|
|
667
|
+
const hasConfig = framework.configFiles.some(cfg => existsSync(join(root, cfg)));
|
|
668
|
+
if (hasConfig) {
|
|
669
|
+
this.detectedFramework = framework;
|
|
670
|
+
this.frameworkDetected = true;
|
|
671
|
+
return framework.name;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
this.frameworkDetected = true;
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Run tests related to a changed file.
|
|
679
|
+
* Attempts to find and run only the test files that cover the changed source.
|
|
680
|
+
*/
|
|
681
|
+
async runRelatedTests(filePath) {
|
|
682
|
+
this.ensureDetected();
|
|
683
|
+
if (!this.detectedFramework) {
|
|
684
|
+
return this.emptyResult('none');
|
|
685
|
+
}
|
|
686
|
+
const lang = this.getLanguageForFile(filePath);
|
|
687
|
+
if (!lang) {
|
|
688
|
+
// If we can't determine language, run all tests
|
|
689
|
+
return this.runTests();
|
|
690
|
+
}
|
|
691
|
+
// Find related test files
|
|
692
|
+
const relatedTestFiles = this.findRelatedTestFiles(filePath, lang);
|
|
693
|
+
if (relatedTestFiles.length === 0) {
|
|
694
|
+
// No related tests found — run the full suite if not in related-only mode
|
|
695
|
+
if (!this.config.relatedTestsOnly) {
|
|
696
|
+
return this.runTests();
|
|
697
|
+
}
|
|
698
|
+
return this.emptyResult('no-related-tests');
|
|
699
|
+
}
|
|
700
|
+
// Run each related test file
|
|
701
|
+
const allFailures = [];
|
|
702
|
+
let totalTests = 0;
|
|
703
|
+
let totalPassed = 0;
|
|
704
|
+
let totalFailed = 0;
|
|
705
|
+
let totalSkipped = 0;
|
|
706
|
+
let totalDuration = 0;
|
|
707
|
+
for (const testFile of relatedTestFiles) {
|
|
708
|
+
const absTestPath = resolve(this.projectRoot, testFile);
|
|
709
|
+
if (!existsSync(absTestPath))
|
|
710
|
+
continue;
|
|
711
|
+
const result = await this.runTests(testFile);
|
|
712
|
+
totalTests += result.totalTests;
|
|
713
|
+
totalPassed += result.passed;
|
|
714
|
+
totalFailed += result.failed;
|
|
715
|
+
totalSkipped += result.skipped;
|
|
716
|
+
totalDuration += result.duration;
|
|
717
|
+
allFailures.push(...result.failures);
|
|
718
|
+
}
|
|
719
|
+
const combinedResult = {
|
|
720
|
+
success: totalFailed === 0,
|
|
721
|
+
totalTests,
|
|
722
|
+
passed: totalPassed,
|
|
723
|
+
failed: totalFailed,
|
|
724
|
+
skipped: totalSkipped,
|
|
725
|
+
duration: totalDuration,
|
|
726
|
+
framework: this.detectedFramework.name,
|
|
727
|
+
failures: allFailures,
|
|
728
|
+
};
|
|
729
|
+
return combinedResult;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Get test coverage report.
|
|
733
|
+
*/
|
|
734
|
+
async getCoverage(options) {
|
|
735
|
+
return this.runTests(undefined, { ...options, coverage: true });
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Start watch mode for continuous testing.
|
|
739
|
+
* Returns a function that stops the watcher when called.
|
|
740
|
+
*/
|
|
741
|
+
async watchTests(testPath) {
|
|
742
|
+
this.ensureDetected();
|
|
743
|
+
if (!this.detectedFramework) {
|
|
744
|
+
throw new Error('No test framework detected');
|
|
745
|
+
}
|
|
746
|
+
const framework = this.detectedFramework;
|
|
747
|
+
if (!framework.commands.watch) {
|
|
748
|
+
throw new Error(`${framework.name} does not support watch mode`);
|
|
749
|
+
}
|
|
750
|
+
const commandParts = [...framework.commands.watch];
|
|
751
|
+
if (testPath) {
|
|
752
|
+
commandParts.push(resolve(this.projectRoot, testPath));
|
|
753
|
+
}
|
|
754
|
+
// Stop any existing watcher
|
|
755
|
+
this.stopWatcher();
|
|
756
|
+
const [cmd, ...args] = commandParts;
|
|
757
|
+
this.watchProcess = spawn(cmd, args, {
|
|
758
|
+
cwd: this.projectRoot,
|
|
759
|
+
stdio: 'pipe',
|
|
760
|
+
shell: true,
|
|
761
|
+
});
|
|
762
|
+
this.watchProcess.stdout?.on('data', (data) => {
|
|
763
|
+
const output = data.toString('utf-8');
|
|
764
|
+
console.log(output);
|
|
765
|
+
});
|
|
766
|
+
this.watchProcess.stderr?.on('data', (data) => {
|
|
767
|
+
const output = data.toString('utf-8');
|
|
768
|
+
console.error(output);
|
|
769
|
+
});
|
|
770
|
+
// Return stop function
|
|
771
|
+
return () => this.stopWatcher();
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Generate a test file for a source file based on conventions.
|
|
775
|
+
* Returns the path of the generated test file, or null if generation failed.
|
|
776
|
+
*/
|
|
777
|
+
async generateTest(filePath) {
|
|
778
|
+
const lang = this.getLanguageForFile(filePath);
|
|
779
|
+
if (!lang)
|
|
780
|
+
return null;
|
|
781
|
+
const absPath = resolve(this.projectRoot, filePath);
|
|
782
|
+
if (!existsSync(absPath))
|
|
783
|
+
return null;
|
|
784
|
+
// Determine test file path
|
|
785
|
+
const testFilePaths = this.findRelatedTestFilePaths(filePath, lang);
|
|
786
|
+
if (testFilePaths.length === 0)
|
|
787
|
+
return null;
|
|
788
|
+
const testFilePath = testFilePaths[0];
|
|
789
|
+
const fullTestPath = resolve(this.projectRoot, testFilePath);
|
|
790
|
+
// Check if test file already exists
|
|
791
|
+
if (existsSync(fullTestPath)) {
|
|
792
|
+
return fullTestPath;
|
|
793
|
+
}
|
|
794
|
+
// Generate test file content
|
|
795
|
+
const content = this.generateTestContent(filePath, lang, testFilePath);
|
|
796
|
+
if (!content)
|
|
797
|
+
return null;
|
|
798
|
+
try {
|
|
799
|
+
const testDir = dirname(fullTestPath);
|
|
800
|
+
if (!existsSync(testDir)) {
|
|
801
|
+
mkdirSync(testDir, { recursive: true });
|
|
802
|
+
}
|
|
803
|
+
writeFileSync(fullTestPath, content, 'utf-8');
|
|
804
|
+
return fullTestPath;
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
return null;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Register a callback for test results.
|
|
812
|
+
*/
|
|
813
|
+
onTestResult(callback) {
|
|
814
|
+
this.callbacks.push(callback);
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Remove a previously registered callback.
|
|
818
|
+
*/
|
|
819
|
+
offTestResult(callback) {
|
|
820
|
+
this.callbacks = this.callbacks.filter(cb => cb !== callback);
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Get the current testing configuration.
|
|
824
|
+
*/
|
|
825
|
+
getConfig() {
|
|
826
|
+
return { ...this.config };
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Update the testing configuration.
|
|
830
|
+
*/
|
|
831
|
+
updateConfig(updates) {
|
|
832
|
+
Object.assign(this.config, updates);
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* Get the last cached test result.
|
|
836
|
+
*/
|
|
837
|
+
getLastResult() {
|
|
838
|
+
return this.cachedResults;
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Get the name of the detected test framework.
|
|
842
|
+
*/
|
|
843
|
+
getDetectedFramework() {
|
|
844
|
+
this.ensureDetected();
|
|
845
|
+
return this.detectedFramework?.name ?? null;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Determine the language of a file based on its extension.
|
|
849
|
+
*/
|
|
850
|
+
getLanguageForFile(filePath) {
|
|
851
|
+
const ext = extname(filePath).toLowerCase();
|
|
852
|
+
const extMap = {
|
|
853
|
+
'.js': 'javascript',
|
|
854
|
+
'.jsx': 'javascript',
|
|
855
|
+
'.mjs': 'javascript',
|
|
856
|
+
'.cjs': 'javascript',
|
|
857
|
+
'.ts': 'javascript',
|
|
858
|
+
'.tsx': 'javascript',
|
|
859
|
+
'.mts': 'javascript',
|
|
860
|
+
'.cts': 'javascript',
|
|
861
|
+
'.py': 'python',
|
|
862
|
+
'.pyi': 'python',
|
|
863
|
+
'.go': 'go',
|
|
864
|
+
'.rs': 'rust',
|
|
865
|
+
'.java': 'java',
|
|
866
|
+
};
|
|
867
|
+
return extMap[ext] ?? null;
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Check if a file is a test file based on naming conventions.
|
|
871
|
+
*/
|
|
872
|
+
isTestFile(filePath) {
|
|
873
|
+
const lang = this.getLanguageForFile(filePath);
|
|
874
|
+
if (!lang)
|
|
875
|
+
return false;
|
|
876
|
+
const patterns = TEST_FILE_PATTERNS[lang] ?? [];
|
|
877
|
+
return patterns.some(pattern => pattern.test(basename(filePath)));
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Find all test files in the project.
|
|
881
|
+
*/
|
|
882
|
+
findTestFiles(directory) {
|
|
883
|
+
const root = directory ?? this.projectRoot;
|
|
884
|
+
const testFiles = [];
|
|
885
|
+
this.walkDir(root, (filePath) => {
|
|
886
|
+
if (this.isTestFile(filePath)) {
|
|
887
|
+
testFiles.push(relative(this.projectRoot, filePath));
|
|
888
|
+
}
|
|
889
|
+
});
|
|
890
|
+
return testFiles;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Print a summary of test results.
|
|
894
|
+
*/
|
|
895
|
+
printSummary(result) {
|
|
896
|
+
console.log('');
|
|
897
|
+
console.log('--- Test Summary ---');
|
|
898
|
+
console.log(` Framework: ${result.framework}`);
|
|
899
|
+
console.log(` Duration: ${result.duration}ms`);
|
|
900
|
+
console.log(` Total: ${result.totalTests}`);
|
|
901
|
+
console.log(` Passed: ${result.passed}`);
|
|
902
|
+
console.log(` Failed: ${result.failed}`);
|
|
903
|
+
console.log(` Skipped: ${result.skipped}`);
|
|
904
|
+
console.log(` Status: ${result.success ? 'PASS' : 'FAIL'}`);
|
|
905
|
+
if (result.coverage) {
|
|
906
|
+
console.log('');
|
|
907
|
+
console.log(' Coverage:');
|
|
908
|
+
console.log(` Lines: ${result.coverage.lines.toFixed(1)}%`);
|
|
909
|
+
console.log(` Branches: ${result.coverage.branches.toFixed(1)}%`);
|
|
910
|
+
console.log(` Functions: ${result.coverage.functions.toFixed(1)}%`);
|
|
911
|
+
console.log(` Statements: ${result.coverage.statements.toFixed(1)}%`);
|
|
912
|
+
if (result.coverage.lines < this.config.coverageThreshold) {
|
|
913
|
+
console.log(` ⚠ Below threshold (${this.config.coverageThreshold}%)`);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (result.failures.length > 0) {
|
|
917
|
+
console.log('');
|
|
918
|
+
console.log(' Failures:');
|
|
919
|
+
for (const failure of result.failures.slice(0, 20)) {
|
|
920
|
+
console.log(` ✗ ${failure.testName}`);
|
|
921
|
+
if (failure.file)
|
|
922
|
+
console.log(` File: ${failure.file}${failure.line ? `:${failure.line}` : ''}`);
|
|
923
|
+
console.log(` ${failure.error.split('\n')[0]}`);
|
|
924
|
+
if (failure.expected)
|
|
925
|
+
console.log(` Expected: ${failure.expected}`);
|
|
926
|
+
if (failure.actual)
|
|
927
|
+
console.log(` Actual: ${failure.actual}`);
|
|
928
|
+
}
|
|
929
|
+
if (result.failures.length > 20) {
|
|
930
|
+
console.log(` ... and ${result.failures.length - 20} more`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
console.log('');
|
|
934
|
+
}
|
|
935
|
+
// ----------------------------------------------------------
|
|
936
|
+
// Private helpers
|
|
937
|
+
// ----------------------------------------------------------
|
|
938
|
+
ensureDetected() {
|
|
939
|
+
if (!this.frameworkDetected) {
|
|
940
|
+
this.detectTestFramework(this.projectRoot);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
emit(result) {
|
|
944
|
+
for (const cb of this.callbacks) {
|
|
945
|
+
try {
|
|
946
|
+
cb(result);
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
// Callback errors should not interrupt the flow
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
emptyResult(reason) {
|
|
954
|
+
return {
|
|
955
|
+
success: reason !== 'fail',
|
|
956
|
+
totalTests: 0,
|
|
957
|
+
passed: 0,
|
|
958
|
+
failed: 0,
|
|
959
|
+
skipped: 0,
|
|
960
|
+
duration: 0,
|
|
961
|
+
framework: reason,
|
|
962
|
+
failures: [],
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
stopWatcher() {
|
|
966
|
+
if (this.watchProcess) {
|
|
967
|
+
this.watchProcess.kill('SIGTERM');
|
|
968
|
+
this.watchProcess = null;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
findRelatedTestFiles(filePath, lang) {
|
|
972
|
+
const testPaths = [];
|
|
973
|
+
// First check if the file itself is a test file
|
|
974
|
+
if (this.isTestFile(filePath)) {
|
|
975
|
+
testPaths.push(filePath);
|
|
976
|
+
return testPaths;
|
|
977
|
+
}
|
|
978
|
+
// Use source-to-test mapping
|
|
979
|
+
const mappers = SOURCE_TO_TEST_MAP[lang] ?? [];
|
|
980
|
+
for (const mapper of mappers) {
|
|
981
|
+
const candidates = mapper(filePath);
|
|
982
|
+
for (const candidate of candidates) {
|
|
983
|
+
const absCandidate = resolve(this.projectRoot, candidate);
|
|
984
|
+
if (existsSync(absCandidate)) {
|
|
985
|
+
testPaths.push(candidate);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return testPaths;
|
|
990
|
+
}
|
|
991
|
+
findRelatedTestFilePaths(filePath, lang) {
|
|
992
|
+
const mappers = SOURCE_TO_TEST_MAP[lang] ?? [];
|
|
993
|
+
const paths = [];
|
|
994
|
+
for (const mapper of mappers) {
|
|
995
|
+
paths.push(...mapper(filePath));
|
|
996
|
+
}
|
|
997
|
+
return paths;
|
|
998
|
+
}
|
|
999
|
+
generateTestContent(sourceFilePath, lang, _testFilePath) {
|
|
1000
|
+
const sourceBase = basename(sourceFilePath, extname(sourceFilePath));
|
|
1001
|
+
switch (lang) {
|
|
1002
|
+
case 'javascript':
|
|
1003
|
+
return this.generateJSTestContent(sourceBase, sourceFilePath);
|
|
1004
|
+
case 'python':
|
|
1005
|
+
return this.generatePythonTestContent(sourceBase, sourceFilePath);
|
|
1006
|
+
case 'go':
|
|
1007
|
+
return this.generateGoTestContent(sourceBase, sourceFilePath);
|
|
1008
|
+
case 'rust':
|
|
1009
|
+
return null; // Rust tests are inline
|
|
1010
|
+
case 'java':
|
|
1011
|
+
return this.generateJavaTestContent(sourceBase, sourceFilePath);
|
|
1012
|
+
default:
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
generateJSTestContent(sourceBase, sourceFilePath) {
|
|
1017
|
+
const isTypeScript = /\.(ts|tsx|mts|cts)$/.test(sourceFilePath);
|
|
1018
|
+
const ext = isTypeScript ? 'ts' : 'js';
|
|
1019
|
+
const importExt = isTypeScript ? '' : '.js';
|
|
1020
|
+
// Detect if using vitest or jest
|
|
1021
|
+
const framework = this.detectedFramework?.name ?? 'jest';
|
|
1022
|
+
return `// Test file for ${sourceFilePath}
|
|
1023
|
+
// Generated by NeuroCLI Testing Integration
|
|
1024
|
+
|
|
1025
|
+
import { describe, it, expect${framework === 'vitest' ? ', vi' : ''} } from '${framework === 'vitest' ? 'vitest' : '@jest/globals'}';
|
|
1026
|
+
|
|
1027
|
+
describe('${sourceBase}', () => {
|
|
1028
|
+
it('should work correctly', () => {
|
|
1029
|
+
// TODO: Implement test
|
|
1030
|
+
expect(true).toBe(true);
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
`;
|
|
1034
|
+
}
|
|
1035
|
+
generatePythonTestContent(sourceBase, _sourceFilePath) {
|
|
1036
|
+
return `# Test file for ${sourceBase}
|
|
1037
|
+
# Generated by NeuroCLI Testing Integration
|
|
1038
|
+
|
|
1039
|
+
import pytest
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
class Test${this.pascalCase(sourceBase)}:
|
|
1043
|
+
"""Tests for ${sourceBase} module."""
|
|
1044
|
+
|
|
1045
|
+
def test_placeholder(self):
|
|
1046
|
+
"""TODO: Implement test."""
|
|
1047
|
+
assert True
|
|
1048
|
+
`;
|
|
1049
|
+
}
|
|
1050
|
+
generateGoTestContent(sourceBase, sourceFilePath) {
|
|
1051
|
+
const packageName = dirname(sourceFilePath).split('/').pop() ?? 'main';
|
|
1052
|
+
return `package ${packageName}
|
|
1053
|
+
|
|
1054
|
+
// Test file for ${sourceFilePath}
|
|
1055
|
+
// Generated by NeuroCLI Testing Integration
|
|
1056
|
+
|
|
1057
|
+
import "testing"
|
|
1058
|
+
|
|
1059
|
+
func Test${this.pascalCase(sourceBase)}(t *testing.T) {
|
|
1060
|
+
// TODO: Implement test
|
|
1061
|
+
if true != true {
|
|
1062
|
+
t.Fatal("placeholder test failed")
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
`;
|
|
1066
|
+
}
|
|
1067
|
+
generateJavaTestContent(sourceBase, _sourceFilePath) {
|
|
1068
|
+
const className = this.pascalCase(sourceBase);
|
|
1069
|
+
return `// Test file for ${className}
|
|
1070
|
+
// Generated by NeuroCLI Testing Integration
|
|
1071
|
+
|
|
1072
|
+
import org.junit.jupiter.api.Test;
|
|
1073
|
+
import static org.junit.jupiter.api.Assertions.*;
|
|
1074
|
+
|
|
1075
|
+
class ${className}Test {
|
|
1076
|
+
|
|
1077
|
+
@Test
|
|
1078
|
+
void testPlaceholder() {
|
|
1079
|
+
// TODO: Implement test
|
|
1080
|
+
assertTrue(true);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
`;
|
|
1084
|
+
}
|
|
1085
|
+
pascalCase(str) {
|
|
1086
|
+
return str
|
|
1087
|
+
.split(/[-_]/)
|
|
1088
|
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
1089
|
+
.join('');
|
|
1090
|
+
}
|
|
1091
|
+
walkDir(dir, callback) {
|
|
1092
|
+
if (!existsSync(dir))
|
|
1093
|
+
return;
|
|
1094
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1095
|
+
for (const entry of entries) {
|
|
1096
|
+
const fullPath = join(dir, entry.name);
|
|
1097
|
+
// Skip common non-source directories
|
|
1098
|
+
if (entry.isDirectory()) {
|
|
1099
|
+
if (['node_modules', '.git', 'dist', 'build', '__pycache__', 'target', '.next', 'vendor', '.venv', 'venv'].includes(entry.name)) {
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
this.walkDir(fullPath, callback);
|
|
1103
|
+
}
|
|
1104
|
+
else if (entry.isFile()) {
|
|
1105
|
+
callback(fullPath);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
execCommand(commandParts, cwd, timeout) {
|
|
1110
|
+
return new Promise((resolve, reject) => {
|
|
1111
|
+
const [cmd, ...args] = commandParts;
|
|
1112
|
+
try {
|
|
1113
|
+
const result = execSync(`${cmd} ${args.map(a => `"${a}"`).join(' ')}`, {
|
|
1114
|
+
cwd,
|
|
1115
|
+
timeout,
|
|
1116
|
+
encoding: 'utf-8',
|
|
1117
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
1118
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1119
|
+
});
|
|
1120
|
+
resolve({ stdout: result, stderr: '', exitCode: 0 });
|
|
1121
|
+
}
|
|
1122
|
+
catch (error) {
|
|
1123
|
+
const execError = error;
|
|
1124
|
+
if (execError.stdout || execError.stderr) {
|
|
1125
|
+
resolve({
|
|
1126
|
+
stdout: typeof execError.stdout === 'string' ? execError.stdout : (execError.stdout?.toString('utf-8') ?? ''),
|
|
1127
|
+
stderr: typeof execError.stderr === 'string' ? execError.stderr : (execError.stderr?.toString('utf-8') ?? ''),
|
|
1128
|
+
exitCode: execError.status ?? 1,
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
reject(error);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
// -----------------------------------------------------------
|
|
1139
|
+
// Standalone helper functions
|
|
1140
|
+
// -----------------------------------------------------------
|
|
1141
|
+
/**
|
|
1142
|
+
* Parse a Jest error message to extract expected/actual values.
|
|
1143
|
+
*/
|
|
1144
|
+
function parseJestError(errorMsg) {
|
|
1145
|
+
const result = {
|
|
1146
|
+
message: errorMsg.split('\n')[0],
|
|
1147
|
+
};
|
|
1148
|
+
// Try to extract expected/actual from Jest assertion errors
|
|
1149
|
+
const expectedMatch = errorMsg.match(/Expected:\s*(.+)/);
|
|
1150
|
+
const receivedMatch = errorMsg.match(/Received:\s*(.+)/);
|
|
1151
|
+
if (expectedMatch)
|
|
1152
|
+
result.expected = expectedMatch[1].trim();
|
|
1153
|
+
if (receivedMatch)
|
|
1154
|
+
result.actual = receivedMatch[1].trim();
|
|
1155
|
+
// Try to extract line number
|
|
1156
|
+
const lineMatch = errorMsg.match(/:(\d+):\d+/);
|
|
1157
|
+
if (lineMatch)
|
|
1158
|
+
result.line = parseInt(lineMatch[1], 10);
|
|
1159
|
+
return result;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Parse Jest coverage map into a CoverageReport.
|
|
1163
|
+
*/
|
|
1164
|
+
function parseJestCoverage(coverageMap, rootDir) {
|
|
1165
|
+
let totalStatements = 0;
|
|
1166
|
+
let coveredStatements = 0;
|
|
1167
|
+
let totalBranches = 0;
|
|
1168
|
+
let coveredBranches = 0;
|
|
1169
|
+
let totalFunctions = 0;
|
|
1170
|
+
let coveredFunctions = 0;
|
|
1171
|
+
let totalLines = 0;
|
|
1172
|
+
let coveredLines = 0;
|
|
1173
|
+
const files = [];
|
|
1174
|
+
for (const [filePath, coverage] of Object.entries(coverageMap)) {
|
|
1175
|
+
totalStatements += coverage.statements.total;
|
|
1176
|
+
coveredStatements += coverage.statements.covered;
|
|
1177
|
+
totalBranches += coverage.branches.total;
|
|
1178
|
+
coveredBranches += coverage.branches.covered;
|
|
1179
|
+
totalFunctions += coverage.functions.total;
|
|
1180
|
+
coveredFunctions += coverage.functions.covered;
|
|
1181
|
+
totalLines += coverage.lines.total;
|
|
1182
|
+
coveredLines += coverage.lines.covered;
|
|
1183
|
+
files.push({
|
|
1184
|
+
path: relative(rootDir, filePath),
|
|
1185
|
+
lines: coverage.lines.total > 0 ? (coverage.lines.covered / coverage.lines.total) * 100 : 100,
|
|
1186
|
+
branches: coverage.branches.total > 0 ? (coverage.branches.covered / coverage.branches.total) * 100 : 100,
|
|
1187
|
+
functions: coverage.functions.total > 0 ? (coverage.functions.covered / coverage.functions.total) * 100 : 100,
|
|
1188
|
+
statements: coverage.statements.total > 0 ? (coverage.statements.covered / coverage.statements.total) * 100 : 100,
|
|
1189
|
+
uncoveredLines: [],
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
return {
|
|
1193
|
+
lines: totalLines > 0 ? (coveredLines / totalLines) * 100 : 100,
|
|
1194
|
+
branches: totalBranches > 0 ? (coveredBranches / totalBranches) * 100 : 100,
|
|
1195
|
+
functions: totalFunctions > 0 ? (coveredFunctions / totalFunctions) * 100 : 100,
|
|
1196
|
+
statements: totalStatements > 0 ? (coveredStatements / totalStatements) * 100 : 100,
|
|
1197
|
+
files,
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
//# sourceMappingURL=testing.js.map
|