opencode 服务端测试指南:基于 Effect 的 HttpApi 中间件测试模式实战
本篇技术文章以 opencode 仓库中的 服务端测试指南 为核心,系统讲解如何针对 packages/opencode/test/server/ 目录下的 server 与 HttpApi 中间件测试编写聚焦、可复现、且与生产路径一致的测试:从“小型探针路由代替完整 API 路由树”的选题原则,到 testEffect + NodeHttpServer.layerTest 的测试服务器搭建、中间件顺序声明、二级上游服务器构建、WebSocket 转发断言,以及全局可变状态的 Scoped 管理。读完本文,你可以直接套用这些模式,为 opencode 的 HttpApi 路由、代理与中间件策略编写源码级可信的测试用例。
适用场景与总体原则
该指南约束的是 packages/opencode/test/server/ 目录中的两类测试:
- server 测试:针对 opencode 内置 HTTP 服务器(基于 Effect HttpApi 栈)的端到端路由行为;
- HttpApi 中间件测试:针对
packages/opencode/src/server/routes/instance/httpapi/middleware/下的中间件(如实例上下文、工作区路由、代理转发)做隔离验证。
该目录下目前已有 40 余个测试文件,命名上分为几族:httpapi-*.test.ts(中间件与路由契约)、workspace-*.test.ts(工作区代理/路由)、session-*.test.ts(会话端点)、sdk-*.test.ts(SDK 兼容性冒烟),以及一套 httpapi-exercise/ 子目录(路由巡检的 DSL 与报告工具)。指南给出的第一条总原则是:
在测试路由、上下文、代理或中间件策略时,优先使用“小型 fake 路由”的聚焦中间件测试,而不是拉起完整的 API 路由树。
这条原则的核心价值在于:中间件测试关心的是“请求经过中间件后上下文/目的地如何变化”,而不是业务端点本身。用一个 2~3 条探针路由的小 API 组即可精确断言中间件契约,避免被完整路由树的噪音和依赖拖慢测试。
模式一:小型 HttpApiBuilder 探针组暴露被测上下文
指南要求使用“tiny HttpApiBuilder probe groups”,即声明只包含被测中间件的小 API 组,让端点 handler 直接吐出任一需要断言的上下文。仓库中最标准的范例是 工作区路由中间件测试:
const ProbeResult = Schema.Struct({
directory: Schema.String,
workspaceID: Schema.optional(Schema.String),
})
const ProbeApi = HttpApi.make("workspace-routing-probe").add(
HttpApiGroup.make("probe")
.add(
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.patch("patch", "/probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.get("workspace", WorkspacePaths.list, {
query: WorkspaceRoutingQuery,
success: ProbeResult,
}),
)
.middleware(WorkspaceRoutingMiddleware), // 只声明被测的那一个中间件
)
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
handlers
.handle("get", () => routeContextResponse) // handler 只负责把上下文原样返回
.handle("patch", () => Effect.succeed(false))
.handle("session", () => routeContextResponse)
.handle("workspace", () => routeContextResponse),
)
const serveProbe = HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(probeHandlers),
Layer.provide(workspaceRoutingTestLayer),
Layer.provide(Layer.mock(Session.Service)({})),
HttpRouter.serve,
Layer.build,
)
这个探针组的要点:
- Schema 化的响应:
ProbeResult是一个Schema.Struct,探针 handler 把中间件写入的WorkspaceRouteContext(directory、workspaceID)直接序列化为 JSON 返回,测试端即可对“路由上下文最终变成了什么”做精确断言; - 中间件按声明挂到 group 上:
.middleware(WorkspaceRoutingMiddleware)使探针组成为一个独立的最小 HttpApi 服务,其依赖(workspaceRoutingLayer、Socket.layerWebSocketConstructorGlobal等)通过Layer.provide精确注入; - 未使用的依赖用
Layer.mock占位:如Layer.mock(Session.Service)({}),避免拉入真实会话服务。
另一个探针暴露的上下文是 InstanceRef / WorkspaceRef 这类 Effect 服务。见 实例上下文中间件测试:
const probeInstanceContext = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return {
directory: instance?.directory,
worktree: instance?.worktree,
projectID: instance?.project.id,
workspaceID,
}
})
// 探针组按生产顺序声明两级中间件
// .middleware(InstanceContextMiddleware)
// .middleware(WorkspaceRoutingMiddleware)
探针 handler 只读取 InstanceRef、WorkspaceRef 并回显,测试即可断言中间件是否把正确的实例/工作区上下文注入到了请求作用域。
模式二:主测试服务器用 testEffect + NodeHttpServer.layerTest
指南规定:主测试服务器用 testEffect(...) 配合 NodeHttpServer.layerTest 启动,测试客户端对其发起相对路径的 HttpClient 请求。
仓库的测试入口是 test/lib/effect.ts 导出的 testEffect,它把 bun 的 test 包装成 Effect 执行器:
const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer())
const liveEnv = TestConsole.layer
export const it = make<never, never>(testEnv, liveEnv)
export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
make<R, E>(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
关键机制(见 test/lib/effect.ts):
- 每个测试体是一个
Effect,执行时先Effect.scoped再Effect.provide(layer),因此测试作用域内的所有 scoped 资源(监听器、临时目录、finalizer)会在测试结束时自动释放; effect变体叠加TestClock(虚拟时钟),live变体使用真实时钟——需要真实网络/IO 行为的 server 测试一律用it.live(...);- 返回值通过
Effect.exit捕获失败并以Cause.prettyErrors打印,避免 Effect 失败信息不可读的问题。
一个典型的 server 测试文件头部长这样(工作区路由测试):
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() => Effect.promise(async () => { await resetDatabase() }))
}),
)
const it = testEffect(
Layer.mergeAll(
testStateLayer,
NodeHttpServer.layerTest, // 指南要求的主服务器层
NodeServices.layer,
workspaceLayer,
Socket.layerWebSocketConstructorGlobal,
),
)
NodeHttpServer.layerTest 提供的测试监听器绑定在本地端口上;测试中通过 HttpServer.HttpServer 服务取回实际地址(HttpServer.formatAddress(server.address)),再用相对路径的 HttpClientRequest 发请求——例如 HttpClient.get(\/probe?workspace=${workspaceID}`)`,客户端不需要拼写绝对 URL。
当需要测试完整生产路由树(而非探针组)时,复用共享层 httpapi-layer.ts:
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
HttpApiApp.routes,
{ disableListenLog: true, disableLogger: true },
)
export const httpApiLayer = servedRoutes.pipe(
Layer.provide(layerWebSocketConstructorGlobal),
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provideMerge(NodeServices.layer),
)
export function request(path: string, init?: RequestInit) {
const url = new URL(path, "http://localhost")
return HttpClientRequest.fromWeb(new Request(url, init)).pipe(
HttpClientRequest.setUrl(url.pathname), // 转成相对 URL,走测试监听器
HttpClient.execute,
)
}
注意 requestInDirectory(path, directory) 会额外注入 x-opencode-directory 请求头,这正是“目录上下文”类中间件的输入信号。
此外,testEffectShared 是 testEffect 的变体:它通过进程级共享 memoMap(Layer.buildWithMemoMap)构建测试层,使 Bus、Session 等被 memo 的服务与 Server.Default 解析到同一实例——当测试需要向进程内 HTTP 服务器发布事件并依赖 pub/sub 身份一致性时使用;其余测试应默认使用普通 testEffect。
模式三:中间件声明顺序必须与生产一致
指南明确要求:“测试中间件交互时,按生产顺序声明中间件”,例如 InstanceContextMiddleware 之后紧跟 WorkspaceRoutingMiddleware。
这一点在 httpapi-instance-context.test.ts 中得到印证:探针组依次调用
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware),
与生产 HttpApi 的中间件装配顺序完全一致。顺序错误的后果是上下文注入的依赖关系被打破(工作区路由可能依赖实例上下文已建立的 InstanceRef),测试会验证出与生产不同的行为。编写新的多中间件交互测试时,建议先到 packages/opencode/src/server/routes/instance/httpapi/ 下核对生产装配顺序,再在探针组上逐一对齐。
模式四:二级上游服务器用 Layer.build 构建进测试作用域
当被测中间件的职责是代理转发(例如把选中工作区的请求转发到远端 opencode 服务器),需要一个“假上游”。指南给出做法:
对二级上游服务器,用
Layer.build(...)把 Effect 的NodeHttpServer.layer(...)构建进当前测试作用域,使监听器存活到测试作用域退出为止。
工作区路由测试 中的 listenAdditionalServer 是标准实现:
const serverUrl = HttpServer.HttpServer.use((server) =>
Effect.succeed(HttpServer.formatAddress(server.address)))
const listenAdditionalServer = <E, R>(handler: TestHandler<E, R>) =>
Effect.gen(function* () {
const context = yield* Layer.build(
NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
)
const server = Context.get(context, HttpServer.HttpServer)
yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler))
return HttpServer.formatAddress(server.address)
})
要点:
port: 0让操作系统分配空闲端口,返回formatAddress(server.address)得到真实地址,测试无端口冲突;Layer.build在当前测试作用域内构建,监听器作为 scoped 资源随测试结束自动关闭,不存在端口泄漏;- 上游 handler 直接接收
HttpServerRequest,可以拿到request.text、request.headers等,因此能精确断言“中间件到底转发出去了什么”。
该测试中上游服务器同时承担两个角色:提供工作区同步所需的 bootstrap 路由(/base/global/event、/base/sync/history,见 syncResponse 函数)让 Workspace.isSyncing(...) 为真,以及记录被代理的请求(forwarded 变量)用于断言路由契约:
// These assertions are the routing contract: append the original path to
// the remote base URL, preserve normal query params, and remove workspace.
expect(forwardedURL?.pathname).toBe("/base/probe")
expect(forwardedURL?.searchParams.get("keep")).toBe("yes")
expect(forwardedURL?.searchParams.get("workspace")).toBeNull()
expect(forwarded?.method).toBe("PATCH")
expect(forwarded?.body).toBe(body)
expect(forwarded?.headers["x-target-auth"]).toBe("secret")
expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined()
expect(forwarded?.headers["x-opencode-workspace"]).toBeUndefined()
这段断言清晰展示了代理契约:原始 path 拼接到远端 base 之后、普通 query 参数保留、workspace 参数被剥离、自定义鉴权头注入、而本地目录/工作区标记头被清除。
模式五:避免 Bun.serve,保持在 Effect HTTP 栈内
指南明确:测试 Effect HTTP 中间件时避免使用 Bun.serve;除非被测的生产路径本身是 Bun 专属,否则把测试保持在 Effect HTTP 栈内。
原因是:中间件(HttpApi、HttpRouter、HttpClient、Socket)全部运行在 Effect 的 HTTP 抽象上;用 Bun.serve 搭一个裸 Node 服务器只会测试到“服务器能收到请求”,却绕过了中间件依赖注入、升级(WebSocket upgrade)与流式响应等 Effect 栈行为,产生“测试通过但生产行为不同”的假阳性。NodeHttpServer.layerTest / NodeHttpServer.layer 提供的是与生产装配同构的监听器。
模式六:WebSocket 路径用 Socket.makeWebSocket 断言转发
指南要求:WebSocket 路径使用测试客户端的 Socket.makeWebSocket(...),在相关处断言协议转发或帧中继。
工作区路由测试 中的 WebSocket 代理测试是完整范例:
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
{ closeCodeIsError: () => false, protocols: "chat" },
)
// ...
expect(yield* Queue.take(messages)).toBe("protocol:chat") // 协议转发断言
yield* write("hello")
expect(yield* Queue.take(messages)).toBe("echo:hello") // 帧中继断言
上游侧(echoWebSocket)通过 request.upgrade 完成握手,回显帧并回显收到的 sec-websocket-protocol。客户端连接到本地测试服务器,断言链验证了中间件对 upgrade 请求的识别、到远端 /base/probe 的代理,以及协议头与数据帧的双向中继——这正是指南所说“assert protocol forwarding or frame relay when relevant”的落地。
模式七:全局可变状态一律 Scoped 管理并在 finalizer 中恢复
指南规定:对 flags、数据库重置及其他全局可变状态,使用 scoped 测试层;在 finalizers 中恢复 flag 并重置状态。
三类典型实现:
1. 数据库重置层——测试开始 resetDatabase(),finalizer 中再重置一次,保证测试间隔离(工作区路由测试):
const testStateLayer = Layer.effectDiscard(
Effect.gen(function* () {
yield* Effect.promise(() => resetDatabase())
yield* Effect.addFinalizer(() =>
Effect.promise(async () => { await resetDatabase() }),
)
}),
)
2. Flag 覆盖——test/fixture/flag.ts 的 withFixedWorkspaceID 展示了标准的“保存—覆盖—finalizer 恢复”模式:
export function withFixedWorkspaceID(id: WorkspaceV2.ID): Effect.Effect<void, never, Scope.Scope> {
return Effect.gen(function* () {
const previous = Flag.OPENCODE_WORKSPACE_ID
Flag.OPENCODE_WORKSPACE_ID = id
yield* Effect.addFinalizer(() =>
Effect.sync(() => { Flag.OPENCODE_WORKSPACE_ID = previous }),
)
})
}
由于 finalizer 绑定在 Effect 作用域上,无论测试成功、失败还是抛出异常,flag 都会被恢复——这比手写 try/finally 更可靠,也是指南强调“in finalizers”的原因。
3. 运行时特性开关——需要开启实验性能力时,用带 flags 的 fixture 层(如 workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true }),见 test/fixture/workspace.ts),而不是修改全局配置对象。
模式八:项目级请求用 tmpdirScoped({ git: true }) + Project.use.fromDirectory
指南要求:项目相关的请求使用 tmpdirScoped({ git: true }) 加 Project.use.fromDirectory(dir)。
tmpdirScoped 定义在 test/fixture/fixture.ts,其行为:
- 在
os.tmpdir()下创建opencode-test-<随机串>目录并做realpath规整; { git: true }时执行git init、关闭core.fsmonitor与 GPG 签名、写入测试user.email/user.name,并创建一条空 root commit(为需要版本历史/HEAD 的项目测试提供基线);{ config }时写入带$schema的opencode.json;- 注册
Effect.addFinalizer,作用域关闭时先停掉 git fsmonitor daemon 再递归删除目录——与testEffect的 scoped 执行天然配合,测试结束即清理干净。
测试体中的标准用法:
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.use.fromDirectory(dir)
Project.use.fromDirectory(dir) 让 opencode 的项目解析逻辑(.git 发现、实例构建)真实运行在一个受控的临时目录上,后续请求(如带 x-opencode-directory 头的请求或探针查询)即可基于这个项目展开。httpapi-layer.ts 中的 requestInDirectory(path, directory) 则是“把请求头指向某目录”的配套工具。
模式九:无运行时对应的持久化状态放进窄命名 helper
指南的一条精妙约定:
当测试需要持久化状态但没有对应的运行时状态时,把直接数据库的 setup 放在一个窄命名的 helper 里,并在其中解释该状态。
标准范例是 工作区路由测试 的 insertRemoteWorkspaceWithoutSync:
const insertRemoteWorkspaceWithoutSync = (input: {
dir: string
projectID: Project.Info["id"]
type: string
url: string
}) =>
Effect.gen(function* () {
const id = WorkspaceV2.ID.ascending()
registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url))
const { db } = yield* Database.Service
yield* db
.insert(WorkspaceTable)
.values({ id, type: input.type, project_id: input.projectID })
.run()
.pipe(Effect.orDie)
return id
})
它绕过 Workspace.Service.create(后者会启动同步循环)直接插入 WorkspaceTable 记录,精确构造“DB 里有远端工作区、但同步未建立”的异常状态,从而能断言中间件返回 503 broken sync connection for workspace: ...。helper 的名字本身即文档:读者一眼知道这个测试准备的是“没有同步的远端工作区”,无需追踪 Workspace.Service 的完整创建流程。凡是“直接写库”的代码,都应遵循这一约定并附带注释说明其用意。
模式十:为不直观的测试拓扑添加注释
指南最后一条:对不直观的测试拓扑添加注释,尤其是同时涉及本地测试服务器与假上游服务器的测试。
工作区路由测试 中代理测试的注释是良好示范:
// This starts a second HTTP server that stands in for the opencode server
// backing a remote workspace. The client below still calls the local test
// server; only the middleware should call this server.
const remoteUrl = yield* startRemoteWorkspaceHttpServer((request) => { ... })
以及:
// The client connects to the local test server. The middleware should
// detect the WebSocket upgrade and proxy it to the remote /base/probe.
拓扑注释回答三个问题:有几个服务器、各自的职责、客户端的请求路径经过谁。对于“本地探针服务器 + 假上游 + WebSocket/代理”这类双层拓扑,缺失这些注释会让测试在三个月后几乎不可维护。
速查:测试模式与对应实现位置
| 指南要求 | 核心 API | 参考实现 |
|---|---|---|
| 聚焦中间件测试、小型 fake 路由 | HttpApi.make + HttpApiGroup + HttpApiBuilder.group |
httpapi-workspace-routing.test.ts |
| 探针暴露上下文 | WorkspaceRouteContext、InstanceRef、WorkspaceRef |
httpapi-instance-context.test.ts |
| 主服务器 + 相对 HttpClient | testEffect + NodeHttpServer.layerTest |
test/lib/effect.ts、httpapi-layer.ts |
| 中间件顺序与生产一致 | .middleware(InstanceContextMiddleware).middleware(WorkspaceRoutingMiddleware) |
httpapi-instance-context.test.ts |
| 二级上游服务器 | Layer.build(NodeHttpServer.layer(...)) |
httpapi-workspace-routing.test.ts |
| 避免 Bun.serve | 保持 Effect HTTP 栈 | 全目录约定 |
| WebSocket 转发断言 | Socket.makeWebSocket(...) |
httpapi-workspace-routing.test.ts |
| 全局状态 Scoped 管理 | Effect.addFinalizer、resetDatabase、withFixedWorkspaceID |
test/fixture/flag.ts |
| 项目级请求 | tmpdirScoped({ git: true }) + Project.use.fromDirectory |
test/fixture/fixture.ts |
| 窄命名 DB helper | insertRemoteWorkspaceWithoutSync |
httpapi-workspace-routing.test.ts |
| 拓扑注释 | 双层服务器说明 | httpapi-workspace-routing.test.ts |
小结
这套模式的本质是用 Effect 的 Layer/Scope 体系把测试基础设施也当作依赖注入的一等公民:测试服务器、假上游、临时 git 目录、DB 重置、flag 覆盖全部是 scoped 资源,随 testEffect 的作用域自动建立与回收;探针 API 保证断言对象是“中间件契约”而非业务实现;顺序对齐生产则保证测试拓扑的真实性。遵循 AGENTS.md 中这十项约定,可以写出隔离性、可读性与生产一致性兼备的 opencode 服务端测试。
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 StartedRust0623
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