首页
/ opencode 服务端测试指南:基于 Effect 的 HttpApi 中间件测试模式实战

opencode 服务端测试指南:基于 Effect 的 HttpApi 中间件测试模式实战

2026-09-06 13:33:14作者:鲍丁臣Ursa

本篇技术文章以 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,
)

这个探针组的要点:

  1. Schema 化的响应ProbeResult 是一个 Schema.Struct,探针 handler 把中间件写入的 WorkspaceRouteContextdirectoryworkspaceID)直接序列化为 JSON 返回,测试端即可对“路由上下文最终变成了什么”做精确断言;
  2. 中间件按声明挂到 group 上.middleware(WorkspaceRoutingMiddleware) 使探针组成为一个独立的最小 HttpApi 服务,其依赖(workspaceRoutingLayerSocket.layerWebSocketConstructorGlobal 等)通过 Layer.provide 精确注入;
  3. 未使用的依赖用 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 只读取 InstanceRefWorkspaceRef 并回显,测试即可断言中间件是否把正确的实例/工作区上下文注入到了请求作用域。

模式二:主测试服务器用 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.scopedEffect.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 请求头,这正是“目录上下文”类中间件的输入信号。

此外,testEffectSharedtestEffect 的变体:它通过进程级共享 memoMapLayer.buildWithMemoMap)构建测试层,使 BusSession 等被 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.textrequest.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 栈内

原因是:中间件(HttpApiHttpRouterHttpClientSocket)全部运行在 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.tswithFixedWorkspaceID 展示了标准的“保存—覆盖—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 } 时写入带 $schemaopencode.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
探针暴露上下文 WorkspaceRouteContextInstanceRefWorkspaceRef httpapi-instance-context.test.ts
主服务器 + 相对 HttpClient testEffect + NodeHttpServer.layerTest test/lib/effect.tshttpapi-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.addFinalizerresetDatabasewithFixedWorkspaceID 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 服务端测试。

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