@theihtisham/ai-testgen 1.0.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.
Files changed (149) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -0
  3. package/dist/analyzers/analyzer.d.ts +10 -0
  4. package/dist/analyzers/analyzer.d.ts.map +1 -0
  5. package/dist/analyzers/analyzer.js +131 -0
  6. package/dist/analyzers/analyzer.js.map +1 -0
  7. package/dist/analyzers/go-analyzer.d.ts +3 -0
  8. package/dist/analyzers/go-analyzer.d.ts.map +1 -0
  9. package/dist/analyzers/go-analyzer.js +244 -0
  10. package/dist/analyzers/go-analyzer.js.map +1 -0
  11. package/dist/analyzers/index.d.ts +5 -0
  12. package/dist/analyzers/index.d.ts.map +1 -0
  13. package/dist/analyzers/index.js +15 -0
  14. package/dist/analyzers/index.js.map +1 -0
  15. package/dist/analyzers/js-ts-analyzer.d.ts +3 -0
  16. package/dist/analyzers/js-ts-analyzer.d.ts.map +1 -0
  17. package/dist/analyzers/js-ts-analyzer.js +299 -0
  18. package/dist/analyzers/js-ts-analyzer.js.map +1 -0
  19. package/dist/analyzers/python-analyzer.d.ts +3 -0
  20. package/dist/analyzers/python-analyzer.d.ts.map +1 -0
  21. package/dist/analyzers/python-analyzer.js +306 -0
  22. package/dist/analyzers/python-analyzer.js.map +1 -0
  23. package/dist/cli.d.ts +3 -0
  24. package/dist/cli.d.ts.map +1 -0
  25. package/dist/cli.js +381 -0
  26. package/dist/cli.js.map +1 -0
  27. package/dist/config/defaults.d.ts +6 -0
  28. package/dist/config/defaults.d.ts.map +1 -0
  29. package/dist/config/defaults.js +80 -0
  30. package/dist/config/defaults.js.map +1 -0
  31. package/dist/config/index.d.ts +3 -0
  32. package/dist/config/index.d.ts.map +1 -0
  33. package/dist/config/index.js +14 -0
  34. package/dist/config/index.js.map +1 -0
  35. package/dist/config/loader.d.ts +6 -0
  36. package/dist/config/loader.d.ts.map +1 -0
  37. package/dist/config/loader.js +126 -0
  38. package/dist/config/loader.js.map +1 -0
  39. package/dist/coverage.d.ts +4 -0
  40. package/dist/coverage.d.ts.map +1 -0
  41. package/dist/coverage.js +108 -0
  42. package/dist/coverage.js.map +1 -0
  43. package/dist/generators/ai-generator.d.ts +4 -0
  44. package/dist/generators/ai-generator.d.ts.map +1 -0
  45. package/dist/generators/ai-generator.js +175 -0
  46. package/dist/generators/ai-generator.js.map +1 -0
  47. package/dist/generators/generator.d.ts +4 -0
  48. package/dist/generators/generator.d.ts.map +1 -0
  49. package/dist/generators/generator.js +121 -0
  50. package/dist/generators/generator.js.map +1 -0
  51. package/dist/generators/go-generator.d.ts +3 -0
  52. package/dist/generators/go-generator.d.ts.map +1 -0
  53. package/dist/generators/go-generator.js +175 -0
  54. package/dist/generators/go-generator.js.map +1 -0
  55. package/dist/generators/index.d.ts +6 -0
  56. package/dist/generators/index.d.ts.map +1 -0
  57. package/dist/generators/index.js +16 -0
  58. package/dist/generators/index.js.map +1 -0
  59. package/dist/generators/js-ts-generator.d.ts +3 -0
  60. package/dist/generators/js-ts-generator.d.ts.map +1 -0
  61. package/dist/generators/js-ts-generator.js +331 -0
  62. package/dist/generators/js-ts-generator.js.map +1 -0
  63. package/dist/generators/python-generator.d.ts +3 -0
  64. package/dist/generators/python-generator.d.ts.map +1 -0
  65. package/dist/generators/python-generator.js +180 -0
  66. package/dist/generators/python-generator.js.map +1 -0
  67. package/dist/incremental.d.ts +16 -0
  68. package/dist/incremental.d.ts.map +1 -0
  69. package/dist/incremental.js +146 -0
  70. package/dist/incremental.js.map +1 -0
  71. package/dist/index.d.ts +9 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +44 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/mutation/index.d.ts +2 -0
  76. package/dist/mutation/index.d.ts.map +1 -0
  77. package/dist/mutation/index.js +9 -0
  78. package/dist/mutation/index.js.map +1 -0
  79. package/dist/mutation/mutator.d.ts +6 -0
  80. package/dist/mutation/mutator.d.ts.map +1 -0
  81. package/dist/mutation/mutator.js +237 -0
  82. package/dist/mutation/mutator.js.map +1 -0
  83. package/dist/types.d.ts +199 -0
  84. package/dist/types.d.ts.map +1 -0
  85. package/dist/types.js +4 -0
  86. package/dist/types.js.map +1 -0
  87. package/dist/utils/file.d.ts +10 -0
  88. package/dist/utils/file.d.ts.map +1 -0
  89. package/dist/utils/file.js +108 -0
  90. package/dist/utils/file.js.map +1 -0
  91. package/dist/utils/index.d.ts +4 -0
  92. package/dist/utils/index.d.ts.map +1 -0
  93. package/dist/utils/index.js +24 -0
  94. package/dist/utils/index.js.map +1 -0
  95. package/dist/utils/language.d.ts +8 -0
  96. package/dist/utils/language.d.ts.map +1 -0
  97. package/dist/utils/language.js +137 -0
  98. package/dist/utils/language.js.map +1 -0
  99. package/dist/utils/logger.d.ts +13 -0
  100. package/dist/utils/logger.d.ts.map +1 -0
  101. package/dist/utils/logger.js +57 -0
  102. package/dist/utils/logger.js.map +1 -0
  103. package/dist/watcher/index.d.ts +2 -0
  104. package/dist/watcher/index.d.ts.map +1 -0
  105. package/dist/watcher/index.js +6 -0
  106. package/dist/watcher/index.js.map +1 -0
  107. package/dist/watcher/watcher.d.ts +19 -0
  108. package/dist/watcher/watcher.d.ts.map +1 -0
  109. package/dist/watcher/watcher.js +122 -0
  110. package/dist/watcher/watcher.js.map +1 -0
  111. package/package.json +63 -0
  112. package/src/analyzers/analyzer.ts +180 -0
  113. package/src/analyzers/go-analyzer.ts +235 -0
  114. package/src/analyzers/index.ts +4 -0
  115. package/src/analyzers/js-ts-analyzer.ts +324 -0
  116. package/src/analyzers/python-analyzer.ts +306 -0
  117. package/src/cli.ts +416 -0
  118. package/src/config/defaults.ts +81 -0
  119. package/src/config/index.ts +2 -0
  120. package/src/config/loader.ts +114 -0
  121. package/src/coverage.ts +128 -0
  122. package/src/generators/ai-generator.ts +170 -0
  123. package/src/generators/generator.ts +117 -0
  124. package/src/generators/go-generator.ts +183 -0
  125. package/src/generators/index.ts +5 -0
  126. package/src/generators/js-ts-generator.ts +379 -0
  127. package/src/generators/python-generator.ts +201 -0
  128. package/src/incremental.ts +131 -0
  129. package/src/index.ts +8 -0
  130. package/src/mutation/index.ts +1 -0
  131. package/src/mutation/mutator.ts +314 -0
  132. package/src/types.ts +240 -0
  133. package/src/utils/file.ts +73 -0
  134. package/src/utils/index.ts +3 -0
  135. package/src/utils/language.ts +114 -0
  136. package/src/utils/logger.ts +61 -0
  137. package/src/watcher/index.ts +1 -0
  138. package/src/watcher/watcher.ts +103 -0
  139. package/tests/analyzer.test.ts +429 -0
  140. package/tests/config.test.ts +171 -0
  141. package/tests/coverage.test.ts +197 -0
  142. package/tests/file-utils.test.ts +121 -0
  143. package/tests/generators.test.ts +383 -0
  144. package/tests/incremental.test.ts +108 -0
  145. package/tests/language.test.ts +90 -0
  146. package/tests/mutation.test.ts +286 -0
  147. package/tests/watcher.test.ts +35 -0
  148. package/tsconfig.json +26 -0
  149. package/vitest.config.ts +25 -0
@@ -0,0 +1,379 @@
1
+ import {
2
+ SourceAnalysis,
3
+ TestSuite,
4
+ TestCase,
5
+ MockDefinition,
6
+ TestFramework,
7
+ AnalyzedFunction,
8
+ AnalyzedClass,
9
+ } from '../types.js';
10
+ import { detectEdgeCases, detectMocks } from '../analyzers/analyzer.js';
11
+
12
+ export function generateJsTsTestSuite(
13
+ analysis: SourceAnalysis,
14
+ framework: TestFramework,
15
+ maxTestsPerFunction: number,
16
+ ): TestSuite {
17
+ const testCases: TestCase[] = [];
18
+ const mocks = detectMocks(analysis);
19
+
20
+ for (const fn of analysis.functions) {
21
+ if (!fn.isExported) continue;
22
+ testCases.push(...generateFunctionUnitTests(fn, framework));
23
+ if (testCases.length > maxTestsPerFunction * analysis.functions.length) break;
24
+ }
25
+
26
+ for (const cls of analysis.classes) {
27
+ testCases.push(...generateClassUnitTests(cls, framework));
28
+ }
29
+
30
+ for (const fn of analysis.functions) {
31
+ if (!fn.isExported) continue;
32
+ const edgeCases = detectEdgeCases(fn).slice(0, 3);
33
+ testCases.push(...edgeCases);
34
+ }
35
+
36
+ const imports = generateImports(analysis, framework, mocks);
37
+ const setupCode = generateSetup(mocks, framework);
38
+ const teardownCode = generateTeardown(mocks, framework);
39
+
40
+ const code = renderTestSuite(analysis, testCases, imports, setupCode, teardownCode);
41
+
42
+ return {
43
+ filePath: '',
44
+ sourceFilePath: analysis.filePath,
45
+ language: analysis.language,
46
+ framework,
47
+ testCases,
48
+ mocks,
49
+ imports: [code],
50
+ setupCode,
51
+ teardownCode,
52
+ coverageEstimate: estimateCoverage(analysis, testCases),
53
+ };
54
+ }
55
+
56
+ function generateFunctionUnitTests(fn: AnalyzedFunction, _framework: TestFramework): TestCase[] {
57
+ const cases: TestCase[] = [];
58
+
59
+ cases.push({
60
+ name: `${fn.name} returns expected result for valid input`,
61
+ type: 'unit',
62
+ description: `Test basic invocation of ${fn.name}`,
63
+ code: generateFunctionTestCode(fn, 'happy-path'),
64
+ expectedBehavior: 'Returns the expected result',
65
+ inputDescription: 'Valid input parameters',
66
+ tags: ['unit', 'happy-path'],
67
+ });
68
+
69
+ if (fn.returnType) {
70
+ cases.push({
71
+ name: `${fn.name} returns correct type`,
72
+ type: 'unit',
73
+ description: `Verify return type of ${fn.name}`,
74
+ code: generateFunctionTestCode(fn, 'type-check'),
75
+ expectedBehavior: `Return value should be of type ${fn.returnType}`,
76
+ inputDescription: 'Standard input',
77
+ tags: ['unit', 'type-check'],
78
+ });
79
+ }
80
+
81
+ if (fn.isAsync) {
82
+ cases.push({
83
+ name: `${fn.name} resolves with expected value`,
84
+ type: 'unit',
85
+ description: `Test that ${fn.name} async resolves correctly`,
86
+ code: generateFunctionTestCode(fn, 'async-resolve'),
87
+ expectedBehavior: 'Promise resolves with expected value',
88
+ inputDescription: 'Valid input',
89
+ tags: ['unit', 'async'],
90
+ });
91
+ }
92
+
93
+ if (fn.throws.length > 0) {
94
+ cases.push({
95
+ name: `${fn.name} throws on invalid input`,
96
+ type: 'unit',
97
+ description: `Test that ${fn.name} throws for invalid input`,
98
+ code: generateFunctionTestCode(fn, 'throws'),
99
+ expectedBehavior: 'Should throw an error',
100
+ inputDescription: 'Invalid input',
101
+ tags: ['unit', 'error'],
102
+ });
103
+ }
104
+
105
+ return cases;
106
+ }
107
+
108
+ function generateClassUnitTests(cls: AnalyzedClass, _framework: TestFramework): TestCase[] {
109
+ const cases: TestCase[] = [];
110
+
111
+ cases.push({
112
+ name: `${cls.name} can be instantiated`,
113
+ type: 'unit',
114
+ description: `Test ${cls.name} constructor`,
115
+ code: generateClassTestCode(cls, 'constructor'),
116
+ expectedBehavior: 'Instance should be created successfully',
117
+ inputDescription: 'Constructor arguments',
118
+ tags: ['unit', 'constructor'],
119
+ });
120
+
121
+ for (const method of cls.methods.slice(0, 5)) {
122
+ cases.push({
123
+ name: `${cls.name}.${method.name} works correctly`,
124
+ type: 'unit',
125
+ description: `Test ${cls.name}.${method.name} method`,
126
+ code: generateClassTestCode(cls, method.name),
127
+ expectedBehavior: 'Method should work as expected',
128
+ inputDescription: `Valid input for ${method.name}`,
129
+ tags: ['unit', 'method'],
130
+ });
131
+
132
+ if (method.isAsync) {
133
+ cases.push({
134
+ name: `${cls.name}.${method.name} handles async correctly`,
135
+ type: 'unit',
136
+ description: `Test async behavior of ${cls.name}.${method.name}`,
137
+ code: generateClassTestCode(cls, `${method.name}-async`),
138
+ expectedBehavior: 'Should resolve/reject correctly',
139
+ inputDescription: 'Valid input for async method',
140
+ tags: ['unit', 'async'],
141
+ });
142
+ }
143
+ }
144
+
145
+ return cases;
146
+ }
147
+
148
+ function generateFunctionTestCode(fn: AnalyzedFunction, testType: string): string {
149
+ const args = fn.params
150
+ .filter((p) => p.name !== 'self' && p.name !== 'cls')
151
+ .map((p) => generateMockValue(p))
152
+ .join(', ');
153
+
154
+ switch (testType) {
155
+ case 'happy-path':
156
+ if (fn.isAsync) {
157
+ return `const result = await ${fn.name}(${args});\n expect(result).toBeDefined();`;
158
+ }
159
+ return `const result = ${fn.name}(${args});\n expect(result).toBeDefined();`;
160
+
161
+ case 'type-check':
162
+ return `const result = ${fn.isAsync ? 'await ' : ''}${fn.name}(${args});\n expect(typeof result).toBeDefined();`;
163
+
164
+ case 'async-resolve':
165
+ return `await expect(${fn.name}(${args})).resolves.toBeDefined();`;
166
+
167
+ case 'throws':
168
+ return `expect(() => ${fn.name}(${generateInvalidArgs(fn)})).toThrow();`;
169
+
170
+ default:
171
+ return `const result = ${fn.isAsync ? 'await ' : ''}${fn.name}(${args});\n expect(result).toBeDefined();`;
172
+ }
173
+ }
174
+
175
+ function generateClassTestCode(cls: AnalyzedClass, methodOrType: string): string {
176
+ const constructorArgs = cls.constructorParams
177
+ .map((p) => generateMockValue(p))
178
+ .join(', ');
179
+
180
+ if (methodOrType === 'constructor') {
181
+ return `const instance = new ${cls.name}(${constructorArgs});\n expect(instance).toBeInstanceOf(${cls.name});`;
182
+ }
183
+
184
+ const method = cls.methods.find((m) => m.name === methodOrType);
185
+ if (!method) {
186
+ return `const instance = new ${cls.name}(${constructorArgs});\n expect(instance).toBeDefined();`;
187
+ }
188
+
189
+ const methodArgs = method.params
190
+ .map((p) => generateMockValue(p))
191
+ .join(', ');
192
+ const awaitPrefix = method.isAsync ? 'await ' : '';
193
+
194
+ return `const instance = new ${cls.name}(${constructorArgs});\n const result = ${awaitPrefix}instance.${method.name}(${methodArgs});\n expect(result).toBeDefined();`;
195
+ }
196
+
197
+ function generateMockValue(param: { type: string | null; name: string }): string {
198
+ if (!param.type) return `'${param.name}-value'`;
199
+
200
+ const t = param.type;
201
+
202
+ if (t.includes('string') || t.includes('String')) return `'test-${param.name}'`;
203
+ if (t.includes('number') || t.includes('Number')) return '42';
204
+ if (t.includes('boolean') || t.includes('Boolean')) return 'true';
205
+ if (t.includes('Array') || t.includes('[]')) return '[]';
206
+ if (t.includes('Promise')) return 'Promise.resolve({})';
207
+ if (t.includes('Date')) return 'new Date()';
208
+ if (t.includes('Record') || t.includes('{') || t.includes('object')) return '{}';
209
+ if (t.includes('null')) return 'null';
210
+ if (t.includes('undefined')) return 'undefined';
211
+ if (t.includes('void')) return 'undefined';
212
+ if (t.includes('Function') || t.includes('() =>')) return 'jest.fn()';
213
+ if (t.includes('RegExp')) return '/test/';
214
+ if (t.includes('Map')) return 'new Map()';
215
+ if (t.includes('Set')) return 'new Set()';
216
+ if (t.includes('Error')) return "new Error('test error')";
217
+ if (t.includes('Buffer')) return "Buffer.from('test')";
218
+
219
+ return '{} as any';
220
+ }
221
+
222
+ function generateInvalidArgs(fn: AnalyzedFunction): string {
223
+ return fn.params
224
+ .map((p) => {
225
+ if (p.type?.includes('string')) return '123';
226
+ if (p.type?.includes('number')) return "'not-a-number'";
227
+ if (p.type?.includes('boolean')) return "'not-a-bool'";
228
+ if (p.type?.includes('Array') || p.type?.includes('[]')) return "'not-an-array'";
229
+ return 'null';
230
+ })
231
+ .join(', ');
232
+ }
233
+
234
+ function generateImports(
235
+ analysis: SourceAnalysis,
236
+ framework: TestFramework,
237
+ mocks: MockDefinition[],
238
+ ): string {
239
+ const lines: string[] = [];
240
+
241
+ if (framework === 'jest') {
242
+ lines.push("import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';");
243
+ } else {
244
+ lines.push("import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';");
245
+ }
246
+
247
+ for (const mock of mocks) {
248
+ if (framework === 'jest') {
249
+ lines.push(`jest.mock('${mock.moduleName}');`);
250
+ } else {
251
+ lines.push(`vi.mock('${mock.moduleName}');`);
252
+ }
253
+ }
254
+
255
+ const sourcePath = './' + analysis.filePath.replace(/\.ts$/, '').replace(/\.js$/, '');
256
+ const exportedNames = analysis.functions
257
+ .filter((f) => f.isExported)
258
+ .map((f) => f.name);
259
+ const classNames = analysis.classes.map((c) => c.name);
260
+
261
+ const allImports = [...exportedNames, ...classNames];
262
+ if (allImports.length > 0) {
263
+ lines.push(`import { ${allImports.join(', ')} } from '${sourcePath}';`);
264
+ } else {
265
+ lines.push(`import * as subject from '${sourcePath}';`);
266
+ }
267
+
268
+ return lines.join('\n');
269
+ }
270
+
271
+ function generateSetup(mocks: MockDefinition[], framework: TestFramework): string {
272
+ if (mocks.length === 0) return '';
273
+
274
+ const fn = framework === 'jest' ? 'jest' : 'vi';
275
+ const lines: string[] = [];
276
+
277
+ for (const mock of mocks) {
278
+ for (const implValue of Object.values(mock.implementations)) {
279
+ lines.push(`const ${mock.mockName} = ${fn}.fn()${implValue.includes('jest.fn()') ? '' : ''};`);
280
+ }
281
+ }
282
+
283
+ return lines.join('\n ');
284
+ }
285
+
286
+ function generateTeardown(mocks: MockDefinition[], framework: TestFramework): string {
287
+ if (mocks.length === 0) return '';
288
+ const fn = framework === 'jest' ? 'jest' : 'vi';
289
+ return `${fn}.clearAllMocks();`;
290
+ }
291
+
292
+ function renderTestSuite(
293
+ analysis: SourceAnalysis,
294
+ testCases: TestCase[],
295
+ imports: string,
296
+ setupCode: string,
297
+ teardownCode: string,
298
+ ): string {
299
+ const lines: string[] = [];
300
+
301
+ lines.push('/**');
302
+ lines.push(` * Auto-generated test suite for ${analysis.filePath}`);
303
+ lines.push(' * Generated by AI-TestGen');
304
+ lines.push(` * ${testCases.length} test cases | ~${estimateCoverage(analysis, testCases)}% coverage estimate`);
305
+ lines.push(' */');
306
+ lines.push('');
307
+
308
+ lines.push(imports);
309
+ lines.push('');
310
+
311
+ lines.push(`describe('${pathBasename(analysis.filePath)}', () => {`);
312
+
313
+ if (setupCode.trim()) {
314
+ lines.push(' beforeEach(() => {');
315
+ lines.push(` ${setupCode}`);
316
+ lines.push(' });');
317
+ lines.push('');
318
+ }
319
+
320
+ if (teardownCode.trim()) {
321
+ lines.push(' afterEach(() => {');
322
+ lines.push(` ${teardownCode}`);
323
+ lines.push(' });');
324
+ lines.push('');
325
+ }
326
+
327
+ const grouped = groupTestsByTarget(testCases);
328
+
329
+ for (const [target, tests] of grouped) {
330
+ lines.push(` describe('${target}', () => {`);
331
+ for (const test of tests) {
332
+ lines.push(` it('${test.name}', () => {`);
333
+ if (test.code) {
334
+ lines.push(` ${test.code}`);
335
+ } else {
336
+ lines.push(` // TODO: Implement test for: ${test.inputDescription}`);
337
+ lines.push(` // Expected: ${test.expectedBehavior}`);
338
+ lines.push(` expect(true).toBe(true); // placeholder`);
339
+ }
340
+ lines.push(' });');
341
+ lines.push('');
342
+ }
343
+ lines.push(' });');
344
+ lines.push('');
345
+ }
346
+
347
+ lines.push('});');
348
+
349
+ return lines.join('\n');
350
+ }
351
+
352
+ function groupTestsByTarget(testCases: TestCase[]): Map<string, TestCase[]> {
353
+ const groups = new Map<string, TestCase[]>();
354
+ for (const tc of testCases) {
355
+ const target = tc.name.split(' ')[0] ?? 'general';
356
+ const existing = groups.get(target) ?? [];
357
+ existing.push(tc);
358
+ groups.set(target, existing);
359
+ }
360
+ return groups;
361
+ }
362
+
363
+ function pathBasename(filePath: string): string {
364
+ const parts = filePath.replace(/\\/g, '/').split('/');
365
+ return parts[parts.length - 1] ?? filePath;
366
+ }
367
+
368
+ function estimateCoverage(analysis: SourceAnalysis, testCases: TestCase[]): number {
369
+ const exportedFunctions = analysis.functions.filter((f) => f.isExported).length;
370
+ const classMethods = analysis.classes.reduce((sum, cls) => sum + cls.methods.length, 0);
371
+ const totalTestable = exportedFunctions + classMethods;
372
+ if (totalTestable === 0) return 0;
373
+
374
+ const uniqueTargets = new Set(testCases.map((tc) => tc.name.split(' ')[0]));
375
+ const coveredRatio = Math.min(uniqueTargets.size / totalTestable, 1);
376
+ const edgeCaseBonus = testCases.filter((tc) => tc.type === 'edge-case').length > 0 ? 5 : 0;
377
+
378
+ return Math.min(Math.round(coveredRatio * 80 + edgeCaseBonus + 10), 100);
379
+ }
@@ -0,0 +1,201 @@
1
+ import {
2
+ SourceAnalysis,
3
+ TestSuite,
4
+ TestCase,
5
+ TestFramework,
6
+ AnalyzedFunction,
7
+ AnalyzedClass,
8
+ } from '../types.js';
9
+ import { detectEdgeCases, detectMocks } from '../analyzers/analyzer.js';
10
+
11
+ export function generatePythonTestSuite(
12
+ analysis: SourceAnalysis,
13
+ _framework: TestFramework,
14
+ maxTestsPerFunction: number,
15
+ ): TestSuite {
16
+ const testCases: TestCase[] = [];
17
+ const mocks = detectMocks(analysis);
18
+
19
+ // Generate unit tests for each function
20
+ for (const fn of analysis.functions) {
21
+ if (!fn.isExported) continue;
22
+ testCases.push(...generatePythonFunctionTests(fn));
23
+ if (testCases.length > maxTestsPerFunction * analysis.functions.length) break;
24
+ }
25
+
26
+ // Generate class tests
27
+ for (const cls of analysis.classes) {
28
+ testCases.push(...generatePythonClassTests(cls));
29
+ }
30
+
31
+ // Generate edge case tests
32
+ for (const fn of analysis.functions) {
33
+ if (!fn.isExported) continue;
34
+ const edgeCases = detectEdgeCases(fn).slice(0, 3);
35
+ testCases.push(...edgeCases);
36
+ }
37
+
38
+ const code = renderPythonTestSuite(analysis, testCases, mocks);
39
+
40
+ return {
41
+ filePath: '',
42
+ sourceFilePath: analysis.filePath,
43
+ language: 'python',
44
+ framework: 'pytest',
45
+ testCases,
46
+ mocks,
47
+ imports: [code],
48
+ setupCode: '',
49
+ teardownCode: '',
50
+ coverageEstimate: estimatePythonCoverage(analysis, testCases),
51
+ };
52
+ }
53
+
54
+ function generatePythonFunctionTests(fn: AnalyzedFunction): TestCase[] {
55
+ const cases: TestCase[] = [];
56
+
57
+ cases.push({
58
+ name: `test_${fn.name}_returns_expected`,
59
+ type: 'unit',
60
+ description: `Test basic invocation of ${fn.name}`,
61
+ code: `def test_${fn.name}_returns_expected():\n result = ${fn.name}(${generatePythonArgs(fn)})\n assert result is not None`,
62
+ expectedBehavior: 'Returns expected result',
63
+ inputDescription: 'Valid input',
64
+ tags: ['unit'],
65
+ });
66
+
67
+ if (fn.isAsync) {
68
+ cases.push({
69
+ name: `test_${fn.name}_async`,
70
+ type: 'unit',
71
+ description: `Test async ${fn.name}`,
72
+ code: `import pytest\n\n@pytest.mark.asyncio\nasync def test_${fn.name}_async():\n result = await ${fn.name}(${generatePythonArgs(fn)})\n assert result is not None`,
73
+ expectedBehavior: 'Async resolves correctly',
74
+ inputDescription: 'Valid input',
75
+ tags: ['unit', 'async'],
76
+ });
77
+ }
78
+
79
+ if (fn.throws.length > 0) {
80
+ cases.push({
81
+ name: `test_${fn.name}_raises_error`,
82
+ type: 'unit',
83
+ description: `Test ${fn.name} raises on invalid input`,
84
+ code: `def test_${fn.name}_raises_error():\n with pytest.raises(Exception):\n ${fn.name}(${generatePythonInvalidArgs(fn)})`,
85
+ expectedBehavior: 'Raises expected exception',
86
+ inputDescription: 'Invalid input',
87
+ tags: ['unit', 'error'],
88
+ });
89
+ }
90
+
91
+ return cases;
92
+ }
93
+
94
+ function generatePythonClassTests(cls: AnalyzedClass): TestCase[] {
95
+ const cases: TestCase[] = [];
96
+
97
+ cases.push({
98
+ name: `test_${cls.name}_instantiation`,
99
+ type: 'unit',
100
+ description: `Test ${cls.name} can be instantiated`,
101
+ code: `def test_${cls.name}_instantiation():\n instance = ${cls.name}(${cls.constructorParams.map(() => 'None').join(', ')})\n assert instance is not None`,
102
+ expectedBehavior: 'Instance is created',
103
+ inputDescription: 'Constructor args',
104
+ tags: ['unit', 'constructor'],
105
+ });
106
+
107
+ for (const method of cls.methods.slice(0, 5)) {
108
+ if (method.name.startsWith('_')) continue;
109
+ cases.push({
110
+ name: `test_${cls.name}_${method.name}`,
111
+ type: 'unit',
112
+ description: `Test ${cls.name}.${method.name}`,
113
+ code: `def test_${cls.name}_${method.name}():\n instance = ${cls.name}()\n result = instance.${method.name}(${method.params.map(() => 'None').join(', ')})\n assert result is not None`,
114
+ expectedBehavior: 'Method works correctly',
115
+ inputDescription: 'Valid input',
116
+ tags: ['unit', 'method'],
117
+ });
118
+ }
119
+
120
+ return cases;
121
+ }
122
+
123
+ function generatePythonArgs(fn: AnalyzedFunction): string {
124
+ return fn.params
125
+ .map((p) => {
126
+ if (!p.type) return "'test'";
127
+ if (p.type.includes('str')) return "'test'";
128
+ if (p.type.includes('int') || p.type.includes('float')) return '42';
129
+ if (p.type.includes('bool')) return 'True';
130
+ if (p.type.includes('list') || p.type.includes('List')) return '[]';
131
+ if (p.type.includes('dict') || p.type.includes('Dict')) return '{}';
132
+ return 'None';
133
+ })
134
+ .join(', ');
135
+ }
136
+
137
+ function generatePythonInvalidArgs(fn: AnalyzedFunction): string {
138
+ return fn.params
139
+ .map((p) => {
140
+ if (p.type?.includes('int')) return "'not-a-number'";
141
+ if (p.type?.includes('str')) return '12345';
142
+ if (p.type?.includes('list')) return "'not-a-list'";
143
+ return 'None';
144
+ })
145
+ .join(', ');
146
+ }
147
+
148
+ function renderPythonTestSuite(
149
+ analysis: SourceAnalysis,
150
+ testCases: TestCase[],
151
+ _mocks: import('../types.js').MockDefinition[],
152
+ ): string {
153
+ const lines: string[] = [];
154
+ const baseName = analysis.filePath.replace(/\.py$/, '').split('/').pop() ?? 'module';
155
+
156
+ lines.push('"""');
157
+ lines.push(`Auto-generated test suite for ${analysis.filePath}`);
158
+ lines.push('Generated by AI-TestGen');
159
+ lines.push(` ${testCases.length} test cases`);
160
+ lines.push('"""');
161
+ lines.push('');
162
+ lines.push('import pytest');
163
+ lines.push(`from ${baseName} import *`);
164
+ lines.push('');
165
+
166
+ // Group by target
167
+ const grouped = new Map<string, TestCase[]>();
168
+ for (const tc of testCases) {
169
+ const target = tc.name.split('_').slice(0, 3).join('_') ?? 'general';
170
+ const existing = grouped.get(target) ?? [];
171
+ existing.push(tc);
172
+ grouped.set(target, existing);
173
+ }
174
+
175
+ for (const [_target, tests] of grouped) {
176
+ for (const test of tests) {
177
+ if (test.code) {
178
+ lines.push(test.code);
179
+ } else {
180
+ lines.push(`def ${test.name}():`);
181
+ lines.push(` # ${test.description}`);
182
+ lines.push(` # Expected: ${test.expectedBehavior}`);
183
+ lines.push(` assert True # placeholder`);
184
+ }
185
+ lines.push('');
186
+ }
187
+ }
188
+
189
+ return lines.join('\n');
190
+ }
191
+
192
+ function estimatePythonCoverage(analysis: SourceAnalysis, testCases: TestCase[]): number {
193
+ const exportedFunctions = analysis.functions.filter((f) => f.isExported).length;
194
+ const classMethods = analysis.classes.reduce((sum, cls) => sum + cls.methods.length, 0);
195
+ const totalTestable = exportedFunctions + classMethods;
196
+ if (totalTestable === 0) return 0;
197
+
198
+ const uniqueTargets = new Set(testCases.map((tc) => tc.name));
199
+ const coveredRatio = Math.min(uniqueTargets.size / totalTestable, 1);
200
+ return Math.min(Math.round(coveredRatio * 75 + 15), 100);
201
+ }
@@ -0,0 +1,131 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { execSync } from 'child_process';
4
+ import { TestGenConfig } from './types.js';
5
+ import { ensureDir, readFile, writeFile, getFileHash } from './utils/file.js';
6
+ import { logger } from './utils/logger.js';
7
+
8
+ interface CacheEntry {
9
+ filePath: string;
10
+ hash: string;
11
+ lastGenerated: number;
12
+ testFilePath: string;
13
+ }
14
+
15
+ export class IncrementalCache {
16
+ private cacheDir: string;
17
+ private cache: Map<string, CacheEntry> = new Map();
18
+ private gitBased: boolean;
19
+
20
+ constructor(config: TestGenConfig) {
21
+ this.cacheDir = path.resolve(config.incremental.cacheDir);
22
+ this.gitBased = config.incremental.gitBased;
23
+ }
24
+
25
+ async initialize(): Promise<void> {
26
+ ensureDir(this.cacheDir);
27
+ this.loadCache();
28
+ }
29
+
30
+ getChangedFiles(allFiles: string[]): string[] {
31
+ if (this.gitBased) {
32
+ return this.getGitChangedFiles(allFiles);
33
+ }
34
+ return this.getHashChangedFiles(allFiles);
35
+ }
36
+
37
+ private getGitChangedFiles(allFiles: string[]): string[] {
38
+ try {
39
+ const output = execSync('git diff --name-only HEAD', {
40
+ encoding: 'utf-8',
41
+ stdio: ['pipe', 'pipe', 'pipe'],
42
+ }).trim();
43
+
44
+ const changedRelPaths = output
45
+ .split('\n')
46
+ .map((line) => line.trim())
47
+ .filter(Boolean);
48
+
49
+ const cwd = process.cwd();
50
+ const changedAbsPaths = new Set(
51
+ changedRelPaths.map((rel) => path.resolve(cwd, rel)),
52
+ );
53
+
54
+ // Also check files that aren't cached yet
55
+ const uncached = allFiles.filter((f) => !this.cache.has(f));
56
+
57
+ return [...new Set([...uncached, ...allFiles.filter((f) => changedAbsPaths.has(f))])];
58
+ } catch {
59
+ // Git not available, fall back to hash-based
60
+ return this.getHashChangedFiles(allFiles);
61
+ }
62
+ }
63
+
64
+ private getHashChangedFiles(allFiles: string[]): string[] {
65
+ const changed: string[] = [];
66
+
67
+ for (const filePath of allFiles) {
68
+ const cached = this.cache.get(filePath);
69
+ if (!cached) {
70
+ changed.push(filePath);
71
+ continue;
72
+ }
73
+
74
+ try {
75
+ const currentHash = getFileHash(filePath);
76
+ if (currentHash !== cached.hash) {
77
+ changed.push(filePath);
78
+ }
79
+ } catch {
80
+ // File might have been deleted
81
+ changed.push(filePath);
82
+ }
83
+ }
84
+
85
+ return changed;
86
+ }
87
+
88
+ markGenerated(filePath: string, testFilePath: string): void {
89
+ try {
90
+ const hash = getFileHash(filePath);
91
+ this.cache.set(filePath, {
92
+ filePath,
93
+ hash,
94
+ lastGenerated: Date.now(),
95
+ testFilePath,
96
+ });
97
+ this.saveCache();
98
+ } catch {
99
+ logger.warn(`Failed to update cache for ${filePath}`);
100
+ }
101
+ }
102
+
103
+ private loadCache(): void {
104
+ const cacheFile = path.join(this.cacheDir, 'incremental-cache.json');
105
+ if (fs.existsSync(cacheFile)) {
106
+ try {
107
+ const data = JSON.parse(readFile(cacheFile)) as CacheEntry[];
108
+ for (const entry of data) {
109
+ this.cache.set(entry.filePath, entry);
110
+ }
111
+ logger.debug(`Loaded ${this.cache.size} cache entries`);
112
+ } catch {
113
+ logger.warn('Failed to load incremental cache, starting fresh');
114
+ }
115
+ }
116
+ }
117
+
118
+ private saveCache(): void {
119
+ const cacheFile = path.join(this.cacheDir, 'incremental-cache.json');
120
+ const data = Array.from(this.cache.values());
121
+ writeFile(cacheFile, JSON.stringify(data, null, 2));
122
+ }
123
+
124
+ clear(): void {
125
+ this.cache.clear();
126
+ const cacheFile = path.join(this.cacheDir, 'incremental-cache.json');
127
+ if (fs.existsSync(cacheFile)) {
128
+ fs.unlinkSync(cacheFile);
129
+ }
130
+ }
131
+ }