@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 +1 -1
- package/src/ai/ai-engine.js +39 -2
- package/src/ai/prompt-builder.js +26 -12
- package/src/cli/commands/generate.js +156 -43
- package/src/core/constants.js +1 -1
package/package.json
CHANGED
package/src/ai/ai-engine.js
CHANGED
|
@@ -64,8 +64,45 @@ export class AIEngine {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
async generateTestCode(testCases, context) {
|
|
67
|
-
const
|
|
68
|
-
|
|
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) {
|
package/src/ai/prompt-builder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
-
|
|
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.
|
|
51
|
-
2. Use @testing-library/
|
|
52
|
-
3. Use
|
|
53
|
-
4. Use
|
|
54
|
-
5.
|
|
55
|
-
6. Use
|
|
56
|
-
7.
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
126
|
+
testPlan = await ai.analyzeCode(sourceCode, {
|
|
93
127
|
framework: profile.techStack.framework,
|
|
94
|
-
runner
|
|
95
|
-
existingTestCount:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
128
|
-
logger.
|
|
129
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/core/constants.js
CHANGED