opencode 测试套件提速实践:从全量基准到按文件剖析的假设驱动优化方法
perf/test-suite.md 记录了 opencode 仓库针对 packages/opencode 测试套件的一次完整提速研究:如何在不降低覆盖率、不掩盖失败的前提下缩短全量测试时间。本文基于该文档并结合仓库源码,完整还原其基准命令、指标体系、34 项已保留的优化决策与 5 个被放弃的尝试,并深入剖析配套的基准脚本(bench-test-suite.ts、profile-test-files.ts)和测试夹具基础设施(test/lib/effect.ts),读完你可以掌握一套“单跑中位数基准 + 按文件剖析 + 假设循环验证”的测试提速方法论,并理解其中每项优化背后的源码依据。
一、优化目标与边界
文档开宗明义地给出了唯一目标与硬约束:
Speed up the
packages/opencodetest suite without reducing coverage or hiding failures.
即:只优化耗时,不允许通过删测试、放宽断言或降低隔离强度来“赢得”时间。围绕这一约束,文档划定了文件范围(Files In Scope):
packages/opencode/test/**下的测试文件;- 测试 fixture(夹具);
- package 中的测试脚本(
packages/opencode/package.json的scripts); - 仅当基准剖析明确指向实现层时,才允许触碰实现代码的 setup 路径。
以及五类需要重点观察的信号(Signals To Watch):
- 重复执行的 setup 工作;
- 长 sleep / 长超时等待;
- 串行运行的集成测试;
- 文件系统/数据库 fixture 的构建成本;
- 宽泛的 test glob 把无关工作拉进当前运行。
后续全部 34 项保留优化与 5 个 Dead End,本质上都是对这五类信号的逐个击破。
二、基准工具链:两个脚本、六个环境变量
提速研究的工具链由 packages/opencode/package.json 中注册的两个 npm script 组成:
| 脚本 | 作用 |
|---|---|
bun run bench:test |
全量套件计时,输出主/次指标 |
bun run profile:test |
逐文件剖析,定位慢文件 |
2.1 全量基准:bun run bench:test
在 packages/opencode 目录下执行:
bun run bench:test
全量基准默认只测量一次。文档特别强调“重复运行只用在拿到定向收益之后”:
BENCH_WARMUPS=1 BENCH_RUNS=3 bun run bench:test
其实现 bench-test-suite.ts 只有 52 行,逻辑与文档完全对应:
BENCH_WARMUPS(默认0,必须是非负整数)、BENCH_RUNS(默认1,必须是正整数),非法值直接退出(L4-L15);- 每次运行通过
Bun.spawn拉起bun test --timeout 30000(即与test脚本相同的超时参数),逐次打印bench:test run i/N x.xxxs(L17-L39); - 结束前计算并打印中位数、均值、最好、最坏四档数据,随后输出机器可解析的
METRIC行(L41-L52)。
2.2 按文件剖析:bun run profile:test
定位慢文件:
bun run profile:test
探索期通过环境变量缩小范围:
TEST_PROFILE_GLOB='test/server/**/*.test.ts' bun run profile:test
TEST_PROFILE_LIMIT=20 bun run profile:test
实现 profile-test-files.ts 支持四个环境变量(L4-L9):
| 环境变量 | 默认值 | 说明 |
|---|---|---|
TEST_PROFILE_GLOB |
test/**/*.test.{ts,tsx} |
扫描 glob,相对 packages/opencode 解析 |
TEST_PROFILE_LIMIT |
0(不限) |
截断到前 N 个文件 |
TEST_PROFILE_TIMEOUT |
30000 |
传给每个 bun test 的 --timeout |
TEST_PROFILE_TOP |
20 |
最终“最慢文件榜”显示条数 |
它的工作方式是串行逐个文件 spawn bun test --timeout <timeout> <file>,逐行打印 PASS/FAIL 耗时 文件,任何文件失败都会让进程以退出码 1 结束(L12-L42)。文档中说明这样做的原因:该版本的 Bun 没有 slowest-test reporter,只能直接按文件剖析(见假设循环第 1 行的 Notes)。
三、指标体系:为什么主指标是中位数
| 指标 | 含义 | 来源 |
|---|---|---|
test_suite_seconds(主指标) |
全量套件运行的中位数 wall-clock 秒数 | 脚本 METRIC test_suite_seconds= 行 |
test_suite_best_seconds / test_suite_worst_seconds |
多次运行中的最好/最坏 | 同上 |
| failures / noisy spread | 失败数与噪声波动幅度 | 人工判读 |
slowest_test_file_seconds |
剖析模式下最慢单文件耗时 | profile 脚本 METRIC 行 |
| 最慢文件列表 | Top N 慢文件及其 PASS/FAIL 状态 | profile 脚本打印 |
选中位数而非均值/单次值,是因为测试耗时受冷缓存、CI 负载、机器状态影响大——文档中多次出现“median from 3/5 targeted runs because ... noisy”的表述,且“Profiling Results”一节明确记录了同一文件初始 23s 与定向 3 跑 10s 的巨大差异,证明单次全量跑的数据不可直接用于归因。
四、假设循环(Hypothesis Loop):34 项已保留的优化全记录
文档的核心资产是一张假设循环表:每条记录包含假设、改动、前后耗时、决策(keep/discard)与备注。以下按主题完整重组全部 34 项 keep 决策(数据与备注逐条对应原文,未做删减)。
4.1 方法与基线
| # | 假设 | 改动 | 前 | 后 | 备注 |
|---|---|---|---|---|---|
| 1 | 重复全量跑对“发现期”太昂贵 | 全量基准改为单跑,并新增按文件剖析器 | ~250s/run | pending | keep:该版本 Bun 无 slowest-test reporter,改为直接剖析文件 |
4.2 移除不必要的 git fixture(7 项)
多个测试只断言 shell/config/session 行为,却为每个临时目录初始化 git 仓库。统一做法是把 git: true 从临时目录 fixture 中去掉,同时保留 config setup 等真实依赖:
| # | 假设 | 改动 | 前 | 后 | 备注 |
|---|---|---|---|---|---|
| 2 | httpapi-listen PTY 路由测试为它并不断言的 git 仓库付费 |
临时目录去掉 git: true,保留 config setup |
10.554s | 7.818s | 3 次定向跑中位数;HTTP 路由、tickets、websocket 升级、重启、无鉴权路径仍通过 |
| 3 | SDK parity 助手为“只需要文件/config/session 状态”的测试创建 git 仓库 | withProject 默认改为无 git;显式的 git init 测试显式选择 no-git fixture |
8.011s | 5.180s | 5 次中位数(首跑冷/有噪声) |
| 4 | Skill 工具测试只读本地 skill 文件却初始化了 git | 临时目录 fixture 去掉 git: true |
2.320s | 1.425s | 单次定向复跑;仍覆盖 skill 发现、权限请求、bundle 文件输出 |
| 5 | Prompt shell 语义测试只断言 shell/session 行为却初始化 git | shell 类 prompt fixture 去掉 git: true,保留 config setup |
26.930s | 23.400s | 改动后 3 次复跑通过:23.80s / 23.55s / 23.40s |
| 6 | 其余 prompt 行为测试基本不需要仓库状态 | safe loop / reference / error fixture 去掉 git;恢复 shell queue/cancel 用例 | 23.400s | 19.610s | 安全评审发现 shell runner 就绪状态在若干测试中依赖 git-backed setup;当前单次复跑通过 |
| 7 | Session processor effect 测试不需要仓库状态 | 所有 processor-effect 临时 server fixture 去掉 git | 12.500s | 9.230s | 2 次复跑通过:9.61s / 9.23s |
| 8 | Processor AI SDK tool-call 用例不断言 git 行为 | 非原生 tool-call processor 测试去掉 git: true |
10.22s | 9.48s | 单基线;全文件 3 次中位数 9.48 / 9.60 / 9.36;聚焦用例 1.39s |
4.3 缩短等待与超时(7 项)
针对“长 sleep / 生产超时被测试全额等待”这一信号,原则是:生产默认值保持不变,测试注入短超时或用既有 fixture 消除空等:
| # | 假设 | 改动 | 前 | 后 | 备注 |
|---|---|---|---|---|---|
| 9 | 插件安装并发测试启动了比“验证锁竞争”所需更多的 worker | worker 数从 12/10/8 降到 6/6/5;保留 holdMs: 30 |
7.800s | 6.204s | 3 次中位数;仍覆盖对 server、server+tui、既有 json config 的跨进程并发写 |
| 10 | workspace.waitForSync 超时测试等满了生产超时 |
新增可选 timeout 参数,默认值仍为生产超时;超时测试用 25ms | 12.949s | 8.305s | 3 次中位数;生产调用方保持 5000ms 默认 |
| 11 | .gitignore 是同步写入的,config.test 在依赖就绪后仍在空等 |
去掉可写 OPENCODE_CONFIG_DIR 测试中废弃的 1000ms sleep |
10.270s | 9.433s | 5 次中位数(一次有噪声);更简单的测试、无固定 sleep |
| 12 | Provider 插件过滤测试在等“插件依赖就绪”的 setup | 用既有 fixture 助手把本地插件依赖标记为 ready | 7.543s | 6.366s | 3 次中位数;与相邻 plugin provider 测试的 setup 方式对齐 |
| 13 | HTTP provider 测试生成本地插件但没有 dependency-ready 的 fixture 状态 | 把生成的 .opencode 插件 fixture 标记为 dependency-ready |
7.905s | 2.980s | 3 次中位数;避免路由测试里做无关的插件依赖 setup |
| 14 | TUI 插件生命周期超时覆盖等满了生产清理超时 | 新增可选 runtime dispose 超时覆写,超时测试用 25ms | 7.330s | 1.507s | 3 次中位数;生产默认仍为 5000ms |
| 15 | 文件 watcher 就绪判定可能在异步原生订阅激活前就写入 | 对短的就绪写入做重试,并接受 symlink-realpath 的 HEAD 事件 | failed | 4.62s | 3 次顺序聚焦跑通过:4.62 / 4.57 / 4.64s;全量套件不再在 watcher.test.ts 失败 |
其中两条超时改动有明确的源码依据:
waitForSync的默认超时定义在 src/control-plane/workspace.ts,timeout = TIMEOUT且const TIMEOUT = 5000(L890),与文档“生产调用方保持 5000ms 默认”一致;- TUI 插件 runtime 的清理超时
DISPOSE_TIMEOUT_MS = 5000定义在 src/plugin/tui/runtime.ts。
4.4 用例归并与并发化(2 项)
| # | 假设 | 改动 | 前 | 后 | 备注 |
|---|---|---|---|---|---|
| 16 | HTTP listen PTY ticket 测试把同一监听拓扑重启了两遍 | 将目录级 ticket 回归折叠进更广的 unsafe-ticket 测试 | 7.051s | 6.170s | 2 次复跑通过:6.76s / 6.17s;仍覆盖 mint 失败与同目录升级成功 |
| 17 | CLI run 子进程用例彼此独立,可并发 | run-process.test.ts 子进程用例标记为 concurrent |
11.87s | 4.13s | 最新 dev 单基线;3 次中位数 4.13 / 4.17 / 4.11s;每个用例有独立临时 home 与 LLM 端口 |
4.5 迁移到 it.instance:Effect 感知实例夹具(14 项)
这一组是最大的主题:把手工 tmpdir + withTestInstance 的样板代码逐步迁移到测试框架自带的 it.instance。从 test/lib/effect.ts 看,it.instance 会把测试体包进 withTmpdirInstance(options) 再挂到 live layer 上运行,options 支持 git / config / init 三个字段(L13-L23)——即临时目录创建、git 开关、配置文件注入全部由夹具统一承担,测试代码里不再手写文件写入与实例生命周期管理。文档把这个迁移定为后续 provider 切片的标准模式(第 20 项 Notes:“use as the pattern for later provider slices”)。
| # | 假设 | 改动 | 前 | 后 | 备注 |
|---|---|---|---|---|---|
| 18 | 首个 provider config/env/filtering 块可用 Effect 感知实例夹具 | 6 个 tmpdir + withTestInstance 用例迁到 it.instance |
6.06s | 6.07s | 计时中性,但消除了手工配置文件写入与实例管道;作为后续 provider 切片的模式 |
| 19 | 自定义 provider/model 配置用例同样适用 | 再迁移 3 个配置密集 provider 用例 | 6.07s | 6.12s | 噪声内中性;在首个 provider fixture 之上继续消除手工配置写入 |
| 20 | provider env 优先级与 model 查找用例同样适用 | 再迁移 4 个 provider 查找/默认模型用例 | 6.12s | 6.36s | 5 跑中位数有噪声;作为小切片保留,不宣称提速 |
| 21 | 简单 config 加载用例同样适用 | JSON、shell、formatter、lsp 配置加载用例迁到 it.instance |
14.18s | 3.93s | 前后均为 3 跑中位数;消除首个简单 config 块的手工 tmpdir + withTestInstance |
| 22 | config 模板、file include 与简单 agent 用例同样适用 | JSONC、env/file 替换、非法 config、agent config 用例迁移 | 1.87s | 1.90s | 栈式叠加在首个 config 切片上;中性计时,继续消除手工管道 |
| 23 | agent 选项、command 与 legacy 迁移 config 用例同样适用 | agent 变体、command、autoshare、mode 迁移用例迁移 | 1.90s | 1.83s | 小幅中性偏正;手工 setup 更少 |
| 24 | 本地 config update 与 directories 用例同样适用 | 本地 update、directories 用例迁移 |
1.77s | 1.71s | 3 跑中位数;顺带消除了一处既有的不安全类型断言(unsafe cast) |
| 25 | .opencode agent/command 文件加载用例同样适用 |
单/复数 agent 与 command 的 markdown fixture 用例迁移 | 7.21s | 1.87s | 父基线有噪声(7.42 / 7.21 / 2.83);改动后稳定在 1.87 / 1.98 / 1.83;作为清理保留,不做宽泛声明 |
| 26 | legacy tools 与 permission-order config 用例同样适用 | legacy tools 迁移与 permission order 用例迁移 |
1.87s | 1.87s | 中性计时;继续减少 legacy config 迁移覆盖的手工临时实例管道 |
| 27 | 剩余简单 config 加载用例同样适用 | 默认 config 加载与 legacy TUI-key 用例迁移 | 7.78s | 6.39s | 改动前单基线;改动后 3 次顺序中位数 5.76 / 6.39 / 6.53;谨慎保留计时结论 |
| 28 | 托管设置(managed settings)config 用例同样适用 | managed 覆写与 managed 文件缺失用例迁移 | 2.40s | 1.76s | 改动前单基线;改动后 1.75 / 1.76 / 1.80 |
| 29 | 本地插件与 subagent config fixture 同样适用 | scoped npm 插件与自定义 subagent markdown 用例迁移 | 2.37s | 1.67s | 改动前单基线;改动后 1.66 / 1.67 / 1.67 |
| 30 | MCP 合并 config 用例同样适用 | 3 个 MCP merge/override 用例迁移 | 1.98s | 1.95s | 噪声内中性;消除隔离文件系统用例的手工 tmpdir + withTestInstance |
| 31 | 剩余 legacy tools config 用例同样适用 | allow/deny 的 legacy tools 权限用例迁移 |
2.65s | 1.90s | 改动前单基线;改动后 2.58 / 1.90 / 1.90 |
4.6 夹具瘦身与 Runner 调整(3 项)
| # | 假设 | 改动 | 前 | 后 | 备注 |
|---|---|---|---|---|---|
| 32 | 超大规模 snapshot 批处理测试只需跨过 100 文件边界 | 缩小大 diff/revert fixture 体积,但保证每个用例仍越过 batch 边界 | 4.32s | 3.66s | 涉及 3 个 snapshot 测试;3 次中位数 4.32 / 3.66 / 3.66 |
| 33 | 不需要调用 LLM 的 prompt 测试不必启动测试 LLM server | 新增 no-server runner,把明显的非 LLM prompt/shell 用例迁过去 | 25.41s | 21.03s | 整文件 3 次中位数 20.66 / 21.03 / 21.64;LLM 类测试留在原 runner |
| 34 | snapshot 初始化不需要在源仓库里提交种子文件 | 从 snapshot 测试 initialize() 助手里去掉多余的 git add/commit |
22.22s | 20.23s | 最新 dev 单基线;3 次中位数 20.23 / 22.59 / 20.11;fixture 仍会创建 git 仓库根提交 |
五、剖析结果:慢文件榜单与全量 sanity check
5.1 发现期的初始慢文件
剖析命令形态:
TEST_PROFILE_GLOB='test/<area>/**/*.test.ts' TEST_PROFILE_TOP=15 bun run profile:test
发现期观测到的最慢文件(文档明确注明:这是历史剖析输入,不是保留改动后的当前排名):
| 文件 | 秒数 | 范围 |
|---|---|---|
test/config/config.test.ts |
23.546 | config |
test/provider/provider.test.ts |
18.747 | provider |
test/control-plane/workspace.test.ts |
16.447 | control-plane |
test/plugin/install-concurrency.test.ts |
14.804 | plugin |
test/server/httpapi-cors.test.ts |
14.620 | server |
test/server/httpapi-listen.test.ts |
10.073 | server |
test/server/httpapi-sdk.test.ts |
8.661 | server |
test/server/httpapi-provider.test.ts |
7.905 | server |
test/cli/tui/plugin-lifecycle.test.ts |
7.330 | cli/tui |
test/file/index.test.ts |
7.214 | file |
(上表路径均相对 packages/opencode/。)
5.2 定向 3 跑基线
对候选目标做 3 次定向基准,区分“真慢”与“混合范围噪声/顺序效应”:
| 文件 | 三次运行 | 中位数 | 备注 |
|---|---|---|---|
test/control-plane/workspace.test.ts |
12.949, 12.949, 12.773 | 12.949 | 稳定的慢目标 |
test/server/httpapi-listen.test.ts |
10.554, 10.631, 10.479 | 10.554 | 稳定慢目标;WebSocket/listener 生命周期 |
test/config/config.test.ts |
10.270, 9.042, 10.737 | 10.270 | 大串行文件;初始 23s 是混合范围竞争/噪声 |
test/server/httpapi-sdk.test.ts |
7.600, 8.011, 8.035 | 8.011 | 稳定慢目标 |
test/plugin/install-concurrency.test.ts |
7.949, 7.800, 7.712 | 7.800 | 稳定慢目标;大量子进程 |
test/provider/provider.test.ts |
8.323, 7.543, 7.474 | 7.543 | 大串行文件 |
test/server/httpapi-cors.test.ts |
2.621, 1.682, 1.518 | 1.682 | 并非独立头部目标;初始 14s 是混合范围噪声/顺序效应 |
这个基线本身就是一个方法论样本:httpapi-cors.test.ts 在混合范围下高达 14.6s,定向 3 跑后中位数只有 1.68s,因此没有成为优化对象——避免了针对噪声的无效改动。
5.3 全量套件 sanity check
每积累一批定向收益后,用 bun run bench:test 做全量回归:
| 命令 | 结果 | 备注 |
|---|---|---|
bun run bench:test |
225.069s | 继续 prompt/session 工作前 |
bun run bench:test |
186.729s | prompt、processor、PTY 收益之后,安全评审恢复之前 |
bun run bench:test |
202.317s | 恢复 prompt shell 覆盖与 SDK VCS parity 覆盖之后 |
bun run bench:test |
failed | watcher 阻塞点已清除;该次运行后续在单跑通过的 tool/skill.test.ts 与 prompt shell 超时用例上失败(全量负载下) |
值得注意:第 2 → 3 行耗时从 186.7s 回升到 202.3s,是因为把安全评审判定为“依赖 git-backed setup”的用例恢复了覆盖——用耗时换回覆盖率,恰好兑现了开头“不降低覆盖”的承诺。最后一行则说明全量负载下会出现单跑不暴露的不稳定,这也是文档保留该记录的价值之一。
六、Dead Ends:5 个被放弃的尝试
与保留决策同等重要的是失败记录。原文完整保留的 Dead Ends 如下(decision 均为 discard):
| 假设 | 尝试的改动 | 前 | 后 | 备注 |
|---|---|---|---|---|
file/index.test.ts 存在不必要的逐测试全局实例清理 |
移除 afterEach(disposeAllInstances),保留显式 disposal 测试导入 |
5.262s | 5.089s | 改善在噪声范围内,且该清理是很多实例状态测试的安全护栏 |
| Socket reset 重试测试可以缩短其空闲超时路径 | 调小 Bun server 空闲超时并尝试强制关闭 server | 16.46s | failed | 更短的空闲超时改变了错误形态;强制关闭直接挂起。保留真实的 socket reset |
tool/webfetch 可以避免逐测试实例 setup |
本地 HTTP 测试从 it.instance 切到 it.live |
1.219s | failed | 工具执行读取实例本地的 agent 状态,临时实例是必需的 |
| LSP client 互操作测试可以缩短粗粒度的请求处理 sleep | 固定通知后等待从 100ms 降到 10ms | 4.270s | 4.740s | 首跑曾降到 3.870s,但验证轮比基线更慢;不构成明确收益 |
| Config 内容 env 用例可用 Effect 感知实例夹具 | 两个 OPENCODE_CONFIG_CONTENT token 替换用例迁到 it.instance |
1.95s | 2.06s | 通过但定向复跑不优于基线;保留既有显式 env 清理 |
(disposeAllInstances 对应 test/fixture/fixture.ts 中暴露的 disposeAllInstances / disposeAllInstancesEffect。)
七、方法论提炼
把整篇研究文档抽象出来,这套提速方法有五个可复用的要点:
- 两级工具,分工明确:全量基准(中位数,单次或多次测量)只用于阶段性 sanity check;发现期用按文件剖析器逐文件计时。脚本层面二者刻意解耦,见 bench-test-suite.ts 与 profile-test-files.ts 文件头注释。
- 中位数 + 多次定向跑对抗噪声:每条 keep 决策都注明基线是“单次”还是“3/5 次中位数”,噪声大的显式声明“不宣称提速”(如 it.instance 迁移的多个中性切片)。
- 先归因再动手:
httpapi-cors的案例说明,混合范围下的高耗时可能只是顺序效应,定向 3 跑基线是进入假设循环前的必要闸门。 - 优化只削“多余成本”,不削覆盖:移除未断言的 git 初始化、缩短测试专属超时(生产默认 5000ms 不动,见 workspace.ts)、把独立用例并发化(每个用例独立临时 home 与端口)。
- 失败也入库:Dead Ends 表与“恢复覆盖后全量耗时回升”“全量负载下偶发失败”的记录,保证了后续研究者不会重复踩坑,也让耗时曲线的每一次回升都有解释。
如果你要在自己的仓库复用这套方法,最小可行配置就是:一个“单跑 + METRIC 行”的全量计时脚本、一个“glob 扫描 + 逐文件 spawn + Top N 榜单”的剖析脚本,以及一张强制填写 before/after/decision/notes 四列的假设循环表——opencode 的这份 perf/test-suite.md 就是这三者协作产出的完整范本。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00