ai-test-cli 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 +21 -0
- package/README.md +115 -0
- package/bin/aitest.js +2 -0
- package/dist/commands/config.d.ts +5 -0
- package/dist/commands/config.js +80 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +185 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/explain.d.ts +5 -0
- package/dist/commands/explain.js +146 -0
- package/dist/commands/explain.js.map +1 -0
- package/dist/commands/fix.d.ts +5 -0
- package/dist/commands/fix.js +1207 -0
- package/dist/commands/fix.js.map +1 -0
- package/dist/commands/generate.d.ts +28 -0
- package/dist/commands/generate.js +1089 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +386 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/run.d.ts +5 -0
- package/dist/commands/run.js +190 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/commands/scan.d.ts +5 -0
- package/dist/commands/scan.js +140 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1789 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1789 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/core/logger.ts
|
|
12
|
+
import chalk from "chalk";
|
|
13
|
+
import ora from "ora";
|
|
14
|
+
var logger;
|
|
15
|
+
var init_logger = __esm({
|
|
16
|
+
"src/core/logger.ts"() {
|
|
17
|
+
"use strict";
|
|
18
|
+
logger = {
|
|
19
|
+
info: (msg) => console.log(chalk.blue("\u2139"), msg),
|
|
20
|
+
success: (msg) => console.log(chalk.green("\u2714"), msg),
|
|
21
|
+
error: (msg) => console.error(chalk.red("\u2716"), msg),
|
|
22
|
+
warn: (msg) => console.warn(chalk.yellow("\u26A0"), msg),
|
|
23
|
+
log: (msg) => console.log(msg),
|
|
24
|
+
spinner: (text) => ora(text)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// src/core/config.ts
|
|
30
|
+
import { cosmiconfig } from "cosmiconfig";
|
|
31
|
+
import { writeFile } from "fs/promises";
|
|
32
|
+
import { resolve } from "path";
|
|
33
|
+
async function loadConfig() {
|
|
34
|
+
try {
|
|
35
|
+
const result = await explorer.search();
|
|
36
|
+
if (result) {
|
|
37
|
+
return result.config;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
logger.error(`Failed to load config: ${error}`);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function saveConfig(config2, targetDir = process.cwd()) {
|
|
46
|
+
const configPath = resolve(targetDir, ".aitestrc.json");
|
|
47
|
+
try {
|
|
48
|
+
await writeFile(configPath, JSON.stringify(config2, null, 2), "utf-8");
|
|
49
|
+
logger.success(`Saved config to ${configPath}`);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
logger.error(`Failed to save config: ${error}`);
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
var moduleName, explorer;
|
|
56
|
+
var init_config = __esm({
|
|
57
|
+
"src/core/config.ts"() {
|
|
58
|
+
"use strict";
|
|
59
|
+
init_logger();
|
|
60
|
+
moduleName = "aitest";
|
|
61
|
+
explorer = cosmiconfig(moduleName);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// src/core/detector.ts
|
|
66
|
+
import { readFileSync, existsSync } from "fs";
|
|
67
|
+
import { resolve as resolve2 } from "path";
|
|
68
|
+
function detectProjectInfo(targetDir = process.cwd()) {
|
|
69
|
+
const packageJsonPath = resolve2(targetDir, "package.json");
|
|
70
|
+
const tsconfigPath = resolve2(targetDir, "tsconfig.json");
|
|
71
|
+
let packageJson2 = {};
|
|
72
|
+
if (existsSync(packageJsonPath)) {
|
|
73
|
+
try {
|
|
74
|
+
packageJson2 = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
75
|
+
} catch (e) {
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const allDeps = {
|
|
79
|
+
...packageJson2.dependencies || {},
|
|
80
|
+
...packageJson2.devDependencies || {}
|
|
81
|
+
};
|
|
82
|
+
const language = existsSync(tsconfigPath) || allDeps["typescript"] ? "typescript" : "javascript";
|
|
83
|
+
let testRunner = "unknown";
|
|
84
|
+
if (allDeps["jest"]) testRunner = "jest";
|
|
85
|
+
else if (allDeps["vitest"]) testRunner = "vitest";
|
|
86
|
+
else if (allDeps["mocha"]) testRunner = "mocha";
|
|
87
|
+
let framework = "unknown";
|
|
88
|
+
if (allDeps["react"]) framework = "react";
|
|
89
|
+
else if (allDeps["vue"]) framework = "vue";
|
|
90
|
+
else if (allDeps["@angular/core"]) framework = "angular";
|
|
91
|
+
else if (allDeps["express"] || allDeps["@nestjs/core"] || allDeps["fastify"]) framework = "node";
|
|
92
|
+
let packageManager = "npm";
|
|
93
|
+
if (existsSync(resolve2(targetDir, "pnpm-lock.yaml"))) packageManager = "pnpm";
|
|
94
|
+
else if (existsSync(resolve2(targetDir, "yarn.lock"))) packageManager = "yarn";
|
|
95
|
+
else if (existsSync(resolve2(targetDir, "bun.lockb"))) packageManager = "bun";
|
|
96
|
+
return { language, testRunner, framework, packageManager };
|
|
97
|
+
}
|
|
98
|
+
var init_detector = __esm({
|
|
99
|
+
"src/core/detector.ts"() {
|
|
100
|
+
"use strict";
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// src/core/ai.ts
|
|
105
|
+
import { generateText } from "ai";
|
|
106
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
107
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
108
|
+
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
|
109
|
+
import { createDeepSeek } from "@ai-sdk/deepseek";
|
|
110
|
+
import * as dotenv from "dotenv";
|
|
111
|
+
function getAIModel(config2) {
|
|
112
|
+
const providerName = config2.provider.toLowerCase();
|
|
113
|
+
if (providerName === "openai") {
|
|
114
|
+
const openai = createOpenAI({
|
|
115
|
+
apiKey: config2.apiKey || process.env.OPENAI_API_KEY
|
|
116
|
+
});
|
|
117
|
+
return openai(config2.model || "gpt-4o");
|
|
118
|
+
}
|
|
119
|
+
if (providerName === "anthropic") {
|
|
120
|
+
const anthropic = createAnthropic({
|
|
121
|
+
apiKey: config2.apiKey || process.env.ANTHROPIC_API_KEY
|
|
122
|
+
});
|
|
123
|
+
return anthropic(config2.model || "claude-3-5-sonnet-20240620");
|
|
124
|
+
}
|
|
125
|
+
if (providerName === "gemini" || providerName === "google") {
|
|
126
|
+
const google = createGoogleGenerativeAI({
|
|
127
|
+
apiKey: config2.apiKey || process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY
|
|
128
|
+
});
|
|
129
|
+
return google(config2.model || "gemini-2.5-flash");
|
|
130
|
+
}
|
|
131
|
+
if (providerName === "deepseek") {
|
|
132
|
+
const deepseek = createDeepSeek({
|
|
133
|
+
apiKey: config2.apiKey || process.env.DEEPSEEK_API_KEY
|
|
134
|
+
});
|
|
135
|
+
return deepseek(config2.model || "deepseek-coder");
|
|
136
|
+
}
|
|
137
|
+
if (providerName === "ollama") {
|
|
138
|
+
const ollama = createOpenAI({
|
|
139
|
+
baseURL: "http://localhost:11434/v1",
|
|
140
|
+
apiKey: "ollama"
|
|
141
|
+
// API key isn't strictly needed for local Ollama
|
|
142
|
+
});
|
|
143
|
+
return ollama(config2.model || "llama3.1");
|
|
144
|
+
}
|
|
145
|
+
if (providerName === "custom") {
|
|
146
|
+
const custom = createOpenAI({
|
|
147
|
+
baseURL: config2.baseURL,
|
|
148
|
+
apiKey: config2.apiKey || process.env.CUSTOM_API_KEY || "custom",
|
|
149
|
+
headers: config2.customHeaders
|
|
150
|
+
});
|
|
151
|
+
return custom(config2.model || "local-model");
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`Unsupported AI provider: ${config2.provider}`);
|
|
154
|
+
}
|
|
155
|
+
async function askAI(config2, system, prompt) {
|
|
156
|
+
const model = getAIModel(config2);
|
|
157
|
+
const { text } = await generateText({
|
|
158
|
+
model,
|
|
159
|
+
system,
|
|
160
|
+
prompt,
|
|
161
|
+
maxRetries: 5,
|
|
162
|
+
temperature: config2.temperature || 0.1
|
|
163
|
+
});
|
|
164
|
+
return text;
|
|
165
|
+
}
|
|
166
|
+
var init_ai = __esm({
|
|
167
|
+
"src/core/ai.ts"() {
|
|
168
|
+
"use strict";
|
|
169
|
+
dotenv.config();
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// src/core/scanner.ts
|
|
174
|
+
var scanner_exports = {};
|
|
175
|
+
__export(scanner_exports, {
|
|
176
|
+
generateWorkspaceMap: () => generateWorkspaceMap,
|
|
177
|
+
scanDependencies: () => scanDependencies
|
|
178
|
+
});
|
|
179
|
+
import { Project } from "ts-morph";
|
|
180
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5, statSync } from "fs";
|
|
181
|
+
import { resolve as resolve6, dirname } from "path";
|
|
182
|
+
import fg2 from "fast-glob";
|
|
183
|
+
function resolveLocalPath(baseDir, moduleSpecifier) {
|
|
184
|
+
let cleanedSpecifier = moduleSpecifier;
|
|
185
|
+
if (cleanedSpecifier.endsWith(".js")) {
|
|
186
|
+
cleanedSpecifier = cleanedSpecifier.replace(/\.js$/, "");
|
|
187
|
+
}
|
|
188
|
+
const exts = [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.js", "/index.tsx", "/index.jsx"];
|
|
189
|
+
const fullPath = resolve6(baseDir, cleanedSpecifier);
|
|
190
|
+
try {
|
|
191
|
+
const exactPath = resolve6(baseDir, moduleSpecifier);
|
|
192
|
+
if (existsSync3(exactPath) && statSync(exactPath).isFile()) {
|
|
193
|
+
return exactPath;
|
|
194
|
+
}
|
|
195
|
+
} catch (e) {
|
|
196
|
+
}
|
|
197
|
+
for (const ext of exts) {
|
|
198
|
+
const withExt = fullPath + ext;
|
|
199
|
+
try {
|
|
200
|
+
if (existsSync3(withExt) && statSync(withExt).isFile()) {
|
|
201
|
+
return withExt;
|
|
202
|
+
}
|
|
203
|
+
} catch (e) {
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
function scanDependencies(filePath, maxTokens = 2e3) {
|
|
209
|
+
try {
|
|
210
|
+
const project = new Project();
|
|
211
|
+
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
212
|
+
let context = "";
|
|
213
|
+
const imports = sourceFile.getImportDeclarations();
|
|
214
|
+
const baseDir = dirname(filePath);
|
|
215
|
+
for (const imp of imports) {
|
|
216
|
+
const moduleSpecifier = imp.getModuleSpecifierValue();
|
|
217
|
+
if (!moduleSpecifier.startsWith(".") && !moduleSpecifier.startsWith("/")) {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const resolvedPath = resolveLocalPath(baseDir, moduleSpecifier);
|
|
221
|
+
if (resolvedPath) {
|
|
222
|
+
const content = readFileSync5(resolvedPath, "utf-8");
|
|
223
|
+
context += `
|
|
224
|
+
--- Related Context from ${moduleSpecifier} ---
|
|
225
|
+
${content}
|
|
226
|
+
`;
|
|
227
|
+
if (context.length > maxTokens * 4) {
|
|
228
|
+
context = context.substring(0, maxTokens * 4) + "\n... [Context Truncated due to size limits]";
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const possiblePrismaPaths = [
|
|
234
|
+
resolve6(process.cwd(), "prisma/schema.prisma"),
|
|
235
|
+
resolve6(process.cwd(), "schema.prisma")
|
|
236
|
+
];
|
|
237
|
+
for (const p of possiblePrismaPaths) {
|
|
238
|
+
if (existsSync3(p)) {
|
|
239
|
+
const content = readFileSync5(p, "utf-8");
|
|
240
|
+
context += `
|
|
241
|
+
--- Prisma Schema Context ---
|
|
242
|
+
${content}
|
|
243
|
+
`;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return context.trim();
|
|
248
|
+
} catch (error) {
|
|
249
|
+
logger.error(`Architecture Scanner failed for ${filePath}: ${error.message}`);
|
|
250
|
+
return "";
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function generateWorkspaceMap() {
|
|
254
|
+
try {
|
|
255
|
+
const files = await fg2([
|
|
256
|
+
"**/*.{ts,js,tsx,jsx,json,prisma}",
|
|
257
|
+
"!**/node_modules/**",
|
|
258
|
+
"!**/dist/**",
|
|
259
|
+
"!**/build/**",
|
|
260
|
+
"!**/coverage/**"
|
|
261
|
+
], { cwd: process.cwd(), dot: true });
|
|
262
|
+
let mapStr = files.join("\n");
|
|
263
|
+
if (mapStr.length > 5e3) {
|
|
264
|
+
mapStr = mapStr.substring(0, 5e3) + "\n\n... [WORKSPACE MAP TRUNCATED DUE TO SIZE LIMIT].\nWARNING: The repository is too large to fit in this prompt. Use your `list_dir` tool to explore directories to find the exact file paths you need.";
|
|
265
|
+
}
|
|
266
|
+
return mapStr;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
return "";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
var init_scanner = __esm({
|
|
272
|
+
"src/core/scanner.ts"() {
|
|
273
|
+
"use strict";
|
|
274
|
+
init_logger();
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// src/core/runner.ts
|
|
279
|
+
import { exec as exec4 } from "child_process";
|
|
280
|
+
import { promisify as promisify4 } from "util";
|
|
281
|
+
async function runSingleTest(testFilePath, projectInfo) {
|
|
282
|
+
let command = `npm test -- "${testFilePath}"`;
|
|
283
|
+
if (projectInfo.testRunner === "jest") {
|
|
284
|
+
command = `npx jest "${testFilePath}" --forceExit`;
|
|
285
|
+
} else if (projectInfo.testRunner === "vitest") {
|
|
286
|
+
command = `npx vitest run "${testFilePath}"`;
|
|
287
|
+
} else if (projectInfo.testRunner === "mocha") {
|
|
288
|
+
command = `npx mocha "${testFilePath}" --exit`;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const { stdout, stderr } = await execAsync4(command, { cwd: process.cwd() });
|
|
292
|
+
return {
|
|
293
|
+
passed: true,
|
|
294
|
+
output: `${stdout}
|
|
295
|
+
${stderr}`.trim()
|
|
296
|
+
};
|
|
297
|
+
} catch (error) {
|
|
298
|
+
return {
|
|
299
|
+
passed: false,
|
|
300
|
+
output: `${error.stdout || ""}
|
|
301
|
+
${error.stderr || ""}
|
|
302
|
+
${error.message || ""}`.trim()
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
var execAsync4;
|
|
307
|
+
var init_runner = __esm({
|
|
308
|
+
"src/core/runner.ts"() {
|
|
309
|
+
"use strict";
|
|
310
|
+
execAsync4 = promisify4(exec4);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// src/core/agentic.ts
|
|
315
|
+
import { exec as exec5 } from "child_process";
|
|
316
|
+
import { promisify as promisify5 } from "util";
|
|
317
|
+
import { writeFileSync as writeFileSync3, readFileSync as readFileSync6, existsSync as existsSync4, readdirSync } from "fs";
|
|
318
|
+
import { resolve as resolve7, dirname as dirname2 } from "path";
|
|
319
|
+
import chalk2 from "chalk";
|
|
320
|
+
var execAsync5, AgenticPlanner;
|
|
321
|
+
var init_agentic = __esm({
|
|
322
|
+
"src/core/agentic.ts"() {
|
|
323
|
+
"use strict";
|
|
324
|
+
init_ai();
|
|
325
|
+
init_runner();
|
|
326
|
+
init_generate();
|
|
327
|
+
execAsync5 = promisify5(exec5);
|
|
328
|
+
AgenticPlanner = class {
|
|
329
|
+
constructor(config2, projectInfo, workspaceMap = "") {
|
|
330
|
+
this.config = config2;
|
|
331
|
+
this.projectInfo = projectInfo;
|
|
332
|
+
this.workspaceMap = workspaceMap;
|
|
333
|
+
}
|
|
334
|
+
config;
|
|
335
|
+
projectInfo;
|
|
336
|
+
workspaceMap;
|
|
337
|
+
/** Detect if the AI provider is a cloud provider with a massive context window */
|
|
338
|
+
_isCloudProvider() {
|
|
339
|
+
const p = (this.config.provider || "").toLowerCase();
|
|
340
|
+
return p.includes("deepseek") || p.includes("openai") || p.includes("anthropic") || p.includes("gemini") || p.includes("google");
|
|
341
|
+
}
|
|
342
|
+
/** Generate tests for a file using the LLM planning loop. */
|
|
343
|
+
async generateFile(sourceFilePath, options = {}) {
|
|
344
|
+
const { forceUpdate = false, evaluateExisting = false } = options;
|
|
345
|
+
let isMassive = false;
|
|
346
|
+
let fileLength = 0;
|
|
347
|
+
try {
|
|
348
|
+
const content = readFileSync6(sourceFilePath, "utf-8");
|
|
349
|
+
fileLength = content.length;
|
|
350
|
+
isMassive = fileLength > 2e4;
|
|
351
|
+
} catch (e) {
|
|
352
|
+
}
|
|
353
|
+
let initialResult = "failed";
|
|
354
|
+
if (isMassive) {
|
|
355
|
+
console.log(chalk2.yellow(`
|
|
356
|
+
\u26A0 File ${sourceFilePath.split("/").pop()} is massive (${fileLength} chars). Engaging Agentic Chunking Generator...`));
|
|
357
|
+
initialResult = await this._runAgenticGeneratorLoop(sourceFilePath, this._deriveTestPath(sourceFilePath));
|
|
358
|
+
} else {
|
|
359
|
+
initialResult = await generateTestForFile(
|
|
360
|
+
sourceFilePath,
|
|
361
|
+
this.config,
|
|
362
|
+
this.projectInfo,
|
|
363
|
+
forceUpdate,
|
|
364
|
+
evaluateExisting,
|
|
365
|
+
this.workspaceMap
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
if (initialResult === "skipped") return "skipped";
|
|
369
|
+
if (initialResult === "failed") return "failed";
|
|
370
|
+
return this._runAgenticLoop(this._deriveTestPath(sourceFilePath), sourceFilePath);
|
|
371
|
+
}
|
|
372
|
+
/** Run the agentic repair loop on an existing test file. */
|
|
373
|
+
async fixTestFile(testFilePath, sourceFilePath) {
|
|
374
|
+
return this._runAgenticLoop(testFilePath, sourceFilePath);
|
|
375
|
+
}
|
|
376
|
+
async _runAgenticGeneratorLoop(sourceFilePath, testFilePath) {
|
|
377
|
+
let attempts = 0;
|
|
378
|
+
const maxAttempts = 50;
|
|
379
|
+
let stagnationCounter = 0;
|
|
380
|
+
let previousStepsHash = "";
|
|
381
|
+
let duplicateCount = 0;
|
|
382
|
+
let testCode = "";
|
|
383
|
+
const history = [];
|
|
384
|
+
let fileLines = [];
|
|
385
|
+
try {
|
|
386
|
+
fileLines = readFileSync6(sourceFilePath, "utf-8").split("\n");
|
|
387
|
+
} catch (e) {
|
|
388
|
+
return "failed";
|
|
389
|
+
}
|
|
390
|
+
if (existsSync4(testFilePath)) {
|
|
391
|
+
testCode = readFileSync6(testFilePath, "utf-8");
|
|
392
|
+
} else {
|
|
393
|
+
writeFileSync3(testFilePath, testCode, "utf-8");
|
|
394
|
+
}
|
|
395
|
+
console.log(chalk2.blue(`\u2139 Starting Agentic Chunking Generator for ${sourceFilePath.split("/").pop()} (${fileLines.length} lines)...`));
|
|
396
|
+
while (attempts < maxAttempts) {
|
|
397
|
+
attempts++;
|
|
398
|
+
const plan = await this._requestGenPlan(testCode, testFilePath, sourceFilePath, fileLines.length, history);
|
|
399
|
+
if (!plan) {
|
|
400
|
+
console.log(chalk2.red(`\u2716 AI failed to generate a valid generation plan.`));
|
|
401
|
+
return "failed";
|
|
402
|
+
}
|
|
403
|
+
const currentStepsHash = JSON.stringify(plan.steps);
|
|
404
|
+
if (currentStepsHash === previousStepsHash) {
|
|
405
|
+
duplicateCount++;
|
|
406
|
+
if (duplicateCount >= 3) {
|
|
407
|
+
console.log(chalk2.red(`\u2716 AI generated the exact same action 3 times in a row. Forcibly breaking loop.`));
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
} else {
|
|
411
|
+
duplicateCount = 0;
|
|
412
|
+
previousStepsHash = currentStepsHash;
|
|
413
|
+
}
|
|
414
|
+
console.log(chalk2.cyan(`\u{1F916} AI Generator returned a plan:
|
|
415
|
+
\u{1F914} Reasoning: ${plan.reasoning}`));
|
|
416
|
+
let isFinished = false;
|
|
417
|
+
let madeProgress = false;
|
|
418
|
+
for (const step of plan.steps) {
|
|
419
|
+
if (step.action === "read_lines") {
|
|
420
|
+
console.log(chalk2.cyan(` - \u{1F4D6} Reading lines ${step.start} to ${step.end}`));
|
|
421
|
+
const startIdx = Math.max(0, step.start - 1);
|
|
422
|
+
const endIdx = Math.min(fileLines.length, step.end);
|
|
423
|
+
const chunk = fileLines.slice(startIdx, endIdx).map((l, i) => `${startIdx + i + 1}: ${l}`).join("\n");
|
|
424
|
+
history.push(`Attempt ${attempts}: Read lines ${step.start}-${step.end}:
|
|
425
|
+
${chunk}`);
|
|
426
|
+
} else if (step.action === "search_file") {
|
|
427
|
+
console.log(chalk2.cyan(` - \u{1F50D} Searching for "${step.query}"`));
|
|
428
|
+
const matches = fileLines.map((line, idx) => ({ line, idx: idx + 1 })).filter(({ line }) => line.includes(step.query)).slice(0, 30);
|
|
429
|
+
const matchStr = matches.length > 0 ? matches.map((m) => `Line ${m.idx}: ${m.line}`).join("\n") : "No matches found.";
|
|
430
|
+
history.push(`Attempt ${attempts}: Searched for "${step.query}". Results:
|
|
431
|
+
${matchStr}`);
|
|
432
|
+
} else if (step.action === "append_test") {
|
|
433
|
+
console.log(chalk2.cyan(` - \u{1F4DD} Appending test code chunk`));
|
|
434
|
+
testCode += "\n" + step.code;
|
|
435
|
+
writeFileSync3(testFilePath, testCode, "utf-8");
|
|
436
|
+
history.push(`Attempt ${attempts}: Appended test code.`);
|
|
437
|
+
madeProgress = true;
|
|
438
|
+
} else if (step.action === "finish") {
|
|
439
|
+
console.log(chalk2.green(` - \u2705 AI finished generating the test file.`));
|
|
440
|
+
history.push(`Attempt ${attempts}: Finished. Reason: ${step.reason}`);
|
|
441
|
+
isFinished = true;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (isFinished) {
|
|
445
|
+
return "generated";
|
|
446
|
+
}
|
|
447
|
+
if (madeProgress) {
|
|
448
|
+
stagnationCounter = 0;
|
|
449
|
+
} else {
|
|
450
|
+
stagnationCounter++;
|
|
451
|
+
}
|
|
452
|
+
if (stagnationCounter >= 10) {
|
|
453
|
+
console.log(chalk2.red(`
|
|
454
|
+
\u2716 AI exhausted 10 exploration attempts without making progress. Forcibly breaking loop.`));
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
458
|
+
}
|
|
459
|
+
if (attempts >= maxAttempts) {
|
|
460
|
+
console.log(chalk2.yellow(`
|
|
461
|
+
\u26A0 Reached maximum limit of ${maxAttempts} attempts. Safe breaking. To continue generating coverage, run the command again.`));
|
|
462
|
+
}
|
|
463
|
+
return testCode.trim().length > 0 ? "generated" : "failed";
|
|
464
|
+
}
|
|
465
|
+
async _requestGenPlan(testCode, testFilePath, sourceFilePath, totalLines, history) {
|
|
466
|
+
const historyContext = history.length > 0 ? `
|
|
467
|
+
--- PREVIOUS ACTIONS & RESULTS ---
|
|
468
|
+
${history.join("\n\n")}
|
|
469
|
+
` : "";
|
|
470
|
+
const testFramework = this.projectInfo.testRunner === "unknown" ? "Jest" : this.projectInfo.testRunner;
|
|
471
|
+
const isCloud = this._isCloudProvider();
|
|
472
|
+
const testLines = testCode.split("\n");
|
|
473
|
+
let testContext = "";
|
|
474
|
+
if (!isCloud && testLines.length > 500) {
|
|
475
|
+
const topLines = testLines.slice(0, 50).map((l, i) => `${i + 1}: ${l}`).join("\n");
|
|
476
|
+
const bottomLines = testLines.slice(-200).map((l, i) => `${testLines.length - 200 + i + 1}: ${l}`).join("\n");
|
|
477
|
+
testContext = `
|
|
478
|
+
--- CURRENT TEST FILE PROGRESS (${testFilePath}) ---
|
|
479
|
+
${topLines}
|
|
480
|
+
... [${testLines.length - 250} lines omitted for Local LLM context support] ...
|
|
481
|
+
${bottomLines}`;
|
|
482
|
+
} else {
|
|
483
|
+
const testCodeWithLines = testLines.map((l, i) => `${i + 1}: ${l}`).join("\n");
|
|
484
|
+
testContext = `
|
|
485
|
+
--- CURRENT TEST FILE PROGRESS (${testFilePath}) ---
|
|
486
|
+
${testCodeWithLines || "(Empty)"}`;
|
|
487
|
+
}
|
|
488
|
+
const prompt = `You are an expert QA engineer building a test suite for a massive file using an interactive chunking agent.
|
|
489
|
+
--- TARGET FILE INFO ---
|
|
490
|
+
File: ${sourceFilePath}
|
|
491
|
+
Total Lines: ${totalLines}
|
|
492
|
+
Test Framework: ${testFramework}
|
|
493
|
+
${testContext}
|
|
494
|
+
${historyContext}
|
|
495
|
+
Your goal is to explore the target file chunk-by-chunk and incrementally build the test suite by appending test blocks.
|
|
496
|
+
Based on the current progress, suggest ONE JSON plan describing your next steps. You can combine multiple actions in one plan.
|
|
497
|
+
The JSON must follow this exact schema:
|
|
498
|
+
{
|
|
499
|
+
"reasoning": "<explain what you are looking for or writing>",
|
|
500
|
+
"steps": [
|
|
501
|
+
{ "action": "search_file", "query": "<string to search for, e.g. 'export function'>" }
|
|
502
|
+
| { "action": "read_lines", "start": <line number>, "end": <line number> }
|
|
503
|
+
| { "action": "append_test", "code": "<valid javascript/typescript test code block to append>" }
|
|
504
|
+
| { "action": "finish", "reason": "<explanation of completion>" }
|
|
505
|
+
]
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
CRITICAL RULES:
|
|
509
|
+
1. Do NOT wrap the JSON in markdown blocks. Output ONLY raw JSON.
|
|
510
|
+
2. If you don't know where the functions are, use \`search_file\` with queries like "class ", "function ", or "module.exports" to find line numbers.
|
|
511
|
+
3. Once you know the line numbers, use \`read_lines\` to read the implementation of a specific function (max 300 lines at a time).
|
|
512
|
+
4. After reading the implementation, use \`append_test\` to write the test case(s) for that specific function.
|
|
513
|
+
5. If using \`append_test\`, ensure the code is a complete block (e.g. \`describe('...', () => { ... })\`).
|
|
514
|
+
6. When you have tested all major functions, use \`finish\`.`;
|
|
515
|
+
try {
|
|
516
|
+
const raw = await askAI(this.config, "Generate a JSON plan for incremental test generation.", prompt);
|
|
517
|
+
const cleaned = raw.replace(/```json/g, "").replace(/```/g, "").trim();
|
|
518
|
+
const match = cleaned.match(/\{[\s\S]*\}/);
|
|
519
|
+
if (!match) return null;
|
|
520
|
+
return JSON.parse(match[0]);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
console.log(chalk2.red(`
|
|
523
|
+
\u2716 AI generator request failed: ${error.message || error}`));
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/** Core agentic reasoning loop for generating and fixing tests. */
|
|
528
|
+
async _runAgenticLoop(testFilePath, sourceFilePath) {
|
|
529
|
+
let attempts = 0;
|
|
530
|
+
const maxAttempts = this.config.maxRetries !== void 0 ? this.config.maxRetries : 3;
|
|
531
|
+
let lastError = "";
|
|
532
|
+
let testCode = readFileSync6(testFilePath, "utf-8");
|
|
533
|
+
const history = [];
|
|
534
|
+
const recentPatches = [];
|
|
535
|
+
const fileLabel = sourceFilePath ? sourceFilePath.split("/").pop() : testFilePath.split("/").pop();
|
|
536
|
+
while (maxAttempts === -1 || attempts < maxAttempts) {
|
|
537
|
+
attempts++;
|
|
538
|
+
const result = await runSingleTest(testFilePath, this.projectInfo);
|
|
539
|
+
if (result.passed) {
|
|
540
|
+
if (attempts > 1) {
|
|
541
|
+
console.log(chalk2.green(`
|
|
542
|
+
\u2714 AI successfully fixed the test for ${fileLabel} after ${attempts - 1} attempt(s)!`));
|
|
543
|
+
} else {
|
|
544
|
+
console.log(chalk2.green(`
|
|
545
|
+
\u2714 Test ${fileLabel} passed successfully in isolation. No AI fixes were needed!`));
|
|
546
|
+
}
|
|
547
|
+
return "generated";
|
|
548
|
+
}
|
|
549
|
+
lastError = result.output;
|
|
550
|
+
const maxText = maxAttempts === -1 ? "\u221E" : maxAttempts;
|
|
551
|
+
console.log(chalk2.yellow(`
|
|
552
|
+
\u26A0 Test failed for ${fileLabel}. Requesting fix from AI (Attempt ${attempts} of ${maxText})...`));
|
|
553
|
+
const snippet = lastError.split("\n").slice(0, 15).join("\n");
|
|
554
|
+
console.log(chalk2.dim(` Error Snippet:
|
|
555
|
+
${snippet.replace(/\n/g, "\n ")}`));
|
|
556
|
+
let explorationContext = "";
|
|
557
|
+
let explorationAttempts = 0;
|
|
558
|
+
let planExecuted = false;
|
|
559
|
+
let previousStepsHash = "";
|
|
560
|
+
let duplicateCount = 0;
|
|
561
|
+
while (explorationAttempts < 5 && !planExecuted) {
|
|
562
|
+
explorationAttempts++;
|
|
563
|
+
const plan = await this._requestPlan(lastError, testCode, testFilePath, history, sourceFilePath, explorationContext);
|
|
564
|
+
if (!plan) {
|
|
565
|
+
console.log(chalk2.red(`\u2716 AI failed to generate a valid plan. Giving up on this file.`));
|
|
566
|
+
return "failed";
|
|
567
|
+
}
|
|
568
|
+
const currentStepsHash = JSON.stringify(plan.steps);
|
|
569
|
+
if (currentStepsHash === previousStepsHash) {
|
|
570
|
+
duplicateCount++;
|
|
571
|
+
if (duplicateCount >= 2) {
|
|
572
|
+
console.log(chalk2.red(`\u2716 AI generated the exact same exploration action consecutively. Forcibly breaking loop.`));
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
} else {
|
|
576
|
+
duplicateCount = 0;
|
|
577
|
+
previousStepsHash = currentStepsHash;
|
|
578
|
+
}
|
|
579
|
+
console.log(chalk2.cyan(`\u{1F916} AI returned a plan:`));
|
|
580
|
+
if (plan.reasoning) {
|
|
581
|
+
console.log(chalk2.gray(` \u{1F914} Reasoning: ${plan.reasoning}`));
|
|
582
|
+
}
|
|
583
|
+
let needsToBreakAndRunTests = false;
|
|
584
|
+
for (const step of plan.steps) {
|
|
585
|
+
if (step.action === "read_file") {
|
|
586
|
+
console.log(chalk2.cyan(` - \u{1F4D6} Reading file: ${step.path}`));
|
|
587
|
+
try {
|
|
588
|
+
const content = readFileSync6(resolve7(process.cwd(), step.path), "utf-8");
|
|
589
|
+
explorationContext += `
|
|
590
|
+
--- READ FILE: ${step.path} ---
|
|
591
|
+
${content}
|
|
592
|
+
`;
|
|
593
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Read file ${step.path}`);
|
|
594
|
+
} catch (err) {
|
|
595
|
+
explorationContext += `
|
|
596
|
+
--- FAILED TO READ FILE: ${step.path} ---
|
|
597
|
+
Error: ${err.message}
|
|
598
|
+
`;
|
|
599
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Failed to read file ${step.path}`);
|
|
600
|
+
}
|
|
601
|
+
} else if (step.action === "search_file") {
|
|
602
|
+
console.log(chalk2.cyan(` - \u{1F50D} Searching for "${step.query}" in ${step.file}`));
|
|
603
|
+
try {
|
|
604
|
+
const content = readFileSync6(resolve7(process.cwd(), step.file), "utf-8");
|
|
605
|
+
const matches = content.split("\n").map((line, idx) => ({ line, idx: idx + 1 })).filter(({ line }) => line.includes(step.query)).slice(0, 30);
|
|
606
|
+
const matchStr = matches.length > 0 ? matches.map((m) => `Line ${m.idx}: ${m.line}`).join("\n") : "No matches found.";
|
|
607
|
+
explorationContext += `
|
|
608
|
+
--- SEARCH RESULTS FOR "${step.query}" IN ${step.file} ---
|
|
609
|
+
${matchStr}
|
|
610
|
+
`;
|
|
611
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Searched for "${step.query}" in ${step.file}`);
|
|
612
|
+
} catch (err) {
|
|
613
|
+
explorationContext += `
|
|
614
|
+
--- FAILED TO SEARCH FILE: ${step.file} ---
|
|
615
|
+
Error: ${err.message}
|
|
616
|
+
`;
|
|
617
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Failed to search file ${step.file}`);
|
|
618
|
+
}
|
|
619
|
+
} else if (step.action === "read_lines") {
|
|
620
|
+
console.log(chalk2.cyan(` - \u{1F4D6} Reading lines ${step.startLine}-${step.endLine} from ${step.file}`));
|
|
621
|
+
try {
|
|
622
|
+
const content = readFileSync6(resolve7(process.cwd(), step.file), "utf-8");
|
|
623
|
+
const lines = content.split("\n");
|
|
624
|
+
const startIdx = Math.max(0, step.startLine - 1);
|
|
625
|
+
const endIdx = Math.min(lines.length, step.endLine);
|
|
626
|
+
const chunk = lines.slice(startIdx, endIdx).map((l, i) => `${startIdx + i + 1}: ${l}`).join("\n");
|
|
627
|
+
explorationContext += `
|
|
628
|
+
--- READ LINES ${step.startLine}-${step.endLine} FROM ${step.file} ---
|
|
629
|
+
${chunk}
|
|
630
|
+
`;
|
|
631
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Read lines ${step.startLine}-${step.endLine} from ${step.file}`);
|
|
632
|
+
} catch (err) {
|
|
633
|
+
explorationContext += `
|
|
634
|
+
--- FAILED TO READ LINES: ${step.file} ---
|
|
635
|
+
Error: ${err.message}
|
|
636
|
+
`;
|
|
637
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Failed to read lines from ${step.file}`);
|
|
638
|
+
}
|
|
639
|
+
} else if (step.action === "list_dir") {
|
|
640
|
+
console.log(chalk2.cyan(` - \u{1F4C2} Listing directory: ${step.path}`));
|
|
641
|
+
try {
|
|
642
|
+
const files = readdirSync(resolve7(process.cwd(), step.path));
|
|
643
|
+
explorationContext += `
|
|
644
|
+
--- DIRECTORY: ${step.path} ---
|
|
645
|
+
${files.join("\n")}
|
|
646
|
+
`;
|
|
647
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Listed directory ${step.path}`);
|
|
648
|
+
} catch (err) {
|
|
649
|
+
explorationContext += `
|
|
650
|
+
--- FAILED TO LIST DIRECTORY: ${step.path} ---
|
|
651
|
+
Error: ${err.message}
|
|
652
|
+
`;
|
|
653
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Failed to list directory ${step.path}`);
|
|
654
|
+
}
|
|
655
|
+
} else if (step.action === "replace_lines") {
|
|
656
|
+
needsToBreakAndRunTests = true;
|
|
657
|
+
console.log(chalk2.cyan(` - \u{1F4DD} Replace lines ${step.startLine}-${step.endLine} in ${step.file.split("/").pop()}`));
|
|
658
|
+
history.push(`Attempt ${attempts}: Replaced lines ${step.startLine}-${step.endLine} in ${step.file.split("/").pop()}`);
|
|
659
|
+
recentPatches.push(step.replacementCode);
|
|
660
|
+
if (recentPatches.length > 3) recentPatches.shift();
|
|
661
|
+
if (recentPatches.length === 3 && recentPatches.every((code) => code === step.replacementCode)) {
|
|
662
|
+
console.log(chalk2.red(`\u2716 AI generated the exact same patch 3 times in a row. Forcibly breaking infinite loop.`));
|
|
663
|
+
return "failed";
|
|
664
|
+
}
|
|
665
|
+
} else if (step.action === "install_deps") {
|
|
666
|
+
needsToBreakAndRunTests = true;
|
|
667
|
+
console.log(chalk2.cyan(` - \u{1F4E6} Install dependencies: ${step.deps.join(", ")}`));
|
|
668
|
+
history.push(`Attempt ${attempts}: Installed dependencies ${step.deps.join(", ")}`);
|
|
669
|
+
} else if (step.action === "abort") {
|
|
670
|
+
needsToBreakAndRunTests = true;
|
|
671
|
+
console.log(chalk2.cyan(` - \u{1F6D1} Abort: ${step.reason}`));
|
|
672
|
+
history.push(`Attempt ${attempts}: Aborted with reason: ${step.reason}`);
|
|
673
|
+
if (sourceFilePath) {
|
|
674
|
+
const bugFile = resolve7(process.cwd(), "aitest-bugs.md");
|
|
675
|
+
const fs = await import("fs");
|
|
676
|
+
fs.appendFileSync(bugFile, `
|
|
677
|
+
## Source Code Issue Detected in ${sourceFilePath}
|
|
678
|
+
**AI Reasoning**: ${plan.reasoning || "No reasoning provided."}
|
|
679
|
+
**Abort Reason**: ${step.reason}
|
|
680
|
+
`, "utf-8");
|
|
681
|
+
console.log(chalk2.yellow(` \u26A0 Issue logged to aitest-bugs.md for later fixing.`));
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
if (needsToBreakAndRunTests) {
|
|
686
|
+
const execResult = await this._executePlan(plan);
|
|
687
|
+
if (execResult === "abort") {
|
|
688
|
+
console.log(chalk2.red(`\u2716 AI plan execution aborted.`));
|
|
689
|
+
return "skipped";
|
|
690
|
+
}
|
|
691
|
+
planExecuted = true;
|
|
692
|
+
} else {
|
|
693
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (!planExecuted) {
|
|
697
|
+
console.log(chalk2.red(`\u2716 AI exhausted exploration limit without applying a patch. Giving up on this file.`));
|
|
698
|
+
return "failed";
|
|
699
|
+
}
|
|
700
|
+
if (existsSync4(testFilePath)) {
|
|
701
|
+
testCode = readFileSync6(testFilePath, "utf-8");
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
console.log(chalk2.red(`
|
|
705
|
+
\u2716 Exhausted ${maxAttempts} AI attempts without passing.`));
|
|
706
|
+
return "failed";
|
|
707
|
+
}
|
|
708
|
+
/** Derive the .test.* filename from a source file path. */
|
|
709
|
+
_deriveTestPath(sourceFilePath) {
|
|
710
|
+
const ext = sourceFilePath.substring(sourceFilePath.lastIndexOf("."));
|
|
711
|
+
const base = sourceFilePath.substring(
|
|
712
|
+
sourceFilePath.lastIndexOf("/") + 1,
|
|
713
|
+
sourceFilePath.length - ext.length
|
|
714
|
+
);
|
|
715
|
+
const dir = dirname2(sourceFilePath);
|
|
716
|
+
return resolve7(dir, `${base}.test${ext}`);
|
|
717
|
+
}
|
|
718
|
+
/** Prompt the LLM for a JSON plan based on a failed test run. */
|
|
719
|
+
async _requestPlan(errorOutput, testCode, testFilePath, history, sourceFilePath, explorationContext = "") {
|
|
720
|
+
const historyContext = history.length > 0 ? `
|
|
721
|
+
--- PREVIOUS ACTIONS TAKEN ---
|
|
722
|
+
${history.join("\n")}
|
|
723
|
+
WARNING: The test is still failing. Do NOT suggest the exact same action again.` : "";
|
|
724
|
+
let sourceContext = "";
|
|
725
|
+
if (sourceFilePath && existsSync4(sourceFilePath)) {
|
|
726
|
+
const sourceContent = readFileSync6(sourceFilePath, "utf-8");
|
|
727
|
+
const sourceLines = sourceContent.split("\n");
|
|
728
|
+
if (sourceLines.length > 1e3) {
|
|
729
|
+
sourceContext = `
|
|
730
|
+
--- SOURCE FILE INFO ---
|
|
731
|
+
The source file (${sourceFilePath}) is massive (${sourceLines.length} lines) and has been omitted from this prompt to save context and improve accuracy. You MUST use the "search_file" and "read_lines" actions to dynamically read the specific functions you need to fix the test.`;
|
|
732
|
+
} else {
|
|
733
|
+
sourceContext = `
|
|
734
|
+
--- SOURCE FILE (${sourceFilePath}) ---
|
|
735
|
+
${sourceContent}`;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
const workspaceContext = this.workspaceMap ? `
|
|
739
|
+
--- WORKSPACE FILE STRUCTURE ---
|
|
740
|
+
${this.workspaceMap}` : "";
|
|
741
|
+
const explorationContextString = explorationContext ? `
|
|
742
|
+
--- EXPLORATION CONTEXT ---
|
|
743
|
+
${explorationContext}` : "";
|
|
744
|
+
const isCloud = this._isCloudProvider();
|
|
745
|
+
const testLines = testCode.split("\n");
|
|
746
|
+
let testContext = "";
|
|
747
|
+
if (!isCloud && testLines.length > 500) {
|
|
748
|
+
testContext = `
|
|
749
|
+
--- TEST FILE INFO ---
|
|
750
|
+
The test file (${testFilePath}) is massive (${testLines.length} lines) and has been omitted to support Local LLMs. Look at the ERROR OUTPUT to find the line number of the failing test (e.g., file.test.js:6540). You MUST use the "read_lines" action to read the test file around that line number before applying a "replace_lines" patch.`;
|
|
751
|
+
} else {
|
|
752
|
+
const testCodeWithLines = testLines.map((line, idx) => `${idx + 1}: ${line}`).join("\n");
|
|
753
|
+
testContext = `
|
|
754
|
+
--- TEST FILE (${testFilePath}) ---
|
|
755
|
+
${testCodeWithLines}`;
|
|
756
|
+
}
|
|
757
|
+
const prompt = `You are an expert QA engineer. The following test has failed.${sourceContext}${workspaceContext}${explorationContextString}${testContext}
|
|
758
|
+
--- ERROR OUTPUT ---
|
|
759
|
+
${errorOutput}${historyContext}
|
|
760
|
+
Based on this information, suggest ONE JSON plan describing the next actionable step. The JSON must follow this schema:
|
|
761
|
+
{ "reasoning": "<explain the failure and your fix or exploration strategy>", "steps": [ { "action": "replace_lines", "file": "${testFilePath}", "startLine": <number>, "endLine": <number>, "replacementCode": "<new test code to replace the specified lines>" } | { "action": "search_file", "file": "<path>", "query": "<string to search for>" } | { "action": "read_lines", "file": "<path>", "startLine": <number>, "endLine": <number> } | { "action": "read_file", "path": "<relative path to file>" } | { "action": "list_dir", "path": "<relative path to dir>" } | { "action": "install_deps", "deps": ["<package>"] } | { "action": "abort", "reason": "<text>" } ] }
|
|
762
|
+
CRITICAL RULES:
|
|
763
|
+
1. To explore massive files safely, use "search_file" to find function signatures, then "read_lines" to read the implementation block. For small files, use "read_file". If you need to see what files exist in a directory, use "list_dir".
|
|
764
|
+
2. You may ONLY patch the test file (${testFilePath}) using the "replace_lines" action.
|
|
765
|
+
3. For "replace_lines", provide the exact startLine and endLine numbers based on the line numbers shown in the TEST FILE block. To insert code without removing any lines, set startLine and endLine to the line number where you want to insert.
|
|
766
|
+
4. Do NOT wrap the JSON in markdown code blocks. Output ONLY the raw JSON object.
|
|
767
|
+
5. If you cannot fix the issue, return an "abort" action.`;
|
|
768
|
+
try {
|
|
769
|
+
const raw = await askAI(this.config, "Generate a JSON plan to fix the failing test.", prompt);
|
|
770
|
+
const plan = this._parsePlan(raw);
|
|
771
|
+
if (plan && this._validatePlan(plan, testFilePath)) return plan;
|
|
772
|
+
return null;
|
|
773
|
+
} catch (error) {
|
|
774
|
+
console.log(chalk2.red(`
|
|
775
|
+
\u2716 AI request failed: ${error.message || error}`));
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/** Extract JSON from LLM response and parse it. */
|
|
780
|
+
_parsePlan(text) {
|
|
781
|
+
try {
|
|
782
|
+
const cleaned = text.replace(/```json/g, "").replace(/```/g, "").trim();
|
|
783
|
+
const match = cleaned.match(/\{[\s\S]*\}/);
|
|
784
|
+
if (!match) return null;
|
|
785
|
+
const obj = JSON.parse(match[0]);
|
|
786
|
+
if (obj && Array.isArray(obj.steps)) return obj;
|
|
787
|
+
} catch (e) {
|
|
788
|
+
}
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
/** Validate that the plan only contains allowed actions and safe file paths. */
|
|
792
|
+
_validatePlan(plan, testFilePath) {
|
|
793
|
+
const allowed = /* @__PURE__ */ new Set(["replace_lines", "install_deps", "abort", "read_file", "list_dir", "search_file", "read_lines"]);
|
|
794
|
+
for (const step of plan.steps) {
|
|
795
|
+
if (!allowed.has(step.action)) return false;
|
|
796
|
+
if (step.action === "replace_lines") {
|
|
797
|
+
const abs = resolve7(step.file);
|
|
798
|
+
if (abs !== testFilePath) return false;
|
|
799
|
+
if (typeof step.startLine !== "number") return false;
|
|
800
|
+
if (typeof step.endLine !== "number") return false;
|
|
801
|
+
if (typeof step.replacementCode !== "string") return false;
|
|
802
|
+
}
|
|
803
|
+
if (step.action === "install_deps") {
|
|
804
|
+
if (!Array.isArray(step.deps)) return false;
|
|
805
|
+
}
|
|
806
|
+
if (step.action === "read_file" || step.action === "list_dir") {
|
|
807
|
+
if (typeof step.path !== "string") return false;
|
|
808
|
+
}
|
|
809
|
+
if (step.action === "search_file") {
|
|
810
|
+
if (typeof step.file !== "string") return false;
|
|
811
|
+
if (typeof step.query !== "string") return false;
|
|
812
|
+
}
|
|
813
|
+
if (step.action === "read_lines") {
|
|
814
|
+
if (typeof step.file !== "string") return false;
|
|
815
|
+
if (typeof step.startLine !== "number") return false;
|
|
816
|
+
if (typeof step.endLine !== "number") return false;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return true;
|
|
820
|
+
}
|
|
821
|
+
/** Execute a validated plan step‑by‑step. */
|
|
822
|
+
async _executePlan(plan) {
|
|
823
|
+
for (const step of plan.steps) {
|
|
824
|
+
switch (step.action) {
|
|
825
|
+
case "replace_lines": {
|
|
826
|
+
const fileContent = existsSync4(step.file) ? readFileSync6(step.file, "utf-8") : "";
|
|
827
|
+
const lines = fileContent.split("\n");
|
|
828
|
+
const startIdx = Math.max(0, step.startLine - 1);
|
|
829
|
+
const endIdx = Math.min(lines.length, step.endLine);
|
|
830
|
+
const newLines = step.replacementCode.split("\n");
|
|
831
|
+
lines.splice(startIdx, endIdx - startIdx, ...newLines);
|
|
832
|
+
writeFileSync3(step.file, lines.join("\n"), "utf-8");
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
case "install_deps": {
|
|
836
|
+
const deps = step.deps.join(" ");
|
|
837
|
+
try {
|
|
838
|
+
await execAsync5(`npm install --save-dev ${deps}`, { cwd: process.cwd() });
|
|
839
|
+
} catch (e) {
|
|
840
|
+
return "abort";
|
|
841
|
+
}
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
case "abort": {
|
|
845
|
+
return "abort";
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return "continue";
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// src/core/agentic.js
|
|
856
|
+
var agentic_exports = {};
|
|
857
|
+
__export(agentic_exports, {
|
|
858
|
+
AgenticPlanner: () => AgenticPlanner
|
|
859
|
+
});
|
|
860
|
+
var init_agentic2 = __esm({
|
|
861
|
+
"src/core/agentic.js"() {
|
|
862
|
+
"use strict";
|
|
863
|
+
init_agentic();
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// src/commands/generate.ts
|
|
868
|
+
import { Command as Command3 } from "commander";
|
|
869
|
+
import { exec as exec6 } from "child_process";
|
|
870
|
+
import { promisify as promisify6 } from "util";
|
|
871
|
+
import { readFileSync as readFileSync7, writeFileSync as writeFileSync4, existsSync as existsSync5 } from "fs";
|
|
872
|
+
import { resolve as resolve8, relative, dirname as dirname3, basename, extname } from "path";
|
|
873
|
+
import fg3 from "fast-glob";
|
|
874
|
+
import chalk3 from "chalk";
|
|
875
|
+
async function generateTestForFile(filePath, config2, projectInfo, forceUpdate = false, evaluateExisting = false, workspaceMap = "") {
|
|
876
|
+
const relPath = relative(process.cwd(), filePath);
|
|
877
|
+
const dir = dirname3(filePath);
|
|
878
|
+
const ext = extname(filePath);
|
|
879
|
+
const name = basename(filePath, ext);
|
|
880
|
+
const testFilePath = resolve8(dir, `${name}.test${ext}`);
|
|
881
|
+
const spinner = logger.spinner(`Analyzing ${relPath}...`).start();
|
|
882
|
+
if (existsSync5(testFilePath) && !forceUpdate) {
|
|
883
|
+
spinner.info(`Evaluating existing test file for missing coverage: ${relPath}`);
|
|
884
|
+
evaluateExisting = true;
|
|
885
|
+
}
|
|
886
|
+
let fileContent = "";
|
|
887
|
+
try {
|
|
888
|
+
fileContent = readFileSync7(filePath, "utf-8");
|
|
889
|
+
} catch (e) {
|
|
890
|
+
spinner.fail(`Could not read file ${filePath}`);
|
|
891
|
+
return "failed";
|
|
892
|
+
}
|
|
893
|
+
const depsContext = await scanDependencies(filePath);
|
|
894
|
+
let additionalContext = depsContext ? `
|
|
895
|
+
|
|
896
|
+
--- ARCHITECTURAL CONTEXT (DEPENDENCIES) ---
|
|
897
|
+
${depsContext}` : "";
|
|
898
|
+
if (workspaceMap) {
|
|
899
|
+
additionalContext += `
|
|
900
|
+
|
|
901
|
+
--- WORKSPACE FILE STRUCTURE ---
|
|
902
|
+
${workspaceMap}`;
|
|
903
|
+
}
|
|
904
|
+
let existingTestContext = "";
|
|
905
|
+
if (existsSync5(testFilePath)) {
|
|
906
|
+
const existingCode = readFileSync7(testFilePath, "utf-8");
|
|
907
|
+
existingTestContext = `
|
|
908
|
+
|
|
909
|
+
--- EXISTING TEST SUITE (NEEDS IMPROVEMENT) ---
|
|
910
|
+
${existingCode}
|
|
911
|
+
`;
|
|
912
|
+
}
|
|
913
|
+
try {
|
|
914
|
+
const promptContext = `Original file name: ${relPath}
|
|
915
|
+
|
|
916
|
+
Code:
|
|
917
|
+
${fileContent}
|
|
918
|
+
|
|
919
|
+
${additionalContext}${existingTestContext}`;
|
|
920
|
+
let promptInstructions = "";
|
|
921
|
+
if (existingTestContext) {
|
|
922
|
+
if (forceUpdate) {
|
|
923
|
+
promptInstructions = "You are an expert QA and software testing engineer. The provided existing test suite does not have 100% code coverage. Analyze the source code and the existing test suite, and REWRITE the test suite to include new test cases that cover the missing branches and logic. Output ONLY valid executable code (with markdown code blocks) and nothing else.";
|
|
924
|
+
} else if (evaluateExisting) {
|
|
925
|
+
promptInstructions = 'You are an expert QA and software testing engineer. Analyze the source code and the existing test suite to evaluate its code coverage. If the existing test suite is missing test cases for certain branches or logic, REWRITE the test suite to include new test cases that cover the missing parts. Output ONLY valid executable code (with markdown code blocks) and nothing else. If the existing test suite already fully covers the source code, reply ONLY with the exact string "SKIP_FILE".';
|
|
926
|
+
}
|
|
927
|
+
} else {
|
|
928
|
+
const testFramework = projectInfo.testRunner === "unknown" ? "Jest" : projectInfo.testRunner;
|
|
929
|
+
promptInstructions = `You are an expert QA and software testing engineer. Generate a comprehensive unit test suite for the provided code. Output ONLY valid executable code (with markdown code blocks) and nothing else. The target test framework is ${testFramework}. If the file is purely type definitions, interfaces, simple exports, or does not contain testable logic, reply ONLY with the exact string "SKIP_FILE" and do not generate any code. You will be provided with the target code and its imported dependencies to ensure you have the full architectural context.
|
|
930
|
+
CRITICAL: If testing Prisma or databases, use standard mock techniques (e.g. jest-mock-extended or vi.mock). If using Vitest, use vi.mocked() to mock imported functions to avoid TypeScript errors like 'Property does not exist on type'.`;
|
|
931
|
+
}
|
|
932
|
+
const generatedCode = await askAI(config2, promptInstructions, promptContext);
|
|
933
|
+
if (generatedCode.includes("SKIP_FILE")) {
|
|
934
|
+
spinner.info(`Skipped ${relPath}: No testable logic found.`);
|
|
935
|
+
return "skipped";
|
|
936
|
+
}
|
|
937
|
+
let testCode = generatedCode;
|
|
938
|
+
const match = generatedCode.match(/```(?:javascript|typescript|ts|js)?\n([\s\S]*?)```/) || generatedCode.match(/```[\w]*\n([\s\S]*?)```/);
|
|
939
|
+
if (match && match[1]) {
|
|
940
|
+
testCode = match[1].trim();
|
|
941
|
+
}
|
|
942
|
+
if (!testCode || testCode.trim() === "") {
|
|
943
|
+
spinner.warn(`AI returned empty code for ${relPath}. Skipping to prevent writing an empty file.`);
|
|
944
|
+
return "skipped";
|
|
945
|
+
}
|
|
946
|
+
writeFileSync4(testFilePath, testCode, "utf-8");
|
|
947
|
+
spinner.stop();
|
|
948
|
+
return "generated";
|
|
949
|
+
} catch (error) {
|
|
950
|
+
spinner.fail(`Error generating test for ${relPath}: ${error.message}`);
|
|
951
|
+
return "failed";
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
var execAsync6, generateCommand;
|
|
955
|
+
var init_generate = __esm({
|
|
956
|
+
"src/commands/generate.ts"() {
|
|
957
|
+
"use strict";
|
|
958
|
+
init_config();
|
|
959
|
+
init_logger();
|
|
960
|
+
init_detector();
|
|
961
|
+
init_ai();
|
|
962
|
+
init_scanner();
|
|
963
|
+
execAsync6 = promisify6(exec6);
|
|
964
|
+
generateCommand = new Command3("generate").description("Generate missing unit tests for the current project using Agentic AI").option("-a, --all", "Generate tests for all missing files").option("-f, --file <path>", "Generate tests for a specific file").option("-u, --force-update", "Force rewrite existing test files (ignores code coverage logic)").option("-e, --evaluate-existing", "Evaluate existing tests and ONLY rewrite if missing code coverage").action(async (options) => {
|
|
965
|
+
const config2 = await loadConfig();
|
|
966
|
+
if (!config2) {
|
|
967
|
+
logger.error("Configuration not found. Run `aitest init` first.");
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (!options.file && !options.all && !options.coverage) {
|
|
971
|
+
logger.error("Please specify --file <path>, --all, or --coverage");
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const projectInfo = detectProjectInfo();
|
|
975
|
+
const workspaceMap = await generateWorkspaceMap();
|
|
976
|
+
if (options.coverage) {
|
|
977
|
+
logger.info("Running coverage-driven test generation...");
|
|
978
|
+
let testCommand = "npm test -- --coverage";
|
|
979
|
+
if (projectInfo.testRunner === "mocha" || projectInfo.testRunner === "unknown") {
|
|
980
|
+
testCommand = "npm test --coverage";
|
|
981
|
+
}
|
|
982
|
+
const spinner = logger.spinner("Running test suite to collect coverage metrics...").start();
|
|
983
|
+
try {
|
|
984
|
+
const { stdout } = await execAsync6(testCommand, { cwd: process.cwd() });
|
|
985
|
+
spinner.succeed("Coverage data collected.");
|
|
986
|
+
const analyzeSpinner = logger.spinner("Analyzing coverage gaps with AI...").start();
|
|
987
|
+
const aiResponse = await askAI(
|
|
988
|
+
config2,
|
|
989
|
+
'Extract all file paths from this coverage report that have less than 100% coverage (statement, branch, or function). Return ONLY a JSON array of string paths (e.g. ["src/math.js"]). Do not output anything else. If no files are listed, return [].',
|
|
990
|
+
`STDOUT:
|
|
991
|
+
${stdout.slice(-5e3)}`
|
|
992
|
+
);
|
|
993
|
+
analyzeSpinner.succeed("Coverage gaps identified.");
|
|
994
|
+
const match = aiResponse.match(/\[[\s\S]*\]/);
|
|
995
|
+
if (match) {
|
|
996
|
+
const filesToFix = JSON.parse(match[0]);
|
|
997
|
+
if (filesToFix.length === 0) {
|
|
998
|
+
logger.success("All files have 100% coverage!");
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
logger.info(`Found ${filesToFix.length} files missing coverage. Starting targeted generation...`);
|
|
1002
|
+
for (const file of filesToFix) {
|
|
1003
|
+
const fullPath = resolve8(process.cwd(), file);
|
|
1004
|
+
if (existsSync5(fullPath)) {
|
|
1005
|
+
await generateTestForFile(fullPath, config2, projectInfo, true, false, workspaceMap);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
logger.success(`
|
|
1009
|
+
Finished coverage-driven generation for ${filesToFix.length} files.`);
|
|
1010
|
+
return;
|
|
1011
|
+
} else {
|
|
1012
|
+
logger.error("Failed to parse AI response for coverage files.");
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
} catch (e) {
|
|
1016
|
+
spinner.fail(`Failed to collect coverage data: ${e.message}`);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
if (options.all) {
|
|
1021
|
+
logger.info("Scanning project for source files...");
|
|
1022
|
+
const files = await fg3(["**/*.{js,ts,jsx,tsx}"], {
|
|
1023
|
+
ignore: [
|
|
1024
|
+
"**/node_modules/**",
|
|
1025
|
+
"**/dist/**",
|
|
1026
|
+
"**/build/**",
|
|
1027
|
+
"**/coverage/**",
|
|
1028
|
+
"**/*.test.*",
|
|
1029
|
+
"**/*.spec.*",
|
|
1030
|
+
"**/vite.config.*",
|
|
1031
|
+
"**/tsup.config.*",
|
|
1032
|
+
"**/.*"
|
|
1033
|
+
],
|
|
1034
|
+
cwd: process.cwd()
|
|
1035
|
+
});
|
|
1036
|
+
if (files.length === 0) {
|
|
1037
|
+
logger.info("No source files found.");
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
logger.info(`Found ${files.length} files. Starting test generation using Agentic AI...`);
|
|
1041
|
+
let successCount = 0;
|
|
1042
|
+
let skippedCount = 0;
|
|
1043
|
+
const { AgenticPlanner: AgenticPlanner2 } = await Promise.resolve().then(() => (init_agentic2(), agentic_exports));
|
|
1044
|
+
const planner = new AgenticPlanner2(config2, projectInfo, workspaceMap);
|
|
1045
|
+
const startTime = Date.now();
|
|
1046
|
+
for (let i = 0; i < files.length; i++) {
|
|
1047
|
+
const file = files[i];
|
|
1048
|
+
const fullPath = resolve8(process.cwd(), file);
|
|
1049
|
+
const result = await planner.generateFile(fullPath, { forceUpdate: options.forceUpdate, evaluateExisting: options.evaluateExisting, workspaceMap });
|
|
1050
|
+
if (result === "generated") successCount++;
|
|
1051
|
+
else if (result === "skipped") skippedCount++;
|
|
1052
|
+
}
|
|
1053
|
+
logger.success(`
|
|
1054
|
+
Finished! Generated tests for ${successCount} files (Skipped ${skippedCount} files). Total processed: ${files.length}.`);
|
|
1055
|
+
if (successCount > 0) {
|
|
1056
|
+
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1e3);
|
|
1057
|
+
console.log(chalk3.magentaBright(`
|
|
1058
|
+
\u2728 AI successfully generated tests for ${successCount} files in ${elapsedSeconds}s! Saved you ~${successCount * 2} hours of typing.`));
|
|
1059
|
+
const terminalLink = (await import("terminal-link")).default;
|
|
1060
|
+
const tweetUrl = `https://twitter.com/intent/tweet?text=I%20just%20used%20%40aitestcli%20to%20autonomously%20generate%20${successCount}%20test%20suites%20in%20${elapsedSeconds}s!%20%F0%9F%A4%AF%0A%0A%23ai%20%23testing%20%23javascript&url=https://www.npmjs.com/package/ai-test-cli`;
|
|
1061
|
+
console.log(chalk3.cyan("\u{1F680} " + terminalLink("Share your win on Twitter!", tweetUrl)));
|
|
1062
|
+
console.log(chalk3.yellow("\u2615 " + terminalLink("Buy the creator a coffee!", "https://buymeacoffee.com/cijaytechnh")) + "\n");
|
|
1063
|
+
}
|
|
1064
|
+
process.exit(0);
|
|
1065
|
+
} else if (options.file) {
|
|
1066
|
+
if (options.file.includes(".test.") || options.file.includes(".spec.")) {
|
|
1067
|
+
logger.error(`
|
|
1068
|
+
\u2716 Oops! You passed a test file (${options.file}) to the generate command.`);
|
|
1069
|
+
logger.info(`\u{1F4A1} The 'generate' command expects the SOURCE file (e.g. app/controllers/AdminController.js).`);
|
|
1070
|
+
logger.info(`\u{1F4A1} If you want to evaluate/extend this existing test, point the generate command to the SOURCE file.`);
|
|
1071
|
+
logger.info(`\u{1F4A1} If you want to fix this broken test file, run: aitest fix`);
|
|
1072
|
+
process.exit(1);
|
|
1073
|
+
}
|
|
1074
|
+
const filePath = resolve8(process.cwd(), options.file);
|
|
1075
|
+
const { AgenticPlanner: AgenticPlanner2 } = await Promise.resolve().then(() => (init_agentic2(), agentic_exports));
|
|
1076
|
+
const planner = new AgenticPlanner2(config2, projectInfo, workspaceMap);
|
|
1077
|
+
logger.info(`Starting Agentic test generation for ${options.file}...`);
|
|
1078
|
+
const startTime = Date.now();
|
|
1079
|
+
const result = await planner.generateFile(filePath, { forceUpdate: options.forceUpdate, evaluateExisting: options.evaluateExisting, workspaceMap });
|
|
1080
|
+
if (result === "generated") {
|
|
1081
|
+
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1e3);
|
|
1082
|
+
logger.success(`
|
|
1083
|
+
Finished! Successfully generated test for ${options.file}.`);
|
|
1084
|
+
console.log(chalk3.magentaBright(`
|
|
1085
|
+
\u2728 AI successfully generated the test suite in ${elapsedSeconds}s! Saved you ~2 hours of typing.`));
|
|
1086
|
+
const terminalLink = (await import("terminal-link")).default;
|
|
1087
|
+
const tweetUrl = `https://twitter.com/intent/tweet?text=I%20just%20used%20%40aitestcli%20to%20autonomously%20generate%20a%20test%20suite%20in%20${elapsedSeconds}s!%20%F0%9F%A4%AF%0A%0A%23ai%20%23testing%20%23javascript&url=https://www.npmjs.com/package/ai-test-cli`;
|
|
1088
|
+
console.log(chalk3.cyan("\u{1F680} " + terminalLink("Share your win on Twitter!", tweetUrl)));
|
|
1089
|
+
console.log(chalk3.yellow("\u2615 " + terminalLink("Buy the creator a coffee!", "https://buymeacoffee.com/cijaytechnh")) + "\n");
|
|
1090
|
+
} else if (result === "skipped") {
|
|
1091
|
+
logger.info(`
|
|
1092
|
+
Skipped test generation for ${options.file}.`);
|
|
1093
|
+
} else {
|
|
1094
|
+
logger.error(`
|
|
1095
|
+
Failed to generate test for ${options.file}.`);
|
|
1096
|
+
}
|
|
1097
|
+
process.exit(0);
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
// src/index.ts
|
|
1104
|
+
import { Command as Command9 } from "commander";
|
|
1105
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
1106
|
+
import { resolve as resolve10 } from "path";
|
|
1107
|
+
import { fileURLToPath } from "url";
|
|
1108
|
+
|
|
1109
|
+
// src/commands/init.ts
|
|
1110
|
+
init_config();
|
|
1111
|
+
init_detector();
|
|
1112
|
+
init_logger();
|
|
1113
|
+
import { Command } from "commander";
|
|
1114
|
+
import prompts from "prompts";
|
|
1115
|
+
import { existsSync as existsSync2, appendFileSync, readFileSync as readFileSync4 } from "fs";
|
|
1116
|
+
import { resolve as resolve5 } from "path";
|
|
1117
|
+
|
|
1118
|
+
// src/core/setup.ts
|
|
1119
|
+
init_logger();
|
|
1120
|
+
import { exec } from "child_process";
|
|
1121
|
+
import { promisify } from "util";
|
|
1122
|
+
import { resolve as resolve3 } from "path";
|
|
1123
|
+
import { readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
1124
|
+
var execAsync = promisify(exec);
|
|
1125
|
+
async function installTestRunner(projectInfo, targetDir = process.cwd()) {
|
|
1126
|
+
const spinner = logger.spinner("Setting up test runner...").start();
|
|
1127
|
+
const isTs = projectInfo.language === "typescript";
|
|
1128
|
+
const runner = isTs ? "vitest" : "jest";
|
|
1129
|
+
const packages = isTs ? "vitest" : "jest";
|
|
1130
|
+
let installCmd = "";
|
|
1131
|
+
switch (projectInfo.packageManager) {
|
|
1132
|
+
case "npm":
|
|
1133
|
+
installCmd = `npm install -D ${packages}`;
|
|
1134
|
+
break;
|
|
1135
|
+
case "yarn":
|
|
1136
|
+
installCmd = `yarn add -D ${packages}`;
|
|
1137
|
+
break;
|
|
1138
|
+
case "pnpm":
|
|
1139
|
+
installCmd = `pnpm add -D ${packages}`;
|
|
1140
|
+
break;
|
|
1141
|
+
case "bun":
|
|
1142
|
+
installCmd = `bun add -D ${packages}`;
|
|
1143
|
+
break;
|
|
1144
|
+
}
|
|
1145
|
+
try {
|
|
1146
|
+
spinner.text = `Installing ${runner} using ${projectInfo.packageManager}...`;
|
|
1147
|
+
await execAsync(installCmd, { cwd: targetDir });
|
|
1148
|
+
spinner.text = "Updating package.json...";
|
|
1149
|
+
const packageJsonPath = resolve3(targetDir, "package.json");
|
|
1150
|
+
let pkg = {};
|
|
1151
|
+
try {
|
|
1152
|
+
pkg = JSON.parse(readFileSync2(packageJsonPath, "utf-8"));
|
|
1153
|
+
} catch (e) {
|
|
1154
|
+
pkg = {};
|
|
1155
|
+
}
|
|
1156
|
+
if (!pkg.scripts) pkg.scripts = {};
|
|
1157
|
+
if (runner === "vitest") {
|
|
1158
|
+
pkg.scripts.test = "vitest run";
|
|
1159
|
+
} else {
|
|
1160
|
+
pkg.scripts.test = "jest";
|
|
1161
|
+
}
|
|
1162
|
+
writeFileSync(packageJsonPath, JSON.stringify(pkg, null, 2), "utf-8");
|
|
1163
|
+
spinner.succeed(`Successfully installed and configured ${runner}!`);
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
spinner.fail(`Failed to set up test runner: ${error.message}`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// src/commands/init.ts
|
|
1170
|
+
init_ai();
|
|
1171
|
+
|
|
1172
|
+
// src/core/config-fixer.ts
|
|
1173
|
+
init_logger();
|
|
1174
|
+
import { writeFileSync as writeFileSync2, readFileSync as readFileSync3 } from "fs";
|
|
1175
|
+
import { resolve as resolve4 } from "path";
|
|
1176
|
+
import { exec as exec2 } from "child_process";
|
|
1177
|
+
import { promisify as promisify2 } from "util";
|
|
1178
|
+
var execAsync2 = promisify2(exec2);
|
|
1179
|
+
async function applyConfigFix(configFix, projectInfo, spinner = logger.spinner("Applying fix...")) {
|
|
1180
|
+
spinner.info(`Autonomous Config Fix initiated: ${configFix.reason}`);
|
|
1181
|
+
if (configFix.dependencies && configFix.dependencies.length > 0) {
|
|
1182
|
+
spinner.text = `Installing missing dependencies: ${configFix.dependencies.join(", ")}...`;
|
|
1183
|
+
const installCmd = projectInfo.packageManager === "yarn" ? "yarn add -D" : projectInfo.packageManager === "pnpm" ? "pnpm add -D" : projectInfo.packageManager === "bun" ? "bun add -d" : "npm install -D --legacy-peer-deps";
|
|
1184
|
+
await execAsync2(`${installCmd} ${configFix.dependencies.join(" ")}`, { cwd: process.cwd() });
|
|
1185
|
+
}
|
|
1186
|
+
if (configFix.files && configFix.files.length > 0) {
|
|
1187
|
+
for (const file of configFix.files) {
|
|
1188
|
+
const filePath = resolve4(process.cwd(), file.path);
|
|
1189
|
+
spinner.text = `Writing config file: ${file.path}...`;
|
|
1190
|
+
if (file.path.endsWith("package.json")) {
|
|
1191
|
+
try {
|
|
1192
|
+
const existingPkg = JSON.parse(readFileSync3(filePath, "utf-8"));
|
|
1193
|
+
const newPkg = JSON.parse(file.content);
|
|
1194
|
+
if (newPkg.scripts) {
|
|
1195
|
+
existingPkg.scripts = { ...existingPkg.scripts || {}, ...newPkg.scripts };
|
|
1196
|
+
}
|
|
1197
|
+
if (newPkg.jest) {
|
|
1198
|
+
existingPkg.jest = { ...existingPkg.jest || {}, ...newPkg.jest };
|
|
1199
|
+
}
|
|
1200
|
+
writeFileSync2(filePath, JSON.stringify(existingPkg, null, 2), "utf-8");
|
|
1201
|
+
} catch (e) {
|
|
1202
|
+
spinner.warn(`Failed to safely merge package.json: ${e.message}`);
|
|
1203
|
+
}
|
|
1204
|
+
} else {
|
|
1205
|
+
writeFileSync2(filePath, file.content, "utf-8");
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// src/commands/init.ts
|
|
1212
|
+
import fg from "fast-glob";
|
|
1213
|
+
var initCommand = new Command("init").description("Initialize the project").action(async () => {
|
|
1214
|
+
logger.info("Initializing AI Test CLI...");
|
|
1215
|
+
const projectInfo = detectProjectInfo();
|
|
1216
|
+
logger.info(`Detected Language: ${projectInfo.language}`);
|
|
1217
|
+
logger.info(`Detected Framework: ${projectInfo.framework}`);
|
|
1218
|
+
logger.info(`Detected Test Runner: ${projectInfo.testRunner}`);
|
|
1219
|
+
logger.info(`Detected Package Manager: ${projectInfo.packageManager}`);
|
|
1220
|
+
if (projectInfo.testRunner === "unknown") {
|
|
1221
|
+
const setupRes = await prompts({
|
|
1222
|
+
type: "confirm",
|
|
1223
|
+
name: "installRunner",
|
|
1224
|
+
message: "No test runner detected. Would you like AI Test CLI to automatically install and configure one?",
|
|
1225
|
+
initial: true
|
|
1226
|
+
});
|
|
1227
|
+
if (setupRes.installRunner) {
|
|
1228
|
+
await installTestRunner(projectInfo);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
const response = await prompts([
|
|
1232
|
+
{
|
|
1233
|
+
type: "select",
|
|
1234
|
+
name: "provider",
|
|
1235
|
+
message: "Select AI Provider",
|
|
1236
|
+
choices: [
|
|
1237
|
+
{ title: "OpenAI", value: "openai" },
|
|
1238
|
+
{ title: "Anthropic", value: "anthropic" },
|
|
1239
|
+
{ title: "DeepSeek", value: "deepseek" },
|
|
1240
|
+
{ title: "Gemini", value: "gemini" },
|
|
1241
|
+
{ title: "Ollama (Local)", value: "ollama" },
|
|
1242
|
+
{ title: "Custom (OpenAI Compatible)", value: "custom" }
|
|
1243
|
+
],
|
|
1244
|
+
initial: 0
|
|
1245
|
+
},
|
|
1246
|
+
{
|
|
1247
|
+
type: "text",
|
|
1248
|
+
name: "model",
|
|
1249
|
+
message: "Enter model name",
|
|
1250
|
+
initial: (prev) => {
|
|
1251
|
+
if (prev === "openai") return "gpt-4o";
|
|
1252
|
+
if (prev === "anthropic") return "claude-3-5-sonnet-latest";
|
|
1253
|
+
if (prev === "deepseek") return "deepseek-coder";
|
|
1254
|
+
if (prev === "gemini") return "gemini-2.5-flash";
|
|
1255
|
+
if (prev === "ollama") return "llama3.1";
|
|
1256
|
+
if (prev === "custom") return "local-model";
|
|
1257
|
+
return "";
|
|
1258
|
+
}
|
|
1259
|
+
},
|
|
1260
|
+
{
|
|
1261
|
+
type: (prev, values) => values.provider === "custom" ? "text" : null,
|
|
1262
|
+
name: "baseURL",
|
|
1263
|
+
message: "Enter API Base URL (e.g., http://localhost:1234/v1)"
|
|
1264
|
+
},
|
|
1265
|
+
{
|
|
1266
|
+
type: (prev, values) => values.provider === "custom" ? "text" : null,
|
|
1267
|
+
name: "customHeaders",
|
|
1268
|
+
message: 'Enter custom headers as "Key: Value, Key2: Value2" (or leave empty)'
|
|
1269
|
+
},
|
|
1270
|
+
{
|
|
1271
|
+
type: (prev, values) => values.provider === "ollama" ? null : "password",
|
|
1272
|
+
name: "apiKey",
|
|
1273
|
+
message: "Enter API Key"
|
|
1274
|
+
}
|
|
1275
|
+
]);
|
|
1276
|
+
if (!response.provider || !response.model) {
|
|
1277
|
+
logger.error("Initialization aborted.");
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
if (response.apiKey) {
|
|
1281
|
+
const envKeyMap = {
|
|
1282
|
+
openai: "OPENAI_API_KEY",
|
|
1283
|
+
anthropic: "ANTHROPIC_API_KEY",
|
|
1284
|
+
deepseek: "DEEPSEEK_API_KEY",
|
|
1285
|
+
gemini: "GEMINI_API_KEY",
|
|
1286
|
+
custom: "CUSTOM_API_KEY"
|
|
1287
|
+
};
|
|
1288
|
+
const envKey = envKeyMap[response.provider] || "CUSTOM_API_KEY";
|
|
1289
|
+
const envPath = resolve5(process.cwd(), ".env");
|
|
1290
|
+
appendFileSync(envPath, `
|
|
1291
|
+
${envKey}=${response.apiKey}
|
|
1292
|
+
`, "utf-8");
|
|
1293
|
+
process.env[envKey] = response.apiKey;
|
|
1294
|
+
logger.success(`Saved API key to .env securely.`);
|
|
1295
|
+
const gitignorePath = resolve5(process.cwd(), ".gitignore");
|
|
1296
|
+
let gitignoreContent = "";
|
|
1297
|
+
if (existsSync2(gitignorePath)) {
|
|
1298
|
+
gitignoreContent = readFileSync4(gitignorePath, "utf-8");
|
|
1299
|
+
}
|
|
1300
|
+
if (!gitignoreContent.includes(".env")) {
|
|
1301
|
+
appendFileSync(gitignorePath, "\n.env\n", "utf-8");
|
|
1302
|
+
logger.info("Added .env to .gitignore");
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
let parsedHeaders = void 0;
|
|
1306
|
+
if (response.customHeaders && response.customHeaders.trim() !== "") {
|
|
1307
|
+
parsedHeaders = {};
|
|
1308
|
+
const parts = response.customHeaders.split(",");
|
|
1309
|
+
for (const part of parts) {
|
|
1310
|
+
const [key, ...valParts] = part.split(":");
|
|
1311
|
+
if (key && valParts.length > 0) {
|
|
1312
|
+
const cleanKey = key.trim().replace(/^["']|["']$/g, "");
|
|
1313
|
+
const cleanVal = valParts.join(":").trim().replace(/^["']|["']$/g, "");
|
|
1314
|
+
parsedHeaders[cleanKey] = cleanVal;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
const config2 = {
|
|
1319
|
+
provider: response.provider,
|
|
1320
|
+
model: response.model,
|
|
1321
|
+
baseURL: response.baseURL,
|
|
1322
|
+
customHeaders: parsedHeaders,
|
|
1323
|
+
temperature: 0.1,
|
|
1324
|
+
autoFix: true,
|
|
1325
|
+
maxRetries: -1
|
|
1326
|
+
// Infinite by default
|
|
1327
|
+
};
|
|
1328
|
+
await saveConfig(config2);
|
|
1329
|
+
logger.success("Basic initialization complete.");
|
|
1330
|
+
const setupVerify = await prompts({
|
|
1331
|
+
type: "confirm",
|
|
1332
|
+
name: "verify",
|
|
1333
|
+
message: "Would you like the AI to verify and configure your testing environment right now? (Recommended)",
|
|
1334
|
+
initial: true
|
|
1335
|
+
});
|
|
1336
|
+
if (setupVerify.verify) {
|
|
1337
|
+
const spinner = logger.spinner("Analyzing project setup...").start();
|
|
1338
|
+
try {
|
|
1339
|
+
const pkgPath = resolve5(process.cwd(), "package.json");
|
|
1340
|
+
let pkgContent = "{}";
|
|
1341
|
+
if (existsSync2(pkgPath)) {
|
|
1342
|
+
pkgContent = readFileSync4(pkgPath, "utf-8");
|
|
1343
|
+
}
|
|
1344
|
+
const existingConfigs = await fg(["jest.config.*", "vite.config.*", ".babelrc*", "tsconfig.*", "vitest.config.*"], { cwd: process.cwd(), deep: 1 });
|
|
1345
|
+
const setupContext = `Language: ${projectInfo.language}
|
|
1346
|
+
Framework: ${projectInfo.framework}
|
|
1347
|
+
Test Runner: ${projectInfo.testRunner}
|
|
1348
|
+
Existing Config Files: ${existingConfigs.join(", ") || "None"}
|
|
1349
|
+
Package.json:
|
|
1350
|
+
${pkgContent}`;
|
|
1351
|
+
const responseCode = await askAI(
|
|
1352
|
+
config2,
|
|
1353
|
+
'You are a testing architecture expert. Analyze this project setup. If the test environment is perfect and ready to run tests, reply exactly with "READY". If it is missing dependencies (e.g., Jest, Vitest, Testing Library, jsdom) or needs config files (e.g., .babelrc, jest.config.js) to support the detected framework, reply ONLY with a JSON block wrapped in ```json starting with {"type": "CONFIG_FIX", "dependencies": ["pkg"], "files": [{"path": ".babelrc", "content": "..."}], "reason": "..."}. CRITICAL: You must escape all newlines within the JSON "content" strings as \\n. DO NOT create duplicate configuration files for the same tool (e.g., if jest.config.js exists, do not create jest.config.ts). If an existing file needs updating, provide the file path of the existing file. Do not output raw unescaped newlines inside the JSON string. Do not output anything else.',
|
|
1354
|
+
setupContext
|
|
1355
|
+
);
|
|
1356
|
+
const configFixMatch = responseCode.match(/```json\s*(\{[\s\S]*?"type":\s*"CONFIG_FIX"[\s\S]*?\})\s*```/);
|
|
1357
|
+
if (configFixMatch && configFixMatch[1]) {
|
|
1358
|
+
const configFix = JSON.parse(configFixMatch[1]);
|
|
1359
|
+
await applyConfigFix(configFix, projectInfo, spinner);
|
|
1360
|
+
spinner.succeed("Test environment successfully scaffolded!");
|
|
1361
|
+
} else {
|
|
1362
|
+
spinner.succeed("Test environment looks perfectly configured!");
|
|
1363
|
+
}
|
|
1364
|
+
} catch (e) {
|
|
1365
|
+
spinner.fail(`Setup verification failed: ${e.message}`);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
logger.success("You can now use `aitest run` or `aitest generate`.");
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
// src/commands/run.ts
|
|
1372
|
+
init_config();
|
|
1373
|
+
init_logger();
|
|
1374
|
+
init_ai();
|
|
1375
|
+
init_detector();
|
|
1376
|
+
import { Command as Command2 } from "commander";
|
|
1377
|
+
import { exec as exec3 } from "child_process";
|
|
1378
|
+
import { promisify as promisify3 } from "util";
|
|
1379
|
+
var execAsync3 = promisify3(exec3);
|
|
1380
|
+
var runCommand = new Command2("run").description("Run tests and analyze with AI (implicitly tracks coverage)").action(async () => {
|
|
1381
|
+
const config2 = await loadConfig();
|
|
1382
|
+
if (!config2) {
|
|
1383
|
+
logger.error("Configuration not found. Run `aitest init` first.");
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
const projectInfo = detectProjectInfo();
|
|
1387
|
+
let testCommand = "npm test -- --coverage";
|
|
1388
|
+
if (projectInfo.testRunner === "mocha" || projectInfo.testRunner === "unknown") {
|
|
1389
|
+
testCommand = "npm test --coverage";
|
|
1390
|
+
}
|
|
1391
|
+
const spinner = logger.spinner(`Running tests with coverage (${testCommand})...`).start();
|
|
1392
|
+
let stdoutStr = "";
|
|
1393
|
+
let stderrStr = "";
|
|
1394
|
+
let testFailed = false;
|
|
1395
|
+
try {
|
|
1396
|
+
const { stdout, stderr } = await execAsync3(testCommand);
|
|
1397
|
+
stdoutStr = stdout;
|
|
1398
|
+
stderrStr = stderr;
|
|
1399
|
+
spinner.succeed("Tests passed!");
|
|
1400
|
+
} catch (error) {
|
|
1401
|
+
testFailed = true;
|
|
1402
|
+
stdoutStr = error.stdout || "";
|
|
1403
|
+
stderrStr = error.stderr || error.message;
|
|
1404
|
+
spinner.fail("Tests failed.");
|
|
1405
|
+
}
|
|
1406
|
+
const outputToAnalyze = `
|
|
1407
|
+
STDOUT:
|
|
1408
|
+
${stdoutStr.slice(-2e3)}
|
|
1409
|
+
|
|
1410
|
+
STDERR:
|
|
1411
|
+
${stderrStr.slice(-2e3)}
|
|
1412
|
+
`.trim();
|
|
1413
|
+
logger.info("Analyzing test results with AI...");
|
|
1414
|
+
const analyzeSpinner = logger.spinner("Thinking...").start();
|
|
1415
|
+
try {
|
|
1416
|
+
const analysis = await askAI(
|
|
1417
|
+
config2,
|
|
1418
|
+
"You are an expert QA and software testing engineer. Analyze the provided test output and code coverage metrics. Keep it concise. If there are failures, provide a clear summary of what failed and why. Also, briefly analyze the coverage table and suggest what areas of the code need more tests.",
|
|
1419
|
+
outputToAnalyze
|
|
1420
|
+
);
|
|
1421
|
+
analyzeSpinner.succeed("Analysis complete");
|
|
1422
|
+
console.log("\n" + analysis + "\n");
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
analyzeSpinner.fail("AI analysis failed.");
|
|
1425
|
+
logger.error(error.message);
|
|
1426
|
+
}
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
// src/index.ts
|
|
1430
|
+
init_generate();
|
|
1431
|
+
|
|
1432
|
+
// src/commands/explain.ts
|
|
1433
|
+
init_config();
|
|
1434
|
+
init_logger();
|
|
1435
|
+
init_ai();
|
|
1436
|
+
import { Command as Command4 } from "commander";
|
|
1437
|
+
import { exec as exec7 } from "child_process";
|
|
1438
|
+
import { promisify as promisify7 } from "util";
|
|
1439
|
+
var execAsync7 = promisify7(exec7);
|
|
1440
|
+
var explainCommand = new Command4("explain").description("Explains test failures in plain English").action(async () => {
|
|
1441
|
+
const config2 = await loadConfig();
|
|
1442
|
+
if (!config2) {
|
|
1443
|
+
logger.error("Configuration not found. Run `aitest init` first.");
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
const spinner = logger.spinner("Running tests to gather failure data...").start();
|
|
1447
|
+
let stdoutStr = "";
|
|
1448
|
+
let stderrStr = "";
|
|
1449
|
+
try {
|
|
1450
|
+
await execAsync7("npm test");
|
|
1451
|
+
spinner.succeed("Tests passed! Nothing to explain.");
|
|
1452
|
+
return;
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
stdoutStr = error.stdout || "";
|
|
1455
|
+
stderrStr = error.stderr || error.message;
|
|
1456
|
+
spinner.info("Test failure detected.");
|
|
1457
|
+
}
|
|
1458
|
+
const outputToAnalyze = `
|
|
1459
|
+
STDOUT:
|
|
1460
|
+
${stdoutStr.slice(-2e3)}
|
|
1461
|
+
|
|
1462
|
+
STDERR:
|
|
1463
|
+
${stderrStr.slice(-2e3)}
|
|
1464
|
+
`.trim();
|
|
1465
|
+
logger.info("Analyzing root cause with AI...");
|
|
1466
|
+
const analyzeSpinner = logger.spinner("Thinking...").start();
|
|
1467
|
+
try {
|
|
1468
|
+
const explanation = await askAI(
|
|
1469
|
+
config2,
|
|
1470
|
+
"You are an expert QA and software testing engineer. The user has failing tests. Explain the failure in plain English. Include:\n- Root cause\n- Stack analysis\n- Suggested fixes",
|
|
1471
|
+
outputToAnalyze
|
|
1472
|
+
);
|
|
1473
|
+
analyzeSpinner.succeed("Explanation ready:");
|
|
1474
|
+
console.log("\n" + explanation + "\n");
|
|
1475
|
+
} catch (error) {
|
|
1476
|
+
analyzeSpinner.fail("AI explanation failed.");
|
|
1477
|
+
logger.error(error.message);
|
|
1478
|
+
}
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
// src/commands/doctor.ts
|
|
1482
|
+
init_config();
|
|
1483
|
+
init_logger();
|
|
1484
|
+
init_detector();
|
|
1485
|
+
init_ai();
|
|
1486
|
+
import { Command as Command5 } from "commander";
|
|
1487
|
+
var doctorCommand = new Command5("doctor").description("Verify installation, configuration, and environment").action(async () => {
|
|
1488
|
+
logger.info("Running AI Test CLI Diagnostics...\n");
|
|
1489
|
+
let allChecksPassed = true;
|
|
1490
|
+
const configSpinner = logger.spinner("Checking configuration...").start();
|
|
1491
|
+
const config2 = await loadConfig();
|
|
1492
|
+
if (config2) {
|
|
1493
|
+
configSpinner.succeed("Configuration found");
|
|
1494
|
+
} else {
|
|
1495
|
+
configSpinner.fail("Configuration missing. Run `aitest init`");
|
|
1496
|
+
allChecksPassed = false;
|
|
1497
|
+
}
|
|
1498
|
+
const apiSpinner = logger.spinner("Checking API key...").start();
|
|
1499
|
+
if (config2?.apiKey || process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.GEMINI_API_KEY) {
|
|
1500
|
+
apiSpinner.succeed("API key configured");
|
|
1501
|
+
} else {
|
|
1502
|
+
apiSpinner.warn("No explicit API key found in config or environment variables (might be okay for local models)");
|
|
1503
|
+
}
|
|
1504
|
+
if (config2) {
|
|
1505
|
+
const modelSpinner = logger.spinner(`Testing access to ${config2.provider} (${config2.model})...`).start();
|
|
1506
|
+
try {
|
|
1507
|
+
const response = await askAI(config2, 'Reply with exactly "OK"', "Test connection");
|
|
1508
|
+
if (response.includes("OK") || response.trim() !== "") {
|
|
1509
|
+
modelSpinner.succeed("Model connection successful");
|
|
1510
|
+
} else {
|
|
1511
|
+
modelSpinner.warn("Model responded, but unexpectedly");
|
|
1512
|
+
}
|
|
1513
|
+
} catch (error) {
|
|
1514
|
+
modelSpinner.fail(`Model connection failed: ${error.message}`);
|
|
1515
|
+
allChecksPassed = false;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
const envSpinner = logger.spinner("Checking project environment...").start();
|
|
1519
|
+
const projectInfo = detectProjectInfo();
|
|
1520
|
+
if (projectInfo.packageManager !== "npm" && projectInfo.packageManager !== "yarn" && projectInfo.packageManager !== "pnpm" && projectInfo.packageManager !== "bun") {
|
|
1521
|
+
envSpinner.fail("Supported package manager not found");
|
|
1522
|
+
allChecksPassed = false;
|
|
1523
|
+
} else if (projectInfo.testRunner === "unknown") {
|
|
1524
|
+
envSpinner.warn(`Test runner not explicitly detected. Fallback may be used.`);
|
|
1525
|
+
} else {
|
|
1526
|
+
envSpinner.succeed(`Project environment valid (${projectInfo.language}, ${projectInfo.packageManager}, ${projectInfo.testRunner})`);
|
|
1527
|
+
}
|
|
1528
|
+
console.log("\n--- Diagnostic Summary ---");
|
|
1529
|
+
if (allChecksPassed) {
|
|
1530
|
+
logger.success("\u2705 All critical checks passed. You are ready to generate tests!");
|
|
1531
|
+
} else {
|
|
1532
|
+
logger.warn("\u26A0\uFE0F Some checks failed. Please resolve the issues above before proceeding.");
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
// src/commands/scan.ts
|
|
1537
|
+
init_logger();
|
|
1538
|
+
init_detector();
|
|
1539
|
+
import { Command as Command6 } from "commander";
|
|
1540
|
+
import fg4 from "fast-glob";
|
|
1541
|
+
var scanCommand = new Command6("scan").description("Analyze the repository for testing health").action(async () => {
|
|
1542
|
+
logger.info("Scanning repository...\n");
|
|
1543
|
+
const spinner = logger.spinner("Analyzing files and configuration...").start();
|
|
1544
|
+
const projectInfo = detectProjectInfo();
|
|
1545
|
+
try {
|
|
1546
|
+
const sourceFiles = await fg4(["**/*.{js,ts,jsx,tsx}"], {
|
|
1547
|
+
ignore: [
|
|
1548
|
+
"**/node_modules/**",
|
|
1549
|
+
"**/dist/**",
|
|
1550
|
+
"**/build/**",
|
|
1551
|
+
"**/coverage/**",
|
|
1552
|
+
"**/*.test.*",
|
|
1553
|
+
"**/*.spec.*",
|
|
1554
|
+
"**/vite.config.*",
|
|
1555
|
+
"**/tsup.config.*",
|
|
1556
|
+
"**/.*"
|
|
1557
|
+
],
|
|
1558
|
+
cwd: process.cwd()
|
|
1559
|
+
});
|
|
1560
|
+
const testFiles = await fg4(["**/*.test.{js,ts,jsx,tsx}", "**/*.spec.{js,ts,jsx,tsx}"], {
|
|
1561
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/coverage/**"],
|
|
1562
|
+
cwd: process.cwd()
|
|
1563
|
+
});
|
|
1564
|
+
const sourceBases = sourceFiles.map((f) => {
|
|
1565
|
+
const parts = f.split("/");
|
|
1566
|
+
const name = parts[parts.length - 1];
|
|
1567
|
+
return name.replace(/\.(js|ts|jsx|tsx)$/, "");
|
|
1568
|
+
});
|
|
1569
|
+
const testBases = testFiles.map((f) => {
|
|
1570
|
+
const parts = f.split("/");
|
|
1571
|
+
const name = parts[parts.length - 1];
|
|
1572
|
+
return name.replace(/\.(test|spec)\.(js|ts|jsx|tsx)$/, "");
|
|
1573
|
+
});
|
|
1574
|
+
let coveredFiles = 0;
|
|
1575
|
+
const missingTestsFiles = [];
|
|
1576
|
+
for (let i = 0; i < sourceFiles.length; i++) {
|
|
1577
|
+
const base = sourceBases[i];
|
|
1578
|
+
if (testBases.includes(base)) {
|
|
1579
|
+
coveredFiles++;
|
|
1580
|
+
} else {
|
|
1581
|
+
missingTestsFiles.push(sourceFiles[i]);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
const coverageEstimate = sourceFiles.length > 0 ? Math.round(coveredFiles / sourceFiles.length * 100) : 100;
|
|
1585
|
+
let riskScore = "Low";
|
|
1586
|
+
let riskColor = "\x1B[32m";
|
|
1587
|
+
if (coverageEstimate < 40) {
|
|
1588
|
+
riskScore = "High";
|
|
1589
|
+
riskColor = "\x1B[31m";
|
|
1590
|
+
} else if (coverageEstimate < 70) {
|
|
1591
|
+
riskScore = "Medium";
|
|
1592
|
+
riskColor = "\x1B[33m";
|
|
1593
|
+
}
|
|
1594
|
+
const resetColor = "\x1B[0m";
|
|
1595
|
+
spinner.succeed("Analysis complete\n");
|
|
1596
|
+
console.log("\u{1F4CA} Repository Scan Report\n");
|
|
1597
|
+
console.log("--- Architecture ---");
|
|
1598
|
+
console.log(`Language: ${projectInfo.language}`);
|
|
1599
|
+
console.log(`Framework: ${projectInfo.framework}`);
|
|
1600
|
+
console.log(`Test Runner: ${projectInfo.testRunner}`);
|
|
1601
|
+
console.log(`Package Manager: ${projectInfo.packageManager}
|
|
1602
|
+
`);
|
|
1603
|
+
console.log("--- Metrics ---");
|
|
1604
|
+
console.log(`Total Source Files: ${sourceFiles.length}`);
|
|
1605
|
+
console.log(`Total Test Files: ${testFiles.length}`);
|
|
1606
|
+
console.log(`Coverage Estimate: ${coverageEstimate}% (based on file pairing)`);
|
|
1607
|
+
console.log(`Risk Score: ${riskColor}${riskScore}${resetColor}
|
|
1608
|
+
`);
|
|
1609
|
+
console.log("--- Missing Tests (Top 10) ---");
|
|
1610
|
+
if (missingTestsFiles.length === 0) {
|
|
1611
|
+
console.log("All source files appear to have matching test files! \u{1F389}");
|
|
1612
|
+
} else {
|
|
1613
|
+
const displayLimit = Math.min(10, missingTestsFiles.length);
|
|
1614
|
+
for (let i = 0; i < displayLimit; i++) {
|
|
1615
|
+
console.log(`- ${missingTestsFiles[i]}`);
|
|
1616
|
+
}
|
|
1617
|
+
if (missingTestsFiles.length > 10) {
|
|
1618
|
+
console.log(`... and ${missingTestsFiles.length - 10} more.`);
|
|
1619
|
+
}
|
|
1620
|
+
console.log("\nRun `aitest generate --all` to automatically generate missing tests.");
|
|
1621
|
+
}
|
|
1622
|
+
} catch (error) {
|
|
1623
|
+
spinner.fail(`Scan failed: ${error.message}`);
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
// src/commands/config.ts
|
|
1628
|
+
init_config();
|
|
1629
|
+
init_logger();
|
|
1630
|
+
import { Command as Command7 } from "commander";
|
|
1631
|
+
var configCommand = new Command7("config").description("Manage settings (e.g., provider, model, apiKey)");
|
|
1632
|
+
configCommand.command("set <key> <value>").description("Set a configuration value").action(async (key, value) => {
|
|
1633
|
+
let config2 = await loadConfig();
|
|
1634
|
+
if (!config2) {
|
|
1635
|
+
config2 = { provider: "openai", model: "gpt-4o" };
|
|
1636
|
+
}
|
|
1637
|
+
let parsedValue = value;
|
|
1638
|
+
if (value === "true") parsedValue = true;
|
|
1639
|
+
if (value === "false") parsedValue = false;
|
|
1640
|
+
if (!isNaN(Number(value))) parsedValue = Number(value);
|
|
1641
|
+
config2[key] = parsedValue;
|
|
1642
|
+
await saveConfig(config2);
|
|
1643
|
+
});
|
|
1644
|
+
configCommand.command("list").description("List current configuration").action(async () => {
|
|
1645
|
+
const config2 = await loadConfig();
|
|
1646
|
+
if (!config2) {
|
|
1647
|
+
logger.warn("No configuration found. Run `aitest init`.");
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
console.log("\n--- Current Configuration ---");
|
|
1651
|
+
for (const [key, value] of Object.entries(config2)) {
|
|
1652
|
+
if (key === "apiKey") {
|
|
1653
|
+
console.log(`${key}: ********* (Hidden)`);
|
|
1654
|
+
} else {
|
|
1655
|
+
console.log(`${key}: ${value}`);
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
console.log("---------------------------\n");
|
|
1659
|
+
});
|
|
1660
|
+
|
|
1661
|
+
// src/commands/fix.ts
|
|
1662
|
+
init_config();
|
|
1663
|
+
init_logger();
|
|
1664
|
+
init_detector();
|
|
1665
|
+
init_ai();
|
|
1666
|
+
import { Command as Command8 } from "commander";
|
|
1667
|
+
import { exec as exec8 } from "child_process";
|
|
1668
|
+
import { promisify as promisify8 } from "util";
|
|
1669
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1670
|
+
import { resolve as resolve9, dirname as dirname4, basename as basename2, extname as extname2 } from "path";
|
|
1671
|
+
import chalk4 from "chalk";
|
|
1672
|
+
var execAsync8 = promisify8(exec8);
|
|
1673
|
+
var fixCommand = new Command8("fix").description("Attempts automatic repair of failing tests using Agentic AI").action(async (options) => {
|
|
1674
|
+
const config2 = await loadConfig();
|
|
1675
|
+
if (!config2) {
|
|
1676
|
+
logger.error("Configuration not found. Run `aitest init` first.");
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
logger.info("Starting test suite to identify failures using Agentic AI...\n");
|
|
1680
|
+
const projectInfo = detectProjectInfo();
|
|
1681
|
+
let testCommand = "npm test";
|
|
1682
|
+
if (projectInfo.testRunner === "vitest") {
|
|
1683
|
+
testCommand = "npx vitest run";
|
|
1684
|
+
} else if (projectInfo.testRunner === "jest") {
|
|
1685
|
+
testCommand = "npx jest";
|
|
1686
|
+
} else if (projectInfo.testRunner === "mocha") {
|
|
1687
|
+
testCommand = "npx mocha";
|
|
1688
|
+
}
|
|
1689
|
+
const spinner = logger.spinner(`Running tests (${testCommand})...`).start();
|
|
1690
|
+
let stdoutStr = "";
|
|
1691
|
+
let stderrStr = "";
|
|
1692
|
+
let testFailed = false;
|
|
1693
|
+
try {
|
|
1694
|
+
const { stdout, stderr } = await execAsync8(testCommand);
|
|
1695
|
+
stdoutStr = stdout;
|
|
1696
|
+
stderrStr = stderr;
|
|
1697
|
+
spinner.succeed("Tests passed! No fixing needed.");
|
|
1698
|
+
return;
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
testFailed = true;
|
|
1701
|
+
stdoutStr = error.stdout || "";
|
|
1702
|
+
stderrStr = error.stderr || error.message;
|
|
1703
|
+
spinner.warn("Tests failed. Starting AI repair flow...");
|
|
1704
|
+
}
|
|
1705
|
+
const analyzeSpinner = logger.spinner("Analyzing test failure to identify the failing file...").start();
|
|
1706
|
+
const outputToAnalyze = `
|
|
1707
|
+
STDOUT:
|
|
1708
|
+
${stdoutStr.slice(-3e3)}
|
|
1709
|
+
|
|
1710
|
+
STDERR:
|
|
1711
|
+
${stderrStr.slice(-3e3)}
|
|
1712
|
+
`.trim();
|
|
1713
|
+
try {
|
|
1714
|
+
const aiResponse = await askAI(
|
|
1715
|
+
config2,
|
|
1716
|
+
'You are an expert QA engineer. Analyze the following test failure output. Identify the primary test file that is failing. Return ONLY the exact file path (relative to project root) of the failing test file. Do not add any extra text. If you cannot find a failing file, return "UNKNOWN".',
|
|
1717
|
+
outputToAnalyze
|
|
1718
|
+
);
|
|
1719
|
+
const failingFile = aiResponse.trim();
|
|
1720
|
+
if (failingFile === "UNKNOWN" || failingFile === "") {
|
|
1721
|
+
analyzeSpinner.fail("Could not identify a failing test file from the output.");
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
const fullPath = resolve9(process.cwd(), failingFile);
|
|
1725
|
+
if (!existsSync6(fullPath)) {
|
|
1726
|
+
analyzeSpinner.fail(`AI identified ${failingFile} as the failing file, but it does not exist.`);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
analyzeSpinner.succeed(`Identified failing test file: ${failingFile}`);
|
|
1730
|
+
const dir = dirname4(fullPath);
|
|
1731
|
+
const ext = extname2(fullPath);
|
|
1732
|
+
const name = basename2(fullPath, ext).replace(/\.test|\.spec/, "");
|
|
1733
|
+
const possibleSourceFiles = [
|
|
1734
|
+
resolve9(dir, `${name}.ts`),
|
|
1735
|
+
resolve9(dir, `${name}.js`),
|
|
1736
|
+
resolve9(dir, `${name}.tsx`),
|
|
1737
|
+
resolve9(dir, `${name}.jsx`),
|
|
1738
|
+
resolve9(dir, `../${name}.ts`),
|
|
1739
|
+
// one level up might work
|
|
1740
|
+
resolve9(dir, `../${name}.js`)
|
|
1741
|
+
];
|
|
1742
|
+
let sourceAbsPath = void 0;
|
|
1743
|
+
for (const sf of possibleSourceFiles) {
|
|
1744
|
+
if (existsSync6(sf)) {
|
|
1745
|
+
sourceAbsPath = sf;
|
|
1746
|
+
break;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
const { AgenticPlanner: AgenticPlanner2 } = await Promise.resolve().then(() => (init_agentic2(), agentic_exports));
|
|
1750
|
+
const { generateWorkspaceMap: generateWorkspaceMap2 } = await Promise.resolve().then(() => (init_scanner(), scanner_exports));
|
|
1751
|
+
const startTime = Date.now();
|
|
1752
|
+
const workspaceMap = await generateWorkspaceMap2();
|
|
1753
|
+
const planner = new AgenticPlanner2(config2, projectInfo, workspaceMap);
|
|
1754
|
+
logger.info(`Delegating repair of ${failingFile} to AgenticPlanner...`);
|
|
1755
|
+
const plannerResult = await planner.fixTestFile(fullPath, sourceAbsPath);
|
|
1756
|
+
if (plannerResult === "generated") {
|
|
1757
|
+
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1e3);
|
|
1758
|
+
logger.success(`\u2705 Auto-repair flow finished successfully for ${failingFile}.`);
|
|
1759
|
+
console.log(chalk4.magentaBright(`
|
|
1760
|
+
\u2728 AI successfully fixed the broken tests in ${elapsedSeconds}s! Saved you ~1 hour of debugging.`));
|
|
1761
|
+
const terminalLink = (await import("terminal-link")).default;
|
|
1762
|
+
const tweetUrl = `https://twitter.com/intent/tweet?text=I%20just%20used%20%40aitestcli%20to%20autonomously%20fix%20my%20broken%20test%20suite%20in%20${elapsedSeconds}s!%20%F0%9F%A4%AF%0A%0A%23ai%20%23testing%20%23javascript&url=https://www.npmjs.com/package/ai-test-cli`;
|
|
1763
|
+
console.log(chalk4.cyan("\u{1F680} " + terminalLink("Share your win on Twitter!", tweetUrl)));
|
|
1764
|
+
console.log(chalk4.yellow("\u2615 " + terminalLink("Buy the creator a coffee!", "https://buymeacoffee.com/cijaytechnh")) + "\n");
|
|
1765
|
+
} else {
|
|
1766
|
+
logger.error(`\u2716 Auto-repair flow failed or aborted for ${failingFile}.`);
|
|
1767
|
+
}
|
|
1768
|
+
process.exit(0);
|
|
1769
|
+
} catch (error) {
|
|
1770
|
+
analyzeSpinner.fail(`Repair flow failed: ${error.message}`);
|
|
1771
|
+
process.exit(1);
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
// src/index.ts
|
|
1776
|
+
var __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
1777
|
+
var packageJson = JSON.parse(readFileSync9(resolve10(__dirname, "../package.json"), "utf-8"));
|
|
1778
|
+
var program = new Command9();
|
|
1779
|
+
program.name("aitest").description(packageJson.description).version(packageJson.version);
|
|
1780
|
+
program.addCommand(initCommand);
|
|
1781
|
+
program.addCommand(runCommand);
|
|
1782
|
+
program.addCommand(generateCommand);
|
|
1783
|
+
program.addCommand(explainCommand);
|
|
1784
|
+
program.addCommand(doctorCommand);
|
|
1785
|
+
program.addCommand(scanCommand);
|
|
1786
|
+
program.addCommand(configCommand);
|
|
1787
|
+
program.addCommand(fixCommand);
|
|
1788
|
+
program.parse(process.argv);
|
|
1789
|
+
//# sourceMappingURL=index.js.map
|