Cline Hooks 测试夹具体系:从 fixtures 目录理解 Hook 系统的可测试设计
本文基于 Cline VSCode 扩展中 apps/vscode/src/core/hooks/__tests__/fixtures/README.md 这份夹具目录说明文档展开,介绍 Cline Hook 系统的预置测试脚本(fixtures)如何按 Hook 类型组织、如何在测试中加载执行、底层运行链路(超时、输出解析、失败降级)如何工作,以及跨平台(Linux/macOS 与 Windows)执行差异的处理方式。读完本文,你可以直接复用这套夹具机制为任意 Hook 编写隔离、可清理、跨平台的测试用例,并新增符合规范的新 fixture。
fixtures 目录的定位与结构
Cline 的 Hook 机制允许用户在工具调用前后、任务生命周期节点注入自定义脚本(例如 PreToolUse、PostToolUse、UserPromptSubmit、TaskStart 等),脚本通过 stdin 接收 JSON 输入、向 stdout 输出 JSON 结果。为了对这条“进程间 JSON 通信”链路做端到端验证,测试目录维护了一套预写的真实 Hook 脚本(而非纯 Mock),存放在 fixtures 目录中,并按 Hook 类型与场景分类:
fixtures/
├── hooks/
│ ├── pretooluse/ # PreToolUse hook 夹具
│ │ ├── success/ # 立即返回成功
│ │ ├── blocking/ # 阻止工具执行
│ │ ├── context-injection/ # 带类型前缀注入上下文
│ │ └── error/ # 以错误码退出
│ ├── posttooluse/ # PostToolUse 夹具
│ │ ├── success/ # 立即返回成功
│ │ └── error/ # 以错误码退出
│ ├── userpromptsubmit/ # UserPromptSubmit 夹具(见后文清单)
│ ├── taskstart/ # TaskStart 夹具
│ ├── taskresume/ # TaskResume 夹具
│ ├── taskcomplete/ # TaskComplete 夹具
│ ├── taskcancel/ # TaskCancel 夹具
│ └── template/ # 新建 fixture 的模板
└── inputs/ # 示例输入数据(预留)
从当前仓库实际文件看,夹具覆盖的 Hook 类型比 README 中列出的更多:除 pretooluse、posttooluse、userpromptsubmit、taskstart 外,还存在 taskresume(含 long-pause、message-count、context-deleted 等场景)、taskcomplete 与 taskcancel(按 cancel 布尔值与是否带 errorMessage 组合出 4 个场景)等目录,说明该目录是随 Hook 功能演进持续扩充的。
设计原则在维护文档中有明确表述:每个 fixture 只聚焦一个场景(success / blocking / context-injection / error 四类基本情形),且全部是跨平台可运行的 Node.js 脚本。
各 fixture 的行为契约
每个 fixture 都是一个独立的可执行 Node 脚本,其“返回什么、如何退出”在 README 中逐条约定。下面按 Hook 类型继承原文档的完整清单,并给出对应脚本的真实实现作为佐证(路径均相对于仓库根目录)。
PreToolUse 夹具
| 夹具路径 | 行为 | 适用场景 |
|---|---|---|
| hooks/pretooluse/success | 返回 { cancel: false, contextModification: "PreToolUse hook executed successfully", errorMessage: "" } |
测试正常路径(happy path) |
| hooks/pretooluse/blocking | 返回 { cancel: true, contextModification: "", errorMessage: "Tool execution blocked by hook" } |
测试阻断工具执行 |
| hooks/pretooluse/context-injection | 返回 { cancel: false, contextModification: "WORKSPACE_RULES: Tool [toolName] requires review", errorMessage: "" },动态读取输入中的 toolName |
测试带类型前缀的上下文注入 |
| hooks/pretooluse/error | 向 stderr 打印 Hook execution failed 并以退出码 1 结束 |
测试错误处理 |
以 context-injection 为例,脚本从 stdin 解析输入并拼接工具名:
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
const toolName = input.preToolUse?.toolName || 'unknown';
console.log(JSON.stringify({
cancel: false,
contextModification: `WORKSPACE_RULES: Tool ${toolName} requires review`,
errorMessage: ""
}));
PostToolUse 夹具
| 夹具路径 | 行为 | 适用场景 |
|---|---|---|
| hooks/posttooluse/success | 返回 { cancel: false, contextModification: "PostToolUse hook executed successfully", errorMessage: "" } |
测试 PostToolUse 正常执行 |
| hooks/posttooluse/error | stderr 打印错误并以退出码 1 结束 | 测试 PostToolUse 的错误处理 |
UserPromptSubmit 夹具
UserPromptSubmit 是夹具最丰富的类型,因为它要验证“用户输入序列化”这条容易出问题的链路(多行文本、超长 prompt、特殊字符、空 prompt、畸形 JSON 等):
| 夹具路径 | 行为 | 适用场景 |
|---|---|---|
hooks/userpromptsubmit/success |
返回 { cancel: false, contextModification: "Prompt approved", ... } |
测试提示词成功提交 |
hooks/userpromptsubmit/blocking |
返回 { cancel: true, contextModification: "", errorMessage: "Prompt violates policy" } |
测试提示词提交被阻断(如策略拦截) |
hooks/userpromptsubmit/context-injection |
返回 { cancel: false, contextModification: "CONTEXT_INJECTION: User is in plan mode", ... } |
测试向任务请求注入上下文 |
hooks/userpromptsubmit/multiline |
动态统计 prompt 中换行符数量,返回 "Line count: N" |
测试多行 prompt 的完整性 |
hooks/userpromptsubmit/large-prompt |
动态报告 prompt 字符数,返回 "Prompt size: N" |
测试大体积 prompt 处理 |
hooks/userpromptsubmit/special-chars |
检查 prompt 是否同时包含 @、#、$,返回 "Special chars preserved" 或 "Missing special chars" |
测试特殊字符是否被完整透传 |
hooks/userpromptsubmit/empty-prompt |
对 undefined 或空 prompt 安全处理,返回 "Prompt length: 0" |
测试空 prompt 边界 |
hooks/userpromptsubmit/malformed-json |
直接输出非法 JSON 字符串 not valid json |
测试畸形 JSON 的错误处理 |
hooks/userpromptsubmit/error |
stderr 打印错误并以退出码 1 结束 | 测试 UserPromptSubmit 的错误处理 |
例如 special-chars 夹具用一次断言同时覆盖三类特殊字符的序列化保真度:
const prompt = input.userPromptSubmit.prompt;
const hasSpecialChars = prompt.includes("@") && prompt.includes("#") && prompt.includes("$");
console.log(JSON.stringify({
cancel: false,
contextModification: hasSpecialChars ? "Special chars preserved" : "Missing special chars",
errorMessage: ""
}));
TaskStart 夹具
| 夹具路径 | 行为 | 适用场景 |
|---|---|---|
hooks/taskstart/success |
返回 { cancel: false, contextModification: "TaskStart hook executed successfully", ... } |
任务正常放行 |
hooks/taskstart/blocking |
返回 { cancel: true, contextModification: "", errorMessage: "Task execution blocked by hook" } |
测试任务启动阶段的策略拦截 |
hooks/taskstart/error |
stderr 打印错误并以退出码 1 结束 | 测试 TaskStart 错误处理 |
输出协议的一个演进细节
所有正常路径夹具统一输出 { cancel, contextModification, errorMessage } 三字段 JSON。需要注意,仓库中的 HookName 模板脚本 及模板说明里仍出现旧字段 shouldContinue,而当前运行器源码 hook-factory.ts 中的 validateHookOutput 会显式拒绝含 shouldContinue 的输出,并提示“已移除该字段,请使用 cancel: true 触发取消”。因此新编写或修改 fixture 时应以 README 中列出的 cancel 三字段协议为准,把模板中残留的 shouldContinue 视为历史遗留。
在测试中使用 fixture:test-utils 工具链
fixtures 的价值取决于加载机制。测试工具集 test-utils.ts 提供了从环境创建、fixture 加载到断言的完整工具链。
1. createHookTestEnv():隔离环境
createHookTestEnv() 是测试的起点。它做四件事(见 test-utils.ts#L85-L115):
- 若
HostProvider未初始化则注入 VSCode host 的 mock(hook 执行会触达遥测路径); - 在系统临时目录创建一次性
tempDir(hook-test-*),并在其下建立<tempDir>/.clinerules/hooks目录结构——这正是生产环境中 workspace 级 hook 的存放位置; - 用
stubWorkspacePaths把“workspace 根目录”指向 tempDir,并用spyOn替换disk模块的getAllHooksDirs,使 Hook 发现逻辑只扫描测试目录; - 重置全局的
HookDiscoveryCache,并通过cleanup()还原 stub、重置缓存、删除临时目录(Windows 下对EBUSY文件锁做了带重试的清理,最多 5 次)。
返回的 HookTestEnv 形如:
export type HookTestEnv = {
tempDir: string
hooksDir: string
sandbox: sinon.SinonSandbox
cleanup: () => Promise<void>
}
2. loadFixture():把夹具复制进隔离环境
loadFixture(fixtureName, destDir) 将夹具目录下的所有脚本复制到 <destDir>/.clinerules/hooks/。其跨平台行为(见 test-utils.ts#L591-L613):
- 非 Windows:直接
copyFile,并用源文件的stats.mode恢复可执行权限位; - Windows:读取源脚本内容后改走
writeHookScriptForPlatform,拆写成<HookName>.js(Node 实现)+<HookName>.ps1(PowerShell 桥接脚本)两个文件,模拟生产运行时“Windows hook 经由 PowerShell 执行”的行为。
原文档给出的最小用法即基于这两个函数:
import { createHookTestEnv, loadFixture } from '../test-utils'
it("should work with real hook", async () => {
const env = await createHookTestEnv()
try {
await loadFixture("hooks/pretooluse/success", env.tempDir)
const factory = new HookFactory()
const runner = await factory.create("PreToolUse")
const result = await runner.run(buildPreToolUseInput({ toolName: "test_tool" }))
result.cancel.should.be.false()
} finally {
await env.cleanup()
}
})
实际测试(如 taskstart.test.ts)普遍采用更上层的 withFixtureRunner(hookName, fixtureName, callback) 包装:它内部依次执行“删除旧 hooks 目录 → 重建 → 重置发现缓存 → 加载 fixture → 创建 runner → 执行回调 → 兜底清理”,保证多个场景之间不共享 hook 目录、缓存状态或文件系统残留(test-utils.ts#L623-L659)。
3. 输入构造与输出断言
buildPreToolUseInput({ toolName, parameters, taskId })/buildPostToolUseInput(...):按 hook-factory.ts 中NamedHookInput的类型约束构造完整输入;assertHookOutput(actual, expected):对cancel/contextModification/errorMessage三个字段做局部匹配断言,失败时打印期望值与实际值,便于定位序列化问题;MockHookRunner:不 spawn 进程、记录每次调用输入并返回预设HookOutput的快速集成测试替身,配合assertCalled(n)/assertCalledWith(matcher)使用——即“验证调度逻辑用 Mock,验证真实进程通信用 fixture”的分层策略。
底层执行链路:fixture 到底测的是什么
fixture 测试并非孤立脚本,它验证的是 hook-factory.ts 中真实的执行链路。理解这条链路才能解释各 fixture 场景为何必须存在。
发现与创建:HookFactory
new HookFactory().create(hookName)(或其带流式回调的 createWithStreaming 版本)负责:
- 解析本窗口的 workspace 根目录集合(必要时并入会话级
sessionWorkspaceRoot的.clinerules/hooks); - 通过
HookDiscoveryCache做 O(1) 的脚本发现; - 按发现结果返回三种 runner 之一:无脚本 →
NoOpRunner(空对象模式,run()恒返回cancel: false,保证 hook 是可选/优雅降级的);单脚本 →StdioHookRunner;多脚本(多根工作区同时存在全局与工作区 hook)→CombinedHookRunner。
CombinedHookRunner 的合并语义值得注意(hook-factory.ts#L672-L706):所有 hook 并行执行;任一 hook 返回 cancel: true 则合并结果为取消;所有 contextModification 以双换行拼接、errorMessage 以单换行拼接。blocking 类 fixture 正是用来验证这种“一票否决”语义的。
执行:StdioHookRunner 的四道防线
StdioHookRunner(hook-factory.ts#L291-L652)把输入序列化为 JSON 经 stdin 送入子进程,其关键约束解释了各 error/malformed 类 fixture 的存在意义:
| 机制 | 常量/行为 | 对应 fixture 场景 |
|---|---|---|
| 执行超时 | HOOK_EXECUTION_TIMEOUT_MS = 30000(30 秒),超时抛出 HookExecutionError.timeout |
长时 hook(taskresume 的 long-pause 等场景关注暂停时长语义) |
| 上下文体积上限 | MAX_CONTEXT_MODIFICATION_SIZE = 50000(约 50KB),超出部分截断并追加 [... context truncated due to size limit ...] 标记 |
large-prompt 等大体积场景 |
| 输出结构校验 | validateHookOutput 强制 cancel 为布尔、contextModification/errorMessage 为字符串,并拒绝已废弃的 shouldContinue |
所有 fixture 的三字段输出协议 |
| JSON 兜底提取 | stdout 整体解析失败时,从输出末尾按花括号配平反向扫描,提取“最后一个完整 JSON 对象”——容忍 hook 在正式响应前打印调试输出 | malformed-json(无合法 JSON 时应 fail-open 而非崩溃) |
| 失败降级(fail-open) | 退出码 0 但无 JSON → 视为成功放行(cancel: false)并记录告警;非零退出码且无合法 JSON → 抛出 HookExecutionError.execution |
全部 error 类 fixture |
另外,UserPromptSubmit 的序列化有特殊处理:Proto3 的 toJSON() 默认省略空字符串字段,运行器会手动补回 prompt: ""(hook-factory.ts#L333-L338),这正是 empty-prompt fixture 存在的原因——hook 脚本必须始终收到 {"prompt": ""} 而不是 {}。
输入协议:hook 脚本能收到什么
HookRunner.completeParams 会自动为每个 hook 输入补齐公共元数据(hook-factory.ts#L213-L230):clineVersion、hookName、timestamp、workspaceRoots、userId、taskId、model.{provider,slug},再叠加 hook 专属数据(如 preToolUse、userPromptSubmit)。模板脚本中解构的正是这些字段:
const { hookName: hookType, timestamp, taskId, workspaceRoots, userId } = input;
测试中(如 user-prompt-submit.test.ts#L120-L146)会写一个临时 hook 脚本逐项断言这些公共字段齐全,从而锁定输入协议不回归。
跨平台差异:Unix 可执行文件 vs PowerShell 桥
原文档的 Platform Considerations 一节概括为“Linux/macOS 直接执行可执行 hook 文件(shebang/可执行位);Windows 经 PowerShell 执行,测试中可使用一个把 stdin 管道转给 Node 伴生脚本的小桥接脚本”。源码中对此有更完整的解释(test-utils.ts#L230-L249):
- 发现规则:Windows 上
HookFactory.findWindowsHook只识别<HookName>.ps1,有意忽略无扩展名脚本——支持的契约是“每种 hook 类型一个显式 PowerShell 脚本”(hook-factory.ts#L988-L997); - 桥接实现:
buildPowerShellNodeBridge生成的 ps1 不再使用[Console]::In.ReadToEnd()+$inputData | & node管道,而是直接& '<node>' $scriptPath启动子进程并透传父进程的 stdin/stdout/stderr。源码注释说明了原因:旧管道方案在 Windows CI 上偶发“脚本未读完 stdin 就退出时,PowerShell 的管道写入与子进程退出发生竞态”,导致桥接以退出码 1 终止并吞掉脚本 stdout,测试因此 flaky; - Node 可执行文件选择:
resolveNodeExecutableForBridge优先用Bun.which("node")解析真实 node 而非process.execPath(bun.exe),因为 bun 在 Windows 上偶发在console.log后脚本立即退出时丢弃管道 stdout。
测试侧的 hookFileName/hookPath 辅助函数也据此切换文件名(PreToolUse vs PreToolUse.ps1),配合 withPlatform("win32", fn)(临时改写 process.platform)即可在同一套 CI 上验证两个平台的行为。
创建新 fixture 的规范流程
原文档给出的四步流程(结合 template/README.md 的最佳实践):
- 在对应 hook 类型目录下新建场景目录(场景名即用途:
success/blocking/context-injection/error或描述性名称); - 从 template/HookName 复制或按原文档示例手写脚本,首行必须为
#!/usr/bin/env node; - 赋予可执行权限
chmod +x; - 更新
fixtures/README.md的清单(路径、返回值、用途、特殊行为注记),并删除废弃 fixture 时同步更新引用。
原文档的完整示例:
# 创建目录
mkdir -p src/core/hooks/__tests__/fixtures/hooks/pretooluse/my-new-scenario
# 创建 hook 脚本
cat > src/core/hooks/__tests__/fixtures/hooks/pretooluse/my-new-scenario/PreToolUse << 'EOF'
#!/usr/bin/env node
const input = JSON.parse(require('fs').readFileSync(0, 'utf-8'));
console.log(JSON.stringify({
cancel: false,
contextModification: "My custom context",
errorMessage: ""
}));
EOF
# 赋予可执行权限
chmod +x src/core/hooks/__tests__/fixtures/hooks/pretooluse/my-new-scenario/PreToolUse
命名与内容约定(来自模板说明):
- 一个 fixture 只测一个场景,逻辑简单、复杂行为用注释说明;
- 上下文注入使用大写类型前缀(如
WORKSPACE_RULES:、FILE_OPERATIONS:、TASK_CONTEXT:),仓库现存 fixture 均遵循此约定; - 编写可移植 Node.js 代码,避免平台特定逻辑(Windows 差异由
loadFixture/writeHookScriptForPlatform在测试加载期自动处理); - 错误分支中应自行兜底(模板的 catch 块以
HOOK_ERROR:前缀输出cancel: true的合法 JSON,而非裸抛异常)。
维护约定小结
fixtures/README.md 的 Maintenance 一节与模板 README 共同构成该目录的维护契约:
- 保持 fixture 简单且单场景聚焦;
- 所有 fixture 均为跨平台 Node.js 脚本,通过“嵌入式 shebang 执行”(类 git hooks)运行;
- 新增 fixture 必须同步更新 README 清单;
- 移除废弃 fixture 时同步清理测试中的引用。
从仓库现状看,这套约定是持续执行的:fixtures 目录已与 hook-factory.test.ts、taskstart.test.ts、taskresume.test.ts、user-prompt-submit.test.ts 等 11 个测试文件形成配套,每个新 Hook 类型(TaskResume、TaskComplete、TaskCancel)落地时都补充了对应夹具与场景化断言。对需要扩展 Hook 行为或修复回归的开发来说,这套“夹具 + 隔离环境 + 真实进程执行”的测试模式本身就是可直接照抄的参考实现。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00