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