mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Merge branch 'claude-code-best:main' into main
This commit is contained in:
20
README.md
20
README.md
@@ -1,8 +1,8 @@
|
||||
# Claude Code Best V3 (CCB)
|
||||
|
||||
Anthropic 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现。虽然很难绷, 但是它叫做 CCB(踩踩背)...
|
||||
牢 A (Anthropic) 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 工具的源码反编译/逆向还原项目。目标是将 Claude Code 大部分功能及工程化能力复现 (问就是老佛爷已经付过钱了)。虽然很难绷, 但是它叫做 CCB(踩踩背)...
|
||||
|
||||
[项目解析文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/)
|
||||
[文档在这里, 支持投稿 PR](https://ccb.agent-aura.top/)
|
||||
|
||||
赞助商占位符
|
||||
|
||||
@@ -12,20 +12,22 @@ Anthropic 官方 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) C
|
||||
- [x] 构建流水线完成, 产物 Node/Bun 都可以运行
|
||||
- [x] V3 会写大量文档, 完善文档站点
|
||||
- [ ] V4 会完成大量的测试文件, 以提高稳定性
|
||||
- [ ] V5 大规模重构石山代码, 全面模块分包
|
||||
- [ ] V5 将会为全新分支, 届时 main 分支将会封存为历史版本
|
||||
|
||||
> 我不知道这个项目还会存在多久, Star + Fork + git clone + .zip 包最稳健;
|
||||
> 我不知道这个项目还会存在多久, Star + Fork + git clone + .zip 包最稳健; 说白了就是扛旗项目, 看看能走多远
|
||||
>
|
||||
> 这个项目更新很快, 后台有 Opus 持续优化, 所以你可以提 issues, 但是 PR 暂时不会接受;
|
||||
> 这个项目更新很快, 后台有 Opus 持续优化, 几乎几个小时就有新变化;
|
||||
>
|
||||
> Claude 已经烧了 600$ 以上, 如果你个人想赞助, 请随便找个机构捐款, 然后截图在 issues, 大家的力量是温暖的;
|
||||
> Claude 已经烧了 1000$ 以上, 继续玩;
|
||||
>
|
||||
> 某些模型提供商想要赞助, 那么请私发一个 1w 额度以上的账号到 <claude-code-best@proton.me>; 我们会在赞助商栏直接给你最亮的位置
|
||||
|
||||
存活记录:
|
||||
|
||||
1. 开源后 15 小时: 完成了构建产物的 node 支持, 现在是完全体了; star 快到 3k 了; 等待牢 A 的邮件
|
||||
2. 开源后 12 小时: 愚人节, star 破 1k, 并且牢 A 没有发邮件搞这个项目
|
||||
3. 如果你想要私人咨询服务, 那么可以发送邮件到 <claude-code-best@proton.me>, 备注咨询与联系方式即可; 由于后续工作非常多, 可能会忽略邮件, 半天没回复, 可以多发;
|
||||
1. 开源后 48 小时: 突破 7k Star; 测试代码小有成效;
|
||||
2. 开源后 24 小时: 突破 6k Star, 感谢各位支持. 完成 docs 文档的站点构建, 达到 v3 版本, 后续开始进行测试用例维护, 完成之后可以接受 PR; 看来牢 A 是不想理我们了;
|
||||
3. 开源后 15 小时: 完成了构建产物的 node 支持, 现在是完全体了; star 快到 3k 了; 等待牢 A 的邮件
|
||||
4. 开源后 12 小时: 愚人节, star 破 1k, 并且牢 A 没有发邮件搞这个项目
|
||||
|
||||
## 快速开始
|
||||
|
||||
|
||||
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')
|
||||
377
docs/testing-spec.md
Normal file
377
docs/testing-spec.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# Testing Specification
|
||||
|
||||
本文档定义了 claude-code 项目的测试规范,作为编写和维护测试代码的统一标准。
|
||||
|
||||
## 1. 测试目标
|
||||
|
||||
| 目标 | 说明 |
|
||||
|------|------|
|
||||
| **防止回归** | 确保已有功能不被新改动破坏,每次 PR 必须通过全部测试 |
|
||||
| **验证核心流程** | 覆盖 CLI 核心交互流程:Tool 调用链、Context 构建、消息处理 |
|
||||
| **文档化行为** | 通过测试用例记录各模块的预期行为,作为活文档供开发者参考 |
|
||||
|
||||
## 2. 技术栈
|
||||
|
||||
| 项 | 选型 | 说明 |
|
||||
|----|------|------|
|
||||
| 测试框架 | `bun:test` | Bun 内置,零配置,与运行时一致 |
|
||||
| 断言库 | `bun:test` 内置 `expect` | 兼容 Jest `expect` API |
|
||||
| Mock | `bun:test` 内置 `mock`/`spyOn` | 配合手动 mock fixtures |
|
||||
| 覆盖率 | `bun test --coverage` | 内置覆盖率报告 |
|
||||
|
||||
## 3. 测试层次
|
||||
|
||||
本项目采用 **单元测试 + 集成测试** 两层结构,不做 E2E 或快照测试。
|
||||
|
||||
### 3.1 单元测试
|
||||
|
||||
- **对象**:纯函数、工具类、解析器、独立模块
|
||||
- **特征**:无外部依赖、执行快、可并行
|
||||
- **示例场景**:
|
||||
- `src/utils/array.ts` — 数组操作函数
|
||||
- `src/utils/path.ts` — 路径解析
|
||||
- `src/utils/diff.ts` — diff 算法
|
||||
- `src/utils/permissions/` — 权限判断逻辑
|
||||
- `src/utils/model/` — 模型选择与 provider 路由
|
||||
- Tool 的 `inputSchema` 校验逻辑
|
||||
|
||||
### 3.2 集成测试
|
||||
|
||||
- **对象**:多模块协作流程
|
||||
- **特征**:可能需要 mock 外部服务(API、文件系统),测试模块间协作
|
||||
- **示例场景**:
|
||||
- Tool 调用链:`tools.ts` 注册 → `findToolByName` → tool `call()` 执行
|
||||
- Context 构建:`context.ts` 组装系统提示(CLAUDE.md 加载 + git status + 日期)
|
||||
- 消息处理管线:用户输入 → 消息格式化 → API 请求构建
|
||||
|
||||
## 4. 文件结构
|
||||
|
||||
采用 **混合模式**:单元测试就近放置,集成测试集中管理。
|
||||
|
||||
```
|
||||
src/
|
||||
├── utils/
|
||||
│ ├── array.ts
|
||||
│ ├── __tests__/ # 单元测试:就近放置
|
||||
│ │ ├── array.test.ts
|
||||
│ │ ├── set.test.ts
|
||||
│ │ └── path.test.ts
|
||||
│ ├── model/
|
||||
│ │ ├── providers.ts
|
||||
│ │ └── __tests__/
|
||||
│ │ └── providers.test.ts
|
||||
│ └── permissions/
|
||||
│ ├── index.ts
|
||||
│ └── __tests__/
|
||||
│ └── permissions.test.ts
|
||||
├── tools/
|
||||
│ ├── BashTool/
|
||||
│ │ ├── index.ts
|
||||
│ │ └── __tests__/
|
||||
│ │ └── BashTool.test.ts
|
||||
│ └── FileEditTool/
|
||||
│ ├── index.ts
|
||||
│ └── __tests__/
|
||||
│ └── FileEditTool.test.ts
|
||||
tests/ # 集成测试:集中管理
|
||||
├── integration/
|
||||
│ ├── tool-chain.test.ts
|
||||
│ ├── context-build.test.ts
|
||||
│ └── message-pipeline.test.ts
|
||||
├── mocks/ # 通用 mock / fixtures
|
||||
│ ├── api-responses.ts # Claude API mock 响应
|
||||
│ ├── file-system.ts # 文件系统 mock 工具
|
||||
│ └── fixtures/
|
||||
│ ├── sample-claudemd.md
|
||||
│ └── sample-messages.json
|
||||
└── helpers/ # 测试辅助函数
|
||||
└── setup.ts
|
||||
```
|
||||
|
||||
### 命名规则
|
||||
|
||||
| 项 | 规则 |
|
||||
|----|------|
|
||||
| 测试文件 | `<module-name>.test.ts` |
|
||||
| 测试目录 | `__tests__/`(单元)、`tests/integration/`(集成) |
|
||||
| Fixture 文件 | `tests/mocks/fixtures/` 下按用途命名 |
|
||||
| Helper 文件 | `tests/helpers/` 下按功能命名 |
|
||||
|
||||
## 5. 命名与编写规范
|
||||
|
||||
### 5.1 命名风格
|
||||
|
||||
使用 `describe` + `it`/`test` 英文描述:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
describe("findToolByName", () => {
|
||||
test("returns the tool when name matches exactly", () => {
|
||||
// ...
|
||||
});
|
||||
|
||||
test("returns undefined when no tool matches", () => {
|
||||
// ...
|
||||
});
|
||||
|
||||
test("is case-insensitive for tool name lookup", () => {
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 5.2 describe 块组织原则
|
||||
|
||||
- 顶层 `describe` 对应被测函数/类/模块名
|
||||
- 可嵌套 `describe` 对分支场景分组(如 `describe("when input is empty", ...)`)
|
||||
- 每个 `test` 应测试一个行为,命名采用 **"动作 + 预期结果"** 格式
|
||||
|
||||
### 5.3 编写原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|------|------|
|
||||
| **Arrange-Act-Assert** | 每个测试分三段:准备数据、执行操作、验证结果 |
|
||||
| **单一职责** | 一个 `test` 只验证一个行为 |
|
||||
| **独立性** | 测试之间无顺序依赖,无共享可变状态 |
|
||||
| **可读性优先** | 测试代码是文档,宁可重复也不过度抽象 |
|
||||
| **边界覆盖** | 空值、边界值、异常输入必须覆盖 |
|
||||
|
||||
### 5.4 异步测试
|
||||
|
||||
```typescript
|
||||
test("reads file content correctly", async () => {
|
||||
const content = await readFile("/tmp/test.txt");
|
||||
expect(content).toContain("expected");
|
||||
});
|
||||
```
|
||||
|
||||
## 6. Mock 策略
|
||||
|
||||
采用 **混合管理**:通用 mock 集中于 `tests/mocks/`,专用 mock 就近定义。
|
||||
|
||||
### 6.1 Claude API Mock(集中管理)
|
||||
|
||||
所有 API 测试全部使用 mock,不调用真实 API。
|
||||
|
||||
```typescript
|
||||
// tests/mocks/api-responses.ts
|
||||
export const mockStreamResponse = {
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_mock_001",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [],
|
||||
model: "claude-sonnet-4-20250514",
|
||||
// ...
|
||||
},
|
||||
};
|
||||
|
||||
export const mockToolUseResponse = {
|
||||
type: "content_block_start",
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "toolu_mock_001",
|
||||
name: "Read",
|
||||
input: { file_path: "/tmp/test.txt" },
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 6.2 模块级 Mock(就近定义)
|
||||
|
||||
```typescript
|
||||
import { mock } from "bun:test";
|
||||
|
||||
// mock 整个模块
|
||||
mock.module("src/services/api/claude.ts", () => ({
|
||||
createApiClient: () => ({
|
||||
stream: mock(() => mockStreamResponse),
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
### 6.3 文件系统 Mock
|
||||
|
||||
对于需要文件系统交互的测试,使用临时目录:
|
||||
|
||||
```typescript
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeAll } from "bun:test";
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "claude-test-"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tempDir, { recursive: true });
|
||||
});
|
||||
```
|
||||
|
||||
## 7. 优先测试模块
|
||||
|
||||
按优先级从高到低排列,括号内为目标覆盖率:
|
||||
|
||||
### P0 — 核心(行覆盖率 >= 80%)
|
||||
|
||||
| 模块 | 路径 | 测试重点 |
|
||||
|------|------|----------|
|
||||
| **Tool 系统** | `src/tools/`, `src/Tool.ts`, `src/tools.ts` | tool 注册/发现、inputSchema 校验、call() 执行与错误处理 |
|
||||
| **工具函数** | `src/utils/` 下纯函数 | 各种 utility 的正确性与边界情况 |
|
||||
| **Context 构建** | `src/context.ts`, `src/utils/claudemd.ts` | 系统提示拼装、CLAUDE.md 发现与加载、context 内容完整性 |
|
||||
|
||||
### P1 — 重要(行覆盖率 >= 60%)
|
||||
|
||||
| 模块 | 路径 | 测试重点 |
|
||||
|------|------|----------|
|
||||
| **权限系统** | `src/utils/permissions/` | 权限模式判断、tool 许可/拒绝逻辑 |
|
||||
| **模型路由** | `src/utils/model/` | provider 选择、模型名映射、fallback 逻辑 |
|
||||
| **消息处理** | `src/types/message.ts`, `src/utils/messages.ts` | 消息类型构造、格式化、过滤 |
|
||||
| **CLI 参数** | `src/main.tsx` 中的 Commander 配置 | 参数解析、模式切换(REPL/pipe) |
|
||||
|
||||
### P2 — 补充
|
||||
|
||||
| 模块 | 路径 | 测试重点 |
|
||||
|------|------|----------|
|
||||
| **Cron 调度** | `src/utils/cron*.ts` | cron 表达式解析、任务调度逻辑 |
|
||||
| **Git 工具** | `src/utils/git.ts` | git 命令构造、输出解析 |
|
||||
| **Config** | `src/utils/config.ts`, `src/utils/settings/` | 配置加载、合并、默认值 |
|
||||
|
||||
## 8. 覆盖率要求
|
||||
|
||||
| 范围 | 目标 | 说明 |
|
||||
|------|------|------|
|
||||
| P0 核心模块 | **>= 80%** 行覆盖率 | Tool 系统、工具函数、Context 构建 |
|
||||
| P1 重要模块 | **>= 60%** 行覆盖率 | 权限、模型路由、消息处理 |
|
||||
| 整体 | 不设强制指标 | 逐步提升,不追求数字 |
|
||||
|
||||
运行覆盖率报告:
|
||||
|
||||
```bash
|
||||
bun test --coverage
|
||||
```
|
||||
|
||||
## 9. CI 集成
|
||||
|
||||
已有 GitHub Actions 配置(`.github/workflows/ci.yml`),`bun test` 步骤已就位。
|
||||
|
||||
### CI 中测试的运行条件
|
||||
|
||||
- **push** 到 `main` 或 `feature/*` 分支时自动运行
|
||||
- **pull_request** 到 `main` 分支时自动运行
|
||||
- 测试失败将阻止合并
|
||||
|
||||
### 本地运行
|
||||
|
||||
```bash
|
||||
# 运行全部测试
|
||||
bun test
|
||||
|
||||
# 运行特定文件
|
||||
bun test src/utils/__tests__/array.test.ts
|
||||
|
||||
# 运行匹配模式
|
||||
bun test --filter "findToolByName"
|
||||
|
||||
# 带覆盖率
|
||||
bun test --coverage
|
||||
|
||||
# watch 模式(开发时)
|
||||
bun test --watch
|
||||
```
|
||||
|
||||
## 10. 编写测试 Checklist
|
||||
|
||||
每次新增或修改测试时,确认以下事项:
|
||||
|
||||
- [ ] 测试文件位置正确(单元 → `__tests__/`,集成 → `tests/integration/`)
|
||||
- [ ] 命名遵循 `describe` + `test` 英文格式
|
||||
- [ ] 每个 test 只验证一个行为
|
||||
- [ ] 覆盖了正常路径、边界情况和错误情况
|
||||
- [ ] 无硬编码的绝对路径或系统特定值
|
||||
- [ ] Mock 使用得当(通用 → `tests/mocks/`,专用 → 就近)
|
||||
- [ ] 测试可独立运行,无顺序依赖
|
||||
- [ ] `bun test` 本地全部通过后再提交
|
||||
|
||||
## 11. 当前测试覆盖状态
|
||||
|
||||
> 更新日期:2026-04-02 | 总计:**647 tests, 32 files, 0 failures**
|
||||
|
||||
### P0 — 核心模块
|
||||
|
||||
| 测试计划 | 测试文件 | 测试数 | 覆盖范围 |
|
||||
|----------|----------|--------|----------|
|
||||
| 01 - Tool 系统 | `src/__tests__/Tool.test.ts` | 25 | buildTool, toolMatchesName, findToolByName, getEmptyToolPermissionContext, filterToolProgressMessages |
|
||||
| | `src/__tests__/tools.test.ts` | 10 | parseToolPreset, filterToolsByDenyRules |
|
||||
| | `src/tools/shared/__tests__/gitOperationTracking.test.ts` | 16 | parseGitCommitId, detectGitOperation |
|
||||
| | `src/tools/FileEditTool/__tests__/utils.test.ts` | 24 | normalizeQuotes, stripTrailingWhitespace, findActualString, preserveQuoteStyle, applyEditToFile |
|
||||
| 02 - Utils 纯函数 | `src/utils/__tests__/array.test.ts` | 12 | intersperse, count, uniq |
|
||||
| | `src/utils/__tests__/set.test.ts` | 12 | difference, intersects, every, union |
|
||||
| | `src/utils/__tests__/xml.test.ts` | 9 | escapeXml, escapeXmlAttr |
|
||||
| | `src/utils/__tests__/hash.test.ts` | 12 | djb2Hash, hashContent, hashPair |
|
||||
| | `src/utils/__tests__/stringUtils.test.ts` | 35 | escapeRegExp, capitalize, plural, firstLineOf, countCharInString, normalizeFullWidthDigits/Space, safeJoinLines, EndTruncatingAccumulator, truncateToLines |
|
||||
| | `src/utils/__tests__/semver.test.ts` | 21 | gt, gte, lt, lte, satisfies, order |
|
||||
| | `src/utils/__tests__/uuid.test.ts` | 6 | validateUuid |
|
||||
| | `src/utils/__tests__/format.test.ts` | 24 | formatFileSize, formatSecondsShort, formatDuration, formatNumber, formatTokens, formatRelativeTime |
|
||||
| | `src/utils/__tests__/frontmatterParser.test.ts` | 28 | parseFrontmatter, splitPathInFrontmatter, parsePositiveIntFromFrontmatter, parseBooleanFrontmatter, parseShellFrontmatter |
|
||||
| | `src/utils/__tests__/file.test.ts` | 17 | convertLeadingTabsToSpaces, addLineNumbers, stripLineNumberPrefix, normalizePathForComparison, pathsEqual |
|
||||
| | `src/utils/__tests__/glob.test.ts` | 6 | extractGlobBaseDirectory |
|
||||
| | `src/utils/__tests__/diff.test.ts` | 8 | adjustHunkLineNumbers, getPatchFromContents |
|
||||
| | `src/utils/__tests__/json.test.ts` | 27 | safeParseJSON, safeParseJSONC, parseJSONL, addItemToJSONCArray (mock log.ts) |
|
||||
| | `src/utils/__tests__/truncate.test.ts` | 24 | truncateToWidth, truncateStartToWidth, truncateToWidthNoEllipsis, truncatePathMiddle, truncate, wrapText |
|
||||
| | `src/utils/__tests__/path.test.ts` | 15 | containsPathTraversal, normalizePathForConfigKey |
|
||||
| | `src/utils/__tests__/tokens.test.ts` | 22 | getTokenCountFromUsage, getTokenUsage, tokenCountFromLastAPIResponse, messageTokenCountFromLastAPIResponse, getCurrentUsage, doesMostRecentAssistantMessageExceed200k, getAssistantMessageContentLength (mock log.ts, tokenEstimation, slowOperations) |
|
||||
| 03 - Context 构建 | `src/utils/__tests__/claudemd.test.ts` | 16 | stripHtmlComments, isMemoryFilePath, getLargeMemoryFiles |
|
||||
| | `src/utils/__tests__/systemPrompt.test.ts` | 9 | buildEffectiveSystemPrompt |
|
||||
|
||||
### P1 — 重要模块
|
||||
|
||||
| 测试计划 | 测试文件 | 测试数 | 覆盖范围 |
|
||||
|----------|----------|--------|----------|
|
||||
| 04 - 权限系统 | `src/utils/permissions/__tests__/permissionRuleParser.test.ts` | 25 | escapeRuleContent, unescapeRuleContent, permissionRuleValueFromString, permissionRuleValueToString, normalizeLegacyToolName |
|
||||
| | `src/utils/permissions/__tests__/permissions.test.ts` | 13 | getDenyRuleForTool, getAskRuleForTool, getDenyRuleForAgent, filterDeniedAgents (mock log.ts, slowOperations) |
|
||||
| 05 - 模型路由 | `src/utils/model/__tests__/aliases.test.ts` | 16 | isModelAlias, isModelFamilyAlias |
|
||||
| | `src/utils/model/__tests__/model.test.ts` | 14 | firstPartyNameToCanonical |
|
||||
| | `src/utils/model/__tests__/providers.test.ts` | 10 | getAPIProvider, isFirstPartyAnthropicBaseUrl |
|
||||
| 06 - 消息处理 | `src/utils/__tests__/messages.test.ts` | 56 | createAssistantMessage, createUserMessage, isSyntheticMessage, getLastAssistantMessage, hasToolCallsInLastAssistantTurn, extractTag, isNotEmptyMessage, normalizeMessages, deriveUUID, isClassifierDenial 等 |
|
||||
|
||||
### P2 — 补充模块
|
||||
|
||||
| 测试计划 | 测试文件 | 测试数 | 覆盖范围 |
|
||||
|----------|----------|--------|----------|
|
||||
| 07 - Cron 调度 | `src/utils/__tests__/cron.test.ts` | 38 | parseCronExpression, computeNextCronRun, cronToHuman |
|
||||
| 08 - Git 工具 | `src/utils/__tests__/git.test.ts` | 18 | normalizeGitRemoteUrl (SSH/HTTPS/ssh:///代理URL/大小写规范化) |
|
||||
| 09 - 配置与设置 | `src/utils/settings/__tests__/config.test.ts` | 62 | SettingsSchema, PermissionsSchema, AllowedMcpServerEntrySchema, MCP 类型守卫, 设置常量函数, filterInvalidPermissionRules, validateSettingsFileContent, formatZodError |
|
||||
|
||||
### 已知限制
|
||||
|
||||
以下模块因 Bun 运行时限制或极重依赖链,暂时无法或不适合测试:
|
||||
|
||||
| 模块 | 问题 | 说明 |
|
||||
|------|------|------|
|
||||
| `Bun.JSONL.parseChunk` | 处理畸形行时无限挂起 | Bun 1.3.10 bug,错误恢复循环卡死;已跳过 parseJSONL 畸形行测试 |
|
||||
| `src/tools.ts` 部分函数 | `getAllBaseTools`/`getTools` 加载全量 tool | 导入链过重,mock 难度大 |
|
||||
| `src/tools/shared/spawnMultiAgent.ts` | 依赖 bootstrap/state + AppState + 50+ 模块 | mock 成本极高,投入产出比低 |
|
||||
| `src/utils/messages.ts` 部分函数 | `withMemoryCorrectionHint` 等 | 依赖 `getFeatureValue_CACHED_MAY_BE_STALE` |
|
||||
|
||||
### Mock 策略总结
|
||||
|
||||
通过 `mock.module()` + `await import()` 模式成功解锁了以下重依赖模块的测试:
|
||||
|
||||
| 被 Mock 模块 | 解锁的测试 |
|
||||
|-------------|-----------|
|
||||
| `src/utils/log.ts` | json.ts, tokens.ts, FileEditTool/utils.ts, permissions.ts |
|
||||
| `src/services/tokenEstimation.ts` | tokens.ts |
|
||||
| `src/utils/slowOperations.ts` | tokens.ts, permissions.ts |
|
||||
|
||||
**关键约束**:`mock.module()` 必须在每个测试文件中内联调用,不能从共享 helper 导入(Bun 在 mock 生效前就解析了 helper 的导入)。
|
||||
|
||||
## 12. 参考
|
||||
|
||||
- [Bun Test 文档](https://bun.sh/docs/cli/test)
|
||||
- 现有测试示例:`src/utils/__tests__/set.test.ts`, `src/utils/__tests__/array.test.ts`
|
||||
201
src/__tests__/Tool.test.ts
Normal file
201
src/__tests__/Tool.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildTool,
|
||||
toolMatchesName,
|
||||
findToolByName,
|
||||
getEmptyToolPermissionContext,
|
||||
filterToolProgressMessages,
|
||||
} from "../Tool";
|
||||
|
||||
// Minimal tool definition for testing buildTool
|
||||
function makeMinimalToolDef(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "TestTool",
|
||||
inputSchema: { type: "object" as const } as any,
|
||||
maxResultSizeChars: 10000,
|
||||
call: async () => ({ data: "ok" }),
|
||||
description: async () => "A test tool",
|
||||
prompt: async () => "test prompt",
|
||||
mapToolResultToToolResultBlockParam: (content: unknown, toolUseID: string) => ({
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: toolUseID,
|
||||
content: String(content),
|
||||
}),
|
||||
renderToolUseMessage: () => null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildTool", () => {
|
||||
test("fills in default isEnabled as true", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("fills in default isConcurrencySafe as false", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.isConcurrencySafe({})).toBe(false);
|
||||
});
|
||||
|
||||
test("fills in default isReadOnly as false", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.isReadOnly({})).toBe(false);
|
||||
});
|
||||
|
||||
test("fills in default isDestructive as false", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.isDestructive!({})).toBe(false);
|
||||
});
|
||||
|
||||
test("fills in default checkPermissions as allow", async () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
const input = { foo: "bar" };
|
||||
const result = await tool.checkPermissions(input, {} as any);
|
||||
expect(result).toEqual({ behavior: "allow", updatedInput: input });
|
||||
});
|
||||
|
||||
test("fills in default userFacingName from tool name", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.userFacingName(undefined)).toBe("TestTool");
|
||||
});
|
||||
|
||||
test("fills in default toAutoClassifierInput as empty string", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.toAutoClassifierInput({})).toBe("");
|
||||
});
|
||||
|
||||
test("preserves explicitly provided methods", () => {
|
||||
const tool = buildTool(
|
||||
makeMinimalToolDef({
|
||||
isEnabled: () => false,
|
||||
isConcurrencySafe: () => true,
|
||||
isReadOnly: () => true,
|
||||
})
|
||||
);
|
||||
expect(tool.isEnabled()).toBe(false);
|
||||
expect(tool.isConcurrencySafe({})).toBe(true);
|
||||
expect(tool.isReadOnly({})).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves all non-defaultable properties", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.name).toBe("TestTool");
|
||||
expect(tool.maxResultSizeChars).toBe(10000);
|
||||
expect(typeof tool.call).toBe("function");
|
||||
expect(typeof tool.description).toBe("function");
|
||||
expect(typeof tool.prompt).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolMatchesName", () => {
|
||||
test("returns true for exact name match", () => {
|
||||
expect(toolMatchesName({ name: "Bash" }, "Bash")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for non-matching name", () => {
|
||||
expect(toolMatchesName({ name: "Bash" }, "Read")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when name matches an alias", () => {
|
||||
expect(
|
||||
toolMatchesName({ name: "Bash", aliases: ["BashTool", "Shell"] }, "BashTool")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when aliases is undefined", () => {
|
||||
expect(toolMatchesName({ name: "Bash" }, "BashTool")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when aliases is empty", () => {
|
||||
expect(
|
||||
toolMatchesName({ name: "Bash", aliases: [] }, "BashTool")
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findToolByName", () => {
|
||||
const mockTools = [
|
||||
buildTool(makeMinimalToolDef({ name: "Bash" })),
|
||||
buildTool(makeMinimalToolDef({ name: "Read", aliases: ["FileRead"] })),
|
||||
buildTool(makeMinimalToolDef({ name: "Edit" })),
|
||||
];
|
||||
|
||||
test("finds tool by primary name", () => {
|
||||
const tool = findToolByName(mockTools, "Bash");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool!.name).toBe("Bash");
|
||||
});
|
||||
|
||||
test("finds tool by alias", () => {
|
||||
const tool = findToolByName(mockTools, "FileRead");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool!.name).toBe("Read");
|
||||
});
|
||||
|
||||
test("returns undefined when no match", () => {
|
||||
expect(findToolByName(mockTools, "NonExistent")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns first match when duplicates exist", () => {
|
||||
const dupeTools = [
|
||||
buildTool(makeMinimalToolDef({ name: "Bash", maxResultSizeChars: 100 })),
|
||||
buildTool(makeMinimalToolDef({ name: "Bash", maxResultSizeChars: 200 })),
|
||||
];
|
||||
const tool = findToolByName(dupeTools, "Bash");
|
||||
expect(tool!.maxResultSizeChars).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmptyToolPermissionContext", () => {
|
||||
test("returns default permission mode", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
expect(ctx.mode).toBe("default");
|
||||
});
|
||||
|
||||
test("returns empty maps and arrays", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
expect(ctx.additionalWorkingDirectories.size).toBe(0);
|
||||
expect(ctx.alwaysAllowRules).toEqual({});
|
||||
expect(ctx.alwaysDenyRules).toEqual({});
|
||||
expect(ctx.alwaysAskRules).toEqual({});
|
||||
});
|
||||
|
||||
test("returns isBypassPermissionsModeAvailable as false", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
expect(ctx.isBypassPermissionsModeAvailable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterToolProgressMessages", () => {
|
||||
test("filters out hook_progress messages", () => {
|
||||
const messages = [
|
||||
{ data: { type: "hook_progress", hookName: "pre" } },
|
||||
{ data: { type: "tool_progress", toolName: "Bash" } },
|
||||
] as any[];
|
||||
const result = filterToolProgressMessages(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0]!.data as any).type).toBe("tool_progress");
|
||||
});
|
||||
|
||||
test("keeps tool progress messages", () => {
|
||||
const messages = [
|
||||
{ data: { type: "tool_progress", toolName: "Bash" } },
|
||||
{ data: { type: "tool_progress", toolName: "Read" } },
|
||||
] as any[];
|
||||
const result = filterToolProgressMessages(messages);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(filterToolProgressMessages([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles messages without type field", () => {
|
||||
const messages = [
|
||||
{ data: { toolName: "Bash" } },
|
||||
{ data: { type: "hook_progress" } },
|
||||
] as any[];
|
||||
const result = filterToolProgressMessages(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
82
src/__tests__/tools.test.ts
Normal file
82
src/__tests__/tools.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseToolPreset, filterToolsByDenyRules } from "../tools";
|
||||
import { getEmptyToolPermissionContext } from "../Tool";
|
||||
|
||||
describe("parseToolPreset", () => {
|
||||
test('returns "default" for "default" input', () => {
|
||||
expect(parseToolPreset("default")).toBe("default");
|
||||
});
|
||||
|
||||
test('returns "default" for "Default" input (case-insensitive)', () => {
|
||||
expect(parseToolPreset("Default")).toBe("default");
|
||||
});
|
||||
|
||||
test("returns null for unknown preset", () => {
|
||||
expect(parseToolPreset("unknown")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseToolPreset("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for random string", () => {
|
||||
expect(parseToolPreset("custom-preset")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── filterToolsByDenyRules ─────────────────────────────────────────────
|
||||
|
||||
describe("filterToolsByDenyRules", () => {
|
||||
const mockTools = [
|
||||
{ name: "Bash", mcpInfo: undefined },
|
||||
{ name: "Read", mcpInfo: undefined },
|
||||
{ name: "Write", mcpInfo: undefined },
|
||||
{ name: "mcp__server__tool", mcpInfo: { serverName: "server", toolName: "tool" } },
|
||||
];
|
||||
|
||||
test("returns all tools when no deny rules", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
const result = filterToolsByDenyRules(mockTools, ctx);
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("filters out denied tool by name", () => {
|
||||
const ctx = {
|
||||
...getEmptyToolPermissionContext(),
|
||||
alwaysDenyRules: {
|
||||
localSettings: ["Bash"],
|
||||
},
|
||||
};
|
||||
const result = filterToolsByDenyRules(mockTools, ctx as any);
|
||||
expect(result.find((t) => t.name === "Bash")).toBeUndefined();
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("filters out multiple denied tools", () => {
|
||||
const ctx = {
|
||||
...getEmptyToolPermissionContext(),
|
||||
alwaysDenyRules: {
|
||||
localSettings: ["Bash", "Write"],
|
||||
},
|
||||
};
|
||||
const result = filterToolsByDenyRules(mockTools, ctx as any);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t) => t.name)).toEqual(["Read", "mcp__server__tool"]);
|
||||
});
|
||||
|
||||
test("returns empty array when all tools denied", () => {
|
||||
const ctx = {
|
||||
...getEmptyToolPermissionContext(),
|
||||
alwaysDenyRules: {
|
||||
localSettings: mockTools.map((t) => t.name),
|
||||
},
|
||||
};
|
||||
const result = filterToolsByDenyRules(mockTools, ctx as any);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("handles empty tools array", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
expect(filterToolsByDenyRules([], ctx)).toEqual([]);
|
||||
});
|
||||
});
|
||||
164
src/tools/FileEditTool/__tests__/utils.test.ts
Normal file
164
src/tools/FileEditTool/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock log.ts to cut the heavy dependency chain
|
||||
mock.module("src/utils/log.ts", () => ({
|
||||
logError: () => {},
|
||||
logToFile: () => {},
|
||||
getLogDisplayTitle: () => "",
|
||||
logEvent: () => {},
|
||||
logMCPError: () => {},
|
||||
logMCPDebug: () => {},
|
||||
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"),
|
||||
getLogFilePath: () => "/tmp/mock-log",
|
||||
attachErrorLogSink: () => {},
|
||||
getInMemoryErrors: () => [],
|
||||
loadErrorLogs: async () => [],
|
||||
getErrorLogByIndex: async () => null,
|
||||
captureAPIRequest: () => {},
|
||||
_resetErrorLogForTesting: () => {},
|
||||
}));
|
||||
|
||||
const {
|
||||
normalizeQuotes,
|
||||
stripTrailingWhitespace,
|
||||
findActualString,
|
||||
preserveQuoteStyle,
|
||||
applyEditToFile,
|
||||
LEFT_SINGLE_CURLY_QUOTE,
|
||||
RIGHT_SINGLE_CURLY_QUOTE,
|
||||
LEFT_DOUBLE_CURLY_QUOTE,
|
||||
RIGHT_DOUBLE_CURLY_QUOTE,
|
||||
} = await import("../utils");
|
||||
|
||||
// ─── normalizeQuotes ────────────────────────────────────────────────────
|
||||
|
||||
describe("normalizeQuotes", () => {
|
||||
test("converts left single curly to straight", () => {
|
||||
expect(normalizeQuotes(`${LEFT_SINGLE_CURLY_QUOTE}hello`)).toBe("'hello");
|
||||
});
|
||||
|
||||
test("converts right single curly to straight", () => {
|
||||
expect(normalizeQuotes(`hello${RIGHT_SINGLE_CURLY_QUOTE}`)).toBe("hello'");
|
||||
});
|
||||
|
||||
test("converts left double curly to straight", () => {
|
||||
expect(normalizeQuotes(`${LEFT_DOUBLE_CURLY_QUOTE}hello`)).toBe('"hello');
|
||||
});
|
||||
|
||||
test("converts right double curly to straight", () => {
|
||||
expect(normalizeQuotes(`hello${RIGHT_DOUBLE_CURLY_QUOTE}`)).toBe('hello"');
|
||||
});
|
||||
|
||||
test("leaves straight quotes unchanged", () => {
|
||||
expect(normalizeQuotes("'hello' \"world\"")).toBe("'hello' \"world\"");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(normalizeQuotes("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stripTrailingWhitespace ────────────────────────────────────────────
|
||||
|
||||
describe("stripTrailingWhitespace", () => {
|
||||
test("strips trailing spaces from lines", () => {
|
||||
expect(stripTrailingWhitespace("hello \nworld ")).toBe("hello\nworld");
|
||||
});
|
||||
|
||||
test("strips trailing tabs", () => {
|
||||
expect(stripTrailingWhitespace("hello\t\nworld\t")).toBe("hello\nworld");
|
||||
});
|
||||
|
||||
test("preserves leading whitespace", () => {
|
||||
expect(stripTrailingWhitespace(" hello \n world ")).toBe(
|
||||
" hello\n world"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(stripTrailingWhitespace("")).toBe("");
|
||||
});
|
||||
|
||||
test("handles CRLF line endings", () => {
|
||||
expect(stripTrailingWhitespace("hello \r\nworld ")).toBe(
|
||||
"hello\r\nworld"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles no trailing whitespace", () => {
|
||||
expect(stripTrailingWhitespace("hello\nworld")).toBe("hello\nworld");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── findActualString ───────────────────────────────────────────────────
|
||||
|
||||
describe("findActualString", () => {
|
||||
test("finds exact match", () => {
|
||||
expect(findActualString("hello world", "hello")).toBe("hello");
|
||||
});
|
||||
|
||||
test("finds match with curly quotes normalized", () => {
|
||||
const fileContent = `${LEFT_DOUBLE_CURLY_QUOTE}hello${RIGHT_DOUBLE_CURLY_QUOTE}`;
|
||||
const result = findActualString(fileContent, '"hello"');
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when not found", () => {
|
||||
expect(findActualString("hello world", "xyz")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty search in non-empty content", () => {
|
||||
// Empty string is always found at index 0 via includes()
|
||||
const result = findActualString("hello", "");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── preserveQuoteStyle ─────────────────────────────────────────────────
|
||||
|
||||
describe("preserveQuoteStyle", () => {
|
||||
test("returns newString unchanged when no normalization happened", () => {
|
||||
expect(preserveQuoteStyle("hello", "hello", "world")).toBe("world");
|
||||
});
|
||||
|
||||
test("converts straight double quotes to curly in replacement", () => {
|
||||
const oldString = '"hello"';
|
||||
const actualOldString = `${LEFT_DOUBLE_CURLY_QUOTE}hello${RIGHT_DOUBLE_CURLY_QUOTE}`;
|
||||
const newString = '"world"';
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString);
|
||||
expect(result).toContain(LEFT_DOUBLE_CURLY_QUOTE);
|
||||
expect(result).toContain(RIGHT_DOUBLE_CURLY_QUOTE);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyEditToFile ────────────────────────────────────────────────────
|
||||
|
||||
describe("applyEditToFile", () => {
|
||||
test("replaces first occurrence by default", () => {
|
||||
expect(applyEditToFile("foo bar foo", "foo", "baz")).toBe("baz bar foo");
|
||||
});
|
||||
|
||||
test("replaces all occurrences with replaceAll=true", () => {
|
||||
expect(applyEditToFile("foo bar foo", "foo", "baz", true)).toBe(
|
||||
"baz bar baz"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles deletion (empty newString) with trailing newline", () => {
|
||||
const result = applyEditToFile("line1\nline2\nline3\n", "line2", "");
|
||||
expect(result).toBe("line1\nline3\n");
|
||||
});
|
||||
|
||||
test("handles deletion without trailing newline", () => {
|
||||
const result = applyEditToFile("foobar", "foo", "");
|
||||
expect(result).toBe("bar");
|
||||
});
|
||||
|
||||
test("handles no match (returns original)", () => {
|
||||
expect(applyEditToFile("hello world", "xyz", "abc")).toBe("hello world");
|
||||
});
|
||||
|
||||
test("handles empty original content with insertion", () => {
|
||||
expect(applyEditToFile("", "", "new content")).toBe("new content");
|
||||
});
|
||||
});
|
||||
134
src/tools/shared/__tests__/gitOperationTracking.test.ts
Normal file
134
src/tools/shared/__tests__/gitOperationTracking.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseGitCommitId, detectGitOperation } from "../gitOperationTracking";
|
||||
|
||||
describe("parseGitCommitId", () => {
|
||||
test("extracts commit hash from git commit output", () => {
|
||||
expect(parseGitCommitId("[main abc1234] fix: some message")).toBe("abc1234");
|
||||
});
|
||||
|
||||
test("extracts hash from root commit output", () => {
|
||||
expect(
|
||||
parseGitCommitId("[main (root-commit) abc1234] initial commit")
|
||||
).toBe("abc1234");
|
||||
});
|
||||
|
||||
test("returns undefined for non-commit output", () => {
|
||||
expect(parseGitCommitId("nothing to commit")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles various branch name formats", () => {
|
||||
expect(parseGitCommitId("[feature/foo abc1234] message")).toBe("abc1234");
|
||||
expect(parseGitCommitId("[fix/bar-baz abc1234] message")).toBe("abc1234");
|
||||
expect(parseGitCommitId("[v1.0.0 abc1234] message")).toBe("abc1234");
|
||||
});
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(parseGitCommitId("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectGitOperation", () => {
|
||||
test("detects git commit operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"git commit -m 'fix bug'",
|
||||
"[main abc1234] fix bug"
|
||||
);
|
||||
expect(result.commit).toBeDefined();
|
||||
expect(result.commit!.sha).toBe("abc123");
|
||||
expect(result.commit!.kind).toBe("committed");
|
||||
});
|
||||
|
||||
test("detects git commit --amend operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"git commit --amend -m 'updated'",
|
||||
"[main def5678] updated"
|
||||
);
|
||||
expect(result.commit).toBeDefined();
|
||||
expect(result.commit!.kind).toBe("amended");
|
||||
});
|
||||
|
||||
test("detects git cherry-pick operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"git cherry-pick abc1234",
|
||||
"[main def5678] cherry picked commit"
|
||||
);
|
||||
expect(result.commit).toBeDefined();
|
||||
expect(result.commit!.kind).toBe("cherry-picked");
|
||||
});
|
||||
|
||||
test("detects git push operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"git push origin main",
|
||||
" abc1234..def5678 main -> main"
|
||||
);
|
||||
expect(result.push).toBeDefined();
|
||||
expect(result.push!.branch).toBe("main");
|
||||
});
|
||||
|
||||
test("detects git merge operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"git merge feature-branch",
|
||||
"Merge made by the 'ort' strategy."
|
||||
);
|
||||
expect(result.branch).toBeDefined();
|
||||
expect(result.branch!.action).toBe("merged");
|
||||
expect(result.branch!.ref).toBe("feature-branch");
|
||||
});
|
||||
|
||||
test("detects git rebase operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"git rebase main",
|
||||
"Successfully rebased and updated refs/heads/feature."
|
||||
);
|
||||
expect(result.branch).toBeDefined();
|
||||
expect(result.branch!.action).toBe("rebased");
|
||||
expect(result.branch!.ref).toBe("main");
|
||||
});
|
||||
|
||||
test("returns null for non-git commands", () => {
|
||||
const result = detectGitOperation("ls -la", "total 100\ndrwxr-xr-x");
|
||||
expect(result.commit).toBeUndefined();
|
||||
expect(result.push).toBeUndefined();
|
||||
expect(result.branch).toBeUndefined();
|
||||
expect(result.pr).toBeUndefined();
|
||||
});
|
||||
|
||||
test("detects gh pr create operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"gh pr create --title 'fix' --body 'desc'",
|
||||
"https://github.com/owner/repo/pull/42"
|
||||
);
|
||||
expect(result.pr).toBeDefined();
|
||||
expect(result.pr!.number).toBe(42);
|
||||
expect(result.pr!.action).toBe("created");
|
||||
});
|
||||
|
||||
test("detects gh pr merge operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"gh pr merge 42",
|
||||
"✓ Merged pull request owner/repo#42"
|
||||
);
|
||||
expect(result.pr).toBeDefined();
|
||||
expect(result.pr!.number).toBe(42);
|
||||
expect(result.pr!.action).toBe("merged");
|
||||
});
|
||||
|
||||
test("handles git commit with -c options", () => {
|
||||
const result = detectGitOperation(
|
||||
"git -c commit.gpgsign=false commit -m 'msg'",
|
||||
"[main aaa1111] msg"
|
||||
);
|
||||
expect(result.commit).toBeDefined();
|
||||
expect(result.commit!.sha).toBe("aaa111");
|
||||
});
|
||||
|
||||
test("detects fast-forward merge", () => {
|
||||
const result = detectGitOperation(
|
||||
"git merge develop",
|
||||
"Fast-forward\n file.txt | 1 +\n 1 file changed"
|
||||
);
|
||||
expect(result.branch).toBeDefined();
|
||||
expect(result.branch!.action).toBe("merged");
|
||||
expect(result.branch!.ref).toBe("develop");
|
||||
});
|
||||
});
|
||||
123
src/utils/__tests__/claudemd.test.ts
Normal file
123
src/utils/__tests__/claudemd.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
stripHtmlComments,
|
||||
isMemoryFilePath,
|
||||
getLargeMemoryFiles,
|
||||
MAX_MEMORY_CHARACTER_COUNT,
|
||||
type MemoryFileInfo,
|
||||
} from "../claudemd";
|
||||
|
||||
function mockMemoryFile(overrides: Partial<MemoryFileInfo> = {}): MemoryFileInfo {
|
||||
return {
|
||||
path: "/project/CLAUDE.md",
|
||||
type: "Project",
|
||||
content: "test content",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("stripHtmlComments", () => {
|
||||
test("strips block-level HTML comments (own line)", () => {
|
||||
// CommonMark type-2 HTML blocks: comment must start at beginning of line
|
||||
const result = stripHtmlComments("text\n<!-- block comment -->\nmore");
|
||||
expect(result.content).not.toContain("block comment");
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
|
||||
test("returns stripped: false when no comments", () => {
|
||||
const result = stripHtmlComments("no comments here");
|
||||
expect(result.stripped).toBe(false);
|
||||
expect(result.content).toBe("no comments here");
|
||||
});
|
||||
|
||||
test("returns stripped: true when block comments exist", () => {
|
||||
const result = stripHtmlComments("hello\n<!-- world -->\nend");
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
const result = stripHtmlComments("");
|
||||
expect(result.content).toBe("");
|
||||
expect(result.stripped).toBe(false);
|
||||
});
|
||||
|
||||
test("handles multiple block comments", () => {
|
||||
const result = stripHtmlComments(
|
||||
"a\n<!-- c1 -->\nb\n<!-- c2 -->\nc"
|
||||
);
|
||||
expect(result.content).not.toContain("c1");
|
||||
expect(result.content).not.toContain("c2");
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves code block content", () => {
|
||||
const input = "text\n```html\n<!-- not stripped -->\n```\nmore";
|
||||
const result = stripHtmlComments(input);
|
||||
expect(result.content).toContain("<!-- not stripped -->");
|
||||
});
|
||||
|
||||
test("preserves inline comments within paragraphs", () => {
|
||||
// Inline comments are NOT stripped (CommonMark paragraph semantics)
|
||||
const result = stripHtmlComments("text <!-- inline --> more");
|
||||
expect(result.content).toContain("<!-- inline -->");
|
||||
expect(result.stripped).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMemoryFilePath", () => {
|
||||
test("returns true for CLAUDE.md path", () => {
|
||||
expect(isMemoryFilePath("/project/CLAUDE.md")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for CLAUDE.local.md path", () => {
|
||||
expect(isMemoryFilePath("/project/CLAUDE.local.md")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for .claude/rules/ path", () => {
|
||||
expect(isMemoryFilePath("/project/.claude/rules/foo.md")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for regular file", () => {
|
||||
expect(isMemoryFilePath("/project/src/main.ts")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for unrelated .md file", () => {
|
||||
expect(isMemoryFilePath("/project/README.md")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for .claude directory non-rules file", () => {
|
||||
expect(isMemoryFilePath("/project/.claude/settings.json")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLargeMemoryFiles", () => {
|
||||
test("returns files exceeding threshold", () => {
|
||||
const largeContent = "x".repeat(MAX_MEMORY_CHARACTER_COUNT + 1);
|
||||
const files = [
|
||||
mockMemoryFile({ content: "small" }),
|
||||
mockMemoryFile({ content: largeContent, path: "/big.md" }),
|
||||
];
|
||||
const result = getLargeMemoryFiles(files);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe("/big.md");
|
||||
});
|
||||
|
||||
test("returns empty array when all files are small", () => {
|
||||
const files = [
|
||||
mockMemoryFile({ content: "small" }),
|
||||
mockMemoryFile({ content: "also small" }),
|
||||
];
|
||||
expect(getLargeMemoryFiles(files)).toEqual([]);
|
||||
});
|
||||
|
||||
test("correctly identifies threshold boundary", () => {
|
||||
const atThreshold = "x".repeat(MAX_MEMORY_CHARACTER_COUNT);
|
||||
const overThreshold = "x".repeat(MAX_MEMORY_CHARACTER_COUNT + 1);
|
||||
const files = [
|
||||
mockMemoryFile({ content: atThreshold }),
|
||||
mockMemoryFile({ content: overThreshold }),
|
||||
];
|
||||
const result = getLargeMemoryFiles(files);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
253
src/utils/__tests__/cron.test.ts
Normal file
253
src/utils/__tests__/cron.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseCronExpression, computeNextCronRun, cronToHuman } from "../cron";
|
||||
|
||||
describe("parseCronExpression", () => {
|
||||
describe("valid expressions", () => {
|
||||
test("parses wildcard fields", () => {
|
||||
const result = parseCronExpression("* * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toHaveLength(60);
|
||||
expect(result!.hour).toHaveLength(24);
|
||||
expect(result!.dayOfMonth).toHaveLength(31);
|
||||
expect(result!.month).toHaveLength(12);
|
||||
expect(result!.dayOfWeek).toHaveLength(7);
|
||||
});
|
||||
|
||||
test("parses specific values", () => {
|
||||
const result = parseCronExpression("30 14 1 6 3");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([30]);
|
||||
expect(result!.hour).toEqual([14]);
|
||||
expect(result!.dayOfMonth).toEqual([1]);
|
||||
expect(result!.month).toEqual([6]);
|
||||
expect(result!.dayOfWeek).toEqual([3]);
|
||||
});
|
||||
|
||||
test("parses step syntax", () => {
|
||||
const result = parseCronExpression("*/5 * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]);
|
||||
});
|
||||
|
||||
test("parses range syntax", () => {
|
||||
const result = parseCronExpression("1-5 * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test("parses range with step", () => {
|
||||
const result = parseCronExpression("1-10/3 * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([1, 4, 7, 10]);
|
||||
});
|
||||
|
||||
test("parses comma-separated list", () => {
|
||||
const result = parseCronExpression("1,15,30 * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([1, 15, 30]);
|
||||
});
|
||||
|
||||
test("parses day-of-week 7 as Sunday alias", () => {
|
||||
const result = parseCronExpression("0 0 * * 7");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.dayOfWeek).toEqual([0]);
|
||||
});
|
||||
|
||||
test("parses range with day-of-week 7", () => {
|
||||
const result = parseCronExpression("0 0 * * 5-7");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.dayOfWeek).toEqual([0, 5, 6]);
|
||||
});
|
||||
|
||||
test("parses complex combined expression", () => {
|
||||
const result = parseCronExpression("0,30 9-17 * * 1-5");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([0, 30]);
|
||||
expect(result!.hour).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]);
|
||||
expect(result!.dayOfWeek).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid expressions", () => {
|
||||
test("returns null for wrong field count", () => {
|
||||
expect(parseCronExpression("* * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for out-of-range values", () => {
|
||||
expect(parseCronExpression("60 * * * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for invalid step", () => {
|
||||
expect(parseCronExpression("*/0 * * * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for reversed range", () => {
|
||||
expect(parseCronExpression("10-5 * * * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseCronExpression("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for non-numeric tokens", () => {
|
||||
expect(parseCronExpression("abc * * * *")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("field range validation", () => {
|
||||
test("minute: 0-59", () => {
|
||||
expect(parseCronExpression("0 * * * *")).not.toBeNull();
|
||||
expect(parseCronExpression("59 * * * *")).not.toBeNull();
|
||||
expect(parseCronExpression("60 * * * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("hour: 0-23", () => {
|
||||
expect(parseCronExpression("* 0 * * *")).not.toBeNull();
|
||||
expect(parseCronExpression("* 23 * * *")).not.toBeNull();
|
||||
expect(parseCronExpression("* 24 * * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("dayOfMonth: 1-31", () => {
|
||||
expect(parseCronExpression("* * 1 * *")).not.toBeNull();
|
||||
expect(parseCronExpression("* * 31 * *")).not.toBeNull();
|
||||
expect(parseCronExpression("* * 0 * *")).toBeNull();
|
||||
expect(parseCronExpression("* * 32 * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("month: 1-12", () => {
|
||||
expect(parseCronExpression("* * * 1 *")).not.toBeNull();
|
||||
expect(parseCronExpression("* * * 12 *")).not.toBeNull();
|
||||
expect(parseCronExpression("* * * 0 *")).toBeNull();
|
||||
expect(parseCronExpression("* * * 13 *")).toBeNull();
|
||||
});
|
||||
|
||||
test("dayOfWeek: 0-6 (plus 7 alias)", () => {
|
||||
expect(parseCronExpression("* * * * 0")).not.toBeNull();
|
||||
expect(parseCronExpression("* * * * 6")).not.toBeNull();
|
||||
expect(parseCronExpression("* * * * 7")).not.toBeNull(); // alias for 0
|
||||
expect(parseCronExpression("* * * * 8")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeNextCronRun", () => {
|
||||
test("finds next minute", () => {
|
||||
const fields = parseCronExpression("31 14 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 30, 45); // 14:30:45
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getHours()).toBe(14);
|
||||
expect(next!.getMinutes()).toBe(31);
|
||||
});
|
||||
|
||||
test("finds next hour", () => {
|
||||
const fields = parseCronExpression("0 15 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 30);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getHours()).toBe(15);
|
||||
expect(next!.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
test("rolls to next day", () => {
|
||||
const fields = parseCronExpression("0 10 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 30);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getDate()).toBe(16);
|
||||
expect(next!.getHours()).toBe(10);
|
||||
});
|
||||
|
||||
test("is strictly after from date", () => {
|
||||
const fields = parseCronExpression("30 14 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 30, 0); // exactly on cron time
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getTime()).toBeGreaterThan(from.getTime());
|
||||
});
|
||||
|
||||
test("every 5 minutes from arbitrary time", () => {
|
||||
const fields = parseCronExpression("*/5 * * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 32);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getMinutes()).toBe(35);
|
||||
});
|
||||
|
||||
test("every minute", () => {
|
||||
const fields = parseCronExpression("* * * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 32, 45);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getMinutes()).toBe(33);
|
||||
});
|
||||
|
||||
test("handles step across midnight", () => {
|
||||
const fields = parseCronExpression("0 0 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 23, 59);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getHours()).toBe(0);
|
||||
expect(next!.getDate()).toBe(16);
|
||||
});
|
||||
|
||||
test("OR semantics when both dom and dow constrained", () => {
|
||||
// dom=15, dow=3(Wed) - matches 15th OR Wednesday
|
||||
const fields = parseCronExpression("0 0 15 * 3")!;
|
||||
const from = new Date(2026, 0, 12, 0, 0); // Monday Jan 12
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
// Should match the first of either: next Wednesday(Jan 14) or 15th(Jan 15)
|
||||
const dayOfWeek = next!.getDay();
|
||||
const dayOfMonth = next!.getDate();
|
||||
expect(dayOfWeek === 3 || dayOfMonth === 15).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cronToHuman", () => {
|
||||
test("every N minutes", () => {
|
||||
expect(cronToHuman("*/5 * * * *")).toBe("Every 5 minutes");
|
||||
});
|
||||
|
||||
test("every minute", () => {
|
||||
expect(cronToHuman("*/1 * * * *")).toBe("Every minute");
|
||||
});
|
||||
|
||||
test("every hour at :00", () => {
|
||||
expect(cronToHuman("0 * * * *")).toBe("Every hour");
|
||||
});
|
||||
|
||||
test("every hour at :30", () => {
|
||||
expect(cronToHuman("30 * * * *")).toBe("Every hour at :30");
|
||||
});
|
||||
|
||||
test("every N hours", () => {
|
||||
expect(cronToHuman("0 */2 * * *")).toBe("Every 2 hours");
|
||||
});
|
||||
|
||||
test("daily at specific time", () => {
|
||||
const result = cronToHuman("30 9 * * *");
|
||||
expect(result).toContain("Every day at");
|
||||
expect(result).toContain("9:30");
|
||||
});
|
||||
|
||||
test("specific day of week", () => {
|
||||
const result = cronToHuman("0 9 * * 3");
|
||||
expect(result).toContain("Wednesday");
|
||||
expect(result).toContain("9:00");
|
||||
});
|
||||
|
||||
test("weekdays", () => {
|
||||
const result = cronToHuman("0 9 * * 1-5");
|
||||
expect(result).toContain("Weekdays");
|
||||
expect(result).toContain("9:00");
|
||||
});
|
||||
|
||||
test("returns raw cron for complex patterns", () => {
|
||||
expect(cronToHuman("0,30 9-17 * * 1-5")).toBe("0,30 9-17 * * 1-5");
|
||||
});
|
||||
|
||||
test("returns raw cron for wrong field count", () => {
|
||||
expect(cronToHuman("* * *")).toBe("* * *");
|
||||
});
|
||||
});
|
||||
77
src/utils/__tests__/diff.test.ts
Normal file
77
src/utils/__tests__/diff.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { adjustHunkLineNumbers, getPatchFromContents } from "../diff";
|
||||
|
||||
describe("adjustHunkLineNumbers", () => {
|
||||
test("shifts hunk line numbers by offset", () => {
|
||||
const hunks = [
|
||||
{ oldStart: 1, oldLines: 3, newStart: 1, newLines: 4, lines: [" a", "-b", "+c", "+d", " e"] },
|
||||
] as any[];
|
||||
const result = adjustHunkLineNumbers(hunks, 10);
|
||||
expect(result[0].oldStart).toBe(11);
|
||||
expect(result[0].newStart).toBe(11);
|
||||
});
|
||||
|
||||
test("returns original hunks for zero offset", () => {
|
||||
const hunks = [
|
||||
{ oldStart: 5, oldLines: 2, newStart: 5, newLines: 2, lines: [] },
|
||||
] as any[];
|
||||
const result = adjustHunkLineNumbers(hunks, 0);
|
||||
expect(result).toBe(hunks); // same reference
|
||||
});
|
||||
|
||||
test("handles negative offset", () => {
|
||||
const hunks = [
|
||||
{ oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [] },
|
||||
] as any[];
|
||||
const result = adjustHunkLineNumbers(hunks, -5);
|
||||
expect(result[0].oldStart).toBe(5);
|
||||
expect(result[0].newStart).toBe(5);
|
||||
});
|
||||
|
||||
test("handles empty hunks array", () => {
|
||||
expect(adjustHunkLineNumbers([], 10)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPatchFromContents", () => {
|
||||
test("returns hunks for different content", () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "hello\nworld",
|
||||
newContent: "hello\nplanet",
|
||||
});
|
||||
expect(hunks.length).toBeGreaterThan(0);
|
||||
expect(hunks[0].lines.some((l: string) => l.startsWith("-"))).toBe(true);
|
||||
expect(hunks[0].lines.some((l: string) => l.startsWith("+"))).toBe(true);
|
||||
});
|
||||
|
||||
test("returns empty hunks for identical content", () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "same content",
|
||||
newContent: "same content",
|
||||
});
|
||||
expect(hunks).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles content with ampersands", () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "a & b",
|
||||
newContent: "a & c",
|
||||
});
|
||||
expect(hunks.length).toBeGreaterThan(0);
|
||||
// Verify ampersands are unescaped in the output
|
||||
const allLines = hunks.flatMap((h: any) => h.lines);
|
||||
expect(allLines.some((l: string) => l.includes("&"))).toBe(true);
|
||||
});
|
||||
|
||||
test("handles empty old content (new file)", () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "",
|
||||
newContent: "new content",
|
||||
});
|
||||
expect(hunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
95
src/utils/__tests__/file.test.ts
Normal file
95
src/utils/__tests__/file.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
convertLeadingTabsToSpaces,
|
||||
addLineNumbers,
|
||||
stripLineNumberPrefix,
|
||||
pathsEqual,
|
||||
normalizePathForComparison,
|
||||
} from "../file";
|
||||
|
||||
describe("convertLeadingTabsToSpaces", () => {
|
||||
test("converts leading tabs to 2 spaces each", () => {
|
||||
expect(convertLeadingTabsToSpaces("\t\thello")).toBe(" hello");
|
||||
});
|
||||
|
||||
test("only converts leading tabs", () => {
|
||||
expect(convertLeadingTabsToSpaces("\thello\tworld")).toBe(" hello\tworld");
|
||||
});
|
||||
|
||||
test("returns unchanged if no tabs", () => {
|
||||
expect(convertLeadingTabsToSpaces("no tabs")).toBe("no tabs");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(convertLeadingTabsToSpaces("")).toBe("");
|
||||
});
|
||||
|
||||
test("handles multiline content", () => {
|
||||
const input = "\tline1\n\t\tline2\nline3";
|
||||
const expected = " line1\n line2\nline3";
|
||||
expect(convertLeadingTabsToSpaces(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addLineNumbers", () => {
|
||||
test("adds line numbers starting from 1", () => {
|
||||
const result = addLineNumbers({ content: "a\nb\nc", startLine: 1 });
|
||||
expect(result).toContain("1");
|
||||
expect(result).toContain("a");
|
||||
expect(result).toContain("b");
|
||||
expect(result).toContain("c");
|
||||
});
|
||||
|
||||
test("returns empty string for empty content", () => {
|
||||
expect(addLineNumbers({ content: "", startLine: 1 })).toBe("");
|
||||
});
|
||||
|
||||
test("respects startLine offset", () => {
|
||||
const result = addLineNumbers({ content: "hello", startLine: 10 });
|
||||
expect(result).toContain("10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripLineNumberPrefix", () => {
|
||||
test("strips arrow-separated prefix", () => {
|
||||
expect(stripLineNumberPrefix(" 1→content")).toBe("content");
|
||||
});
|
||||
|
||||
test("strips tab-separated prefix", () => {
|
||||
expect(stripLineNumberPrefix("1\tcontent")).toBe("content");
|
||||
});
|
||||
|
||||
test("returns line unchanged if no prefix", () => {
|
||||
expect(stripLineNumberPrefix("no prefix")).toBe("no prefix");
|
||||
});
|
||||
|
||||
test("handles large line numbers", () => {
|
||||
expect(stripLineNumberPrefix("123456→content")).toBe("content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePathForComparison", () => {
|
||||
test("normalizes redundant separators", () => {
|
||||
const result = normalizePathForComparison("/a//b/c");
|
||||
expect(result).toBe("/a/b/c");
|
||||
});
|
||||
|
||||
test("resolves dot segments", () => {
|
||||
const result = normalizePathForComparison("/a/./b/../c");
|
||||
expect(result).toBe("/a/c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathsEqual", () => {
|
||||
test("returns true for identical paths", () => {
|
||||
expect(pathsEqual("/a/b/c", "/a/b/c")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for equivalent paths with dot segments", () => {
|
||||
expect(pathsEqual("/a/./b", "/a/b")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for different paths", () => {
|
||||
expect(pathsEqual("/a/b", "/a/c")).toBe(false);
|
||||
});
|
||||
});
|
||||
133
src/utils/__tests__/format.test.ts
Normal file
133
src/utils/__tests__/format.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
formatFileSize,
|
||||
formatSecondsShort,
|
||||
formatDuration,
|
||||
formatNumber,
|
||||
formatTokens,
|
||||
formatRelativeTime,
|
||||
} from "../format";
|
||||
|
||||
describe("formatFileSize", () => {
|
||||
test("formats bytes", () => {
|
||||
expect(formatFileSize(500)).toBe("500 bytes");
|
||||
});
|
||||
|
||||
test("formats kilobytes", () => {
|
||||
expect(formatFileSize(1536)).toBe("1.5KB");
|
||||
});
|
||||
|
||||
test("formats megabytes", () => {
|
||||
expect(formatFileSize(1.5 * 1024 * 1024)).toBe("1.5MB");
|
||||
});
|
||||
|
||||
test("formats gigabytes", () => {
|
||||
expect(formatFileSize(2 * 1024 * 1024 * 1024)).toBe("2GB");
|
||||
});
|
||||
|
||||
test("removes trailing .0", () => {
|
||||
expect(formatFileSize(1024)).toBe("1KB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSecondsShort", () => {
|
||||
test("formats milliseconds to seconds", () => {
|
||||
expect(formatSecondsShort(1234)).toBe("1.2s");
|
||||
});
|
||||
|
||||
test("formats zero", () => {
|
||||
expect(formatSecondsShort(0)).toBe("0.0s");
|
||||
});
|
||||
|
||||
test("formats sub-second", () => {
|
||||
expect(formatSecondsShort(500)).toBe("0.5s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDuration", () => {
|
||||
test("formats 0 as 0s", () => {
|
||||
expect(formatDuration(0)).toBe("0s");
|
||||
});
|
||||
|
||||
test("formats seconds", () => {
|
||||
expect(formatDuration(5000)).toBe("5s");
|
||||
});
|
||||
|
||||
test("formats minutes and seconds", () => {
|
||||
expect(formatDuration(125000)).toBe("2m 5s");
|
||||
});
|
||||
|
||||
test("formats hours", () => {
|
||||
expect(formatDuration(3661000)).toBe("1h 1m 1s");
|
||||
});
|
||||
|
||||
test("formats days", () => {
|
||||
expect(formatDuration(90000000)).toBe("1d 1h 0m");
|
||||
});
|
||||
|
||||
test("hideTrailingZeros removes zero components", () => {
|
||||
expect(formatDuration(3600000, { hideTrailingZeros: true })).toBe("1h");
|
||||
expect(formatDuration(60000, { hideTrailingZeros: true })).toBe("1m");
|
||||
});
|
||||
|
||||
test("mostSignificantOnly returns largest unit", () => {
|
||||
expect(formatDuration(90000000, { mostSignificantOnly: true })).toBe("1d");
|
||||
expect(formatDuration(3661000, { mostSignificantOnly: true })).toBe("1h");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatNumber", () => {
|
||||
test("formats small numbers as-is", () => {
|
||||
expect(formatNumber(900)).toBe("900");
|
||||
});
|
||||
|
||||
test("formats thousands with k suffix", () => {
|
||||
const result = formatNumber(1321);
|
||||
expect(result).toContain("k");
|
||||
});
|
||||
|
||||
test("formats millions", () => {
|
||||
const result = formatNumber(1500000);
|
||||
expect(result).toContain("m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokens", () => {
|
||||
test("removes .0 from formatted number", () => {
|
||||
const result = formatTokens(1000);
|
||||
expect(result).not.toContain(".0");
|
||||
});
|
||||
|
||||
test("formats small numbers", () => {
|
||||
expect(formatTokens(500)).toBe("500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeTime", () => {
|
||||
const now = new Date("2026-01-15T12:00:00Z");
|
||||
|
||||
test("formats seconds ago", () => {
|
||||
const date = new Date("2026-01-15T11:59:30Z");
|
||||
const result = formatRelativeTime(date, { now });
|
||||
expect(result).toContain("30");
|
||||
expect(result).toContain("ago");
|
||||
});
|
||||
|
||||
test("formats minutes ago", () => {
|
||||
const date = new Date("2026-01-15T11:55:00Z");
|
||||
const result = formatRelativeTime(date, { now });
|
||||
expect(result).toContain("5");
|
||||
expect(result).toContain("ago");
|
||||
});
|
||||
|
||||
test("formats future time", () => {
|
||||
const date = new Date("2026-01-15T13:00:00Z");
|
||||
const result = formatRelativeTime(date, { now });
|
||||
expect(result).toContain("in");
|
||||
});
|
||||
|
||||
test("handles zero difference", () => {
|
||||
const result = formatRelativeTime(now, { now });
|
||||
expect(result).toContain("0");
|
||||
});
|
||||
});
|
||||
164
src/utils/__tests__/frontmatterParser.test.ts
Normal file
164
src/utils/__tests__/frontmatterParser.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
parseFrontmatter,
|
||||
splitPathInFrontmatter,
|
||||
parsePositiveIntFromFrontmatter,
|
||||
parseBooleanFrontmatter,
|
||||
parseShellFrontmatter,
|
||||
} from "../frontmatterParser";
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
test("parses valid frontmatter", () => {
|
||||
const md = `---
|
||||
description: A test
|
||||
type: user
|
||||
---
|
||||
Content here`;
|
||||
const result = parseFrontmatter(md);
|
||||
expect(result.frontmatter.description).toBe("A test");
|
||||
expect(result.frontmatter.type).toBe("user");
|
||||
expect(result.content).toBe("Content here");
|
||||
});
|
||||
|
||||
test("returns empty frontmatter when none exists", () => {
|
||||
const md = "Just content, no frontmatter";
|
||||
const result = parseFrontmatter(md);
|
||||
expect(result.frontmatter).toEqual({});
|
||||
expect(result.content).toBe(md);
|
||||
});
|
||||
|
||||
test("handles empty frontmatter block", () => {
|
||||
const md = `---
|
||||
---
|
||||
Content`;
|
||||
const result = parseFrontmatter(md);
|
||||
expect(result.frontmatter).toEqual({});
|
||||
expect(result.content).toBe("Content");
|
||||
});
|
||||
|
||||
test("handles frontmatter with list values", () => {
|
||||
const md = `---
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
---
|
||||
Content`;
|
||||
const result = parseFrontmatter(md);
|
||||
expect(result.frontmatter["allowed-tools"]).toEqual(["Bash", "Read"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitPathInFrontmatter", () => {
|
||||
test("splits comma-separated paths", () => {
|
||||
expect(splitPathInFrontmatter("a, b, c")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("expands brace patterns", () => {
|
||||
expect(splitPathInFrontmatter("src/*.{ts,tsx}")).toEqual([
|
||||
"src/*.ts",
|
||||
"src/*.tsx",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles nested brace expansion", () => {
|
||||
expect(splitPathInFrontmatter("{a,b}/{c,d}")).toEqual([
|
||||
"a/c", "a/d", "b/c", "b/d",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles array input", () => {
|
||||
expect(splitPathInFrontmatter(["a", "b"])).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("returns empty array for non-string", () => {
|
||||
expect(splitPathInFrontmatter(123 as any)).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves braces in comma-separated list", () => {
|
||||
expect(splitPathInFrontmatter("a, src/*.{ts,tsx}")).toEqual([
|
||||
"a",
|
||||
"src/*.ts",
|
||||
"src/*.tsx",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePositiveIntFromFrontmatter", () => {
|
||||
test("returns number for positive integer", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(5)).toBe(5);
|
||||
});
|
||||
|
||||
test("parses string number", () => {
|
||||
expect(parsePositiveIntFromFrontmatter("10")).toBe(10);
|
||||
});
|
||||
|
||||
test("returns undefined for zero", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for negative number", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(-1)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for float", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(1.5)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for null/undefined", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(null)).toBeUndefined();
|
||||
expect(parsePositiveIntFromFrontmatter(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for non-numeric string", () => {
|
||||
expect(parsePositiveIntFromFrontmatter("abc")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBooleanFrontmatter", () => {
|
||||
test("returns true for boolean true", () => {
|
||||
expect(parseBooleanFrontmatter(true)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for string 'true'", () => {
|
||||
expect(parseBooleanFrontmatter("true")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for boolean false", () => {
|
||||
expect(parseBooleanFrontmatter(false)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for string 'false'", () => {
|
||||
expect(parseBooleanFrontmatter("false")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for null/undefined", () => {
|
||||
expect(parseBooleanFrontmatter(null)).toBe(false);
|
||||
expect(parseBooleanFrontmatter(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseShellFrontmatter", () => {
|
||||
test("returns bash for 'bash'", () => {
|
||||
expect(parseShellFrontmatter("bash", "test")).toBe("bash");
|
||||
});
|
||||
|
||||
test("returns powershell for 'powershell'", () => {
|
||||
expect(parseShellFrontmatter("powershell", "test")).toBe("powershell");
|
||||
});
|
||||
|
||||
test("returns undefined for null", () => {
|
||||
expect(parseShellFrontmatter(null, "test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for unrecognized value", () => {
|
||||
expect(parseShellFrontmatter("zsh", "test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("is case insensitive", () => {
|
||||
expect(parseShellFrontmatter("BASH", "test")).toBe("bash");
|
||||
});
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(parseShellFrontmatter("", "test")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
124
src/utils/__tests__/git.test.ts
Normal file
124
src/utils/__tests__/git.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { normalizeGitRemoteUrl } from "../git";
|
||||
|
||||
describe("normalizeGitRemoteUrl", () => {
|
||||
describe("SSH format (git@host:owner/repo)", () => {
|
||||
test("normalizes basic SSH URL", () => {
|
||||
expect(normalizeGitRemoteUrl("git@github.com:owner/repo.git")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles SSH URL without .git suffix", () => {
|
||||
expect(normalizeGitRemoteUrl("git@github.com:owner/repo")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles nested paths", () => {
|
||||
expect(normalizeGitRemoteUrl("git@gitlab.com:group/sub/repo.git")).toBe(
|
||||
"gitlab.com/group/sub/repo"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTPS format", () => {
|
||||
test("normalizes basic HTTPS URL", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("https://github.com/owner/repo.git")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
|
||||
test("handles HTTPS without .git suffix", () => {
|
||||
expect(normalizeGitRemoteUrl("https://github.com/owner/repo")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles HTTP URL", () => {
|
||||
expect(normalizeGitRemoteUrl("http://github.com/owner/repo.git")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles HTTPS with auth", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("https://user@github.com/owner/repo.git")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ssh:// format", () => {
|
||||
test("normalizes ssh:// URL", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("ssh://git@github.com/owner/repo")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
|
||||
test("handles ssh:// with .git suffix", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("ssh://git@github.com/owner/repo.git")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CCR proxy URLs", () => {
|
||||
test("handles legacy proxy format (assumes github.com)", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl(
|
||||
"http://local_proxy@127.0.0.1:16583/git/owner/repo"
|
||||
)
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
|
||||
test("handles GHE proxy format (host in path)", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl(
|
||||
"http://local_proxy@127.0.0.1:16583/git/ghe.company.com/owner/repo"
|
||||
)
|
||||
).toBe("ghe.company.com/owner/repo");
|
||||
});
|
||||
|
||||
test("handles localhost proxy", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl(
|
||||
"http://proxy@localhost:8080/git/owner/repo"
|
||||
)
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("case normalization", () => {
|
||||
test("converts to lowercase", () => {
|
||||
expect(normalizeGitRemoteUrl("git@GitHub.COM:Owner/Repo.git")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts HTTPS to lowercase", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("https://GitHub.COM/Owner/Repo.git")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("returns null for empty string", () => {
|
||||
expect(normalizeGitRemoteUrl("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for whitespace only", () => {
|
||||
expect(normalizeGitRemoteUrl(" ")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for unrecognized format", () => {
|
||||
expect(normalizeGitRemoteUrl("not-a-url")).toBeNull();
|
||||
});
|
||||
|
||||
test("trims whitespace before parsing", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl(" git@github.com:owner/repo.git ")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
});
|
||||
40
src/utils/__tests__/glob.test.ts
Normal file
40
src/utils/__tests__/glob.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { extractGlobBaseDirectory } from "../glob";
|
||||
|
||||
describe("extractGlobBaseDirectory", () => {
|
||||
test("extracts base dir from glob with *", () => {
|
||||
const result = extractGlobBaseDirectory("src/utils/*.ts");
|
||||
expect(result.baseDir).toBe("src/utils");
|
||||
expect(result.relativePattern).toBe("*.ts");
|
||||
});
|
||||
|
||||
test("extracts base dir from glob with **", () => {
|
||||
const result = extractGlobBaseDirectory("src/**/*.ts");
|
||||
expect(result.baseDir).toBe("src");
|
||||
expect(result.relativePattern).toBe("**/*.ts");
|
||||
});
|
||||
|
||||
test("returns dirname for literal path", () => {
|
||||
const result = extractGlobBaseDirectory("src/utils/file.ts");
|
||||
expect(result.baseDir).toBe("src/utils");
|
||||
expect(result.relativePattern).toBe("file.ts");
|
||||
});
|
||||
|
||||
test("handles glob starting with pattern", () => {
|
||||
const result = extractGlobBaseDirectory("*.ts");
|
||||
expect(result.baseDir).toBe("");
|
||||
expect(result.relativePattern).toBe("*.ts");
|
||||
});
|
||||
|
||||
test("handles braces pattern", () => {
|
||||
const result = extractGlobBaseDirectory("src/{a,b}/*.ts");
|
||||
expect(result.baseDir).toBe("src");
|
||||
expect(result.relativePattern).toBe("{a,b}/*.ts");
|
||||
});
|
||||
|
||||
test("handles question mark pattern", () => {
|
||||
const result = extractGlobBaseDirectory("src/?.ts");
|
||||
expect(result.baseDir).toBe("src");
|
||||
expect(result.relativePattern).toBe("?.ts");
|
||||
});
|
||||
});
|
||||
57
src/utils/__tests__/hash.test.ts
Normal file
57
src/utils/__tests__/hash.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { djb2Hash, hashContent, hashPair } from "../hash";
|
||||
|
||||
describe("djb2Hash", () => {
|
||||
test("returns a number", () => {
|
||||
expect(typeof djb2Hash("hello")).toBe("number");
|
||||
});
|
||||
|
||||
test("returns 0 for empty string", () => {
|
||||
expect(djb2Hash("")).toBe(0);
|
||||
});
|
||||
|
||||
test("is deterministic", () => {
|
||||
expect(djb2Hash("test")).toBe(djb2Hash("test"));
|
||||
});
|
||||
|
||||
test("different strings produce different hashes", () => {
|
||||
expect(djb2Hash("abc")).not.toBe(djb2Hash("def"));
|
||||
});
|
||||
|
||||
test("returns 32-bit integer", () => {
|
||||
const hash = djb2Hash("some long string to hash");
|
||||
expect(hash).toBe(hash | 0); // bitwise OR with 0 preserves 32-bit int
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashContent", () => {
|
||||
test("returns a string", () => {
|
||||
expect(typeof hashContent("hello")).toBe("string");
|
||||
});
|
||||
|
||||
test("is deterministic", () => {
|
||||
expect(hashContent("test")).toBe(hashContent("test"));
|
||||
});
|
||||
|
||||
test("different strings produce different hashes", () => {
|
||||
expect(hashContent("abc")).not.toBe(hashContent("def"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashPair", () => {
|
||||
test("returns a string", () => {
|
||||
expect(typeof hashPair("a", "b")).toBe("string");
|
||||
});
|
||||
|
||||
test("is deterministic", () => {
|
||||
expect(hashPair("a", "b")).toBe(hashPair("a", "b"));
|
||||
});
|
||||
|
||||
test("order matters", () => {
|
||||
expect(hashPair("a", "b")).not.toBe(hashPair("b", "a"));
|
||||
});
|
||||
|
||||
test("disambiguates different splits", () => {
|
||||
expect(hashPair("ts", "code")).not.toBe(hashPair("tsc", "ode"));
|
||||
});
|
||||
});
|
||||
153
src/utils/__tests__/json.test.ts
Normal file
153
src/utils/__tests__/json.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock log.ts to cut the heavy dependency chain (log.ts → bootstrap/state.ts → analytics)
|
||||
mock.module("src/utils/log.ts", () => ({
|
||||
logError: () => {},
|
||||
logToFile: () => {},
|
||||
getLogDisplayTitle: () => "",
|
||||
logEvent: () => {},
|
||||
}));
|
||||
|
||||
const { safeParseJSON, safeParseJSONC, parseJSONL, addItemToJSONCArray } =
|
||||
await import("../json");
|
||||
|
||||
// ─── safeParseJSON ──────────────────────────────────────────────────────
|
||||
|
||||
describe("safeParseJSON", () => {
|
||||
test("parses valid object", () => {
|
||||
expect(safeParseJSON('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test("parses valid array", () => {
|
||||
expect(safeParseJSON("[1,2,3]")).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("parses string value", () => {
|
||||
expect(safeParseJSON('"hello"')).toBe("hello");
|
||||
});
|
||||
|
||||
test("parses number value", () => {
|
||||
expect(safeParseJSON("42")).toBe(42);
|
||||
});
|
||||
|
||||
test("parses boolean value", () => {
|
||||
expect(safeParseJSON("true")).toBe(true);
|
||||
});
|
||||
|
||||
test("parses null value", () => {
|
||||
expect(safeParseJSON("null")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for invalid JSON", () => {
|
||||
expect(safeParseJSON("{bad}")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(safeParseJSON("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for undefined input", () => {
|
||||
expect(safeParseJSON(undefined as any)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for null input", () => {
|
||||
expect(safeParseJSON(null as any)).toBeNull();
|
||||
});
|
||||
|
||||
test("handles JSON with BOM", () => {
|
||||
expect(safeParseJSON('\uFEFF{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test("parses nested objects", () => {
|
||||
const input = '{"a":{"b":{"c":1}}}';
|
||||
expect(safeParseJSON(input)).toEqual({ a: { b: { c: 1 } } });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── safeParseJSONC ─────────────────────────────────────────────────────
|
||||
|
||||
describe("safeParseJSONC", () => {
|
||||
test("parses standard JSON", () => {
|
||||
expect(safeParseJSONC('{"a":1}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test("parses JSON with single-line comments", () => {
|
||||
expect(safeParseJSONC('{\n// comment\n"a":1\n}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test("parses JSON with block comments", () => {
|
||||
expect(safeParseJSONC('{\n/* comment */\n"a":1\n}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test("parses JSON with trailing commas", () => {
|
||||
expect(safeParseJSONC('{"a":1,}')).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test("returns null for null input", () => {
|
||||
expect(safeParseJSONC(null as any)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(safeParseJSONC("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── parseJSONL ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("parseJSONL", () => {
|
||||
test("parses multiple lines", () => {
|
||||
const result = parseJSONL('{"a":1}\n{"b":2}');
|
||||
expect(result).toEqual([{ a: 1 }, { b: 2 }]);
|
||||
});
|
||||
|
||||
test("returns empty array for empty string", () => {
|
||||
expect(parseJSONL("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("parses single line", () => {
|
||||
expect(parseJSONL('{"a":1}')).toEqual([{ a: 1 }]);
|
||||
});
|
||||
|
||||
test("accepts Buffer input", () => {
|
||||
const buf = Buffer.from('{"x":1}\n{"y":2}');
|
||||
const result = parseJSONL(buf as any);
|
||||
expect(result).toEqual([{ x: 1 }, { y: 2 }]);
|
||||
});
|
||||
|
||||
// NOTE: Skipping malformed-line test — Bun.JSONL.parseChunk hangs
|
||||
// indefinitely in its error-recovery loop when encountering bad lines.
|
||||
});
|
||||
|
||||
// ─── addItemToJSONCArray ────────────────────────────────────────────────
|
||||
|
||||
describe("addItemToJSONCArray", () => {
|
||||
test("appends to existing array", () => {
|
||||
const result = addItemToJSONCArray('["a","b"]', "c");
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("appends to empty array", () => {
|
||||
const result = addItemToJSONCArray("[]", "item");
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual(["item"]);
|
||||
});
|
||||
|
||||
test("creates array from empty content", () => {
|
||||
const result = addItemToJSONCArray("", "first");
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual(["first"]);
|
||||
});
|
||||
|
||||
test("handles object item", () => {
|
||||
const result = addItemToJSONCArray("[]", { key: "val" });
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual([{ key: "val" }]);
|
||||
});
|
||||
|
||||
test("wraps item in new array for non-array content", () => {
|
||||
const result = addItemToJSONCArray('{"a":1}', "item");
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual(["item"]);
|
||||
});
|
||||
});
|
||||
473
src/utils/__tests__/messages.test.ts
Normal file
473
src/utils/__tests__/messages.test.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
deriveShortMessageId,
|
||||
INTERRUPT_MESSAGE,
|
||||
INTERRUPT_MESSAGE_FOR_TOOL_USE,
|
||||
CANCEL_MESSAGE,
|
||||
REJECT_MESSAGE,
|
||||
NO_RESPONSE_REQUESTED,
|
||||
SYNTHETIC_MESSAGES,
|
||||
isSyntheticMessage,
|
||||
getLastAssistantMessage,
|
||||
hasToolCallsInLastAssistantTurn,
|
||||
createAssistantMessage,
|
||||
createAssistantAPIErrorMessage,
|
||||
createUserMessage,
|
||||
createUserInterruptionMessage,
|
||||
prepareUserContent,
|
||||
createToolResultStopMessage,
|
||||
extractTag,
|
||||
isNotEmptyMessage,
|
||||
deriveUUID,
|
||||
normalizeMessages,
|
||||
isClassifierDenial,
|
||||
buildYoloRejectionMessage,
|
||||
buildClassifierUnavailableMessage,
|
||||
AUTO_REJECT_MESSAGE,
|
||||
DONT_ASK_REJECT_MESSAGE,
|
||||
SYNTHETIC_MODEL,
|
||||
} from "../messages";
|
||||
import type { Message, AssistantMessage, UserMessage } from "../../types/message";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAssistantMsg(
|
||||
contentBlocks: Array<{ type: string; text?: string; [key: string]: any }>
|
||||
): AssistantMessage {
|
||||
return createAssistantMessage({
|
||||
content: contentBlocks as any,
|
||||
});
|
||||
}
|
||||
|
||||
function makeUserMsg(text: string): UserMessage {
|
||||
return createUserMessage({ content: text });
|
||||
}
|
||||
|
||||
// ─── deriveShortMessageId ───────────────────────────────────────────────
|
||||
|
||||
describe("deriveShortMessageId", () => {
|
||||
test("returns 6-char string", () => {
|
||||
const id = deriveShortMessageId("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(id).toHaveLength(6);
|
||||
});
|
||||
|
||||
test("is deterministic for same input", () => {
|
||||
const uuid = "a0b1c2d3-e4f5-6789-abcd-ef0123456789";
|
||||
expect(deriveShortMessageId(uuid)).toBe(deriveShortMessageId(uuid));
|
||||
});
|
||||
|
||||
test("produces different IDs for different UUIDs", () => {
|
||||
const id1 = deriveShortMessageId("00000000-0000-0000-0000-000000000001");
|
||||
const id2 = deriveShortMessageId("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("message constants", () => {
|
||||
test("SYNTHETIC_MESSAGES contains expected messages", () => {
|
||||
expect(SYNTHETIC_MESSAGES.has(INTERRUPT_MESSAGE)).toBe(true);
|
||||
expect(SYNTHETIC_MESSAGES.has(INTERRUPT_MESSAGE_FOR_TOOL_USE)).toBe(true);
|
||||
expect(SYNTHETIC_MESSAGES.has(CANCEL_MESSAGE)).toBe(true);
|
||||
expect(SYNTHETIC_MESSAGES.has(REJECT_MESSAGE)).toBe(true);
|
||||
expect(SYNTHETIC_MESSAGES.has(NO_RESPONSE_REQUESTED)).toBe(true);
|
||||
});
|
||||
|
||||
test("SYNTHETIC_MODEL is <synthetic>", () => {
|
||||
expect(SYNTHETIC_MODEL).toBe("<synthetic>");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Message factories ──────────────────────────────────────────────────
|
||||
|
||||
describe("createAssistantMessage", () => {
|
||||
test("creates assistant message with string content", () => {
|
||||
const msg = createAssistantMessage({ content: "hello" });
|
||||
expect(msg.type).toBe("assistant");
|
||||
expect(msg.message.role).toBe("assistant");
|
||||
expect(msg.message.content).toHaveLength(1);
|
||||
expect((msg.message.content[0] as any).text).toBe("hello");
|
||||
});
|
||||
|
||||
test("creates assistant message with content blocks", () => {
|
||||
const blocks = [{ type: "text" as const, text: "hello" }];
|
||||
const msg = createAssistantMessage({ content: blocks as any });
|
||||
expect(msg.type).toBe("assistant");
|
||||
expect(msg.message.content).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("generates unique uuid per call", () => {
|
||||
const msg1 = createAssistantMessage({ content: "a" });
|
||||
const msg2 = createAssistantMessage({ content: "b" });
|
||||
expect(msg1.uuid).not.toBe(msg2.uuid);
|
||||
});
|
||||
|
||||
test("has isApiErrorMessage false", () => {
|
||||
const msg = createAssistantMessage({ content: "test" });
|
||||
expect(msg.isApiErrorMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAssistantAPIErrorMessage", () => {
|
||||
test("sets isApiErrorMessage to true", () => {
|
||||
const msg = createAssistantAPIErrorMessage({ content: "error" });
|
||||
expect(msg.isApiErrorMessage).toBe(true);
|
||||
});
|
||||
|
||||
test("includes error details", () => {
|
||||
const msg = createAssistantAPIErrorMessage({
|
||||
content: "fail",
|
||||
errorDetails: "rate limited",
|
||||
});
|
||||
expect(msg.errorDetails).toBe("rate limited");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUserMessage", () => {
|
||||
test("creates user message with string content", () => {
|
||||
const msg = createUserMessage({ content: "hello" });
|
||||
expect(msg.type).toBe("user");
|
||||
expect(msg.message.role).toBe("user");
|
||||
expect(msg.message.content).toBe("hello");
|
||||
});
|
||||
|
||||
test("generates unique uuid", () => {
|
||||
const msg1 = createUserMessage({ content: "a" });
|
||||
const msg2 = createUserMessage({ content: "b" });
|
||||
expect(msg1.uuid).not.toBe(msg2.uuid);
|
||||
});
|
||||
|
||||
test("uses provided uuid when given", () => {
|
||||
const msg = createUserMessage({
|
||||
content: "test",
|
||||
uuid: "custom-uuid-1234-5678-abcd-ef0123456789",
|
||||
});
|
||||
expect(msg.uuid).toBe("custom-uuid-1234-5678-abcd-ef0123456789");
|
||||
});
|
||||
|
||||
test("sets isMeta flag", () => {
|
||||
const msg = createUserMessage({ content: "test", isMeta: true });
|
||||
expect(msg.isMeta).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUserInterruptionMessage", () => {
|
||||
test("creates interrupt message without tool use", () => {
|
||||
const msg = createUserInterruptionMessage({});
|
||||
expect(msg.type).toBe("user");
|
||||
expect((msg.message.content as any)[0].text).toBe(INTERRUPT_MESSAGE);
|
||||
});
|
||||
|
||||
test("creates interrupt message with tool use", () => {
|
||||
const msg = createUserInterruptionMessage({ toolUse: true });
|
||||
expect((msg.message.content as any)[0].text).toBe(
|
||||
INTERRUPT_MESSAGE_FOR_TOOL_USE
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareUserContent", () => {
|
||||
test("returns string when no preceding blocks", () => {
|
||||
const result = prepareUserContent({
|
||||
inputString: "hello",
|
||||
precedingInputBlocks: [],
|
||||
});
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
test("returns array when preceding blocks exist", () => {
|
||||
const blocks = [{ type: "image" as const, source: {} } as any];
|
||||
const result = prepareUserContent({
|
||||
inputString: "describe this",
|
||||
precedingInputBlocks: blocks,
|
||||
});
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect((result as any[]).length).toBe(2);
|
||||
expect((result as any[])[1].text).toBe("describe this");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createToolResultStopMessage", () => {
|
||||
test("creates tool result with error flag", () => {
|
||||
const result = createToolResultStopMessage("tool-123");
|
||||
expect(result.type).toBe("tool_result");
|
||||
expect(result.is_error).toBe(true);
|
||||
expect(result.tool_use_id).toBe("tool-123");
|
||||
expect(result.content).toBe(CANCEL_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isSyntheticMessage ─────────────────────────────────────────────────
|
||||
|
||||
describe("isSyntheticMessage", () => {
|
||||
test("identifies interrupt message as synthetic", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: [{ type: "text", text: INTERRUPT_MESSAGE }] },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("identifies cancel message as synthetic", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: [{ type: "text", text: CANCEL_MESSAGE }] },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for normal user message", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: [{ type: "text", text: "hello" }] },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for progress message", () => {
|
||||
const msg: any = {
|
||||
type: "progress",
|
||||
message: { content: [{ type: "text", text: INTERRUPT_MESSAGE }] },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for string content", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: INTERRUPT_MESSAGE },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getLastAssistantMessage ────────────────────────────────────────────
|
||||
|
||||
describe("getLastAssistantMessage", () => {
|
||||
test("returns last assistant message", () => {
|
||||
const a1 = makeAssistantMsg([{ type: "text", text: "first" }]);
|
||||
const u = makeUserMsg("mid");
|
||||
const a2 = makeAssistantMsg([{ type: "text", text: "last" }]);
|
||||
const result = getLastAssistantMessage([a1, u, a2]);
|
||||
expect(result).toBe(a2);
|
||||
});
|
||||
|
||||
test("returns undefined for empty array", () => {
|
||||
expect(getLastAssistantMessage([])).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined when no assistant messages", () => {
|
||||
const u = makeUserMsg("hello");
|
||||
expect(getLastAssistantMessage([u])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── hasToolCallsInLastAssistantTurn ────────────────────────────────────
|
||||
|
||||
describe("hasToolCallsInLastAssistantTurn", () => {
|
||||
test("returns true when last assistant has tool_use", () => {
|
||||
const msg = makeAssistantMsg([
|
||||
{ type: "text", text: "let me check" },
|
||||
{ type: "tool_use", id: "t1", name: "Bash", input: {} },
|
||||
]);
|
||||
expect(hasToolCallsInLastAssistantTurn([msg])).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when last assistant has only text", () => {
|
||||
const msg = makeAssistantMsg([{ type: "text", text: "done" }]);
|
||||
expect(hasToolCallsInLastAssistantTurn([msg])).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty messages", () => {
|
||||
expect(hasToolCallsInLastAssistantTurn([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractTag ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("extractTag", () => {
|
||||
test("extracts simple tag content", () => {
|
||||
expect(extractTag("<foo>bar</foo>", "foo")).toBe("bar");
|
||||
});
|
||||
|
||||
test("extracts tag with attributes", () => {
|
||||
expect(extractTag('<foo class="a">bar</foo>', "foo")).toBe("bar");
|
||||
});
|
||||
|
||||
test("handles multiline content", () => {
|
||||
expect(extractTag("<foo>\nline1\nline2\n</foo>", "foo")).toBe(
|
||||
"\nline1\nline2\n"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null for missing tag", () => {
|
||||
expect(extractTag("<foo>bar</foo>", "baz")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty html", () => {
|
||||
expect(extractTag("", "foo")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty tagName", () => {
|
||||
expect(extractTag("<foo>bar</foo>", "")).toBeNull();
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(extractTag("<FOO>bar</FOO>", "foo")).toBe("bar");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isNotEmptyMessage ──────────────────────────────────────────────────
|
||||
|
||||
describe("isNotEmptyMessage", () => {
|
||||
test("returns true for message with text content", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: "hello" },
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for empty string content", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: " " },
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty content array", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: [] },
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for progress message", () => {
|
||||
const msg: any = {
|
||||
type: "progress",
|
||||
message: { content: [] },
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for multi-block content", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: "text", text: "b" },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for non-text block", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{ type: "tool_use", id: "t1", name: "Bash", input: {} }],
|
||||
},
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deriveUUID ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("deriveUUID", () => {
|
||||
test("produces deterministic output", () => {
|
||||
const parent = "550e8400-e29b-41d4-a716-446655440000" as any;
|
||||
expect(deriveUUID(parent, 0)).toBe(deriveUUID(parent, 0));
|
||||
});
|
||||
|
||||
test("produces different output for different indices", () => {
|
||||
const parent = "550e8400-e29b-41d4-a716-446655440000" as any;
|
||||
expect(deriveUUID(parent, 0)).not.toBe(deriveUUID(parent, 1));
|
||||
});
|
||||
|
||||
test("preserves UUID-like length", () => {
|
||||
const parent = "550e8400-e29b-41d4-a716-446655440000" as any;
|
||||
const derived = deriveUUID(parent, 5);
|
||||
expect(derived.length).toBe(parent.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isClassifierDenial ─────────────────────────────────────────────────
|
||||
|
||||
describe("isClassifierDenial", () => {
|
||||
test("returns true for classifier denial prefix", () => {
|
||||
expect(
|
||||
isClassifierDenial(
|
||||
"Permission for this action has been denied. Reason: unsafe"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for normal content", () => {
|
||||
expect(isClassifierDenial("hello world")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Message builder functions ──────────────────────────────────────────
|
||||
|
||||
describe("AUTO_REJECT_MESSAGE", () => {
|
||||
test("includes tool name", () => {
|
||||
const msg = AUTO_REJECT_MESSAGE("Bash");
|
||||
expect(msg).toContain("Bash");
|
||||
expect(msg).toContain("denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DONT_ASK_REJECT_MESSAGE", () => {
|
||||
test("includes tool name and dont ask mode", () => {
|
||||
const msg = DONT_ASK_REJECT_MESSAGE("Write");
|
||||
expect(msg).toContain("Write");
|
||||
expect(msg).toContain("don't ask mode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildYoloRejectionMessage", () => {
|
||||
test("includes reason", () => {
|
||||
const msg = buildYoloRejectionMessage("potentially destructive");
|
||||
expect(msg).toContain("potentially destructive");
|
||||
expect(msg).toContain("denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClassifierUnavailableMessage", () => {
|
||||
test("includes tool name and model", () => {
|
||||
const msg = buildClassifierUnavailableMessage("Bash", "classifier-v1");
|
||||
expect(msg).toContain("Bash");
|
||||
expect(msg).toContain("classifier-v1");
|
||||
expect(msg).toContain("unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeMessages ──────────────────────────────────────────────────
|
||||
|
||||
describe("normalizeMessages", () => {
|
||||
test("splits multi-block assistant message into individual messages", () => {
|
||||
const msg = makeAssistantMsg([
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "text", text: "second" },
|
||||
]);
|
||||
const normalized = normalizeMessages([msg]);
|
||||
expect(normalized.length).toBe(2);
|
||||
});
|
||||
|
||||
test("handles empty array", () => {
|
||||
const result = normalizeMessages([] as AssistantMessage[]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves single-block message", () => {
|
||||
const msg = makeAssistantMsg([{ type: "text", text: "hello" }]);
|
||||
const normalized = normalizeMessages([msg]);
|
||||
expect(normalized.length).toBe(1);
|
||||
});
|
||||
});
|
||||
72
src/utils/__tests__/path.test.ts
Normal file
72
src/utils/__tests__/path.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { containsPathTraversal, normalizePathForConfigKey } from "../path";
|
||||
|
||||
// ─── containsPathTraversal ──────────────────────────────────────────────
|
||||
|
||||
describe("containsPathTraversal", () => {
|
||||
test("detects ../ at start", () => {
|
||||
expect(containsPathTraversal("../foo")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects ../ in middle", () => {
|
||||
expect(containsPathTraversal("foo/../bar")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects .. at end", () => {
|
||||
expect(containsPathTraversal("foo/..")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects standalone ..", () => {
|
||||
expect(containsPathTraversal("..")).toBe(true);
|
||||
});
|
||||
|
||||
test("detects backslash traversal", () => {
|
||||
expect(containsPathTraversal("foo\\..\\bar")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for normal path", () => {
|
||||
expect(containsPathTraversal("foo/bar/baz")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for single dot", () => {
|
||||
expect(containsPathTraversal("./foo")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for ... in filename", () => {
|
||||
expect(containsPathTraversal("foo/...bar")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(containsPathTraversal("")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for dotdot in filename without separator", () => {
|
||||
expect(containsPathTraversal("foo..bar")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizePathForConfigKey ──────────────────────────────────────────
|
||||
|
||||
describe("normalizePathForConfigKey", () => {
|
||||
test("normalizes forward slashes (no change on POSIX)", () => {
|
||||
expect(normalizePathForConfigKey("foo/bar/baz")).toBe("foo/bar/baz");
|
||||
});
|
||||
|
||||
test("resolves dot segments", () => {
|
||||
expect(normalizePathForConfigKey("foo/./bar")).toBe("foo/bar");
|
||||
});
|
||||
|
||||
test("resolves double-dot segments", () => {
|
||||
expect(normalizePathForConfigKey("foo/bar/../baz")).toBe("foo/baz");
|
||||
});
|
||||
|
||||
test("handles absolute path", () => {
|
||||
const result = normalizePathForConfigKey("/Users/test/project");
|
||||
expect(result).toBe("/Users/test/project");
|
||||
});
|
||||
|
||||
test("converts backslashes to forward slashes", () => {
|
||||
const result = normalizePathForConfigKey("foo\\bar\\baz");
|
||||
expect(result).toBe("foo/bar/baz");
|
||||
});
|
||||
});
|
||||
98
src/utils/__tests__/semver.test.ts
Normal file
98
src/utils/__tests__/semver.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { gt, gte, lt, lte, satisfies, order } from "../semver";
|
||||
|
||||
describe("gt", () => {
|
||||
test("returns true when a > b", () => {
|
||||
expect(gt("2.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when a < b", () => {
|
||||
expect(gt("1.0.0", "2.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when equal", () => {
|
||||
expect(gt("1.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gte", () => {
|
||||
test("returns true when a > b", () => {
|
||||
expect(gte("2.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when equal", () => {
|
||||
expect(gte("1.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when a < b", () => {
|
||||
expect(gte("1.0.0", "2.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lt", () => {
|
||||
test("returns true when a < b", () => {
|
||||
expect(lt("1.0.0", "2.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when a > b", () => {
|
||||
expect(lt("2.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when equal", () => {
|
||||
expect(lt("1.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lte", () => {
|
||||
test("returns true when a < b", () => {
|
||||
expect(lte("1.0.0", "2.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when equal", () => {
|
||||
expect(lte("1.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when a > b", () => {
|
||||
expect(lte("2.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("satisfies", () => {
|
||||
test("matches exact version", () => {
|
||||
expect(satisfies("1.2.3", "1.2.3")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches range", () => {
|
||||
expect(satisfies("1.2.3", ">=1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match out-of-range version", () => {
|
||||
expect(satisfies("0.9.0", ">=1.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("matches caret range", () => {
|
||||
expect(satisfies("1.2.3", "^1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match major bump in caret", () => {
|
||||
expect(satisfies("2.0.0", "^1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("order", () => {
|
||||
test("returns 1 when a > b", () => {
|
||||
expect(order("2.0.0", "1.0.0")).toBe(1);
|
||||
});
|
||||
|
||||
test("returns -1 when a < b", () => {
|
||||
expect(order("1.0.0", "2.0.0")).toBe(-1);
|
||||
});
|
||||
|
||||
test("returns 0 when equal", () => {
|
||||
expect(order("1.0.0", "1.0.0")).toBe(0);
|
||||
});
|
||||
|
||||
test("compares patch versions", () => {
|
||||
expect(order("1.0.1", "1.0.0")).toBe(1);
|
||||
});
|
||||
});
|
||||
195
src/utils/__tests__/stringUtils.test.ts
Normal file
195
src/utils/__tests__/stringUtils.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
escapeRegExp,
|
||||
capitalize,
|
||||
plural,
|
||||
firstLineOf,
|
||||
countCharInString,
|
||||
normalizeFullWidthDigits,
|
||||
normalizeFullWidthSpace,
|
||||
safeJoinLines,
|
||||
EndTruncatingAccumulator,
|
||||
truncateToLines,
|
||||
} from "../stringUtils";
|
||||
|
||||
describe("escapeRegExp", () => {
|
||||
test("escapes special regex chars", () => {
|
||||
expect(escapeRegExp("a.b*c?d")).toBe("a\\.b\\*c\\?d");
|
||||
});
|
||||
|
||||
test("escapes brackets and parens", () => {
|
||||
expect(escapeRegExp("[foo](bar)")).toBe("\\[foo\\]\\(bar\\)");
|
||||
});
|
||||
|
||||
test("escapes all special chars", () => {
|
||||
expect(escapeRegExp("^${}()|[]\\.*+?")).toBe(
|
||||
"\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\.\\*\\+\\?"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns normal string unchanged", () => {
|
||||
expect(escapeRegExp("hello")).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("capitalize", () => {
|
||||
test("uppercases first char", () => {
|
||||
expect(capitalize("hello")).toBe("Hello");
|
||||
});
|
||||
|
||||
test("does NOT lowercase rest", () => {
|
||||
expect(capitalize("fooBar")).toBe("FooBar");
|
||||
});
|
||||
|
||||
test("handles single char", () => {
|
||||
expect(capitalize("a")).toBe("A");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(capitalize("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plural", () => {
|
||||
test("returns singular for 1", () => {
|
||||
expect(plural(1, "file")).toBe("file");
|
||||
});
|
||||
|
||||
test("returns plural for 0", () => {
|
||||
expect(plural(0, "file")).toBe("files");
|
||||
});
|
||||
|
||||
test("returns plural for many", () => {
|
||||
expect(plural(3, "file")).toBe("files");
|
||||
});
|
||||
|
||||
test("uses custom plural form", () => {
|
||||
expect(plural(2, "entry", "entries")).toBe("entries");
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstLineOf", () => {
|
||||
test("returns first line of multiline string", () => {
|
||||
expect(firstLineOf("line1\nline2\nline3")).toBe("line1");
|
||||
});
|
||||
|
||||
test("returns whole string if no newline", () => {
|
||||
expect(firstLineOf("single line")).toBe("single line");
|
||||
});
|
||||
|
||||
test("returns empty string for leading newline", () => {
|
||||
expect(firstLineOf("\nline2")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("countCharInString", () => {
|
||||
test("counts occurrences of a character", () => {
|
||||
expect(countCharInString("hello world", "l")).toBe(3);
|
||||
});
|
||||
|
||||
test("returns 0 for no match", () => {
|
||||
expect(countCharInString("hello", "z")).toBe(0);
|
||||
});
|
||||
|
||||
test("counts from start offset", () => {
|
||||
expect(countCharInString("aabaa", "a", 2)).toBe(2);
|
||||
});
|
||||
|
||||
test("returns 0 for empty string", () => {
|
||||
expect(countCharInString("", "a")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeFullWidthDigits", () => {
|
||||
test("converts full-width digits to half-width", () => {
|
||||
expect(normalizeFullWidthDigits("0123456789")).toBe("0123456789");
|
||||
});
|
||||
|
||||
test("leaves half-width digits unchanged", () => {
|
||||
expect(normalizeFullWidthDigits("0123")).toBe("0123");
|
||||
});
|
||||
|
||||
test("handles mixed content", () => {
|
||||
expect(normalizeFullWidthDigits("test123")).toBe("test123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeFullWidthSpace", () => {
|
||||
test("converts full-width space to half-width", () => {
|
||||
expect(normalizeFullWidthSpace("a\u3000b")).toBe("a b");
|
||||
});
|
||||
|
||||
test("leaves normal spaces unchanged", () => {
|
||||
expect(normalizeFullWidthSpace("a b")).toBe("a b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeJoinLines", () => {
|
||||
test("joins lines with delimiter", () => {
|
||||
expect(safeJoinLines(["a", "b", "c"], ",")).toBe("a,b,c");
|
||||
});
|
||||
|
||||
test("truncates when exceeding maxSize", () => {
|
||||
const result = safeJoinLines(["hello", "world", "foo"], ",", 12);
|
||||
expect(result.length).toBeLessThanOrEqual(12 + "...[truncated]".length);
|
||||
expect(result).toContain("...[truncated]");
|
||||
});
|
||||
|
||||
test("returns empty string for empty input", () => {
|
||||
expect(safeJoinLines([])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EndTruncatingAccumulator", () => {
|
||||
test("accumulates text", () => {
|
||||
const acc = new EndTruncatingAccumulator(100);
|
||||
acc.append("hello ");
|
||||
acc.append("world");
|
||||
expect(acc.toString()).toBe("hello world");
|
||||
});
|
||||
|
||||
test("truncates when exceeding maxSize", () => {
|
||||
const acc = new EndTruncatingAccumulator(10);
|
||||
acc.append("12345678901234567890");
|
||||
expect(acc.truncated).toBe(true);
|
||||
expect(acc.length).toBe(10);
|
||||
});
|
||||
|
||||
test("reports total bytes received", () => {
|
||||
const acc = new EndTruncatingAccumulator(5);
|
||||
acc.append("1234567890");
|
||||
expect(acc.totalBytes).toBe(10);
|
||||
});
|
||||
|
||||
test("clear resets state", () => {
|
||||
const acc = new EndTruncatingAccumulator(100);
|
||||
acc.append("hello");
|
||||
acc.clear();
|
||||
expect(acc.toString()).toBe("");
|
||||
expect(acc.length).toBe(0);
|
||||
expect(acc.truncated).toBe(false);
|
||||
});
|
||||
|
||||
test("stops accepting data once truncated and full", () => {
|
||||
const acc = new EndTruncatingAccumulator(5);
|
||||
acc.append("12345");
|
||||
acc.append("67890");
|
||||
expect(acc.length).toBe(5);
|
||||
acc.append("more");
|
||||
expect(acc.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateToLines", () => {
|
||||
test("returns text unchanged if within limit", () => {
|
||||
expect(truncateToLines("a\nb\nc", 5)).toBe("a\nb\nc");
|
||||
});
|
||||
|
||||
test("truncates text exceeding limit", () => {
|
||||
expect(truncateToLines("a\nb\nc\nd\ne", 3)).toBe("a\nb\nc…");
|
||||
});
|
||||
|
||||
test("handles single line", () => {
|
||||
expect(truncateToLines("hello", 1)).toBe("hello");
|
||||
});
|
||||
});
|
||||
88
src/utils/__tests__/systemPrompt.test.ts
Normal file
88
src/utils/__tests__/systemPrompt.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildEffectiveSystemPrompt } from "../systemPrompt";
|
||||
|
||||
const defaultPrompt = ["You are a helpful assistant.", "Follow instructions."];
|
||||
|
||||
function buildPrompt(overrides: Record<string, unknown> = {}) {
|
||||
return buildEffectiveSystemPrompt({
|
||||
mainThreadAgentDefinition: undefined,
|
||||
toolUseContext: { options: {} as any },
|
||||
customSystemPrompt: undefined,
|
||||
defaultSystemPrompt: defaultPrompt,
|
||||
appendSystemPrompt: undefined,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildEffectiveSystemPrompt", () => {
|
||||
test("returns default system prompt when no overrides", () => {
|
||||
const result = buildPrompt();
|
||||
expect(Array.from(result)).toEqual(defaultPrompt);
|
||||
});
|
||||
|
||||
test("overrideSystemPrompt replaces everything", () => {
|
||||
const result = buildPrompt({ overrideSystemPrompt: "override" });
|
||||
expect(Array.from(result)).toEqual(["override"]);
|
||||
});
|
||||
|
||||
test("customSystemPrompt replaces default", () => {
|
||||
const result = buildPrompt({ customSystemPrompt: "custom" });
|
||||
expect(Array.from(result)).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
test("appendSystemPrompt is appended after main prompt", () => {
|
||||
const result = buildPrompt({ appendSystemPrompt: "appended" });
|
||||
expect(Array.from(result)).toEqual([...defaultPrompt, "appended"]);
|
||||
});
|
||||
|
||||
test("agent definition replaces default prompt", () => {
|
||||
const agentDef = {
|
||||
getSystemPrompt: () => "agent prompt",
|
||||
agentType: "custom",
|
||||
} as any;
|
||||
const result = buildPrompt({ mainThreadAgentDefinition: agentDef });
|
||||
expect(Array.from(result)).toEqual(["agent prompt"]);
|
||||
});
|
||||
|
||||
test("agent definition with append combines both", () => {
|
||||
const agentDef = {
|
||||
getSystemPrompt: () => "agent prompt",
|
||||
agentType: "custom",
|
||||
} as any;
|
||||
const result = buildPrompt({
|
||||
mainThreadAgentDefinition: agentDef,
|
||||
appendSystemPrompt: "extra",
|
||||
});
|
||||
expect(Array.from(result)).toEqual(["agent prompt", "extra"]);
|
||||
});
|
||||
|
||||
test("override takes precedence over agent and custom", () => {
|
||||
const agentDef = {
|
||||
getSystemPrompt: () => "agent prompt",
|
||||
agentType: "custom",
|
||||
} as any;
|
||||
const result = buildPrompt({
|
||||
mainThreadAgentDefinition: agentDef,
|
||||
customSystemPrompt: "custom",
|
||||
appendSystemPrompt: "extra",
|
||||
overrideSystemPrompt: "override",
|
||||
});
|
||||
expect(Array.from(result)).toEqual(["override"]);
|
||||
});
|
||||
|
||||
test("returns array of strings", () => {
|
||||
const result = buildPrompt();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
for (const item of result) {
|
||||
expect(typeof item).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("custom + append combines both", () => {
|
||||
const result = buildPrompt({
|
||||
customSystemPrompt: "custom",
|
||||
appendSystemPrompt: "extra",
|
||||
});
|
||||
expect(Array.from(result)).toEqual(["custom", "extra"]);
|
||||
});
|
||||
});
|
||||
296
src/utils/__tests__/tokens.test.ts
Normal file
296
src/utils/__tests__/tokens.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock heavy dependency chain: tokenEstimation.ts → log.ts → bootstrap/state.ts
|
||||
mock.module("src/utils/log.ts", () => ({
|
||||
logError: () => {},
|
||||
logToFile: () => {},
|
||||
getLogDisplayTitle: () => "",
|
||||
logEvent: () => {},
|
||||
logMCPError: () => {},
|
||||
logMCPDebug: () => {},
|
||||
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"),
|
||||
getLogFilePath: () => "/tmp/mock-log",
|
||||
attachErrorLogSink: () => {},
|
||||
getInMemoryErrors: () => [],
|
||||
loadErrorLogs: async () => [],
|
||||
getErrorLogByIndex: async () => null,
|
||||
captureAPIRequest: () => {},
|
||||
_resetErrorLogForTesting: () => {},
|
||||
}));
|
||||
|
||||
// Mock tokenEstimation to avoid pulling in API provider deps
|
||||
mock.module("src/services/tokenEstimation.ts", () => ({
|
||||
roughTokenCountEstimation: (text: string) => Math.ceil(text.length / 4),
|
||||
roughTokenCountEstimationForMessages: (msgs: any[]) => msgs.length * 100,
|
||||
roughTokenCountEstimationForMessage: () => 100,
|
||||
roughTokenCountEstimationForFileType: () => 100,
|
||||
bytesPerTokenForFileType: () => 4,
|
||||
countTokensWithAPI: async () => 0,
|
||||
countMessagesTokensWithAPI: async () => 0,
|
||||
countTokensViaHaikuFallback: async () => 0,
|
||||
}));
|
||||
|
||||
// Mock slowOperations to avoid bun:bundle import
|
||||
mock.module("src/utils/slowOperations.ts", () => ({
|
||||
jsonStringify: JSON.stringify,
|
||||
jsonParse: JSON.parse,
|
||||
slowLogging: { enabled: false },
|
||||
clone: (v: any) => structuredClone(v),
|
||||
cloneDeep: (v: any) => structuredClone(v),
|
||||
callerFrame: () => "",
|
||||
SLOW_OPERATION_THRESHOLD_MS: 100,
|
||||
writeFileSync_DEPRECATED: () => {},
|
||||
}));
|
||||
|
||||
const {
|
||||
getTokenCountFromUsage,
|
||||
getTokenUsage,
|
||||
tokenCountFromLastAPIResponse,
|
||||
messageTokenCountFromLastAPIResponse,
|
||||
getCurrentUsage,
|
||||
doesMostRecentAssistantMessageExceed200k,
|
||||
getAssistantMessageContentLength,
|
||||
} = await import("../tokens");
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAssistantMessage(
|
||||
content: any[],
|
||||
usage?: any,
|
||||
model?: string,
|
||||
id?: string
|
||||
) {
|
||||
return {
|
||||
type: "assistant" as const,
|
||||
uuid: `test-${Math.random()}`,
|
||||
message: {
|
||||
id: id ?? `msg_${Math.random()}`,
|
||||
role: "assistant" as const,
|
||||
content,
|
||||
model: model ?? "claude-sonnet-4-20250514",
|
||||
usage: usage ?? {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 10,
|
||||
cache_read_input_tokens: 5,
|
||||
},
|
||||
},
|
||||
isApiErrorMessage: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeUserMessage(text: string) {
|
||||
return {
|
||||
type: "user" as const,
|
||||
uuid: `test-${Math.random()}`,
|
||||
message: { role: "user" as const, content: text },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── getTokenCountFromUsage ─────────────────────────────────────────────
|
||||
|
||||
describe("getTokenCountFromUsage", () => {
|
||||
test("sums all token fields", () => {
|
||||
const usage = {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 20,
|
||||
cache_read_input_tokens: 10,
|
||||
};
|
||||
expect(getTokenCountFromUsage(usage)).toBe(180);
|
||||
});
|
||||
|
||||
test("handles missing cache fields", () => {
|
||||
const usage = {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
};
|
||||
expect(getTokenCountFromUsage(usage)).toBe(150);
|
||||
});
|
||||
|
||||
test("handles zero values", () => {
|
||||
const usage = {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
};
|
||||
expect(getTokenCountFromUsage(usage)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getTokenUsage ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getTokenUsage", () => {
|
||||
test("returns usage for valid assistant message", () => {
|
||||
const msg = makeAssistantMessage([{ type: "text", text: "hello" }]);
|
||||
const usage = getTokenUsage(msg as any);
|
||||
expect(usage).toBeDefined();
|
||||
expect(usage!.input_tokens).toBe(100);
|
||||
});
|
||||
|
||||
test("returns undefined for user message", () => {
|
||||
const msg = makeUserMessage("hello");
|
||||
expect(getTokenUsage(msg as any)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for synthetic model", () => {
|
||||
const msg = makeAssistantMessage(
|
||||
[{ type: "text", text: "hello" }],
|
||||
{ input_tokens: 10, output_tokens: 5 },
|
||||
"<synthetic>"
|
||||
);
|
||||
expect(getTokenUsage(msg as any)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── tokenCountFromLastAPIResponse ──────────────────────────────────────
|
||||
|
||||
describe("tokenCountFromLastAPIResponse", () => {
|
||||
test("returns token count from last assistant message", () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
input_tokens: 200,
|
||||
output_tokens: 100,
|
||||
cache_creation_input_tokens: 50,
|
||||
cache_read_input_tokens: 25,
|
||||
}),
|
||||
];
|
||||
expect(tokenCountFromLastAPIResponse(msgs as any)).toBe(375);
|
||||
});
|
||||
|
||||
test("returns 0 for empty messages", () => {
|
||||
expect(tokenCountFromLastAPIResponse([])).toBe(0);
|
||||
});
|
||||
|
||||
test("skips user messages to find last assistant", () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
}),
|
||||
makeUserMessage("reply"),
|
||||
];
|
||||
expect(tokenCountFromLastAPIResponse(msgs as any)).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── messageTokenCountFromLastAPIResponse ───────────────────────────────
|
||||
|
||||
describe("messageTokenCountFromLastAPIResponse", () => {
|
||||
test("returns output_tokens from last assistant", () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
input_tokens: 200,
|
||||
output_tokens: 75,
|
||||
}),
|
||||
];
|
||||
expect(messageTokenCountFromLastAPIResponse(msgs as any)).toBe(75);
|
||||
});
|
||||
|
||||
test("returns 0 for empty messages", () => {
|
||||
expect(messageTokenCountFromLastAPIResponse([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getCurrentUsage ────────────────────────────────────────────────────
|
||||
|
||||
describe("getCurrentUsage", () => {
|
||||
test("returns usage object from last assistant", () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 10,
|
||||
cache_read_input_tokens: 5,
|
||||
}),
|
||||
];
|
||||
const usage = getCurrentUsage(msgs as any);
|
||||
expect(usage).toEqual({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 10,
|
||||
cache_read_input_tokens: 5,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null for empty messages", () => {
|
||||
expect(getCurrentUsage([])).toBeNull();
|
||||
});
|
||||
|
||||
test("defaults cache fields to 0", () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
}),
|
||||
];
|
||||
const usage = getCurrentUsage(msgs as any);
|
||||
expect(usage!.cache_creation_input_tokens).toBe(0);
|
||||
expect(usage!.cache_read_input_tokens).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── doesMostRecentAssistantMessageExceed200k ───────────────────────────
|
||||
|
||||
describe("doesMostRecentAssistantMessageExceed200k", () => {
|
||||
test("returns false when under 200k", () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
}),
|
||||
];
|
||||
expect(doesMostRecentAssistantMessageExceed200k(msgs as any)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when over 200k", () => {
|
||||
const msgs = [
|
||||
makeAssistantMessage([{ type: "text", text: "hi" }], {
|
||||
input_tokens: 190000,
|
||||
output_tokens: 15000,
|
||||
}),
|
||||
];
|
||||
expect(doesMostRecentAssistantMessageExceed200k(msgs as any)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for empty messages", () => {
|
||||
expect(doesMostRecentAssistantMessageExceed200k([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAssistantMessageContentLength ───────────────────────────────────
|
||||
|
||||
describe("getAssistantMessageContentLength", () => {
|
||||
test("counts text content length", () => {
|
||||
const msg = makeAssistantMessage([{ type: "text", text: "hello" }]);
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBe(5);
|
||||
});
|
||||
|
||||
test("counts multiple blocks", () => {
|
||||
const msg = makeAssistantMessage([
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "text", text: "world" },
|
||||
]);
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBe(10);
|
||||
});
|
||||
|
||||
test("counts thinking content", () => {
|
||||
const msg = makeAssistantMessage([
|
||||
{ type: "thinking", thinking: "let me think" },
|
||||
]);
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBe(12);
|
||||
});
|
||||
|
||||
test("returns 0 for empty content", () => {
|
||||
const msg = makeAssistantMessage([]);
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBe(0);
|
||||
});
|
||||
|
||||
test("counts tool_use input", () => {
|
||||
const msg = makeAssistantMessage([
|
||||
{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } },
|
||||
]);
|
||||
expect(getAssistantMessageContentLength(msg as any)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
146
src/utils/__tests__/truncate.test.ts
Normal file
146
src/utils/__tests__/truncate.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
truncatePathMiddle,
|
||||
truncateToWidth,
|
||||
truncateStartToWidth,
|
||||
truncateToWidthNoEllipsis,
|
||||
truncate,
|
||||
wrapText,
|
||||
} from "../truncate";
|
||||
|
||||
// ─── truncateToWidth ────────────────────────────────────────────────────
|
||||
|
||||
describe("truncateToWidth", () => {
|
||||
test("returns original when within limit", () => {
|
||||
expect(truncateToWidth("hello", 10)).toBe("hello");
|
||||
});
|
||||
|
||||
test("truncates long string with ellipsis", () => {
|
||||
const result = truncateToWidth("hello world", 8);
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
expect(result.length).toBeLessThanOrEqual(9); // 8 visible + ellipsis char
|
||||
});
|
||||
|
||||
test("returns ellipsis for maxWidth 1", () => {
|
||||
expect(truncateToWidth("hello", 1)).toBe("…");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(truncateToWidth("", 10)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── truncateStartToWidth ───────────────────────────────────────────────
|
||||
|
||||
describe("truncateStartToWidth", () => {
|
||||
test("returns original when within limit", () => {
|
||||
expect(truncateStartToWidth("hello", 10)).toBe("hello");
|
||||
});
|
||||
|
||||
test("truncates from start with ellipsis prefix", () => {
|
||||
const result = truncateStartToWidth("hello world", 8);
|
||||
expect(result.startsWith("…")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns ellipsis for maxWidth 1", () => {
|
||||
expect(truncateStartToWidth("hello", 1)).toBe("…");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── truncateToWidthNoEllipsis ──────────────────────────────────────────
|
||||
|
||||
describe("truncateToWidthNoEllipsis", () => {
|
||||
test("returns original when within limit", () => {
|
||||
expect(truncateToWidthNoEllipsis("hello", 10)).toBe("hello");
|
||||
});
|
||||
|
||||
test("truncates without ellipsis", () => {
|
||||
const result = truncateToWidthNoEllipsis("hello world", 5);
|
||||
expect(result).toBe("hello");
|
||||
expect(result.includes("…")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns empty for maxWidth 0", () => {
|
||||
expect(truncateToWidthNoEllipsis("hello", 0)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── truncatePathMiddle ─────────────────────────────────────────────────
|
||||
|
||||
describe("truncatePathMiddle", () => {
|
||||
test("returns original when path fits", () => {
|
||||
expect(truncatePathMiddle("src/index.ts", 50)).toBe("src/index.ts");
|
||||
});
|
||||
|
||||
test("truncates middle of long path", () => {
|
||||
const path = "src/components/deeply/nested/folder/MyComponent.tsx";
|
||||
const result = truncatePathMiddle(path, 30);
|
||||
expect(result).toContain("…");
|
||||
expect(result.endsWith("MyComponent.tsx")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns ellipsis for maxLength 0", () => {
|
||||
expect(truncatePathMiddle("src/index.ts", 0)).toBe("…");
|
||||
});
|
||||
|
||||
test("handles path without slashes", () => {
|
||||
const result = truncatePathMiddle("verylongfilename.ts", 10);
|
||||
expect(result).toContain("…");
|
||||
});
|
||||
|
||||
test("handles short maxLength < 5", () => {
|
||||
const result = truncatePathMiddle("src/components/foo.ts", 4);
|
||||
expect(result).toContain("…");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── truncate ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("truncate", () => {
|
||||
test("returns original when within limit", () => {
|
||||
expect(truncate("hello", 10)).toBe("hello");
|
||||
});
|
||||
|
||||
test("truncates long string", () => {
|
||||
const result = truncate("hello world foo bar", 10);
|
||||
expect(result).toContain("…");
|
||||
});
|
||||
|
||||
test("truncates at newline in singleLine mode", () => {
|
||||
const result = truncate("first line\nsecond line", 50, true);
|
||||
expect(result).toBe("first line…");
|
||||
});
|
||||
|
||||
test("does not truncate at newline when singleLine is false", () => {
|
||||
const result = truncate("first\nsecond", 50, false);
|
||||
expect(result).toBe("first\nsecond");
|
||||
});
|
||||
|
||||
test("truncates singleLine when first line exceeds maxWidth", () => {
|
||||
const result = truncate("a very long first line\nsecond", 10, true);
|
||||
expect(result).toContain("…");
|
||||
expect(result).not.toContain("\n");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── wrapText ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("wrapText", () => {
|
||||
test("wraps text at specified width", () => {
|
||||
const result = wrapText("hello world", 6);
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test("returns single line when text fits", () => {
|
||||
expect(wrapText("hello", 10)).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(wrapText("", 10)).toEqual([]);
|
||||
});
|
||||
|
||||
test("wraps each character on width 1", () => {
|
||||
const result = wrapText("abc", 1);
|
||||
expect(result).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
34
src/utils/__tests__/uuid.test.ts
Normal file
34
src/utils/__tests__/uuid.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { validateUuid } from "../uuid";
|
||||
|
||||
describe("validateUuid", () => {
|
||||
test("validates correct UUID", () => {
|
||||
const result = validateUuid("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(result).toBe("550e8400-e29b-41d4-a716-446655440000");
|
||||
});
|
||||
|
||||
test("validates uppercase UUID", () => {
|
||||
const result = validateUuid("550E8400-E29B-41D4-A716-446655440000");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for non-string", () => {
|
||||
expect(validateUuid(123)).toBeNull();
|
||||
expect(validateUuid(null)).toBeNull();
|
||||
expect(validateUuid(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for invalid UUID format", () => {
|
||||
expect(validateUuid("not-a-uuid")).toBeNull();
|
||||
expect(validateUuid("550e8400-e29b-41d4-a716")).toBeNull();
|
||||
expect(validateUuid("550e8400e29b41d4a716446655440000")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(validateUuid("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for UUID with invalid chars", () => {
|
||||
expect(validateUuid("550e8400-e29b-41d4-a716-44665544000g")).toBeNull();
|
||||
});
|
||||
});
|
||||
42
src/utils/__tests__/xml.test.ts
Normal file
42
src/utils/__tests__/xml.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { escapeXml, escapeXmlAttr } from "../xml";
|
||||
|
||||
describe("escapeXml", () => {
|
||||
test("escapes ampersand", () => {
|
||||
expect(escapeXml("a & b")).toBe("a & b");
|
||||
});
|
||||
|
||||
test("escapes less-than", () => {
|
||||
expect(escapeXml("<div>")).toBe("<div>");
|
||||
});
|
||||
|
||||
test("escapes greater-than", () => {
|
||||
expect(escapeXml("a > b")).toBe("a > b");
|
||||
});
|
||||
|
||||
test("escapes multiple special chars", () => {
|
||||
expect(escapeXml("<a & b>")).toBe("<a & b>");
|
||||
});
|
||||
|
||||
test("returns empty string unchanged", () => {
|
||||
expect(escapeXml("")).toBe("");
|
||||
});
|
||||
|
||||
test("returns normal text unchanged", () => {
|
||||
expect(escapeXml("hello world")).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeXmlAttr", () => {
|
||||
test("escapes double quotes", () => {
|
||||
expect(escapeXmlAttr('say "hello"')).toBe("say "hello"");
|
||||
});
|
||||
|
||||
test("escapes single quotes", () => {
|
||||
expect(escapeXmlAttr("it's")).toBe("it's");
|
||||
});
|
||||
|
||||
test("escapes all special chars", () => {
|
||||
expect(escapeXmlAttr('<a & "b">')).toBe("<a & "b">");
|
||||
});
|
||||
});
|
||||
70
src/utils/model/__tests__/aliases.test.ts
Normal file
70
src/utils/model/__tests__/aliases.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isModelAlias, isModelFamilyAlias } from "../aliases";
|
||||
|
||||
describe("isModelAlias", () => {
|
||||
test('returns true for "sonnet"', () => {
|
||||
expect(isModelAlias("sonnet")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "opus"', () => {
|
||||
expect(isModelAlias("opus")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "haiku"', () => {
|
||||
expect(isModelAlias("haiku")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "best"', () => {
|
||||
expect(isModelAlias("best")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "sonnet[1m]"', () => {
|
||||
expect(isModelAlias("sonnet[1m]")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "opus[1m]"', () => {
|
||||
expect(isModelAlias("opus[1m]")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "opusplan"', () => {
|
||||
expect(isModelAlias("opusplan")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for full model ID", () => {
|
||||
expect(isModelAlias("claude-sonnet-4-6-20250514")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for unknown string", () => {
|
||||
expect(isModelAlias("gpt-4")).toBe(false);
|
||||
});
|
||||
|
||||
test("is case-sensitive", () => {
|
||||
expect(isModelAlias("Sonnet")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isModelFamilyAlias", () => {
|
||||
test('returns true for "sonnet"', () => {
|
||||
expect(isModelFamilyAlias("sonnet")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "opus"', () => {
|
||||
expect(isModelFamilyAlias("opus")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "haiku"', () => {
|
||||
expect(isModelFamilyAlias("haiku")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for "best"', () => {
|
||||
expect(isModelFamilyAlias("best")).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for "opusplan"', () => {
|
||||
expect(isModelFamilyAlias("opusplan")).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for "sonnet[1m]"', () => {
|
||||
expect(isModelFamilyAlias("sonnet[1m]")).toBe(false);
|
||||
});
|
||||
});
|
||||
92
src/utils/model/__tests__/model.test.ts
Normal file
92
src/utils/model/__tests__/model.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { firstPartyNameToCanonical } from "../model";
|
||||
|
||||
describe("firstPartyNameToCanonical", () => {
|
||||
test("maps opus-4-6 full name to canonical", () => {
|
||||
expect(firstPartyNameToCanonical("claude-opus-4-6-20250514")).toBe(
|
||||
"claude-opus-4-6"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps sonnet-4-6 full name", () => {
|
||||
expect(firstPartyNameToCanonical("claude-sonnet-4-6-20250514")).toBe(
|
||||
"claude-sonnet-4-6"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps haiku-4-5", () => {
|
||||
expect(firstPartyNameToCanonical("claude-haiku-4-5-20251001")).toBe(
|
||||
"claude-haiku-4-5"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps 3P provider format", () => {
|
||||
expect(
|
||||
firstPartyNameToCanonical("us.anthropic.claude-opus-4-6-v1:0")
|
||||
).toBe("claude-opus-4-6");
|
||||
});
|
||||
|
||||
test("maps claude-3-7-sonnet", () => {
|
||||
expect(firstPartyNameToCanonical("claude-3-7-sonnet-20250219")).toBe(
|
||||
"claude-3-7-sonnet"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps claude-3-5-sonnet", () => {
|
||||
expect(firstPartyNameToCanonical("claude-3-5-sonnet-20241022")).toBe(
|
||||
"claude-3-5-sonnet"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps claude-3-5-haiku", () => {
|
||||
expect(firstPartyNameToCanonical("claude-3-5-haiku-20241022")).toBe(
|
||||
"claude-3-5-haiku"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps claude-3-opus", () => {
|
||||
expect(firstPartyNameToCanonical("claude-3-opus-20240229")).toBe(
|
||||
"claude-3-opus"
|
||||
);
|
||||
});
|
||||
|
||||
test("is case insensitive", () => {
|
||||
expect(firstPartyNameToCanonical("Claude-Opus-4-6-20250514")).toBe(
|
||||
"claude-opus-4-6"
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to input for unknown model", () => {
|
||||
expect(firstPartyNameToCanonical("unknown-model")).toBe("unknown-model");
|
||||
});
|
||||
|
||||
test("differentiates opus-4 vs opus-4-5 vs opus-4-6", () => {
|
||||
expect(firstPartyNameToCanonical("claude-opus-4-20240101")).toBe(
|
||||
"claude-opus-4"
|
||||
);
|
||||
expect(firstPartyNameToCanonical("claude-opus-4-5-20240101")).toBe(
|
||||
"claude-opus-4-5"
|
||||
);
|
||||
expect(firstPartyNameToCanonical("claude-opus-4-6-20240101")).toBe(
|
||||
"claude-opus-4-6"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps opus-4-1", () => {
|
||||
expect(firstPartyNameToCanonical("claude-opus-4-1-20240101")).toBe(
|
||||
"claude-opus-4-1"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps sonnet-4-5", () => {
|
||||
expect(firstPartyNameToCanonical("claude-sonnet-4-5-20240101")).toBe(
|
||||
"claude-sonnet-4-5"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps sonnet-4", () => {
|
||||
expect(firstPartyNameToCanonical("claude-sonnet-4-20240101")).toBe(
|
||||
"claude-sonnet-4"
|
||||
);
|
||||
});
|
||||
});
|
||||
84
src/utils/model/__tests__/providers.test.ts
Normal file
84
src/utils/model/__tests__/providers.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from "../providers";
|
||||
|
||||
describe("getAPIProvider", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX;
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY;
|
||||
});
|
||||
|
||||
test('returns "firstParty" by default', () => {
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX;
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY;
|
||||
expect(getAPIProvider()).toBe("firstParty");
|
||||
});
|
||||
|
||||
test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set', () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
expect(getAPIProvider()).toBe("bedrock");
|
||||
});
|
||||
|
||||
test('returns "vertex" when CLAUDE_CODE_USE_VERTEX is set', () => {
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||
expect(getAPIProvider()).toBe("vertex");
|
||||
});
|
||||
|
||||
test('returns "foundry" when CLAUDE_CODE_USE_FOUNDRY is set', () => {
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
||||
expect(getAPIProvider()).toBe("foundry");
|
||||
});
|
||||
|
||||
test("bedrock takes precedence over vertex", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||
expect(getAPIProvider()).toBe("bedrock");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFirstPartyAnthropicBaseUrl", () => {
|
||||
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
|
||||
const originalUserType = process.env.USER_TYPE;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBaseUrl !== undefined) {
|
||||
process.env.ANTHROPIC_BASE_URL = originalBaseUrl;
|
||||
} else {
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
}
|
||||
if (originalUserType !== undefined) {
|
||||
process.env.USER_TYPE = originalUserType;
|
||||
} else {
|
||||
delete process.env.USER_TYPE;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns true when ANTHROPIC_BASE_URL is not set", () => {
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for api.anthropic.com", () => {
|
||||
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for custom URL", () => {
|
||||
process.env.ANTHROPIC_BASE_URL = "https://my-proxy.com";
|
||||
expect(isFirstPartyAnthropicBaseUrl()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for invalid URL", () => {
|
||||
process.env.ANTHROPIC_BASE_URL = "not-a-url";
|
||||
expect(isFirstPartyAnthropicBaseUrl()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for staging URL when USER_TYPE is ant", () => {
|
||||
process.env.ANTHROPIC_BASE_URL = "https://api-staging.anthropic.com";
|
||||
process.env.USER_TYPE = "ant";
|
||||
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
|
||||
});
|
||||
});
|
||||
152
src/utils/permissions/__tests__/permissionRuleParser.test.ts
Normal file
152
src/utils/permissions/__tests__/permissionRuleParser.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
escapeRuleContent,
|
||||
unescapeRuleContent,
|
||||
permissionRuleValueFromString,
|
||||
permissionRuleValueToString,
|
||||
normalizeLegacyToolName,
|
||||
} from "../permissionRuleParser";
|
||||
|
||||
describe("escapeRuleContent", () => {
|
||||
test("escapes backslashes first", () => {
|
||||
expect(escapeRuleContent("a\\b")).toBe("a\\\\b");
|
||||
});
|
||||
|
||||
test("escapes opening parentheses", () => {
|
||||
expect(escapeRuleContent("fn(x)")).toBe("fn\\(x\\)");
|
||||
});
|
||||
|
||||
test("escapes backslash before parens correctly", () => {
|
||||
expect(escapeRuleContent('echo "test\\nvalue"')).toBe(
|
||||
'echo "test\\\\nvalue"'
|
||||
);
|
||||
});
|
||||
|
||||
test("returns unchanged string with no special chars", () => {
|
||||
expect(escapeRuleContent("npm install")).toBe("npm install");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(escapeRuleContent("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unescapeRuleContent", () => {
|
||||
test("unescapes parentheses", () => {
|
||||
expect(unescapeRuleContent("fn\\(x\\)")).toBe("fn(x)");
|
||||
});
|
||||
|
||||
test("unescapes backslashes", () => {
|
||||
expect(unescapeRuleContent("a\\\\b")).toBe("a\\b");
|
||||
});
|
||||
|
||||
test("roundtrips with escapeRuleContent", () => {
|
||||
const original = 'python -c "print(1)"';
|
||||
expect(unescapeRuleContent(escapeRuleContent(original))).toBe(original);
|
||||
});
|
||||
|
||||
test("handles content with backslash-paren combo", () => {
|
||||
const original = 'echo "test\\nvalue"';
|
||||
expect(unescapeRuleContent(escapeRuleContent(original))).toBe(original);
|
||||
});
|
||||
|
||||
test("returns unchanged string with no escapes", () => {
|
||||
expect(unescapeRuleContent("npm install")).toBe("npm install");
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissionRuleValueFromString", () => {
|
||||
test("parses tool name only", () => {
|
||||
expect(permissionRuleValueFromString("Bash")).toEqual({
|
||||
toolName: "Bash",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses tool name with content", () => {
|
||||
expect(permissionRuleValueFromString("Bash(npm install)")).toEqual({
|
||||
toolName: "Bash",
|
||||
ruleContent: "npm install",
|
||||
});
|
||||
});
|
||||
|
||||
test("handles escaped parens in content", () => {
|
||||
const result = permissionRuleValueFromString(
|
||||
'Bash(python -c "print\\(1\\)")'
|
||||
);
|
||||
expect(result.toolName).toBe("Bash");
|
||||
expect(result.ruleContent).toBe('python -c "print(1)"');
|
||||
});
|
||||
|
||||
test("treats empty content as tool-wide rule", () => {
|
||||
expect(permissionRuleValueFromString("Bash()")).toEqual({
|
||||
toolName: "Bash",
|
||||
});
|
||||
});
|
||||
|
||||
test("treats wildcard content as tool-wide rule", () => {
|
||||
expect(permissionRuleValueFromString("Bash(*)")).toEqual({
|
||||
toolName: "Bash",
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes legacy tool names", () => {
|
||||
const result = permissionRuleValueFromString("Task");
|
||||
expect(result.toolName).toBe("Agent");
|
||||
});
|
||||
|
||||
test("handles MCP-style tool names", () => {
|
||||
expect(permissionRuleValueFromString("mcp__server__tool")).toEqual({
|
||||
toolName: "mcp__server__tool",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissionRuleValueToString", () => {
|
||||
test("formats tool name only", () => {
|
||||
expect(permissionRuleValueToString({ toolName: "Bash" })).toBe("Bash");
|
||||
});
|
||||
|
||||
test("formats tool name with content", () => {
|
||||
expect(
|
||||
permissionRuleValueToString({
|
||||
toolName: "Bash",
|
||||
ruleContent: "npm install",
|
||||
})
|
||||
).toBe("Bash(npm install)");
|
||||
});
|
||||
|
||||
test("escapes parens in content", () => {
|
||||
expect(
|
||||
permissionRuleValueToString({
|
||||
toolName: "Bash",
|
||||
ruleContent: 'python -c "print(1)"',
|
||||
})
|
||||
).toBe('Bash(python -c "print\\(1\\)")');
|
||||
});
|
||||
|
||||
test("roundtrips with permissionRuleValueFromString", () => {
|
||||
const original = { toolName: "Bash", ruleContent: "npm install" };
|
||||
const str = permissionRuleValueToString(original);
|
||||
const parsed = permissionRuleValueFromString(str);
|
||||
expect(parsed).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeLegacyToolName", () => {
|
||||
test("maps Task to Agent", () => {
|
||||
expect(normalizeLegacyToolName("Task")).toBe("Agent");
|
||||
});
|
||||
|
||||
test("maps KillShell to TaskStop", () => {
|
||||
expect(normalizeLegacyToolName("KillShell")).toBe("TaskStop");
|
||||
});
|
||||
|
||||
test("returns unknown name as-is", () => {
|
||||
expect(normalizeLegacyToolName("UnknownTool")).toBe("UnknownTool");
|
||||
});
|
||||
|
||||
test("preserves current canonical names", () => {
|
||||
expect(normalizeLegacyToolName("Bash")).toBe("Bash");
|
||||
expect(normalizeLegacyToolName("Agent")).toBe("Agent");
|
||||
});
|
||||
});
|
||||
165
src/utils/permissions/__tests__/permissions.test.ts
Normal file
165
src/utils/permissions/__tests__/permissions.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock log.ts to cut the heavy dependency chain
|
||||
mock.module("src/utils/log.ts", () => ({
|
||||
logError: () => {},
|
||||
logToFile: () => {},
|
||||
getLogDisplayTitle: () => "",
|
||||
logEvent: () => {},
|
||||
logMCPError: () => {},
|
||||
logMCPDebug: () => {},
|
||||
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"),
|
||||
getLogFilePath: () => "/tmp/mock-log",
|
||||
attachErrorLogSink: () => {},
|
||||
getInMemoryErrors: () => [],
|
||||
loadErrorLogs: async () => [],
|
||||
getErrorLogByIndex: async () => null,
|
||||
captureAPIRequest: () => {},
|
||||
_resetErrorLogForTesting: () => {},
|
||||
}));
|
||||
|
||||
// Mock slowOperations to avoid bun:bundle
|
||||
mock.module("src/utils/slowOperations.ts", () => ({
|
||||
jsonStringify: JSON.stringify,
|
||||
jsonParse: JSON.parse,
|
||||
slowLogging: { enabled: false },
|
||||
clone: (v: any) => structuredClone(v),
|
||||
cloneDeep: (v: any) => structuredClone(v),
|
||||
callerFrame: () => "",
|
||||
SLOW_OPERATION_THRESHOLD_MS: 100,
|
||||
writeFileSync_DEPRECATED: () => {},
|
||||
}));
|
||||
|
||||
const {
|
||||
getDenyRuleForTool,
|
||||
getAskRuleForTool,
|
||||
getDenyRuleForAgent,
|
||||
filterDeniedAgents,
|
||||
} = await import("../permissions");
|
||||
|
||||
import { getEmptyToolPermissionContext } from "../../../Tool";
|
||||
|
||||
// ─── Helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
function makeContext(opts: {
|
||||
denyRules?: string[];
|
||||
askRules?: string[];
|
||||
}) {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
const deny: Record<string, string[]> = {};
|
||||
const ask: Record<string, string[]> = {};
|
||||
|
||||
// alwaysDenyRules stores raw rule strings — getDenyRules() calls
|
||||
// permissionRuleValueFromString internally
|
||||
if (opts.denyRules?.length) {
|
||||
deny["localSettings"] = opts.denyRules;
|
||||
}
|
||||
if (opts.askRules?.length) {
|
||||
ask["localSettings"] = opts.askRules;
|
||||
}
|
||||
|
||||
return {
|
||||
...ctx,
|
||||
alwaysDenyRules: deny,
|
||||
alwaysAskRules: ask,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeTool(name: string, mcpInfo?: { serverName: string; toolName: string }) {
|
||||
return { name, mcpInfo };
|
||||
}
|
||||
|
||||
// ─── getDenyRuleForTool ─────────────────────────────────────────────────
|
||||
|
||||
describe("getDenyRuleForTool", () => {
|
||||
test("returns null when no deny rules", () => {
|
||||
const ctx = makeContext({});
|
||||
expect(getDenyRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
||||
});
|
||||
|
||||
test("returns matching deny rule for tool", () => {
|
||||
const ctx = makeContext({ denyRules: ["Bash"] });
|
||||
const result = getDenyRuleForTool(ctx, makeTool("Bash"));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ruleValue.toolName).toBe("Bash");
|
||||
});
|
||||
|
||||
test("returns null for non-matching tool", () => {
|
||||
const ctx = makeContext({ denyRules: ["Bash"] });
|
||||
expect(getDenyRuleForTool(ctx, makeTool("Read"))).toBeNull();
|
||||
});
|
||||
|
||||
test("rule with content does not match whole-tool deny", () => {
|
||||
// getDenyRuleForTool uses toolMatchesRule which requires ruleContent === undefined
|
||||
// Rules like "Bash(rm -rf)" only match specific invocations, not the entire tool
|
||||
const ctx = makeContext({ denyRules: ["Bash(rm -rf)"] });
|
||||
const result = getDenyRuleForTool(ctx, makeTool("Bash"));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAskRuleForTool ──────────────────────────────────────────────────
|
||||
|
||||
describe("getAskRuleForTool", () => {
|
||||
test("returns null when no ask rules", () => {
|
||||
const ctx = makeContext({});
|
||||
expect(getAskRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
||||
});
|
||||
|
||||
test("returns matching ask rule", () => {
|
||||
const ctx = makeContext({ askRules: ["Write"] });
|
||||
const result = getAskRuleForTool(ctx, makeTool("Write"));
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for non-matching tool", () => {
|
||||
const ctx = makeContext({ askRules: ["Write"] });
|
||||
expect(getAskRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getDenyRuleForAgent ────────────────────────────────────────────────
|
||||
|
||||
describe("getDenyRuleForAgent", () => {
|
||||
test("returns null when no deny rules", () => {
|
||||
const ctx = makeContext({});
|
||||
expect(getDenyRuleForAgent(ctx, "Agent", "Explore")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns matching deny rule for agent type", () => {
|
||||
const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
||||
const result = getDenyRuleForAgent(ctx, "Agent", "Explore");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for non-matching agent type", () => {
|
||||
const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
||||
expect(getDenyRuleForAgent(ctx, "Agent", "Research")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── filterDeniedAgents ─────────────────────────────────────────────────
|
||||
|
||||
describe("filterDeniedAgents", () => {
|
||||
test("returns all agents when no deny rules", () => {
|
||||
const ctx = makeContext({});
|
||||
const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
||||
expect(filterDeniedAgents(agents, ctx, "Agent")).toEqual(agents);
|
||||
});
|
||||
|
||||
test("filters out denied agent type", () => {
|
||||
const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
||||
const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
||||
const result = filterDeniedAgents(agents, ctx, "Agent");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.agentType).toBe("Research");
|
||||
});
|
||||
|
||||
test("returns empty array when all agents denied", () => {
|
||||
const ctx = makeContext({
|
||||
denyRules: ["Agent(Explore)", "Agent(Research)"],
|
||||
});
|
||||
const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
||||
expect(filterDeniedAgents(agents, ctx, "Agent")).toEqual([]);
|
||||
});
|
||||
});
|
||||
476
src/utils/settings/__tests__/config.test.ts
Normal file
476
src/utils/settings/__tests__/config.test.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
SettingsSchema,
|
||||
EnvironmentVariablesSchema,
|
||||
PermissionsSchema,
|
||||
AllowedMcpServerEntrySchema,
|
||||
DeniedMcpServerEntrySchema,
|
||||
isMcpServerNameEntry,
|
||||
isMcpServerCommandEntry,
|
||||
isMcpServerUrlEntry,
|
||||
CUSTOMIZATION_SURFACES,
|
||||
} from "../types";
|
||||
import {
|
||||
SETTING_SOURCES,
|
||||
SOURCES,
|
||||
CLAUDE_CODE_SETTINGS_SCHEMA_URL,
|
||||
getSettingSourceName,
|
||||
getSourceDisplayName,
|
||||
getSettingSourceDisplayNameLowercase,
|
||||
getSettingSourceDisplayNameCapitalized,
|
||||
parseSettingSourcesFlag,
|
||||
} from "../constants";
|
||||
import {
|
||||
formatZodError,
|
||||
filterInvalidPermissionRules,
|
||||
validateSettingsFileContent,
|
||||
} from "../validation";
|
||||
|
||||
// ─── Settings Schema Validation ──────────────────────────────────────────
|
||||
|
||||
describe("SettingsSchema", () => {
|
||||
test("accepts empty object", () => {
|
||||
const result = SettingsSchema().safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts model string", () => {
|
||||
const result = SettingsSchema().safeParse({ model: "sonnet" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts permissions block with allow rules", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
permissions: { allow: ["Bash(npm install)"] },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts permissions block with deny rules", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
permissions: { deny: ["Bash(rm -rf *)"] },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts env variables", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
env: { FOO: "bar", DEBUG: "1" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts hooks configuration", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: "Bash",
|
||||
hooks: [{ type: "command", command: "echo test" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts attribution settings", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
attribution: {
|
||||
commit: "Generated by AI",
|
||||
pr: "AI-generated PR",
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts worktree settings", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
worktree: {
|
||||
symlinkDirectories: ["node_modules", ".cache"],
|
||||
sparsePaths: ["src/"],
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts $schema field", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
$schema: CLAUDE_CODE_SETTINGS_SCHEMA_URL,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("passes through unknown keys (passthrough mode)", () => {
|
||||
const result = SettingsSchema().safeParse({ unknownKey: "value" });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect((result.data as any).unknownKey).toBe("value");
|
||||
}
|
||||
});
|
||||
|
||||
test("coerces env var numbers to strings", () => {
|
||||
const result = EnvironmentVariablesSchema().safeParse({ PORT: 3000 });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.PORT).toBe("3000");
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts boolean settings", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
includeCoAuthoredBy: true,
|
||||
respectGitignore: false,
|
||||
disableAllHooks: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts cleanupPeriodDays", () => {
|
||||
const result = SettingsSchema().safeParse({ cleanupPeriodDays: 30 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects negative cleanupPeriodDays", () => {
|
||||
const result = SettingsSchema().safeParse({ cleanupPeriodDays: -1 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts statusLine configuration", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
statusLine: { type: "command", command: "echo status" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts sshConfigs", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
sshConfigs: [
|
||||
{
|
||||
id: "dev-server",
|
||||
name: "Development Server",
|
||||
sshHost: "dev.example.com",
|
||||
sshPort: 22,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Permissions Schema ─────────────────────────────────────────────────
|
||||
|
||||
describe("PermissionsSchema", () => {
|
||||
test("accepts defaultMode", () => {
|
||||
const result = PermissionsSchema().safeParse({
|
||||
defaultMode: "acceptEdits",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts additionalDirectories", () => {
|
||||
const result = PermissionsSchema().safeParse({
|
||||
additionalDirectories: ["/tmp/extra"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts disableBypassPermissionsMode", () => {
|
||||
const result = PermissionsSchema().safeParse({
|
||||
disableBypassPermissionsMode: "disable",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AllowedMcpServerEntrySchema ────────────────────────────────────────
|
||||
|
||||
describe("AllowedMcpServerEntrySchema", () => {
|
||||
test("accepts serverName entry", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({
|
||||
serverName: "my-server",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts serverCommand entry", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({
|
||||
serverCommand: ["npx", "mcp-server"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts serverUrl entry", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({
|
||||
serverUrl: "https://*.example.com/*",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects entry with no fields", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects entry with multiple fields", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({
|
||||
serverName: "my-server",
|
||||
serverUrl: "https://example.com",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects invalid serverName characters", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({
|
||||
serverName: "my server with spaces",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects empty serverCommand array", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({
|
||||
serverCommand: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Type guards ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("MCP server entry type guards", () => {
|
||||
test("isMcpServerNameEntry identifies name entry", () => {
|
||||
expect(isMcpServerNameEntry({ serverName: "test" })).toBe(true);
|
||||
});
|
||||
|
||||
test("isMcpServerNameEntry rejects non-name entry", () => {
|
||||
expect(isMcpServerNameEntry({ serverUrl: "https://example.com" })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("isMcpServerCommandEntry identifies command entry", () => {
|
||||
expect(isMcpServerCommandEntry({ serverCommand: ["npx", "srv"] })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("isMcpServerCommandEntry rejects non-command entry", () => {
|
||||
expect(isMcpServerCommandEntry({ serverName: "test" })).toBe(false);
|
||||
});
|
||||
|
||||
test("isMcpServerUrlEntry identifies url entry", () => {
|
||||
expect(
|
||||
isMcpServerUrlEntry({ serverUrl: "https://example.com" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("isMcpServerUrlEntry rejects non-url entry", () => {
|
||||
expect(isMcpServerUrlEntry({ serverName: "test" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("SETTING_SOURCES", () => {
|
||||
test("contains all five sources in order", () => {
|
||||
expect(SETTING_SOURCES).toEqual([
|
||||
"userSettings",
|
||||
"projectSettings",
|
||||
"localSettings",
|
||||
"flagSettings",
|
||||
"policySettings",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SOURCES (editable)", () => {
|
||||
test("contains three editable sources", () => {
|
||||
expect(SOURCES).toEqual([
|
||||
"localSettings",
|
||||
"projectSettings",
|
||||
"userSettings",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CUSTOMIZATION_SURFACES", () => {
|
||||
test("contains expected surfaces", () => {
|
||||
expect(CUSTOMIZATION_SURFACES).toEqual([
|
||||
"skills",
|
||||
"agents",
|
||||
"hooks",
|
||||
"mcp",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettingSourceName", () => {
|
||||
test("maps userSettings to user", () => {
|
||||
expect(getSettingSourceName("userSettings")).toBe("user");
|
||||
});
|
||||
|
||||
test("maps projectSettings to project", () => {
|
||||
expect(getSettingSourceName("projectSettings")).toBe("project");
|
||||
});
|
||||
|
||||
test("maps localSettings to project, gitignored", () => {
|
||||
expect(getSettingSourceName("localSettings")).toBe("project, gitignored");
|
||||
});
|
||||
|
||||
test("maps flagSettings to cli flag", () => {
|
||||
expect(getSettingSourceName("flagSettings")).toBe("cli flag");
|
||||
});
|
||||
|
||||
test("maps policySettings to managed", () => {
|
||||
expect(getSettingSourceName("policySettings")).toBe("managed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSourceDisplayName", () => {
|
||||
test("maps userSettings to User", () => {
|
||||
expect(getSourceDisplayName("userSettings")).toBe("User");
|
||||
});
|
||||
|
||||
test("maps plugin to Plugin", () => {
|
||||
expect(getSourceDisplayName("plugin")).toBe("Plugin");
|
||||
});
|
||||
|
||||
test("maps built-in to Built-in", () => {
|
||||
expect(getSourceDisplayName("built-in")).toBe("Built-in");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettingSourceDisplayNameLowercase", () => {
|
||||
test("maps policySettings correctly", () => {
|
||||
expect(getSettingSourceDisplayNameLowercase("policySettings")).toBe(
|
||||
"enterprise managed settings"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps cliArg correctly", () => {
|
||||
expect(getSettingSourceDisplayNameLowercase("cliArg")).toBe("CLI argument");
|
||||
});
|
||||
|
||||
test("maps session correctly", () => {
|
||||
expect(getSettingSourceDisplayNameLowercase("session")).toBe(
|
||||
"current session"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettingSourceDisplayNameCapitalized", () => {
|
||||
test("maps userSettings correctly", () => {
|
||||
expect(getSettingSourceDisplayNameCapitalized("userSettings")).toBe(
|
||||
"User settings"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps command correctly", () => {
|
||||
expect(getSettingSourceDisplayNameCapitalized("command")).toBe(
|
||||
"Command configuration"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSettingSourcesFlag", () => {
|
||||
test("parses comma-separated sources", () => {
|
||||
expect(parseSettingSourcesFlag("user,project,local")).toEqual([
|
||||
"userSettings",
|
||||
"projectSettings",
|
||||
"localSettings",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses single source", () => {
|
||||
expect(parseSettingSourcesFlag("user")).toEqual(["userSettings"]);
|
||||
});
|
||||
|
||||
test("returns empty array for empty string", () => {
|
||||
expect(parseSettingSourcesFlag("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("trims whitespace", () => {
|
||||
expect(parseSettingSourcesFlag("user , project")).toEqual([
|
||||
"userSettings",
|
||||
"projectSettings",
|
||||
]);
|
||||
});
|
||||
|
||||
test("throws for invalid source name", () => {
|
||||
expect(() => parseSettingSourcesFlag("invalid")).toThrow(
|
||||
"Invalid setting source"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Validation ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("filterInvalidPermissionRules", () => {
|
||||
test("returns empty for non-object input", () => {
|
||||
expect(filterInvalidPermissionRules(null, "test.json")).toEqual([]);
|
||||
expect(filterInvalidPermissionRules("string", "test.json")).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns empty when no permissions", () => {
|
||||
expect(filterInvalidPermissionRules({}, "test.json")).toEqual([]);
|
||||
});
|
||||
|
||||
test("filters non-string rules and returns warnings", () => {
|
||||
const data = { permissions: { allow: ["Bash", 123, "Read"] } };
|
||||
const warnings = filterInvalidPermissionRules(data, "test.json");
|
||||
expect(warnings.length).toBe(1);
|
||||
expect(warnings[0]!.path).toBe("permissions.allow");
|
||||
expect((data.permissions as any).allow).toEqual(["Bash", "Read"]);
|
||||
});
|
||||
|
||||
test("preserves valid rules", () => {
|
||||
const data = {
|
||||
permissions: { allow: ["Bash(npm install)", "Read", "Write"] },
|
||||
};
|
||||
const warnings = filterInvalidPermissionRules(data, "test.json");
|
||||
expect(warnings).toEqual([]);
|
||||
expect((data.permissions as any).allow).toEqual([
|
||||
"Bash(npm install)",
|
||||
"Read",
|
||||
"Write",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSettingsFileContent", () => {
|
||||
test("accepts valid JSON settings", () => {
|
||||
const result = validateSettingsFileContent('{"model": "sonnet"}');
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts empty object", () => {
|
||||
const result = validateSettingsFileContent("{}");
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects invalid JSON", () => {
|
||||
const result = validateSettingsFileContent("not json");
|
||||
expect(result.isValid).toBe(false);
|
||||
if (!result.isValid) {
|
||||
expect(result.error).toContain("Invalid JSON");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unknown keys in strict mode", () => {
|
||||
const result = validateSettingsFileContent('{"unknownField": true}');
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatZodError", () => {
|
||||
test("formats invalid type error", () => {
|
||||
const result = SettingsSchema().safeParse({ model: 123 });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const errors = formatZodError(result.error, "settings.json");
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]!.file).toBe("settings.json");
|
||||
expect(errors[0]!.path).toContain("model");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user