☰
cloudflare-os 集成测试实战:如何用 Wrangler Test Harness 在 workerd 中驱动真实 Worker(3 个组件 · 6 个坑)
2026/10/5 10:15:04 网站建设 项目流程

cloudflare-os 集成测试实战:如何用 Wrangler Test Harness 在 workerd 中驱动真实 Worker(3 个组件 · 6 个坑)

【免费下载链接】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 workspace。本文讲解它的集成测试架构:用 wrangler Test Harness 在 workerd 里启动真实 Worker,只 stub 出站 HTTP,再经浏览器同款 WebSocket 传输做断言。

关键结论先立住:被测代码在另一个进程里

wrangler 提供的createTestHarness()会在workerd 中把workshop-backend与一个或多个 gatekeeper 作为真实 Worker 启动,它们 checked-in 的wrangler.jsonc在内存里被 patch。测试通过 WebSocket 上的 Cap'n Web 协议与/api通信——和浏览器用的传输完全一致——并向 overseer 提供一个ObserverConfigCallback供其回调。

除此之外没有任何东西被 mock。这句话是全文的地基:测试进程碰不到 Worker 进程里的变量、时钟和存储,只能通过协议与它说话。后面几乎所有"反直觉"的设计,都是这一条推出来的。

三个组件总览:harness / network-interceptor / rpc-client

harness.ts负责把环境拉起来。输入是 gatekeeper 列表(绑定名、包目录、可选 patch 函数),输出是一个带url(运行中 server 的基地址)与fetchWorker()(直接向指定 Worker 的 HTTP 入口派发请求,host 不被解析,因此不需要routes配置)的Harness对象。

network-interceptor.ts是隔离的机制层。由于createTestHarness会把 Worker 的出站 fetch 路由回 Node 进程,patch 掉globalThis.fetch就足够,不需要任何拦截库。输入是 handler 链与放行规则,输出是"逃逸"请求的记录。

rpc-client.ts是测试驱动产品的操作面:用前端同款 API 开 RPC 会话,提供注册/登录、轮询等待、observer 提示记录器。ObserverConfigRecorder记录每次configure()调用作为断言面,MAX_OBSERVER_PROMPTS = 2把 overseer 的提示上限集中成常量,避免每个套件里重复魔法数字。

组件职责输入输出
harness启动真实 Workergatekeeper 列表、配置 patch基地址、fetchWorker
network-interceptor拦截出站 HTTPhandler 链、放行规则逃逸记录
rpc-client浏览器同款传输驱动基地址RPC 会话、记录器

一句话分工:harness 管"开机",interceptor 管"断网",rpc-client 管"操作产品"。

跟一次集成测试的生命周期:从预构建到 teardown

预构建:先把两个 Worker 的入口摆到同一位置

package.json里test:run会先执行test:prebuild,把workshop-backend与 fixture gatekeeper 的入口都构建到各自的.wrangler/validate/。global-setup.ts 校验这两个产物存在(缺失直接抛错),并设置WORKSHOP_INTEGRATION_PREBUILT=1——harness 看到它会删掉config.build,因为共享构建已完成,每个 vitest fork 再重建只会争抢同一目录。

harness 启动:配置 patch 的三处讲究

startHarness()对每个 gatekeeper 的配置做三件事:

  • 路径钉死:inline 配置没有自己的文件路径,wrangler 会把相对main相对 harness 的root解析,所以main必须改成绝对路径、build.cwd钉到 Worker 自身目录——main由构建生成的 Worker(capnweb-validate 产物)不这么做,输出就会落到错误位置。
  • 只加套件要求的 services 绑定:GATEKEEPER_<binding>指向对应 Worker,entrypoint 固定GatekeeperVendor,这样 vendor 发现不会有意外的一行。
  • 本地 secrets 不进测试:harness 的root特意取一个不存放 vars 文件的目录。若 inline 配置落在仓库根,开发者本地的.dev.vars(比如CF_AI_GATEWAY_*)会覆盖配置里的同名 vars,套件在你机器上和 CI 上行为不一致,甚至发出真实 AI 流量。

workshop 侧同时不设CF_ACCESS_AUD(/api走未认证路径、开放密码注册)、把ADMINS设为["admin"]、默认删掉worker_loaders(绝大多数测试不需要 Gadget 执行)。

配置校验是"刻意宽松"的:harness.ts 用一个z.looseObjectschema 只校验 harness 自己触碰的字段,其余原样透传——wrangler 会在 Worker 启动时对整个文件重新校验。好处是配置损坏时在这里就带字段名报错,而不是被强制类型转换后死在某个更隐蔽的角落。

RPC 连接与登录:像浏览器,但不完全像

connect(baseUrl)把/api转成 ws/wss 地址开会话。signUp()用 SHA-256 顶替前端的 64 MiB argon2id——server 对这些字节原样存储比较、从不重新推导,所以确定性替身足够。waitFor()以 25ms 间隔在 30 秒内轮询,专门用于"效果只能通过 API 的最终状态观察到"的场景,比如账号出现在用户列表里。

用控制面操纵 fixture

fixture gatekeeper 在自身fetch()上挂了一组普通 HTTP 路由(/control/verify-outcome、/control/observer-events、/control/fetch-probe等),测试用harness.fetchWorker("gatekeeper-test", ...)调它。test-gatekeeper.ts 里addObserver()先问 verifier "谁在请求",再查控制状态,allow: false时直接throw new Error(reason)——gatekeeper 报告"此用户不可观察"的方式就是抛错,overseer 的失败处理正是围绕这个行为构建的。

控制路由对请求体逐字段校验,返回指明错在哪个字段的 400。注释说得很直白:这不是为了安全,而是为了失败模式——不校验的话,一个拼错的字段会注册给名为undefined的账号,gatekeeper 继续放行本该失败的账号,测试在几步之后死于一个与真实原因无关的断言。

断言与逃逸检查:一次"负向证明"

observer-reverification.test.ts 安装不带任何 handler的 interceptor——本文件不应有任何出站请求,发生即失败。它的afterAll里:

const unmocked = interceptor.getUnmockedCalls(); await harness?.server.close(); interceptor.uninstall(); expect(unmocked).toEqual([]);

但怎么证明拦截本身有效?同文件里/control/fetch-probe让 fixture Worker 对一个未 mock 的地址发起子请求:请求被拦截接住,以合成 500 返回,takeUnmockedCalls()精确取走这一条记录——只取自己的条目、不重置整个列表,afterAll仍能抓到并发兄弟测试放跑的逃逸。若 Worker 子请求能绕过被 patch 的globalThis.fetch直奔互联网,那所有"零逃逸"断言都是自证的。

teardown:reset 不是清盘工具

server.close()关闭 server。而server.reset()每次调用约 3 秒——比整个套件跑一遍还久——且会重启 server:server.url变 undefined,所有已打开的 WebSocket RPC 会话以 "WebSocket connection failed" 死掉。它是 teardown,不是可反复使用的存储清空器。

为什么这么设计:四个反直觉的决策

假定时器为什么救不了你?

直觉做法:vi.useFakeTimers()快进时钟,让凭证"过期"。行不通——假定时器 patch 的是测试进程的时钟,被测代码读的是 workerd 的时钟,跨进程的假时钟对它不可见。isTokenExpired()的 30 秒 skew 在 Worker 内部求值,你在测试进程里怎么拨都没用。

边界要分清:在vitest-pool-workers下测试与被测代码同处一个 isolate,假定时器确实可用;这条限制只属于跨进程的集成测试。所以时间敏感状态只能靠 fixture 的控制面制造——一次 HTTP 调用把验证结果设成拒绝,比拨时钟可靠得多。

为什么 overseer 逻辑要用 fixture 而不是真实 gatekeeper?

直觉做法:拿 gatekeeper-google 直接测。但 overseer 的用例需要一个能按命令拒绝 observer的 gatekeeper,真实 gatekeeper 的代价太高:OAuth 类要先 mock 一整套厂商认证面才能铸出账号;Context Library 只在观察被记录之后才拒绝,而记录观察需要 Worker Loader、斜杠命令或 AI 聊天快照——它还是单例,永远造不出"两个绑定同时失败"。

给真实 Worker 加测试钩子(比如"标记已观察")的方案被考虑过并否决:那等于 stub 掉 tracker 自己维护的状态,测试变成循环论证。

所以 fixture 是一个说着真实协议的真正 Worker,验证结果由测试经 HTTP 设定。两条边界:它只服务 overseer 逻辑,不是 per-vendor 覆盖的替代品;它刻意不区分"已定型的拒绝"与"运行性失败(凭证过期)"——两者到达 overseer 时都是抛出的错误,这是设计使然(overseer 把所有失败都当可修复的),只有一个allow旋钮,区分靠 reason 字符串承载。

为什么存储隔离靠约定而不靠 reset?

直觉做法:测试之间清空存储。实测数据摆在这里:server.reset()一次约 3 秒且杀死全部会话。于是存储在整个 harness 生命周期内持续存在,任何测试都不得假设干净起点。独立性靠"每次取全新身份":rpc-client.ts 的nextUsernames()用递增计数器生成alice7、bob7(Workshop 要求用户名以字母开头的字母数字串),资源 URL 每测试唯一,账号标签由 helper 分配而非调用方自选。

一个容易踩错的推论:"没有任何请求逃逸到互联网"的断言必须放afterAll而不是afterEach。it.concurrent下,某个afterEach触发时兄弟测试还在运行,它会检查并清空对方仍在用的状态,甚至丢掉一个本该由兄弟背锅的逃逸。

为什么两对依赖必须步调一致?

workerd 没有被直接钉住:wrangler 与 miniflare 各自依赖精确版本的 workerd。版本漂移的后果是 harness 启动失败(compatibility date 报错)——锁文件里装出了第二套运行时栈。pnpm-workspace.yaml 的做法是:catalog 里把miniflare精确钉到与 wrangler 配套的预发布版,overrides再把@cloudflare/vitest-pool-workers所依赖的 wrangler/miniflare 指向同一 catalog。升级 wrangler 而不动 miniflare,等于装了两套栈。

capnweb 双副本是另一类边界问题:消费方仓库会安装自己的工作区和public/子模块的工作区,两个 pnpm store 各自解析一份capnweb。stub 只能由拥有会话的那个实例序列化,混用就是:

TypeError: Cannot serialize value: [object RpcStub]

坑在于:开发机单次pnpm install会把两份去重合并,永远看不到这个错;CI 分开执行两次 install,它才首次出现。所以 toolkit 独占 capnweb 边界:回调 stub 一律用stubFor()铸造,RpcStub只作类型导入(编译期擦除,无碍)。本仓库用 lint 规则在结构上强制了这一点——包内对capnweb的值导入只允许出现在rpc-client.ts。

顺带一个更小的坑:workerd 把入口模块的每个具名导出都当 entrypoint,导出一个普通字符串常量会得到:

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"的由来,其余值必须保持模块私有。

复刻指南:为新 gatekeeper 写一套集成套件的 3 步

无论在本仓库内还是消费方仓库内,新增套件形态相同,且不需要 fork 任何 harness 代码:

  1. 新建 handler 模块(如google-handlers.ts):实现Handler签名,mock 厂商的 token 端点与 API 端点,返回真实形状的响应。注意约束:handler 只有在决定自己拥有该 URL 之后才可读取request——先消费 body 再返回 null,会破坏后续 handler 对同一流的读取。
  2. 把 harness 指向该包:startHarness({ gatekeepers: [{ binding: "GOOGLE", dir: "../gatekeeper-google" }] }),必要时用patch设置测试依赖的 vars。
  3. 不碰生产代码:真实 gatekeeper 原样运行,厂商外部面全部由 interceptor 的 handler 模块 mock。

消费方仓库(把本仓库作为public/子模块 vendor、以工作区依赖消费public/packages/integration-tests)还需守两条铁律:

  • 回调 stub 一律经stubFor(),禁止值导入RpcStub(类型导入可以)。
  • "零逃逸"断言放afterAll,不是afterEach。

集成测试避坑速查表:现象 / 原因 / 正确做法

现象原因正确做法
用例互相污染存储跨测试持久存在nextUsernames()全新身份 + 每测试唯一资源 URL
reset 后全部会话断开、白等 3 秒server.reset()是 teardown 而非清盘隔离靠全新身份,reset 只当 teardown
并发下逃逸断言误判、丢证据afterEach触发时兄弟测试仍在运行放afterAll,getUnmockedCalls()一次性断言
CI 报Cannot serialize value: [object RpcStub]capnweb 双副本,stub 跨实例一律stubFor();类型导入无碍
harness 启动报 compatibility date 错误wrangler/miniflare 漂移,装出第二套 workerd 栈随 catalog/overrides 步调一致升级
Unmocked outbound request抛错出站请求没被任何 handler 接住这是隔离保证本身:让它失败,而非触网

收束

这套集成测试架构的本质只有一句话:代码在另一个进程里,测试用真实传输驱动它,唯一被 stub 的是出站 HTTP。假定时器、存储 reset、真实 gatekeeper 三个直觉选项,都被这个前提逐一否掉,换来了 fixture 控制面、身份级隔离与组件间的版本/边界耦合。参数化正是它的扩展点:本仓库的套件验证 overseer 逻辑,消费方仓库的 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询