@nhonh/qabot 0.3.2 → 0.4.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.3.2",
3
+ "version": "0.4.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": {
@@ -64,8 +64,45 @@ export class AIEngine {
64
64
  }
65
65
 
66
66
  async generateTestCode(testCases, context) {
67
- const prompt = buildGenerationPrompt(testCases, context);
68
- return this.complete(prompt);
67
+ const maxBatchSize = 8;
68
+
69
+ if (testCases.length <= maxBatchSize) {
70
+ const prompt = buildGenerationPrompt(testCases, context);
71
+ const savedMaxTokens = this.maxTokens;
72
+ this.maxTokens = Math.max(this.maxTokens, 8192);
73
+ try {
74
+ return await this.complete(prompt);
75
+ } finally {
76
+ this.maxTokens = savedMaxTokens;
77
+ }
78
+ }
79
+
80
+ const batches = [];
81
+ for (let i = 0; i < testCases.length; i += maxBatchSize) {
82
+ batches.push(testCases.slice(i, i + maxBatchSize));
83
+ }
84
+
85
+ const savedMaxTokens = this.maxTokens;
86
+ this.maxTokens = Math.max(this.maxTokens, 8192);
87
+
88
+ let fullCode = "";
89
+ try {
90
+ for (let i = 0; i < batches.length; i++) {
91
+ const isFirst = i === 0;
92
+ const batchContext = { ...context };
93
+ if (!isFirst) {
94
+ batchContext.existingTestCode = "";
95
+ batchContext.sourceCode = "";
96
+ }
97
+ const prompt = buildGenerationPrompt(batches[i], batchContext);
98
+ const code = await this.complete(prompt);
99
+ fullCode += (isFirst ? "" : "\n\n") + code;
100
+ }
101
+ } finally {
102
+ this.maxTokens = savedMaxTokens;
103
+ }
104
+
105
+ return fullCode;
69
106
  }
70
107
 
71
108
  async generateRecommendations(results) {
@@ -35,27 +35,41 @@ Return ONLY valid JSON array, no markdown fences, no explanation.`;
35
35
  }
36
36
 
37
37
  export function buildGenerationPrompt(testCases, context) {
38
- return `You are a senior test engineer writing test code.
38
+ const sourceSection = context.sourceCode
39
+ ? `\n## Source Code Being Tested\n\`\`\`\n${truncate(context.sourceCode, 6000)}\n\`\`\`\n`
40
+ : "";
41
+
42
+ const existingTestSection = context.existingTestCode
43
+ ? `\n## Existing Test Examples (match this style exactly)\n\`\`\`\n${truncate(context.existingTestCode, 3000)}\n\`\`\`\n`
44
+ : "";
45
+
46
+ const importPath = context.importPath || "../index";
47
+
48
+ return `You are a senior test engineer writing production-quality test code.
39
49
 
40
50
  ## Context
41
51
  - Framework: ${context.framework || "react"}
42
52
  - Test Runner: ${context.runner || "jest"}
43
53
  - Language: JavaScript/JSX
44
- - Libraries: @testing-library/react, @testing-library/user-event, redux-mock-store
45
-
54
+ - Import path for module under test: "${importPath}"
55
+ ${sourceSection}${existingTestSection}
46
56
  ## Test Cases to Implement
47
57
  ${JSON.stringify(testCases, null, 2)}
48
58
 
49
59
  ## Rules
50
- 1. Use @testing-library/react for rendering
51
- 2. Use @testing-library/user-event for interactions
52
- 3. Use screen queries (getByRole, getByText, getByTestId)
53
- 4. Use waitFor for async assertions
54
- 5. Each test must be independent
55
- 6. Use descriptive test names
56
- 7. Handle async operations properly
57
-
58
- Write complete, runnable test code. Return ONLY the code, no explanation.`;
60
+ 1. Write a COMPLETE, RUNNABLE test file with all imports
61
+ 2. Use @testing-library/react for rendering components
62
+ 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
65
+ 6. Use waitFor and findBy* for async assertions
66
+ 7. Each test must be independent — no shared mutable state
67
+ 8. Use descriptive it() names matching the test case names above
68
+ 9. Group related tests in describe() blocks
69
+ 10. Mock external dependencies (API calls, Redux store, Router)
70
+ 11. Do NOT import from node_modules paths — use package names
71
+
72
+ Return ONLY the JavaScript code. No markdown fences. No explanation before or after the code.`;
59
73
  }
60
74
 
61
75
  export function buildRecommendationPrompt(results) {
@@ -1,11 +1,14 @@
1
1
  import chalk from "chalk";
2
2
  import ora from "ora";
3
+ import path from "node:path";
4
+ import { writeFile } from "node:fs/promises";
5
+ import Enquirer from "enquirer";
3
6
  import { logger } from "../../core/logger.js";
4
7
  import { loadConfig } from "../../core/config.js";
5
8
  import { analyzeProject } from "../../analyzers/project-analyzer.js";
6
9
  import { AIEngine } from "../../ai/ai-engine.js";
7
10
  import { UseCaseParser } from "../../ai/usecase-parser.js";
8
- import { findFiles, safeReadFile } from "../../utils/file-utils.js";
11
+ import { findFiles, safeReadFile, ensureDir } from "../../utils/file-utils.js";
9
12
 
10
13
  export function registerGenerateCommand(program) {
11
14
  program
@@ -38,11 +41,6 @@ async function runGenerate(feature, options) {
38
41
  logger.info("Quick setup:");
39
42
  logger.dim(" qabot auth # Interactive provider setup");
40
43
  logger.blank();
41
- logger.info("Or set manually:");
42
- logger.dim(
43
- " export OPENAI_API_KEY=sk-... # Then set ai.provider in qabot.config.js",
44
- );
45
- logger.blank();
46
44
  logger.info(
47
45
  "Supported: openai, anthropic, gemini, deepseek, groq, ollama, proxy",
48
46
  );
@@ -52,26 +50,55 @@ async function runGenerate(feature, options) {
52
50
  const profile = await analyzeProject(projectDir);
53
51
  logger.header("QABot \u2014 AI Test Generation");
54
52
 
55
- const spinner = ora("Analyzing feature source code...").start();
56
-
57
53
  const featureConfig = config.features?.[feature];
58
54
  if (!featureConfig) {
59
- spinner.fail(
55
+ logger.error(
60
56
  `Feature "${feature}" not found in config. Run \`qabot list features\` to see available.`,
61
57
  );
62
58
  return;
63
59
  }
64
60
 
61
+ const spinner = ora("Reading feature source code...").start();
62
+
65
63
  const sourceFiles = await findFiles(
66
64
  projectDir,
67
65
  `${featureConfig.src}/**/*.{js,jsx,ts,tsx}`,
68
66
  );
67
+ const sourceFilesFiltered = sourceFiles.filter(
68
+ (f) => !f.includes("/tests/") && !f.includes(".test."),
69
+ );
69
70
  const sourceCode = (
70
- await Promise.all(sourceFiles.slice(0, 10).map((f) => safeReadFile(f)))
71
+ await Promise.all(
72
+ sourceFilesFiltered.slice(0, 10).map((f) => safeReadFile(f)),
73
+ )
71
74
  )
72
75
  .filter(Boolean)
73
76
  .join("\n\n---\n\n");
74
77
 
78
+ if (!sourceCode.trim()) {
79
+ spinner.fail(`No source files found at ${featureConfig.src}`);
80
+ return;
81
+ }
82
+
83
+ spinner.text = `Found ${sourceFilesFiltered.length} source files. Reading existing tests...`;
84
+
85
+ const existingTestFiles = await findFiles(
86
+ projectDir,
87
+ `${featureConfig.src}/**/tests/*.test.{js,jsx,ts,tsx}`,
88
+ );
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
+ }
101
+
75
102
  let useCases = [];
76
103
  const useCaseDir = options.useCases || config.useCases?.dir;
77
104
  if (useCaseDir) {
@@ -81,55 +108,141 @@ async function runGenerate(feature, options) {
81
108
  `${useCaseDir}/**/*.{md,feature,txt}`,
82
109
  );
83
110
  for (const f of ucFiles) {
84
- const parsed = await parser.parse(f);
85
- useCases.push(...parsed);
111
+ try {
112
+ const parsed = await parser.parse(f);
113
+ useCases.push(...parsed);
114
+ } catch {
115
+ /* skip unparseable */
116
+ }
86
117
  }
87
118
  }
88
119
 
89
120
  spinner.text = "AI is analyzing code and generating test plan...";
90
121
 
122
+ const runner = config.layers?.[options.layer || "unit"]?.runner || "jest";
123
+
124
+ let testPlan;
91
125
  try {
92
- const testPlan = await ai.analyzeCode(sourceCode, {
126
+ testPlan = await ai.analyzeCode(sourceCode, {
93
127
  framework: profile.techStack.framework,
94
- runner: config.layers?.[options.layer || "unit"]?.runner || "jest",
95
- existingTestCount: 0,
128
+ runner,
129
+ existingTestCount: existingTestFiles.length,
96
130
  useCases,
97
131
  });
132
+ } catch (err) {
133
+ spinner.fail("AI analysis failed");
134
+ logger.error(err.message);
135
+ return;
136
+ }
98
137
 
99
- spinner.succeed("Test plan generated");
138
+ if (!Array.isArray(testPlan) || testPlan.length === 0) {
139
+ spinner.fail("AI returned no test cases");
140
+ return;
141
+ }
142
+
143
+ spinner.succeed(`Generated ${testPlan.length} test cases`);
144
+ logger.blank();
145
+
146
+ for (const tc of testPlan) {
147
+ const icon =
148
+ tc.priority === "P0"
149
+ ? chalk.red("\u25cf")
150
+ : tc.priority === "P1"
151
+ ? chalk.yellow("\u25cf")
152
+ : chalk.blue("\u25cf");
153
+ console.log(` ${icon} ${chalk.bold(tc.name)}`);
154
+ logger.dim(` Type: ${tc.type} | Priority: ${tc.priority}`);
155
+ }
156
+
157
+ if (options.dryRun) {
100
158
  logger.blank();
159
+ logger.info("Dry run complete. No files written.");
160
+ return;
161
+ }
101
162
 
102
- if (Array.isArray(testPlan)) {
103
- logger.info(
104
- `Generated ${chalk.bold(testPlan.length)} test cases for "${feature}":`,
105
- );
106
- logger.blank();
107
- for (const tc of testPlan) {
108
- const icon =
109
- tc.priority === "P0"
110
- ? chalk.red("\u25cf")
111
- : tc.priority === "P1"
112
- ? chalk.yellow("\u25cf")
113
- : chalk.blue("\u25cf");
114
- console.log(` ${icon} ${chalk.bold(tc.name)}`);
115
- logger.dim(` ${tc.description}`);
116
- logger.dim(` Type: ${tc.type} | Priority: ${tc.priority}`);
117
- }
118
- }
163
+ logger.blank();
119
164
 
120
- if (options.dryRun) {
121
- logger.blank();
122
- logger.info("Dry run complete. No files written.");
123
- return;
124
- }
165
+ const enquirer = new Enquirer();
166
+ const { proceed } = await enquirer.prompt({
167
+ type: "confirm",
168
+ name: "proceed",
169
+ message: `Generate test code for ${testPlan.length} test cases?`,
170
+ initial: true,
171
+ });
172
+
173
+ if (!proceed) {
174
+ logger.warn("Cancelled.");
175
+ return;
176
+ }
177
+
178
+ const spinner2 = ora("AI is writing test code...").start();
179
+
180
+ try {
181
+ const code = await ai.generateTestCode(testPlan, {
182
+ framework: profile.techStack.framework,
183
+ runner,
184
+ sourceCode,
185
+ existingTestCode,
186
+ importPath: `./${path.basename(featureConfig.src)}`,
187
+ });
188
+
189
+ spinner2.succeed("Test code generated");
190
+
191
+ const cleanCode = cleanGeneratedCode(code);
192
+
193
+ const testsDir = path.join(projectDir, featureConfig.src, "tests");
194
+ await ensureDir(testsDir);
195
+
196
+ const testFileName = `${toPascalCase(feature)}.generated.test.js`;
197
+ const testFilePath = path.join(testsDir, testFileName);
198
+ await writeFile(testFilePath, cleanCode, "utf-8");
199
+
200
+ const relativePath = path.relative(projectDir, testFilePath);
125
201
 
126
202
  logger.blank();
127
- logger.info("Test code generation coming in next version.");
128
- logger.dim(
129
- "For now, use the test plan above as a guide to write tests manually.",
130
- );
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();
131
210
  } catch (err) {
132
- spinner.fail("AI analysis failed");
211
+ spinner2.fail("Test code generation failed");
133
212
  logger.error(err.message);
134
213
  }
135
214
  }
215
+
216
+ function cleanGeneratedCode(code) {
217
+ let cleaned = code.trim();
218
+ if (cleaned.startsWith("```")) {
219
+ const firstNewline = cleaned.indexOf("\n");
220
+ cleaned = cleaned.slice(firstNewline + 1);
221
+ }
222
+ if (cleaned.endsWith("```")) {
223
+ cleaned = cleaned.slice(0, cleaned.lastIndexOf("```"));
224
+ }
225
+ cleaned = cleaned.trim();
226
+
227
+ const openBraces = (cleaned.match(/\{/g) || []).length;
228
+ const closeBraces = (cleaned.match(/\}/g) || []).length;
229
+ const openParens = (cleaned.match(/\(/g) || []).length;
230
+ const closeParens = (cleaned.match(/\)/g) || []).length;
231
+
232
+ let suffix = "";
233
+ 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
+ }
239
+
240
+ return cleaned + "\n";
241
+ }
242
+
243
+ function toPascalCase(str) {
244
+ return str
245
+ .split(/[-_\s]+/)
246
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
247
+ .join("");
248
+ }
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.3.2";
1
+ export const VERSION = "0.4.1";
2
2
  export const TOOL_NAME = "qabot";
3
3
 
4
4
  export const PROJECT_TYPES = [