@angli/unit-test-tool 0.1.4 → 0.1.5

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 CHANGED
@@ -201,21 +201,30 @@ testbot schedule --project /path/to/project --interval 5m
201
201
  testbot start --project /path/to/project --schedule --guard-interval 5m
202
202
  ```
203
203
 
204
- ### 8) Claude CLI 项目级配置
204
+ ### 8) Claude CLI 项目级配置(必填)
205
205
 
206
- 补测执行时会优先读取“待补测项目目录”下的 `.claude/settings.local.json`,并使用其中 `env` 的 `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` 覆盖全局环境变量。
206
+ 补测执行时会优先读取**待补测项目目录**下的 `.claude/settings.local.json`,并使用其中 `env` 的 `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` 覆盖全局环境变量。
207
207
 
208
- 示例:
208
+ 如果目标项目没有配置 `env`,且全局环境变量也不可用,**所有非平凡目标(Vue 组件、HOC 等)的 Claude CLI 调用将全部失败**,导致只能处理 `.d.ts`、`.js` 等简单文件。
209
+
210
+ 配置方式:在目标项目的 `.claude/settings.local.json` 中添加 `env` 字段:
209
211
 
210
212
  ```json
211
213
  {
212
- "env": {
213
- "ANTHROPIC_AUTH_TOKEN": "...",
214
- "ANTHROPIC_BASE_URL": "..."
214
+ env”: {
215
+ ANTHROPIC_AUTH_TOKEN”: “sk-ant-...”,
216
+ ANTHROPIC_BASE_URL”: “https://api.anthropic.com”
217
+ },
218
+ “permissions”: {
219
+ “allow”: [“Bash(*)”, “Edit(*)”, “Write(*)”, “Read(*)”],
220
+ “deny”: [],
221
+ “ask”: []
215
222
  }
216
223
  }
217
224
  ```
218
225
 
226
+ > **注意**:如果全局环境变量中已正确配置 `ANTHROPIC_AUTH_TOKEN`,可以不写 `env`,工具会使用全局环境变量。
227
+
219
228
  ### 9) config.json 中的 promptOverrides
220
229
 
221
230
  `testbot init` 写入的 `.unit_test_tool_workspace/config.json` 支持项目级 prompt 覆盖。工具会先使用内置“通用单测策略 + 案例片段库”,再追加这里的覆盖内容。
@@ -21,6 +21,23 @@ export async function runLoop(ctx, input = {}) {
21
21
  lastResult = result;
22
22
  if (result.targetFile)
23
23
  attemptedTargets.add(result.targetFile);
24
+ // 如果当前目标失败(CLI 失败或 no-op),且不是显式指定文件,则标记为跳过并继续下一轮
25
+ const isBlocked = !result.ok && !input.targetFile;
26
+ if (isBlocked) {
27
+ blockedTargets += 1;
28
+ await syncLoopSummary(ctx, {
29
+ iterationCount,
30
+ completedTargets,
31
+ blockedTargets,
32
+ noOpTargets,
33
+ lastCompletedTarget
34
+ });
35
+ if (blockedTargets >= Math.max(1, ctx.limits.maxRetryPerTask))
36
+ break;
37
+ if (iterationCount >= maxIterations)
38
+ break;
39
+ continue;
40
+ }
24
41
  const coverageSummary = await ctx.reportStore.loadCoverageSummary();
25
42
  const currentCoverage = coverageSummary?.coverageSnapshot?.lines;
26
43
  lastCoverage = currentCoverage;
@@ -39,12 +56,7 @@ export async function runLoop(ctx, input = {}) {
39
56
  });
40
57
  continue;
41
58
  }
42
- if (result.summary.includes('no-op')) {
43
- noOpTargets += 1;
44
- }
45
- else {
46
- blockedTargets += 1;
47
- }
59
+ noOpTargets += 1;
48
60
  await syncLoopSummary(ctx, {
49
61
  iterationCount,
50
62
  completedTargets,
@@ -54,8 +66,6 @@ export async function runLoop(ctx, input = {}) {
54
66
  });
55
67
  if (input.targetFile)
56
68
  break;
57
- if (blockedTargets >= Math.max(1, ctx.limits.maxRetryPerTask))
58
- break;
59
69
  if (noOpTargets >= Math.max(1, ctx.limits.loopThreshold))
60
70
  break;
61
71
  if (!result.targetFile)
@@ -118,7 +118,8 @@ export async function runWithClaudeCli(ctx, input = {}) {
118
118
  summary: `Claude CLI 执行失败:${firstRun.failureCategory ?? 'unknown'}。`
119
119
  }, 'cli_run_failed', {
120
120
  stdoutPath: firstStdoutPath,
121
- stderrPath: firstStderrPath
121
+ stderrPath: firstStderrPath,
122
+ failureMessage
122
123
  });
123
124
  await markRunFailed(ctx, target.task, failureMessage);
124
125
  return {
@@ -244,7 +245,8 @@ export async function runWithClaudeCli(ctx, input = {}) {
244
245
  summary: `Claude CLI 重试失败:${retryRun.failureCategory ?? 'unknown'}。`
245
246
  }, 'cli_run_failed', {
246
247
  stdoutPath: retryStdoutSaved,
247
- stderrPath: retryStderrSaved
248
+ stderrPath: retryStderrSaved,
249
+ failureMessage
248
250
  });
249
251
  await markRunFailed(ctx, target.task, failureMessage);
250
252
  return {
@@ -503,6 +505,19 @@ async function validateTestChanges(ctx, task, beforeSnapshot) {
503
505
  relatedTestFiles
504
506
  };
505
507
  }
508
+ // Snapshot diff 未命中时,兜底检查:是否有新创建的文件匹配目标
509
+ const newCandidateFiles = await detectNewTestFiles(ctx, task);
510
+ if (newCandidateFiles.length > 0) {
511
+ ctx.logger.info(`[run] step=test-change status=new-files taskId=${task.taskId} target=${task.targetFile} new=${newCandidateFiles.join(',')}`);
512
+ return {
513
+ ok: true,
514
+ phase: 'DONE',
515
+ summary: '检测到新创建的测试文件。',
516
+ targetFile: task.targetFile,
517
+ changedTestFiles: newCandidateFiles,
518
+ relatedTestFiles: newCandidateFiles
519
+ };
520
+ }
506
521
  ctx.logger.warn(`[run] step=test-change status=noop taskId=${task.taskId} target=${task.targetFile} changed=${changedTestFiles.join(',') || 'none'}`);
507
522
  return {
508
523
  ok: false,
@@ -518,6 +533,37 @@ async function validateTestChanges(ctx, task, beforeSnapshot) {
518
533
  relatedTestFiles
519
534
  };
520
535
  }
536
+ async function detectNewTestFiles(ctx, task) {
537
+ const patterns = buildTestSnapshotPatterns(ctx.config.testDirNames);
538
+ const files = await fg(patterns, {
539
+ cwd: ctx.projectPath,
540
+ ignore: ctx.config.exclude,
541
+ onlyFiles: true,
542
+ absolute: false
543
+ });
544
+ const baseName = path.basename(task.targetFile, path.extname(task.targetFile));
545
+ const targetDir = path.dirname(task.targetFile);
546
+ const normalizedTargetDir = normalizePath(targetDir);
547
+ const candidates = files.filter((file) => {
548
+ const fileBaseName = path.basename(file, path.extname(file));
549
+ const normalizedFile = normalizePath(file);
550
+ // 匹配同名或同目录下的测试文件
551
+ return (fileBaseName === baseName ||
552
+ fileBaseName === `${baseName}.test` ||
553
+ fileBaseName === `${baseName}.spec` ||
554
+ normalizedFile.startsWith(`${normalizedTargetDir}/`));
555
+ });
556
+ // 只保留确实存在且非空的
557
+ const result = [];
558
+ for (const file of candidates) {
559
+ const fullPath = path.join(ctx.projectPath, file);
560
+ const content = await ctx.fileSystem.readText(fullPath);
561
+ if (content !== undefined && content.trim().length > 0) {
562
+ result.push(file);
563
+ }
564
+ }
565
+ return result.sort();
566
+ }
521
567
  function findRelatedTestFiles(task, changedTestFiles) {
522
568
  if (changedTestFiles.length === 0)
523
569
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@angli/unit-test-tool",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "testbot": "./dist/src/cli/index.js"
@@ -40,6 +40,21 @@ export async function runLoop(
40
40
 
41
41
  if (result.targetFile) attemptedTargets.add(result.targetFile)
42
42
 
43
+ // 如果当前目标失败(CLI 失败或 no-op),且不是显式指定文件,则标记为跳过并继续下一轮
44
+ const isBlocked = !result.ok && !input.targetFile
45
+ if (isBlocked) {
46
+ blockedTargets += 1
47
+ await syncLoopSummary(ctx, {
48
+ iterationCount,
49
+ completedTargets,
50
+ blockedTargets,
51
+ noOpTargets,
52
+ lastCompletedTarget
53
+ })
54
+ if (blockedTargets >= Math.max(1, ctx.limits.maxRetryPerTask)) break
55
+ if (iterationCount >= maxIterations) break
56
+ continue
57
+ }
43
58
 
44
59
  const coverageSummary = await ctx.reportStore.loadCoverageSummary<{ coverageSnapshot?: { lines?: number } }>()
45
60
  const currentCoverage = coverageSummary?.coverageSnapshot?.lines
@@ -61,11 +76,7 @@ export async function runLoop(
61
76
  continue
62
77
  }
63
78
 
64
- if (result.summary.includes('no-op')) {
65
- noOpTargets += 1
66
- } else {
67
- blockedTargets += 1
68
- }
79
+ noOpTargets += 1
69
80
 
70
81
  await syncLoopSummary(ctx, {
71
82
  iterationCount,
@@ -76,7 +87,6 @@ export async function runLoop(
76
87
  })
77
88
 
78
89
  if (input.targetFile) break
79
- if (blockedTargets >= Math.max(1, ctx.limits.maxRetryPerTask)) break
80
90
  if (noOpTargets >= Math.max(1, ctx.limits.loopThreshold)) break
81
91
  if (!result.targetFile) break
82
92
  }
@@ -206,7 +206,8 @@ export async function runWithClaudeCli(
206
206
  summary: `Claude CLI 执行失败:${firstRun.failureCategory ?? 'unknown'}。`
207
207
  }, 'cli_run_failed', {
208
208
  stdoutPath: firstStdoutPath,
209
- stderrPath: firstStderrPath
209
+ stderrPath: firstStderrPath,
210
+ failureMessage
210
211
  })
211
212
  await markRunFailed(ctx, target.task, failureMessage)
212
213
  return {
@@ -388,7 +389,8 @@ export async function runWithClaudeCli(
388
389
  summary: `Claude CLI 重试失败:${retryRun.failureCategory ?? 'unknown'}。`
389
390
  }, 'cli_run_failed', {
390
391
  stdoutPath: retryStdoutSaved,
391
- stderrPath: retryStderrSaved
392
+ stderrPath: retryStderrSaved,
393
+ failureMessage
392
394
  })
393
395
  await markRunFailed(ctx, target.task, failureMessage)
394
396
  return {
@@ -742,6 +744,22 @@ async function validateTestChanges(
742
744
  }
743
745
  }
744
746
 
747
+ // Snapshot diff 未命中时,兜底检查:是否有新创建的文件匹配目标
748
+ const newCandidateFiles = await detectNewTestFiles(ctx, task)
749
+ if (newCandidateFiles.length > 0) {
750
+ ctx.logger.info(
751
+ `[run] step=test-change status=new-files taskId=${task.taskId} target=${task.targetFile} new=${newCandidateFiles.join(',')}`
752
+ )
753
+ return {
754
+ ok: true,
755
+ phase: 'DONE',
756
+ summary: '检测到新创建的测试文件。',
757
+ targetFile: task.targetFile,
758
+ changedTestFiles: newCandidateFiles,
759
+ relatedTestFiles: newCandidateFiles
760
+ }
761
+ }
762
+
745
763
  ctx.logger.warn(
746
764
  `[run] step=test-change status=noop taskId=${task.taskId} target=${task.targetFile} changed=${changedTestFiles.join(',') || 'none'}`
747
765
  )
@@ -760,6 +778,43 @@ async function validateTestChanges(
760
778
  }
761
779
  }
762
780
 
781
+ async function detectNewTestFiles(ctx: AppContext, task: TaskItem): Promise<string[]> {
782
+ const patterns = buildTestSnapshotPatterns(ctx.config.testDirNames)
783
+ const files = await fg(patterns, {
784
+ cwd: ctx.projectPath,
785
+ ignore: ctx.config.exclude,
786
+ onlyFiles: true,
787
+ absolute: false
788
+ })
789
+
790
+ const baseName = path.basename(task.targetFile, path.extname(task.targetFile))
791
+ const targetDir = path.dirname(task.targetFile)
792
+ const normalizedTargetDir = normalizePath(targetDir)
793
+
794
+ const candidates = files.filter((file) => {
795
+ const fileBaseName = path.basename(file, path.extname(file))
796
+ const normalizedFile = normalizePath(file)
797
+ // 匹配同名或同目录下的测试文件
798
+ return (
799
+ fileBaseName === baseName ||
800
+ fileBaseName === `${baseName}.test` ||
801
+ fileBaseName === `${baseName}.spec` ||
802
+ normalizedFile.startsWith(`${normalizedTargetDir}/`)
803
+ )
804
+ })
805
+
806
+ // 只保留确实存在且非空的
807
+ const result: string[] = []
808
+ for (const file of candidates) {
809
+ const fullPath = path.join(ctx.projectPath, file)
810
+ const content = await ctx.fileSystem.readText(fullPath)
811
+ if (content !== undefined && content.trim().length > 0) {
812
+ result.push(file)
813
+ }
814
+ }
815
+ return result.sort()
816
+ }
817
+
763
818
  function findRelatedTestFiles(task: TaskItem, changedTestFiles: string[]): string[] {
764
819
  if (changedTestFiles.length === 0) return []
765
820