@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nhonh/qabot",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "AI-powered universal QA automation tool. Import any project, AI analyzes and runs tests across all layers.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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
- const code = await this.complete(prompt);
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] || "";
@@ -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 # Interactive provider setup");
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 in config. Run \`qabot list features\` to see available.`,
60
+ `Feature "${feature}" not found. Run \`qabot list features\`.`,
57
61
  );
58
62
  return;
59
63
  }
60
64
 
61
- const spinner = ora("Reading feature source code...").start();
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
- `${featureConfig.src}/**/tests/*.test.{js,jsx,ts,tsx}`,
89
+ "src/**/tests/*.test.{js,jsx,ts,tsx}",
88
90
  );
89
- let existingTestCode = "";
90
- if (existingTestFiles.length > 0) {
91
- existingTestCode = (await safeReadFile(existingTestFiles[0])) || "";
92
- } else {
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
- const parsed = await parser.parse(f);
113
- useCases.push(...parsed);
106
+ useCases.push(...(await parser.parse(f)));
114
107
  } catch {
115
- /* skip unparseable */
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
- const code = await ai.generateTestCode(testPlan, {
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
- spinner2.succeed("Test code generated");
190
-
191
- const cleanCode = cleanGeneratedCode(code);
192
+ code = cleanGeneratedCode(code);
193
+ await writeFile(testFilePath, code, "utf-8");
194
+ spinner2.succeed(`Test file written: ${chalk.underline(relativePath)}`);
192
195
 
193
- const testsDir = path.join(projectDir, featureConfig.src, "tests");
194
- await ensureDir(testsDir);
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
- const testFileName = `${toPascalCase(feature)}.generated.test.js`;
197
- const testFilePath = path.join(testsDir, testFileName);
198
- await writeFile(testFilePath, cleanCode, "utf-8");
203
+ logger.blank();
204
+ logger.info("Running auto-fix loop...");
199
205
 
200
- const relativePath = path.relative(projectDir, testFilePath);
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.success(`Test file written: ${chalk.underline(relativePath)}`);
204
- logger.blank();
205
- logger.info("Next steps:");
206
- logger.dim(` qabot run ${feature} # Run the generated tests`);
207
- logger.dim(` cat ${relativePath} # Review generated code`);
208
- logger.dim(` qabot generate ${feature} --dry-run # Regenerate plan only`);
209
- logger.blank();
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
- spinner2.fail("Test code generation failed");
212
- logger.error(err.message);
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
- const firstNewline = cleaned.indexOf("\n");
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 openBraces = (cleaned.match(/\{/g) || []).length;
228
- const closeBraces = (cleaned.match(/\}/g) || []).length;
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 < openBraces - closeBraces; i++) suffix += "\n}";
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
  }
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.4.1";
1
+ export const VERSION = "0.5.1";
2
2
  export const TOOL_NAME = "qabot";
3
3
 
4
4
  export const PROJECT_TYPES = [
@@ -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, readJSON } from "../utils/file-utils.js";
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
- /* fallback to stdout parsing */
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
- /* parse manually */
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
- for (const test of suite.assertionResults || []) {
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: test.fullName || test.title,
72
- suite: test.ancestorTitles?.join(" > ") || "",
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: mapJestStatus(test.status),
75
- duration: test.duration || 0,
76
- error: test.failureMessages?.length
77
- ? { message: test.failureMessages.join("\n"), stack: "" }
78
- : null,
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
- const summary = jsonData
87
- ? {
88
- total: jsonData.numTotalTests || 0,
89
- passed: jsonData.numPassedTests || 0,
90
- failed: jsonData.numFailedTests || 0,
91
- skipped:
92
- (jsonData.numPendingTests || 0) + (jsonData.numTodoTests || 0),
93
- }
94
- : parseSummaryFromStdout(stdout);
95
-
96
- summary.passRate =
97
- summary.total > 0
98
- ? Math.round((summary.passed / summary.total) * 100)
99
- : 0;
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
+ }