@amit641/testpilot-ai 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/dist/cli.js ADDED
@@ -0,0 +1,1270 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import pc2 from 'picocolors';
4
+ import { existsSync, statSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
5
+ import { resolve, extname, join, basename, dirname, relative } from 'path';
6
+ import * as ts from 'typescript';
7
+ import { ai } from 'aiclientjs';
8
+ import { execSync } from 'child_process';
9
+
10
+ // src/frameworks/index.ts
11
+ function detectFramework(packageJson) {
12
+ const deps = {
13
+ ...packageJson["devDependencies"],
14
+ ...packageJson["dependencies"]
15
+ };
16
+ if (deps["vitest"]) return "vitest";
17
+ if (deps["jest"] || deps["@jest/core"]) return "jest";
18
+ if (deps["mocha"]) return "mocha";
19
+ const scripts = packageJson["scripts"];
20
+ if (scripts) {
21
+ const testScript = scripts["test"] ?? "";
22
+ if (testScript.includes("vitest")) return "vitest";
23
+ if (testScript.includes("jest")) return "jest";
24
+ if (testScript.includes("mocha")) return "mocha";
25
+ if (testScript.includes("node --test")) return "node";
26
+ }
27
+ return "vitest";
28
+ }
29
+
30
+ // src/types.ts
31
+ var DEFAULT_CONFIG = {
32
+ provider: "openai",
33
+ framework: "vitest",
34
+ overwrite: false,
35
+ edgeCases: true,
36
+ errorHandling: true,
37
+ maxTokens: 4096,
38
+ temperature: 0.2
39
+ };
40
+
41
+ // src/config/index.ts
42
+ var CONFIG_FILES = [
43
+ "autotest.config.json",
44
+ ".autotestrc",
45
+ ".autotestrc.json"
46
+ ];
47
+ function resolveConfig(cliFlags, cwd = process.cwd()) {
48
+ const fileConfig = loadConfigFile(cwd);
49
+ const pkgConfig = loadFromPackageJson(cwd);
50
+ const detectedFramework = detectFrameworkFromProject(cwd);
51
+ return {
52
+ ...DEFAULT_CONFIG,
53
+ ...detectedFramework && { framework: detectedFramework },
54
+ ...pkgConfig,
55
+ ...fileConfig,
56
+ ...stripUndefined(cliFlags)
57
+ };
58
+ }
59
+ function loadConfigFile(cwd) {
60
+ for (const fileName of CONFIG_FILES) {
61
+ const filePath = resolve(cwd, fileName);
62
+ if (existsSync(filePath)) {
63
+ try {
64
+ const content = readFileSync(filePath, "utf-8");
65
+ return JSON.parse(content);
66
+ } catch {
67
+ }
68
+ }
69
+ }
70
+ return {};
71
+ }
72
+ function loadFromPackageJson(cwd) {
73
+ const pkgPath = resolve(cwd, "package.json");
74
+ if (!existsSync(pkgPath)) return {};
75
+ try {
76
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
77
+ return pkg["autotest"] ?? {};
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+ function detectFrameworkFromProject(cwd) {
83
+ const pkgPath = resolve(cwd, "package.json");
84
+ if (!existsSync(pkgPath)) return void 0;
85
+ try {
86
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
87
+ return detectFramework(pkg);
88
+ } catch {
89
+ return void 0;
90
+ }
91
+ }
92
+ function stripUndefined(obj) {
93
+ const result = {};
94
+ for (const [key, value] of Object.entries(obj)) {
95
+ if (value !== void 0) {
96
+ result[key] = value;
97
+ }
98
+ }
99
+ return result;
100
+ }
101
+ function analyzeFile(filePath) {
102
+ const sourceCode = readFileSync(filePath, "utf-8");
103
+ const ext = extname(filePath);
104
+ const language = ext === ".ts" || ext === ".tsx" ? "typescript" : "javascript";
105
+ const sourceFile = ts.createSourceFile(
106
+ filePath,
107
+ sourceCode,
108
+ ts.ScriptTarget.Latest,
109
+ true,
110
+ ext === ".tsx" || ext === ".jsx" ? ts.ScriptKind.TSX : ts.ScriptKind.TS
111
+ );
112
+ const exports$1 = [];
113
+ const imports = [];
114
+ visitNode(sourceFile, sourceFile, exports$1, imports);
115
+ const dependencies = imports.filter((i) => !i.isRelative).map((i) => i.source);
116
+ return {
117
+ filePath,
118
+ fileName: basename(filePath),
119
+ language,
120
+ sourceCode,
121
+ exports: exports$1,
122
+ imports,
123
+ dependencies
124
+ };
125
+ }
126
+ function visitNode(node, sourceFile, exports$1, imports) {
127
+ if (ts.isImportDeclaration(node) && node.moduleSpecifier) {
128
+ const source = node.moduleSpecifier.text;
129
+ const specifiers = [];
130
+ if (node.importClause) {
131
+ if (node.importClause.name) {
132
+ specifiers.push(node.importClause.name.text);
133
+ }
134
+ if (node.importClause.namedBindings) {
135
+ if (ts.isNamedImports(node.importClause.namedBindings)) {
136
+ for (const el of node.importClause.namedBindings.elements) {
137
+ specifiers.push(el.name.text);
138
+ }
139
+ } else if (ts.isNamespaceImport(node.importClause.namedBindings)) {
140
+ specifiers.push(`* as ${node.importClause.namedBindings.name.text}`);
141
+ }
142
+ }
143
+ }
144
+ imports.push({
145
+ source,
146
+ specifiers,
147
+ isRelative: source.startsWith(".")
148
+ });
149
+ }
150
+ if (ts.isFunctionDeclaration(node) && hasExportModifier(node)) {
151
+ const name = node.name?.text ?? "default";
152
+ exports$1.push({
153
+ name,
154
+ kind: "function",
155
+ signature: getSignatureText(node, sourceFile),
156
+ jsDoc: getJSDoc(node, sourceFile),
157
+ isAsync: hasAsyncModifier(node),
158
+ isDefault: hasDefaultModifier(node),
159
+ lineNumber: getLineNumber(node, sourceFile),
160
+ parameters: getParameters(node.parameters, sourceFile),
161
+ returnType: node.type ? node.type.getText(sourceFile) : void 0
162
+ });
163
+ }
164
+ if (ts.isClassDeclaration(node) && hasExportModifier(node)) {
165
+ const name = node.name?.text ?? "default";
166
+ exports$1.push({
167
+ name,
168
+ kind: "class",
169
+ signature: getClassSignature(node, sourceFile),
170
+ jsDoc: getJSDoc(node, sourceFile),
171
+ isAsync: false,
172
+ isDefault: hasDefaultModifier(node),
173
+ lineNumber: getLineNumber(node, sourceFile)
174
+ });
175
+ }
176
+ if (ts.isVariableStatement(node) && hasExportModifier(node)) {
177
+ for (const decl of node.declarationList.declarations) {
178
+ if (!ts.isIdentifier(decl.name)) continue;
179
+ const name = decl.name.text;
180
+ const isArrow = decl.initializer && ts.isArrowFunction(decl.initializer);
181
+ const isFuncExpr = decl.initializer && ts.isFunctionExpression(decl.initializer);
182
+ if (isArrow || isFuncExpr) {
183
+ const func = decl.initializer;
184
+ exports$1.push({
185
+ name,
186
+ kind: "arrow-function",
187
+ signature: getSignatureText(node, sourceFile),
188
+ jsDoc: getJSDoc(node, sourceFile),
189
+ isAsync: hasAsyncModifier(func),
190
+ isDefault: false,
191
+ lineNumber: getLineNumber(node, sourceFile),
192
+ parameters: getParameters(func.parameters, sourceFile),
193
+ returnType: func.type ? func.type.getText(sourceFile) : void 0
194
+ });
195
+ } else {
196
+ exports$1.push({
197
+ name,
198
+ kind: "variable",
199
+ signature: decl.getText(sourceFile),
200
+ jsDoc: getJSDoc(node, sourceFile),
201
+ isAsync: false,
202
+ isDefault: false,
203
+ lineNumber: getLineNumber(node, sourceFile)
204
+ });
205
+ }
206
+ }
207
+ }
208
+ if (ts.isInterfaceDeclaration(node) && hasExportModifier(node)) {
209
+ exports$1.push({
210
+ name: node.name.text,
211
+ kind: "interface",
212
+ signature: node.getText(sourceFile),
213
+ isAsync: false,
214
+ isDefault: false,
215
+ lineNumber: getLineNumber(node, sourceFile)
216
+ });
217
+ }
218
+ if (ts.isTypeAliasDeclaration(node) && hasExportModifier(node)) {
219
+ exports$1.push({
220
+ name: node.name.text,
221
+ kind: "type",
222
+ signature: node.getText(sourceFile),
223
+ isAsync: false,
224
+ isDefault: false,
225
+ lineNumber: getLineNumber(node, sourceFile)
226
+ });
227
+ }
228
+ if (ts.isEnumDeclaration(node) && hasExportModifier(node)) {
229
+ exports$1.push({
230
+ name: node.name.text,
231
+ kind: "enum",
232
+ signature: node.getText(sourceFile),
233
+ isAsync: false,
234
+ isDefault: false,
235
+ lineNumber: getLineNumber(node, sourceFile)
236
+ });
237
+ }
238
+ ts.forEachChild(node, (child) => visitNode(child, sourceFile, exports$1, imports));
239
+ }
240
+ function hasExportModifier(node) {
241
+ const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : void 0;
242
+ return modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
243
+ }
244
+ function hasDefaultModifier(node) {
245
+ const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : void 0;
246
+ return modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
247
+ }
248
+ function hasAsyncModifier(node) {
249
+ const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : void 0;
250
+ return modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) ?? false;
251
+ }
252
+ function getLineNumber(node, sourceFile) {
253
+ return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
254
+ }
255
+ function getSignatureText(node, sourceFile) {
256
+ const fullText = node.getText(sourceFile);
257
+ const bodyStart = fullText.indexOf("{");
258
+ if (bodyStart === -1) return fullText;
259
+ return fullText.slice(0, bodyStart).trim();
260
+ }
261
+ function getClassSignature(node, sourceFile) {
262
+ const lines = node.getText(sourceFile).split("\n");
263
+ const sigLines = [];
264
+ for (const line of lines) {
265
+ sigLines.push(line);
266
+ if (line.includes("{")) break;
267
+ }
268
+ let sig = sigLines.join("\n");
269
+ const methods = node.members.filter((m) => ts.isMethodDeclaration(m)).map((m) => {
270
+ const methodSig = getSignatureText(m, sourceFile);
271
+ return ` ${methodSig}`;
272
+ });
273
+ if (methods.length > 0) {
274
+ sig += "\n" + methods.join("\n") + "\n}";
275
+ }
276
+ return sig;
277
+ }
278
+ function getJSDoc(node, sourceFile) {
279
+ const fullText = sourceFile.getFullText();
280
+ const ranges = ts.getLeadingCommentRanges(fullText, node.getFullStart());
281
+ if (!ranges) return void 0;
282
+ for (const range of ranges) {
283
+ const comment = fullText.slice(range.pos, range.end);
284
+ if (comment.startsWith("/**")) {
285
+ return comment;
286
+ }
287
+ }
288
+ return void 0;
289
+ }
290
+ function getParameters(params, sourceFile) {
291
+ return params.map((p) => ({
292
+ name: p.name.getText(sourceFile),
293
+ type: p.type ? p.type.getText(sourceFile) : void 0,
294
+ optional: !!p.questionToken,
295
+ defaultValue: p.initializer ? p.initializer.getText(sourceFile) : void 0
296
+ }));
297
+ }
298
+ function gatherImportContext(analysis, maxFiles = 5, maxCharsPerFile = 3e3) {
299
+ const contexts = [];
300
+ const seen = /* @__PURE__ */ new Set();
301
+ for (const imp of analysis.imports) {
302
+ if (!imp.isRelative) continue;
303
+ if (contexts.length >= maxFiles) break;
304
+ const resolvedPath = resolveImportPath(analysis.filePath, imp.source);
305
+ if (!resolvedPath || seen.has(resolvedPath)) continue;
306
+ seen.add(resolvedPath);
307
+ try {
308
+ let content = readFileSync(resolvedPath, "utf-8");
309
+ if (content.length > maxCharsPerFile) {
310
+ content = content.slice(0, maxCharsPerFile) + "\n// ... (truncated)";
311
+ }
312
+ contexts.push({
313
+ importPath: imp.source,
314
+ resolvedPath,
315
+ specifiers: imp.specifiers,
316
+ content
317
+ });
318
+ } catch {
319
+ }
320
+ }
321
+ return contexts;
322
+ }
323
+ function resolveImportPath(fromFile, importSource) {
324
+ const dir = dirname(fromFile);
325
+ const base = resolve(dir, importSource);
326
+ const extensions = [".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx", "/index.js"];
327
+ if (existsSync(base) && extname(base)) return base;
328
+ for (const ext of extensions) {
329
+ const candidate = base + ext;
330
+ if (existsSync(candidate)) return candidate;
331
+ }
332
+ return null;
333
+ }
334
+
335
+ // src/prompt/index.ts
336
+ function buildSystemPrompt(config) {
337
+ const framework = config.framework;
338
+ const frameworkRules = {
339
+ vitest: `- ALWAYS start with: import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
340
+ - Use vi.fn() for mocking, vi.spyOn for spying`,
341
+ jest: `- Jest globals (describe, it, expect, jest) are available \u2014 do not import them
342
+ - Use jest.fn() for mocking, jest.spyOn for spying`,
343
+ mocha: `- ALWAYS start with: import { describe, it } from 'mocha'; import { expect } from 'chai';
344
+ - Use sinon for mocking if needed`,
345
+ node: `- ALWAYS start with: import { describe, it } from 'node:test'; import assert from 'node:assert/strict';
346
+ - Use assert.strictEqual, assert.throws, assert.deepStrictEqual
347
+ - Use t.mock for mocking`
348
+ };
349
+ return `You are an expert test engineer. You write comprehensive, production-quality ${framework} tests.
350
+
351
+ Rules:
352
+ - Use ${framework} syntax and conventions
353
+ ${frameworkRules[framework] ?? frameworkRules["vitest"]}
354
+ - Write ONLY the test code \u2014 no explanations, no markdown fences, no commentary
355
+ - Use descriptive test names that explain the expected behavior
356
+ - Group related tests in describe blocks
357
+ - Test both happy paths and edge cases
358
+ - Test error conditions and boundary values
359
+ - Import the module under test using the EXACT relative path provided
360
+ - Ensure all tests are independent and can run in any order
361
+ ${config.instructions ? `
362
+ Additional instructions: ${config.instructions}` : ""}`;
363
+ }
364
+ function buildUserPrompt(analysis, config, importContexts) {
365
+ const testableExports = analysis.exports.filter(
366
+ (e) => e.kind !== "type" && e.kind !== "interface"
367
+ );
368
+ if (testableExports.length === 0) {
369
+ return buildSimplePrompt(analysis);
370
+ }
371
+ const sections = [];
372
+ sections.push(`Generate comprehensive ${config.framework} tests for the following file.`);
373
+ sections.push(`
374
+ File: ${analysis.fileName}`);
375
+ sections.push(`Language: ${analysis.language}`);
376
+ sections.push("\n## Exported Symbols to Test\n");
377
+ for (const sym of testableExports) {
378
+ sections.push(formatSymbolForPrompt(sym));
379
+ }
380
+ const importPath = `./${analysis.fileName.replace(/\.(ts|tsx|js|jsx)$/, "")}`;
381
+ const exportNames = testableExports.map((e) => e.isDefault ? `default as ${e.name}` : e.name);
382
+ const importLine = `import { ${exportNames.join(", ")} } from '${importPath}';`;
383
+ sections.push("\n## Requirements\n");
384
+ sections.push(`- Your test file MUST start with this exact import:
385
+ ${importLine}`);
386
+ sections.push(`- Import each function/class by name \u2014 do NOT use side-effect imports like \`import '${importPath}'\``);
387
+ if (config.edgeCases) {
388
+ sections.push("- Include edge case tests for boundary values (empty strings, 0, negative numbers, large inputs)");
389
+ sections.push("- Only test null/undefined if the source code explicitly handles them \u2014 JavaScript coerces null/undefined in arithmetic, so do NOT assume they throw");
390
+ }
391
+ if (config.errorHandling) {
392
+ sections.push("- Include error handling tests ONLY for errors explicitly thrown in the source code");
393
+ sections.push("- Read the source code carefully \u2014 only use toThrow/toThrowError for functions that actually have throw statements");
394
+ }
395
+ const asyncExports = testableExports.filter((e) => e.isAsync);
396
+ if (asyncExports.length > 0) {
397
+ sections.push(`- Use async/await for testing: ${asyncExports.map((e) => e.name).join(", ")}`);
398
+ }
399
+ sections.push("\n## Full Source Code\n");
400
+ sections.push("```" + analysis.language);
401
+ sections.push(analysis.sourceCode);
402
+ sections.push("```");
403
+ if (importContexts && importContexts.length > 0) {
404
+ sections.push("\n## Related Files (for context only \u2014 do NOT test these)\n");
405
+ for (const ctx of importContexts) {
406
+ sections.push(`### ${ctx.importPath} (imports: ${ctx.specifiers.join(", ")})
407
+ `);
408
+ sections.push("```" + analysis.language);
409
+ sections.push(ctx.content);
410
+ sections.push("```\n");
411
+ }
412
+ }
413
+ sections.push("\nGenerate the test file now. Output ONLY valid test code, nothing else.");
414
+ return sections.join("\n");
415
+ }
416
+ function buildSimplePrompt(analysis) {
417
+ return `Generate comprehensive tests for the following ${analysis.language} file.
418
+
419
+ File: ${analysis.fileName}
420
+ Import from: './${analysis.fileName.replace(/\.(ts|tsx|js|jsx)$/, "")}'
421
+
422
+ \`\`\`${analysis.language}
423
+ ${analysis.sourceCode}
424
+ \`\`\`
425
+
426
+ Generate the test file now. Output ONLY valid test code, nothing else.`;
427
+ }
428
+ function formatSymbolForPrompt(sym) {
429
+ const parts = [];
430
+ const prefix = sym.isDefault ? "(default export) " : "";
431
+ switch (sym.kind) {
432
+ case "function":
433
+ case "arrow-function": {
434
+ parts.push(`### ${prefix}\`${sym.name}\` (${sym.isAsync ? "async " : ""}function)`);
435
+ if (sym.parameters?.length) {
436
+ const params = sym.parameters.map((p) => ` - \`${p.name}\`: ${p.type ?? "any"}${p.optional ? " (optional)" : ""}${p.defaultValue ? ` = ${p.defaultValue}` : ""}`).join("\n");
437
+ parts.push(`Parameters:
438
+ ${params}`);
439
+ }
440
+ if (sym.returnType) {
441
+ parts.push(`Returns: \`${sym.returnType}\``);
442
+ }
443
+ break;
444
+ }
445
+ case "class": {
446
+ parts.push(`### ${prefix}\`${sym.name}\` (class)`);
447
+ parts.push(`Signature:
448
+ \`\`\`
449
+ ${sym.signature}
450
+ \`\`\``);
451
+ break;
452
+ }
453
+ case "variable": {
454
+ parts.push(`### \`${sym.name}\` (exported variable)`);
455
+ break;
456
+ }
457
+ case "enum": {
458
+ parts.push(`### \`${sym.name}\` (enum)`);
459
+ break;
460
+ }
461
+ }
462
+ if (sym.jsDoc) {
463
+ parts.push(`Documentation: ${sym.jsDoc}`);
464
+ }
465
+ return parts.join("\n") + "\n";
466
+ }
467
+ async function generateWithLLM(systemPrompt, userPrompt, config, onChunk) {
468
+ if (onChunk) {
469
+ const stream = await ai(
470
+ [
471
+ { role: "system", content: systemPrompt },
472
+ { role: "user", content: userPrompt }
473
+ ],
474
+ {
475
+ provider: config.provider,
476
+ model: config.model,
477
+ apiKey: config.apiKey,
478
+ maxTokens: config.maxTokens,
479
+ temperature: config.temperature,
480
+ stream: true
481
+ }
482
+ );
483
+ let fullText = "";
484
+ for await (const chunk of stream) {
485
+ fullText += chunk;
486
+ onChunk(chunk);
487
+ }
488
+ const response2 = await stream.response();
489
+ return {
490
+ text: fullText,
491
+ tokensUsed: response2.usage.totalTokens
492
+ };
493
+ }
494
+ const response = await ai(
495
+ [
496
+ { role: "system", content: systemPrompt },
497
+ { role: "user", content: userPrompt }
498
+ ],
499
+ {
500
+ provider: config.provider,
501
+ model: config.model,
502
+ apiKey: config.apiKey,
503
+ maxTokens: config.maxTokens,
504
+ temperature: config.temperature
505
+ }
506
+ );
507
+ return {
508
+ text: response.text,
509
+ tokensUsed: response.usage.totalTokens
510
+ };
511
+ }
512
+ function parseTestOutput(raw, sourceFile, config) {
513
+ let code = raw.trim();
514
+ const fenceStart = code.indexOf("```");
515
+ if (fenceStart !== -1) {
516
+ code = code.slice(fenceStart);
517
+ const firstNewline = code.indexOf("\n");
518
+ code = code.slice(firstNewline + 1);
519
+ const lastFence = code.lastIndexOf("```");
520
+ if (lastFence !== -1) {
521
+ code = code.slice(0, lastFence);
522
+ }
523
+ code = code.trim();
524
+ }
525
+ const itMatches = code.match(/\bit\s*\(/g) || [];
526
+ const testMatches = code.match(/\btest\s*\(/g) || [];
527
+ const testCount = itMatches.length + testMatches.length;
528
+ const categories = extractCategories(code);
529
+ const testFile = getTestFilePath(sourceFile, config);
530
+ return {
531
+ sourceFile,
532
+ testFile,
533
+ testCode: code,
534
+ testCount,
535
+ categories
536
+ };
537
+ }
538
+ function writeTestFile(test, config) {
539
+ if (existsSync(test.testFile) && !config.overwrite) {
540
+ throw new Error(
541
+ `Test file already exists: ${test.testFile}. Use --overwrite to replace it.`
542
+ );
543
+ }
544
+ const dir = dirname(test.testFile);
545
+ mkdirSync(dir, { recursive: true });
546
+ writeFileSync(test.testFile, test.testCode + "\n", "utf-8");
547
+ }
548
+ function getTestFilePath(sourceFile, config) {
549
+ const ext = extname(sourceFile);
550
+ const base = basename(sourceFile, ext);
551
+ const dir = config.outDir ?? dirname(sourceFile);
552
+ return join(dir, `${base}.test${ext}`);
553
+ }
554
+ function extractCategories(code) {
555
+ const categories = [];
556
+ const describeRegex = /describe\s*\(\s*['"`]([^'"`]+)['"`]/g;
557
+ let match;
558
+ while ((match = describeRegex.exec(code)) !== null) {
559
+ const name = match[1];
560
+ const startIdx = match.index;
561
+ const nextDescribe = code.indexOf("describe(", startIdx + 1);
562
+ const block = nextDescribe === -1 ? code.slice(startIdx) : code.slice(startIdx, nextDescribe);
563
+ const itCount = (block.match(/\bit\s*\(/g) || []).length + (block.match(/\btest\s*\(/g) || []).length;
564
+ categories.push({ name, count: itCount });
565
+ }
566
+ return categories;
567
+ }
568
+ function runTestFile(testFile, framework, cwd) {
569
+ const resolvedCwd = findProjectRoot(testFile);
570
+ const cmd = buildRunCommand(framework, testFile);
571
+ try {
572
+ const output = execSync(cmd, {
573
+ cwd: resolvedCwd,
574
+ encoding: "utf-8",
575
+ timeout: 6e4,
576
+ env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
577
+ stdio: ["pipe", "pipe", "pipe"]
578
+ });
579
+ return parseTestOutput2(output, framework, true);
580
+ } catch (err) {
581
+ const execErr = err;
582
+ const output = (execErr.stdout ?? "") + "\n" + (execErr.stderr ?? "");
583
+ return parseTestOutput2(output, framework, false);
584
+ }
585
+ }
586
+ function buildRunCommand(framework, testFile) {
587
+ switch (framework) {
588
+ case "vitest":
589
+ return `npx vitest run "${testFile}" --reporter=verbose 2>&1`;
590
+ case "jest":
591
+ return `npx jest "${testFile}" --verbose --no-coverage 2>&1`;
592
+ case "mocha":
593
+ return `npx mocha "${testFile}" --reporter spec 2>&1`;
594
+ case "node":
595
+ return `node --test "${testFile}" 2>&1`;
596
+ }
597
+ }
598
+ function parseTestOutput2(output, framework, exitedClean) {
599
+ const failures = extractFailures(output);
600
+ const counts = extractCounts(output);
601
+ const passed = exitedClean || failures.length === 0 && counts.total > 0;
602
+ return {
603
+ passed,
604
+ output,
605
+ failures,
606
+ totalTests: counts.total,
607
+ passedTests: counts.passed,
608
+ failedTests: counts.failed
609
+ };
610
+ }
611
+ function extractFailures(output, _framework) {
612
+ const failures = [];
613
+ const failPattern = /(?:FAIL|×|✕|✗|✖)\s+(.+?)(?:\n|\r\n)([\s\S]*?)(?=(?:FAIL|×|✕|✗|✖)\s|\n\s*(?:Tests|Test Files|Test Suites)|\n\s*$)/gi;
614
+ let match;
615
+ while ((match = failPattern.exec(output)) !== null) {
616
+ const testName = match[1]?.trim() ?? "unknown";
617
+ const errorBlock = match[2]?.trim() ?? "";
618
+ const expectedMatch = errorBlock.match(/Expected:?\s*(.+)/i);
619
+ const receivedMatch = errorBlock.match(/Received:?\s*(.+)/i);
620
+ failures.push({
621
+ testName,
622
+ error: errorBlock.slice(0, 500),
623
+ expected: expectedMatch?.[1]?.trim(),
624
+ received: receivedMatch?.[1]?.trim()
625
+ });
626
+ }
627
+ if (failures.length === 0 && output.includes("FAIL")) {
628
+ const errorLines = output.match(/(?:AssertionError|Error|ReferenceError|TypeError):.*$/gm);
629
+ if (errorLines) {
630
+ for (const line of errorLines) {
631
+ failures.push({
632
+ testName: "unknown",
633
+ error: line.trim()
634
+ });
635
+ }
636
+ }
637
+ }
638
+ if (failures.length === 0) {
639
+ const syntaxErr = output.match(/(?:SyntaxError|ERROR):\s*(.+)/);
640
+ if (syntaxErr) {
641
+ failures.push({
642
+ testName: "compilation",
643
+ error: syntaxErr[0].trim()
644
+ });
645
+ }
646
+ }
647
+ return failures;
648
+ }
649
+ function extractCounts(output, _framework) {
650
+ const vitestMatch = output.match(/Tests\s+(?:(\d+)\s+failed\s*\|?\s*)?(?:(\d+)\s+passed\s*)?\((\d+)\)/);
651
+ if (vitestMatch) {
652
+ const failed = parseInt(vitestMatch[1] ?? "0", 10);
653
+ const passed = parseInt(vitestMatch[2] ?? "0", 10);
654
+ const total = parseInt(vitestMatch[3], 10);
655
+ return { total, passed, failed: failed || total - passed };
656
+ }
657
+ const vitestAlt = output.match(/(\d+)\s+failed\s*\|\s*(\d+)\s+passed/);
658
+ if (vitestAlt) {
659
+ const failed = parseInt(vitestAlt[1], 10);
660
+ const passed = parseInt(vitestAlt[2], 10);
661
+ return { total: failed + passed, passed, failed };
662
+ }
663
+ const jestMatch = output.match(/Tests:\s+(?:(\d+)\s+failed,\s+)?(\d+)\s+passed,\s+(\d+)\s+total/);
664
+ if (jestMatch) {
665
+ const failed = parseInt(jestMatch[1] ?? "0", 10);
666
+ const passed = parseInt(jestMatch[2], 10);
667
+ const total = parseInt(jestMatch[3], 10);
668
+ return { total, passed, failed };
669
+ }
670
+ const filesPass = output.match(/Test Files\s+(\d+)\s+passed/);
671
+ const filesFail = output.match(/Test Files\s+(\d+)\s+failed/);
672
+ if (filesPass || filesFail) {
673
+ const passMarkers = (output.match(/✓|✔|√/g) || []).length;
674
+ const failMarkers = (output.match(/×|✕|✗|✖|❯/g) || []).length;
675
+ if (passMarkers + failMarkers > 0) {
676
+ return { total: passMarkers + failMarkers, passed: passMarkers, failed: failMarkers };
677
+ }
678
+ }
679
+ const passCount = (output.match(/✓|✔|√/g) || []).length;
680
+ const failCount = (output.match(/×|✕|✗|✖/g) || []).length;
681
+ return {
682
+ total: passCount + failCount,
683
+ passed: passCount,
684
+ failed: failCount
685
+ };
686
+ }
687
+ function findProjectRoot(fromFile) {
688
+ let dir = dirname(resolve(fromFile));
689
+ for (let i = 0; i < 10; i++) {
690
+ if (existsSync(resolve(dir, "package.json"))) return dir;
691
+ const parent = dirname(dir);
692
+ if (parent === dir) break;
693
+ dir = parent;
694
+ }
695
+ return dirname(resolve(fromFile));
696
+ }
697
+ async function fixFailingTests(sourceCode, testCode, failures, config, onChunk) {
698
+ const systemPrompt = buildFixSystemPrompt(config);
699
+ const userPrompt = buildFixUserPrompt(sourceCode, testCode, failures);
700
+ if (onChunk) {
701
+ const stream = await ai(
702
+ [
703
+ { role: "system", content: systemPrompt },
704
+ { role: "user", content: userPrompt }
705
+ ],
706
+ {
707
+ provider: config.provider,
708
+ model: config.model,
709
+ apiKey: config.apiKey,
710
+ maxTokens: config.maxTokens,
711
+ temperature: config.temperature,
712
+ stream: true
713
+ }
714
+ );
715
+ let fullText = "";
716
+ for await (const chunk of stream) {
717
+ fullText += chunk;
718
+ onChunk(chunk);
719
+ }
720
+ return fullText;
721
+ }
722
+ const response = await ai(
723
+ [
724
+ { role: "system", content: systemPrompt },
725
+ { role: "user", content: userPrompt }
726
+ ],
727
+ {
728
+ provider: config.provider,
729
+ model: config.model,
730
+ apiKey: config.apiKey,
731
+ maxTokens: config.maxTokens,
732
+ temperature: config.temperature
733
+ }
734
+ );
735
+ return response.text;
736
+ }
737
+ function buildFixSystemPrompt(config) {
738
+ return `You are an expert test engineer fixing failing ${config.framework} tests.
739
+
740
+ Rules:
741
+ - Output ONLY the complete, corrected test file \u2014 no explanations, no markdown fences
742
+ - Fix every failing test based on the actual error messages
743
+ - If a test expected a function to throw but it doesn't, remove or rewrite the test to match actual behavior
744
+ - If the expected value is wrong, correct it to match the actual source code behavior
745
+ - Do NOT remove passing tests
746
+ - Do NOT add new tests \u2014 only fix the broken ones
747
+ - Keep all imports and structure intact
748
+ - Read the source code carefully to understand what each function actually does`;
749
+ }
750
+ function buildFixUserPrompt(sourceCode, testCode, failures) {
751
+ const failureDetails = failures.map((f, i) => {
752
+ let detail = `${i + 1}. **${f.testName}**
753
+ Error: ${f.error}`;
754
+ if (f.expected) detail += `
755
+ Expected: ${f.expected}`;
756
+ if (f.received) detail += `
757
+ Received: ${f.received}`;
758
+ return detail;
759
+ }).join("\n\n");
760
+ return `Fix the failing tests below. The source code is the truth \u2014 adjust the tests to match it.
761
+
762
+ ## Source Code (DO NOT MODIFY)
763
+
764
+ \`\`\`
765
+ ${sourceCode}
766
+ \`\`\`
767
+
768
+ ## Current Test File (FIX THIS)
769
+
770
+ \`\`\`
771
+ ${testCode}
772
+ \`\`\`
773
+
774
+ ## Failures (${failures.length})
775
+
776
+ ${failureDetails}
777
+
778
+ Output the COMPLETE fixed test file. Every test must pass.`;
779
+ }
780
+
781
+ // src/verify/index.ts
782
+ async function verifyAndFix(sourceFile, testFile, config, options) {
783
+ const maxIterations = options?.maxIterations ?? 3;
784
+ const log = options?.onStatus ?? (() => {
785
+ });
786
+ const sourceCode = readFileSync(sourceFile, "utf-8");
787
+ for (let iteration = 1; iteration <= maxIterations; iteration++) {
788
+ log(`
789
+ ${pc2.cyan("\u25B6")} Verify iteration ${iteration}/${maxIterations}...`);
790
+ const result = runTestFile(testFile, config.framework);
791
+ if (result.passed) {
792
+ log(`${pc2.green("\u2714")} All ${result.totalTests} tests pass!`);
793
+ return {
794
+ passed: true,
795
+ iterations: iteration,
796
+ totalTests: result.totalTests,
797
+ passedTests: result.passedTests,
798
+ failedTests: 0,
799
+ finalTestCode: readFileSync(testFile, "utf-8")
800
+ };
801
+ }
802
+ log(
803
+ `${pc2.yellow("\u26A0")} ${result.failedTests}/${result.totalTests} tests failed` + (iteration < maxIterations ? " \u2014 sending to LLM for auto-fix..." : "")
804
+ );
805
+ if (iteration >= maxIterations) {
806
+ return {
807
+ passed: false,
808
+ iterations: iteration,
809
+ totalTests: result.totalTests,
810
+ passedTests: result.passedTests,
811
+ failedTests: result.failedTests,
812
+ finalTestCode: readFileSync(testFile, "utf-8")
813
+ };
814
+ }
815
+ const currentTestCode = readFileSync(testFile, "utf-8");
816
+ if (options?.onChunk) options.onChunk("\n");
817
+ const fixedRaw = await fixFailingTests(
818
+ sourceCode,
819
+ currentTestCode,
820
+ result.failures,
821
+ config,
822
+ options?.onChunk
823
+ );
824
+ if (options?.onChunk) options.onChunk("\n");
825
+ const parsed = parseTestOutput(fixedRaw, sourceFile, config);
826
+ writeFileSync(testFile, parsed.testCode + "\n", "utf-8");
827
+ log(`${pc2.dim(" Wrote fixed tests to")} ${testFile}`);
828
+ }
829
+ return {
830
+ passed: false,
831
+ iterations: maxIterations,
832
+ totalTests: 0,
833
+ passedTests: 0,
834
+ failedTests: 0,
835
+ finalTestCode: readFileSync(testFile, "utf-8")
836
+ };
837
+ }
838
+
839
+ // src/generate.ts
840
+ async function generateTests(sourceFile, config, options) {
841
+ const start = Date.now();
842
+ const analysis = analyzeFile(sourceFile);
843
+ const importContexts = gatherImportContext(analysis);
844
+ const systemPrompt = buildSystemPrompt(config);
845
+ const userPrompt = buildUserPrompt(analysis, config, importContexts);
846
+ const llmResult = await generateWithLLM(
847
+ systemPrompt,
848
+ userPrompt,
849
+ config,
850
+ options?.onChunk
851
+ );
852
+ const generatedTest = parseTestOutput(llmResult.text, sourceFile, config);
853
+ if (!options?.dryRun) {
854
+ writeTestFile(generatedTest, config);
855
+ }
856
+ const result = {
857
+ sourceFile: generatedTest.sourceFile,
858
+ testFile: generatedTest.testFile,
859
+ testCount: generatedTest.testCount,
860
+ categories: generatedTest.categories,
861
+ duration: Date.now() - start,
862
+ tokensUsed: llmResult.tokensUsed
863
+ };
864
+ if (options?.verify && !options?.dryRun) {
865
+ const verifyResult = await verifyAndFix(
866
+ sourceFile,
867
+ generatedTest.testFile,
868
+ config,
869
+ {
870
+ maxIterations: options.maxFixIterations ?? 3,
871
+ onChunk: options.onChunk,
872
+ onStatus: options.onStatus
873
+ }
874
+ );
875
+ result.verified = verifyResult.passed;
876
+ result.verifyIterations = verifyResult.iterations;
877
+ result.testCount = verifyResult.totalTests;
878
+ result.duration = Date.now() - start;
879
+ }
880
+ return result;
881
+ }
882
+ function parseLcov(lcovPath, cwd) {
883
+ const content = readFileSync(lcovPath, "utf-8");
884
+ const files = [];
885
+ let currentFile = null;
886
+ let linesHit = 0;
887
+ let linesFound = 0;
888
+ let branchesHit = 0;
889
+ let branchesFound = 0;
890
+ const uncoveredLines = [];
891
+ for (const line of content.split("\n")) {
892
+ const trimmed = line.trim();
893
+ if (trimmed.startsWith("SF:")) {
894
+ currentFile = trimmed.slice(3);
895
+ linesHit = 0;
896
+ linesFound = 0;
897
+ branchesHit = 0;
898
+ branchesFound = 0;
899
+ uncoveredLines.length = 0;
900
+ } else if (trimmed.startsWith("DA:")) {
901
+ const parts = trimmed.slice(3).split(",");
902
+ const lineNum = parseInt(parts[0], 10);
903
+ const hits = parseInt(parts[1], 10);
904
+ linesFound++;
905
+ if (hits > 0) {
906
+ linesHit++;
907
+ } else {
908
+ uncoveredLines.push(lineNum);
909
+ }
910
+ } else if (trimmed.startsWith("BRF:")) {
911
+ branchesFound = parseInt(trimmed.slice(4), 10);
912
+ } else if (trimmed.startsWith("BRH:")) {
913
+ branchesHit = parseInt(trimmed.slice(4), 10);
914
+ } else if (trimmed === "end_of_record" && currentFile) {
915
+ const filePath = resolve(currentFile);
916
+ const relPath = relative(cwd, filePath);
917
+ const ext = filePath.match(/\.(ts|tsx|js|jsx)$/)?.[0] ?? ".ts";
918
+ const base = basename(filePath, ext);
919
+ const testFile = filePath.replace(
920
+ new RegExp(`${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}${ext.replace(".", "\\.")}$`),
921
+ `${base}.test${ext}`
922
+ );
923
+ files.push({
924
+ filePath,
925
+ relativePath: relPath,
926
+ lineRate: linesFound > 0 ? linesHit / linesFound : 0,
927
+ branchRate: branchesFound > 0 ? branchesHit / branchesFound : 0,
928
+ uncoveredLines: [...uncoveredLines],
929
+ totalLines: linesFound,
930
+ coveredLines: linesHit,
931
+ hasTests: existsSync(testFile)
932
+ });
933
+ currentFile = null;
934
+ }
935
+ }
936
+ const totalLines = files.reduce((s, f) => s + f.totalLines, 0);
937
+ const coveredLines = files.reduce((s, f) => s + f.coveredLines, 0);
938
+ return {
939
+ files,
940
+ totalLines,
941
+ coveredLines,
942
+ overallRate: totalLines > 0 ? coveredLines / totalLines : 0
943
+ };
944
+ }
945
+ function parseCobertura(xmlPath, cwd) {
946
+ const content = readFileSync(xmlPath, "utf-8");
947
+ const files = [];
948
+ const classRegex = /<class\s[^>]*filename="([^"]+)"[^>]*line-rate="([^"]+)"[^>]*branch-rate="([^"]+)"[^>]*>/g;
949
+ let match;
950
+ while ((match = classRegex.exec(content)) !== null) {
951
+ const filename = match[1];
952
+ const lineRate = parseFloat(match[2]);
953
+ const branchRate = parseFloat(match[3]);
954
+ const filePath = resolve(cwd, filename);
955
+ const relPath = relative(cwd, filePath);
956
+ const uncoveredLines = [];
957
+ const fileSection = content.slice(match.index, content.indexOf("</class>", match.index));
958
+ const lineRegex = /<line\s+number="(\d+)"\s+hits="(\d+)"/g;
959
+ let lineMatch;
960
+ let total = 0;
961
+ let covered = 0;
962
+ while ((lineMatch = lineRegex.exec(fileSection)) !== null) {
963
+ total++;
964
+ const lineNum = parseInt(lineMatch[1], 10);
965
+ const hits = parseInt(lineMatch[2], 10);
966
+ if (hits > 0) {
967
+ covered++;
968
+ } else {
969
+ uncoveredLines.push(lineNum);
970
+ }
971
+ }
972
+ const ext = filePath.match(/\.(ts|tsx|js|jsx)$/)?.[0] ?? ".ts";
973
+ const base = basename(filePath, ext);
974
+ const testFile = filePath.replace(
975
+ new RegExp(`${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}${ext.replace(".", "\\.")}$`),
976
+ `${base}.test${ext}`
977
+ );
978
+ files.push({
979
+ filePath,
980
+ relativePath: relPath,
981
+ lineRate,
982
+ branchRate,
983
+ uncoveredLines,
984
+ totalLines: total || Math.round(1 / (1 - lineRate + 1e-3)),
985
+ coveredLines: covered || Math.round(lineRate * (total || 10)),
986
+ hasTests: existsSync(testFile)
987
+ });
988
+ }
989
+ const totalLines = files.reduce((s, f) => s + f.totalLines, 0);
990
+ const coveredLines = files.reduce((s, f) => s + f.coveredLines, 0);
991
+ return {
992
+ files,
993
+ totalLines,
994
+ coveredLines,
995
+ overallRate: totalLines > 0 ? coveredLines / totalLines : 0
996
+ };
997
+ }
998
+ function loadCoverage(cwd) {
999
+ const lcovPaths = [
1000
+ "coverage/lcov.info",
1001
+ "coverage/lcov/lcov.info"
1002
+ ];
1003
+ for (const p of lcovPaths) {
1004
+ const fullPath = resolve(cwd, p);
1005
+ if (existsSync(fullPath)) return parseLcov(fullPath, cwd);
1006
+ }
1007
+ const coberturaPaths = [
1008
+ "coverage/cobertura-coverage.xml",
1009
+ "coverage/cobertura.xml"
1010
+ ];
1011
+ for (const p of coberturaPaths) {
1012
+ const fullPath = resolve(cwd, p);
1013
+ if (existsSync(fullPath)) return parseCobertura(fullPath, cwd);
1014
+ }
1015
+ return null;
1016
+ }
1017
+ function getUncoveredFiles(coverage, targetRate = 0.8) {
1018
+ return coverage.files.filter((f) => f.lineRate < targetRate).filter((f) => !f.relativePath.includes(".test.") && !f.relativePath.includes(".spec.")).filter((f) => !f.relativePath.includes("node_modules")).sort((a, b) => a.lineRate - b.lineRate);
1019
+ }
1020
+
1021
+ // src/cli.ts
1022
+ var VERSION = "0.1.0";
1023
+ var program = new Command();
1024
+ program.name("testpilot").description("AI-powered test generation that actually works").version(VERSION);
1025
+ program.command("generate", { isDefault: true }).description("Generate tests for a file or directory").argument("<target>", "File or directory to generate tests for").option("-p, --provider <provider>", "LLM provider (openai, anthropic, google, ollama)").option("-m, --model <model>", "Model to use").option("-k, --api-key <key>", "API key (or use env var)").option("-f, --framework <framework>", "Test framework: vitest, jest, mocha, node").option("-o, --out-dir <dir>", "Output directory for test files").option("--overwrite", "Overwrite existing test files", false).option("--no-edge-cases", "Skip edge case tests").option("--no-error-handling", "Skip error handling tests").option("--instructions <text>", "Additional instructions for the LLM").option("--max-tokens <n>", "Max tokens for LLM response", parseInt).option("--temperature <n>", "Temperature for LLM", parseFloat).option("--dry-run", "Generate tests without writing to disk").option("-s, --stream", "Stream LLM output in real-time", true).option("--verify", "Run tests after generation and auto-fix failures", false).option("--fix-iterations <n>", "Max auto-fix iterations (with --verify)", parseInt).action(async (target, options) => {
1026
+ try {
1027
+ await runGenerate(target, options);
1028
+ } catch (err) {
1029
+ const msg = err instanceof Error ? err.message : String(err);
1030
+ console.error(`
1031
+ ${pc2.red("\u2716")} ${msg}`);
1032
+ process.exit(1);
1033
+ }
1034
+ });
1035
+ program.command("analyze").description("Analyze project for files needing tests (uses coverage data if available)").option("-t, --target <rate>", "Coverage target (0-1)", parseFloat).option("-l, --limit <n>", "Max files to show", parseInt).action(async (options) => {
1036
+ try {
1037
+ await runAnalyze(options);
1038
+ } catch (err) {
1039
+ const msg = err instanceof Error ? err.message : String(err);
1040
+ console.error(`
1041
+ ${pc2.red("\u2716")} ${msg}`);
1042
+ process.exit(1);
1043
+ }
1044
+ });
1045
+ async function runGenerate(target, options) {
1046
+ const resolvedTarget = resolve(target);
1047
+ if (!existsSync(resolvedTarget)) {
1048
+ throw new Error(`Target not found: ${resolvedTarget}`);
1049
+ }
1050
+ const files = collectFiles(resolvedTarget);
1051
+ if (files.length === 0) {
1052
+ throw new Error("No .ts, .tsx, .js, or .jsx files found");
1053
+ }
1054
+ const config = resolveConfig({
1055
+ provider: options.provider,
1056
+ model: options.model,
1057
+ apiKey: options.apiKey,
1058
+ framework: options.framework,
1059
+ outDir: options.outDir,
1060
+ overwrite: options.overwrite,
1061
+ edgeCases: options.edgeCases,
1062
+ errorHandling: options.errorHandling,
1063
+ instructions: options.instructions,
1064
+ maxTokens: options.maxTokens,
1065
+ temperature: options.temperature
1066
+ });
1067
+ printHeader(files, config, options.verify);
1068
+ const results = [];
1069
+ for (const file of files) {
1070
+ const result = await generateForFile(file, config, options);
1071
+ results.push(result);
1072
+ }
1073
+ printSummary(results, options.verify);
1074
+ }
1075
+ function collectFiles(target) {
1076
+ const validExts = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
1077
+ const stat = statSync(target);
1078
+ if (stat.isFile()) {
1079
+ const ext = extname(target);
1080
+ if (!validExts.has(ext)) {
1081
+ throw new Error(`Unsupported file type: ${ext}`);
1082
+ }
1083
+ if (target.includes(".test.") || target.includes(".spec.")) {
1084
+ throw new Error("Target is already a test file");
1085
+ }
1086
+ return [target];
1087
+ }
1088
+ if (stat.isDirectory()) {
1089
+ const files = [];
1090
+ const entries = readdirSync(target, { withFileTypes: true });
1091
+ for (const entry of entries) {
1092
+ if (entry.isFile()) {
1093
+ const ext = extname(entry.name);
1094
+ if (validExts.has(ext) && !entry.name.includes(".test.") && !entry.name.includes(".spec.")) {
1095
+ files.push(join(target, entry.name));
1096
+ }
1097
+ }
1098
+ }
1099
+ return files.sort();
1100
+ }
1101
+ return [];
1102
+ }
1103
+ async function generateForFile(file, config, options) {
1104
+ const fileName = file.split("/").pop() ?? file;
1105
+ console.log(`
1106
+ ${pc2.cyan("\u25CF")} Generating tests for ${pc2.bold(fileName)}...`);
1107
+ if (options.verify) {
1108
+ console.log(` ${pc2.dim("verify & auto-fix enabled")}`);
1109
+ }
1110
+ console.log();
1111
+ let streamOutput = "";
1112
+ const result = await generateTests(file, config, {
1113
+ dryRun: options.dryRun,
1114
+ verify: options.verify,
1115
+ maxFixIterations: options.fixIterations ?? 3,
1116
+ onChunk: options.stream ? (chunk) => {
1117
+ process.stdout.write(pc2.dim(chunk));
1118
+ streamOutput += chunk;
1119
+ } : void 0,
1120
+ onStatus: (msg) => console.log(msg)
1121
+ });
1122
+ if (streamOutput) {
1123
+ process.stdout.write("\n");
1124
+ }
1125
+ const action = options.dryRun ? "would write" : "wrote";
1126
+ console.log(`${pc2.green("\u2714")} ${action} ${pc2.bold(result.testFile)}`);
1127
+ const parts = [
1128
+ `${result.testCount} tests`,
1129
+ `${result.categories.length} groups`,
1130
+ `${result.tokensUsed} tokens`,
1131
+ `${(result.duration / 1e3).toFixed(1)}s`
1132
+ ];
1133
+ if (result.verified !== void 0) {
1134
+ parts.push(
1135
+ result.verified ? pc2.green("\u2714 all tests pass") : pc2.yellow(`\u26A0 ${result.verifyIterations} fix iterations`)
1136
+ );
1137
+ }
1138
+ console.log(` ${pc2.dim(parts.join(" | "))}`);
1139
+ if (result.categories.length > 0) {
1140
+ for (const cat of result.categories) {
1141
+ console.log(` ${pc2.dim("\u251C")} ${cat.name} ${pc2.dim(`(${cat.count} tests)`)}`);
1142
+ }
1143
+ }
1144
+ return result;
1145
+ }
1146
+ async function runAnalyze(options) {
1147
+ const cwd = process.cwd();
1148
+ const targetRate = options.target ?? 0.8;
1149
+ const limit = options.limit ?? 15;
1150
+ console.log(`
1151
+ ${pc2.bold(pc2.magenta("\u26A1 testpilot analyze"))}
1152
+ `);
1153
+ const coverage = loadCoverage(cwd);
1154
+ if (!coverage) {
1155
+ console.log(`${pc2.yellow("\u26A0")} No coverage data found.`);
1156
+ console.log(` ${pc2.dim("Run your tests with coverage first:")}`);
1157
+ console.log(` ${pc2.dim(" npx vitest run --coverage")}`);
1158
+ console.log(` ${pc2.dim(" npx jest --coverage")}`);
1159
+ console.log();
1160
+ console.log(`${pc2.cyan("\u25CF")} Scanning for files without tests...
1161
+ `);
1162
+ const srcFiles = findSourceFilesWithoutTests(cwd);
1163
+ if (srcFiles.length === 0) {
1164
+ console.log(`${pc2.green("\u2714")} All source files have corresponding test files.`);
1165
+ return;
1166
+ }
1167
+ console.log(`Found ${pc2.bold(String(srcFiles.length))} file(s) without tests:
1168
+ `);
1169
+ for (const f of srcFiles.slice(0, limit)) {
1170
+ console.log(` ${pc2.red("\u25CB")} ${f}`);
1171
+ }
1172
+ if (srcFiles.length > limit) {
1173
+ console.log(` ${pc2.dim(`... and ${srcFiles.length - limit} more`)}`);
1174
+ }
1175
+ console.log(`
1176
+ ${pc2.dim("Generate tests:")} testpilot generate <file>
1177
+ `);
1178
+ return;
1179
+ }
1180
+ console.log(` ${pc2.dim("Coverage:")} ${(coverage.overallRate * 100).toFixed(1)}% (${coverage.coveredLines}/${coverage.totalLines} lines)`);
1181
+ console.log(` ${pc2.dim("Target:")} ${(targetRate * 100).toFixed(0)}%`);
1182
+ console.log(` ${pc2.dim("Files:")} ${coverage.files.length}
1183
+ `);
1184
+ const uncovered = getUncoveredFiles(coverage, targetRate).slice(0, limit);
1185
+ if (uncovered.length === 0) {
1186
+ console.log(`${pc2.green("\u2714")} All files meet the ${(targetRate * 100).toFixed(0)}% coverage target!`);
1187
+ return;
1188
+ }
1189
+ console.log(`${pc2.bold("Files below target:")}
1190
+ `);
1191
+ console.log(` ${pc2.dim("File".padEnd(50))} ${pc2.dim("Coverage".padEnd(10))} ${pc2.dim("Tests?")}`);
1192
+ console.log(` ${pc2.dim("\u2500".repeat(70))}`);
1193
+ for (const file of uncovered) {
1194
+ const covStr = `${(file.lineRate * 100).toFixed(1)}%`.padEnd(10);
1195
+ const testStr = file.hasTests ? pc2.green("yes") : pc2.red("no");
1196
+ const color = file.lineRate < 0.3 ? pc2.red : file.lineRate < 0.6 ? pc2.yellow : pc2.white;
1197
+ console.log(` ${color(file.relativePath.padEnd(50))} ${covStr} ${testStr}`);
1198
+ }
1199
+ console.log(`
1200
+ ${pc2.dim("Generate tests:")} testpilot generate <file> --verify
1201
+ `);
1202
+ }
1203
+ function findSourceFilesWithoutTests(cwd) {
1204
+ const validExts = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
1205
+ const results = [];
1206
+ function scan(dir) {
1207
+ try {
1208
+ const entries = readdirSync(dir, { withFileTypes: true });
1209
+ for (const entry of entries) {
1210
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".git" || entry.name === "docs") continue;
1211
+ const fullPath = join(dir, entry.name);
1212
+ if (entry.isDirectory()) {
1213
+ scan(fullPath);
1214
+ } else if (entry.isFile()) {
1215
+ const ext = extname(entry.name);
1216
+ if (!validExts.has(ext)) continue;
1217
+ if (entry.name.includes(".test.") || entry.name.includes(".spec.")) continue;
1218
+ if (entry.name.includes(".d.ts")) continue;
1219
+ const base = entry.name.replace(/\.(ts|tsx|js|jsx)$/, "");
1220
+ const testNames = [
1221
+ `${base}.test${ext}`,
1222
+ `${base}.spec${ext}`
1223
+ ];
1224
+ const hasTest = testNames.some((t) => existsSync(join(dir, t)));
1225
+ if (!hasTest) {
1226
+ const relPath = fullPath.slice(cwd.length + 1);
1227
+ results.push(relPath);
1228
+ }
1229
+ }
1230
+ }
1231
+ } catch {
1232
+ }
1233
+ }
1234
+ scan(cwd);
1235
+ return results.sort();
1236
+ }
1237
+ function printHeader(files, config, verify) {
1238
+ console.log(`
1239
+ ${pc2.bold(pc2.magenta("\u26A1 testpilot"))} \u2014 AI-powered test generation
1240
+ `);
1241
+ console.log(` ${pc2.dim("Provider:")} ${config.provider}${config.model ? ` (${config.model})` : ""}`);
1242
+ console.log(` ${pc2.dim("Framework:")} ${config.framework}`);
1243
+ console.log(` ${pc2.dim("Files:")} ${files.length}`);
1244
+ console.log(` ${pc2.dim("Edge cases:")} ${config.edgeCases ? "yes" : "no"}`);
1245
+ console.log(` ${pc2.dim("Error handling:")} ${config.errorHandling ? "yes" : "no"}`);
1246
+ if (verify) {
1247
+ console.log(` ${pc2.dim("Verify & fix:")} ${pc2.green("enabled")}`);
1248
+ }
1249
+ }
1250
+ function printSummary(results, verify) {
1251
+ const totalTests = results.reduce((n, r) => n + r.testCount, 0);
1252
+ const totalTokens = results.reduce((n, r) => n + r.tokensUsed, 0);
1253
+ const totalDuration = results.reduce((n, r) => n + r.duration, 0);
1254
+ console.log(`
1255
+ ${pc2.bold(pc2.green("Done!"))} Generated ${pc2.bold(String(totalTests))} tests across ${pc2.bold(String(results.length))} file(s)`);
1256
+ if (verify) {
1257
+ const allPassed = results.every((r) => r.verified);
1258
+ const passedCount = results.filter((r) => r.verified).length;
1259
+ if (allPassed) {
1260
+ console.log(`${pc2.green("\u2714")} All tests verified and passing`);
1261
+ } else {
1262
+ console.log(`${pc2.yellow("\u26A0")} ${passedCount}/${results.length} files fully verified`);
1263
+ }
1264
+ }
1265
+ console.log(`${pc2.dim(`Total: ${totalTokens} tokens | ${(totalDuration / 1e3).toFixed(1)}s`)}
1266
+ `);
1267
+ }
1268
+ program.parse();
1269
+ //# sourceMappingURL=cli.js.map
1270
+ //# sourceMappingURL=cli.js.map