首页
/ Cline Hooks 测试夹具体系:从 fixtures 目录理解 Hook 系统的可测试设计

Cline Hooks 测试夹具体系:从 fixtures 目录理解 Hook 系统的可测试设计

2026-09-06 14:19:32作者:吴年前Myrtle

本文基于 Cline VSCode 扩展中 apps/vscode/src/core/hooks/__tests__/fixtures/README.md 这份夹具目录说明文档展开,介绍 Cline Hook 系统的预置测试脚本(fixtures)如何按 Hook 类型组织、如何在测试中加载执行、底层运行链路(超时、输出解析、失败降级)如何工作,以及跨平台(Linux/macOS 与 Windows)执行差异的处理方式。读完本文,你可以直接复用这套夹具机制为任意 Hook 编写隔离、可清理、跨平台的测试用例,并新增符合规范的新 fixture。

fixtures 目录的定位与结构

Cline 的 Hook 机制允许用户在工具调用前后、任务生命周期节点注入自定义脚本(例如 PreToolUsePostToolUseUserPromptSubmitTaskStart 等),脚本通过 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 中列出的更多:除 pretooluseposttooluseuserpromptsubmittaskstart 外,还存在 taskresume(含 long-pausemessage-countcontext-deleted 等场景)、taskcompletetaskcancel(按 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):

  1. HostProvider 未初始化则注入 VSCode host 的 mock(hook 执行会触达遥测路径);
  2. 在系统临时目录创建一次性 tempDirhook-test-*),并在其下建立 <tempDir>/.clinerules/hooks 目录结构——这正是生产环境中 workspace 级 hook 的存放位置;
  3. stubWorkspacePaths 把“workspace 根目录”指向 tempDir,并用 spyOn 替换 disk 模块的 getAllHooksDirs,使 Hook 发现逻辑只扫描测试目录;
  4. 重置全局的 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.tsNamedHookInput 的类型约束构造完整输入;
  • 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 版本)负责:

  1. 解析本窗口的 workspace 根目录集合(必要时并入会话级 sessionWorkspaceRoot.clinerules/hooks);
  2. 通过 HookDiscoveryCache 做 O(1) 的脚本发现;
  3. 按发现结果返回三种 runner 之一:无脚本 → NoOpRunner(空对象模式,run() 恒返回 cancel: false,保证 hook 是可选/优雅降级的);单脚本 → StdioHookRunner;多脚本(多根工作区同时存在全局与工作区 hook)→ CombinedHookRunner

CombinedHookRunner 的合并语义值得注意(hook-factory.ts#L672-L706):所有 hook 并行执行任一 hook 返回 cancel: true 则合并结果为取消;所有 contextModification 以双换行拼接、errorMessage 以单换行拼接。blocking 类 fixture 正是用来验证这种“一票否决”语义的。

执行:StdioHookRunner 的四道防线

StdioHookRunnerhook-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):clineVersionhookNametimestampworkspaceRootsuserIdtaskIdmodel.{provider,slug},再叠加 hook 专属数据(如 preToolUseuserPromptSubmit)。模板脚本中解构的正是这些字段:

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 的最佳实践):

  1. 在对应 hook 类型目录下新建场景目录(场景名即用途:success / blocking / context-injection / error 或描述性名称);
  2. template/HookName 复制或按原文档示例手写脚本,首行必须为 #!/usr/bin/env node
  3. 赋予可执行权限 chmod +x
  4. 更新 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.tstaskstart.test.tstaskresume.test.tsuser-prompt-submit.test.ts 等 11 个测试文件形成配套,每个新 Hook 类型(TaskResume、TaskComplete、TaskCancel)落地时都补充了对应夹具与场景化断言。对需要扩展 Hook 行为或修复回归的开发来说,这套“夹具 + 隔离环境 + 真实进程执行”的测试模式本身就是可直接照抄的参考实现。

登录后查看全文
热门项目推荐
相关项目推荐