Skip to content

Commit 21ac9e4

Browse files
test: Phase 2-4 — 添加 12 个测试文件 (+321 tests, 968 total)
Phase 2 (轻 Mock): envUtils, sleep/sequential, memoize, groupToolUses, dangerousPatterns, outputLimits Phase 3 (补全): zodToJsonSchema, PermissionMode, envValidation Phase 4 (工具模块): mcpStringUtils, destructiveCommandWarning, commandSemantics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2d9c2ad commit 21ac9e4

13 files changed

Lines changed: 1668 additions & 44 deletions

File tree

docs/testing-spec.md

Lines changed: 44 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ bun test --watch
300300

301301
## 11. 当前测试覆盖状态
302302

303-
> 更新日期:2026-04-02 | 总计:**647 tests, 32 files, 0 failures**
303+
> 更新日期:2026-04-02 | 总计:**968 tests, 52 files, 0 failures**
304304
305305
### P0 — 核心模块
306306

@@ -348,6 +348,41 @@ bun test --watch
348348
| 08 - Git 工具 | `src/utils/__tests__/git.test.ts` | 18 | normalizeGitRemoteUrl (SSH/HTTPS/ssh:///代理URL/大小写规范化) |
349349
| 09 - 配置与设置 | `src/utils/settings/__tests__/config.test.ts` | 62 | SettingsSchema, PermissionsSchema, AllowedMcpServerEntrySchema, MCP 类型守卫, 设置常量函数, filterInvalidPermissionRules, validateSettingsFileContent, formatZodError |
350350

351+
### P3 — Phase 1 纯函数扩展
352+
353+
| 测试文件 | 测试数 | 覆盖范围 |
354+
|----------|--------|----------|
355+
| `src/utils/__tests__/errors.test.ts` | 28 | ClaudeError, AbortError, ConfigParseError, ShellError, TelemetrySafeError, isAbortError, hasExactErrorMessage, toError, errorMessage, getErrnoCode, isENOENT, getErrnoPath, shortErrorStack, isFsInaccessible, classifyAxiosError |
356+
| `src/utils/permissions/__tests__/shellRuleMatching.test.ts` | 22 | permissionRuleExtractPrefix, hasWildcards, matchWildcardPattern, parsePermissionRule, suggestionForExactCommand, suggestionForPrefix |
357+
| `src/utils/__tests__/argumentSubstitution.test.ts` | 18 | parseArguments, parseArgumentNames, generateProgressiveArgumentHint, substituteArguments |
358+
| `src/utils/__tests__/CircularBuffer.test.ts` | 12 | CircularBuffer class: add, addAll, getRecent, toArray, clear, length |
359+
| `src/utils/__tests__/sanitization.test.ts` | 14 | partiallySanitizeUnicode, recursivelySanitizeUnicode |
360+
| `src/utils/__tests__/slashCommandParsing.test.ts` | 8 | parseSlashCommand |
361+
| `src/utils/__tests__/contentArray.test.ts` | 6 | insertBlockAfterToolResults |
362+
| `src/utils/__tests__/objectGroupBy.test.ts` | 5 | objectGroupBy |
363+
364+
### P4 — Phase 2 轻 Mock 扩展
365+
366+
| 测试文件 | 测试数 | 覆盖范围 |
367+
|----------|--------|----------|
368+
| `src/utils/__tests__/envUtils.test.ts` | 34 | isEnvTruthy, isEnvDefinedFalsy, parseEnvVars, hasNodeOption, getAWSRegion, getDefaultVertexRegion, getVertexRegionForModel, isBareMode, shouldMaintainProjectWorkingDir, getClaudeConfigHomeDir |
369+
| `src/utils/__tests__/sleep.test.ts` | 14 | sleep (abort, throwOnAbort, abortError), withTimeout, sequential |
370+
| `src/utils/__tests__/memoize.test.ts` | 16 | memoizeWithTTL, memoizeWithTTLAsync (dedup/cache/clear), memoizeWithLRU (eviction/cache methods) |
371+
| `src/utils/__tests__/groupToolUses.test.ts` | 10 | applyGrouping (verbose, grouping, result collection, mixed messages) |
372+
| `src/utils/permissions/__tests__/dangerousPatterns.test.ts` | 7 | CROSS_PLATFORM_CODE_EXEC, DANGEROUS_BASH_PATTERNS 常量验证 |
373+
| `src/utils/shell/__tests__/outputLimits.test.ts` | 7 | getMaxOutputLength, BASH_MAX_OUTPUT_UPPER_LIMIT, BASH_MAX_OUTPUT_DEFAULT |
374+
375+
### P5 — Phase 3 补全 + Phase 4 工具模块
376+
377+
| 测试文件 | 测试数 | 覆盖范围 |
378+
|----------|--------|----------|
379+
| `src/utils/__tests__/zodToJsonSchema.test.ts` | 9 | zodToJsonSchema (string/number/object/enum/optional/array/boolean + caching) |
380+
| `src/utils/permissions/__tests__/PermissionMode.test.ts` | 19 | PERMISSION_MODES, permissionModeFromString, permissionModeTitle, permissionModeShortTitle, permissionModeSymbol, getModeColor, isDefaultMode, toExternalPermissionMode, isExternalPermissionMode |
381+
| `src/utils/__tests__/envValidation.test.ts` | 9 | validateBoundedIntEnvVar (default/valid/capped/invalid/boundary) |
382+
| `src/services/mcp/__tests__/mcpStringUtils.test.ts` | 18 | mcpInfoFromString, getMcpPrefix, buildMcpToolName, getMcpDisplayName, getToolNameForPermissionCheck, extractMcpToolDisplayName |
383+
| `src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts` | 22 | getDestructiveCommandWarning (git/rm/database/infrastructure patterns) |
384+
| `src/tools/BashTool/__tests__/commandSemantics.test.ts` | 11 | interpretCommandResult (grep/diff/test/rg/find exit code semantics) |
385+
351386
### 已知限制
352387

353388
以下模块因 Bun 运行时限制或极重依赖链,暂时无法或不适合测试:
@@ -365,55 +400,20 @@ bun test --watch
365400

366401
| 被 Mock 模块 | 解锁的测试 |
367402
|-------------|-----------|
368-
| `src/utils/log.ts` | json.ts, tokens.ts, FileEditTool/utils.ts, permissions.ts |
403+
| `src/utils/log.ts` | json.ts, tokens.ts, FileEditTool/utils.ts, permissions.ts, memoize.ts, PermissionMode.ts |
369404
| `src/services/tokenEstimation.ts` | tokens.ts |
370-
| `src/utils/slowOperations.ts` | tokens.ts, permissions.ts |
405+
| `src/utils/slowOperations.ts` | tokens.ts, permissions.ts, memoize.ts, PermissionMode.ts |
406+
| `src/utils/debug.ts` | envValidation.ts, outputLimits.ts |
407+
| `src/utils/bash/commands.ts` | commandSemantics.ts |
371408

372409
**关键约束**`mock.module()` 必须在每个测试文件中内联调用,不能从共享 helper 导入(Bun 在 mock 生效前就解析了 helper 的导入)。
373410

374411
## 12. 后续测试覆盖计划
375412

376-
> 目标:再增加 ~200 tests,从 647 → ~860 tests / 52 files
377-
378-
### Phase 1:纯函数(零依赖,~98 tests,8 files)
379-
380-
| 测试文件 | 源文件 | 关键函数 | 预估 |
381-
|----------|--------|----------|------|
382-
| `errors.test.ts` | `src/utils/errors.ts` | `isAbortError`, `toError`, `errorMessage`, `getErrnoCode`, `isENOENT`, `isFsInaccessible`, `classifyAxiosError` + Error classes | 20 |
383-
| `shellRuleMatching.test.ts` | `src/utils/permissions/shellRuleMatching.ts` | `permissionRuleExtractPrefix`, `hasWildcards`, `matchWildcardPattern`, `parsePermissionRule`, `suggestionForExactCommand` | 20 |
384-
| `argumentSubstitution.test.ts` | `src/utils/argumentSubstitution.ts` | `parseArguments`, `parseArgumentNames`, `generateProgressiveArgumentHint`, `substituteArguments` | 15 |
385-
| `CircularBuffer.test.ts` | `src/utils/CircularBuffer.ts` | `CircularBuffer` class 全部方法 | 12 |
386-
| `sanitization.test.ts` | `src/utils/sanitization.ts` | `partiallySanitizeUnicode`, `recursivelySanitizeUnicode` | 10 |
387-
| `slashCommandParsing.test.ts` | `src/utils/slashCommandParsing.ts` | `parseSlashCommand` | 8 |
388-
| `contentArray.test.ts` | `src/utils/contentArray.ts` | `insertBlockAfterToolResults` | 8 |
389-
| `objectGroupBy.test.ts` | `src/utils/objectGroupBy.ts` | `objectGroupBy` | 5 |
390-
391-
### Phase 2:轻 Mock(mock log.ts / env,~63 tests,6 files)
392-
393-
| 测试文件 | 源文件 | Mock 策略 | 预估 |
394-
|----------|--------|-----------|------|
395-
| `envUtils.test.ts` | `src/utils/envUtils.ts` | 临时修改 `process.env` | 15 |
396-
| `sleep.test.ts` | `src/utils/sleep.ts` + `sequential.ts` | AbortController | 14 |
397-
| `memoize.test.ts` | `src/utils/memoize.ts` | mock `log.ts` + `slowOperations.ts` | 12 |
398-
| `groupToolUses.test.ts` | `src/utils/groupToolUses.ts` | 构造 mock message/tool 对象 | 12 |
399-
| `dangerousPatterns.test.ts` | `src/utils/permissions/dangerousPatterns.ts` | 无(常量导出) | 5 |
400-
| `outputLimits.test.ts` | `src/utils/shell/outputLimits.ts` | 临时修改 `process.env` | 5 |
401-
402-
### Phase 3:补全现有计划缺口(~20 tests,3 files)
403-
404-
| 测试文件 | 源文件 | Mock 策略 | 预估 |
405-
|----------|--------|-----------|------|
406-
| `context.test.ts` | `src/context.ts` | mock `execFileNoThrow`, `log.ts` | 10 |
407-
| `zodToJsonSchema.test.ts` | `src/utils/zodToJsonSchema.ts` | 无(仅依赖 zod) | 5 |
408-
| `PermissionMode.test.ts` | `src/utils/permissions/PermissionMode.ts` | 视导出情况 | 5 |
409-
410-
### Phase 4:工具模块扩展(~30 tests,3 files)
411-
412-
| 测试文件 | 源文件 | 预估 |
413-
|----------|--------|------|
414-
| `bashPermissions.test.ts` | `src/tools/BashTool/` | 10 |
415-
| `GlobTool.test.ts` | `src/tools/GlobTool/` | 10 |
416-
| `mcpStringUtils.test.ts` | `src/services/mcp/mcpStringUtils.ts` | 10 |
413+
> **已完成** — 实际增加 321 tests,从 647 → 968 tests / 52 files
414+
>
415+
> Phase 1-4 全部完成,详见上方 P3-P5 表格。
416+
> 实际调整:Phase 3 中 `context.ts` 因极重依赖链(bootstrap/state + claudemd + git 等)且 `getGitStatus` 在 test 环境直接返回 null,替换为 `envValidation.ts`(更实用);Phase 4 中 GlobTool 纯函数不足,替换为 `commandSemantics.ts` + `destructiveCommandWarning.ts`
417417
418418
### 不纳入计划的模块
419419

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
mcpInfoFromString,
4+
buildMcpToolName,
5+
getMcpPrefix,
6+
getMcpDisplayName,
7+
getToolNameForPermissionCheck,
8+
extractMcpToolDisplayName,
9+
} from "../mcpStringUtils";
10+
11+
// ─── mcpInfoFromString ─────────────────────────────────────────────────
12+
13+
describe("mcpInfoFromString", () => {
14+
test("parses standard mcp tool name", () => {
15+
const result = mcpInfoFromString("mcp__github__list_issues");
16+
expect(result).toEqual({ serverName: "github", toolName: "list_issues" });
17+
});
18+
19+
test("returns null for non-mcp string", () => {
20+
expect(mcpInfoFromString("Bash")).toBeNull();
21+
expect(mcpInfoFromString("grep__pattern")).toBeNull();
22+
});
23+
24+
test("returns null when no server name", () => {
25+
expect(mcpInfoFromString("mcp__")).toBeNull();
26+
});
27+
28+
test("handles server name only (no tool)", () => {
29+
const result = mcpInfoFromString("mcp__server");
30+
expect(result).toEqual({ serverName: "server", toolName: undefined });
31+
});
32+
33+
test("preserves double underscores in tool name", () => {
34+
const result = mcpInfoFromString("mcp__server__tool__with__underscores");
35+
expect(result).toEqual({
36+
serverName: "server",
37+
toolName: "tool__with__underscores",
38+
});
39+
});
40+
41+
test("returns null for empty string", () => {
42+
expect(mcpInfoFromString("")).toBeNull();
43+
});
44+
});
45+
46+
// ─── getMcpPrefix ──────────────────────────────────────────────────────
47+
48+
describe("getMcpPrefix", () => {
49+
test("creates prefix from server name", () => {
50+
expect(getMcpPrefix("github")).toBe("mcp__github__");
51+
});
52+
53+
test("normalizes server name with special chars", () => {
54+
expect(getMcpPrefix("my-server")).toBe("mcp__my-server__");
55+
});
56+
57+
test("normalizes dots to underscores", () => {
58+
expect(getMcpPrefix("my.server")).toBe("mcp__my_server__");
59+
});
60+
});
61+
62+
// ─── buildMcpToolName ──────────────────────────────────────────────────
63+
64+
describe("buildMcpToolName", () => {
65+
test("builds fully qualified name", () => {
66+
expect(buildMcpToolName("github", "list_issues")).toBe(
67+
"mcp__github__list_issues"
68+
);
69+
});
70+
71+
test("normalizes both server and tool names", () => {
72+
expect(buildMcpToolName("my.server", "my.tool")).toBe(
73+
"mcp__my_server__my_tool"
74+
);
75+
});
76+
});
77+
78+
// ─── getMcpDisplayName ─────────────────────────────────────────────────
79+
80+
describe("getMcpDisplayName", () => {
81+
test("strips mcp prefix from full name", () => {
82+
expect(getMcpDisplayName("mcp__github__list_issues", "github")).toBe(
83+
"list_issues"
84+
);
85+
});
86+
87+
test("returns full name if prefix doesn't match", () => {
88+
expect(getMcpDisplayName("mcp__other__tool", "github")).toBe(
89+
"mcp__other__tool"
90+
);
91+
});
92+
});
93+
94+
// ─── getToolNameForPermissionCheck ─────────────────────────────────────
95+
96+
describe("getToolNameForPermissionCheck", () => {
97+
test("returns built MCP name for MCP tools", () => {
98+
const tool = {
99+
name: "list_issues",
100+
mcpInfo: { serverName: "github", toolName: "list_issues" },
101+
};
102+
expect(getToolNameForPermissionCheck(tool)).toBe(
103+
"mcp__github__list_issues"
104+
);
105+
});
106+
107+
test("returns tool name for non-MCP tools", () => {
108+
const tool = { name: "Bash" };
109+
expect(getToolNameForPermissionCheck(tool)).toBe("Bash");
110+
});
111+
112+
test("returns tool name when mcpInfo is undefined", () => {
113+
const tool = { name: "Write", mcpInfo: undefined };
114+
expect(getToolNameForPermissionCheck(tool)).toBe("Write");
115+
});
116+
});
117+
118+
// ─── extractMcpToolDisplayName ─────────────────────────────────────────
119+
120+
describe("extractMcpToolDisplayName", () => {
121+
test("extracts display name from full user-facing name", () => {
122+
expect(
123+
extractMcpToolDisplayName("github - Add comment to issue (MCP)")
124+
).toBe("Add comment to issue");
125+
});
126+
127+
test("removes (MCP) suffix only", () => {
128+
expect(extractMcpToolDisplayName("simple-tool (MCP)")).toBe("simple-tool");
129+
});
130+
131+
test("handles name without (MCP) suffix", () => {
132+
expect(extractMcpToolDisplayName("github - List issues")).toBe(
133+
"List issues"
134+
);
135+
});
136+
137+
test("handles name without dash separator", () => {
138+
expect(extractMcpToolDisplayName("just-a-name")).toBe("just-a-name");
139+
});
140+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { mock, describe, expect, test } from "bun:test";
2+
3+
// Mock commands.ts to cut the heavy shell/prefix.ts → analytics → api chain
4+
mock.module("src/utils/bash/commands.ts", () => ({
5+
splitCommand_DEPRECATED: (cmd: string) =>
6+
cmd.split(/\s*(?:[|;&]+)\s*/).filter(Boolean),
7+
quote: (args: string[]) => args.join(" "),
8+
}));
9+
10+
const { interpretCommandResult } = await import("../commandSemantics");
11+
12+
describe("interpretCommandResult", () => {
13+
// ─── Default semantics ────────────────────────────────────────────
14+
test("exit 0 is not an error for unknown commands", () => {
15+
const result = interpretCommandResult("echo hello", 0, "hello", "");
16+
expect(result.isError).toBe(false);
17+
});
18+
19+
test("non-zero exit is an error for unknown commands", () => {
20+
const result = interpretCommandResult("echo hello", 1, "", "fail");
21+
expect(result.isError).toBe(true);
22+
expect(result.message).toContain("exit code 1");
23+
});
24+
25+
// ─── grep semantics ──────────────────────────────────────────────
26+
test("grep exit 0 is not an error", () => {
27+
const result = interpretCommandResult("grep pattern file", 0, "match", "");
28+
expect(result.isError).toBe(false);
29+
});
30+
31+
test("grep exit 1 means no matches (not error)", () => {
32+
const result = interpretCommandResult("grep pattern file", 1, "", "");
33+
expect(result.isError).toBe(false);
34+
expect(result.message).toBe("No matches found");
35+
});
36+
37+
test("grep exit 2 is an error", () => {
38+
const result = interpretCommandResult("grep pattern file", 2, "", "err");
39+
expect(result.isError).toBe(true);
40+
});
41+
42+
// ─── diff semantics ──────────────────────────────────────────────
43+
test("diff exit 1 means files differ (not error)", () => {
44+
const result = interpretCommandResult("diff a.txt b.txt", 1, "diff", "");
45+
expect(result.isError).toBe(false);
46+
expect(result.message).toBe("Files differ");
47+
});
48+
49+
test("diff exit 2 is an error", () => {
50+
const result = interpretCommandResult("diff a.txt b.txt", 2, "", "err");
51+
expect(result.isError).toBe(true);
52+
});
53+
54+
// ─── test/[ semantics ────────────────────────────────────────────
55+
test("test exit 1 means condition false (not error)", () => {
56+
const result = interpretCommandResult("test -f nofile", 1, "", "");
57+
expect(result.isError).toBe(false);
58+
expect(result.message).toBe("Condition is false");
59+
});
60+
61+
// ─── piped commands ──────────────────────────────────────────────
62+
test("uses last command in pipe for semantics", () => {
63+
// "cat file | grep pattern" → last command is "grep pattern"
64+
const result = interpretCommandResult(
65+
"cat file | grep pattern",
66+
1,
67+
"",
68+
""
69+
);
70+
expect(result.isError).toBe(false);
71+
expect(result.message).toBe("No matches found");
72+
});
73+
74+
// ─── rg (ripgrep) semantics ──────────────────────────────────────
75+
test("rg exit 1 means no matches (not error)", () => {
76+
const result = interpretCommandResult("rg pattern", 1, "", "");
77+
expect(result.isError).toBe(false);
78+
expect(result.message).toBe("No matches found");
79+
});
80+
81+
// ─── find semantics ──────────────────────────────────────────────
82+
test("find exit 1 is partial success", () => {
83+
const result = interpretCommandResult("find . -name '*.ts'", 1, "", "");
84+
expect(result.isError).toBe(false);
85+
expect(result.message).toBe("Some directories were inaccessible");
86+
});
87+
});

0 commit comments

Comments
 (0)