mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
docs: 完善测试文档编写
This commit is contained in:
147
docs/test-plans/01-tool-system.md
Normal file
147
docs/test-plans/01-tool-system.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Tool 系统测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
Tool 系统是 Claude Code 的核心,负责工具的定义、注册、发现和过滤。本计划覆盖 `src/Tool.ts` 中的工具接口与工具函数、`src/tools.ts` 中的注册/过滤逻辑,以及各工具目录下可独立测试的纯函数。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/Tool.ts` | `buildTool`, `toolMatchesName`, `findToolByName`, `getEmptyToolPermissionContext`, `filterToolProgressMessages` |
|
||||
| `src/tools.ts` | `parseToolPreset`, `filterToolsByDenyRules`, `getAllBaseTools`, `getTools`, `assembleToolPool` |
|
||||
| `src/tools/shared/gitOperationTracking.ts` | `parseGitCommitId`, `detectGitOperation` |
|
||||
| `src/tools/shared/spawnMultiAgent.ts` | `resolveTeammateModel`, `generateUniqueTeammateName` |
|
||||
| `src/tools/GrepTool/GrepTool.ts` | `applyHeadLimit`, `formatLimitInfo`(内部辅助函数) |
|
||||
| `src/tools/FileEditTool/utils.ts` | 字符串匹配/补丁相关纯函数 |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/Tool.ts
|
||||
|
||||
#### describe('buildTool')
|
||||
|
||||
- test('fills in default isEnabled as true') — 不传 isEnabled 时,构建的 tool.isEnabled() 应返回 true
|
||||
- test('fills in default isConcurrencySafe as false') — 默认值应为 false(fail-closed)
|
||||
- test('fills in default isReadOnly as false') — 默认假设有写操作
|
||||
- test('fills in default isDestructive as false') — 默认非破坏性
|
||||
- test('fills in default checkPermissions as allow') — 默认 checkPermissions 应返回 `{ behavior: 'allow', updatedInput }`
|
||||
- test('fills in default userFacingName from tool name') — userFacingName 默认应返回 tool.name
|
||||
- test('preserves explicitly provided methods') — 传入自定义 isEnabled 等方法时应覆盖默认值
|
||||
- test('preserves all non-defaultable properties') — name, inputSchema, call, description 等属性原样保留
|
||||
|
||||
#### describe('toolMatchesName')
|
||||
|
||||
- test('returns true for exact name match') — `{ name: 'Bash' }` 匹配 'Bash'
|
||||
- test('returns false for non-matching name') — `{ name: 'Bash' }` 不匹配 'Read'
|
||||
- test('returns true when name matches an alias') — `{ name: 'Bash', aliases: ['BashTool'] }` 匹配 'BashTool'
|
||||
- test('returns false when aliases is undefined') — `{ name: 'Bash' }` 不匹配 'BashTool'
|
||||
- test('returns false when aliases is empty') — `{ name: 'Bash', aliases: [] }` 不匹配 'BashTool'
|
||||
|
||||
#### describe('findToolByName')
|
||||
|
||||
- test('finds tool by primary name') — 从 tools 列表中按 name 找到工具
|
||||
- test('finds tool by alias') — 从 tools 列表中按 alias 找到工具
|
||||
- test('returns undefined when no match') — 找不到时返回 undefined
|
||||
- test('returns first match when duplicates exist') — 多个同名工具时返回第一个
|
||||
|
||||
#### describe('getEmptyToolPermissionContext')
|
||||
|
||||
- test('returns default permission mode') — mode 应为 'default'
|
||||
- test('returns empty maps and arrays') — additionalWorkingDirectories 为空 Map,rules 为空对象
|
||||
- test('returns isBypassPermissionsModeAvailable as false')
|
||||
|
||||
#### describe('filterToolProgressMessages')
|
||||
|
||||
- test('filters out hook_progress messages') — 移除 type 为 hook_progress 的消息
|
||||
- test('keeps tool progress messages') — 保留非 hook_progress 的消息
|
||||
- test('returns empty array for empty input')
|
||||
- test('handles messages without type field') — data 不含 type 时应保留
|
||||
|
||||
---
|
||||
|
||||
### src/tools.ts
|
||||
|
||||
#### describe('parseToolPreset')
|
||||
|
||||
- test('returns "default" for "default" input') — 精确匹配
|
||||
- test('returns "default" for "Default" input') — 大小写不敏感
|
||||
- test('returns null for unknown preset') — 未知字符串返回 null
|
||||
- test('returns null for empty string')
|
||||
|
||||
#### describe('filterToolsByDenyRules')
|
||||
|
||||
- test('returns all tools when no deny rules') — 空 deny 规则不过滤任何工具
|
||||
- test('filters out tools matching blanket deny rule') — deny rule `{ toolName: 'Bash' }` 应移除 Bash
|
||||
- test('does not filter tools with content-specific deny rules') — deny rule `{ toolName: 'Bash', ruleContent: 'rm -rf' }` 不移除 Bash(只在运行时阻止特定命令)
|
||||
- test('filters MCP tools by server name prefix') — deny rule `mcp__server` 应移除该 server 下所有工具
|
||||
- test('preserves tools not matching any deny rule')
|
||||
|
||||
#### describe('getAllBaseTools')
|
||||
|
||||
- test('returns a non-empty array of tools') — 至少包含核心工具
|
||||
- test('each tool has required properties') — 每个工具应有 name, inputSchema, call 等属性
|
||||
- test('includes BashTool, FileReadTool, FileEditTool') — 核心工具始终存在
|
||||
- test('includes TestingPermissionTool when NODE_ENV is test') — 需设置 env
|
||||
|
||||
#### describe('getTools')
|
||||
|
||||
- test('returns filtered tools based on permission context') — 根据 deny rules 过滤
|
||||
- test('returns simple tools in CLAUDE_CODE_SIMPLE mode') — 仅返回 Bash/Read/Edit
|
||||
- test('filters disabled tools via isEnabled') — isEnabled 返回 false 的工具被排除
|
||||
|
||||
---
|
||||
|
||||
### src/tools/shared/gitOperationTracking.ts
|
||||
|
||||
#### describe('parseGitCommitId')
|
||||
|
||||
- test('extracts commit hash from git commit output') — 从 `[main abc1234] message` 中提取 `abc1234`
|
||||
- test('returns null for non-commit output') — 无法解析时返回 null
|
||||
- test('handles various branch name formats') — `[feature/foo abc1234]` 等
|
||||
|
||||
#### describe('detectGitOperation')
|
||||
|
||||
- test('detects git commit operation') — 命令含 `git commit` 时识别为 commit
|
||||
- test('detects git push operation') — 命令含 `git push` 时识别
|
||||
- test('returns null for non-git commands') — 非 git 命令返回 null
|
||||
- test('detects git merge operation')
|
||||
- test('detects git rebase operation')
|
||||
|
||||
---
|
||||
|
||||
### src/tools/shared/spawnMultiAgent.ts
|
||||
|
||||
#### describe('resolveTeammateModel')
|
||||
|
||||
- test('returns specified model when provided')
|
||||
- test('falls back to default model when not specified')
|
||||
|
||||
#### describe('generateUniqueTeammateName')
|
||||
|
||||
- test('generates a name when no existing names') — 无冲突时返回基础名
|
||||
- test('appends suffix when name conflicts') — 与已有名称冲突时添加后缀
|
||||
- test('handles multiple conflicts') — 多次冲突时递增后缀
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `bun:bundle` (feature) | 已 polyfill 为 `() => false` | 不需额外 mock |
|
||||
| `process.env` | `bun:test` mock | 测试 `USER_TYPE`、`NODE_ENV`、`CLAUDE_CODE_SIMPLE` |
|
||||
| `getDenyRuleForTool` | mock module | `filterToolsByDenyRules` 测试中需控制返回值 |
|
||||
| `isToolSearchEnabledOptimistic` | mock module | `getAllBaseTools` 中条件加载 |
|
||||
|
||||
## 集成测试场景
|
||||
|
||||
放在 `tests/integration/tool-chain.test.ts`:
|
||||
|
||||
### describe('Tool registration and discovery')
|
||||
|
||||
- test('getAllBaseTools returns tools that can be found by findToolByName') — 注册 → 查找完整链路
|
||||
- test('filterToolsByDenyRules + getTools produces consistent results') — 过滤管线一致性
|
||||
- test('assembleToolPool deduplicates built-in and MCP tools') — 合并去重逻辑
|
||||
416
docs/test-plans/02-utils-pure-functions.md
Normal file
416
docs/test-plans/02-utils-pure-functions.md
Normal file
@@ -0,0 +1,416 @@
|
||||
# 工具函数(纯函数)测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
覆盖 `src/utils/` 下所有可独立单元测试的纯函数。这些函数无外部依赖,输入输出确定性强,是测试金字塔的底层基石。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 状态 | 关键导出 |
|
||||
|------|------|----------|
|
||||
| `src/utils/array.ts` | **已有测试** | intersperse, count, uniq |
|
||||
| `src/utils/set.ts` | **已有测试** | difference, intersects, every, union |
|
||||
| `src/utils/xml.ts` | 待测 | escapeXml, escapeXmlAttr |
|
||||
| `src/utils/hash.ts` | 待测 | djb2Hash, hashContent, hashPair |
|
||||
| `src/utils/stringUtils.ts` | 待测 | escapeRegExp, capitalize, plural, firstLineOf, countCharInString, normalizeFullWidthDigits, normalizeFullWidthSpace, safeJoinLines, truncateToLines, EndTruncatingAccumulator |
|
||||
| `src/utils/semver.ts` | 待测 | gt, gte, lt, lte, satisfies, order |
|
||||
| `src/utils/uuid.ts` | 待测 | validateUuid, createAgentId |
|
||||
| `src/utils/format.ts` | 待测 | formatFileSize, formatSecondsShort, formatDuration, formatNumber, formatTokens, formatRelativeTime, formatRelativeTimeAgo |
|
||||
| `src/utils/json.ts` | 待测 | safeParseJSON, safeParseJSONC, parseJSONL, addItemToJSONCArray |
|
||||
| `src/utils/truncate.ts` | 待测 | truncatePathMiddle, truncateToWidth, truncateStartToWidth, truncateToWidthNoEllipsis, truncate, wrapText |
|
||||
| `src/utils/diff.ts` | 待测 | adjustHunkLineNumbers, getPatchFromContents |
|
||||
| `src/utils/frontmatterParser.ts` | 待测 | parseFrontmatter, splitPathInFrontmatter, parsePositiveIntFromFrontmatter, parseBooleanFrontmatter, parseShellFrontmatter |
|
||||
| `src/utils/file.ts` | 待测(纯函数部分) | convertLeadingTabsToSpaces, addLineNumbers, stripLineNumberPrefix, pathsEqual, normalizePathForComparison |
|
||||
| `src/utils/glob.ts` | 待测(纯函数部分) | extractGlobBaseDirectory |
|
||||
| `src/utils/tokens.ts` | 待测 | getTokenCountFromUsage |
|
||||
| `src/utils/path.ts` | 待测(纯函数部分) | containsPathTraversal, normalizePathForConfigKey |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/utils/xml.ts — 测试文件: `src/utils/__tests__/xml.test.ts`
|
||||
|
||||
#### describe('escapeXml')
|
||||
|
||||
- test('escapes ampersand') — `&` → `&`
|
||||
- test('escapes less-than') — `<` → `<`
|
||||
- test('escapes greater-than') — `>` → `>`
|
||||
- test('does not escape quotes') — `"` 和 `'` 保持原样
|
||||
- test('handles empty string') — `""` → `""`
|
||||
- test('handles string with no special chars') — `"hello"` 原样返回
|
||||
- test('escapes multiple special chars in one string') — `<a & b>` → `<a & b>`
|
||||
|
||||
#### describe('escapeXmlAttr')
|
||||
|
||||
- test('escapes all xml chars plus quotes') — `"` → `"`, `'` → `'`
|
||||
- test('escapes double quotes') — `he said "hi"` 正确转义
|
||||
- test('escapes single quotes') — `it's` 正确转义
|
||||
|
||||
---
|
||||
|
||||
### src/utils/hash.ts — 测试文件: `src/utils/__tests__/hash.test.ts`
|
||||
|
||||
#### describe('djb2Hash')
|
||||
|
||||
- test('returns consistent hash for same input') — 相同输入返回相同结果
|
||||
- test('returns different hashes for different inputs') — 不同输入大概率不同
|
||||
- test('returns a 32-bit integer') — 结果在 int32 范围内
|
||||
- test('handles empty string') — 空字符串有确定的哈希值
|
||||
- test('handles unicode strings') — 中文/emoji 等正确处理
|
||||
|
||||
#### describe('hashContent')
|
||||
|
||||
- test('returns consistent hash for same content') — 确定性
|
||||
- test('returns string result') — 返回值为字符串
|
||||
|
||||
#### describe('hashPair')
|
||||
|
||||
- test('returns consistent hash for same pair') — 确定性
|
||||
- test('order matters') — hashPair(a, b) ≠ hashPair(b, a)
|
||||
- test('handles empty strings')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/stringUtils.ts — 测试文件: `src/utils/__tests__/stringUtils.test.ts`
|
||||
|
||||
#### describe('escapeRegExp')
|
||||
|
||||
- test('escapes dots') — `.` → `\\.`
|
||||
- test('escapes asterisks') — `*` → `\\*`
|
||||
- test('escapes brackets') — `[` → `\\[`
|
||||
- test('escapes all special chars') — `.*+?^${}()|[]\` 全部转义
|
||||
- test('leaves normal chars unchanged') — `hello` 原样
|
||||
- test('escaped string works in RegExp') — `new RegExp(escapeRegExp('a.b'))` 精确匹配 `a.b`
|
||||
|
||||
#### describe('capitalize')
|
||||
|
||||
- test('uppercases first char') — `"foo"` → `"Foo"`
|
||||
- test('does NOT lowercase rest') — `"fooBar"` → `"FooBar"`(区别于 lodash capitalize)
|
||||
- test('handles single char') — `"a"` → `"A"`
|
||||
- test('handles empty string') — `""` → `""`
|
||||
- test('handles already capitalized') — `"Foo"` → `"Foo"`
|
||||
|
||||
#### describe('plural')
|
||||
|
||||
- test('returns singular for n=1') — `plural(1, 'file')` → `'file'`
|
||||
- test('returns plural for n=0') — `plural(0, 'file')` → `'files'`
|
||||
- test('returns plural for n>1') — `plural(3, 'file')` → `'files'`
|
||||
- test('uses custom plural form') — `plural(2, 'entry', 'entries')` → `'entries'`
|
||||
|
||||
#### describe('firstLineOf')
|
||||
|
||||
- test('returns first line of multi-line string') — `"a\nb\nc"` → `"a"`
|
||||
- test('returns full string when no newline') — `"hello"` → `"hello"`
|
||||
- test('handles empty string') — `""` → `""`
|
||||
- test('handles string starting with newline') — `"\nhello"` → `""`
|
||||
|
||||
#### describe('countCharInString')
|
||||
|
||||
- test('counts occurrences') — `countCharInString("aabac", "a")` → `3`
|
||||
- test('returns 0 when char not found') — `countCharInString("hello", "x")` → `0`
|
||||
- test('handles empty string') — `countCharInString("", "a")` → `0`
|
||||
- test('respects start position') — `countCharInString("aaba", "a", 2)` → `1`
|
||||
|
||||
#### describe('normalizeFullWidthDigits')
|
||||
|
||||
- test('converts full-width digits to half-width') — `"0123"` → `"0123"`
|
||||
- test('leaves half-width digits unchanged') — `"0123"` → `"0123"`
|
||||
- test('mixed content') — `"port 8080"` → `"port 8080"`
|
||||
|
||||
#### describe('normalizeFullWidthSpace')
|
||||
|
||||
- test('converts ideographic space to regular space') — `"\u3000"` → `" "`
|
||||
- test('converts multiple spaces') — `"a\u3000b\u3000c"` → `"a b c"`
|
||||
|
||||
#### describe('safeJoinLines')
|
||||
|
||||
- test('joins lines with default delimiter') — `["a","b"]` → `"a,b"`
|
||||
- test('truncates when exceeding maxSize') — 超限时截断并添加 `...[truncated]`
|
||||
- test('handles empty array') — `[]` → `""`
|
||||
- test('uses custom delimiter') — delimiter 为 `"\n"` 时按行连接
|
||||
|
||||
#### describe('truncateToLines')
|
||||
|
||||
- test('returns full text when within limit') — 行数不超限时原样返回
|
||||
- test('truncates and adds ellipsis') — 超限时截断并加 `…`
|
||||
- test('handles exact limit') — 刚好等于 maxLines 时不截断
|
||||
- test('handles single line') — 单行文本不截断
|
||||
|
||||
#### describe('EndTruncatingAccumulator')
|
||||
|
||||
- test('accumulates strings normally within limit')
|
||||
- test('truncates when exceeding maxSize')
|
||||
- test('reports truncated status correctly')
|
||||
- test('reports totalBytes including truncated content')
|
||||
- test('toString includes truncation marker')
|
||||
- test('clear resets all state')
|
||||
- test('append with Buffer works') — 接受 Buffer 类型
|
||||
|
||||
---
|
||||
|
||||
### src/utils/semver.ts — 测试文件: `src/utils/__tests__/semver.test.ts`
|
||||
|
||||
#### describe('gt / gte / lt / lte')
|
||||
|
||||
- test('gt: 2.0.0 > 1.0.0') → true
|
||||
- test('gt: 1.0.0 > 1.0.0') → false
|
||||
- test('gte: 1.0.0 >= 1.0.0') → true
|
||||
- test('lt: 1.0.0 < 2.0.0') → true
|
||||
- test('lte: 1.0.0 <= 1.0.0') → true
|
||||
- test('handles pre-release versions') — `1.0.0-beta < 1.0.0`
|
||||
|
||||
#### describe('satisfies')
|
||||
|
||||
- test('version satisfies caret range') — `satisfies('1.2.3', '^1.0.0')` → true
|
||||
- test('version does not satisfy range') — `satisfies('2.0.0', '^1.0.0')` → false
|
||||
- test('exact match') — `satisfies('1.0.0', '1.0.0')` → true
|
||||
|
||||
#### describe('order')
|
||||
|
||||
- test('returns -1 for lesser') — `order('1.0.0', '2.0.0')` → -1
|
||||
- test('returns 0 for equal') — `order('1.0.0', '1.0.0')` → 0
|
||||
- test('returns 1 for greater') — `order('2.0.0', '1.0.0')` → 1
|
||||
|
||||
---
|
||||
|
||||
### src/utils/uuid.ts — 测试文件: `src/utils/__tests__/uuid.test.ts`
|
||||
|
||||
#### describe('validateUuid')
|
||||
|
||||
- test('accepts valid v4 UUID') — `'550e8400-e29b-41d4-a716-446655440000'` → 返回 UUID
|
||||
- test('returns null for invalid format') — `'not-a-uuid'` → null
|
||||
- test('returns null for empty string') — `''` → null
|
||||
- test('returns null for null/undefined input')
|
||||
- test('accepts uppercase UUIDs') — 大写字母有效
|
||||
|
||||
#### describe('createAgentId')
|
||||
|
||||
- test('returns string starting with "a"') — 前缀为 `a`
|
||||
- test('has correct length') — 前缀 + 16 hex 字符
|
||||
- test('generates unique ids') — 连续两次调用结果不同
|
||||
|
||||
---
|
||||
|
||||
### src/utils/format.ts — 测试文件: `src/utils/__tests__/format.test.ts`
|
||||
|
||||
#### describe('formatFileSize')
|
||||
|
||||
- test('formats bytes') — `500` → `"500 bytes"`
|
||||
- test('formats kilobytes') — `1536` → `"1.5KB"`
|
||||
- test('formats megabytes') — `1572864` → `"1.5MB"`
|
||||
- test('formats gigabytes') — `1610612736` → `"1.5GB"`
|
||||
- test('removes trailing .0') — `1024` → `"1KB"` (不是 `"1.0KB"`)
|
||||
|
||||
#### describe('formatSecondsShort')
|
||||
|
||||
- test('formats milliseconds to seconds') — `1234` → `"1.2s"`
|
||||
- test('formats zero') — `0` → `"0.0s"`
|
||||
|
||||
#### describe('formatDuration')
|
||||
|
||||
- test('formats seconds') — `5000` → `"5s"`
|
||||
- test('formats minutes and seconds') — `65000` → `"1m 5s"`
|
||||
- test('formats hours') — `3661000` → `"1h 1m 1s"`
|
||||
- test('formats days') — `90061000` → `"1d 1h 1m"`
|
||||
- test('returns "0s" for zero') — `0` → `"0s"`
|
||||
- test('hideTrailingZeros omits zero components') — `3600000` + `hideTrailingZeros` → `"1h"`
|
||||
- test('mostSignificantOnly returns largest unit') — `3661000` + `mostSignificantOnly` → `"1h"`
|
||||
|
||||
#### describe('formatNumber')
|
||||
|
||||
- test('formats thousands') — `1321` → `"1.3k"`
|
||||
- test('formats small numbers as-is') — `900` → `"900"`
|
||||
- test('lowercase output') — `1500` → `"1.5k"` (不是 `"1.5K"`)
|
||||
|
||||
#### describe('formatTokens')
|
||||
|
||||
- test('strips .0 suffix') — `1000` → `"1k"` (不是 `"1.0k"`)
|
||||
- test('keeps non-zero decimal') — `1500` → `"1.5k"`
|
||||
|
||||
#### describe('formatRelativeTime')
|
||||
|
||||
- test('formats past time') — now - 3600s → `"1h ago"` (narrow style)
|
||||
- test('formats future time') — now + 3600s → `"in 1h"` (narrow style)
|
||||
- test('formats less than 1 second') — now → `"0s ago"`
|
||||
- test('uses custom now parameter for deterministic output')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/json.ts — 测试文件: `src/utils/__tests__/json.test.ts`
|
||||
|
||||
#### describe('safeParseJSON')
|
||||
|
||||
- test('parses valid JSON') — `'{"a":1}'` → `{ a: 1 }`
|
||||
- test('returns null for invalid JSON') — `'not json'` → null
|
||||
- test('returns null for null input') — `null` → null
|
||||
- test('returns null for undefined input') — `undefined` → null
|
||||
- test('returns null for empty string') — `""` → null
|
||||
- test('handles JSON with BOM') — BOM 前缀不影响解析
|
||||
- test('caches results for repeated calls') — 同一输入不重复解析
|
||||
|
||||
#### describe('safeParseJSONC')
|
||||
|
||||
- test('parses JSON with comments') — 含 `//` 注释的 JSON 正确解析
|
||||
- test('parses JSON with trailing commas') — 宽松模式
|
||||
- test('returns null for invalid input')
|
||||
- test('returns null for null input')
|
||||
|
||||
#### describe('parseJSONL')
|
||||
|
||||
- test('parses multiple JSON lines') — `'{"a":1}\n{"b":2}'` → `[{a:1}, {b:2}]`
|
||||
- test('skips malformed lines') — 含错误行时跳过该行
|
||||
- test('handles empty input') — `""` → `[]`
|
||||
- test('handles trailing newline') — 尾部换行不产生空元素
|
||||
- test('accepts Buffer input') — Buffer 类型同样工作
|
||||
- test('handles BOM prefix')
|
||||
|
||||
#### describe('addItemToJSONCArray')
|
||||
|
||||
- test('adds item to existing array') — `[1, 2]` + 3 → `[1, 2, 3]`
|
||||
- test('creates new array for empty content') — `""` + item → `[item]`
|
||||
- test('creates new array for non-array content') — `'"hello"'` + item → `[item]`
|
||||
- test('preserves comments in JSONC') — 注释不被丢弃
|
||||
- test('handles empty array') — `"[]"` + item → `[item]`
|
||||
|
||||
---
|
||||
|
||||
### src/utils/diff.ts — 测试文件: `src/utils/__tests__/diff.test.ts`
|
||||
|
||||
#### describe('adjustHunkLineNumbers')
|
||||
|
||||
- test('shifts line numbers by positive offset') — 所有 hunk 的 oldStart/newStart 增加 offset
|
||||
- test('shifts by negative offset') — 负 offset 减少行号
|
||||
- test('handles empty hunk array') — `[]` → `[]`
|
||||
|
||||
#### describe('getPatchFromContents')
|
||||
|
||||
- test('returns empty array for identical content') — 相同内容无差异
|
||||
- test('detects added lines') — 新内容多出行
|
||||
- test('detects removed lines') — 旧内容缺少行
|
||||
- test('detects modified lines') — 行内容变化
|
||||
- test('handles empty old content') — 从空文件到有内容
|
||||
- test('handles empty new content') — 删除所有内容
|
||||
|
||||
---
|
||||
|
||||
### src/utils/frontmatterParser.ts — 测试文件: `src/utils/__tests__/frontmatterParser.test.ts`
|
||||
|
||||
#### describe('parseFrontmatter')
|
||||
|
||||
- test('extracts YAML frontmatter between --- delimiters') — 正确提取 frontmatter 并返回 body
|
||||
- test('returns empty frontmatter for content without ---') — 无 frontmatter 时 data 为空
|
||||
- test('handles empty content') — `""` 正确处理
|
||||
- test('handles frontmatter-only content') — 只有 frontmatter 无 body
|
||||
- test('falls back to quoting on YAML parse error') — 无效 YAML 不崩溃
|
||||
|
||||
#### describe('splitPathInFrontmatter')
|
||||
|
||||
- test('splits comma-separated paths') — `"a.ts, b.ts"` → `["a.ts", "b.ts"]`
|
||||
- test('expands brace patterns') — `"*.{ts,tsx}"` → `["*.ts", "*.tsx"]`
|
||||
- test('handles string array input') — `["a.ts", "b.ts"]` → `["a.ts", "b.ts"]`
|
||||
- test('respects braces in comma splitting') — 大括号内的逗号不作为分隔符
|
||||
|
||||
#### describe('parsePositiveIntFromFrontmatter')
|
||||
|
||||
- test('returns number for valid positive int') — `5` → `5`
|
||||
- test('returns undefined for negative') — `-1` → undefined
|
||||
- test('returns undefined for non-number') — `"abc"` → undefined
|
||||
- test('returns undefined for float') — `1.5` → undefined
|
||||
|
||||
#### describe('parseBooleanFrontmatter')
|
||||
|
||||
- test('returns true for true') — `true` → true
|
||||
- test('returns true for "true"') — `"true"` → true
|
||||
- test('returns false for false') — `false` → false
|
||||
- test('returns false for other values') — `"yes"`, `1` → false
|
||||
|
||||
#### describe('parseShellFrontmatter')
|
||||
|
||||
- test('returns bash for "bash"') — 正确识别
|
||||
- test('returns powershell for "powershell"')
|
||||
- test('returns undefined for invalid value') — `"zsh"` → undefined
|
||||
|
||||
---
|
||||
|
||||
### src/utils/file.ts(纯函数部分)— 测试文件: `src/utils/__tests__/file.test.ts`
|
||||
|
||||
#### describe('convertLeadingTabsToSpaces')
|
||||
|
||||
- test('converts single tab to 2 spaces') — `"\thello"` → `" hello"`
|
||||
- test('converts multiple leading tabs') — `"\t\thello"` → `" hello"`
|
||||
- test('does not convert tabs within line') — `"a\tb"` 保持原样
|
||||
- test('handles mixed content')
|
||||
|
||||
#### describe('addLineNumbers')
|
||||
|
||||
- test('adds line numbers starting from 1') — 每行添加 `N\t` 前缀
|
||||
- test('respects startLine parameter') — 从指定行号开始
|
||||
- test('handles empty content')
|
||||
|
||||
#### describe('stripLineNumberPrefix')
|
||||
|
||||
- test('strips tab-prefixed line number') — `"1\thello"` → `"hello"`
|
||||
- test('strips padded line number') — `" 1\thello"` → `"hello"`
|
||||
- test('returns line unchanged when no prefix')
|
||||
|
||||
#### describe('pathsEqual')
|
||||
|
||||
- test('returns true for identical paths')
|
||||
- test('handles trailing slashes') — 带/不带尾部斜杠视为相同
|
||||
- test('handles case sensitivity based on platform')
|
||||
|
||||
#### describe('normalizePathForComparison')
|
||||
|
||||
- test('normalizes forward slashes')
|
||||
- test('resolves path for comparison')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/glob.ts(纯函数部分)— 测试文件: `src/utils/__tests__/glob.test.ts`
|
||||
|
||||
#### describe('extractGlobBaseDirectory')
|
||||
|
||||
- test('extracts static prefix from glob') — `"src/**/*.ts"` → `{ baseDir: "src", relativePattern: "**/*.ts" }`
|
||||
- test('handles root-level glob') — `"*.ts"` → `{ baseDir: ".", relativePattern: "*.ts" }`
|
||||
- test('handles deep static path') — `"src/utils/model/*.ts"` → baseDir 为 `"src/utils/model"`
|
||||
- test('handles Windows drive root') — `"C:\\Users\\**\\*.ts"` 正确分割
|
||||
|
||||
---
|
||||
|
||||
### src/utils/tokens.ts(纯函数部分)— 测试文件: `src/utils/__tests__/tokens.test.ts`
|
||||
|
||||
#### describe('getTokenCountFromUsage')
|
||||
|
||||
- test('sums input and output tokens') — `{ input_tokens: 100, output_tokens: 50 }` → 150
|
||||
- test('includes cache tokens') — cache_creation + cache_read 加入总数
|
||||
- test('handles zero values') — 全 0 时返回 0
|
||||
|
||||
---
|
||||
|
||||
### src/utils/path.ts(纯函数部分)— 测试文件: `src/utils/__tests__/path.test.ts`
|
||||
|
||||
#### describe('containsPathTraversal')
|
||||
|
||||
- test('detects ../ traversal') — `"../etc/passwd"` → true
|
||||
- test('detects mid-path traversal') — `"foo/../../bar"` → true
|
||||
- test('returns false for safe paths') — `"src/utils/file.ts"` → false
|
||||
- test('returns false for paths containing .. in names') — `"foo..bar"` → false
|
||||
|
||||
#### describe('normalizePathForConfigKey')
|
||||
|
||||
- test('converts backslashes to forward slashes') — `"src\\utils"` → `"src/utils"`
|
||||
- test('leaves forward slashes unchanged')
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
本计划中的函数大部分为纯函数,**不需要 mock**。少数例外:
|
||||
|
||||
| 函数 | 依赖 | 处理 |
|
||||
|------|------|------|
|
||||
| `hashContent` / `hashPair` | `Bun.hash` | Bun 运行时下自动可用 |
|
||||
| `formatRelativeTime` | `Date` | 使用 `now` 参数注入确定性时间 |
|
||||
| `safeParseJSON` | `logError` | 可通过 `shouldLogError: false` 跳过 |
|
||||
| `safeParseJSONC` | `logError` | mock `logError` 避免测试输出噪音 |
|
||||
134
docs/test-plans/03-context-building.md
Normal file
134
docs/test-plans/03-context-building.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Context 构建测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
Context 构建系统负责组装发送给 Claude API 的系统提示和用户上下文。包括 git 状态获取、CLAUDE.md 文件发现与加载、系统提示拼装三部分。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/context.ts` | `getSystemContext`, `getUserContext`, `getGitStatus`, `setSystemPromptInjection` |
|
||||
| `src/utils/claudemd.ts` | `stripHtmlComments`, `getClaudeMds`, `isMemoryFilePath`, `getLargeMemoryFiles`, `filterInjectedMemoryFiles`, `getExternalClaudeMdIncludes`, `hasExternalClaudeMdIncludes`, `processMemoryFile`, `getMemoryFiles` |
|
||||
| `src/utils/systemPrompt.ts` | `buildEffectiveSystemPrompt` |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/utils/claudemd.ts — 纯函数部分
|
||||
|
||||
#### describe('stripHtmlComments')
|
||||
|
||||
- test('strips block-level HTML comments') — `"text <!-- comment --> more"` → content 不含注释
|
||||
- test('preserves inline content') — 行内文本保留
|
||||
- test('preserves code block content') — ` ```html\n<!-- not stripped -->\n``` ` 内的注释不移除
|
||||
- test('returns stripped: false when no comments') — 无注释时 stripped 为 false
|
||||
- test('returns stripped: true when comments exist')
|
||||
- test('handles empty string') — `""` → `{ content: "", stripped: false }`
|
||||
- test('handles multiple comments') — 多个注释全部移除
|
||||
|
||||
#### describe('getClaudeMds')
|
||||
|
||||
- test('assembles memory files with type descriptions') — 不同 type 的文件有不同前缀描述
|
||||
- test('includes instruction prompt prefix') — 输出包含指令前缀
|
||||
- test('handles empty memory files array') — 空数组返回空字符串或最小前缀
|
||||
- test('respects filter parameter') — filter 函数可过滤特定类型
|
||||
- test('concatenates multiple files with separators')
|
||||
|
||||
#### describe('isMemoryFilePath')
|
||||
|
||||
- test('returns true for CLAUDE.md path') — `"/project/CLAUDE.md"` → true
|
||||
- test('returns true for .claude/rules/ path') — `"/project/.claude/rules/foo.md"` → true
|
||||
- test('returns true for memory file path') — `"~/.claude/memory/foo.md"` → true
|
||||
- test('returns false for regular file') — `"/project/src/main.ts"` → false
|
||||
- test('returns false for unrelated .md file') — `"/project/README.md"` → false
|
||||
|
||||
#### describe('getLargeMemoryFiles')
|
||||
|
||||
- test('returns files exceeding 40K chars') — 内容 > MAX_MEMORY_CHARACTER_COUNT 的文件被返回
|
||||
- test('returns empty array when all files are small')
|
||||
- test('correctly identifies threshold boundary')
|
||||
|
||||
#### describe('filterInjectedMemoryFiles')
|
||||
|
||||
- test('filters out AutoMem type files') — feature flag 开启时移除自动记忆
|
||||
- test('filters out TeamMem type files')
|
||||
- test('preserves other types') — 非 AutoMem/TeamMem 的文件保留
|
||||
|
||||
#### describe('getExternalClaudeMdIncludes')
|
||||
|
||||
- test('returns includes from outside CWD') — 外部 @include 路径被识别
|
||||
- test('returns empty array when all includes are internal')
|
||||
|
||||
#### describe('hasExternalClaudeMdIncludes')
|
||||
|
||||
- test('returns true when external includes exist')
|
||||
- test('returns false when no external includes')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/systemPrompt.ts
|
||||
|
||||
#### describe('buildEffectiveSystemPrompt')
|
||||
|
||||
- test('returns default system prompt when no overrides') — 无任何覆盖时使用默认提示
|
||||
- test('overrideSystemPrompt replaces everything') — override 模式替换全部内容
|
||||
- test('customSystemPrompt replaces default') — `--system-prompt` 参数替换默认
|
||||
- test('appendSystemPrompt is appended after main prompt') — append 在主提示之后
|
||||
- test('agent definition replaces default prompt') — agent 模式使用 agent prompt
|
||||
- test('agent definition with append combines both') — agent prompt + append
|
||||
- test('override takes precedence over agent and custom') — 优先级最高
|
||||
- test('returns array of strings') — 返回值为 SystemPrompt 类型(字符串数组)
|
||||
|
||||
---
|
||||
|
||||
### src/context.ts — 需 Mock 的部分
|
||||
|
||||
#### describe('getGitStatus')
|
||||
|
||||
- test('returns formatted git status string') — 包含 branch、status、log、user
|
||||
- test('truncates status at 2000 chars') — 超长 status 被截断
|
||||
- test('returns null in test environment') — `NODE_ENV=test` 时返回 null
|
||||
- test('returns null in non-git directory') — 非 git 仓库返回 null
|
||||
- test('runs git commands in parallel') — 多个 git 命令并行执行
|
||||
|
||||
#### describe('getSystemContext')
|
||||
|
||||
- test('includes gitStatus key') — 返回对象包含 gitStatus
|
||||
- test('returns memoized result on subsequent calls') — 多次调用返回同一结果
|
||||
- test('skips git when instructions disabled')
|
||||
|
||||
#### describe('getUserContext')
|
||||
|
||||
- test('includes currentDate key') — 返回对象包含当前日期
|
||||
- test('includes claudeMd key when CLAUDE.md exists') — 加载 CLAUDE.md 内容
|
||||
- test('respects CLAUDE_CODE_DISABLE_CLAUDE_MDS env') — 设置后不加载 CLAUDE.md
|
||||
- test('returns memoized result')
|
||||
|
||||
#### describe('setSystemPromptInjection')
|
||||
|
||||
- test('clears memoized context caches') — 调用后下次 getSystemContext/getUserContext 重新计算
|
||||
- test('injection value is accessible via getSystemPromptInjection')
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 用途 |
|
||||
|------|-----------|------|
|
||||
| `execFileNoThrow` | `mock.module` | `getGitStatus` 中的 git 命令 |
|
||||
| `getMemoryFiles` | `mock.module` | `getUserContext` 中的 CLAUDE.md 加载 |
|
||||
| `getCwd` | `mock.module` | 路径解析上下文 |
|
||||
| `process.env.NODE_ENV` | 直接设置 | 测试环境检测 |
|
||||
| `process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS` | 直接设置 | 禁用 CLAUDE.md |
|
||||
|
||||
## 集成测试场景
|
||||
|
||||
放在 `tests/integration/context-build.test.ts`:
|
||||
|
||||
### describe('Context assembly pipeline')
|
||||
|
||||
- test('getUserContext produces claudeMd containing CLAUDE.md content') — 端到端验证 CLAUDE.md 被正确加载到 context
|
||||
- test('buildEffectiveSystemPrompt + getUserContext produces complete prompt') — 系统提示 + 用户上下文完整性
|
||||
- test('setSystemPromptInjection invalidates and rebuilds context') — 注入后重新构建上下文
|
||||
104
docs/test-plans/04-permission-system.md
Normal file
104
docs/test-plans/04-permission-system.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 权限系统测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
权限系统控制工具是否可以执行,包含规则解析器、权限检查管线和权限模式判断。测试重点是纯函数解析器和规则匹配逻辑。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/utils/permissions/permissionRuleParser.ts` | `permissionRuleValueFromString`, `permissionRuleValueToString`, `escapeRuleContent`, `unescapeRuleContent`, `normalizeLegacyToolName`, `getLegacyToolNames` |
|
||||
| `src/utils/permissions/PermissionMode.ts` | 权限模式常量和辅助函数 |
|
||||
| `src/utils/permissions/permissions.ts` | `hasPermissionsToUseTool`, `getDenyRuleForTool`, `checkRuleBasedPermissions` |
|
||||
| `src/types/permissions.ts` | `PermissionMode`, `PermissionBehavior`, `PermissionRule` 类型定义 |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/utils/permissions/permissionRuleParser.ts
|
||||
|
||||
#### describe('escapeRuleContent')
|
||||
|
||||
- test('escapes backslashes first') — `'test\\value'` → `'test\\\\value'`
|
||||
- test('escapes opening parentheses') — `'print(1)'` → `'print\\(1\\)'`
|
||||
- test('escapes closing parentheses') — `'func()'` → `'func\\(\\)'`
|
||||
- test('handles combined escape') — `'echo "test\\nvalue"'` 中的 `\\` 先转义
|
||||
- test('handles empty string') — `''` → `''`
|
||||
- test('no-op for string without special chars') — `'npm install'` 原样返回
|
||||
|
||||
#### describe('unescapeRuleContent')
|
||||
|
||||
- test('unescapes parentheses') — `'print\\(1\\)'` → `'print(1)'`
|
||||
- test('unescapes backslashes last') — `'test\\\\nvalue'` → `'test\\nvalue'`
|
||||
- test('handles empty string')
|
||||
- test('roundtrip: escape then unescape returns original') — `unescapeRuleContent(escapeRuleContent(x)) === x`
|
||||
|
||||
#### describe('permissionRuleValueFromString')
|
||||
|
||||
- test('parses tool name only') — `'Bash'` → `{ toolName: 'Bash' }`
|
||||
- test('parses tool name with content') — `'Bash(npm install)'` → `{ toolName: 'Bash', ruleContent: 'npm install' }`
|
||||
- test('parses content with escaped parentheses') — `'Bash(python -c "print\\(1\\)")'` → ruleContent 为 `'python -c "print(1)"'`
|
||||
- test('treats empty parens as tool-wide rule') — `'Bash()'` → `{ toolName: 'Bash' }`(无 ruleContent)
|
||||
- test('treats wildcard content as tool-wide rule') — `'Bash(*)'` → `{ toolName: 'Bash' }`
|
||||
- test('normalizes legacy tool names') — `'Task'` → `{ toolName: 'Agent' }`(或对应的 AGENT_TOOL_NAME)
|
||||
- test('handles malformed input: no closing paren') — `'Bash(npm'` → 整个字符串作为 toolName
|
||||
- test('handles malformed input: content after closing paren') — `'Bash(npm)extra'` → 整个字符串作为 toolName
|
||||
- test('handles missing tool name') — `'(foo)'` → 整个字符串作为 toolName
|
||||
|
||||
#### describe('permissionRuleValueToString')
|
||||
|
||||
- test('serializes tool name only') — `{ toolName: 'Bash' }` → `'Bash'`
|
||||
- test('serializes with content') — `{ toolName: 'Bash', ruleContent: 'npm install' }` → `'Bash(npm install)'`
|
||||
- test('escapes content with parentheses') — ruleContent 含 `()` 时正确转义
|
||||
- test('roundtrip: fromString then toString preserves value') — 往返一致
|
||||
|
||||
#### describe('normalizeLegacyToolName')
|
||||
|
||||
- test('maps Task to Agent tool name') — `'Task'` → AGENT_TOOL_NAME
|
||||
- test('maps KillShell to TaskStop tool name') — `'KillShell'` → TASK_STOP_TOOL_NAME
|
||||
- test('maps AgentOutputTool to TaskOutput tool name')
|
||||
- test('returns unknown names unchanged') — `'UnknownTool'` → `'UnknownTool'`
|
||||
|
||||
#### describe('getLegacyToolNames')
|
||||
|
||||
- test('returns legacy names for canonical name') — 给定 AGENT_TOOL_NAME 返回包含 `'Task'`
|
||||
- test('returns empty array for name with no legacy aliases')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/permissions/permissions.ts — 需 Mock
|
||||
|
||||
#### describe('getDenyRuleForTool')
|
||||
|
||||
- test('returns deny rule matching tool name') — 匹配到 blanket deny 规则时返回
|
||||
- test('returns null when no deny rules match') — 无匹配时返回 null
|
||||
- test('matches MCP tools by server prefix') — `mcp__server` 规则匹配该 server 下的 MCP 工具
|
||||
- test('does not match content-specific deny rules') — 有 ruleContent 的 deny 规则不作为 blanket deny
|
||||
|
||||
#### describe('checkRuleBasedPermissions')(集成级别)
|
||||
|
||||
- test('deny rule takes precedence over allow') — 同时有 allow 和 deny 时 deny 优先
|
||||
- test('ask rule prompts user') — 匹配 ask 规则返回 `{ behavior: 'ask' }`
|
||||
- test('allow rule permits execution') — 匹配 allow 规则返回 `{ behavior: 'allow' }`
|
||||
- test('passthrough when no rules match') — 无匹配规则返回 passthrough
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `bun:bundle` (feature) | 已 polyfill | BRIEF_TOOL_NAME 条件加载 |
|
||||
| Tool 常量导入 | 实际值 | AGENT_TOOL_NAME 等从常量文件导入 |
|
||||
| `appState` | mock object | `hasPermissionsToUseTool` 中的状态依赖 |
|
||||
| Tool 对象 | mock object | 模拟 tool 的 name, checkPermissions 等 |
|
||||
|
||||
## 集成测试场景
|
||||
|
||||
### describe('Permission pipeline end-to-end')
|
||||
|
||||
- test('deny rule blocks tool before it runs') — deny 规则在 call 前拦截
|
||||
- test('bypassPermissions mode allows all') — bypass 模式下 ask → allow
|
||||
- test('dontAsk mode converts ask to deny') — dontAsk 模式下 ask → deny
|
||||
113
docs/test-plans/05-model-routing.md
Normal file
113
docs/test-plans/05-model-routing.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# 模型路由测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
模型路由系统负责 API provider 选择、模型别名解析、模型名规范化和运行时模型决策。测试重点是纯函数和环境变量驱动的逻辑。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/utils/model/aliases.ts` | `MODEL_ALIASES`, `MODEL_FAMILY_ALIASES`, `isModelAlias`, `isModelFamilyAlias` |
|
||||
| `src/utils/model/providers.ts` | `APIProvider`, `getAPIProvider`, `isFirstPartyAnthropicBaseUrl` |
|
||||
| `src/utils/model/model.ts` | `firstPartyNameToCanonical`, `getCanonicalName`, `parseUserSpecifiedModel`, `normalizeModelStringForAPI`, `getRuntimeMainLoopModel`, `getDefaultMainLoopModelSetting` |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/utils/model/aliases.ts
|
||||
|
||||
#### describe('isModelAlias')
|
||||
|
||||
- test('returns true for "sonnet"') — 有效别名
|
||||
- test('returns true for "opus"')
|
||||
- test('returns true for "haiku"')
|
||||
- test('returns true for "best"')
|
||||
- test('returns true for "sonnet[1m]"')
|
||||
- test('returns true for "opus[1m]"')
|
||||
- test('returns true for "opusplan"')
|
||||
- test('returns false for full model ID') — `'claude-sonnet-4-6-20250514'` → false
|
||||
- test('returns false for unknown string') — `'gpt-4'` → false
|
||||
- test('is case-sensitive') — `'Sonnet'` → false(别名是小写)
|
||||
|
||||
#### describe('isModelFamilyAlias')
|
||||
|
||||
- test('returns true for "sonnet"')
|
||||
- test('returns true for "opus"')
|
||||
- test('returns true for "haiku"')
|
||||
- test('returns false for "best"') — best 不是 family alias
|
||||
- test('returns false for "opusplan"')
|
||||
- test('returns false for "sonnet[1m]"')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/model/providers.ts
|
||||
|
||||
#### describe('getAPIProvider')
|
||||
|
||||
- test('returns "firstParty" by default') — 无相关 env 时返回 firstParty
|
||||
- test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set') — env 为 truthy 值
|
||||
- test('returns "vertex" when CLAUDE_CODE_USE_VERTEX is set')
|
||||
- test('returns "foundry" when CLAUDE_CODE_USE_FOUNDRY is set')
|
||||
- test('bedrock takes precedence over vertex') — 多个 env 同时设置时 bedrock 优先
|
||||
|
||||
#### describe('isFirstPartyAnthropicBaseUrl')
|
||||
|
||||
- test('returns true when ANTHROPIC_BASE_URL is not set') — 默认 API
|
||||
- test('returns true for api.anthropic.com') — `'https://api.anthropic.com'` → true
|
||||
- test('returns false for custom URL') — `'https://my-proxy.com'` → false
|
||||
- test('returns false for invalid URL') — 非法 URL → false
|
||||
- test('returns true for staging URL when USER_TYPE is ant') — `'https://api-staging.anthropic.com'` + ant → true
|
||||
|
||||
---
|
||||
|
||||
### src/utils/model/model.ts
|
||||
|
||||
#### describe('firstPartyNameToCanonical')
|
||||
|
||||
- test('maps opus-4-6 full name to canonical') — `'claude-opus-4-6-20250514'` → `'claude-opus-4-6'`
|
||||
- test('maps sonnet-4-6 full name') — `'claude-sonnet-4-6-20250514'` → `'claude-sonnet-4-6'`
|
||||
- test('maps haiku-4-5') — `'claude-haiku-4-5-20251001'` → `'claude-haiku-4-5'`
|
||||
- test('maps 3P provider format') — `'us.anthropic.claude-opus-4-6-v1:0'` → `'claude-opus-4-6'`
|
||||
- test('maps claude-3-7-sonnet') — `'claude-3-7-sonnet-20250219'` → `'claude-3-7-sonnet'`
|
||||
- test('maps claude-3-5-sonnet') → `'claude-3-5-sonnet'`
|
||||
- test('maps claude-3-5-haiku') → `'claude-3-5-haiku'`
|
||||
- test('maps claude-3-opus') → `'claude-3-opus'`
|
||||
- test('is case insensitive') — `'Claude-Opus-4-6'` → `'claude-opus-4-6'`
|
||||
- test('falls back to input for unknown model') — `'unknown-model'` → `'unknown-model'`
|
||||
- test('differentiates opus-4 vs opus-4-5 vs opus-4-6') — 更具体的版本优先匹配
|
||||
|
||||
#### describe('parseUserSpecifiedModel')
|
||||
|
||||
- test('resolves "sonnet" to default sonnet model')
|
||||
- test('resolves "opus" to default opus model')
|
||||
- test('resolves "haiku" to default haiku model')
|
||||
- test('resolves "best" to best model')
|
||||
- test('resolves "opusplan" to default sonnet model') — opusplan 默认用 sonnet
|
||||
- test('appends [1m] suffix when alias has [1m]') — `'sonnet[1m]'` → 模型名 + `'[1m]'`
|
||||
- test('preserves original case for custom model names') — `'my-Custom-Model'` 保留大小写
|
||||
- test('handles [1m] suffix on non-alias models') — `'custom-model[1m]'` → `'custom-model[1m]'`
|
||||
- test('trims whitespace') — `' sonnet '` → 正确解析
|
||||
|
||||
#### describe('getRuntimeMainLoopModel')
|
||||
|
||||
- test('returns mainLoopModel by default') — 无特殊条件时原样返回
|
||||
- test('returns opus in plan mode when opusplan is set') — opusplan + plan mode → opus
|
||||
- test('returns sonnet in plan mode when haiku is set') — haiku + plan mode → sonnet 升级
|
||||
- test('returns mainLoopModel in non-plan mode') — 非 plan 模式不做替换
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `process.env.CLAUDE_CODE_USE_BEDROCK/VERTEX/FOUNDRY` | 直接设置/恢复 | provider 选择 |
|
||||
| `process.env.ANTHROPIC_BASE_URL` | 直接设置/恢复 | URL 检测 |
|
||||
| `process.env.USER_TYPE` | 直接设置/恢复 | staging URL 和 ant 功能 |
|
||||
| `getModelStrings()` | mock.module | 返回固定模型 ID |
|
||||
| `getMainLoopModelOverride` | mock.module | 会话中模型覆盖 |
|
||||
| `getSettings_DEPRECATED` | mock.module | 用户设置中的模型 |
|
||||
| `getUserSpecifiedModelSetting` | mock.module | `getRuntimeMainLoopModel` 依赖 |
|
||||
| `isModelAllowed` | mock.module | allowlist 检查 |
|
||||
165
docs/test-plans/06-message-handling.md
Normal file
165
docs/test-plans/06-message-handling.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# 消息处理测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
消息处理系统负责消息的创建、查询、规范化和文本提取。覆盖消息类型定义、消息工厂函数、消息过滤/查询工具和 API 规范化管线。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/types/message.ts` | `MessageType`, `Message`, `AssistantMessage`, `UserMessage`, `SystemMessage` 等类型 |
|
||||
| `src/utils/messages.ts` | 消息创建、查询、规范化、文本提取等函数(~3100 行) |
|
||||
| `src/utils/messages/mappers.ts` | 消息映射工具 |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/utils/messages.ts — 消息创建
|
||||
|
||||
#### describe('createAssistantMessage')
|
||||
|
||||
- test('creates message with type "assistant"') — type 字段正确
|
||||
- test('creates message with role "assistant"') — role 正确
|
||||
- test('creates message with empty content array') — 默认 content 为空
|
||||
- test('generates unique uuid') — 每次调用 uuid 不同
|
||||
- test('includes costUsd as 0')
|
||||
|
||||
#### describe('createUserMessage')
|
||||
|
||||
- test('creates message with type "user"') — type 字段正确
|
||||
- test('creates message with provided content') — content 正确传入
|
||||
- test('generates unique uuid')
|
||||
|
||||
#### describe('createSystemMessage')
|
||||
|
||||
- test('creates system message with correct type')
|
||||
- test('includes message content')
|
||||
|
||||
#### describe('createProgressMessage')
|
||||
|
||||
- test('creates progress message with data')
|
||||
- test('has correct type "progress"')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/messages.ts — 消息查询
|
||||
|
||||
#### describe('getLastAssistantMessage')
|
||||
|
||||
- test('returns last assistant message from array') — 多条消息中返回最后一条 assistant
|
||||
- test('returns undefined for empty array')
|
||||
- test('returns undefined when no assistant messages exist')
|
||||
|
||||
#### describe('hasToolCallsInLastAssistantTurn')
|
||||
|
||||
- test('returns true when last assistant has tool_use content') — content 含 tool_use block
|
||||
- test('returns false when last assistant has only text')
|
||||
- test('returns false for empty messages')
|
||||
|
||||
#### describe('isSyntheticMessage')
|
||||
|
||||
- test('identifies interrupt message as synthetic') — INTERRUPT_MESSAGE 标记
|
||||
- test('identifies cancel message as synthetic')
|
||||
- test('returns false for normal user messages')
|
||||
|
||||
#### describe('isNotEmptyMessage')
|
||||
|
||||
- test('returns true for message with content')
|
||||
- test('returns false for message with empty content array')
|
||||
- test('returns false for message with empty text content')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/messages.ts — 文本提取
|
||||
|
||||
#### describe('getAssistantMessageText')
|
||||
|
||||
- test('extracts text from text blocks') — content 含 `{ type: 'text', text: 'hello' }` 时提取
|
||||
- test('returns empty string for non-text content') — 仅含 tool_use 时返回空
|
||||
- test('concatenates multiple text blocks')
|
||||
|
||||
#### describe('getUserMessageText')
|
||||
|
||||
- test('extracts text from string content') — content 为纯字符串
|
||||
- test('extracts text from content array') — content 为数组时提取 text 块
|
||||
- test('handles empty content')
|
||||
|
||||
#### describe('extractTextContent')
|
||||
|
||||
- test('extracts text items from mixed content') — 过滤出 type: 'text' 的项
|
||||
- test('returns empty array for all non-text content')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/messages.ts — 规范化
|
||||
|
||||
#### describe('normalizeMessages')
|
||||
|
||||
- test('converts raw messages to normalized format') — 消息数组规范化
|
||||
- test('handles empty array') — `[]` → `[]`
|
||||
- test('preserves message order')
|
||||
- test('handles mixed message types')
|
||||
|
||||
#### describe('normalizeMessagesForAPI')
|
||||
|
||||
- test('filters out system messages') — 系统消息不发送给 API
|
||||
- test('filters out progress messages')
|
||||
- test('filters out attachment messages')
|
||||
- test('preserves user and assistant messages')
|
||||
- test('reorders tool results to match API expectations')
|
||||
- test('handles empty array')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/messages.ts — 合并
|
||||
|
||||
#### describe('mergeUserMessages')
|
||||
|
||||
- test('merges consecutive user messages') — 相邻用户消息合并
|
||||
- test('does not merge non-consecutive user messages')
|
||||
- test('preserves assistant messages between user messages')
|
||||
|
||||
#### describe('mergeAssistantMessages')
|
||||
|
||||
- test('merges consecutive assistant messages')
|
||||
- test('combines content arrays')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/messages.ts — 辅助函数
|
||||
|
||||
#### describe('buildMessageLookups')
|
||||
|
||||
- test('builds index by message uuid') — 按 uuid 建立查找表
|
||||
- test('returns empty lookups for empty messages')
|
||||
- test('handles duplicate uuids gracefully')
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `crypto.randomUUID` | `mock` 或 spy | 消息创建中的 uuid 生成 |
|
||||
| Message 对象 | 手动构造 | 创建符合类型的 mock 消息对象 |
|
||||
|
||||
### Mock 消息工厂(放在 `tests/mocks/messages.ts`)
|
||||
|
||||
```typescript
|
||||
// 通用 mock 消息构造器
|
||||
export function mockAssistantMessage(overrides?: Partial<AssistantMessage>): AssistantMessage
|
||||
export function mockUserMessage(content: string, overrides?: Partial<UserMessage>): UserMessage
|
||||
export function mockSystemMessage(overrides?: Partial<SystemMessage>): SystemMessage
|
||||
export function mockToolUseBlock(name: string, input: unknown): ToolUseBlock
|
||||
export function mockToolResultMessage(toolUseId: string, content: string): UserMessage
|
||||
```
|
||||
|
||||
## 集成测试场景
|
||||
|
||||
### describe('Message pipeline')
|
||||
|
||||
- test('create → normalize → API format produces valid request') — 创建消息 → normalizeMessagesForAPI → 验证输出结构
|
||||
- test('tool use and tool result pairing is preserved through normalization')
|
||||
- test('merge + normalize handles conversation with interruptions')
|
||||
112
docs/test-plans/07-cron.md
Normal file
112
docs/test-plans/07-cron.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Cron 调度测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
Cron 模块提供 cron 表达式解析、下次运行时间计算和人类可读描述。全部为纯函数,无外部依赖,是最适合单元测试的模块之一。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/utils/cron.ts` | `CronFields`, `parseCronExpression`, `computeNextCronRun`, `cronToHuman` |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### describe('parseCronExpression')
|
||||
|
||||
#### 有效表达式
|
||||
|
||||
- test('parses wildcard fields') — `'* * * * *'` → 每个字段为完整范围
|
||||
- test('parses specific values') — `'30 14 1 6 3'` → minute=[30], hour=[14], dom=[1], month=[6], dow=[3]
|
||||
- test('parses step syntax') — `'*/5 * * * *'` → minute=[0,5,10,...,55]
|
||||
- test('parses range syntax') — `'1-5 * * * *'` → minute=[1,2,3,4,5]
|
||||
- test('parses range with step') — `'1-10/3 * * * *'` → minute=[1,4,7,10]
|
||||
- test('parses comma-separated list') — `'1,15,30 * * * *'` → minute=[1,15,30]
|
||||
- test('parses day-of-week 7 as Sunday alias') — `'0 0 * * 7'` → dow=[0]
|
||||
- test('parses range with day-of-week 7') — `'0 0 * * 5-7'` → dow=[0,5,6]
|
||||
- test('parses complex combined expression') — `'0,30 9-17 * * 1-5'` → 工作日 9-17 每半小时
|
||||
|
||||
#### 无效表达式
|
||||
|
||||
- test('returns null for wrong field count') — `'* * *'` → null
|
||||
- test('returns null for out-of-range values') — `'60 * * * *'` → null(minute max=59)
|
||||
- test('returns null for invalid step') — `'*/0 * * * *'` → null(step=0)
|
||||
- test('returns null for reversed range') — `'10-5 * * * *'` → null(lo>hi)
|
||||
- test('returns null for empty string') — `''` → null
|
||||
- test('returns null for non-numeric tokens') — `'abc * * * *'` → null
|
||||
|
||||
#### 字段范围验证
|
||||
|
||||
- test('minute: 0-59')
|
||||
- test('hour: 0-23')
|
||||
- test('dayOfMonth: 1-31')
|
||||
- test('month: 1-12')
|
||||
- test('dayOfWeek: 0-6 (plus 7 alias)')
|
||||
|
||||
---
|
||||
|
||||
### describe('computeNextCronRun')
|
||||
|
||||
#### 基本匹配
|
||||
|
||||
- test('finds next minute') — from 14:30:45, cron `'31 14 * * *'` → 14:31:00 同天
|
||||
- test('finds next hour') — from 14:30, cron `'0 15 * * *'` → 15:00 同天
|
||||
- test('rolls to next day') — from 14:30, cron `'0 10 * * *'` → 10:00 次日
|
||||
- test('rolls to next month') — from 1月31日, cron `'0 0 1 * *'` → 2月1日
|
||||
- test('is strictly after from date') — from 恰好匹配时应返回下一次而非当前时间
|
||||
|
||||
#### DOM/DOW 语义
|
||||
|
||||
- test('OR semantics when both dom and dow constrained') — dom=15, dow=3 → 匹配 15 号 OR 周三
|
||||
- test('only dom constrained uses dom') — dom=15, dow=* → 只匹配 15 号
|
||||
- test('only dow constrained uses dow') — dom=*, dow=3 → 只匹配周三
|
||||
- test('both wildcarded matches every day') — dom=*, dow=* → 每天
|
||||
|
||||
#### 边界情况
|
||||
|
||||
- test('handles month boundary') — 从 2 月 28 日寻找 2 月 29 日或 3 月 1 日
|
||||
- test('returns null after 366-day search') — 不可能匹配的表达式返回 null(理论上不会发生)
|
||||
- test('handles step across midnight') — `'0 0 * * *'` 从 23:59 → 次日 0:00
|
||||
|
||||
#### 每 N 分钟
|
||||
|
||||
- test('every 5 minutes from arbitrary time') — `'*/5 * * * *'` from 14:32 → 14:35
|
||||
- test('every minute') — `'* * * * *'` from 14:32:45 → 14:33:00
|
||||
|
||||
---
|
||||
|
||||
### describe('cronToHuman')
|
||||
|
||||
#### 常见模式
|
||||
|
||||
- test('every N minutes') — `'*/5 * * * *'` → `'Every 5 minutes'`
|
||||
- test('every minute') — `'*/1 * * * *'` → `'Every minute'`
|
||||
- test('every hour at :00') — `'0 * * * *'` → `'Every hour'`
|
||||
- test('every hour at :30') — `'30 * * * *'` → `'Every hour at :30'`
|
||||
- test('every N hours') — `'0 */2 * * *'` → `'Every 2 hours'`
|
||||
- test('daily at specific time') — `'30 9 * * *'` → `'Every day at 9:30 AM'`
|
||||
- test('specific day of week') — `'0 9 * * 3'` → `'Every Wednesday at 9:00 AM'`
|
||||
- test('weekdays') — `'0 9 * * 1-5'` → `'Weekdays at 9:00 AM'`
|
||||
|
||||
#### Fallback
|
||||
|
||||
- test('returns raw cron for complex patterns') — 非常见模式返回原始 cron 字符串
|
||||
- test('returns raw cron for wrong field count') — `'* * *'` → 原样返回
|
||||
|
||||
#### UTC 模式
|
||||
|
||||
- test('UTC option formats time in local timezone') — `{ utc: true }` 时 UTC 时间转本地显示
|
||||
- test('UTC midnight crossing adjusts day name') — UTC 时间跨天时本地星期名正确
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
**无需 Mock**。所有函数为纯函数,唯一的外部依赖是 `Date` 构造器和 `toLocaleTimeString`,可通过传入确定性的 `from` 参数控制。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `cronToHuman` 的时间格式化依赖系统 locale,测试中建议使用 `'en-US'` locale 或只验证部分输出
|
||||
- `computeNextCronRun` 使用本地时区,DST 相关测试需注意运行环境
|
||||
106
docs/test-plans/08-git-utils.md
Normal file
106
docs/test-plans/08-git-utils.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Git 工具测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
Git 工具模块提供 git 远程 URL 规范化、仓库根目录查找、裸仓库安全检测等功能。测试重点是纯函数的 URL 规范化和需要文件系统 mock 的仓库发现逻辑。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/utils/git.ts` | `normalizeGitRemoteUrl`, `findGitRoot`, `findCanonicalGitRoot`, `getIsGit`, `isAtGitRoot`, `getRepoRemoteHash`, `isCurrentDirectoryBareGitRepo`, `gitExe`, `getGitState`, `stashToCleanState`, `preserveGitStateForIssue` |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### describe('normalizeGitRemoteUrl')(纯函数)
|
||||
|
||||
#### SSH 格式
|
||||
|
||||
- test('normalizes SSH URL') — `'git@github.com:owner/repo.git'` → `'github.com/owner/repo'`
|
||||
- test('normalizes SSH URL without .git suffix') — `'git@github.com:owner/repo'` → `'github.com/owner/repo'`
|
||||
- test('handles GitLab SSH') — `'git@gitlab.com:group/subgroup/repo.git'` → `'gitlab.com/group/subgroup/repo'`
|
||||
|
||||
#### HTTPS 格式
|
||||
|
||||
- test('normalizes HTTPS URL') — `'https://github.com/owner/repo.git'` → `'github.com/owner/repo'`
|
||||
- test('normalizes HTTPS URL without .git suffix') — `'https://github.com/owner/repo'` → `'github.com/owner/repo'`
|
||||
- test('normalizes HTTP URL') — `'http://github.com/owner/repo.git'` → `'github.com/owner/repo'`
|
||||
|
||||
#### SSH:// 协议格式
|
||||
|
||||
- test('normalizes ssh:// URL') — `'ssh://git@github.com/owner/repo'` → `'github.com/owner/repo'`
|
||||
- test('handles user prefix in ssh://') — `'ssh://user@host/path'` → `'host/path'`
|
||||
|
||||
#### 代理 URL(CCR git proxy)
|
||||
|
||||
- test('normalizes legacy proxy URL') — `'http://local_proxy@127.0.0.1:16583/git/owner/repo'` → `'github.com/owner/repo'`
|
||||
- test('normalizes GHE proxy URL') — `'http://user@127.0.0.1:8080/git/ghe.company.com/owner/repo'` → `'ghe.company.com/owner/repo'`
|
||||
|
||||
#### 边界情况
|
||||
|
||||
- test('returns null for empty string') — `''` → null
|
||||
- test('returns null for whitespace') — `' '` → null
|
||||
- test('returns null for unrecognized format') — `'not-a-url'` → null
|
||||
- test('output is lowercase') — `'git@GitHub.com:Owner/Repo.git'` → `'github.com/owner/repo'`
|
||||
- test('SSH and HTTPS for same repo produce same result') — 相同仓库不同协议 → 相同输出
|
||||
|
||||
---
|
||||
|
||||
### describe('findGitRoot')(需文件系统 Mock)
|
||||
|
||||
- test('finds git root from nested directory') — `/project/src/utils/` → `/project/`(假设 `/project/.git` 存在)
|
||||
- test('finds git root from root directory') — `/project/` → `/project/`
|
||||
- test('returns null for non-git directory') — 无 `.git` → null
|
||||
- test('handles worktree .git file') — `.git` 为文件时也识别
|
||||
- test('memoizes results') — 同一路径不重复查找
|
||||
|
||||
### describe('findCanonicalGitRoot')
|
||||
|
||||
- test('returns same as findGitRoot for regular repo')
|
||||
- test('resolves worktree to main repo root') — worktree 路径 → 主仓库根目录
|
||||
- test('returns null for non-git directory')
|
||||
|
||||
### describe('gitExe')
|
||||
|
||||
- test('returns git path string') — 返回字符串
|
||||
- test('memoizes the result') — 多次调用返回同一值
|
||||
|
||||
---
|
||||
|
||||
### describe('getRepoRemoteHash')(需 Mock)
|
||||
|
||||
- test('returns 16-char hex hash') — 返回值为 16 位十六进制字符串
|
||||
- test('returns null when no remote') — 无 remote URL 时返回 null
|
||||
- test('same repo SSH/HTTPS produce same hash') — 不同协议同一仓库 hash 相同
|
||||
|
||||
---
|
||||
|
||||
### describe('isCurrentDirectoryBareGitRepo')(需文件系统 Mock)
|
||||
|
||||
- test('detects bare git repo attack vector') — 目录含 HEAD + objects/ + refs/ 但无有效 .git/HEAD → true
|
||||
- test('returns false for normal directory') — 普通目录 → false
|
||||
- test('returns false for regular git repo') — 有效 .git 目录 → false
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
|------|-----------|------|
|
||||
| `statSync` | mock module | `findGitRoot` 中的 .git 检测 |
|
||||
| `readFileSync` | mock module | worktree .git 文件读取 |
|
||||
| `realpathSync` | mock module | 路径解析 |
|
||||
| `execFileNoThrow` | mock module | git 命令执行 |
|
||||
| `whichSync` | mock module | `gitExe` 中的 git 路径查找 |
|
||||
| `getCwd` | mock module | 当前工作目录 |
|
||||
| `getRemoteUrl` | mock module | `getRepoRemoteHash` 依赖 |
|
||||
| 临时目录 | `mkdtemp` | 集成测试中创建临时 git 仓库 |
|
||||
|
||||
## 集成测试场景
|
||||
|
||||
### describe('Git repo discovery')(放在 tests/integration/)
|
||||
|
||||
- test('findGitRoot works in actual git repo') — 在临时 git init 的目录中验证
|
||||
- test('normalizeGitRemoteUrl + getRepoRemoteHash produces stable hash') — URL → hash 端到端验证
|
||||
161
docs/test-plans/09-config-settings.md
Normal file
161
docs/test-plans/09-config-settings.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# 配置系统测试计划
|
||||
|
||||
## 概述
|
||||
|
||||
配置系统包含全局配置(GlobalConfig)、项目配置(ProjectConfig)和设置(Settings)三层。测试重点是纯函数校验逻辑、Zod schema 验证和配置合并策略。
|
||||
|
||||
## 被测文件
|
||||
|
||||
| 文件 | 关键导出 |
|
||||
|------|----------|
|
||||
| `src/utils/config.ts` | `getGlobalConfig`, `saveGlobalConfig`, `getCurrentProjectConfig`, `checkHasTrustDialogAccepted`, `isPathTrusted`, `getOrCreateUserID`, `isAutoUpdaterDisabled` |
|
||||
| `src/utils/settings/settings.ts` | `getSettingsForSource`, `parseSettingsFile`, `getSettingsFilePathForSource`, `getInitialSettings` |
|
||||
| `src/utils/settings/types.ts` | `SettingsSchema`(Zod schema) |
|
||||
| `src/utils/settings/validation.ts` | 设置验证函数 |
|
||||
| `src/utils/settings/constants.ts` | 设置常量 |
|
||||
|
||||
---
|
||||
|
||||
## 测试用例
|
||||
|
||||
### src/utils/config.ts — 纯函数/常量
|
||||
|
||||
#### describe('DEFAULT_GLOBAL_CONFIG')
|
||||
|
||||
- test('has all required fields') — 默认配置对象包含所有必需字段
|
||||
- test('has null auth fields by default') — oauthAccount 等为 null
|
||||
|
||||
#### describe('DEFAULT_PROJECT_CONFIG')
|
||||
|
||||
- test('has empty allowedTools') — 默认为空数组
|
||||
- test('has empty mcpServers') — 默认为空对象
|
||||
|
||||
#### describe('isAutoUpdaterDisabled')
|
||||
|
||||
- test('returns true when CLAUDE_CODE_DISABLE_AUTOUPDATER is set') — env 设置时禁用
|
||||
- test('returns true when disableAutoUpdater config is true')
|
||||
- test('returns false by default')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/config.ts — 需 Mock
|
||||
|
||||
#### describe('getGlobalConfig')
|
||||
|
||||
- test('returns cached config on subsequent calls') — 缓存机制
|
||||
- test('returns TEST_GLOBAL_CONFIG_FOR_TESTING in test mode')
|
||||
- test('reads config from ~/.claude.json')
|
||||
- test('returns default config when file does not exist')
|
||||
|
||||
#### describe('saveGlobalConfig')
|
||||
|
||||
- test('applies updater function to current config') — updater 修改被保存
|
||||
- test('creates backup before writing') — 写入前备份
|
||||
- test('prevents auth state loss') — `wouldLoseAuthState` 检查
|
||||
|
||||
#### describe('getCurrentProjectConfig')
|
||||
|
||||
- test('returns project config for current directory')
|
||||
- test('returns default config when no project config exists')
|
||||
|
||||
#### describe('checkHasTrustDialogAccepted')
|
||||
|
||||
- test('returns true when trust is accepted in current directory')
|
||||
- test('returns true when parent directory is trusted') — 父目录信任传递
|
||||
- test('returns false when no trust accepted')
|
||||
- test('caches positive results')
|
||||
|
||||
#### describe('isPathTrusted')
|
||||
|
||||
- test('returns true for trusted path')
|
||||
- test('returns false for untrusted path')
|
||||
|
||||
#### describe('getOrCreateUserID')
|
||||
|
||||
- test('returns existing user ID from config')
|
||||
- test('creates and persists new ID when none exists')
|
||||
- test('returns consistent ID across calls')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/settings/settings.ts
|
||||
|
||||
#### describe('getSettingsFilePathForSource')
|
||||
|
||||
- test('returns ~/.claude/settings.json for userSettings') — 全局用户设置路径
|
||||
- test('returns .claude/settings.json for projectSettings') — 项目设置路径
|
||||
- test('returns .claude/settings.local.json for localSettings') — 本地设置路径
|
||||
|
||||
#### describe('parseSettingsFile')(需 Mock 文件读取)
|
||||
|
||||
- test('parses valid settings JSON') — 有效 JSON → `{ settings, errors: [] }`
|
||||
- test('returns errors for invalid fields') — 无效字段 → errors 非空
|
||||
- test('returns empty settings for non-existent file')
|
||||
- test('handles JSON with comments') — JSONC 格式支持
|
||||
|
||||
#### describe('getInitialSettings')
|
||||
|
||||
- test('merges settings from all sources') — user + project + local 合并
|
||||
- test('later sources override earlier ones') — 优先级:policy > user > project > local
|
||||
|
||||
---
|
||||
|
||||
### src/utils/settings/types.ts — Zod Schema 验证
|
||||
|
||||
#### describe('SettingsSchema validation')
|
||||
|
||||
- test('accepts valid minimal settings') — `{}` → 有效
|
||||
- test('accepts permissions block') — `{ permissions: { allow: ['Bash(*)'] } }` → 有效
|
||||
- test('accepts model setting') — `{ model: 'sonnet' }` → 有效
|
||||
- test('accepts hooks configuration') — 有效的 hooks 对象被接受
|
||||
- test('accepts env variables') — `{ env: { FOO: 'bar' } }` → 有效
|
||||
- test('rejects unknown top-level keys') — 未知字段被拒绝或忽略(取决于 schema 配置)
|
||||
- test('rejects invalid permission mode') — `{ permissions: { defaultMode: 'invalid' } }` → 错误
|
||||
- test('rejects non-string model') — `{ model: 123 }` → 错误
|
||||
- test('accepts mcpServers configuration') — MCP server 配置有效
|
||||
- test('accepts sandbox configuration')
|
||||
|
||||
---
|
||||
|
||||
### src/utils/settings/validation.ts
|
||||
|
||||
#### describe('settings validation')
|
||||
|
||||
- test('validates permission rules format') — `'Bash(npm install)'` 格式正确
|
||||
- test('rejects malformed permission rules')
|
||||
- test('validates hook configuration structure')
|
||||
- test('provides helpful error messages') — 错误信息包含字段路径
|
||||
|
||||
---
|
||||
|
||||
## Mock 需求
|
||||
|
||||
| 依赖 | Mock 方式 | 说明 |
|
||||
|------|-----------|------|
|
||||
| 文件系统 | 临时目录 + mock | config 文件读写 |
|
||||
| `lockfile` | mock module | 文件锁 |
|
||||
| `getCwd` | mock module | 项目路径判断 |
|
||||
| `findGitRoot` | mock module | 项目根目录 |
|
||||
| `process.env` | 直接设置/恢复 | `CLAUDE_CODE_DISABLE_AUTOUPDATER` 等 |
|
||||
|
||||
### 测试用临时文件结构
|
||||
|
||||
```
|
||||
/tmp/claude-test-xxx/
|
||||
├── .claude/
|
||||
│ ├── settings.json # projectSettings
|
||||
│ └── settings.local.json # localSettings
|
||||
├── home/
|
||||
│ └── .claude/
|
||||
│ └── settings.json # userSettings(mock HOME)
|
||||
└── project/
|
||||
└── .git/
|
||||
```
|
||||
|
||||
## 集成测试场景
|
||||
|
||||
### describe('Config + Settings merge pipeline')
|
||||
|
||||
- test('user settings + project settings merge correctly') — 验证合并优先级
|
||||
- test('deny rules from settings are reflected in tool permission context')
|
||||
- test('trust dialog state persists across config reads')
|
||||
Reference in New Issue
Block a user