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
|
@@ -0,0 +1,1207 @@
|
|
|
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
|
+
async function loadConfig() {
|
|
32
|
+
try {
|
|
33
|
+
const result = await explorer.search();
|
|
34
|
+
if (result) {
|
|
35
|
+
return result.config;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
logger.error(`Failed to load config: ${error}`);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
var moduleName, explorer;
|
|
44
|
+
var init_config = __esm({
|
|
45
|
+
"src/core/config.ts"() {
|
|
46
|
+
"use strict";
|
|
47
|
+
init_logger();
|
|
48
|
+
moduleName = "aitest";
|
|
49
|
+
explorer = cosmiconfig(moduleName);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// src/core/detector.ts
|
|
54
|
+
import { readFileSync, existsSync } from "fs";
|
|
55
|
+
import { resolve } from "path";
|
|
56
|
+
function detectProjectInfo(targetDir = process.cwd()) {
|
|
57
|
+
const packageJsonPath = resolve(targetDir, "package.json");
|
|
58
|
+
const tsconfigPath = resolve(targetDir, "tsconfig.json");
|
|
59
|
+
let packageJson = {};
|
|
60
|
+
if (existsSync(packageJsonPath)) {
|
|
61
|
+
try {
|
|
62
|
+
packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
63
|
+
} catch (e) {
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const allDeps = {
|
|
67
|
+
...packageJson.dependencies || {},
|
|
68
|
+
...packageJson.devDependencies || {}
|
|
69
|
+
};
|
|
70
|
+
const language = existsSync(tsconfigPath) || allDeps["typescript"] ? "typescript" : "javascript";
|
|
71
|
+
let testRunner = "unknown";
|
|
72
|
+
if (allDeps["jest"]) testRunner = "jest";
|
|
73
|
+
else if (allDeps["vitest"]) testRunner = "vitest";
|
|
74
|
+
else if (allDeps["mocha"]) testRunner = "mocha";
|
|
75
|
+
let framework = "unknown";
|
|
76
|
+
if (allDeps["react"]) framework = "react";
|
|
77
|
+
else if (allDeps["vue"]) framework = "vue";
|
|
78
|
+
else if (allDeps["@angular/core"]) framework = "angular";
|
|
79
|
+
else if (allDeps["express"] || allDeps["@nestjs/core"] || allDeps["fastify"]) framework = "node";
|
|
80
|
+
let packageManager = "npm";
|
|
81
|
+
if (existsSync(resolve(targetDir, "pnpm-lock.yaml"))) packageManager = "pnpm";
|
|
82
|
+
else if (existsSync(resolve(targetDir, "yarn.lock"))) packageManager = "yarn";
|
|
83
|
+
else if (existsSync(resolve(targetDir, "bun.lockb"))) packageManager = "bun";
|
|
84
|
+
return { language, testRunner, framework, packageManager };
|
|
85
|
+
}
|
|
86
|
+
var init_detector = __esm({
|
|
87
|
+
"src/core/detector.ts"() {
|
|
88
|
+
"use strict";
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// src/core/ai.ts
|
|
93
|
+
import { generateText } from "ai";
|
|
94
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
95
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
96
|
+
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
|
97
|
+
import { createDeepSeek } from "@ai-sdk/deepseek";
|
|
98
|
+
import * as dotenv from "dotenv";
|
|
99
|
+
function getAIModel(config2) {
|
|
100
|
+
const providerName = config2.provider.toLowerCase();
|
|
101
|
+
if (providerName === "openai") {
|
|
102
|
+
const openai = createOpenAI({
|
|
103
|
+
apiKey: config2.apiKey || process.env.OPENAI_API_KEY
|
|
104
|
+
});
|
|
105
|
+
return openai(config2.model || "gpt-4o");
|
|
106
|
+
}
|
|
107
|
+
if (providerName === "anthropic") {
|
|
108
|
+
const anthropic = createAnthropic({
|
|
109
|
+
apiKey: config2.apiKey || process.env.ANTHROPIC_API_KEY
|
|
110
|
+
});
|
|
111
|
+
return anthropic(config2.model || "claude-3-5-sonnet-20240620");
|
|
112
|
+
}
|
|
113
|
+
if (providerName === "gemini" || providerName === "google") {
|
|
114
|
+
const google = createGoogleGenerativeAI({
|
|
115
|
+
apiKey: config2.apiKey || process.env.GEMINI_API_KEY || process.env.GOOGLE_GENERATIVE_AI_API_KEY
|
|
116
|
+
});
|
|
117
|
+
return google(config2.model || "gemini-2.5-flash");
|
|
118
|
+
}
|
|
119
|
+
if (providerName === "deepseek") {
|
|
120
|
+
const deepseek = createDeepSeek({
|
|
121
|
+
apiKey: config2.apiKey || process.env.DEEPSEEK_API_KEY
|
|
122
|
+
});
|
|
123
|
+
return deepseek(config2.model || "deepseek-coder");
|
|
124
|
+
}
|
|
125
|
+
if (providerName === "ollama") {
|
|
126
|
+
const ollama = createOpenAI({
|
|
127
|
+
baseURL: "http://localhost:11434/v1",
|
|
128
|
+
apiKey: "ollama"
|
|
129
|
+
// API key isn't strictly needed for local Ollama
|
|
130
|
+
});
|
|
131
|
+
return ollama(config2.model || "llama3.1");
|
|
132
|
+
}
|
|
133
|
+
if (providerName === "custom") {
|
|
134
|
+
const custom = createOpenAI({
|
|
135
|
+
baseURL: config2.baseURL,
|
|
136
|
+
apiKey: config2.apiKey || process.env.CUSTOM_API_KEY || "custom",
|
|
137
|
+
headers: config2.customHeaders
|
|
138
|
+
});
|
|
139
|
+
return custom(config2.model || "local-model");
|
|
140
|
+
}
|
|
141
|
+
throw new Error(`Unsupported AI provider: ${config2.provider}`);
|
|
142
|
+
}
|
|
143
|
+
async function askAI(config2, system, prompt) {
|
|
144
|
+
const model = getAIModel(config2);
|
|
145
|
+
const { text } = await generateText({
|
|
146
|
+
model,
|
|
147
|
+
system,
|
|
148
|
+
prompt,
|
|
149
|
+
maxRetries: 5,
|
|
150
|
+
temperature: config2.temperature || 0.1
|
|
151
|
+
});
|
|
152
|
+
return text;
|
|
153
|
+
}
|
|
154
|
+
var init_ai = __esm({
|
|
155
|
+
"src/core/ai.ts"() {
|
|
156
|
+
"use strict";
|
|
157
|
+
dotenv.config();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// src/core/runner.ts
|
|
162
|
+
import { exec } from "child_process";
|
|
163
|
+
import { promisify } from "util";
|
|
164
|
+
async function runSingleTest(testFilePath, projectInfo) {
|
|
165
|
+
let command = `npm test -- "${testFilePath}"`;
|
|
166
|
+
if (projectInfo.testRunner === "jest") {
|
|
167
|
+
command = `npx jest "${testFilePath}" --forceExit`;
|
|
168
|
+
} else if (projectInfo.testRunner === "vitest") {
|
|
169
|
+
command = `npx vitest run "${testFilePath}"`;
|
|
170
|
+
} else if (projectInfo.testRunner === "mocha") {
|
|
171
|
+
command = `npx mocha "${testFilePath}" --exit`;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const { stdout, stderr } = await execAsync(command, { cwd: process.cwd() });
|
|
175
|
+
return {
|
|
176
|
+
passed: true,
|
|
177
|
+
output: `${stdout}
|
|
178
|
+
${stderr}`.trim()
|
|
179
|
+
};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return {
|
|
182
|
+
passed: false,
|
|
183
|
+
output: `${error.stdout || ""}
|
|
184
|
+
${error.stderr || ""}
|
|
185
|
+
${error.message || ""}`.trim()
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
var execAsync;
|
|
190
|
+
var init_runner = __esm({
|
|
191
|
+
"src/core/runner.ts"() {
|
|
192
|
+
"use strict";
|
|
193
|
+
execAsync = promisify(exec);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// src/core/scanner.ts
|
|
198
|
+
var scanner_exports = {};
|
|
199
|
+
__export(scanner_exports, {
|
|
200
|
+
generateWorkspaceMap: () => generateWorkspaceMap,
|
|
201
|
+
scanDependencies: () => scanDependencies
|
|
202
|
+
});
|
|
203
|
+
import { Project } from "ts-morph";
|
|
204
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
205
|
+
import { resolve as resolve2, dirname } from "path";
|
|
206
|
+
import fg from "fast-glob";
|
|
207
|
+
function resolveLocalPath(baseDir, moduleSpecifier) {
|
|
208
|
+
let cleanedSpecifier = moduleSpecifier;
|
|
209
|
+
if (cleanedSpecifier.endsWith(".js")) {
|
|
210
|
+
cleanedSpecifier = cleanedSpecifier.replace(/\.js$/, "");
|
|
211
|
+
}
|
|
212
|
+
const exts = [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.js", "/index.tsx", "/index.jsx"];
|
|
213
|
+
const fullPath = resolve2(baseDir, cleanedSpecifier);
|
|
214
|
+
try {
|
|
215
|
+
const exactPath = resolve2(baseDir, moduleSpecifier);
|
|
216
|
+
if (existsSync2(exactPath) && statSync(exactPath).isFile()) {
|
|
217
|
+
return exactPath;
|
|
218
|
+
}
|
|
219
|
+
} catch (e) {
|
|
220
|
+
}
|
|
221
|
+
for (const ext of exts) {
|
|
222
|
+
const withExt = fullPath + ext;
|
|
223
|
+
try {
|
|
224
|
+
if (existsSync2(withExt) && statSync(withExt).isFile()) {
|
|
225
|
+
return withExt;
|
|
226
|
+
}
|
|
227
|
+
} catch (e) {
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
function scanDependencies(filePath, maxTokens = 2e3) {
|
|
233
|
+
try {
|
|
234
|
+
const project = new Project();
|
|
235
|
+
const sourceFile = project.addSourceFileAtPath(filePath);
|
|
236
|
+
let context = "";
|
|
237
|
+
const imports = sourceFile.getImportDeclarations();
|
|
238
|
+
const baseDir = dirname(filePath);
|
|
239
|
+
for (const imp of imports) {
|
|
240
|
+
const moduleSpecifier = imp.getModuleSpecifierValue();
|
|
241
|
+
if (!moduleSpecifier.startsWith(".") && !moduleSpecifier.startsWith("/")) {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const resolvedPath = resolveLocalPath(baseDir, moduleSpecifier);
|
|
245
|
+
if (resolvedPath) {
|
|
246
|
+
const content = readFileSync2(resolvedPath, "utf-8");
|
|
247
|
+
context += `
|
|
248
|
+
--- Related Context from ${moduleSpecifier} ---
|
|
249
|
+
${content}
|
|
250
|
+
`;
|
|
251
|
+
if (context.length > maxTokens * 4) {
|
|
252
|
+
context = context.substring(0, maxTokens * 4) + "\n... [Context Truncated due to size limits]";
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const possiblePrismaPaths = [
|
|
258
|
+
resolve2(process.cwd(), "prisma/schema.prisma"),
|
|
259
|
+
resolve2(process.cwd(), "schema.prisma")
|
|
260
|
+
];
|
|
261
|
+
for (const p of possiblePrismaPaths) {
|
|
262
|
+
if (existsSync2(p)) {
|
|
263
|
+
const content = readFileSync2(p, "utf-8");
|
|
264
|
+
context += `
|
|
265
|
+
--- Prisma Schema Context ---
|
|
266
|
+
${content}
|
|
267
|
+
`;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return context.trim();
|
|
272
|
+
} catch (error) {
|
|
273
|
+
logger.error(`Architecture Scanner failed for ${filePath}: ${error.message}`);
|
|
274
|
+
return "";
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function generateWorkspaceMap() {
|
|
278
|
+
try {
|
|
279
|
+
const files = await fg([
|
|
280
|
+
"**/*.{ts,js,tsx,jsx,json,prisma}",
|
|
281
|
+
"!**/node_modules/**",
|
|
282
|
+
"!**/dist/**",
|
|
283
|
+
"!**/build/**",
|
|
284
|
+
"!**/coverage/**"
|
|
285
|
+
], { cwd: process.cwd(), dot: true });
|
|
286
|
+
let mapStr = files.join("\n");
|
|
287
|
+
if (mapStr.length > 5e3) {
|
|
288
|
+
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.";
|
|
289
|
+
}
|
|
290
|
+
return mapStr;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
return "";
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
var init_scanner = __esm({
|
|
296
|
+
"src/core/scanner.ts"() {
|
|
297
|
+
"use strict";
|
|
298
|
+
init_logger();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// src/commands/generate.ts
|
|
303
|
+
import { Command } from "commander";
|
|
304
|
+
import { exec as exec2 } from "child_process";
|
|
305
|
+
import { promisify as promisify2 } from "util";
|
|
306
|
+
import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync3 } from "fs";
|
|
307
|
+
import { resolve as resolve3, relative, dirname as dirname2, basename, extname } from "path";
|
|
308
|
+
import fg2 from "fast-glob";
|
|
309
|
+
import chalk2 from "chalk";
|
|
310
|
+
async function generateTestForFile(filePath, config2, projectInfo, forceUpdate = false, evaluateExisting = false, workspaceMap = "") {
|
|
311
|
+
const relPath = relative(process.cwd(), filePath);
|
|
312
|
+
const dir = dirname2(filePath);
|
|
313
|
+
const ext = extname(filePath);
|
|
314
|
+
const name = basename(filePath, ext);
|
|
315
|
+
const testFilePath = resolve3(dir, `${name}.test${ext}`);
|
|
316
|
+
const spinner = logger.spinner(`Analyzing ${relPath}...`).start();
|
|
317
|
+
if (existsSync3(testFilePath) && !forceUpdate) {
|
|
318
|
+
spinner.info(`Evaluating existing test file for missing coverage: ${relPath}`);
|
|
319
|
+
evaluateExisting = true;
|
|
320
|
+
}
|
|
321
|
+
let fileContent = "";
|
|
322
|
+
try {
|
|
323
|
+
fileContent = readFileSync3(filePath, "utf-8");
|
|
324
|
+
} catch (e) {
|
|
325
|
+
spinner.fail(`Could not read file ${filePath}`);
|
|
326
|
+
return "failed";
|
|
327
|
+
}
|
|
328
|
+
const depsContext = await scanDependencies(filePath);
|
|
329
|
+
let additionalContext = depsContext ? `
|
|
330
|
+
|
|
331
|
+
--- ARCHITECTURAL CONTEXT (DEPENDENCIES) ---
|
|
332
|
+
${depsContext}` : "";
|
|
333
|
+
if (workspaceMap) {
|
|
334
|
+
additionalContext += `
|
|
335
|
+
|
|
336
|
+
--- WORKSPACE FILE STRUCTURE ---
|
|
337
|
+
${workspaceMap}`;
|
|
338
|
+
}
|
|
339
|
+
let existingTestContext = "";
|
|
340
|
+
if (existsSync3(testFilePath)) {
|
|
341
|
+
const existingCode = readFileSync3(testFilePath, "utf-8");
|
|
342
|
+
existingTestContext = `
|
|
343
|
+
|
|
344
|
+
--- EXISTING TEST SUITE (NEEDS IMPROVEMENT) ---
|
|
345
|
+
${existingCode}
|
|
346
|
+
`;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
const promptContext = `Original file name: ${relPath}
|
|
350
|
+
|
|
351
|
+
Code:
|
|
352
|
+
${fileContent}
|
|
353
|
+
|
|
354
|
+
${additionalContext}${existingTestContext}`;
|
|
355
|
+
let promptInstructions = "";
|
|
356
|
+
if (existingTestContext) {
|
|
357
|
+
if (forceUpdate) {
|
|
358
|
+
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.";
|
|
359
|
+
} else if (evaluateExisting) {
|
|
360
|
+
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".';
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
const testFramework = projectInfo.testRunner === "unknown" ? "Jest" : projectInfo.testRunner;
|
|
364
|
+
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.
|
|
365
|
+
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'.`;
|
|
366
|
+
}
|
|
367
|
+
const generatedCode = await askAI(config2, promptInstructions, promptContext);
|
|
368
|
+
if (generatedCode.includes("SKIP_FILE")) {
|
|
369
|
+
spinner.info(`Skipped ${relPath}: No testable logic found.`);
|
|
370
|
+
return "skipped";
|
|
371
|
+
}
|
|
372
|
+
let testCode = generatedCode;
|
|
373
|
+
const match = generatedCode.match(/```(?:javascript|typescript|ts|js)?\n([\s\S]*?)```/) || generatedCode.match(/```[\w]*\n([\s\S]*?)```/);
|
|
374
|
+
if (match && match[1]) {
|
|
375
|
+
testCode = match[1].trim();
|
|
376
|
+
}
|
|
377
|
+
if (!testCode || testCode.trim() === "") {
|
|
378
|
+
spinner.warn(`AI returned empty code for ${relPath}. Skipping to prevent writing an empty file.`);
|
|
379
|
+
return "skipped";
|
|
380
|
+
}
|
|
381
|
+
writeFileSync(testFilePath, testCode, "utf-8");
|
|
382
|
+
spinner.stop();
|
|
383
|
+
return "generated";
|
|
384
|
+
} catch (error) {
|
|
385
|
+
spinner.fail(`Error generating test for ${relPath}: ${error.message}`);
|
|
386
|
+
return "failed";
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
var execAsync2, generateCommand;
|
|
390
|
+
var init_generate = __esm({
|
|
391
|
+
"src/commands/generate.ts"() {
|
|
392
|
+
"use strict";
|
|
393
|
+
init_config();
|
|
394
|
+
init_logger();
|
|
395
|
+
init_detector();
|
|
396
|
+
init_ai();
|
|
397
|
+
init_scanner();
|
|
398
|
+
execAsync2 = promisify2(exec2);
|
|
399
|
+
generateCommand = new Command("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) => {
|
|
400
|
+
const config2 = await loadConfig();
|
|
401
|
+
if (!config2) {
|
|
402
|
+
logger.error("Configuration not found. Run `aitest init` first.");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
if (!options.file && !options.all && !options.coverage) {
|
|
406
|
+
logger.error("Please specify --file <path>, --all, or --coverage");
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const projectInfo = detectProjectInfo();
|
|
410
|
+
const workspaceMap = await generateWorkspaceMap();
|
|
411
|
+
if (options.coverage) {
|
|
412
|
+
logger.info("Running coverage-driven test generation...");
|
|
413
|
+
let testCommand = "npm test -- --coverage";
|
|
414
|
+
if (projectInfo.testRunner === "mocha" || projectInfo.testRunner === "unknown") {
|
|
415
|
+
testCommand = "npm test --coverage";
|
|
416
|
+
}
|
|
417
|
+
const spinner = logger.spinner("Running test suite to collect coverage metrics...").start();
|
|
418
|
+
try {
|
|
419
|
+
const { stdout } = await execAsync2(testCommand, { cwd: process.cwd() });
|
|
420
|
+
spinner.succeed("Coverage data collected.");
|
|
421
|
+
const analyzeSpinner = logger.spinner("Analyzing coverage gaps with AI...").start();
|
|
422
|
+
const aiResponse = await askAI(
|
|
423
|
+
config2,
|
|
424
|
+
'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 [].',
|
|
425
|
+
`STDOUT:
|
|
426
|
+
${stdout.slice(-5e3)}`
|
|
427
|
+
);
|
|
428
|
+
analyzeSpinner.succeed("Coverage gaps identified.");
|
|
429
|
+
const match = aiResponse.match(/\[[\s\S]*\]/);
|
|
430
|
+
if (match) {
|
|
431
|
+
const filesToFix = JSON.parse(match[0]);
|
|
432
|
+
if (filesToFix.length === 0) {
|
|
433
|
+
logger.success("All files have 100% coverage!");
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
logger.info(`Found ${filesToFix.length} files missing coverage. Starting targeted generation...`);
|
|
437
|
+
for (const file of filesToFix) {
|
|
438
|
+
const fullPath = resolve3(process.cwd(), file);
|
|
439
|
+
if (existsSync3(fullPath)) {
|
|
440
|
+
await generateTestForFile(fullPath, config2, projectInfo, true, false, workspaceMap);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
logger.success(`
|
|
444
|
+
Finished coverage-driven generation for ${filesToFix.length} files.`);
|
|
445
|
+
return;
|
|
446
|
+
} else {
|
|
447
|
+
logger.error("Failed to parse AI response for coverage files.");
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
} catch (e) {
|
|
451
|
+
spinner.fail(`Failed to collect coverage data: ${e.message}`);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (options.all) {
|
|
456
|
+
logger.info("Scanning project for source files...");
|
|
457
|
+
const files = await fg2(["**/*.{js,ts,jsx,tsx}"], {
|
|
458
|
+
ignore: [
|
|
459
|
+
"**/node_modules/**",
|
|
460
|
+
"**/dist/**",
|
|
461
|
+
"**/build/**",
|
|
462
|
+
"**/coverage/**",
|
|
463
|
+
"**/*.test.*",
|
|
464
|
+
"**/*.spec.*",
|
|
465
|
+
"**/vite.config.*",
|
|
466
|
+
"**/tsup.config.*",
|
|
467
|
+
"**/.*"
|
|
468
|
+
],
|
|
469
|
+
cwd: process.cwd()
|
|
470
|
+
});
|
|
471
|
+
if (files.length === 0) {
|
|
472
|
+
logger.info("No source files found.");
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
logger.info(`Found ${files.length} files. Starting test generation using Agentic AI...`);
|
|
476
|
+
let successCount = 0;
|
|
477
|
+
let skippedCount = 0;
|
|
478
|
+
const { AgenticPlanner: AgenticPlanner2 } = await Promise.resolve().then(() => (init_agentic2(), agentic_exports));
|
|
479
|
+
const planner = new AgenticPlanner2(config2, projectInfo, workspaceMap);
|
|
480
|
+
const startTime = Date.now();
|
|
481
|
+
for (let i = 0; i < files.length; i++) {
|
|
482
|
+
const file = files[i];
|
|
483
|
+
const fullPath = resolve3(process.cwd(), file);
|
|
484
|
+
const result = await planner.generateFile(fullPath, { forceUpdate: options.forceUpdate, evaluateExisting: options.evaluateExisting, workspaceMap });
|
|
485
|
+
if (result === "generated") successCount++;
|
|
486
|
+
else if (result === "skipped") skippedCount++;
|
|
487
|
+
}
|
|
488
|
+
logger.success(`
|
|
489
|
+
Finished! Generated tests for ${successCount} files (Skipped ${skippedCount} files). Total processed: ${files.length}.`);
|
|
490
|
+
if (successCount > 0) {
|
|
491
|
+
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1e3);
|
|
492
|
+
console.log(chalk2.magentaBright(`
|
|
493
|
+
\u2728 AI successfully generated tests for ${successCount} files in ${elapsedSeconds}s! Saved you ~${successCount * 2} hours of typing.`));
|
|
494
|
+
const terminalLink = (await import("terminal-link")).default;
|
|
495
|
+
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`;
|
|
496
|
+
console.log(chalk2.cyan("\u{1F680} " + terminalLink("Share your win on Twitter!", tweetUrl)));
|
|
497
|
+
console.log(chalk2.yellow("\u2615 " + terminalLink("Buy the creator a coffee!", "https://buymeacoffee.com/cijaytechnh")) + "\n");
|
|
498
|
+
}
|
|
499
|
+
process.exit(0);
|
|
500
|
+
} else if (options.file) {
|
|
501
|
+
if (options.file.includes(".test.") || options.file.includes(".spec.")) {
|
|
502
|
+
logger.error(`
|
|
503
|
+
\u2716 Oops! You passed a test file (${options.file}) to the generate command.`);
|
|
504
|
+
logger.info(`\u{1F4A1} The 'generate' command expects the SOURCE file (e.g. app/controllers/AdminController.js).`);
|
|
505
|
+
logger.info(`\u{1F4A1} If you want to evaluate/extend this existing test, point the generate command to the SOURCE file.`);
|
|
506
|
+
logger.info(`\u{1F4A1} If you want to fix this broken test file, run: aitest fix`);
|
|
507
|
+
process.exit(1);
|
|
508
|
+
}
|
|
509
|
+
const filePath = resolve3(process.cwd(), options.file);
|
|
510
|
+
const { AgenticPlanner: AgenticPlanner2 } = await Promise.resolve().then(() => (init_agentic2(), agentic_exports));
|
|
511
|
+
const planner = new AgenticPlanner2(config2, projectInfo, workspaceMap);
|
|
512
|
+
logger.info(`Starting Agentic test generation for ${options.file}...`);
|
|
513
|
+
const startTime = Date.now();
|
|
514
|
+
const result = await planner.generateFile(filePath, { forceUpdate: options.forceUpdate, evaluateExisting: options.evaluateExisting, workspaceMap });
|
|
515
|
+
if (result === "generated") {
|
|
516
|
+
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1e3);
|
|
517
|
+
logger.success(`
|
|
518
|
+
Finished! Successfully generated test for ${options.file}.`);
|
|
519
|
+
console.log(chalk2.magentaBright(`
|
|
520
|
+
\u2728 AI successfully generated the test suite in ${elapsedSeconds}s! Saved you ~2 hours of typing.`));
|
|
521
|
+
const terminalLink = (await import("terminal-link")).default;
|
|
522
|
+
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`;
|
|
523
|
+
console.log(chalk2.cyan("\u{1F680} " + terminalLink("Share your win on Twitter!", tweetUrl)));
|
|
524
|
+
console.log(chalk2.yellow("\u2615 " + terminalLink("Buy the creator a coffee!", "https://buymeacoffee.com/cijaytechnh")) + "\n");
|
|
525
|
+
} else if (result === "skipped") {
|
|
526
|
+
logger.info(`
|
|
527
|
+
Skipped test generation for ${options.file}.`);
|
|
528
|
+
} else {
|
|
529
|
+
logger.error(`
|
|
530
|
+
Failed to generate test for ${options.file}.`);
|
|
531
|
+
}
|
|
532
|
+
process.exit(0);
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// src/core/agentic.ts
|
|
539
|
+
import { exec as exec3 } from "child_process";
|
|
540
|
+
import { promisify as promisify3 } from "util";
|
|
541
|
+
import { writeFileSync as writeFileSync2, readFileSync as readFileSync4, existsSync as existsSync4, readdirSync } from "fs";
|
|
542
|
+
import { resolve as resolve4, dirname as dirname3 } from "path";
|
|
543
|
+
import chalk3 from "chalk";
|
|
544
|
+
var execAsync3, AgenticPlanner;
|
|
545
|
+
var init_agentic = __esm({
|
|
546
|
+
"src/core/agentic.ts"() {
|
|
547
|
+
"use strict";
|
|
548
|
+
init_ai();
|
|
549
|
+
init_runner();
|
|
550
|
+
init_generate();
|
|
551
|
+
execAsync3 = promisify3(exec3);
|
|
552
|
+
AgenticPlanner = class {
|
|
553
|
+
constructor(config2, projectInfo, workspaceMap = "") {
|
|
554
|
+
this.config = config2;
|
|
555
|
+
this.projectInfo = projectInfo;
|
|
556
|
+
this.workspaceMap = workspaceMap;
|
|
557
|
+
}
|
|
558
|
+
config;
|
|
559
|
+
projectInfo;
|
|
560
|
+
workspaceMap;
|
|
561
|
+
/** Detect if the AI provider is a cloud provider with a massive context window */
|
|
562
|
+
_isCloudProvider() {
|
|
563
|
+
const p = (this.config.provider || "").toLowerCase();
|
|
564
|
+
return p.includes("deepseek") || p.includes("openai") || p.includes("anthropic") || p.includes("gemini") || p.includes("google");
|
|
565
|
+
}
|
|
566
|
+
/** Generate tests for a file using the LLM planning loop. */
|
|
567
|
+
async generateFile(sourceFilePath, options = {}) {
|
|
568
|
+
const { forceUpdate = false, evaluateExisting = false } = options;
|
|
569
|
+
let isMassive = false;
|
|
570
|
+
let fileLength = 0;
|
|
571
|
+
try {
|
|
572
|
+
const content = readFileSync4(sourceFilePath, "utf-8");
|
|
573
|
+
fileLength = content.length;
|
|
574
|
+
isMassive = fileLength > 2e4;
|
|
575
|
+
} catch (e) {
|
|
576
|
+
}
|
|
577
|
+
let initialResult = "failed";
|
|
578
|
+
if (isMassive) {
|
|
579
|
+
console.log(chalk3.yellow(`
|
|
580
|
+
\u26A0 File ${sourceFilePath.split("/").pop()} is massive (${fileLength} chars). Engaging Agentic Chunking Generator...`));
|
|
581
|
+
initialResult = await this._runAgenticGeneratorLoop(sourceFilePath, this._deriveTestPath(sourceFilePath));
|
|
582
|
+
} else {
|
|
583
|
+
initialResult = await generateTestForFile(
|
|
584
|
+
sourceFilePath,
|
|
585
|
+
this.config,
|
|
586
|
+
this.projectInfo,
|
|
587
|
+
forceUpdate,
|
|
588
|
+
evaluateExisting,
|
|
589
|
+
this.workspaceMap
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (initialResult === "skipped") return "skipped";
|
|
593
|
+
if (initialResult === "failed") return "failed";
|
|
594
|
+
return this._runAgenticLoop(this._deriveTestPath(sourceFilePath), sourceFilePath);
|
|
595
|
+
}
|
|
596
|
+
/** Run the agentic repair loop on an existing test file. */
|
|
597
|
+
async fixTestFile(testFilePath, sourceFilePath) {
|
|
598
|
+
return this._runAgenticLoop(testFilePath, sourceFilePath);
|
|
599
|
+
}
|
|
600
|
+
async _runAgenticGeneratorLoop(sourceFilePath, testFilePath) {
|
|
601
|
+
let attempts = 0;
|
|
602
|
+
const maxAttempts = 50;
|
|
603
|
+
let stagnationCounter = 0;
|
|
604
|
+
let previousStepsHash = "";
|
|
605
|
+
let duplicateCount = 0;
|
|
606
|
+
let testCode = "";
|
|
607
|
+
const history = [];
|
|
608
|
+
let fileLines = [];
|
|
609
|
+
try {
|
|
610
|
+
fileLines = readFileSync4(sourceFilePath, "utf-8").split("\n");
|
|
611
|
+
} catch (e) {
|
|
612
|
+
return "failed";
|
|
613
|
+
}
|
|
614
|
+
if (existsSync4(testFilePath)) {
|
|
615
|
+
testCode = readFileSync4(testFilePath, "utf-8");
|
|
616
|
+
} else {
|
|
617
|
+
writeFileSync2(testFilePath, testCode, "utf-8");
|
|
618
|
+
}
|
|
619
|
+
console.log(chalk3.blue(`\u2139 Starting Agentic Chunking Generator for ${sourceFilePath.split("/").pop()} (${fileLines.length} lines)...`));
|
|
620
|
+
while (attempts < maxAttempts) {
|
|
621
|
+
attempts++;
|
|
622
|
+
const plan = await this._requestGenPlan(testCode, testFilePath, sourceFilePath, fileLines.length, history);
|
|
623
|
+
if (!plan) {
|
|
624
|
+
console.log(chalk3.red(`\u2716 AI failed to generate a valid generation plan.`));
|
|
625
|
+
return "failed";
|
|
626
|
+
}
|
|
627
|
+
const currentStepsHash = JSON.stringify(plan.steps);
|
|
628
|
+
if (currentStepsHash === previousStepsHash) {
|
|
629
|
+
duplicateCount++;
|
|
630
|
+
if (duplicateCount >= 3) {
|
|
631
|
+
console.log(chalk3.red(`\u2716 AI generated the exact same action 3 times in a row. Forcibly breaking loop.`));
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
} else {
|
|
635
|
+
duplicateCount = 0;
|
|
636
|
+
previousStepsHash = currentStepsHash;
|
|
637
|
+
}
|
|
638
|
+
console.log(chalk3.cyan(`\u{1F916} AI Generator returned a plan:
|
|
639
|
+
\u{1F914} Reasoning: ${plan.reasoning}`));
|
|
640
|
+
let isFinished = false;
|
|
641
|
+
let madeProgress = false;
|
|
642
|
+
for (const step of plan.steps) {
|
|
643
|
+
if (step.action === "read_lines") {
|
|
644
|
+
console.log(chalk3.cyan(` - \u{1F4D6} Reading lines ${step.start} to ${step.end}`));
|
|
645
|
+
const startIdx = Math.max(0, step.start - 1);
|
|
646
|
+
const endIdx = Math.min(fileLines.length, step.end);
|
|
647
|
+
const chunk = fileLines.slice(startIdx, endIdx).map((l, i) => `${startIdx + i + 1}: ${l}`).join("\n");
|
|
648
|
+
history.push(`Attempt ${attempts}: Read lines ${step.start}-${step.end}:
|
|
649
|
+
${chunk}`);
|
|
650
|
+
} else if (step.action === "search_file") {
|
|
651
|
+
console.log(chalk3.cyan(` - \u{1F50D} Searching for "${step.query}"`));
|
|
652
|
+
const matches = fileLines.map((line, idx) => ({ line, idx: idx + 1 })).filter(({ line }) => line.includes(step.query)).slice(0, 30);
|
|
653
|
+
const matchStr = matches.length > 0 ? matches.map((m) => `Line ${m.idx}: ${m.line}`).join("\n") : "No matches found.";
|
|
654
|
+
history.push(`Attempt ${attempts}: Searched for "${step.query}". Results:
|
|
655
|
+
${matchStr}`);
|
|
656
|
+
} else if (step.action === "append_test") {
|
|
657
|
+
console.log(chalk3.cyan(` - \u{1F4DD} Appending test code chunk`));
|
|
658
|
+
testCode += "\n" + step.code;
|
|
659
|
+
writeFileSync2(testFilePath, testCode, "utf-8");
|
|
660
|
+
history.push(`Attempt ${attempts}: Appended test code.`);
|
|
661
|
+
madeProgress = true;
|
|
662
|
+
} else if (step.action === "finish") {
|
|
663
|
+
console.log(chalk3.green(` - \u2705 AI finished generating the test file.`));
|
|
664
|
+
history.push(`Attempt ${attempts}: Finished. Reason: ${step.reason}`);
|
|
665
|
+
isFinished = true;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (isFinished) {
|
|
669
|
+
return "generated";
|
|
670
|
+
}
|
|
671
|
+
if (madeProgress) {
|
|
672
|
+
stagnationCounter = 0;
|
|
673
|
+
} else {
|
|
674
|
+
stagnationCounter++;
|
|
675
|
+
}
|
|
676
|
+
if (stagnationCounter >= 10) {
|
|
677
|
+
console.log(chalk3.red(`
|
|
678
|
+
\u2716 AI exhausted 10 exploration attempts without making progress. Forcibly breaking loop.`));
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
682
|
+
}
|
|
683
|
+
if (attempts >= maxAttempts) {
|
|
684
|
+
console.log(chalk3.yellow(`
|
|
685
|
+
\u26A0 Reached maximum limit of ${maxAttempts} attempts. Safe breaking. To continue generating coverage, run the command again.`));
|
|
686
|
+
}
|
|
687
|
+
return testCode.trim().length > 0 ? "generated" : "failed";
|
|
688
|
+
}
|
|
689
|
+
async _requestGenPlan(testCode, testFilePath, sourceFilePath, totalLines, history) {
|
|
690
|
+
const historyContext = history.length > 0 ? `
|
|
691
|
+
--- PREVIOUS ACTIONS & RESULTS ---
|
|
692
|
+
${history.join("\n\n")}
|
|
693
|
+
` : "";
|
|
694
|
+
const testFramework = this.projectInfo.testRunner === "unknown" ? "Jest" : this.projectInfo.testRunner;
|
|
695
|
+
const isCloud = this._isCloudProvider();
|
|
696
|
+
const testLines = testCode.split("\n");
|
|
697
|
+
let testContext = "";
|
|
698
|
+
if (!isCloud && testLines.length > 500) {
|
|
699
|
+
const topLines = testLines.slice(0, 50).map((l, i) => `${i + 1}: ${l}`).join("\n");
|
|
700
|
+
const bottomLines = testLines.slice(-200).map((l, i) => `${testLines.length - 200 + i + 1}: ${l}`).join("\n");
|
|
701
|
+
testContext = `
|
|
702
|
+
--- CURRENT TEST FILE PROGRESS (${testFilePath}) ---
|
|
703
|
+
${topLines}
|
|
704
|
+
... [${testLines.length - 250} lines omitted for Local LLM context support] ...
|
|
705
|
+
${bottomLines}`;
|
|
706
|
+
} else {
|
|
707
|
+
const testCodeWithLines = testLines.map((l, i) => `${i + 1}: ${l}`).join("\n");
|
|
708
|
+
testContext = `
|
|
709
|
+
--- CURRENT TEST FILE PROGRESS (${testFilePath}) ---
|
|
710
|
+
${testCodeWithLines || "(Empty)"}`;
|
|
711
|
+
}
|
|
712
|
+
const prompt = `You are an expert QA engineer building a test suite for a massive file using an interactive chunking agent.
|
|
713
|
+
--- TARGET FILE INFO ---
|
|
714
|
+
File: ${sourceFilePath}
|
|
715
|
+
Total Lines: ${totalLines}
|
|
716
|
+
Test Framework: ${testFramework}
|
|
717
|
+
${testContext}
|
|
718
|
+
${historyContext}
|
|
719
|
+
Your goal is to explore the target file chunk-by-chunk and incrementally build the test suite by appending test blocks.
|
|
720
|
+
Based on the current progress, suggest ONE JSON plan describing your next steps. You can combine multiple actions in one plan.
|
|
721
|
+
The JSON must follow this exact schema:
|
|
722
|
+
{
|
|
723
|
+
"reasoning": "<explain what you are looking for or writing>",
|
|
724
|
+
"steps": [
|
|
725
|
+
{ "action": "search_file", "query": "<string to search for, e.g. 'export function'>" }
|
|
726
|
+
| { "action": "read_lines", "start": <line number>, "end": <line number> }
|
|
727
|
+
| { "action": "append_test", "code": "<valid javascript/typescript test code block to append>" }
|
|
728
|
+
| { "action": "finish", "reason": "<explanation of completion>" }
|
|
729
|
+
]
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
CRITICAL RULES:
|
|
733
|
+
1. Do NOT wrap the JSON in markdown blocks. Output ONLY raw JSON.
|
|
734
|
+
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.
|
|
735
|
+
3. Once you know the line numbers, use \`read_lines\` to read the implementation of a specific function (max 300 lines at a time).
|
|
736
|
+
4. After reading the implementation, use \`append_test\` to write the test case(s) for that specific function.
|
|
737
|
+
5. If using \`append_test\`, ensure the code is a complete block (e.g. \`describe('...', () => { ... })\`).
|
|
738
|
+
6. When you have tested all major functions, use \`finish\`.`;
|
|
739
|
+
try {
|
|
740
|
+
const raw = await askAI(this.config, "Generate a JSON plan for incremental test generation.", prompt);
|
|
741
|
+
const cleaned = raw.replace(/```json/g, "").replace(/```/g, "").trim();
|
|
742
|
+
const match = cleaned.match(/\{[\s\S]*\}/);
|
|
743
|
+
if (!match) return null;
|
|
744
|
+
return JSON.parse(match[0]);
|
|
745
|
+
} catch (error) {
|
|
746
|
+
console.log(chalk3.red(`
|
|
747
|
+
\u2716 AI generator request failed: ${error.message || error}`));
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
/** Core agentic reasoning loop for generating and fixing tests. */
|
|
752
|
+
async _runAgenticLoop(testFilePath, sourceFilePath) {
|
|
753
|
+
let attempts = 0;
|
|
754
|
+
const maxAttempts = this.config.maxRetries !== void 0 ? this.config.maxRetries : 3;
|
|
755
|
+
let lastError = "";
|
|
756
|
+
let testCode = readFileSync4(testFilePath, "utf-8");
|
|
757
|
+
const history = [];
|
|
758
|
+
const recentPatches = [];
|
|
759
|
+
const fileLabel = sourceFilePath ? sourceFilePath.split("/").pop() : testFilePath.split("/").pop();
|
|
760
|
+
while (maxAttempts === -1 || attempts < maxAttempts) {
|
|
761
|
+
attempts++;
|
|
762
|
+
const result = await runSingleTest(testFilePath, this.projectInfo);
|
|
763
|
+
if (result.passed) {
|
|
764
|
+
if (attempts > 1) {
|
|
765
|
+
console.log(chalk3.green(`
|
|
766
|
+
\u2714 AI successfully fixed the test for ${fileLabel} after ${attempts - 1} attempt(s)!`));
|
|
767
|
+
} else {
|
|
768
|
+
console.log(chalk3.green(`
|
|
769
|
+
\u2714 Test ${fileLabel} passed successfully in isolation. No AI fixes were needed!`));
|
|
770
|
+
}
|
|
771
|
+
return "generated";
|
|
772
|
+
}
|
|
773
|
+
lastError = result.output;
|
|
774
|
+
const maxText = maxAttempts === -1 ? "\u221E" : maxAttempts;
|
|
775
|
+
console.log(chalk3.yellow(`
|
|
776
|
+
\u26A0 Test failed for ${fileLabel}. Requesting fix from AI (Attempt ${attempts} of ${maxText})...`));
|
|
777
|
+
const snippet = lastError.split("\n").slice(0, 15).join("\n");
|
|
778
|
+
console.log(chalk3.dim(` Error Snippet:
|
|
779
|
+
${snippet.replace(/\n/g, "\n ")}`));
|
|
780
|
+
let explorationContext = "";
|
|
781
|
+
let explorationAttempts = 0;
|
|
782
|
+
let planExecuted = false;
|
|
783
|
+
let previousStepsHash = "";
|
|
784
|
+
let duplicateCount = 0;
|
|
785
|
+
while (explorationAttempts < 5 && !planExecuted) {
|
|
786
|
+
explorationAttempts++;
|
|
787
|
+
const plan = await this._requestPlan(lastError, testCode, testFilePath, history, sourceFilePath, explorationContext);
|
|
788
|
+
if (!plan) {
|
|
789
|
+
console.log(chalk3.red(`\u2716 AI failed to generate a valid plan. Giving up on this file.`));
|
|
790
|
+
return "failed";
|
|
791
|
+
}
|
|
792
|
+
const currentStepsHash = JSON.stringify(plan.steps);
|
|
793
|
+
if (currentStepsHash === previousStepsHash) {
|
|
794
|
+
duplicateCount++;
|
|
795
|
+
if (duplicateCount >= 2) {
|
|
796
|
+
console.log(chalk3.red(`\u2716 AI generated the exact same exploration action consecutively. Forcibly breaking loop.`));
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
} else {
|
|
800
|
+
duplicateCount = 0;
|
|
801
|
+
previousStepsHash = currentStepsHash;
|
|
802
|
+
}
|
|
803
|
+
console.log(chalk3.cyan(`\u{1F916} AI returned a plan:`));
|
|
804
|
+
if (plan.reasoning) {
|
|
805
|
+
console.log(chalk3.gray(` \u{1F914} Reasoning: ${plan.reasoning}`));
|
|
806
|
+
}
|
|
807
|
+
let needsToBreakAndRunTests = false;
|
|
808
|
+
for (const step of plan.steps) {
|
|
809
|
+
if (step.action === "read_file") {
|
|
810
|
+
console.log(chalk3.cyan(` - \u{1F4D6} Reading file: ${step.path}`));
|
|
811
|
+
try {
|
|
812
|
+
const content = readFileSync4(resolve4(process.cwd(), step.path), "utf-8");
|
|
813
|
+
explorationContext += `
|
|
814
|
+
--- READ FILE: ${step.path} ---
|
|
815
|
+
${content}
|
|
816
|
+
`;
|
|
817
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Read file ${step.path}`);
|
|
818
|
+
} catch (err) {
|
|
819
|
+
explorationContext += `
|
|
820
|
+
--- FAILED TO READ FILE: ${step.path} ---
|
|
821
|
+
Error: ${err.message}
|
|
822
|
+
`;
|
|
823
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Failed to read file ${step.path}`);
|
|
824
|
+
}
|
|
825
|
+
} else if (step.action === "search_file") {
|
|
826
|
+
console.log(chalk3.cyan(` - \u{1F50D} Searching for "${step.query}" in ${step.file}`));
|
|
827
|
+
try {
|
|
828
|
+
const content = readFileSync4(resolve4(process.cwd(), step.file), "utf-8");
|
|
829
|
+
const matches = content.split("\n").map((line, idx) => ({ line, idx: idx + 1 })).filter(({ line }) => line.includes(step.query)).slice(0, 30);
|
|
830
|
+
const matchStr = matches.length > 0 ? matches.map((m) => `Line ${m.idx}: ${m.line}`).join("\n") : "No matches found.";
|
|
831
|
+
explorationContext += `
|
|
832
|
+
--- SEARCH RESULTS FOR "${step.query}" IN ${step.file} ---
|
|
833
|
+
${matchStr}
|
|
834
|
+
`;
|
|
835
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Searched for "${step.query}" in ${step.file}`);
|
|
836
|
+
} catch (err) {
|
|
837
|
+
explorationContext += `
|
|
838
|
+
--- FAILED TO SEARCH FILE: ${step.file} ---
|
|
839
|
+
Error: ${err.message}
|
|
840
|
+
`;
|
|
841
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Failed to search file ${step.file}`);
|
|
842
|
+
}
|
|
843
|
+
} else if (step.action === "read_lines") {
|
|
844
|
+
console.log(chalk3.cyan(` - \u{1F4D6} Reading lines ${step.startLine}-${step.endLine} from ${step.file}`));
|
|
845
|
+
try {
|
|
846
|
+
const content = readFileSync4(resolve4(process.cwd(), step.file), "utf-8");
|
|
847
|
+
const lines = content.split("\n");
|
|
848
|
+
const startIdx = Math.max(0, step.startLine - 1);
|
|
849
|
+
const endIdx = Math.min(lines.length, step.endLine);
|
|
850
|
+
const chunk = lines.slice(startIdx, endIdx).map((l, i) => `${startIdx + i + 1}: ${l}`).join("\n");
|
|
851
|
+
explorationContext += `
|
|
852
|
+
--- READ LINES ${step.startLine}-${step.endLine} FROM ${step.file} ---
|
|
853
|
+
${chunk}
|
|
854
|
+
`;
|
|
855
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Read lines ${step.startLine}-${step.endLine} from ${step.file}`);
|
|
856
|
+
} catch (err) {
|
|
857
|
+
explorationContext += `
|
|
858
|
+
--- FAILED TO READ LINES: ${step.file} ---
|
|
859
|
+
Error: ${err.message}
|
|
860
|
+
`;
|
|
861
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Failed to read lines from ${step.file}`);
|
|
862
|
+
}
|
|
863
|
+
} else if (step.action === "list_dir") {
|
|
864
|
+
console.log(chalk3.cyan(` - \u{1F4C2} Listing directory: ${step.path}`));
|
|
865
|
+
try {
|
|
866
|
+
const files = readdirSync(resolve4(process.cwd(), step.path));
|
|
867
|
+
explorationContext += `
|
|
868
|
+
--- DIRECTORY: ${step.path} ---
|
|
869
|
+
${files.join("\n")}
|
|
870
|
+
`;
|
|
871
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Listed directory ${step.path}`);
|
|
872
|
+
} catch (err) {
|
|
873
|
+
explorationContext += `
|
|
874
|
+
--- FAILED TO LIST DIRECTORY: ${step.path} ---
|
|
875
|
+
Error: ${err.message}
|
|
876
|
+
`;
|
|
877
|
+
history.push(`Attempt ${attempts} (Exploration ${explorationAttempts}): Failed to list directory ${step.path}`);
|
|
878
|
+
}
|
|
879
|
+
} else if (step.action === "replace_lines") {
|
|
880
|
+
needsToBreakAndRunTests = true;
|
|
881
|
+
console.log(chalk3.cyan(` - \u{1F4DD} Replace lines ${step.startLine}-${step.endLine} in ${step.file.split("/").pop()}`));
|
|
882
|
+
history.push(`Attempt ${attempts}: Replaced lines ${step.startLine}-${step.endLine} in ${step.file.split("/").pop()}`);
|
|
883
|
+
recentPatches.push(step.replacementCode);
|
|
884
|
+
if (recentPatches.length > 3) recentPatches.shift();
|
|
885
|
+
if (recentPatches.length === 3 && recentPatches.every((code) => code === step.replacementCode)) {
|
|
886
|
+
console.log(chalk3.red(`\u2716 AI generated the exact same patch 3 times in a row. Forcibly breaking infinite loop.`));
|
|
887
|
+
return "failed";
|
|
888
|
+
}
|
|
889
|
+
} else if (step.action === "install_deps") {
|
|
890
|
+
needsToBreakAndRunTests = true;
|
|
891
|
+
console.log(chalk3.cyan(` - \u{1F4E6} Install dependencies: ${step.deps.join(", ")}`));
|
|
892
|
+
history.push(`Attempt ${attempts}: Installed dependencies ${step.deps.join(", ")}`);
|
|
893
|
+
} else if (step.action === "abort") {
|
|
894
|
+
needsToBreakAndRunTests = true;
|
|
895
|
+
console.log(chalk3.cyan(` - \u{1F6D1} Abort: ${step.reason}`));
|
|
896
|
+
history.push(`Attempt ${attempts}: Aborted with reason: ${step.reason}`);
|
|
897
|
+
if (sourceFilePath) {
|
|
898
|
+
const bugFile = resolve4(process.cwd(), "aitest-bugs.md");
|
|
899
|
+
const fs = await import("fs");
|
|
900
|
+
fs.appendFileSync(bugFile, `
|
|
901
|
+
## Source Code Issue Detected in ${sourceFilePath}
|
|
902
|
+
**AI Reasoning**: ${plan.reasoning || "No reasoning provided."}
|
|
903
|
+
**Abort Reason**: ${step.reason}
|
|
904
|
+
`, "utf-8");
|
|
905
|
+
console.log(chalk3.yellow(` \u26A0 Issue logged to aitest-bugs.md for later fixing.`));
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (needsToBreakAndRunTests) {
|
|
910
|
+
const execResult = await this._executePlan(plan);
|
|
911
|
+
if (execResult === "abort") {
|
|
912
|
+
console.log(chalk3.red(`\u2716 AI plan execution aborted.`));
|
|
913
|
+
return "skipped";
|
|
914
|
+
}
|
|
915
|
+
planExecuted = true;
|
|
916
|
+
} else {
|
|
917
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
if (!planExecuted) {
|
|
921
|
+
console.log(chalk3.red(`\u2716 AI exhausted exploration limit without applying a patch. Giving up on this file.`));
|
|
922
|
+
return "failed";
|
|
923
|
+
}
|
|
924
|
+
if (existsSync4(testFilePath)) {
|
|
925
|
+
testCode = readFileSync4(testFilePath, "utf-8");
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
console.log(chalk3.red(`
|
|
929
|
+
\u2716 Exhausted ${maxAttempts} AI attempts without passing.`));
|
|
930
|
+
return "failed";
|
|
931
|
+
}
|
|
932
|
+
/** Derive the .test.* filename from a source file path. */
|
|
933
|
+
_deriveTestPath(sourceFilePath) {
|
|
934
|
+
const ext = sourceFilePath.substring(sourceFilePath.lastIndexOf("."));
|
|
935
|
+
const base = sourceFilePath.substring(
|
|
936
|
+
sourceFilePath.lastIndexOf("/") + 1,
|
|
937
|
+
sourceFilePath.length - ext.length
|
|
938
|
+
);
|
|
939
|
+
const dir = dirname3(sourceFilePath);
|
|
940
|
+
return resolve4(dir, `${base}.test${ext}`);
|
|
941
|
+
}
|
|
942
|
+
/** Prompt the LLM for a JSON plan based on a failed test run. */
|
|
943
|
+
async _requestPlan(errorOutput, testCode, testFilePath, history, sourceFilePath, explorationContext = "") {
|
|
944
|
+
const historyContext = history.length > 0 ? `
|
|
945
|
+
--- PREVIOUS ACTIONS TAKEN ---
|
|
946
|
+
${history.join("\n")}
|
|
947
|
+
WARNING: The test is still failing. Do NOT suggest the exact same action again.` : "";
|
|
948
|
+
let sourceContext = "";
|
|
949
|
+
if (sourceFilePath && existsSync4(sourceFilePath)) {
|
|
950
|
+
const sourceContent = readFileSync4(sourceFilePath, "utf-8");
|
|
951
|
+
const sourceLines = sourceContent.split("\n");
|
|
952
|
+
if (sourceLines.length > 1e3) {
|
|
953
|
+
sourceContext = `
|
|
954
|
+
--- SOURCE FILE INFO ---
|
|
955
|
+
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.`;
|
|
956
|
+
} else {
|
|
957
|
+
sourceContext = `
|
|
958
|
+
--- SOURCE FILE (${sourceFilePath}) ---
|
|
959
|
+
${sourceContent}`;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const workspaceContext = this.workspaceMap ? `
|
|
963
|
+
--- WORKSPACE FILE STRUCTURE ---
|
|
964
|
+
${this.workspaceMap}` : "";
|
|
965
|
+
const explorationContextString = explorationContext ? `
|
|
966
|
+
--- EXPLORATION CONTEXT ---
|
|
967
|
+
${explorationContext}` : "";
|
|
968
|
+
const isCloud = this._isCloudProvider();
|
|
969
|
+
const testLines = testCode.split("\n");
|
|
970
|
+
let testContext = "";
|
|
971
|
+
if (!isCloud && testLines.length > 500) {
|
|
972
|
+
testContext = `
|
|
973
|
+
--- TEST FILE INFO ---
|
|
974
|
+
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.`;
|
|
975
|
+
} else {
|
|
976
|
+
const testCodeWithLines = testLines.map((line, idx) => `${idx + 1}: ${line}`).join("\n");
|
|
977
|
+
testContext = `
|
|
978
|
+
--- TEST FILE (${testFilePath}) ---
|
|
979
|
+
${testCodeWithLines}`;
|
|
980
|
+
}
|
|
981
|
+
const prompt = `You are an expert QA engineer. The following test has failed.${sourceContext}${workspaceContext}${explorationContextString}${testContext}
|
|
982
|
+
--- ERROR OUTPUT ---
|
|
983
|
+
${errorOutput}${historyContext}
|
|
984
|
+
Based on this information, suggest ONE JSON plan describing the next actionable step. The JSON must follow this schema:
|
|
985
|
+
{ "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>" } ] }
|
|
986
|
+
CRITICAL RULES:
|
|
987
|
+
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".
|
|
988
|
+
2. You may ONLY patch the test file (${testFilePath}) using the "replace_lines" action.
|
|
989
|
+
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.
|
|
990
|
+
4. Do NOT wrap the JSON in markdown code blocks. Output ONLY the raw JSON object.
|
|
991
|
+
5. If you cannot fix the issue, return an "abort" action.`;
|
|
992
|
+
try {
|
|
993
|
+
const raw = await askAI(this.config, "Generate a JSON plan to fix the failing test.", prompt);
|
|
994
|
+
const plan = this._parsePlan(raw);
|
|
995
|
+
if (plan && this._validatePlan(plan, testFilePath)) return plan;
|
|
996
|
+
return null;
|
|
997
|
+
} catch (error) {
|
|
998
|
+
console.log(chalk3.red(`
|
|
999
|
+
\u2716 AI request failed: ${error.message || error}`));
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/** Extract JSON from LLM response and parse it. */
|
|
1004
|
+
_parsePlan(text) {
|
|
1005
|
+
try {
|
|
1006
|
+
const cleaned = text.replace(/```json/g, "").replace(/```/g, "").trim();
|
|
1007
|
+
const match = cleaned.match(/\{[\s\S]*\}/);
|
|
1008
|
+
if (!match) return null;
|
|
1009
|
+
const obj = JSON.parse(match[0]);
|
|
1010
|
+
if (obj && Array.isArray(obj.steps)) return obj;
|
|
1011
|
+
} catch (e) {
|
|
1012
|
+
}
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
/** Validate that the plan only contains allowed actions and safe file paths. */
|
|
1016
|
+
_validatePlan(plan, testFilePath) {
|
|
1017
|
+
const allowed = /* @__PURE__ */ new Set(["replace_lines", "install_deps", "abort", "read_file", "list_dir", "search_file", "read_lines"]);
|
|
1018
|
+
for (const step of plan.steps) {
|
|
1019
|
+
if (!allowed.has(step.action)) return false;
|
|
1020
|
+
if (step.action === "replace_lines") {
|
|
1021
|
+
const abs = resolve4(step.file);
|
|
1022
|
+
if (abs !== testFilePath) return false;
|
|
1023
|
+
if (typeof step.startLine !== "number") return false;
|
|
1024
|
+
if (typeof step.endLine !== "number") return false;
|
|
1025
|
+
if (typeof step.replacementCode !== "string") return false;
|
|
1026
|
+
}
|
|
1027
|
+
if (step.action === "install_deps") {
|
|
1028
|
+
if (!Array.isArray(step.deps)) return false;
|
|
1029
|
+
}
|
|
1030
|
+
if (step.action === "read_file" || step.action === "list_dir") {
|
|
1031
|
+
if (typeof step.path !== "string") return false;
|
|
1032
|
+
}
|
|
1033
|
+
if (step.action === "search_file") {
|
|
1034
|
+
if (typeof step.file !== "string") return false;
|
|
1035
|
+
if (typeof step.query !== "string") return false;
|
|
1036
|
+
}
|
|
1037
|
+
if (step.action === "read_lines") {
|
|
1038
|
+
if (typeof step.file !== "string") return false;
|
|
1039
|
+
if (typeof step.startLine !== "number") return false;
|
|
1040
|
+
if (typeof step.endLine !== "number") return false;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
return true;
|
|
1044
|
+
}
|
|
1045
|
+
/** Execute a validated plan step‑by‑step. */
|
|
1046
|
+
async _executePlan(plan) {
|
|
1047
|
+
for (const step of plan.steps) {
|
|
1048
|
+
switch (step.action) {
|
|
1049
|
+
case "replace_lines": {
|
|
1050
|
+
const fileContent = existsSync4(step.file) ? readFileSync4(step.file, "utf-8") : "";
|
|
1051
|
+
const lines = fileContent.split("\n");
|
|
1052
|
+
const startIdx = Math.max(0, step.startLine - 1);
|
|
1053
|
+
const endIdx = Math.min(lines.length, step.endLine);
|
|
1054
|
+
const newLines = step.replacementCode.split("\n");
|
|
1055
|
+
lines.splice(startIdx, endIdx - startIdx, ...newLines);
|
|
1056
|
+
writeFileSync2(step.file, lines.join("\n"), "utf-8");
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
case "install_deps": {
|
|
1060
|
+
const deps = step.deps.join(" ");
|
|
1061
|
+
try {
|
|
1062
|
+
await execAsync3(`npm install --save-dev ${deps}`, { cwd: process.cwd() });
|
|
1063
|
+
} catch (e) {
|
|
1064
|
+
return "abort";
|
|
1065
|
+
}
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
case "abort": {
|
|
1069
|
+
return "abort";
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
return "continue";
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
// src/core/agentic.js
|
|
1080
|
+
var agentic_exports = {};
|
|
1081
|
+
__export(agentic_exports, {
|
|
1082
|
+
AgenticPlanner: () => AgenticPlanner
|
|
1083
|
+
});
|
|
1084
|
+
var init_agentic2 = __esm({
|
|
1085
|
+
"src/core/agentic.js"() {
|
|
1086
|
+
"use strict";
|
|
1087
|
+
init_agentic();
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
// src/commands/fix.ts
|
|
1092
|
+
init_config();
|
|
1093
|
+
init_logger();
|
|
1094
|
+
init_detector();
|
|
1095
|
+
init_ai();
|
|
1096
|
+
import { Command as Command2 } from "commander";
|
|
1097
|
+
import { exec as exec4 } from "child_process";
|
|
1098
|
+
import { promisify as promisify4 } from "util";
|
|
1099
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1100
|
+
import { resolve as resolve5, dirname as dirname4, basename as basename2, extname as extname2 } from "path";
|
|
1101
|
+
import chalk4 from "chalk";
|
|
1102
|
+
var execAsync4 = promisify4(exec4);
|
|
1103
|
+
var fixCommand = new Command2("fix").description("Attempts automatic repair of failing tests using Agentic AI").action(async (options) => {
|
|
1104
|
+
const config2 = await loadConfig();
|
|
1105
|
+
if (!config2) {
|
|
1106
|
+
logger.error("Configuration not found. Run `aitest init` first.");
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
logger.info("Starting test suite to identify failures using Agentic AI...\n");
|
|
1110
|
+
const projectInfo = detectProjectInfo();
|
|
1111
|
+
let testCommand = "npm test";
|
|
1112
|
+
if (projectInfo.testRunner === "vitest") {
|
|
1113
|
+
testCommand = "npx vitest run";
|
|
1114
|
+
} else if (projectInfo.testRunner === "jest") {
|
|
1115
|
+
testCommand = "npx jest";
|
|
1116
|
+
} else if (projectInfo.testRunner === "mocha") {
|
|
1117
|
+
testCommand = "npx mocha";
|
|
1118
|
+
}
|
|
1119
|
+
const spinner = logger.spinner(`Running tests (${testCommand})...`).start();
|
|
1120
|
+
let stdoutStr = "";
|
|
1121
|
+
let stderrStr = "";
|
|
1122
|
+
let testFailed = false;
|
|
1123
|
+
try {
|
|
1124
|
+
const { stdout, stderr } = await execAsync4(testCommand);
|
|
1125
|
+
stdoutStr = stdout;
|
|
1126
|
+
stderrStr = stderr;
|
|
1127
|
+
spinner.succeed("Tests passed! No fixing needed.");
|
|
1128
|
+
return;
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
testFailed = true;
|
|
1131
|
+
stdoutStr = error.stdout || "";
|
|
1132
|
+
stderrStr = error.stderr || error.message;
|
|
1133
|
+
spinner.warn("Tests failed. Starting AI repair flow...");
|
|
1134
|
+
}
|
|
1135
|
+
const analyzeSpinner = logger.spinner("Analyzing test failure to identify the failing file...").start();
|
|
1136
|
+
const outputToAnalyze = `
|
|
1137
|
+
STDOUT:
|
|
1138
|
+
${stdoutStr.slice(-3e3)}
|
|
1139
|
+
|
|
1140
|
+
STDERR:
|
|
1141
|
+
${stderrStr.slice(-3e3)}
|
|
1142
|
+
`.trim();
|
|
1143
|
+
try {
|
|
1144
|
+
const aiResponse = await askAI(
|
|
1145
|
+
config2,
|
|
1146
|
+
'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".',
|
|
1147
|
+
outputToAnalyze
|
|
1148
|
+
);
|
|
1149
|
+
const failingFile = aiResponse.trim();
|
|
1150
|
+
if (failingFile === "UNKNOWN" || failingFile === "") {
|
|
1151
|
+
analyzeSpinner.fail("Could not identify a failing test file from the output.");
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
const fullPath = resolve5(process.cwd(), failingFile);
|
|
1155
|
+
if (!existsSync5(fullPath)) {
|
|
1156
|
+
analyzeSpinner.fail(`AI identified ${failingFile} as the failing file, but it does not exist.`);
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
analyzeSpinner.succeed(`Identified failing test file: ${failingFile}`);
|
|
1160
|
+
const dir = dirname4(fullPath);
|
|
1161
|
+
const ext = extname2(fullPath);
|
|
1162
|
+
const name = basename2(fullPath, ext).replace(/\.test|\.spec/, "");
|
|
1163
|
+
const possibleSourceFiles = [
|
|
1164
|
+
resolve5(dir, `${name}.ts`),
|
|
1165
|
+
resolve5(dir, `${name}.js`),
|
|
1166
|
+
resolve5(dir, `${name}.tsx`),
|
|
1167
|
+
resolve5(dir, `${name}.jsx`),
|
|
1168
|
+
resolve5(dir, `../${name}.ts`),
|
|
1169
|
+
// one level up might work
|
|
1170
|
+
resolve5(dir, `../${name}.js`)
|
|
1171
|
+
];
|
|
1172
|
+
let sourceAbsPath = void 0;
|
|
1173
|
+
for (const sf of possibleSourceFiles) {
|
|
1174
|
+
if (existsSync5(sf)) {
|
|
1175
|
+
sourceAbsPath = sf;
|
|
1176
|
+
break;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
const { AgenticPlanner: AgenticPlanner2 } = await Promise.resolve().then(() => (init_agentic2(), agentic_exports));
|
|
1180
|
+
const { generateWorkspaceMap: generateWorkspaceMap2 } = await Promise.resolve().then(() => (init_scanner(), scanner_exports));
|
|
1181
|
+
const startTime = Date.now();
|
|
1182
|
+
const workspaceMap = await generateWorkspaceMap2();
|
|
1183
|
+
const planner = new AgenticPlanner2(config2, projectInfo, workspaceMap);
|
|
1184
|
+
logger.info(`Delegating repair of ${failingFile} to AgenticPlanner...`);
|
|
1185
|
+
const plannerResult = await planner.fixTestFile(fullPath, sourceAbsPath);
|
|
1186
|
+
if (plannerResult === "generated") {
|
|
1187
|
+
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1e3);
|
|
1188
|
+
logger.success(`\u2705 Auto-repair flow finished successfully for ${failingFile}.`);
|
|
1189
|
+
console.log(chalk4.magentaBright(`
|
|
1190
|
+
\u2728 AI successfully fixed the broken tests in ${elapsedSeconds}s! Saved you ~1 hour of debugging.`));
|
|
1191
|
+
const terminalLink = (await import("terminal-link")).default;
|
|
1192
|
+
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`;
|
|
1193
|
+
console.log(chalk4.cyan("\u{1F680} " + terminalLink("Share your win on Twitter!", tweetUrl)));
|
|
1194
|
+
console.log(chalk4.yellow("\u2615 " + terminalLink("Buy the creator a coffee!", "https://buymeacoffee.com/cijaytechnh")) + "\n");
|
|
1195
|
+
} else {
|
|
1196
|
+
logger.error(`\u2716 Auto-repair flow failed or aborted for ${failingFile}.`);
|
|
1197
|
+
}
|
|
1198
|
+
process.exit(0);
|
|
1199
|
+
} catch (error) {
|
|
1200
|
+
analyzeSpinner.fail(`Repair flow failed: ${error.message}`);
|
|
1201
|
+
process.exit(1);
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
export {
|
|
1205
|
+
fixCommand
|
|
1206
|
+
};
|
|
1207
|
+
//# sourceMappingURL=fix.js.map
|