opencode 服务端测试指南:基于 Effect 的 HttpApi 中间件测试模式实战 opencode 服务端测试指南基于 Effect 的 HttpApi 中间件测试模式实战【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode本篇技术文章以 opencode 仓库中的 服务端测试指南 为核心系统讲解如何针对packages/opencode/test/server/目录下的 server 与 HttpApi 中间件测试编写聚焦、可复现、且与生产路径一致的测试从“小型探针路由代替完整 API 路由树”的选题原则到testEffectNodeHttpServer.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.tsSDK 兼容性冒烟以及一套httpapi-exercise/子目录路由巡检的 DSL 与报告工具。指南给出的第一条总原则是在测试路由、上下文、代理或中间件策略时优先使用“小型 fake 路由”的聚焦中间件测试而不是拉起完整的 API 路由树。这条原则的核心价值在于中间件测试关心的是“请求经过中间件后上下文/目的地如何变化”而不是业务端点本身。用一个 23 条探针路由的小 API 组即可精确断言中间件契约避免被完整路由树的噪音和依赖拖慢测试。模式一小型 HttpApiBuilder 探针组暴露被测上下文指南要求使用“tinyHttpApiBuilderprobe 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 把中间件写入的WorkspaceRouteContextdirectory、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 makenever, never(testEnv, liveEnv) export const testEffect R, E(layer: Layer.LayerR, E) makeR, 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.tsconst servedRoutes: Layer.Layernever, 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的变体它通过进程级共享memoMapLayer.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: TestHandlerE, 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.Effectvoid, 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 里并在其中解释该状态。标准范例是 工作区路由测试 的insertRemoteWorkspaceWithoutSyncconst 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 里有远端工作区、但同步未建立”的异常状态从而能断言中间件返回 503broken 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.makeHttpApiGroupHttpApiBuilder.grouphttpapi-workspace-routing.test.ts探针暴露上下文WorkspaceRouteContext、InstanceRef、WorkspaceRefhttpapi-instance-context.test.ts主服务器 相对 HttpClienttestEffectNodeHttpServer.layerTesttest/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、withFixedWorkspaceIDtest/fixture/flag.ts项目级请求tmpdirScoped({ git: true })Project.use.fromDirectorytest/fixture/fixture.ts窄命名 DB helperinsertRemoteWorkspaceWithoutSynchttpapi-workspace-routing.test.ts拓扑注释双层服务器说明httpapi-workspace-routing.test.ts小结这套模式的本质是用 Effect 的 Layer/Scope 体系把测试基础设施也当作依赖注入的一等公民测试服务器、假上游、临时 git 目录、DB 重置、flag 覆盖全部是 scoped 资源随testEffect的作用域自动建立与回收探针 API 保证断言对象是“中间件契约”而非业务实现顺序对齐生产则保证测试拓扑的真实性。遵循 AGENTS.md 中这十项约定可以写出隔离性、可读性与生产一致性兼备的 opencode 服务端测试。【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考