@nhonh/qabot 0.4.1 → 0.5.1
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/package.json +1 -1
- package/src/ai/ai-engine.js +47 -1
- package/src/ai/prompt-builder.js +43 -4
- package/src/cli/commands/generate.js +120 -54
- package/src/core/constants.js +1 -1
- package/src/runners/jest-runner.js +98 -28
package/package.json
CHANGED
package/src/ai/ai-engine.js
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
buildAnalysisPrompt,
|
|
3
3
|
buildGenerationPrompt,
|
|
4
4
|
buildRecommendationPrompt,
|
|
5
|
+
buildFixPrompt,
|
|
5
6
|
} from "./prompt-builder.js";
|
|
6
7
|
import { AI_PROVIDER_DEFAULTS } from "../core/constants.js";
|
|
7
8
|
|
|
@@ -93,9 +94,13 @@ export class AIEngine {
|
|
|
93
94
|
if (!isFirst) {
|
|
94
95
|
batchContext.existingTestCode = "";
|
|
95
96
|
batchContext.sourceCode = "";
|
|
97
|
+
batchContext.isContinuation = true;
|
|
96
98
|
}
|
|
97
99
|
const prompt = buildGenerationPrompt(batches[i], batchContext);
|
|
98
|
-
|
|
100
|
+
let code = await this.complete(prompt);
|
|
101
|
+
if (!isFirst) {
|
|
102
|
+
code = stripDuplicateImports(code);
|
|
103
|
+
}
|
|
99
104
|
fullCode += (isFirst ? "" : "\n\n") + code;
|
|
100
105
|
}
|
|
101
106
|
} finally {
|
|
@@ -105,6 +110,17 @@ export class AIEngine {
|
|
|
105
110
|
return fullCode;
|
|
106
111
|
}
|
|
107
112
|
|
|
113
|
+
async fixTestCode(code, errorMessage, context) {
|
|
114
|
+
const prompt = buildFixPrompt(code, errorMessage, context);
|
|
115
|
+
const savedMaxTokens = this.maxTokens;
|
|
116
|
+
this.maxTokens = Math.max(this.maxTokens, 8192);
|
|
117
|
+
try {
|
|
118
|
+
return await this.complete(prompt);
|
|
119
|
+
} finally {
|
|
120
|
+
this.maxTokens = savedMaxTokens;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
108
124
|
async generateRecommendations(results) {
|
|
109
125
|
const prompt = buildRecommendationPrompt(results);
|
|
110
126
|
return this.complete(prompt);
|
|
@@ -286,6 +302,36 @@ export class AIEngine {
|
|
|
286
302
|
}
|
|
287
303
|
}
|
|
288
304
|
|
|
305
|
+
function stripDuplicateImports(code) {
|
|
306
|
+
const lines = code.split("\n");
|
|
307
|
+
let inMockBlock = false;
|
|
308
|
+
let braceDepth = 0;
|
|
309
|
+
const filtered = [];
|
|
310
|
+
|
|
311
|
+
for (const line of lines) {
|
|
312
|
+
const trimmed = line.trim();
|
|
313
|
+
|
|
314
|
+
if (trimmed.startsWith("import ")) continue;
|
|
315
|
+
if (trimmed.startsWith("jest.mock(")) {
|
|
316
|
+
inMockBlock = true;
|
|
317
|
+
braceDepth = 0;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (inMockBlock) {
|
|
321
|
+
braceDepth += (line.match(/\{/g) || []).length;
|
|
322
|
+
braceDepth -= (line.match(/\}/g) || []).length;
|
|
323
|
+
if (braceDepth <= 0 && trimmed.endsWith(");")) {
|
|
324
|
+
inMockBlock = false;
|
|
325
|
+
}
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
filtered.push(line);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return filtered.join("\n").trim();
|
|
333
|
+
}
|
|
334
|
+
|
|
289
335
|
function resolveApiKey(config) {
|
|
290
336
|
if (config.apiKey) return config.apiKey;
|
|
291
337
|
if (config.apiKeyEnv) return process.env[config.apiKeyEnv] || "";
|
package/src/ai/prompt-builder.js
CHANGED
|
@@ -44,6 +44,11 @@ export function buildGenerationPrompt(testCases, context) {
|
|
|
44
44
|
: "";
|
|
45
45
|
|
|
46
46
|
const importPath = context.importPath || "../index";
|
|
47
|
+
const isContinuation = context.isContinuation || false;
|
|
48
|
+
|
|
49
|
+
const continuationRule = isContinuation
|
|
50
|
+
? `\nIMPORTANT: This is a CONTINUATION batch. Do NOT include any import statements, require() calls, or jest.mock() calls. Write ONLY describe() and it() blocks. The imports and mocks are already defined in a previous batch.\n`
|
|
51
|
+
: "";
|
|
47
52
|
|
|
48
53
|
return `You are a senior test engineer writing production-quality test code.
|
|
49
54
|
|
|
@@ -52,16 +57,15 @@ export function buildGenerationPrompt(testCases, context) {
|
|
|
52
57
|
- Test Runner: ${context.runner || "jest"}
|
|
53
58
|
- Language: JavaScript/JSX
|
|
54
59
|
- Import path for module under test: "${importPath}"
|
|
55
|
-
${sourceSection}${existingTestSection}
|
|
60
|
+
${sourceSection}${existingTestSection}${continuationRule}
|
|
56
61
|
## Test Cases to Implement
|
|
57
62
|
${JSON.stringify(testCases, null, 2)}
|
|
58
63
|
|
|
59
64
|
## Rules
|
|
60
|
-
1. Write a COMPLETE, RUNNABLE test file with all imports
|
|
65
|
+
1. ${isContinuation ? "Write ONLY describe/it blocks — NO imports, NO jest.mock" : "Write a COMPLETE, RUNNABLE test file with all imports"}
|
|
61
66
|
2. Use @testing-library/react for rendering components
|
|
62
67
|
3. Use @testing-library/user-event for user interactions
|
|
63
|
-
4. Use jest.mock() for module mocking
|
|
64
|
-
5. Use screen queries: getByRole, getByText, getByTestId
|
|
68
|
+
${isContinuation ? "" : "4. Use jest.mock() for module mocking\n"}5. Use screen queries: getByRole, getByText, getByTestId
|
|
65
69
|
6. Use waitFor and findBy* for async assertions
|
|
66
70
|
7. Each test must be independent — no shared mutable state
|
|
67
71
|
8. Use descriptive it() names matching the test case names above
|
|
@@ -94,6 +98,41 @@ Provide 3-5 actionable recommendations to improve test quality. Return as JSON a
|
|
|
94
98
|
Return ONLY valid JSON array.`;
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
export function buildFixPrompt(code, errorMessage, context) {
|
|
102
|
+
return `You are a senior test engineer fixing a broken test file.
|
|
103
|
+
|
|
104
|
+
## Error from test runner
|
|
105
|
+
\`\`\`
|
|
106
|
+
${truncate(errorMessage, 2000)}
|
|
107
|
+
\`\`\`
|
|
108
|
+
|
|
109
|
+
## Current test file (has errors)
|
|
110
|
+
\`\`\`javascript
|
|
111
|
+
${truncate(code, 10000)}
|
|
112
|
+
\`\`\`
|
|
113
|
+
|
|
114
|
+
## Project Context
|
|
115
|
+
- Framework: ${context.framework || "react"}
|
|
116
|
+
- Test Runner: ${context.runner || "jest"}
|
|
117
|
+
- Module aliases: ~/ maps to src/
|
|
118
|
+
- Tests are in: src/<feature>/tests/<Name>.test.js
|
|
119
|
+
|
|
120
|
+
## Common fixes needed
|
|
121
|
+
1. Duplicate imports — remove duplicate import/require lines
|
|
122
|
+
2. Wrong import paths — use ~/ alias for src/ imports
|
|
123
|
+
3. Missing mock — add jest.mock() for unmocked dependencies
|
|
124
|
+
4. Syntax errors — fix unclosed brackets, parens, template literals
|
|
125
|
+
5. Wrong API usage — check if mocked functions match actual API
|
|
126
|
+
|
|
127
|
+
## Rules
|
|
128
|
+
- Return the COMPLETE fixed test file
|
|
129
|
+
- Do NOT remove any test cases — fix them
|
|
130
|
+
- Do NOT add markdown fences
|
|
131
|
+
- Return ONLY the JavaScript code
|
|
132
|
+
|
|
133
|
+
Fix ALL errors and return the complete corrected file.`;
|
|
134
|
+
}
|
|
135
|
+
|
|
97
136
|
function truncate(str, maxLen) {
|
|
98
137
|
if (str.length <= maxLen) return str;
|
|
99
138
|
return str.slice(0, maxLen) + "\n... (truncated)";
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import ora from "ora";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { writeFile } from "node:fs/promises";
|
|
4
|
+
import { writeFile, readFile } from "node:fs/promises";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
5
6
|
import Enquirer from "enquirer";
|
|
6
7
|
import { logger } from "../../core/logger.js";
|
|
7
8
|
import { loadConfig } from "../../core/config.js";
|
|
@@ -10,6 +11,8 @@ import { AIEngine } from "../../ai/ai-engine.js";
|
|
|
10
11
|
import { UseCaseParser } from "../../ai/usecase-parser.js";
|
|
11
12
|
import { findFiles, safeReadFile, ensureDir } from "../../utils/file-utils.js";
|
|
12
13
|
|
|
14
|
+
const MAX_FIX_ATTEMPTS = 3;
|
|
15
|
+
|
|
13
16
|
export function registerGenerateCommand(program) {
|
|
14
17
|
program
|
|
15
18
|
.command("generate [feature]")
|
|
@@ -17,6 +20,7 @@ export function registerGenerateCommand(program) {
|
|
|
17
20
|
.option("-l, --layer <layer>", "Target layer (unit, integration, e2e)")
|
|
18
21
|
.option("--use-cases <dir>", "Directory containing use case documents")
|
|
19
22
|
.option("--dry-run", "Show plan without writing files")
|
|
23
|
+
.option("--no-fix", "Skip auto-fix loop")
|
|
20
24
|
.option("--model <model>", "AI model to use")
|
|
21
25
|
.option("-d, --dir <path>", "Project directory", process.cwd())
|
|
22
26
|
.action(runGenerate);
|
|
@@ -39,7 +43,7 @@ async function runGenerate(feature, options) {
|
|
|
39
43
|
logger.error("AI is not configured.");
|
|
40
44
|
logger.blank();
|
|
41
45
|
logger.info("Quick setup:");
|
|
42
|
-
logger.dim(" qabot auth
|
|
46
|
+
logger.dim(" qabot auth");
|
|
43
47
|
logger.blank();
|
|
44
48
|
logger.info(
|
|
45
49
|
"Supported: openai, anthropic, gemini, deepseek, groq, ollama, proxy",
|
|
@@ -53,12 +57,12 @@ async function runGenerate(feature, options) {
|
|
|
53
57
|
const featureConfig = config.features?.[feature];
|
|
54
58
|
if (!featureConfig) {
|
|
55
59
|
logger.error(
|
|
56
|
-
`Feature "${feature}" not found
|
|
60
|
+
`Feature "${feature}" not found. Run \`qabot list features\`.`,
|
|
57
61
|
);
|
|
58
62
|
return;
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
const spinner = ora("Reading
|
|
65
|
+
const spinner = ora("Reading source code...").start();
|
|
62
66
|
|
|
63
67
|
const sourceFiles = await findFiles(
|
|
64
68
|
projectDir,
|
|
@@ -80,24 +84,14 @@ async function runGenerate(feature, options) {
|
|
|
80
84
|
return;
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
spinner.text = `Found ${sourceFilesFiltered.length} source files. Reading existing tests...`;
|
|
84
|
-
|
|
85
87
|
const existingTestFiles = await findFiles(
|
|
86
88
|
projectDir,
|
|
87
|
-
|
|
89
|
+
"src/**/tests/*.test.{js,jsx,ts,tsx}",
|
|
88
90
|
);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const anyTestFiles = await findFiles(
|
|
94
|
-
projectDir,
|
|
95
|
-
"src/**/tests/*.test.{js,jsx,ts,tsx}",
|
|
96
|
-
);
|
|
97
|
-
if (anyTestFiles.length > 0) {
|
|
98
|
-
existingTestCode = (await safeReadFile(anyTestFiles[0])) || "";
|
|
99
|
-
}
|
|
100
|
-
}
|
|
91
|
+
const existingTestCode =
|
|
92
|
+
existingTestFiles.length > 0
|
|
93
|
+
? (await safeReadFile(existingTestFiles[0])) || ""
|
|
94
|
+
: "";
|
|
101
95
|
|
|
102
96
|
let useCases = [];
|
|
103
97
|
const useCaseDir = options.useCases || config.useCases?.dir;
|
|
@@ -109,18 +103,17 @@ async function runGenerate(feature, options) {
|
|
|
109
103
|
);
|
|
110
104
|
for (const f of ucFiles) {
|
|
111
105
|
try {
|
|
112
|
-
|
|
113
|
-
useCases.push(...parsed);
|
|
106
|
+
useCases.push(...(await parser.parse(f)));
|
|
114
107
|
} catch {
|
|
115
|
-
/* skip
|
|
108
|
+
/* skip */
|
|
116
109
|
}
|
|
117
110
|
}
|
|
118
111
|
}
|
|
119
112
|
|
|
120
|
-
spinner.text = "AI is analyzing code and generating test plan...";
|
|
121
|
-
|
|
122
113
|
const runner = config.layers?.[options.layer || "unit"]?.runner || "jest";
|
|
123
114
|
|
|
115
|
+
spinner.text = "AI is generating test plan...";
|
|
116
|
+
|
|
124
117
|
let testPlan;
|
|
125
118
|
try {
|
|
126
119
|
testPlan = await ai.analyzeCode(sourceCode, {
|
|
@@ -161,7 +154,6 @@ async function runGenerate(feature, options) {
|
|
|
161
154
|
}
|
|
162
155
|
|
|
163
156
|
logger.blank();
|
|
164
|
-
|
|
165
157
|
const enquirer = new Enquirer();
|
|
166
158
|
const { proceed } = await enquirer.prompt({
|
|
167
159
|
type: "confirm",
|
|
@@ -169,73 +161,147 @@ async function runGenerate(feature, options) {
|
|
|
169
161
|
message: `Generate test code for ${testPlan.length} test cases?`,
|
|
170
162
|
initial: true,
|
|
171
163
|
});
|
|
172
|
-
|
|
173
164
|
if (!proceed) {
|
|
174
165
|
logger.warn("Cancelled.");
|
|
175
166
|
return;
|
|
176
167
|
}
|
|
177
168
|
|
|
169
|
+
const testsDir = path.join(projectDir, featureConfig.src, "tests");
|
|
170
|
+
await ensureDir(testsDir);
|
|
171
|
+
const testFileName = `${toPascalCase(feature)}.generated.test.js`;
|
|
172
|
+
const testFilePath = path.join(testsDir, testFileName);
|
|
173
|
+
const relativePath = path.relative(projectDir, testFilePath);
|
|
174
|
+
|
|
178
175
|
const spinner2 = ora("AI is writing test code...").start();
|
|
179
176
|
|
|
177
|
+
let code;
|
|
180
178
|
try {
|
|
181
|
-
|
|
179
|
+
code = await ai.generateTestCode(testPlan, {
|
|
182
180
|
framework: profile.techStack.framework,
|
|
183
181
|
runner,
|
|
184
182
|
sourceCode,
|
|
185
183
|
existingTestCode,
|
|
186
184
|
importPath: `./${path.basename(featureConfig.src)}`,
|
|
187
185
|
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
spinner2.fail("Code generation failed");
|
|
188
|
+
logger.error(err.message);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
188
191
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
+
code = cleanGeneratedCode(code);
|
|
193
|
+
await writeFile(testFilePath, code, "utf-8");
|
|
194
|
+
spinner2.succeed(`Test file written: ${chalk.underline(relativePath)}`);
|
|
192
195
|
|
|
193
|
-
|
|
194
|
-
|
|
196
|
+
if (options.fix === false) {
|
|
197
|
+
logger.blank();
|
|
198
|
+
logger.info("Skipping auto-fix (--no-fix). Run manually:");
|
|
199
|
+
logger.dim(` qabot run ${feature}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
195
202
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
await writeFile(testFilePath, cleanCode, "utf-8");
|
|
203
|
+
logger.blank();
|
|
204
|
+
logger.info("Running auto-fix loop...");
|
|
199
205
|
|
|
200
|
-
|
|
206
|
+
const testCommand = config.layers?.unit?.command || "npx jest";
|
|
201
207
|
|
|
208
|
+
for (let attempt = 1; attempt <= MAX_FIX_ATTEMPTS; attempt++) {
|
|
202
209
|
logger.blank();
|
|
203
|
-
logger.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
+
logger.step(
|
|
211
|
+
attempt,
|
|
212
|
+
MAX_FIX_ATTEMPTS,
|
|
213
|
+
`Attempt ${attempt}: running tests...`,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
const { exitCode, error: testError } = runJestQuiet(
|
|
217
|
+
projectDir,
|
|
218
|
+
testFilePath,
|
|
219
|
+
testCommand,
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
if (exitCode === 0) {
|
|
223
|
+
logger.blank();
|
|
224
|
+
logger.success(`Tests pass on attempt ${attempt}!`);
|
|
225
|
+
logger.blank();
|
|
226
|
+
logger.info("Run tests:");
|
|
227
|
+
logger.dim(` qabot run ${feature}`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (attempt >= MAX_FIX_ATTEMPTS) {
|
|
232
|
+
logger.blank();
|
|
233
|
+
logger.warn(`Tests still failing after ${MAX_FIX_ATTEMPTS} attempts.`);
|
|
234
|
+
logger.dim(" Review and fix manually:");
|
|
235
|
+
logger.dim(` cat ${relativePath}`);
|
|
236
|
+
logger.dim(` qabot run ${feature} --verbose`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
logger.dim(` Error: ${testError.split("\\n")[0].slice(0, 120)}`);
|
|
241
|
+
logger.step(attempt, MAX_FIX_ATTEMPTS, "AI is fixing errors...");
|
|
242
|
+
|
|
243
|
+
const currentCode = await readFile(testFilePath, "utf-8");
|
|
244
|
+
try {
|
|
245
|
+
let fixedCode = await ai.fixTestCode(currentCode, testError, {
|
|
246
|
+
framework: profile.techStack.framework,
|
|
247
|
+
runner,
|
|
248
|
+
});
|
|
249
|
+
fixedCode = cleanGeneratedCode(fixedCode);
|
|
250
|
+
await writeFile(testFilePath, fixedCode, "utf-8");
|
|
251
|
+
logger.dim(" Fix applied. Re-running...");
|
|
252
|
+
} catch (err) {
|
|
253
|
+
logger.warn(` AI fix failed: ${err.message}`);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function runJestQuiet(projectDir, testFilePath, testCommand) {
|
|
260
|
+
const relativePath = path.relative(projectDir, testFilePath);
|
|
261
|
+
let cmd;
|
|
262
|
+
if (testCommand.startsWith("npm")) {
|
|
263
|
+
cmd = `${testCommand} -- --testPathPattern="${relativePath}" --no-coverage --forceExit`;
|
|
264
|
+
} else {
|
|
265
|
+
cmd = `npx jest --testPathPattern="${relativePath}" --no-coverage --forceExit`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
execSync(cmd, {
|
|
270
|
+
cwd: projectDir,
|
|
271
|
+
stdio: "pipe",
|
|
272
|
+
timeout: 60000,
|
|
273
|
+
env: { ...process.env, FORCE_COLOR: "0" },
|
|
274
|
+
});
|
|
275
|
+
return { exitCode: 0, error: "" };
|
|
210
276
|
} catch (err) {
|
|
211
|
-
|
|
212
|
-
|
|
277
|
+
const stderr = err.stderr?.toString() || "";
|
|
278
|
+
const stdout = err.stdout?.toString() || "";
|
|
279
|
+
return {
|
|
280
|
+
exitCode: err.status || 1,
|
|
281
|
+
error: (stderr || stdout).slice(0, 3000),
|
|
282
|
+
};
|
|
213
283
|
}
|
|
214
284
|
}
|
|
215
285
|
|
|
216
286
|
function cleanGeneratedCode(code) {
|
|
217
287
|
let cleaned = code.trim();
|
|
218
288
|
if (cleaned.startsWith("```")) {
|
|
219
|
-
|
|
220
|
-
cleaned = cleaned.slice(firstNewline + 1);
|
|
289
|
+
cleaned = cleaned.slice(cleaned.indexOf("\n") + 1);
|
|
221
290
|
}
|
|
222
291
|
if (cleaned.endsWith("```")) {
|
|
223
292
|
cleaned = cleaned.slice(0, cleaned.lastIndexOf("```"));
|
|
224
293
|
}
|
|
225
294
|
cleaned = cleaned.trim();
|
|
226
295
|
|
|
227
|
-
const
|
|
228
|
-
const
|
|
296
|
+
const opens = (cleaned.match(/\{/g) || []).length;
|
|
297
|
+
const closes = (cleaned.match(/\}/g) || []).length;
|
|
229
298
|
const openParens = (cleaned.match(/\(/g) || []).length;
|
|
230
299
|
const closeParens = (cleaned.match(/\)/g) || []).length;
|
|
231
300
|
|
|
232
301
|
let suffix = "";
|
|
233
302
|
for (let i = 0; i < openParens - closeParens; i++) suffix += ")";
|
|
234
|
-
for (let i = 0; i <
|
|
235
|
-
|
|
236
|
-
if (suffix) {
|
|
237
|
-
cleaned += suffix + ";\n";
|
|
238
|
-
}
|
|
303
|
+
for (let i = 0; i < opens - closes; i++) suffix += "\n}";
|
|
304
|
+
if (suffix) cleaned += suffix + ";\n";
|
|
239
305
|
|
|
240
306
|
return cleaned + "\n";
|
|
241
307
|
}
|
package/src/core/constants.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { tmpdir } from "node:os";
|
|
3
4
|
import { nanoid } from "nanoid";
|
|
4
5
|
import { BaseRunner } from "./base-runner.js";
|
|
5
|
-
import { fileExists
|
|
6
|
+
import { fileExists } from "../utils/file-utils.js";
|
|
6
7
|
|
|
7
8
|
export class JestRunner extends BaseRunner {
|
|
8
9
|
constructor(config) {
|
|
@@ -47,12 +48,10 @@ export class JestRunner extends BaseRunner {
|
|
|
47
48
|
|
|
48
49
|
try {
|
|
49
50
|
if (this._jsonFile) {
|
|
50
|
-
jsonData = JSON.parse(
|
|
51
|
-
require("node:fs").readFileSync(this._jsonFile, "utf-8"),
|
|
52
|
-
);
|
|
51
|
+
jsonData = JSON.parse(readFileSync(this._jsonFile, "utf-8"));
|
|
53
52
|
}
|
|
54
53
|
} catch {
|
|
55
|
-
/*
|
|
54
|
+
/* JSON file not found — parse from stdout */
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
if (!jsonData) {
|
|
@@ -60,22 +59,38 @@ export class JestRunner extends BaseRunner {
|
|
|
60
59
|
const jsonStart = stdout.indexOf("{");
|
|
61
60
|
if (jsonStart !== -1) jsonData = JSON.parse(stdout.slice(jsonStart));
|
|
62
61
|
} catch {
|
|
63
|
-
/*
|
|
62
|
+
/* not valid JSON in stdout */
|
|
64
63
|
}
|
|
65
64
|
}
|
|
66
65
|
|
|
67
66
|
if (jsonData && jsonData.testResults) {
|
|
68
67
|
for (const suite of jsonData.testResults) {
|
|
69
|
-
|
|
68
|
+
if (suite.assertionResults?.length > 0) {
|
|
69
|
+
for (const test of suite.assertionResults) {
|
|
70
|
+
tests.push({
|
|
71
|
+
name: test.fullName || test.title,
|
|
72
|
+
suite: test.ancestorTitles?.join(" > ") || "",
|
|
73
|
+
file: path.relative(process.cwd(), suite.testFilePath || ""),
|
|
74
|
+
status: mapJestStatus(test.status),
|
|
75
|
+
duration: test.duration || 0,
|
|
76
|
+
error: test.failureMessages?.length
|
|
77
|
+
? { message: test.failureMessages.join("\n"), stack: "" }
|
|
78
|
+
: null,
|
|
79
|
+
screenshots: [],
|
|
80
|
+
retries: 0,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
} else if (suite.message) {
|
|
70
84
|
tests.push({
|
|
71
|
-
name:
|
|
72
|
-
suite:
|
|
85
|
+
name: `Suite failed: ${path.basename(suite.testFilePath || "unknown")}`,
|
|
86
|
+
suite: "Test Suite Error",
|
|
73
87
|
file: path.relative(process.cwd(), suite.testFilePath || ""),
|
|
74
|
-
status:
|
|
75
|
-
duration:
|
|
76
|
-
error:
|
|
77
|
-
|
|
78
|
-
:
|
|
88
|
+
status: "failed",
|
|
89
|
+
duration: 0,
|
|
90
|
+
error: {
|
|
91
|
+
message: extractErrorSummary(suite.message),
|
|
92
|
+
stack: suite.message,
|
|
93
|
+
},
|
|
79
94
|
screenshots: [],
|
|
80
95
|
retries: 0,
|
|
81
96
|
});
|
|
@@ -83,21 +98,23 @@ export class JestRunner extends BaseRunner {
|
|
|
83
98
|
}
|
|
84
99
|
}
|
|
85
100
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
101
|
+
if (tests.length === 0 && exitCode !== 0) {
|
|
102
|
+
const errorMsg = extractErrorFromOutput(stderr || stdout);
|
|
103
|
+
if (errorMsg) {
|
|
104
|
+
tests.push({
|
|
105
|
+
name: "Test execution failed",
|
|
106
|
+
suite: "Runtime Error",
|
|
107
|
+
file: "",
|
|
108
|
+
status: "failed",
|
|
109
|
+
duration: 0,
|
|
110
|
+
error: { message: errorMsg, stack: "" },
|
|
111
|
+
screenshots: [],
|
|
112
|
+
retries: 0,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
100
116
|
|
|
117
|
+
const summary = buildSummary(jsonData, tests, stdout);
|
|
101
118
|
return { tests, summary, coverage: jsonData?.coverageMap || null };
|
|
102
119
|
}
|
|
103
120
|
}
|
|
@@ -112,6 +129,34 @@ function mapJestStatus(status) {
|
|
|
112
129
|
return map[status] || "skipped";
|
|
113
130
|
}
|
|
114
131
|
|
|
132
|
+
function buildSummary(jsonData, tests, stdout) {
|
|
133
|
+
let summary;
|
|
134
|
+
|
|
135
|
+
if (jsonData) {
|
|
136
|
+
const suiteFails = jsonData.numFailedTestSuites || 0;
|
|
137
|
+
const testFails = jsonData.numFailedTests || 0;
|
|
138
|
+
summary = {
|
|
139
|
+
total: (jsonData.numTotalTests || 0) + suiteFails,
|
|
140
|
+
passed: jsonData.numPassedTests || 0,
|
|
141
|
+
failed: testFails + suiteFails,
|
|
142
|
+
skipped: (jsonData.numPendingTests || 0) + (jsonData.numTodoTests || 0),
|
|
143
|
+
};
|
|
144
|
+
} else {
|
|
145
|
+
summary = parseSummaryFromStdout(stdout);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (summary.total === 0 && tests.length > 0) {
|
|
149
|
+
summary.total = tests.length;
|
|
150
|
+
summary.passed = tests.filter((t) => t.status === "passed").length;
|
|
151
|
+
summary.failed = tests.filter((t) => t.status === "failed").length;
|
|
152
|
+
summary.skipped = tests.filter((t) => t.status === "skipped").length;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
summary.passRate =
|
|
156
|
+
summary.total > 0 ? Math.round((summary.passed / summary.total) * 100) : 0;
|
|
157
|
+
return summary;
|
|
158
|
+
}
|
|
159
|
+
|
|
115
160
|
function parseSummaryFromStdout(stdout) {
|
|
116
161
|
const summary = { total: 0, passed: 0, failed: 0, skipped: 0, passRate: 0 };
|
|
117
162
|
const testMatch = stdout.match(
|
|
@@ -125,3 +170,28 @@ function parseSummaryFromStdout(stdout) {
|
|
|
125
170
|
}
|
|
126
171
|
return summary;
|
|
127
172
|
}
|
|
173
|
+
|
|
174
|
+
function extractErrorSummary(message) {
|
|
175
|
+
const lines = message.split("\n").filter((l) => l.trim());
|
|
176
|
+
const syntaxLine = lines.find(
|
|
177
|
+
(l) =>
|
|
178
|
+
l.includes("SyntaxError") ||
|
|
179
|
+
l.includes("Cannot find") ||
|
|
180
|
+
l.includes("unexpected token"),
|
|
181
|
+
);
|
|
182
|
+
if (syntaxLine) return syntaxLine.trim().slice(0, 200);
|
|
183
|
+
return lines[0]?.trim().slice(0, 200) || "Unknown error";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function extractErrorFromOutput(output) {
|
|
187
|
+
if (!output) return null;
|
|
188
|
+
const lines = output.split("\n");
|
|
189
|
+
const errorLine = lines.find(
|
|
190
|
+
(l) =>
|
|
191
|
+
l.includes("SyntaxError") ||
|
|
192
|
+
l.includes("Error:") ||
|
|
193
|
+
l.includes("Cannot find") ||
|
|
194
|
+
l.includes("FAIL"),
|
|
195
|
+
);
|
|
196
|
+
return errorLine?.trim().slice(0, 300) || null;
|
|
197
|
+
}
|