在 workerd 里跑集成测试:cloudflare-os 端到端测试 harness 完整拆解
【免费下载链接】cloudflare-osAgent workspace built on Cloudflare Workers for creating documents, building apps, and running agents with your company’s context and systems.项目地址: https://gitcode.com/GitHub_Trending/cl/cloudflare-os
cloudflare-os 是一个构建在 Cloudflare Workers 上的 Agent 工作区,你可以用它写文档、搭应用、跑带着公司上下文的 Agent。而它的packages/integration-tests集成测试 harness 回答了一个更硬核的问题:怎么把真实的workshop-backend和真实的 gatekeeper Worker 同时跑起来,让被测代码待在 workerd 的另一个进程里,测试却只 stub 掉出站 HTTP?本文按一次真实搭建的推进顺序,拆透这套端到端测试架构的启动、拦截、传输三层机制,以及藏在背后的六个设计取舍。
先说结论,后面所有细节都是它的推论:这套测试里没有任何东西被 stub,唯一的例外是出站 HTTP。被测代码运行在另一个进程里。想理解 harness、网络拦截器和 RPC client 为什么长那样,抓住这句话就够了。完整的官方叙述见 集成测试文档。
先问自己:我们到底在测什么?
新人接手时最容易走偏的一步,是把"集成测试"理解成"把各模块 mock 起来拼一起"。恰恰相反。
这套测试测的是 Workshop 的overseer(监督者)逻辑:Agent 绑定一个外部数据源(比如 Google 云盘)后,监督者要替用户盯着"这个协作者还有没有权限看这份数据",权限丢了要会重新提示、会点名是谁出了问题。要验证这件事,你必须让真实的workshop-backend和真实的 gatekeeper 一起活着,而不是拿假数据源糊弄。
而验证监督者逻辑有个苛刻要求:得有一个 gatekeeper 能"按命令拒绝某个 observer"。现实里的公开 gatekeeper 谁都做不到,或代价大到喧宾夺主——OAuth 类得在账号存在之前 mock 掉一整个厂商认证面;Context Library 只有观察被记录之后才会拒绝,那又牵出 Worker Loader、斜杠命令或 AI 聊天快照一堆前置。给现成 Worker 加"标记已观察"之类的测试钩子也被否决了:那等于 stub 掉 tracker 自己维护的状态,测试就变成循环论证了。
所以仓库里躺着一个 fixture:fixtures/gatekeeper-test/。它是个说着真实协议、跑真实 DurableObject 的 Worker,但验证结果由测试通过 HTTP 控制路由说了算。记住它的定位——只服务 overseer 逻辑的测试,不是 per-vendor 覆盖的替代品。
凭什么说"真":在 workerd 里启动真实 Worker
harness 的核心入口是 harness.ts 里的startHarness()。它基于 wrangler 的createTestHarness(),把workshop-backend和一组 gatekeeper 作为真实 Worker在 workerd 里启动,用的是各自 checked-in 的wrangler.jsonc,只在内存里 patch。启动前做了三件不起眼但致命的工作:
- 读配置、只校验自己碰的字段。解析用
jsonc-parser,校验用一个刻意宽松的 schema——其余字段原样透传,由 wrangler 在 Worker 启动时对整个文件重新校验。这样配置损坏时会在这里带着字段名报错,而不是被强行类型转换后在更隐蔽的地方炸掉。 - 路径钉死。inline 配置没有自己的文件路径,wrangler 会把相对
main相对 harness 的 root 解析,所以main必须改成绝对路径;main由构建生成的 Worker(capnweb-validate 产物)还得把build.cwd钉到它自己的目录,否则产物落到错误位置。 - 清空本地 dev 变量。harness 从一个不含
.dev.vars/.env的干净目录启动,配置也不声明secrets。否则你机器上的CF_AI_GATEWAY_*之类的本地设置会渗进测试——轻则个人机器和 CI 行为不一致,重则发出真实的 AI 流量。
workshopConfig()还有两个关键调整:只为套件点名的 gatekeeper 添加services绑定(GATEKEEPER_<binding>指向对应 Worker,entrypoint 固定为GatekeeperVendor),这样 vendor 发现列表干干净净,observer 配置提示不会冒出意外行;同时不设CF_ACCESS_AUD,让/api走未认证路径、开放密码注册,ADMINS设为["admin"]。绝大多数测试不需要 Gadget 执行,所以默认删掉worker_loaders,只有显式传enableGadgetExecution才保留。
启动完成后返回的Harness对象就两个关键能力:url(运行中 server 的基地址)和fetchWorker(name, ...)。后者直接向指定 Worker 自己的 HTTP entrypoint 派发请求,host 永远不会被解析,所以不需要routes配置——但路径必须匹配该 Worker 的预期。fixture 的控制路由就是靠它打进去的。
你到底 stub 了什么?只有出站 HTTP
整个隔离保证的机制层在 network-interceptor.ts,原理简洁得让人意外:createTestHarness本来就会把 Worker 的出站fetch()路由回 Node 进程,所以只要 patch 掉globalThis.fetch就够了,不需要任何拦截库。规则有四条:
- loopback(
localhost/127.0.0.1/[::1])默认放行,让测试客户端直连 harness。安全敏感的场景可以关掉allowLoopback,让模型作者发起的请求也走 handler 链,彻底碰不到宿主服务。 - 被
allow放行真放出去的外部请求,会强制加accept-encoding: identity。为什么?Node 的 fetch 解压了压缩体却原样转发Content-Encoding,workerd 拿到明文会再解一次压,"Gzip decompression failed"就是这么杀死了本地 eval 目标里每条 Anthropic 流的。 - 没被任何 handler 接住的请求:记录 + 抛错(
Unmocked outbound request: ...)。未 mock 的调用是失败测试,而不是悄悄触网——这就是"零逃逸"保证的牙齿。 - handler 是纯函数
(url, method, headers, request) => Response | null:返回null表示"这个我不接,下家试试",返回Response即接管。约束很微妙——handler 只有在决定自己拥有该 URL 之后才允许读request,先消费了 body 再返回null,会把后续 handler 对同一条流的读取直接破坏掉。handler 还可以是 async 的,因为有的场景需要等 Worker 发起请求之后,测试再决定返回什么。
这套机制自身有完整单元测试(network-interceptor.test.ts),覆盖 loopback 透传、handler 顺序、allow放行、记录/复位/定向提取等行为。
还有一手"负向证明"值得学:在 observer-reverification.test.ts 里,/control/fetch-probe让 fixture Worker 对https://example.com/definitely-not-mocked发一个真实子请求。逻辑是:如果 Worker 子请求能绕过被 patch 的globalThis.fetch直奔互联网,那其他所有"零逃逸"断言也全都是摆设。probe 证明拦截确实接住了——请求变成一个合成 500(harness 代理出站请求,代理侧失败以 500 返回,而不是在 Worker 内 reject),且takeUnmockedCalls(target)精确取回这一条:只取自己的条目、不 reset 整个列表,这样afterAll里仍能抓到并发兄弟测试放跑的逃逸。
测试怎么和后端"说话":RPC client
rpc-client.ts 让测试用浏览器同款的方式说话——WebSocket 上的 Cap'n Web 协议打到/api,和前端传输层完全一致。几个值得注意的实现选择:
signUp/logIn不用前端那套 64 MiB 的 argon2id,直接用 SHA-256 生成确定性哈希。因为 server 对这些字节是原样存储比较、从不重新推导,测试没有理由花几秒去算慢哈希。waitFor()以 25ms 间隔轮询最多 30 秒,专门用于"效果只能通过 API 最终状态观察"的场景,比如账号出现在用户列表。listConnectedAccounts()把subscribeConnectedAccounts()驱动到ready(),把增量 add/remove 事件收集成一份快照;用using声明式资源管理按逆序释放(先退订、再释放原始 stub),连"订阅调用自身抛错"的路径也覆盖了。accountLabel()刻意镜像 overseer 内部#describeObserverFailures的优先级(uniqueName || displayName || "account N")。目的很实际:让测试断言的错误消息和用户实际读到的措辞一字不差。ObserverConfigRecorder实现ObserverConfigCallback,把每次configure()调用记进calls数组(断言面),再从脚本化队列应答。alwaysChoose(accountId, times)的times必须写死:队列空了configure()直接抛错,多出来的意外提示应当让测试失败,而不是被静默应答掉。配套的MAX_OBSERVER_PROMPTS = 2(overseer 的MAX_CONFIG_REPROMPTS为 1,即初始提示 + 至多一次重提示)被集中定义成常量,免得每个套件里各写一个魔法数字。
三条取舍线:设计背后的六张决策卡
上面这些"为什么",全部从一条总推论展开:代码在另一个进程里。下面把六个设计决策按三条取舍线重新归拢。
取舍一:一切跨进程
卡片 1 · 假定时器在这里没用选了什么:用 fixture 的 HTTP 控制面制造时间敏感状态。否决了什么:vi.useFakeTimers()。代价:测试里想"拨快时钟"验证过期的场景,必须换个表达方式。 原因是跨进程不可见:假定时器 patch 的是测试进程的时钟,而被测代码读的是workerd 的时钟。比如isTokenExpired()里那个 30 秒 skew 在gatekeeper-shared内部、在 Worker 里求值,测试进程怎么拨表都拨不动它。注意边界:在vitest-pool-workers下的 in-isolate 单元测试里假定时器确实可用——测试和被测代码在同一个 isolate 内。这条限制只针对跨进程集成测试。
卡片 2 · 存储隔离靠约定,不靠清空选了什么:每个测试领全新身份。否决了什么:把server.reset()当测试间的存储清空工具。代价:测试之间共享持久存储,任何用例都不得假设干净起点。 实测数据一锤定音:server.reset()每次约 3 秒,比整个套件跑一遍还久;而且它会重启 server——server.url变 undefined,所有已打开的 WebSocket RPC 会话全死在 "WebSocket connection failed"。它本质是 teardown,不是 wipe。所以独立性的来源是:nextUsernames()递增计数器生成alice7、bob7这类用户名(Workshop 要求用户名以字母开头且为字母数字,前缀也得是字母);资源 URL 每测试唯一;账号标签由 connect/provision helper 分配,不许调用方自选。 还有一个容易踩错的推论:"没有请求逃逸到互联网"的断言必须放在afterAll而不是afterEach。it.concurrent下某个afterEach触发时兄弟姐妹还在跑,它去检查并清空对方还在用的状态,甚至可能丢掉本应由兄弟背锅的逃逸请求。
取舍二:谁来测 overseer
卡片 3 · fixture gatekeeper,且明确它的边界选了什么:一个说着真实协议、验证结果由测试通过 HTTP 控制的 fixture Worker(test-gatekeeper.ts)。否决了什么:在现成 gatekeeper 上加测试钩子(循环论证);用真实 gatekeeper 测 overseer(mock 成本主导测试)。代价:fixture 不模拟任何厂商的真实形态,per-vendor 行为另有一套路径(见后文)。 fixture 内部几处设计直接服务于"让 overseer 的失败逻辑可演练":TestControlDurableObject 持有全部控制状态,setVerifyOutcome按账号标签写入验证结果(资源级优先于账号级),默认放行保证协作者第一次打开必然成功;GatekeeperVendor声明autoProvisionAccount: true,createAccount()每次铸造全新账号,而connectAccount()按要求抛错——既然自动开账号,Workshop 永远不会发起 connect 流程;每个绑定资源的TestGatekeeper里,allow: false时直接抛Error(reason)——抛错就是 gatekeeper 报告"此用户不可观察"的方式,也是 overseer 失败处理围绕的核心行为。 它刻意不建模"定型拒绝"(你不可读此数据)和"运行性失败"(凭证过期)的区别:两者到达 overseer 时完全一样,都是抛出的错误,overseer 无法区分——这是设计使然,因为监督者把所有失败都视为可修复的。所以只有一个allow旋钮,区别由 reason 字符串承载,测试选不同文本来演练两种叙事。控制面的每个请求体还逐字段校验,400 会指明哪个字段错了——注释说得很清楚,这不是为了安全(调用者只有包内 helper),而是为了失败模式:一个拼错的字段会给名为undefined的账号注册结果,gatekeeper 继续放行本该失败的账号,测试在若干步之后死在与真实原因毫不相干的断言上。
取舍三:toolkit 出圈之后
这个包被设计成 toolkit:外部仓库把本仓库作为public/子模块 vendor 进来,在自己的pnpm-workspace.yaml里以工作区依赖方式消费它,搭自己的 per-vendor 套件。注意:当前仓库里并不存在这样的套件,也没有任何东西依赖它存在——harness 接受 gatekeeper列表、interceptor 接受可插拔handler 模块,整套参数化就是为它准备的。出圈之后有三个雷:
卡片 4 · wrangler 与 workerd 必须步调一致选了什么:在 pnpm-workspace.yaml 里用 catalog 精确钉版本、用overrides把测试池的miniflare/wrangler联动到同一套栈。否决了什么:让wrangler和workerd各自按 semver 漂移。代价:升级 wrangler 必须同步升级 override,二者不能各自为政。 翻车的样子是:更新 wrangler 带来更新 miniflare,后者要求比 override 所给更新的 workerd,harness 启动失败——The Workers runtime failed to start ... requires compatibility date "2026-07-08", but the newest date supported by this server binary is "2026-06-30"。这类报错指向的不是配置写错,而是运行时版本分裂。
卡片 5 · capnweb 边界由 toolkit 独占选了什么:回调 stub 一律用rpc-client.ts的stubFor()铸造。否决了什么:在测试文件里直接值导入RpcStub。代价:多绕一层工厂函数。 背景是消费方仓库会装两份 pnpm store——自己的工作区和public/子模块的工作区——capnweb因此解析出两个副本:toolkit 的rpc-client拿到子模块那份,消费方自己包的导入拿到另一份。stub 只能由拥有会话的那个实例序列化,混用的报错是TypeError: Cannot serialize value: [object RpcStub]。最坑的是本地单次pnpm install会去重合并、根本不暴露;它首次出现在 CI——CI 分别执行pnpm install与pnpm --dir public install。本地想复现,照做一遍即可。把RpcStub当类型导入没问题,类型在编译期被擦除。本仓库用 lint 规则结构性强制了这条(限制本包内capnweb值导入只能出现在rpc-client.ts,allowTypeImports放行类型导入);没有 linter 的消费方仓库应把它当铁律。
卡片 6 · Worker 入口模块只能导出类与默认 handler选了什么:常量一律保持模块私有。否决了什么:顺手export const THING_URL_PATTERN = ...。代价:几乎没有,只是纪律。 workerd 把入口模块的每个具名导出都当 entrypoint。从 fixture 导出一个普通字符串常量会得到Incorrect type for map entry 'THING_URL_PATTERN': the provided value is not of type 'function or ExportedHandler'。类型导出没任何问题(被擦除)。test-gatekeeper.ts 顶部那句 "Nothing but classes and the default handler may be exported" 就是这么来的。
套件怎么被调度:预构建、全局 setup 与 watch 重建
测试能跑起来还依赖两处编排细节,都在 global-setup.ts 和 vitest.config.ts 里:
test:prebuild先行。test:run会先跑pnpm run test:prebuild,把workshop-backend和 fixture gatekeeper 的main都预构建到.wrangler/validate/;fixture 的wrangler.jsonc的main就指向构建产物,且build:test-gatekeeper应用与生产 gatekeeper 相同的 RPC 校验——fixture 不是"低配版",它过的校验和真 gatekeeper 是同一套。- global-setup 验证产物并设开关。两个预构建产物缺一个就抛错;随后设置
WORKSHOP_INTEGRATION_PREBUILT=1,harness 看到它便删除config.build——共享构建已完成,每个测试进程 fork 里重建只会争抢同一个目录。watch 模式下,测试重跑前会先等当前 run 结束再重建,避免覆盖正在启动的 Worker 还在读的文件;被删除的 Worker 输入文件也通过onFileChange路径触发重跑(vitest 的 unlink 路径不会去查forceRerunTriggers,得手动补这个洞)。 - 超时给足。
testTimeout与hookTimeout都是 120 秒——workerd 冷启动加上真实 RPC 往返,这个量级的余量是必要的。
怎么跑起来:两条命令和一个目录约定
pnpm test pnpm --filter @gadgets/integration-tests run test:run pnpm --filter @gadgets/integration-tests run test:watch第一条是 CI 常规测试任务里的常规路径;后两条只跑集成包,分别是"预构建 + 一次性跑完"和"预构建 + 监听模式"。目录约定也简单:vitest 的include只覆盖__tests__/**/*.test.ts,所有套件文件平铺在该目录下(observer-reverification、workshop-sharing、sensitive-observations等等)。每个测试文件在自己的进程里共享一个 harness,文件内用例用it.concurrent并行——这也正是前面"逃逸断言放afterAll"的原因。
走一遍:observer 重新验证回归测试是怎么抓到 bug 的
observer-reverification.test.ts 是整套设计的浓缩演示,它回归的是一个真实事故:协作者的 observer 账号选择在首次成功打开后被持久化,此后每次打开ensureObserver()都无提示地重新验证;一旦验证失败(凭证过期再常见不过),打开曾经直接死在 "You are not permitted to observe all of the data this Gadget has accessed",用户无路可走。修复后的正确行为是:通过ObserverConfigCallback携带失败信息重新提示;重提示仍未解决,就点名是哪条连接、哪个账号失败的。
测试流的骨架四步:
beforeAll安装一个不带任何 handler的NetworkInterceptor——这个文件不应有任何出站请求,出现一个就是失败——再启动 fixture harness。- 每个用例用
nextUsernames("alice", "bob")铸新身份、signUp、provisionAccount(fixture 无认证流,一次 RPC 即铸造)、newGadget()+newGatekeeper()绑定资源,再addCollaborator(bob, "build")分享给 Bob。 - 通过
harness.fetchWorker调/control/verify-outcome,把 Bob 的验证结果设为拒绝:EXPIRED_REASON("credentials expired — please reconnect")模拟凭证过期,DENIED_REASON模拟定型拒绝。 - 断言
ObserverConfigRecorder记录的configure()调用次数、need.failure.accountId与reason,以及最终错误消息里包含绑定名("Test Thing named")、账号标签和原因——并且每个失败绑定占一行。
其中两个用例最见功力:
- "一次重提示中报告所有失败绑定,而非只报第一个"。两个绑定同时失败时,旧代码只保留第一个错误、丢掉其余,第二条失败连接对用户不可见。用例断言第二次
configure()的失败列表长度为 2、两个failure.accountId都指向 Bob,终端消息同时点名Test Thing multi-a与Test Thing multi-b。 - "另一个绑定终局失败时,保留已修复的既有注册"。重提示的 responder(在两次验证 pass 之间精确执行)修好 a、同时让 b 失效。断言 a 的注册没有产生任何
remove事件——回滚只应撤销本次调用新注册的 observer,把修复后的既有持久化注册重新归类为"新添加"是错的,有 bug 的回滚恰好会多发一次 remove——而两次成功验证留下恰好两条add。
给新 gatekeeper 加套件:三步,零 fork
无论在本仓库内还是消费方仓库内,给一个新 gatekeeper 加集成套件都是同一形态,不需要 fork 任何 harness 代码:
- 新建一个 handler 模块(如
google-handlers.ts):实现Handler签名,mock 该厂商的 token 端点和 API 端点,返回真实形状的响应。 - 把 harness 指向该 gatekeeper 包:
startHarness({ gatekeepers: [ { binding: "GOOGLE", dir: "../gatekeeper-google" }, ], })必要时用GatekeeperSpec.patch调整它的配置(比如设置测试依赖的vars)。 3.不碰生产代码:真实 gatekeeper 原样运行,厂商外部面全部由 interceptor 的 handler 模块 mock。
于是仓库里存在两种形态的套件,职责边界要分清:本仓库的packages/integration-tests跑在 CI 常规测试任务里,用 fixture Worker(验证结果由测试设定),覆盖 overseer 的 observer 逻辑,拥有 harness、interceptor 与 RPC client;消费方仓库的 per-vendor 套件跑在自己的 CI 步骤里,用未做任何修改的真实厂商 gatekeeper,覆盖真实过期凭证的端到端场景,拥有的是该厂商的 handlers 与 token 铸造。第二列今天在本仓库里不存在,但它正是整套参数化设计服务的对象。
消费方仓库额外要遵守两条本仓库已内建为结构的约定:回调 stub 一律经stubFor()(不要值导入RpcStub);"零逃逸"断言放afterAll而不是afterEach。
踩坑清单:九个陷阱,各带解药
- 假设测试之间有干净起点→ 用
nextUsernames()领全新身份、每测试唯一资源 URL、账号标签交给 helper 分配。存储在整个 harness 生命周期内持续存在,这是特性不是缺陷。 - 拿
server.reset()当存储清空→ 它每次约 3 秒且会掐断所有已打开的 WebSocket 会话,只当 teardown 用。 - 逃逸断言放
afterEach→ 并发下会误判甚至丢掉兄弟测试的逃逸证据;放afterAll,getUnmockedCalls()一次断言。 - 值导入
RpcStub→ 本地好好的,CI 报Cannot serialize value: [object RpcStub];一律走stubFor(),类型导入无罪。 - 升级 wrangler 忘了升 override→ harness 起不来,报 compatibility date 不匹配;两者必须步调一致。
- 入口模块导出了非类的值→
Incorrect type for map entry ...;除类和默认 handler 外全部模块私有。 - 用假定时器控制跨进程行为→ 完全无效;时间敏感状态一律走 fixture 控制面制造。
- 未 mock 的出站请求→ 抛
Unmocked outbound request。别试图"修"它——这正是隔离保证:失败,而不是触网。 - 把 fixture 当成 per-vendor 覆盖→ fixture 只服务 overseer 逻辑;真实厂商行为请走"真实 gatekeeper + handler 模块"那条路。
一句话收尾
这套集成测试体系压缩成一句话:代码在另一个进程里,测试通过真实传输协议驱动它,唯一被 stub 的是出站 HTTP。假定时器失效、fixture 承担 overseer 逻辑、存储隔离靠约定、版本步调一致、capnweb 边界独占、入口导出受限——全部约束从这一句话推导出来。而它的扩展点同样清晰:本仓库的套件验证监督者逻辑,消费方仓库的 per-vendor 套件验证真实 gatekeeper 的端到端行为,两者共享同一套基础设施,谁都不需要 fork 任何东西。
【免费下载链接】cloudflare-osAgent workspace built on Cloudflare Workers for creating documents, building apps, and running agents with your company’s context and systems.项目地址: https://gitcode.com/GitHub_Trending/cl/cloudflare-os
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考