@yuaone/core 0.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 +663 -0
- package/README.md +15 -0
- package/dist/__tests__/context-manager.test.d.ts +6 -0
- package/dist/__tests__/context-manager.test.d.ts.map +1 -0
- package/dist/__tests__/context-manager.test.js +220 -0
- package/dist/__tests__/context-manager.test.js.map +1 -0
- package/dist/__tests__/governor.test.d.ts +6 -0
- package/dist/__tests__/governor.test.d.ts.map +1 -0
- package/dist/__tests__/governor.test.js +210 -0
- package/dist/__tests__/governor.test.js.map +1 -0
- package/dist/__tests__/model-router.test.d.ts +6 -0
- package/dist/__tests__/model-router.test.d.ts.map +1 -0
- package/dist/__tests__/model-router.test.js +329 -0
- package/dist/__tests__/model-router.test.js.map +1 -0
- package/dist/agent-logger.d.ts +384 -0
- package/dist/agent-logger.d.ts.map +1 -0
- package/dist/agent-logger.js +820 -0
- package/dist/agent-logger.js.map +1 -0
- package/dist/agent-loop.d.ts +163 -0
- package/dist/agent-loop.d.ts.map +1 -0
- package/dist/agent-loop.js +609 -0
- package/dist/agent-loop.js.map +1 -0
- package/dist/agent-modes.d.ts +85 -0
- package/dist/agent-modes.d.ts.map +1 -0
- package/dist/agent-modes.js +418 -0
- package/dist/agent-modes.js.map +1 -0
- package/dist/approval.d.ts +137 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +299 -0
- package/dist/approval.js.map +1 -0
- package/dist/async-completion-queue.d.ts +56 -0
- package/dist/async-completion-queue.d.ts.map +1 -0
- package/dist/async-completion-queue.js +77 -0
- package/dist/async-completion-queue.js.map +1 -0
- package/dist/auto-fix.d.ts +174 -0
- package/dist/auto-fix.d.ts.map +1 -0
- package/dist/auto-fix.js +319 -0
- package/dist/auto-fix.js.map +1 -0
- package/dist/codebase-context.d.ts +396 -0
- package/dist/codebase-context.d.ts.map +1 -0
- package/dist/codebase-context.js +1260 -0
- package/dist/codebase-context.js.map +1 -0
- package/dist/conflict-resolver.d.ts +191 -0
- package/dist/conflict-resolver.d.ts.map +1 -0
- package/dist/conflict-resolver.js +524 -0
- package/dist/conflict-resolver.js.map +1 -0
- package/dist/constants.d.ts +52 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +141 -0
- package/dist/constants.js.map +1 -0
- package/dist/context-budget.d.ts +435 -0
- package/dist/context-budget.d.ts.map +1 -0
- package/dist/context-budget.js +903 -0
- package/dist/context-budget.js.map +1 -0
- package/dist/context-compressor.d.ts +143 -0
- package/dist/context-compressor.d.ts.map +1 -0
- package/dist/context-compressor.js +511 -0
- package/dist/context-compressor.js.map +1 -0
- package/dist/context-manager.d.ts +112 -0
- package/dist/context-manager.d.ts.map +1 -0
- package/dist/context-manager.js +247 -0
- package/dist/context-manager.js.map +1 -0
- package/dist/continuous-reflection.d.ts +267 -0
- package/dist/continuous-reflection.d.ts.map +1 -0
- package/dist/continuous-reflection.js +338 -0
- package/dist/continuous-reflection.js.map +1 -0
- package/dist/cross-file-refactor.d.ts +352 -0
- package/dist/cross-file-refactor.d.ts.map +1 -0
- package/dist/cross-file-refactor.js +1544 -0
- package/dist/cross-file-refactor.js.map +1 -0
- package/dist/dag-orchestrator.d.ts +138 -0
- package/dist/dag-orchestrator.d.ts.map +1 -0
- package/dist/dag-orchestrator.js +379 -0
- package/dist/dag-orchestrator.js.map +1 -0
- package/dist/debate-orchestrator.d.ts +301 -0
- package/dist/debate-orchestrator.d.ts.map +1 -0
- package/dist/debate-orchestrator.js +719 -0
- package/dist/debate-orchestrator.js.map +1 -0
- package/dist/dependency-analyzer.d.ts +113 -0
- package/dist/dependency-analyzer.d.ts.map +1 -0
- package/dist/dependency-analyzer.js +444 -0
- package/dist/dependency-analyzer.js.map +1 -0
- package/dist/design-loop.d.ts +59 -0
- package/dist/design-loop.d.ts.map +1 -0
- package/dist/design-loop.js +344 -0
- package/dist/design-loop.js.map +1 -0
- package/dist/doc-intelligence.d.ts +383 -0
- package/dist/doc-intelligence.d.ts.map +1 -0
- package/dist/doc-intelligence.js +1307 -0
- package/dist/doc-intelligence.js.map +1 -0
- package/dist/dynamic-role-generator.d.ts +76 -0
- package/dist/dynamic-role-generator.d.ts.map +1 -0
- package/dist/dynamic-role-generator.js +194 -0
- package/dist/dynamic-role-generator.js.map +1 -0
- package/dist/errors.d.ts +69 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +102 -0
- package/dist/errors.js.map +1 -0
- package/dist/event-bus.d.ts +159 -0
- package/dist/event-bus.d.ts.map +1 -0
- package/dist/event-bus.js +305 -0
- package/dist/event-bus.js.map +1 -0
- package/dist/execution-engine.d.ts +425 -0
- package/dist/execution-engine.d.ts.map +1 -0
- package/dist/execution-engine.js +1555 -0
- package/dist/execution-engine.js.map +1 -0
- package/dist/git-intelligence.d.ts +306 -0
- package/dist/git-intelligence.d.ts.map +1 -0
- package/dist/git-intelligence.js +1099 -0
- package/dist/git-intelligence.js.map +1 -0
- package/dist/governor.d.ts +77 -0
- package/dist/governor.d.ts.map +1 -0
- package/dist/governor.js +161 -0
- package/dist/governor.js.map +1 -0
- package/dist/hierarchical-planner.d.ts +313 -0
- package/dist/hierarchical-planner.d.ts.map +1 -0
- package/dist/hierarchical-planner.js +981 -0
- package/dist/hierarchical-planner.js.map +1 -0
- package/dist/index.d.ts +121 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +123 -0
- package/dist/index.js.map +1 -0
- package/dist/intent-inference.d.ts +103 -0
- package/dist/intent-inference.d.ts.map +1 -0
- package/dist/intent-inference.js +605 -0
- package/dist/intent-inference.js.map +1 -0
- package/dist/interrupt-manager.d.ts +143 -0
- package/dist/interrupt-manager.d.ts.map +1 -0
- package/dist/interrupt-manager.js +196 -0
- package/dist/interrupt-manager.js.map +1 -0
- package/dist/kernel.d.ts +564 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +1419 -0
- package/dist/kernel.js.map +1 -0
- package/dist/language-support.d.ts +232 -0
- package/dist/language-support.d.ts.map +1 -0
- package/dist/language-support.js +1134 -0
- package/dist/language-support.js.map +1 -0
- package/dist/llm-client.d.ts +82 -0
- package/dist/llm-client.d.ts.map +1 -0
- package/dist/llm-client.js +475 -0
- package/dist/llm-client.js.map +1 -0
- package/dist/mcp-client.d.ts +232 -0
- package/dist/mcp-client.d.ts.map +1 -0
- package/dist/mcp-client.js +718 -0
- package/dist/mcp-client.js.map +1 -0
- package/dist/memory-manager.d.ts +200 -0
- package/dist/memory-manager.d.ts.map +1 -0
- package/dist/memory-manager.js +568 -0
- package/dist/memory-manager.js.map +1 -0
- package/dist/memory.d.ts +87 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +341 -0
- package/dist/memory.js.map +1 -0
- package/dist/model-router.d.ts +245 -0
- package/dist/model-router.d.ts.map +1 -0
- package/dist/model-router.js +632 -0
- package/dist/model-router.js.map +1 -0
- package/dist/parallel-executor.d.ts +125 -0
- package/dist/parallel-executor.d.ts.map +1 -0
- package/dist/parallel-executor.js +201 -0
- package/dist/parallel-executor.js.map +1 -0
- package/dist/perf-optimizer.d.ts +212 -0
- package/dist/perf-optimizer.d.ts.map +1 -0
- package/dist/perf-optimizer.js +721 -0
- package/dist/perf-optimizer.js.map +1 -0
- package/dist/persona.d.ts +305 -0
- package/dist/persona.d.ts.map +1 -0
- package/dist/persona.js +887 -0
- package/dist/persona.js.map +1 -0
- package/dist/planner.d.ts +70 -0
- package/dist/planner.d.ts.map +1 -0
- package/dist/planner.js +264 -0
- package/dist/planner.js.map +1 -0
- package/dist/qa-pipeline.d.ts +365 -0
- package/dist/qa-pipeline.d.ts.map +1 -0
- package/dist/qa-pipeline.js +1352 -0
- package/dist/qa-pipeline.js.map +1 -0
- package/dist/reasoning-adapter.d.ts +116 -0
- package/dist/reasoning-adapter.d.ts.map +1 -0
- package/dist/reasoning-adapter.js +187 -0
- package/dist/reasoning-adapter.js.map +1 -0
- package/dist/role-registry.d.ts +55 -0
- package/dist/role-registry.d.ts.map +1 -0
- package/dist/role-registry.js +192 -0
- package/dist/role-registry.js.map +1 -0
- package/dist/sandbox-tiers.d.ts +327 -0
- package/dist/sandbox-tiers.d.ts.map +1 -0
- package/dist/sandbox-tiers.js +928 -0
- package/dist/sandbox-tiers.js.map +1 -0
- package/dist/security-scanner.d.ts +222 -0
- package/dist/security-scanner.d.ts.map +1 -0
- package/dist/security-scanner.js +1129 -0
- package/dist/security-scanner.js.map +1 -0
- package/dist/security.d.ts +93 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +393 -0
- package/dist/security.js.map +1 -0
- package/dist/self-reflection.d.ts +397 -0
- package/dist/self-reflection.d.ts.map +1 -0
- package/dist/self-reflection.js +908 -0
- package/dist/self-reflection.js.map +1 -0
- package/dist/session-persistence.d.ts +191 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +395 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/speculative-executor.d.ts +210 -0
- package/dist/speculative-executor.d.ts.map +1 -0
- package/dist/speculative-executor.js +618 -0
- package/dist/speculative-executor.js.map +1 -0
- package/dist/state-machine.d.ts +289 -0
- package/dist/state-machine.d.ts.map +1 -0
- package/dist/state-machine.js +695 -0
- package/dist/state-machine.js.map +1 -0
- package/dist/sub-agent.d.ts +177 -0
- package/dist/sub-agent.d.ts.map +1 -0
- package/dist/sub-agent.js +303 -0
- package/dist/sub-agent.js.map +1 -0
- package/dist/system-prompt.d.ts +26 -0
- package/dist/system-prompt.d.ts.map +1 -0
- package/dist/system-prompt.js +84 -0
- package/dist/system-prompt.js.map +1 -0
- package/dist/test-intelligence.d.ts +439 -0
- package/dist/test-intelligence.d.ts.map +1 -0
- package/dist/test-intelligence.js +1165 -0
- package/dist/test-intelligence.js.map +1 -0
- package/dist/types.d.ts +632 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/vector-index.d.ts +314 -0
- package/dist/vector-index.d.ts.map +1 -0
- package/dist/vector-index.js +618 -0
- package/dist/vector-index.js.map +1 -0
- package/package.json +41 -0
|
@@ -0,0 +1,1165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module test-intelligence
|
|
3
|
+
* @description Test Intelligence System — discovers test files, maps source-to-test coverage,
|
|
4
|
+
* detects affected tests for changed files, identifies coverage gaps, and generates test suggestions.
|
|
5
|
+
*
|
|
6
|
+
* Uses regex-based analysis (no external test runner dependency). Designed for the YUAN coding agent
|
|
7
|
+
* to intelligently run only relevant tests and suggest missing test coverage.
|
|
8
|
+
*/
|
|
9
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
10
|
+
import { join, resolve, dirname, extname, basename, relative } from "node:path";
|
|
11
|
+
// ─── Constants ───
|
|
12
|
+
const DEFAULT_TEST_PATTERNS = [
|
|
13
|
+
"**/*.test.ts",
|
|
14
|
+
"**/*.spec.ts",
|
|
15
|
+
"**/*.test.js",
|
|
16
|
+
"**/*.spec.js",
|
|
17
|
+
"**/__tests__/**/*.ts",
|
|
18
|
+
"**/__tests__/**/*.js",
|
|
19
|
+
"**/test/**/*.ts",
|
|
20
|
+
"**/test/**/*.js",
|
|
21
|
+
];
|
|
22
|
+
const DEFAULT_IGNORE_PATTERNS = [
|
|
23
|
+
"node_modules",
|
|
24
|
+
"dist",
|
|
25
|
+
"build",
|
|
26
|
+
".git",
|
|
27
|
+
"coverage",
|
|
28
|
+
".next",
|
|
29
|
+
".turbo",
|
|
30
|
+
"__pycache__",
|
|
31
|
+
];
|
|
32
|
+
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
33
|
+
/** Regex for test file naming conventions */
|
|
34
|
+
const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx)$/;
|
|
35
|
+
const TEST_DIR_RE = /(?:^|[/\\])(?:__tests__|test)[/\\]/;
|
|
36
|
+
const INTEGRATION_TEST_RE = /\.(?:integration|e2e)\.(test|spec)\.(ts|tsx|js|jsx)$/;
|
|
37
|
+
// ─── Import parsing regex (mirrors dependency-analyzer.ts) ───
|
|
38
|
+
const IMPORT_RE = /import\s+(?:type\s+)?(?:\{([^}]*)\}|(\w+)|\*\s+as\s+(\w+))\s+from\s+["']([^"']+)["']/g;
|
|
39
|
+
const RE_EXPORT_RE = /export\s+(?:type\s+)?\{([^}]*)\}\s+from\s+["']([^"']+)["']/g;
|
|
40
|
+
const REQUIRE_RE = /require\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
41
|
+
// ─── Test case extraction regex ───
|
|
42
|
+
const IT_RE = /it\(\s*['"`](.+?)['"`]\s*,/g;
|
|
43
|
+
const TEST_RE = /test\(\s*['"`](.+?)['"`]\s*,/g;
|
|
44
|
+
const DESCRIBE_RE = /describe\(\s*['"`](.+?)['"`]\s*,/g;
|
|
45
|
+
// ─── Framework detection regex ───
|
|
46
|
+
const VITEST_IMPORT_RE = /import\s+\{[^}]*\}\s+from\s+['"]vitest['"]/;
|
|
47
|
+
const VITEST_IMPORT_TEST_RE = /import\s+\{?\s*test\s*\}?\s+from\s+['"]vitest['"]/;
|
|
48
|
+
const NODE_TEST_RE = /import\s+(?:\{[^}]*\}|test)\s+from\s+['"]node:test['"]/;
|
|
49
|
+
const CHAI_RE = /require\s*\(\s*['"]chai['"]\s*\)/;
|
|
50
|
+
// ─── Symbol extraction regex (simplified from codebase-context.ts) ───
|
|
51
|
+
const EXPORT_SYMBOL_RE = /export\s+(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?(?:function\s*\*?|class|const|let|var|interface|type|enum)\s+(\w+)/g;
|
|
52
|
+
/**
|
|
53
|
+
* Test Intelligence System — discovers, maps, and analyzes test coverage.
|
|
54
|
+
*
|
|
55
|
+
* Provides affected test detection, coverage gap analysis, test suggestions,
|
|
56
|
+
* and test command building for the YUAN coding agent.
|
|
57
|
+
*/
|
|
58
|
+
export class TestIntelligence {
|
|
59
|
+
config;
|
|
60
|
+
testFiles;
|
|
61
|
+
sourceToTests;
|
|
62
|
+
testHistory;
|
|
63
|
+
constructor(config) {
|
|
64
|
+
this.config = {
|
|
65
|
+
projectPath: resolve(config.projectPath),
|
|
66
|
+
testPatterns: config.testPatterns ?? DEFAULT_TEST_PATTERNS,
|
|
67
|
+
ignorePatterns: config.ignorePatterns ?? DEFAULT_IGNORE_PATTERNS,
|
|
68
|
+
maxTransitiveDepth: config.maxTransitiveDepth ?? 3,
|
|
69
|
+
includeIntegrationTests: config.includeIntegrationTests ?? true,
|
|
70
|
+
};
|
|
71
|
+
this.testFiles = new Map();
|
|
72
|
+
this.sourceToTests = new Map();
|
|
73
|
+
this.testHistory = [];
|
|
74
|
+
}
|
|
75
|
+
// ─── Discovery ───
|
|
76
|
+
/**
|
|
77
|
+
* Scan the project for all test files, parse their content,
|
|
78
|
+
* and build the internal test file index.
|
|
79
|
+
*
|
|
80
|
+
* @returns Array of discovered test files
|
|
81
|
+
*/
|
|
82
|
+
async discoverTests() {
|
|
83
|
+
const testPaths = await this.findTestFiles();
|
|
84
|
+
this.testFiles.clear();
|
|
85
|
+
for (const filePath of testPaths) {
|
|
86
|
+
const content = await this.readFile(filePath);
|
|
87
|
+
if (!content)
|
|
88
|
+
continue;
|
|
89
|
+
const framework = this.detectFramework(content);
|
|
90
|
+
const testCases = this.parseTestCases(content, framework);
|
|
91
|
+
const imports = this.parseTestImports(content, filePath);
|
|
92
|
+
const testFile = {
|
|
93
|
+
path: filePath,
|
|
94
|
+
framework,
|
|
95
|
+
testCases,
|
|
96
|
+
imports,
|
|
97
|
+
};
|
|
98
|
+
this.testFiles.set(filePath, testFile);
|
|
99
|
+
}
|
|
100
|
+
return [...this.testFiles.values()];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Detect the test framework used in a file based on its content.
|
|
104
|
+
*
|
|
105
|
+
* Detection priority:
|
|
106
|
+
* 1. Vitest (explicit import from 'vitest')
|
|
107
|
+
* 2. Node.js test runner (import from 'node:test')
|
|
108
|
+
* 3. Mocha (chai require)
|
|
109
|
+
* 4. Jest (describe/it/test without vitest import)
|
|
110
|
+
* 5. Unknown (fallback)
|
|
111
|
+
*
|
|
112
|
+
* @param content - File content to analyze
|
|
113
|
+
* @returns Detected framework identifier
|
|
114
|
+
*/
|
|
115
|
+
detectFramework(content) {
|
|
116
|
+
if (VITEST_IMPORT_RE.test(content) || VITEST_IMPORT_TEST_RE.test(content)) {
|
|
117
|
+
return "vitest";
|
|
118
|
+
}
|
|
119
|
+
if (NODE_TEST_RE.test(content)) {
|
|
120
|
+
return "node_test";
|
|
121
|
+
}
|
|
122
|
+
if (CHAI_RE.test(content)) {
|
|
123
|
+
return "mocha";
|
|
124
|
+
}
|
|
125
|
+
// Jest uses describe/it/test as globals (no explicit import needed)
|
|
126
|
+
const hasDescribe = /\bdescribe\s*\(/.test(content);
|
|
127
|
+
const hasItOrTest = /\b(?:it|test)\s*\(/.test(content);
|
|
128
|
+
if (hasDescribe && hasItOrTest) {
|
|
129
|
+
return "jest";
|
|
130
|
+
}
|
|
131
|
+
if (hasItOrTest) {
|
|
132
|
+
return "jest";
|
|
133
|
+
}
|
|
134
|
+
return "unknown";
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Extract test cases from file content.
|
|
138
|
+
*
|
|
139
|
+
* Parses `it()`, `test()`, and `describe()` blocks, infers test type
|
|
140
|
+
* from naming patterns, and extracts tags from the description.
|
|
141
|
+
*
|
|
142
|
+
* @param content - File content to parse
|
|
143
|
+
* @param _framework - Framework identifier (reserved for future framework-specific parsing)
|
|
144
|
+
* @returns Array of extracted test cases
|
|
145
|
+
*/
|
|
146
|
+
parseTestCases(content, _framework) {
|
|
147
|
+
const cases = [];
|
|
148
|
+
const lines = content.split("\n");
|
|
149
|
+
// Build a line lookup for fast line-number resolution
|
|
150
|
+
const lineOffsets = [];
|
|
151
|
+
let offset = 0;
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
lineOffsets.push(offset);
|
|
154
|
+
offset += line.length + 1; // +1 for newline
|
|
155
|
+
}
|
|
156
|
+
const getLineNumber = (charIndex) => {
|
|
157
|
+
let lo = 0;
|
|
158
|
+
let hi = lineOffsets.length - 1;
|
|
159
|
+
while (lo <= hi) {
|
|
160
|
+
const mid = (lo + hi) >>> 1;
|
|
161
|
+
if (lineOffsets[mid] <= charIndex) {
|
|
162
|
+
lo = mid + 1;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
hi = mid - 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return lo; // 1-based
|
|
169
|
+
};
|
|
170
|
+
// Extract describe blocks for context
|
|
171
|
+
const describeNames = [];
|
|
172
|
+
const describeRe = new RegExp(DESCRIBE_RE.source, "g");
|
|
173
|
+
let match;
|
|
174
|
+
while ((match = describeRe.exec(content)) !== null) {
|
|
175
|
+
describeNames.push(match[1]);
|
|
176
|
+
}
|
|
177
|
+
// Extract it() cases
|
|
178
|
+
const itRe = new RegExp(IT_RE.source, "g");
|
|
179
|
+
while ((match = itRe.exec(content)) !== null) {
|
|
180
|
+
const name = match[1];
|
|
181
|
+
const line = getLineNumber(match.index);
|
|
182
|
+
cases.push({
|
|
183
|
+
name,
|
|
184
|
+
line,
|
|
185
|
+
type: this.inferTestType(name, describeNames),
|
|
186
|
+
tags: this.extractTags(name),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
// Extract test() cases
|
|
190
|
+
const testRe = new RegExp(TEST_RE.source, "g");
|
|
191
|
+
while ((match = testRe.exec(content)) !== null) {
|
|
192
|
+
const name = match[1];
|
|
193
|
+
const line = getLineNumber(match.index);
|
|
194
|
+
// Avoid duplicates (test() and it() may overlap in pattern matching)
|
|
195
|
+
if (!cases.some((c) => c.line === line)) {
|
|
196
|
+
cases.push({
|
|
197
|
+
name,
|
|
198
|
+
line,
|
|
199
|
+
type: this.inferTestType(name, describeNames),
|
|
200
|
+
tags: this.extractTags(name),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return cases;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Build a mapping from source files to their corresponding test files.
|
|
208
|
+
*
|
|
209
|
+
* Uses two strategies:
|
|
210
|
+
* 1. Import-based: checks what each test file imports
|
|
211
|
+
* 2. Convention-based: maps test file names to source files
|
|
212
|
+
*
|
|
213
|
+
* @returns Map from source file path to array of test file paths
|
|
214
|
+
*/
|
|
215
|
+
async buildTestMap() {
|
|
216
|
+
if (this.testFiles.size === 0) {
|
|
217
|
+
await this.discoverTests();
|
|
218
|
+
}
|
|
219
|
+
this.sourceToTests.clear();
|
|
220
|
+
for (const [testPath, testFile] of this.testFiles) {
|
|
221
|
+
// Strategy 1: import-based mapping
|
|
222
|
+
for (const importedFile of testFile.imports) {
|
|
223
|
+
if (!this.isTestFile(importedFile)) {
|
|
224
|
+
const existing = this.sourceToTests.get(importedFile) ?? [];
|
|
225
|
+
if (!existing.includes(testPath)) {
|
|
226
|
+
existing.push(testPath);
|
|
227
|
+
}
|
|
228
|
+
this.sourceToTests.set(importedFile, existing);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Strategy 2: convention-based mapping
|
|
232
|
+
const sourceFile = this.findSourceForTest(testPath);
|
|
233
|
+
if (sourceFile) {
|
|
234
|
+
const existing = this.sourceToTests.get(sourceFile) ?? [];
|
|
235
|
+
if (!existing.includes(testPath)) {
|
|
236
|
+
existing.push(testPath);
|
|
237
|
+
}
|
|
238
|
+
this.sourceToTests.set(sourceFile, existing);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return new Map(this.sourceToTests);
|
|
242
|
+
}
|
|
243
|
+
// ─── Affected Tests ───
|
|
244
|
+
/**
|
|
245
|
+
* Find all tests affected by a set of changed files.
|
|
246
|
+
*
|
|
247
|
+
* Algorithm:
|
|
248
|
+
* 1. Direct: test files that directly import any changed file
|
|
249
|
+
* 2. Transitive: test files that depend on changed files through intermediaries
|
|
250
|
+
* 3. Integration: integration/e2e tests in the same module directory
|
|
251
|
+
*
|
|
252
|
+
* Confidence scoring:
|
|
253
|
+
* - 1.0 if only direct tests found
|
|
254
|
+
* - 0.8 if transitive depth <= 2
|
|
255
|
+
* - 0.6 if transitive depth > 2
|
|
256
|
+
* - -0.1 for each changed file without any test coverage
|
|
257
|
+
*
|
|
258
|
+
* @param changedFiles - Array of absolute file paths that changed
|
|
259
|
+
* @returns Affected test result with confidence and reasoning
|
|
260
|
+
*/
|
|
261
|
+
async findAffectedTests(changedFiles) {
|
|
262
|
+
if (this.sourceToTests.size === 0) {
|
|
263
|
+
await this.buildTestMap();
|
|
264
|
+
}
|
|
265
|
+
const directTests = new Set();
|
|
266
|
+
const transitiveTests = new Set();
|
|
267
|
+
const integrationTests = new Set();
|
|
268
|
+
const reasoning = [];
|
|
269
|
+
let maxDepthUsed = 0;
|
|
270
|
+
let filesWithoutTests = 0;
|
|
271
|
+
const resolvedChanged = changedFiles.map((f) => resolve(f));
|
|
272
|
+
for (const changedFile of resolvedChanged) {
|
|
273
|
+
// 1. Direct tests
|
|
274
|
+
const directForFile = this.sourceToTests.get(changedFile) ?? [];
|
|
275
|
+
if (directForFile.length === 0 && !this.isTestFile(changedFile)) {
|
|
276
|
+
filesWithoutTests++;
|
|
277
|
+
}
|
|
278
|
+
for (const testPath of directForFile) {
|
|
279
|
+
directTests.add(testPath);
|
|
280
|
+
reasoning.push(`Direct: ${relative(this.config.projectPath, testPath)} imports ${relative(this.config.projectPath, changedFile)}`);
|
|
281
|
+
}
|
|
282
|
+
// If the changed file is itself a test, include it
|
|
283
|
+
if (this.isTestFile(changedFile) && this.testFiles.has(changedFile)) {
|
|
284
|
+
directTests.add(changedFile);
|
|
285
|
+
reasoning.push(`Direct: ${relative(this.config.projectPath, changedFile)} is a test file that was modified`);
|
|
286
|
+
}
|
|
287
|
+
// 2. Transitive tests
|
|
288
|
+
const visited = new Set();
|
|
289
|
+
const transitive = this.findTransitiveTestDeps(changedFile, this.config.maxTransitiveDepth, visited);
|
|
290
|
+
for (const testPath of transitive) {
|
|
291
|
+
if (!directTests.has(testPath)) {
|
|
292
|
+
transitiveTests.add(testPath);
|
|
293
|
+
const depth = visited.size;
|
|
294
|
+
if (depth > maxDepthUsed)
|
|
295
|
+
maxDepthUsed = depth;
|
|
296
|
+
reasoning.push(`Transitive: ${relative(this.config.projectPath, testPath)} depends on ${relative(this.config.projectPath, changedFile)} (via reverse deps)`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// 3. Integration tests
|
|
300
|
+
if (this.config.includeIntegrationTests) {
|
|
301
|
+
const integrationForFile = this.findModuleIntegrationTests(changedFile);
|
|
302
|
+
for (const testPath of integrationForFile) {
|
|
303
|
+
if (!directTests.has(testPath) && !transitiveTests.has(testPath)) {
|
|
304
|
+
integrationTests.add(testPath);
|
|
305
|
+
reasoning.push(`Integration: ${relative(this.config.projectPath, testPath)} is an integration test in the same module as ${relative(this.config.projectPath, changedFile)}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Confidence scoring
|
|
311
|
+
let confidence = 1.0;
|
|
312
|
+
if (transitiveTests.size > 0) {
|
|
313
|
+
confidence = maxDepthUsed > 2 ? 0.6 : 0.8;
|
|
314
|
+
}
|
|
315
|
+
confidence = Math.max(0, confidence - filesWithoutTests * 0.1);
|
|
316
|
+
const allTests = new Set([...directTests, ...transitiveTests, ...integrationTests]);
|
|
317
|
+
return {
|
|
318
|
+
changedFiles: resolvedChanged,
|
|
319
|
+
directTests: [...directTests],
|
|
320
|
+
transitiveTests: [...transitiveTests],
|
|
321
|
+
integrationTests: [...integrationTests],
|
|
322
|
+
totalTests: allTests.size,
|
|
323
|
+
confidence: Math.round(confidence * 100) / 100,
|
|
324
|
+
reasoning,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Quick check: does a source file have any tests?
|
|
329
|
+
*
|
|
330
|
+
* @param sourceFile - Absolute path to the source file
|
|
331
|
+
* @returns True if at least one test file covers this source
|
|
332
|
+
*/
|
|
333
|
+
hasTests(sourceFile) {
|
|
334
|
+
const resolved = resolve(sourceFile);
|
|
335
|
+
const tests = this.sourceToTests.get(resolved);
|
|
336
|
+
return tests !== undefined && tests.length > 0;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Get test file paths for a source file.
|
|
340
|
+
*
|
|
341
|
+
* @param sourceFile - Absolute path to the source file
|
|
342
|
+
* @returns Array of absolute test file paths
|
|
343
|
+
*/
|
|
344
|
+
getTestsFor(sourceFile) {
|
|
345
|
+
const resolved = resolve(sourceFile);
|
|
346
|
+
return this.sourceToTests.get(resolved) ?? [];
|
|
347
|
+
}
|
|
348
|
+
// ─── Coverage Analysis ───
|
|
349
|
+
/**
|
|
350
|
+
* Find untested code (coverage gaps).
|
|
351
|
+
*
|
|
352
|
+
* For each source file, checks:
|
|
353
|
+
* 1. Whether a corresponding test file exists
|
|
354
|
+
* 2. Whether exported symbols are referenced in any test
|
|
355
|
+
* 3. Whether error cases are tested
|
|
356
|
+
*
|
|
357
|
+
* @param files - Optional list of files to check (defaults to all source files)
|
|
358
|
+
* @returns Array of coverage gaps
|
|
359
|
+
*/
|
|
360
|
+
async findCoverageGaps(files) {
|
|
361
|
+
if (this.sourceToTests.size === 0) {
|
|
362
|
+
await this.buildTestMap();
|
|
363
|
+
}
|
|
364
|
+
const sourceFiles = files
|
|
365
|
+
? files.map((f) => resolve(f))
|
|
366
|
+
: await this.collectSourceFiles();
|
|
367
|
+
const gaps = [];
|
|
368
|
+
for (const filePath of sourceFiles) {
|
|
369
|
+
if (this.isTestFile(filePath))
|
|
370
|
+
continue;
|
|
371
|
+
const content = await this.readFile(filePath);
|
|
372
|
+
if (!content)
|
|
373
|
+
continue;
|
|
374
|
+
const tests = this.sourceToTests.get(filePath) ?? [];
|
|
375
|
+
const relPath = relative(this.config.projectPath, filePath);
|
|
376
|
+
// No test file at all
|
|
377
|
+
if (tests.length === 0) {
|
|
378
|
+
// Extract exported symbols to report gaps
|
|
379
|
+
const symbols = this.extractExportedSymbols(content);
|
|
380
|
+
if (symbols.length > 0) {
|
|
381
|
+
for (const sym of symbols) {
|
|
382
|
+
gaps.push({
|
|
383
|
+
file: filePath,
|
|
384
|
+
symbol: sym.name,
|
|
385
|
+
symbolType: sym.kind,
|
|
386
|
+
line: sym.line,
|
|
387
|
+
reason: "no test file",
|
|
388
|
+
severity: "high",
|
|
389
|
+
suggestion: `Add test for ${sym.kind} '${sym.name}' from ${relPath}`,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
gaps.push({
|
|
395
|
+
file: filePath,
|
|
396
|
+
symbol: basename(filePath, extname(filePath)),
|
|
397
|
+
symbolType: "module",
|
|
398
|
+
line: 1,
|
|
399
|
+
reason: "no test file",
|
|
400
|
+
severity: "medium",
|
|
401
|
+
suggestion: `Add test file for ${relPath}`,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
// Has test files — check if specific exported symbols are tested
|
|
407
|
+
const symbols = this.extractExportedSymbols(content);
|
|
408
|
+
let testContents = "";
|
|
409
|
+
for (const testPath of tests) {
|
|
410
|
+
const tc = await this.readFile(testPath);
|
|
411
|
+
if (tc)
|
|
412
|
+
testContents += "\n" + tc;
|
|
413
|
+
}
|
|
414
|
+
for (const sym of symbols) {
|
|
415
|
+
const symbolRe = new RegExp(`\\b${this.escapeRegex(sym.name)}\\b`);
|
|
416
|
+
if (!symbolRe.test(testContents)) {
|
|
417
|
+
gaps.push({
|
|
418
|
+
file: filePath,
|
|
419
|
+
symbol: sym.name,
|
|
420
|
+
symbolType: sym.kind,
|
|
421
|
+
line: sym.line,
|
|
422
|
+
reason: "untested function",
|
|
423
|
+
severity: sym.exported ? "high" : "low",
|
|
424
|
+
suggestion: `Add test for ${sym.kind} '${sym.name}' in ${relPath}`,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// Check for error case testing
|
|
429
|
+
const hasThrows = /\bthrow\s+new\b/.test(content);
|
|
430
|
+
const hasErrorTests = /\b(?:toThrow|rejects|throws|expect.*error|expect.*Error)\b/i.test(testContents);
|
|
431
|
+
if (hasThrows && !hasErrorTests) {
|
|
432
|
+
gaps.push({
|
|
433
|
+
file: filePath,
|
|
434
|
+
symbol: basename(filePath, extname(filePath)),
|
|
435
|
+
symbolType: "module",
|
|
436
|
+
line: 1,
|
|
437
|
+
reason: "no error case",
|
|
438
|
+
severity: "medium",
|
|
439
|
+
suggestion: `Add error case tests for ${relPath} (has throw statements but no error assertions in tests)`,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return gaps;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Suggest tests for a specific source file.
|
|
447
|
+
*
|
|
448
|
+
* Generates test case suggestions based on exported symbols,
|
|
449
|
+
* their types, parameters, and common testing patterns.
|
|
450
|
+
*
|
|
451
|
+
* @param sourceFile - Absolute path to the source file
|
|
452
|
+
* @param symbols - Optional list of specific symbol names to suggest tests for
|
|
453
|
+
* @returns Array of test suggestions
|
|
454
|
+
*/
|
|
455
|
+
suggestTests(sourceFile, symbols) {
|
|
456
|
+
const resolved = resolve(sourceFile);
|
|
457
|
+
const suggestions = [];
|
|
458
|
+
// Read from cache if we have the content (best-effort, may need async)
|
|
459
|
+
const testFile = this.inferTestFilePath(resolved);
|
|
460
|
+
const existingTests = this.testFiles.get(testFile);
|
|
461
|
+
const framework = existingTests?.framework ?? "vitest";
|
|
462
|
+
// We need synchronous access, so we work with what we have in cache
|
|
463
|
+
// For full analysis, callers should use findCoverageGaps() first
|
|
464
|
+
const existingTestNames = new Set(existingTests?.testCases.map((tc) => tc.name) ?? []);
|
|
465
|
+
// Generate suggestions based on file naming patterns
|
|
466
|
+
const fileName = basename(resolved, extname(resolved));
|
|
467
|
+
const isClass = /[A-Z]/.test(fileName[0] ?? "");
|
|
468
|
+
const targetSymbol = symbols?.[0] ?? fileName;
|
|
469
|
+
const testCases = [];
|
|
470
|
+
// Happy path
|
|
471
|
+
testCases.push({
|
|
472
|
+
name: `should ${isClass ? "create instance" : "return expected result"}`,
|
|
473
|
+
type: "happy_path",
|
|
474
|
+
description: `Verify ${targetSymbol} works with valid input`,
|
|
475
|
+
inputHint: "valid input matching expected type",
|
|
476
|
+
expectedHint: "expected return value or side effect",
|
|
477
|
+
});
|
|
478
|
+
// Edge case
|
|
479
|
+
testCases.push({
|
|
480
|
+
name: `should handle empty input`,
|
|
481
|
+
type: "edge_case",
|
|
482
|
+
description: `Verify ${targetSymbol} handles edge cases gracefully`,
|
|
483
|
+
inputHint: "empty string, empty array, or zero",
|
|
484
|
+
expectedHint: "graceful handling (default value or empty result)",
|
|
485
|
+
});
|
|
486
|
+
// Error case
|
|
487
|
+
testCases.push({
|
|
488
|
+
name: `should throw on invalid input`,
|
|
489
|
+
type: "error_case",
|
|
490
|
+
description: `Verify ${targetSymbol} rejects invalid input`,
|
|
491
|
+
inputHint: "null, undefined, or malformed data",
|
|
492
|
+
expectedHint: "throws appropriate error",
|
|
493
|
+
});
|
|
494
|
+
// Null check
|
|
495
|
+
testCases.push({
|
|
496
|
+
name: `should handle null/undefined`,
|
|
497
|
+
type: "null_check",
|
|
498
|
+
description: `Verify ${targetSymbol} handles nullish values`,
|
|
499
|
+
inputHint: "null or undefined",
|
|
500
|
+
expectedHint: "does not crash, returns default or throws",
|
|
501
|
+
});
|
|
502
|
+
// Boundary case
|
|
503
|
+
testCases.push({
|
|
504
|
+
name: `should handle boundary values`,
|
|
505
|
+
type: "boundary",
|
|
506
|
+
description: `Verify ${targetSymbol} at boundary conditions`,
|
|
507
|
+
inputHint: "max int, empty collection, single element",
|
|
508
|
+
expectedHint: "correct behavior at boundaries",
|
|
509
|
+
});
|
|
510
|
+
// Filter out already-existing tests
|
|
511
|
+
const filteredCases = testCases.filter((tc) => !existingTestNames.has(tc.name));
|
|
512
|
+
if (filteredCases.length > 0) {
|
|
513
|
+
suggestions.push({
|
|
514
|
+
targetFile: resolved,
|
|
515
|
+
targetSymbol,
|
|
516
|
+
testFile,
|
|
517
|
+
framework,
|
|
518
|
+
testCases: filteredCases,
|
|
519
|
+
priority: this.hasTests(resolved) ? "medium" : "high",
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
return suggestions;
|
|
523
|
+
}
|
|
524
|
+
// ─── Test Execution ───
|
|
525
|
+
/**
|
|
526
|
+
* Build the shell command to run specific test files with a given framework.
|
|
527
|
+
*
|
|
528
|
+
* @param testFiles - Array of test file paths to run
|
|
529
|
+
* @param framework - Test framework identifier
|
|
530
|
+
* @returns Shell command string
|
|
531
|
+
*/
|
|
532
|
+
buildTestCommand(testFiles, framework) {
|
|
533
|
+
const files = testFiles
|
|
534
|
+
.map((f) => relative(this.config.projectPath, resolve(f)))
|
|
535
|
+
.join(" ");
|
|
536
|
+
switch (framework) {
|
|
537
|
+
case "vitest":
|
|
538
|
+
return `npx vitest run ${files}`;
|
|
539
|
+
case "jest":
|
|
540
|
+
return `npx jest ${files}`;
|
|
541
|
+
case "node_test":
|
|
542
|
+
return `node --test ${files}`;
|
|
543
|
+
case "mocha":
|
|
544
|
+
return `npx mocha ${files}`;
|
|
545
|
+
default:
|
|
546
|
+
return `npx vitest run ${files}`;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Parse test runner output into a structured TestRunResult.
|
|
551
|
+
*
|
|
552
|
+
* Supports vitest, jest, mocha, and node:test output formats.
|
|
553
|
+
*
|
|
554
|
+
* @param output - Raw test runner stdout/stderr
|
|
555
|
+
* @param framework - Framework that produced the output
|
|
556
|
+
* @returns Parsed test run result
|
|
557
|
+
*/
|
|
558
|
+
parseTestOutput(output, framework) {
|
|
559
|
+
const result = {
|
|
560
|
+
file: "",
|
|
561
|
+
passed: 0,
|
|
562
|
+
failed: 0,
|
|
563
|
+
skipped: 0,
|
|
564
|
+
duration: 0,
|
|
565
|
+
errors: [],
|
|
566
|
+
timestamp: Date.now(),
|
|
567
|
+
};
|
|
568
|
+
switch (framework) {
|
|
569
|
+
case "vitest":
|
|
570
|
+
this.parseVitestOutput(output, result);
|
|
571
|
+
break;
|
|
572
|
+
case "jest":
|
|
573
|
+
this.parseJestOutput(output, result);
|
|
574
|
+
break;
|
|
575
|
+
case "node_test":
|
|
576
|
+
this.parseNodeTestOutput(output, result);
|
|
577
|
+
break;
|
|
578
|
+
case "mocha":
|
|
579
|
+
this.parseMochaOutput(output, result);
|
|
580
|
+
break;
|
|
581
|
+
default:
|
|
582
|
+
this.parseGenericOutput(output, result);
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
return result;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Record a test run result for history tracking.
|
|
589
|
+
*
|
|
590
|
+
* @param result - Test run result to record
|
|
591
|
+
*/
|
|
592
|
+
recordResult(result) {
|
|
593
|
+
this.testHistory.push(result);
|
|
594
|
+
// Update lastRun on the test file if we have it
|
|
595
|
+
const testFile = this.testFiles.get(resolve(result.file));
|
|
596
|
+
if (testFile) {
|
|
597
|
+
testFile.lastRun = result;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Get test run history for a specific test file.
|
|
602
|
+
*
|
|
603
|
+
* @param testFile - Absolute path to the test file
|
|
604
|
+
* @returns Array of test run results, most recent first
|
|
605
|
+
*/
|
|
606
|
+
getHistory(testFile) {
|
|
607
|
+
const resolved = resolve(testFile);
|
|
608
|
+
return this.testHistory
|
|
609
|
+
.filter((r) => resolve(r.file) === resolved)
|
|
610
|
+
.sort((a, b) => b.timestamp - a.timestamp);
|
|
611
|
+
}
|
|
612
|
+
// ─── Stats ───
|
|
613
|
+
/**
|
|
614
|
+
* Get test coverage statistics for the project.
|
|
615
|
+
*
|
|
616
|
+
* @returns Test coverage summary
|
|
617
|
+
*/
|
|
618
|
+
getStats() {
|
|
619
|
+
const totalTestFiles = this.testFiles.size;
|
|
620
|
+
let totalTestCases = 0;
|
|
621
|
+
const frameworkBreakdown = {};
|
|
622
|
+
for (const testFile of this.testFiles.values()) {
|
|
623
|
+
totalTestCases += testFile.testCases.length;
|
|
624
|
+
frameworkBreakdown[testFile.framework] =
|
|
625
|
+
(frameworkBreakdown[testFile.framework] ?? 0) + 1;
|
|
626
|
+
}
|
|
627
|
+
const sourceFilesWithTests = this.sourceToTests.size;
|
|
628
|
+
// Count unique source files we know about (from test imports)
|
|
629
|
+
const allSourceFiles = new Set();
|
|
630
|
+
for (const testFile of this.testFiles.values()) {
|
|
631
|
+
for (const imp of testFile.imports) {
|
|
632
|
+
if (!this.isTestFile(imp)) {
|
|
633
|
+
allSourceFiles.add(imp);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// Also add source files from sourceToTests that have no tests
|
|
638
|
+
for (const src of this.sourceToTests.keys()) {
|
|
639
|
+
allSourceFiles.add(src);
|
|
640
|
+
}
|
|
641
|
+
const totalSourceFiles = Math.max(allSourceFiles.size, sourceFilesWithTests);
|
|
642
|
+
const sourceFilesWithoutTests = totalSourceFiles - sourceFilesWithTests;
|
|
643
|
+
const coveragePercent = totalSourceFiles > 0
|
|
644
|
+
? Math.round((sourceFilesWithTests / totalSourceFiles) * 100)
|
|
645
|
+
: 0;
|
|
646
|
+
return {
|
|
647
|
+
totalTestFiles,
|
|
648
|
+
totalTestCases,
|
|
649
|
+
sourceFilesWithTests,
|
|
650
|
+
sourceFilesWithoutTests,
|
|
651
|
+
coveragePercent,
|
|
652
|
+
frameworkBreakdown,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
// ─── Private Methods ───
|
|
656
|
+
/**
|
|
657
|
+
* Find test files matching configured patterns.
|
|
658
|
+
* Walks the project directory and filters by test file conventions.
|
|
659
|
+
*/
|
|
660
|
+
async findTestFiles() {
|
|
661
|
+
const allFiles = await this.walkDirectory(this.config.projectPath);
|
|
662
|
+
return allFiles.filter((f) => this.isTestFile(f));
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Check if a file path matches test file conventions.
|
|
666
|
+
*
|
|
667
|
+
* @param filePath - File path to check
|
|
668
|
+
* @returns True if the file is a test file
|
|
669
|
+
*/
|
|
670
|
+
isTestFile(filePath) {
|
|
671
|
+
const rel = relative(this.config.projectPath, filePath);
|
|
672
|
+
return TEST_FILE_RE.test(rel) || TEST_DIR_RE.test(rel);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Find the source file for a test file using naming conventions.
|
|
676
|
+
*
|
|
677
|
+
* Conventions:
|
|
678
|
+
* - `foo.test.ts` → `foo.ts`
|
|
679
|
+
* - `foo.spec.ts` → `foo.ts`
|
|
680
|
+
* - `__tests__/foo.ts` → `../foo.ts`
|
|
681
|
+
*
|
|
682
|
+
* @param testFile - Absolute test file path
|
|
683
|
+
* @returns Absolute source file path, or null if not found
|
|
684
|
+
*/
|
|
685
|
+
findSourceForTest(testFile) {
|
|
686
|
+
const dir = dirname(testFile);
|
|
687
|
+
const base = basename(testFile);
|
|
688
|
+
// foo.test.ts → foo.ts
|
|
689
|
+
const sourceMatch = base.match(/^(.+)\.(test|spec)\.(ts|tsx|js|jsx)$/);
|
|
690
|
+
if (sourceMatch) {
|
|
691
|
+
const sourceName = sourceMatch[1];
|
|
692
|
+
const ext = sourceMatch[3];
|
|
693
|
+
// Check same directory
|
|
694
|
+
const sameDirPath = join(dir, `${sourceName}.${ext}`);
|
|
695
|
+
// Check parent directory (for __tests__/ convention)
|
|
696
|
+
const parentDirPath = join(dirname(dir), `${sourceName}.${ext}`);
|
|
697
|
+
// Check src/ sibling (for test/ convention)
|
|
698
|
+
const srcDirPath = join(dirname(dir), "src", `${sourceName}.${ext}`);
|
|
699
|
+
// Return the first plausible path (we can't verify existence synchronously)
|
|
700
|
+
if (dirname(dir).endsWith("__tests__") || basename(dir) === "__tests__") {
|
|
701
|
+
return parentDirPath;
|
|
702
|
+
}
|
|
703
|
+
if (basename(dir) === "test") {
|
|
704
|
+
return srcDirPath;
|
|
705
|
+
}
|
|
706
|
+
return sameDirPath;
|
|
707
|
+
}
|
|
708
|
+
// __tests__/foo.ts → ../foo.ts
|
|
709
|
+
if (basename(dir) === "__tests__") {
|
|
710
|
+
return join(dirname(dir), base);
|
|
711
|
+
}
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Traverse reverse dependency graph to find test files that
|
|
716
|
+
* transitively depend on a given source file.
|
|
717
|
+
*
|
|
718
|
+
* @param file - Starting file path
|
|
719
|
+
* @param depth - Maximum traversal depth
|
|
720
|
+
* @param visited - Set of already-visited paths (cycle prevention)
|
|
721
|
+
* @returns Array of test file paths found via transitive dependencies
|
|
722
|
+
*/
|
|
723
|
+
findTransitiveTestDeps(file, depth, visited) {
|
|
724
|
+
if (depth <= 0)
|
|
725
|
+
return [];
|
|
726
|
+
visited.add(file);
|
|
727
|
+
const results = [];
|
|
728
|
+
// Find all files that import this file (reverse lookup from sourceToTests)
|
|
729
|
+
// We need to check all test files' imports
|
|
730
|
+
for (const [testPath, testFile] of this.testFiles) {
|
|
731
|
+
if (visited.has(testPath))
|
|
732
|
+
continue;
|
|
733
|
+
// Check if any of the test's imports transitively reach our file
|
|
734
|
+
for (const importedFile of testFile.imports) {
|
|
735
|
+
if (importedFile === file && !visited.has(testPath)) {
|
|
736
|
+
results.push(testPath);
|
|
737
|
+
visited.add(testPath);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// Also check non-test files that import this file, then find their tests
|
|
742
|
+
for (const [sourcePath, testPaths] of this.sourceToTests) {
|
|
743
|
+
if (visited.has(sourcePath))
|
|
744
|
+
continue;
|
|
745
|
+
// Check if any test of this source file imports our target file
|
|
746
|
+
// This is a simplified transitive check — for deeper traversal,
|
|
747
|
+
// we'd need the full dependency graph from DependencyAnalyzer
|
|
748
|
+
for (const testPath of testPaths) {
|
|
749
|
+
if (visited.has(testPath))
|
|
750
|
+
continue;
|
|
751
|
+
const testFile = this.testFiles.get(testPath);
|
|
752
|
+
if (!testFile)
|
|
753
|
+
continue;
|
|
754
|
+
for (const imp of testFile.imports) {
|
|
755
|
+
if (imp === file) {
|
|
756
|
+
results.push(testPath);
|
|
757
|
+
visited.add(testPath);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// Recurse into dependents at reduced depth
|
|
763
|
+
// Simplified: look for source files that import the current file
|
|
764
|
+
for (const [testPath, testFile] of this.testFiles) {
|
|
765
|
+
if (visited.has(testPath))
|
|
766
|
+
continue;
|
|
767
|
+
for (const imp of testFile.imports) {
|
|
768
|
+
if (!visited.has(imp) && !this.isTestFile(imp)) {
|
|
769
|
+
const deeper = this.findTransitiveTestDeps(imp, depth - 1, visited);
|
|
770
|
+
results.push(...deeper);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return [...new Set(results)];
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Find integration/e2e test files in the same directory or module.
|
|
778
|
+
*
|
|
779
|
+
* @param sourceFile - Source file to find integration tests for
|
|
780
|
+
* @returns Array of integration test file paths
|
|
781
|
+
*/
|
|
782
|
+
findModuleIntegrationTests(sourceFile) {
|
|
783
|
+
const dir = dirname(sourceFile);
|
|
784
|
+
const results = [];
|
|
785
|
+
for (const [testPath] of this.testFiles) {
|
|
786
|
+
if (INTEGRATION_TEST_RE.test(testPath) && dirname(testPath) === dir) {
|
|
787
|
+
results.push(testPath);
|
|
788
|
+
}
|
|
789
|
+
// Also check parent directory for integration tests
|
|
790
|
+
if (INTEGRATION_TEST_RE.test(testPath) && dirname(testPath) === dirname(dir)) {
|
|
791
|
+
results.push(testPath);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return results;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Collect all source (non-test) files in the project.
|
|
798
|
+
*/
|
|
799
|
+
async collectSourceFiles() {
|
|
800
|
+
const allFiles = await this.walkDirectory(this.config.projectPath);
|
|
801
|
+
return allFiles.filter((f) => !this.isTestFile(f));
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Read a file's content, returning empty string on failure.
|
|
805
|
+
*
|
|
806
|
+
* @param path - Absolute file path
|
|
807
|
+
* @returns File content or empty string
|
|
808
|
+
*/
|
|
809
|
+
async readFile(path) {
|
|
810
|
+
try {
|
|
811
|
+
return await readFile(path, "utf-8");
|
|
812
|
+
}
|
|
813
|
+
catch {
|
|
814
|
+
return "";
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Recursively walk a directory, collecting source files.
|
|
819
|
+
* Skips ignored directories (node_modules, dist, etc.).
|
|
820
|
+
*/
|
|
821
|
+
async walkDirectory(dir) {
|
|
822
|
+
const results = [];
|
|
823
|
+
let entries;
|
|
824
|
+
try {
|
|
825
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
return results;
|
|
829
|
+
}
|
|
830
|
+
const ignoreSet = new Set(this.config.ignorePatterns);
|
|
831
|
+
for (const entry of entries) {
|
|
832
|
+
const fullPath = join(dir, entry.name);
|
|
833
|
+
if (entry.isDirectory()) {
|
|
834
|
+
if (!ignoreSet.has(entry.name)) {
|
|
835
|
+
const sub = await this.walkDirectory(fullPath);
|
|
836
|
+
results.push(...sub);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
else if (entry.isFile() && SOURCE_EXTENSIONS.has(extname(entry.name))) {
|
|
840
|
+
results.push(fullPath);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return results;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Parse import paths from test file content and resolve them to absolute paths.
|
|
847
|
+
*/
|
|
848
|
+
parseTestImports(content, fromFile) {
|
|
849
|
+
const imports = [];
|
|
850
|
+
const dir = dirname(fromFile);
|
|
851
|
+
let match;
|
|
852
|
+
// Standard imports
|
|
853
|
+
const importRe = new RegExp(IMPORT_RE.source, "g");
|
|
854
|
+
while ((match = importRe.exec(content)) !== null) {
|
|
855
|
+
const source = match[4];
|
|
856
|
+
if (source.startsWith(".")) {
|
|
857
|
+
const resolved = this.resolveImportPath(dir, source);
|
|
858
|
+
if (resolved)
|
|
859
|
+
imports.push(resolved);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
// Re-exports
|
|
863
|
+
const reExportRe = new RegExp(RE_EXPORT_RE.source, "g");
|
|
864
|
+
while ((match = reExportRe.exec(content)) !== null) {
|
|
865
|
+
const source = match[2];
|
|
866
|
+
if (source.startsWith(".")) {
|
|
867
|
+
const resolved = this.resolveImportPath(dir, source);
|
|
868
|
+
if (resolved)
|
|
869
|
+
imports.push(resolved);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
// CJS requires
|
|
873
|
+
const requireRe = new RegExp(REQUIRE_RE.source, "g");
|
|
874
|
+
while ((match = requireRe.exec(content)) !== null) {
|
|
875
|
+
const source = match[1];
|
|
876
|
+
if (source.startsWith(".")) {
|
|
877
|
+
const resolved = this.resolveImportPath(dir, source);
|
|
878
|
+
if (resolved)
|
|
879
|
+
imports.push(resolved);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return [...new Set(imports)];
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Resolve a relative import specifier to an absolute path.
|
|
886
|
+
* Handles .js → .ts resolution for ESM TypeScript projects.
|
|
887
|
+
*/
|
|
888
|
+
resolveImportPath(fromDir, importPath) {
|
|
889
|
+
let resolved = resolve(fromDir, importPath);
|
|
890
|
+
// Strip .js extension and try .ts (ESM TS convention)
|
|
891
|
+
if (resolved.endsWith(".js")) {
|
|
892
|
+
const tsPath = resolved.slice(0, -3) + ".ts";
|
|
893
|
+
return tsPath;
|
|
894
|
+
}
|
|
895
|
+
// If it already has a known extension, return as-is
|
|
896
|
+
if (SOURCE_EXTENSIONS.has(extname(resolved))) {
|
|
897
|
+
return resolved;
|
|
898
|
+
}
|
|
899
|
+
// Try adding .ts extension (most common in this codebase)
|
|
900
|
+
return resolved + ".ts";
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Infer the test type from test name and describe block context.
|
|
904
|
+
*/
|
|
905
|
+
inferTestType(testName, describeNames) {
|
|
906
|
+
const combined = `${testName} ${describeNames.join(" ")}`.toLowerCase();
|
|
907
|
+
if (/\b(?:e2e|end.to.end|playwright|cypress|browser)\b/.test(combined)) {
|
|
908
|
+
return "e2e";
|
|
909
|
+
}
|
|
910
|
+
if (/\b(?:integration|api|database|db|http|server|route)\b/.test(combined)) {
|
|
911
|
+
return "integration";
|
|
912
|
+
}
|
|
913
|
+
return "unit";
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Extract tags from a test name.
|
|
917
|
+
* Tags are inferred from keywords in the test description.
|
|
918
|
+
*/
|
|
919
|
+
extractTags(testName) {
|
|
920
|
+
const tags = [];
|
|
921
|
+
const lower = testName.toLowerCase();
|
|
922
|
+
if (/\b(?:error|fail|throw|reject)\b/.test(lower))
|
|
923
|
+
tags.push("error");
|
|
924
|
+
if (/\b(?:edge|boundary|limit|max|min)\b/.test(lower))
|
|
925
|
+
tags.push("edge-case");
|
|
926
|
+
if (/\b(?:async|await|promise)\b/.test(lower))
|
|
927
|
+
tags.push("async");
|
|
928
|
+
if (/\b(?:mock|stub|spy)\b/.test(lower))
|
|
929
|
+
tags.push("mock");
|
|
930
|
+
if (/\b(?:snapshot)\b/.test(lower))
|
|
931
|
+
tags.push("snapshot");
|
|
932
|
+
if (/\b(?:performance|perf|bench)\b/.test(lower))
|
|
933
|
+
tags.push("performance");
|
|
934
|
+
if (/\b(?:security|auth|permission)\b/.test(lower))
|
|
935
|
+
tags.push("security");
|
|
936
|
+
if (/\b(?:regression)\b/.test(lower))
|
|
937
|
+
tags.push("regression");
|
|
938
|
+
return tags;
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Extract exported symbols from file content.
|
|
942
|
+
*/
|
|
943
|
+
extractExportedSymbols(content) {
|
|
944
|
+
const symbols = [];
|
|
945
|
+
const lines = content.split("\n");
|
|
946
|
+
// Build line offset map
|
|
947
|
+
const lineOffsets = [];
|
|
948
|
+
let offset = 0;
|
|
949
|
+
for (const line of lines) {
|
|
950
|
+
lineOffsets.push(offset);
|
|
951
|
+
offset += line.length + 1;
|
|
952
|
+
}
|
|
953
|
+
const getLineNumber = (charIndex) => {
|
|
954
|
+
let lo = 0;
|
|
955
|
+
let hi = lineOffsets.length - 1;
|
|
956
|
+
while (lo <= hi) {
|
|
957
|
+
const mid = (lo + hi) >>> 1;
|
|
958
|
+
if (lineOffsets[mid] <= charIndex) {
|
|
959
|
+
lo = mid + 1;
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
hi = mid - 1;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return lo;
|
|
966
|
+
};
|
|
967
|
+
const exportRe = new RegExp(EXPORT_SYMBOL_RE.source, "g");
|
|
968
|
+
let match;
|
|
969
|
+
while ((match = exportRe.exec(content)) !== null) {
|
|
970
|
+
const name = match[1];
|
|
971
|
+
const line = getLineNumber(match.index);
|
|
972
|
+
// Determine kind from the match
|
|
973
|
+
const fullMatch = match[0];
|
|
974
|
+
let kind = "variable";
|
|
975
|
+
if (/\bfunction\b/.test(fullMatch))
|
|
976
|
+
kind = "function";
|
|
977
|
+
else if (/\bclass\b/.test(fullMatch))
|
|
978
|
+
kind = "class";
|
|
979
|
+
else if (/\binterface\b/.test(fullMatch))
|
|
980
|
+
kind = "interface";
|
|
981
|
+
else if (/\btype\b/.test(fullMatch))
|
|
982
|
+
kind = "type";
|
|
983
|
+
else if (/\benum\b/.test(fullMatch))
|
|
984
|
+
kind = "enum";
|
|
985
|
+
symbols.push({ name, kind, line, exported: true });
|
|
986
|
+
}
|
|
987
|
+
return symbols;
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Infer the expected test file path for a source file.
|
|
991
|
+
*/
|
|
992
|
+
inferTestFilePath(sourceFile) {
|
|
993
|
+
const dir = dirname(sourceFile);
|
|
994
|
+
const ext = extname(sourceFile);
|
|
995
|
+
const base = basename(sourceFile, ext);
|
|
996
|
+
return join(dir, `${base}.test${ext}`);
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Escape special regex characters in a string.
|
|
1000
|
+
*/
|
|
1001
|
+
escapeRegex(str) {
|
|
1002
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1003
|
+
}
|
|
1004
|
+
// ─── Output Parsers (Private) ───
|
|
1005
|
+
/**
|
|
1006
|
+
* Parse vitest output format.
|
|
1007
|
+
*
|
|
1008
|
+
* Vitest output example:
|
|
1009
|
+
* ```
|
|
1010
|
+
* ✓ src/foo.test.ts (3 tests) 45ms
|
|
1011
|
+
* ✓ should do X
|
|
1012
|
+
* × should do Y
|
|
1013
|
+
* Tests 2 passed | 1 failed
|
|
1014
|
+
* ```
|
|
1015
|
+
*/
|
|
1016
|
+
parseVitestOutput(output, result) {
|
|
1017
|
+
// Match summary line: "Tests N passed | N failed | N skipped"
|
|
1018
|
+
const summaryMatch = output.match(/Tests\s+(?:(\d+)\s+passed)?(?:\s*\|\s*(\d+)\s+failed)?(?:\s*\|\s*(\d+)\s+skipped)?/);
|
|
1019
|
+
if (summaryMatch) {
|
|
1020
|
+
result.passed = parseInt(summaryMatch[1] ?? "0", 10);
|
|
1021
|
+
result.failed = parseInt(summaryMatch[2] ?? "0", 10);
|
|
1022
|
+
result.skipped = parseInt(summaryMatch[3] ?? "0", 10);
|
|
1023
|
+
}
|
|
1024
|
+
// Match duration: "Duration 1.23s" or "Time 45ms"
|
|
1025
|
+
const durationMatch = output.match(/(?:Duration|Time)\s+([\d.]+)\s*(s|ms)/);
|
|
1026
|
+
if (durationMatch) {
|
|
1027
|
+
const value = parseFloat(durationMatch[1]);
|
|
1028
|
+
result.duration = durationMatch[2] === "s" ? value * 1000 : value;
|
|
1029
|
+
}
|
|
1030
|
+
// Extract failures
|
|
1031
|
+
this.extractFailureBlocks(output, result);
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Parse jest output format.
|
|
1035
|
+
*
|
|
1036
|
+
* Jest output example:
|
|
1037
|
+
* ```
|
|
1038
|
+
* Tests: 1 failed, 2 passed, 3 total
|
|
1039
|
+
* Time: 1.234 s
|
|
1040
|
+
* ```
|
|
1041
|
+
*/
|
|
1042
|
+
parseJestOutput(output, result) {
|
|
1043
|
+
const summaryMatch = output.match(/Tests:\s+(?:(\d+)\s+failed,\s+)?(?:(\d+)\s+passed,\s+)?(\d+)\s+total/);
|
|
1044
|
+
if (summaryMatch) {
|
|
1045
|
+
result.failed = parseInt(summaryMatch[1] ?? "0", 10);
|
|
1046
|
+
result.passed = parseInt(summaryMatch[2] ?? "0", 10);
|
|
1047
|
+
const total = parseInt(summaryMatch[3] ?? "0", 10);
|
|
1048
|
+
result.skipped = total - result.passed - result.failed;
|
|
1049
|
+
}
|
|
1050
|
+
const durationMatch = output.match(/Time:\s+([\d.]+)\s*(s|ms)/);
|
|
1051
|
+
if (durationMatch) {
|
|
1052
|
+
const value = parseFloat(durationMatch[1]);
|
|
1053
|
+
result.duration = durationMatch[2] === "s" ? value * 1000 : value;
|
|
1054
|
+
}
|
|
1055
|
+
this.extractFailureBlocks(output, result);
|
|
1056
|
+
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Parse node:test output format (TAP-like).
|
|
1059
|
+
*
|
|
1060
|
+
* Node test output example:
|
|
1061
|
+
* ```
|
|
1062
|
+
* TAP version 13
|
|
1063
|
+
* ok 1 - should work
|
|
1064
|
+
* not ok 2 - should fail
|
|
1065
|
+
* 1..2
|
|
1066
|
+
* # tests 2
|
|
1067
|
+
* # pass 1
|
|
1068
|
+
* # fail 1
|
|
1069
|
+
* ```
|
|
1070
|
+
*/
|
|
1071
|
+
parseNodeTestOutput(output, result) {
|
|
1072
|
+
const passMatch = output.match(/#\s*pass\s+(\d+)/);
|
|
1073
|
+
const failMatch = output.match(/#\s*fail\s+(\d+)/);
|
|
1074
|
+
const skipMatch = output.match(/#\s*skip\s+(\d+)/);
|
|
1075
|
+
result.passed = parseInt(passMatch?.[1] ?? "0", 10);
|
|
1076
|
+
result.failed = parseInt(failMatch?.[1] ?? "0", 10);
|
|
1077
|
+
result.skipped = parseInt(skipMatch?.[1] ?? "0", 10);
|
|
1078
|
+
const durationMatch = output.match(/#\s*duration_ms\s+([\d.]+)/);
|
|
1079
|
+
if (durationMatch) {
|
|
1080
|
+
result.duration = parseFloat(durationMatch[1]);
|
|
1081
|
+
}
|
|
1082
|
+
// Extract "not ok" lines as failures
|
|
1083
|
+
const failLineRe = /not ok \d+ - (.+)/g;
|
|
1084
|
+
let match;
|
|
1085
|
+
while ((match = failLineRe.exec(output)) !== null) {
|
|
1086
|
+
result.errors.push({
|
|
1087
|
+
testName: match[1],
|
|
1088
|
+
message: match[1],
|
|
1089
|
+
file: result.file,
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Parse mocha output format.
|
|
1095
|
+
*
|
|
1096
|
+
* Mocha output example:
|
|
1097
|
+
* ```
|
|
1098
|
+
* 2 passing (45ms)
|
|
1099
|
+
* 1 failing
|
|
1100
|
+
* ```
|
|
1101
|
+
*/
|
|
1102
|
+
parseMochaOutput(output, result) {
|
|
1103
|
+
const passMatch = output.match(/(\d+)\s+passing/);
|
|
1104
|
+
const failMatch = output.match(/(\d+)\s+failing/);
|
|
1105
|
+
const pendingMatch = output.match(/(\d+)\s+pending/);
|
|
1106
|
+
result.passed = parseInt(passMatch?.[1] ?? "0", 10);
|
|
1107
|
+
result.failed = parseInt(failMatch?.[1] ?? "0", 10);
|
|
1108
|
+
result.skipped = parseInt(pendingMatch?.[1] ?? "0", 10);
|
|
1109
|
+
const durationMatch = output.match(/passing\s+\((\d+)(ms|s)\)/);
|
|
1110
|
+
if (durationMatch) {
|
|
1111
|
+
const value = parseInt(durationMatch[1], 10);
|
|
1112
|
+
result.duration = durationMatch[2] === "s" ? value * 1000 : value;
|
|
1113
|
+
}
|
|
1114
|
+
this.extractFailureBlocks(output, result);
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Generic output parser — tries common patterns.
|
|
1118
|
+
*/
|
|
1119
|
+
parseGenericOutput(output, result) {
|
|
1120
|
+
// Try to find pass/fail counts from any format
|
|
1121
|
+
const passMatch = output.match(/(\d+)\s+(?:pass(?:ed|ing)?)/i);
|
|
1122
|
+
const failMatch = output.match(/(\d+)\s+(?:fail(?:ed|ing|ure)?)/i);
|
|
1123
|
+
result.passed = parseInt(passMatch?.[1] ?? "0", 10);
|
|
1124
|
+
result.failed = parseInt(failMatch?.[1] ?? "0", 10);
|
|
1125
|
+
this.extractFailureBlocks(output, result);
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Extract failure blocks from test output.
|
|
1129
|
+
* Looks for common failure patterns across frameworks.
|
|
1130
|
+
*/
|
|
1131
|
+
extractFailureBlocks(output, result) {
|
|
1132
|
+
// Common pattern: "FAIL" or "✕" or "×" followed by test name
|
|
1133
|
+
const failRe = /(?:FAIL|✕|×|✗)\s+(.+?)(?:\n|$)/g;
|
|
1134
|
+
let match;
|
|
1135
|
+
while ((match = failRe.exec(output)) !== null) {
|
|
1136
|
+
const testName = match[1].trim();
|
|
1137
|
+
if (testName && !result.errors.some((e) => e.testName === testName)) {
|
|
1138
|
+
result.errors.push({
|
|
1139
|
+
testName,
|
|
1140
|
+
message: testName,
|
|
1141
|
+
file: result.file,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
// Extract assertion errors: "Expected: X" / "Received: Y"
|
|
1146
|
+
const assertRe = /Expected:\s*(.+?)\n\s*Received:\s*(.+?)(?:\n|$)/g;
|
|
1147
|
+
while ((match = assertRe.exec(output)) !== null) {
|
|
1148
|
+
const lastError = result.errors[result.errors.length - 1];
|
|
1149
|
+
if (lastError) {
|
|
1150
|
+
lastError.expected = match[1].trim();
|
|
1151
|
+
lastError.actual = match[2].trim();
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// Extract file:line references
|
|
1155
|
+
const fileLineRe = /at\s+(?:\S+\s+)?\(?(.+?):(\d+):\d+\)?/g;
|
|
1156
|
+
while ((match = fileLineRe.exec(output)) !== null) {
|
|
1157
|
+
const lastError = result.errors[result.errors.length - 1];
|
|
1158
|
+
if (lastError && !lastError.line) {
|
|
1159
|
+
lastError.file = match[1];
|
|
1160
|
+
lastError.line = parseInt(match[2], 10);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
//# sourceMappingURL=test-intelligence.js.map
|