ai-unit-test-generator 1.3.0 → 1.4.1
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/README.md +35 -0
- package/bin/cli.js +19 -0
- package/lib/core/scorer.mjs +50 -14
- package/lib/extract-tests.mjs +199 -0
- package/lib/run-batch.mjs +81 -0
- package/lib/workflows/all.mjs +7 -1
- package/lib/workflows/batch.mjs +11 -5
- package/package.json +1 -2
- package/templates/default.config.json +40 -12
package/README.md
CHANGED
@@ -319,6 +319,41 @@ Notes:
|
|
319
319
|
|
320
320
|
## 📈 Metrics Explained
|
321
321
|
|
322
|
+
### Coverage-aware Scoring (New)
|
323
|
+
|
324
|
+
We incorporate code coverage into prioritization, balancing both incremental (new code) and stock (existing code) scenarios.
|
325
|
+
|
326
|
+
- Coverage Score (CS) mapping:
|
327
|
+
- 0% → 10 (highest)
|
328
|
+
- 1-40% → 8
|
329
|
+
- 41-70% → 6
|
330
|
+
- 71-90% → 3
|
331
|
+
- 91-100% → 1 (lowest)
|
332
|
+
- N/A → 5 (no data)
|
333
|
+
- Default weights (legacy mode): `coverage: 0.20`
|
334
|
+
- Layered weights: each layer includes a `coverage` weight (see default config)
|
335
|
+
- Optional `coverageBoost` provides a small additive boost for low-coverage files to break ties.
|
336
|
+
|
337
|
+
Coverage is parsed from `coverage/coverage-summary.json` if present. You can run coverage before `scan` to leverage coverage-aware prioritization.
|
338
|
+
|
339
|
+
Configuration snippet:
|
340
|
+
|
341
|
+
```json
|
342
|
+
{
|
343
|
+
"coverageScoring": {
|
344
|
+
"naScore": 5,
|
345
|
+
"mapping": [
|
346
|
+
{ "lte": 0, "score": 10 },
|
347
|
+
{ "lte": 40, "score": 8 },
|
348
|
+
{ "lte": 70, "score": 6 },
|
349
|
+
{ "lte": 90, "score": 3 },
|
350
|
+
{ "lte": 100, "score": 1 }
|
351
|
+
]
|
352
|
+
},
|
353
|
+
"coverageBoost": { "enable": true, "threshold": 60, "scale": 0.5, "maxBoost": 0.5 }
|
354
|
+
}
|
355
|
+
```
|
356
|
+
|
322
357
|
### Testability (50% weight in Foundation layer)
|
323
358
|
- Pure functions: 10/10
|
324
359
|
- Simple mocks: 8-9/10
|
package/bin/cli.js
CHANGED
@@ -43,6 +43,25 @@ program
|
|
43
43
|
mkdirSync(output, { recursive: true })
|
44
44
|
}
|
45
45
|
|
46
|
+
// 可选:在扫描前自动运行覆盖率(由配置控制)
|
47
|
+
try {
|
48
|
+
const cfgText = existsSync(config) ? readFileSync(config, 'utf-8') : '{}'
|
49
|
+
const cfg = JSON.parse(cfgText)
|
50
|
+
const covCfg = cfg?.coverage || { runBeforeScan: false }
|
51
|
+
if (covCfg.runBeforeScan) {
|
52
|
+
console.log('🧪 Running coverage before scan...')
|
53
|
+
await new Promise((resolve, reject) => {
|
54
|
+
const cmd = covCfg.command || 'npx jest --coverage --silent'
|
55
|
+
const child = spawn(cmd, { stdio: 'inherit', shell: true, cwd: process.cwd() })
|
56
|
+
child.on('close', code => code === 0 ? resolve(0) : reject(new Error(`coverage exited ${code}`)))
|
57
|
+
child.on('error', reject)
|
58
|
+
})
|
59
|
+
}
|
60
|
+
} catch (err) {
|
61
|
+
console.warn('⚠️ Coverage step failed or Jest not installed. Skipping coverage and continuing scan.')
|
62
|
+
console.warn(' - npm i -D jest@29 ts-jest@29 @types/jest@29 jest-environment-jsdom@29 --legacy-peer-deps')
|
63
|
+
}
|
64
|
+
|
46
65
|
console.log('🚀 Starting code scan...\n')
|
47
66
|
|
48
67
|
try {
|
package/lib/core/scorer.mjs
CHANGED
@@ -191,8 +191,12 @@ function mapDependencyCount(depGraphData, cfg) {
|
|
191
191
|
return 2
|
192
192
|
}
|
193
193
|
// 传统评分(兼容旧配置)
|
194
|
-
function computeScoreLegacy({ BC, CC, ER, ROI }, cfg) {
|
195
|
-
const s = BC * pickWeight(cfg,'BC',0.4)
|
194
|
+
function computeScoreLegacy({ BC, CC, ER, ROI, coverageScore }, cfg) {
|
195
|
+
const s = BC * pickWeight(cfg,'BC',0.4)
|
196
|
+
+ CC * pickWeight(cfg,'CC',0.3)
|
197
|
+
+ ER * pickWeight(cfg,'ER',0.2)
|
198
|
+
+ ROI * pickWeight(cfg,'ROI',0.1)
|
199
|
+
+ (typeof coverageScore === 'number' ? coverageScore * pickWeight(cfg,'coverage',0) : 0)
|
196
200
|
const score = toFixedDown(s, (cfg?.round?.digits ?? 2))
|
197
201
|
const P0 = pickThreshold(cfg,'P0',8.5), P1 = pickThreshold(cfg,'P1',6.5), P2 = pickThreshold(cfg,'P2',4.5)
|
198
202
|
let priority = 'P3'
|
@@ -203,7 +207,7 @@ function computeScoreLegacy({ BC, CC, ER, ROI }, cfg) {
|
|
203
207
|
}
|
204
208
|
|
205
209
|
// 分层评分(新方法)
|
206
|
-
function computeScoreLayered({ BC, CC, ER, testability, dependencyCount }, target, cfg) {
|
210
|
+
function computeScoreLayered({ BC, CC, ER, testability, dependencyCount, coverageScore }, target, cfg) {
|
207
211
|
const layer = target.layer || 'unknown'
|
208
212
|
const layerDef = cfg?.layers?.[layer]
|
209
213
|
|
@@ -221,6 +225,7 @@ function computeScoreLayered({ BC, CC, ER, testability, dependencyCount }, targe
|
|
221
225
|
if (weights.errorRisk !== undefined) score += ER * weights.errorRisk
|
222
226
|
if (weights.testability !== undefined) score += testability * weights.testability
|
223
227
|
if (weights.dependencyCount !== undefined) score += dependencyCount * weights.dependencyCount
|
228
|
+
if (weights.coverage !== undefined && typeof coverageScore === 'number') score += coverageScore * weights.coverage
|
224
229
|
|
225
230
|
score = toFixedDown(score, cfg?.round?.digits ?? 2)
|
226
231
|
|
@@ -245,6 +250,25 @@ function computeScore(metrics, target, cfg) {
|
|
245
250
|
}
|
246
251
|
}
|
247
252
|
|
253
|
+
// 覆盖率百分比到覆盖率分数(Coverage Score)映射
|
254
|
+
function mapCoverageScore(pct, cfg) {
|
255
|
+
if (pct === null || pct === undefined || Number.isNaN(pct)) {
|
256
|
+
return cfg?.coverageScoring?.naScore ?? 5
|
257
|
+
}
|
258
|
+
const mapping = cfg?.coverageScoring?.mapping
|
259
|
+
if (Array.isArray(mapping) && mapping.length) {
|
260
|
+
const ordered = mapping.slice().sort((a,b)=> (a.lte ?? 1e9) - (b.lte ?? 1e9))
|
261
|
+
for (const rule of ordered) {
|
262
|
+
if (typeof rule.lte === 'number' && pct <= rule.lte) return rule.score
|
263
|
+
}
|
264
|
+
}
|
265
|
+
if (pct <= 0) return 10
|
266
|
+
if (pct <= 40) return 8
|
267
|
+
if (pct <= 70) return 6
|
268
|
+
if (pct <= 90) return 3
|
269
|
+
return 1
|
270
|
+
}
|
271
|
+
|
248
272
|
function defaultMd(rows, statusMap = new Map()) {
|
249
273
|
// 按总分降序排序(高分在前)
|
250
274
|
const sorted = [...rows].sort((a, b) => b.score - a.score)
|
@@ -252,14 +276,14 @@ function defaultMd(rows, statusMap = new Map()) {
|
|
252
276
|
let md = '<!-- UT Priority Scoring Report -->\n'
|
253
277
|
md += '<!-- Format: Status can be "TODO" | "DONE" | "SKIP" -->\n'
|
254
278
|
md += '<!-- You can mark status by replacing TODO with DONE or SKIP -->\n\n'
|
255
|
-
md += '| Status | Score | Priority | Name | Type | Layer | Path | BC | CC | ER | Testability | DepCount | Notes |\n'
|
256
|
-
md += '
|
279
|
+
md += '| Status | Score | Priority | Name | Type | Layer | Path | Coverage | CS | BC | CC | ER | Testability | DepCount | Notes |\n'
|
280
|
+
md += '|--------|-------|----------|------|------|-------|------|----------|----|----|----|----|-----------|---------| ------- |\n'
|
257
281
|
|
258
282
|
sorted.forEach(r => {
|
259
283
|
// 从状态映射中查找现有状态,否则默认为 TODO
|
260
284
|
const key = `${r.path}#${r.name}`
|
261
285
|
const status = statusMap.get(key) || 'TODO'
|
262
|
-
md += `| ${status} | ${r.score} | ${r.priority} | ${r.name} | ${r.type} | ${r.layerName || r.layer} | ${r.path} | ${r.BC} | ${r.CC} | ${r.ER} | ${r.testability || r.ROI} | ${r.dependencyCount || 'N/A'} | ${r.notes} |\n`
|
286
|
+
md += `| ${status} | ${r.score} | ${r.priority} | ${r.name} | ${r.type} | ${r.layerName || r.layer} | ${r.path} | ${typeof r.coveragePct === 'number' ? r.coveragePct.toFixed(1) + '%':'N/A'} | ${r.coverageScore ?? 'N/A'} | ${r.BC} | ${r.CC} | ${r.ER} | ${r.testability || r.ROI} | ${r.dependencyCount || 'N/A'} | ${r.notes} |\n`
|
263
287
|
})
|
264
288
|
|
265
289
|
// 添加统计信息
|
@@ -291,7 +315,7 @@ function defaultCsv(rows) {
|
|
291
315
|
// 按总分降序排序(高分在前)
|
292
316
|
const sorted = [...rows].sort((a, b) => b.score - a.score)
|
293
317
|
|
294
|
-
const head = ['status','score','priority','name','path','type','layer','layerName','BC','CC','ER','testability','dependencyCount','notes'].join(',')
|
318
|
+
const head = ['status','score','priority','name','path','type','layer','layerName','coveragePct','coverageScore','BC','CC','ER','testability','dependencyCount','notes'].join(',')
|
295
319
|
const body = sorted.map(r => [
|
296
320
|
'TODO',
|
297
321
|
r.score,
|
@@ -301,6 +325,8 @@ function defaultCsv(rows) {
|
|
301
325
|
r.type,
|
302
326
|
r.layer || 'N/A',
|
303
327
|
r.layerName || 'N/A',
|
328
|
+
(typeof r.coveragePct === 'number' ? r.coveragePct.toFixed(1) + '%' : 'N/A'),
|
329
|
+
(r.coverageScore ?? 'N/A'),
|
304
330
|
r.BC,
|
305
331
|
r.CC,
|
306
332
|
r.ER,
|
@@ -483,7 +509,7 @@ async function main() {
|
|
483
509
|
// P0-2: 构建依赖图
|
484
510
|
const depGraph = cfg?.depGraph?.enable ? buildDepGraph(funcProvider.project, cfg) : null
|
485
511
|
|
486
|
-
//
|
512
|
+
// 读取覆盖率汇总(如果存在)
|
487
513
|
let coverageSummary = null
|
488
514
|
if (existsSync(coverageJsonPath)) {
|
489
515
|
try { coverageSummary = JSON.parse(readFileSync(coverageJsonPath, 'utf8')) } catch {}
|
@@ -524,19 +550,27 @@ async function main() {
|
|
524
550
|
const testability = mapTestabilityByConfig(roiHint, cfg, localROI, overrides, t)
|
525
551
|
const dependencyCount = mapDependencyCount(depGraphData, cfg)
|
526
552
|
|
527
|
-
|
528
|
-
|
529
|
-
|
553
|
+
// 计算覆盖率分数(用于打分)
|
554
|
+
let coveragePct = null
|
555
|
+
let coverageScore = null
|
530
556
|
if (coverageSummary) {
|
531
557
|
const fileKey = Object.keys(coverageSummary).find(k => k.endsWith(path))
|
532
558
|
const fileCov = fileKey ? coverageSummary[fileKey] : null
|
533
559
|
const linesPct = fileCov?.lines?.pct
|
560
|
+
if (typeof linesPct === 'number') coveragePct = linesPct
|
561
|
+
coverageScore = mapCoverageScore(linesPct, cfg)
|
562
|
+
}
|
563
|
+
|
564
|
+
let { score, priority, layer, layerName } = computeScore({ BC, CC: CCFinal, ER, ROI, testability, dependencyCount, coverageScore }, t, cfg)
|
565
|
+
|
566
|
+
// 覆盖率加权(可选):对低覆盖文件小幅加分
|
567
|
+
if (coverageSummary) {
|
534
568
|
const cfgBoost = cfg?.coverageBoost || { enable: false }
|
535
|
-
if (cfgBoost.enable && typeof
|
569
|
+
if (cfgBoost.enable && typeof coveragePct === 'number') {
|
536
570
|
const threshold = cfgBoost.threshold ?? 60
|
537
571
|
const maxBoost = cfgBoost.maxBoost ?? 0.5
|
538
|
-
if (
|
539
|
-
const ratio = (threshold -
|
572
|
+
if (coveragePct < threshold) {
|
573
|
+
const ratio = (threshold - coveragePct) / Math.max(threshold, 1)
|
540
574
|
const delta = toFixedDown(Math.min(maxBoost, ratio * (cfgBoost.scale ?? 0.5)), 2)
|
541
575
|
score = toFixedDown(score + delta, cfg?.round?.digits ?? 2)
|
542
576
|
// 重新判定优先级
|
@@ -583,6 +617,8 @@ async function main() {
|
|
583
617
|
ROI,
|
584
618
|
testability,
|
585
619
|
dependencyCount,
|
620
|
+
coveragePct: coveragePct ?? 'N/A',
|
621
|
+
coverageScore: coverageScore ?? 'N/A',
|
586
622
|
score,
|
587
623
|
priority,
|
588
624
|
notes: notesParts.join('; ')
|
@@ -0,0 +1,199 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
/**
|
3
|
+
* 从 AI 回复中提取测试文件并自动创建
|
4
|
+
*/
|
5
|
+
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
7
|
+
import { dirname } from 'path';
|
8
|
+
|
9
|
+
/**
|
10
|
+
* 从 AI 响应中提取测试文件
|
11
|
+
*/
|
12
|
+
export function extractTests(content, options = {}) {
|
13
|
+
const { overwrite = false, dryRun = false } = options;
|
14
|
+
|
15
|
+
// 先尝试解析 JSON Manifest(优先)
|
16
|
+
// 形如:```json { version: 1, files: [{ path, source, ... }] }
|
17
|
+
let manifest = null;
|
18
|
+
const manifestRegex = /```json\s*\n([\s\S]*?)\n```/gi;
|
19
|
+
let m;
|
20
|
+
while ((m = manifestRegex.exec(content)) !== null) {
|
21
|
+
const jsonStr = m[1].trim();
|
22
|
+
try {
|
23
|
+
const obj = JSON.parse(jsonStr);
|
24
|
+
if (obj && Array.isArray(obj.files)) {
|
25
|
+
manifest = obj;
|
26
|
+
break;
|
27
|
+
}
|
28
|
+
} catch {}
|
29
|
+
}
|
30
|
+
const manifestPaths = manifest?.files?.map(f => String(f.path).trim()) ?? [];
|
31
|
+
|
32
|
+
// 再匹配多种文件代码块格式(回退/兼容,增强鲁棒性)
|
33
|
+
const patterns = [
|
34
|
+
// 格式1: ### 测试文件: path (有/无反引号)
|
35
|
+
/###\s*测试文件\s*[::]\s*`?([^\n`]+?)`?\s*\n```(?:typescript|ts|tsx|javascript|js|jsx|json|text)?\s*\n([\s\S]*?)\n```/gi,
|
36
|
+
// 格式2: **测试文件**: path
|
37
|
+
/\*\*测试文件\*\*\s*[::]\s*`?([^\n`]+)`?\s*\n```(?:typescript|ts|tsx|javascript|js|jsx|json|text)?\s*\n([\s\S]*?)\n```/gi,
|
38
|
+
// 格式3: 文件路径: path
|
39
|
+
/文件路径\s*[::]\s*`?([^\n`]+)`?\s*\n```(?:typescript|ts|tsx|javascript|js|jsx|json|text)?\s*\n([\s\S]*?)\n```/gi,
|
40
|
+
// 格式4: # path.test.ts (仅文件名标题)
|
41
|
+
/^#+\s+([^\n]+\.test\.[jt]sx?)\s*\n```(?:typescript|ts|tsx|javascript|js|jsx)?\s*\n([\s\S]*?)\n```/gim,
|
42
|
+
// 格式5: 更宽松的匹配(任意"path"后接代码块,路径必须含 .test.)
|
43
|
+
/(?:path|文件|file)\s*[::]?\s*`?([^\n`]*\.test\.[jt]sx?[^\n`]*?)`?\s*\n```(?:typescript|ts|tsx|javascript|js|jsx)?\s*\n([\s\S]*?)\n```/gi,
|
44
|
+
];
|
45
|
+
|
46
|
+
const created = [];
|
47
|
+
const skipped = [];
|
48
|
+
const errors = [];
|
49
|
+
|
50
|
+
patterns.forEach(fileRegex => {
|
51
|
+
let match;
|
52
|
+
while ((match = fileRegex.exec(content)) !== null) {
|
53
|
+
const [, filePath, testCode] = match;
|
54
|
+
const cleanPath = filePath.trim();
|
55
|
+
|
56
|
+
// 若存在 Manifest,则只允许清单内的路径
|
57
|
+
if (manifestPaths.length > 0 && !manifestPaths.includes(cleanPath)) {
|
58
|
+
continue;
|
59
|
+
}
|
60
|
+
|
61
|
+
// 跳过已处理的文件
|
62
|
+
if (created.some(f => f.path === cleanPath) ||
|
63
|
+
skipped.some(f => f.path === cleanPath)) {
|
64
|
+
continue;
|
65
|
+
}
|
66
|
+
|
67
|
+
// 检查文件是否已存在
|
68
|
+
if (existsSync(cleanPath) && !overwrite) {
|
69
|
+
skipped.push({ path: cleanPath, reason: 'exists' });
|
70
|
+
continue;
|
71
|
+
}
|
72
|
+
|
73
|
+
if (dryRun) {
|
74
|
+
created.push({ path: cleanPath, code: testCode.trim(), dryRun: true });
|
75
|
+
continue;
|
76
|
+
}
|
77
|
+
|
78
|
+
try {
|
79
|
+
// 确保目录存在
|
80
|
+
mkdirSync(dirname(cleanPath), { recursive: true });
|
81
|
+
|
82
|
+
// 写入测试文件
|
83
|
+
const cleanCode = testCode.trim();
|
84
|
+
writeFileSync(cleanPath, cleanCode + '\n');
|
85
|
+
created.push({ path: cleanPath, code: cleanCode });
|
86
|
+
} catch (err) {
|
87
|
+
errors.push({ path: cleanPath, error: err.message });
|
88
|
+
}
|
89
|
+
}
|
90
|
+
});
|
91
|
+
|
92
|
+
// 若有 Manifest 但未生成任何文件,则报告可能缺失代码块
|
93
|
+
if (manifestPaths.length > 0) {
|
94
|
+
const createdPaths = new Set(created.map(f => f.path));
|
95
|
+
const missing = manifestPaths.filter(p => !createdPaths.has(p));
|
96
|
+
missing.forEach(p => errors.push({ path: p, error: 'missing code block for manifest entry' }));
|
97
|
+
}
|
98
|
+
|
99
|
+
return { created, skipped, errors };
|
100
|
+
}
|
101
|
+
|
102
|
+
/**
|
103
|
+
* CLI 入口
|
104
|
+
*/
|
105
|
+
export function runCLI(argv = process.argv) {
|
106
|
+
const args = argv.slice(2);
|
107
|
+
|
108
|
+
if (args.length === 0) {
|
109
|
+
console.error('❌ 缺少参数\n');
|
110
|
+
console.error('用法: ai-test extract-tests <response-file> [options]\n');
|
111
|
+
console.error('选项:');
|
112
|
+
console.error(' --overwrite 覆盖已存在的文件');
|
113
|
+
console.error(' --dry-run 仅显示将要创建的文件,不实际写入\n');
|
114
|
+
console.error('示例:');
|
115
|
+
console.error(' ai-test extract-tests response.txt');
|
116
|
+
console.error(' ai-test extract-tests response.txt --overwrite');
|
117
|
+
process.exit(1);
|
118
|
+
}
|
119
|
+
|
120
|
+
const responseFile = args[0];
|
121
|
+
const options = {
|
122
|
+
overwrite: args.includes('--overwrite'),
|
123
|
+
dryRun: args.includes('--dry-run')
|
124
|
+
};
|
125
|
+
|
126
|
+
if (!existsSync(responseFile)) {
|
127
|
+
console.error(`❌ 文件不存在: ${responseFile}`);
|
128
|
+
process.exit(1);
|
129
|
+
}
|
130
|
+
|
131
|
+
try {
|
132
|
+
const content = readFileSync(responseFile, 'utf8');
|
133
|
+
const { created, skipped, errors } = extractTests(content, options);
|
134
|
+
|
135
|
+
// 输出结果
|
136
|
+
created.forEach(f => {
|
137
|
+
if (f.dryRun) {
|
138
|
+
console.log(`[DRY-RUN] 将创建: ${f.path}`);
|
139
|
+
} else {
|
140
|
+
console.log(`✅ 创建测试文件: ${f.path}`);
|
141
|
+
}
|
142
|
+
});
|
143
|
+
|
144
|
+
skipped.forEach(f => {
|
145
|
+
if (f.reason === 'exists') {
|
146
|
+
console.log(`⚠️ 跳过(已存在): ${f.path}`);
|
147
|
+
}
|
148
|
+
});
|
149
|
+
|
150
|
+
errors.forEach(f => {
|
151
|
+
console.error(`❌ 创建失败: ${f.path} - ${f.error}`);
|
152
|
+
});
|
153
|
+
|
154
|
+
console.log('');
|
155
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
156
|
+
console.log(`✨ 总共创建 ${created.length} 个测试文件`);
|
157
|
+
if (skipped.length > 0) {
|
158
|
+
console.log(`⚠️ 跳过 ${skipped.length} 个已存在的文件`);
|
159
|
+
}
|
160
|
+
if (errors.length > 0) {
|
161
|
+
console.log(`❌ ${errors.length} 个文件创建失败`);
|
162
|
+
}
|
163
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
164
|
+
console.log('');
|
165
|
+
|
166
|
+
if (created.length > 0 && !options.dryRun) {
|
167
|
+
console.log('🧪 运行测试:');
|
168
|
+
console.log(' npm test');
|
169
|
+
console.log('');
|
170
|
+
console.log('📝 标记完成(如需):');
|
171
|
+
const functionNames = created
|
172
|
+
.map(f => f.path.match(/\/([^/]+)\.test\./)?.[1])
|
173
|
+
.filter(Boolean)
|
174
|
+
.join(',');
|
175
|
+
if (functionNames) {
|
176
|
+
console.log(` ai-test mark-done "${functionNames}"`);
|
177
|
+
}
|
178
|
+
} else if (created.length === 0) {
|
179
|
+
console.log('❌ 未找到任何测试文件');
|
180
|
+
console.log('');
|
181
|
+
console.log('请检查 AI 回复格式是否正确。预期格式:');
|
182
|
+
console.log(' ### 测试文件: src/utils/xxx.test.ts');
|
183
|
+
console.log(' ```typescript');
|
184
|
+
console.log(' // 测试代码');
|
185
|
+
console.log(' ```');
|
186
|
+
}
|
187
|
+
|
188
|
+
process.exit(errors.length > 0 ? 1 : 0);
|
189
|
+
} catch (err) {
|
190
|
+
console.error(`❌ 错误: ${err.message}`);
|
191
|
+
process.exit(1);
|
192
|
+
}
|
193
|
+
}
|
194
|
+
|
195
|
+
// 作为脚本直接运行时
|
196
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
197
|
+
runCLI();
|
198
|
+
}
|
199
|
+
|
@@ -0,0 +1,81 @@
|
|
1
|
+
#!/usr/bin/env node
|
2
|
+
/**
|
3
|
+
* 单批次:生成 prompt → 调用 AI → 提取测试 → 运行 Jest → 输出失败摘要
|
4
|
+
*/
|
5
|
+
|
6
|
+
import { spawn } from 'child_process'
|
7
|
+
import { existsSync, readFileSync } from 'fs'
|
8
|
+
|
9
|
+
function sh(cmd, args = []) {
|
10
|
+
return new Promise((resolve, reject) => {
|
11
|
+
const child = spawn(cmd, args, { stdio: 'inherit', cwd: process.cwd() })
|
12
|
+
child.on('close', code => code === 0 ? resolve(0) : reject(new Error(`${cmd} exited ${code}`)))
|
13
|
+
child.on('error', reject)
|
14
|
+
})
|
15
|
+
}
|
16
|
+
|
17
|
+
function readCoverageSummary() {
|
18
|
+
const path = 'coverage/coverage-summary.json'
|
19
|
+
if (!existsSync(path)) return null
|
20
|
+
try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return null }
|
21
|
+
}
|
22
|
+
|
23
|
+
function getCoveragePercent(summary) {
|
24
|
+
if (!summary || !summary.total) return 0
|
25
|
+
return summary.total.lines?.pct ?? 0
|
26
|
+
}
|
27
|
+
|
28
|
+
async function main(argv = process.argv) {
|
29
|
+
const args = argv.slice(2)
|
30
|
+
const priority = args[0] || 'P0'
|
31
|
+
const limit = Number(args[1] || 10)
|
32
|
+
const skip = Number(args[2] || 0)
|
33
|
+
const minCovDelta = priority === 'P0' ? 2 : (priority === 'P1' ? 1 : 0)
|
34
|
+
|
35
|
+
// 记录初始覆盖率
|
36
|
+
const beforeCov = getCoveragePercent(readCoverageSummary())
|
37
|
+
|
38
|
+
// 1) 生成 Prompt(加入上一轮失败提示 hints.txt 如存在)
|
39
|
+
const promptArgs = ['ai-test-generator/lib/gen-test-prompt.mjs', '--report', 'reports/ut_scores.md', '-p', priority, '-n', String(limit), '--skip', String(skip)]
|
40
|
+
try { await sh('node', [...promptArgs, '--hints-file', 'reports/hints.txt']) } catch { await sh('node', promptArgs) }
|
41
|
+
|
42
|
+
// 2) 调用 AI
|
43
|
+
await sh('node', ['ai-test-generator/lib/ai-generate.mjs', '--prompt', 'prompt.txt', '--out', 'reports/ai_response.txt'])
|
44
|
+
|
45
|
+
// 3) 提取测试
|
46
|
+
await sh('node', ['ai-test-generator/lib/extract-tests.mjs', 'reports/ai_response.txt', '--overwrite'])
|
47
|
+
|
48
|
+
// 4) 运行 Jest(按优先级自适应重跑与覆盖率增量)
|
49
|
+
const reruns = priority === 'P0' ? 1 : (priority === 'P1' ? 0 : 0)
|
50
|
+
for (let i = 0; i < Math.max(1, reruns + 1); i++) {
|
51
|
+
try { await sh('node', ['ai-test-generator/lib/jest-runner.mjs']) } catch { /* 继续做失败分析 */ }
|
52
|
+
}
|
53
|
+
|
54
|
+
// 校验覆盖率增量(P0/P1)
|
55
|
+
if (minCovDelta > 0) {
|
56
|
+
const afterCov = getCoveragePercent(readCoverageSummary())
|
57
|
+
const delta = afterCov - beforeCov
|
58
|
+
if (delta < minCovDelta) {
|
59
|
+
console.warn(`⚠️ Coverage delta ${delta.toFixed(2)}% < required ${minCovDelta}% (before: ${beforeCov.toFixed(2)}%, after: ${afterCov.toFixed(2)}%)`)
|
60
|
+
} else {
|
61
|
+
console.log(`✅ Coverage improved: ${beforeCov.toFixed(2)}% → ${afterCov.toFixed(2)}% (+${delta.toFixed(2)}%)`)
|
62
|
+
}
|
63
|
+
}
|
64
|
+
|
65
|
+
// 5) 失败分析并落盘 hints
|
66
|
+
const { spawn } = await import('child_process')
|
67
|
+
const { writeFileSync } = await import('fs')
|
68
|
+
await new Promise((resolve) => {
|
69
|
+
const child = spawn('node', ['ai-test-generator/lib/jest-failure-analyzer.mjs'], { stdio: ['inherit','pipe','inherit'] })
|
70
|
+
const chunks = []
|
71
|
+
child.stdout.on('data', d => chunks.push(Buffer.from(d)))
|
72
|
+
child.on('close', () => {
|
73
|
+
try { const obj = JSON.parse(Buffer.concat(chunks).toString('utf8')); writeFileSync('reports/hints.txt', obj.hints?.length ? `# 上一轮失败修复建议\n- ${obj.hints.join('\n- ')}` : '') } catch {}
|
74
|
+
resolve()
|
75
|
+
})
|
76
|
+
})
|
77
|
+
}
|
78
|
+
|
79
|
+
if (import.meta.url === `file://${process.argv[1]}`) main()
|
80
|
+
|
81
|
+
|
package/lib/workflows/all.mjs
CHANGED
@@ -5,6 +5,12 @@
|
|
5
5
|
|
6
6
|
import { readFileSync, existsSync } from 'fs'
|
7
7
|
import { spawn } from 'child_process'
|
8
|
+
import { fileURLToPath } from 'url'
|
9
|
+
import { dirname, join } from 'path'
|
10
|
+
|
11
|
+
const __filename = fileURLToPath(import.meta.url)
|
12
|
+
const __dirname = dirname(__filename)
|
13
|
+
const pkgRoot = join(__dirname, '../..')
|
8
14
|
|
9
15
|
function sh(cmd, args = []) { return new Promise((resolve, reject) => {
|
10
16
|
const child = spawn(cmd, args, { stdio: 'inherit', cwd: process.cwd() })
|
@@ -37,7 +43,7 @@ Remaining TODO: ${remain}
|
|
37
43
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`)
|
38
44
|
|
39
45
|
try {
|
40
|
-
await sh('node', ['
|
46
|
+
await sh('node', [join(pkgRoot, 'lib/workflows/batch.mjs'), priority, String(batchSize), String(skip)])
|
41
47
|
} catch (err) {
|
42
48
|
console.error('Batch failed:', err.message)
|
43
49
|
}
|
package/lib/workflows/batch.mjs
CHANGED
@@ -5,6 +5,12 @@
|
|
5
5
|
|
6
6
|
import { spawn } from 'child_process'
|
7
7
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
8
|
+
import { fileURLToPath } from 'url'
|
9
|
+
import { dirname, join } from 'path'
|
10
|
+
|
11
|
+
const __filename = fileURLToPath(import.meta.url)
|
12
|
+
const __dirname = dirname(__filename)
|
13
|
+
const pkgRoot = join(__dirname, '../..')
|
8
14
|
|
9
15
|
function sh(cmd, args = []) {
|
10
16
|
return new Promise((resolve, reject) => {
|
@@ -99,7 +105,7 @@ async function main(argv = process.argv) {
|
|
99
105
|
|
100
106
|
// 1) 生成 Prompt(只针对 TODO 函数,加入上一轮失败提示)
|
101
107
|
const promptArgs = [
|
102
|
-
'
|
108
|
+
join(pkgRoot, 'lib/ai/prompt-builder.mjs'),
|
103
109
|
'--report', reportPath,
|
104
110
|
'-p', priority,
|
105
111
|
'-n', String(limit),
|
@@ -114,11 +120,11 @@ async function main(argv = process.argv) {
|
|
114
120
|
|
115
121
|
// 2) 调用 AI
|
116
122
|
console.log('\n🤖 Calling AI...')
|
117
|
-
await sh('node', ['
|
123
|
+
await sh('node', [join(pkgRoot, 'lib/ai/client.mjs'), '--prompt', 'prompt.txt', '--out', 'reports/ai_response.txt'])
|
118
124
|
|
119
125
|
// 3) 提取测试
|
120
126
|
console.log('\n📦 Extracting tests...')
|
121
|
-
await sh('node', ['
|
127
|
+
await sh('node', [join(pkgRoot, 'lib/ai/extractor.mjs'), 'reports/ai_response.txt', '--overwrite'])
|
122
128
|
|
123
129
|
// 4) 运行 Jest(按优先级自适应重跑)
|
124
130
|
console.log('\n🧪 Running tests...')
|
@@ -127,7 +133,7 @@ async function main(argv = process.argv) {
|
|
127
133
|
|
128
134
|
for (let i = 0; i < Math.max(1, reruns + 1); i++) {
|
129
135
|
try {
|
130
|
-
await sh('node', ['
|
136
|
+
await sh('node', [join(pkgRoot, 'lib/testing/runner.mjs')])
|
131
137
|
testsPassed = true
|
132
138
|
break
|
133
139
|
} catch {
|
@@ -163,7 +169,7 @@ async function main(argv = process.argv) {
|
|
163
169
|
const { spawn: spawnLocal } = await import('child_process')
|
164
170
|
const { writeFileSync: writeFileSyncLocal } = await import('fs')
|
165
171
|
await new Promise((resolve) => {
|
166
|
-
const child = spawnLocal('node', ['
|
172
|
+
const child = spawnLocal('node', [join(pkgRoot, 'lib/testing/analyzer.mjs')], { stdio: ['inherit','pipe','inherit'] })
|
167
173
|
const chunks = []
|
168
174
|
child.stdout.on('data', d => chunks.push(Buffer.from(d)))
|
169
175
|
child.on('close', () => {
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "ai-unit-test-generator",
|
3
|
-
"version": "1.
|
3
|
+
"version": "1.4.1",
|
4
4
|
"description": "AI-powered unit test generator with smart priority scoring",
|
5
5
|
"keywords": [
|
6
6
|
"unit-test",
|
@@ -66,4 +66,3 @@
|
|
66
66
|
},
|
67
67
|
"homepage": "https://github.com/YuhengZhou/ai-unit-test-generator#readme"
|
68
68
|
}
|
69
|
-
|
@@ -22,9 +22,10 @@
|
|
22
22
|
"multipleReferences": true
|
23
23
|
},
|
24
24
|
"weights": {
|
25
|
-
"testability": 0.
|
26
|
-
"dependencyCount": 0.
|
27
|
-
"complexity": 0.20
|
25
|
+
"testability": 0.45,
|
26
|
+
"dependencyCount": 0.25,
|
27
|
+
"complexity": 0.20,
|
28
|
+
"coverage": 0.10
|
28
29
|
},
|
29
30
|
"thresholds": { "P0": 7.5, "P1": 6.0, "P2": 4.0 },
|
30
31
|
"coverageTarget": 100,
|
@@ -41,9 +42,10 @@
|
|
41
42
|
"mayDependOnUtils": true
|
42
43
|
},
|
43
44
|
"weights": {
|
44
|
-
"businessCriticality": 0.
|
45
|
-
"complexity": 0.
|
46
|
-
"errorRisk": 0.
|
45
|
+
"businessCriticality": 0.35,
|
46
|
+
"complexity": 0.25,
|
47
|
+
"errorRisk": 0.25,
|
48
|
+
"coverage": 0.15
|
47
49
|
},
|
48
50
|
"thresholds": { "P0": 8.0, "P1": 6.5, "P2": 4.5 },
|
49
51
|
"coverageTarget": 80,
|
@@ -58,9 +60,10 @@
|
|
58
60
|
"connectsLogicAndUI": true
|
59
61
|
},
|
60
62
|
"weights": {
|
61
|
-
"businessCriticality": 0.
|
62
|
-
"complexity": 0.
|
63
|
-
"errorRisk": 0.20
|
63
|
+
"businessCriticality": 0.45,
|
64
|
+
"complexity": 0.25,
|
65
|
+
"errorRisk": 0.20,
|
66
|
+
"coverage": 0.10
|
64
67
|
},
|
65
68
|
"thresholds": { "P0": 8.0, "P1": 6.5, "P2": 4.5 },
|
66
69
|
"coverageTarget": 70,
|
@@ -75,15 +78,40 @@
|
|
75
78
|
"hasInteraction": true
|
76
79
|
},
|
77
80
|
"weights": {
|
78
|
-
"businessCriticality": 0.
|
79
|
-
"complexity": 0.
|
80
|
-
"testability": 0.
|
81
|
+
"businessCriticality": 0.35,
|
82
|
+
"complexity": 0.25,
|
83
|
+
"testability": 0.25,
|
84
|
+
"coverage": 0.15
|
81
85
|
},
|
82
86
|
"thresholds": { "P0": 8.5, "P1": 7.0, "P2": 5.0 },
|
83
87
|
"coverageTarget": 50,
|
84
88
|
"notes": "UI层,更适合集成测试"
|
85
89
|
}
|
86
90
|
},
|
91
|
+
"coverageScoring": {
|
92
|
+
"naScore": 5,
|
93
|
+
"mapping": [
|
94
|
+
{ "lte": 0, "score": 10 },
|
95
|
+
{ "lte": 40, "score": 8 },
|
96
|
+
{ "lte": 70, "score": 6 },
|
97
|
+
{ "lte": 90, "score": 3 },
|
98
|
+
{ "lte": 100, "score": 1 }
|
99
|
+
]
|
100
|
+
},
|
101
|
+
"coverage": {
|
102
|
+
"runBeforeScan": true,
|
103
|
+
"command": "npx jest --coverage --silent"
|
104
|
+
},
|
105
|
+
"weights": {
|
106
|
+
"BC": 0.25,
|
107
|
+
"CC": 0.15,
|
108
|
+
"ER": 0.15,
|
109
|
+
"ROI": 0.15,
|
110
|
+
"dependencyCount": 0.10,
|
111
|
+
"coverage": 0.20
|
112
|
+
},
|
113
|
+
"thresholds": { "P0": 8.0, "P1": 6.5, "P2": 4.5 },
|
114
|
+
"coverageBoost": { "enable": true, "threshold": 60, "scale": 0.5, "maxBoost": 0.5 },
|
87
115
|
"ccFusion": {
|
88
116
|
"useCognitive": true,
|
89
117
|
"cyclomaticT": 15,
|