cloudflare-os 集成测试源码解析:让真实 Worker 跑在 workerd 另一进程里,只 stub 出站 HTTP
【免费下载链接】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 的 packages/integration-tests 是套只有一条规则的集成测试体系:生产代码跑在 workerd 进程里,测试进程用真实传输协议驱动它,被 stub 掉的只有出站 HTTP。代码里所有看起来"反直觉"的规矩——不能用假定时器、不能清空存储、stub 必须走某个函数——都是这一条前提的推论。本文按源码把 harness、拦截器、RPC 客户端、fixture gatekeeper 的机制逐块拆完,再集中回答每个取舍"为什么是 A 不是 B",读完可以直接把这套架构搬到你的 gatekeeper 上。
一、为什么"进程内单测"在这里不够
先明确被测对象是什么。cloudflare-os 是构建在 Cloudflare Workers 上的 agent workspace:workshop-backend(产品后端)把若干 gatekeeper Worker 以 service 绑定的方式接入,每个 gatekeeper 负责一个厂商资源的账号、凭证与授权,浏览器与后端之间走 WebSocket 上的 Cap'n Web(capnweb)RPC。
把 workspace 分享给协作者这条关键路径,会依次经过:overseer 通过 RPC 回调进客户端(ObserverConfigCallback)、gatekeeper 的addObserver()做验证、Durable Object 存储落状态、全程走 WebSocket 传输。单测如果把 RPC 层 mock 掉,你测到的就是 mock 自己的行为——传输层语义、workerd 运行时、序列化行为一进入路径,进程内测试全部失明;反过来对着真实部署测,又没法确定性地复现"协作者的凭证恰好过期了"这种状态。
于是唯一站得住的路线是:把运行时搬进测试(workerd),把传输搬进测试(真实 WebSocket),把剩下唯一不可控的变量——出站 HTTP——mock 掉。toolkit 的定位就在这条路线上,官方说明见 docs/integration-testing.md。
二、系统全景:一套参数化三件套 + 一个 fixture
| 组件 | 位置 | 职责 |
|---|---|---|
| 启动器 | src/harness.ts | 用 wrangler 的createTestHarness()把 workshop-backend 与 N 个 gatekeeper 作为真实 Worker 启动,内存中 patch checked-in 的wrangler.jsonc |
| 网络拦截器 | src/network-interceptor.ts | patchglobalThis.fetch:mock 出站请求,未 mock 的调用被记录并抛错 |
| RPC 客户端 | src/rpc-client.ts | 以浏览器同款传输与/api对话:注册、登录、开 workspace、记录 observer 提示 |
| fixture gatekeeper | fixtures/gatekeeper-test/ | 说着真实协议的真实 Worker,验证结果由测试经 HTTP 控制路由设定 |
| 编排 | src/global-setup.ts + vite.config.ts + vitest.config.ts | 预构建 Worker 入口、跨 fork 共享构建、源码变更触发重建 |
toolkit 的对外面是 package.jsonexports里的五个入口(harness、agent-session、network-interceptor、rpc-client、mock-model)。这是刻意的:消费方仓库(consumer repo)把它作为工作区依赖引进来,用这五件东西组装自己的套件,一行都不用 fork。
套件有两种形态,边界先划清:
本仓库套件(packages/integration-tests) | 消费方仓库的 per-vendor 套件 | |
|---|---|---|
| 被测 gatekeeper | fixture Worker,验证结果由测试控制 | 真实厂商 gatekeeper,一行不改 |
| 覆盖目标 | overseer 自身的 observer 逻辑 | 真实过期凭证的端到端路径 |
| 各自动手 | harness、interceptor、RPC client | 该厂商的 handlers 与 token 铸造 |
需要说透:本仓库当前只有左列。右列是 toolkit 参数化设计"指向"的形态——harness 接受 gatekeeper列表、interceptor 接受可插拔handler 模块,正是为了让右列能在本仓库之外被添加。文档里所有描述 per-vendor 套件的内容,读作"这种形态的工作示例"即可。
三、机制拆解
3.1 harness.ts:把生产 Worker"原地打补丁"地启动
startHarness()干三件事:
- 读取并 patch 每个 Worker checked-in 的
wrangler.jsonc。readWorkerConfig()用jsonc-parser解析,再按一个刻意宽松的z.looseObjectschema(WORKER_CONFIG)校验——只盯 harness 会碰的字段:name、main、build、services、vars、worker_loaders等,其余字段原样透传,由 wrangler 在 Worker 启动时对整个文件重新校验。源码注释把动机写得很明白:配置一旦损坏,应当在这里带着字段名失败,而不是"活过一个类型转换之后在更奇怪的地方失败"。 - 两处路径改写。inline 配置没有自己的文件路径,wrangler 会把相对
main相对 harness 的root解析,所以main必须转绝对路径;对main由构建产生的 Worker(capnweb-validate 产物),还必须把build.cwd钉到它自己的目录,否则构建产物落到错误位置——scripts/run-dev-server.ts 出于同一原因做了同样的事。 - 从一个空目录启动。
createTestHarness的root是专用空目录HARNESS_ROOT(../.wrangler/harness-root)。原因很隐蔽:wrangler 把 inline 配置当作位于<root>/wrangler.jsonc,会加载该目录的.dev.vars/.env,且这些值可以覆盖配置自身的同名vars。如果开发者在仓库根目录放了本地的CF_AI_GATEWAY_*,套件就会在他机器上和 CI 上行为不一致——严重到发出真实 AI 流量。所以每个 harness 都从一个不放任何 var 文件的目录启动;配置也不许声明secrets(那会让 wrangler 把process.env以同样方式折进 vars)。
workshopConfig()还做了三处产品层面的调整:
- 只为套件点名的 gatekeeper 添加 service 绑定:
GATEKEEPER_<binding>指向对应 Worker,entrypoint 固定GatekeeperVendor。Workshop 靠扫描这些绑定发现 vendor,"只加被要求的"意味着 observer 配置提示里不会出现意外行; - 不设
CF_ACCESS_AUD:/api走未认证路径、开放密码注册,ADMINS设为["admin"]; - 默认删除
worker_loaders:多数集成测试不需要 Gadget 执行,只有显式enableGadgetExecution才保留 loader。
启动后返回的Harness有两个值得注意的成员:url(运行中 server 的基地址)和fetchWorker(name, ...)——后者直接向指定 Worker 自身的 HTTP entrypoint 派发请求,host 永远不会被解析、请求直达该 Worker,因此不需要routes配置,但路径仍要匹配该 Worker 的预期。fixture 的控制路由就是这么调用的。
另有一个小工具settleRestart():扩大协作者的验证范围会通过约 100ms 后 abort DO 来重启 workspace,所以断言"这次改动没有重启 workspace"的测试要先等RESTART_SETTLE_MS = 400毫秒——让违规的重启落在断言之前,而不是落在测试结束之后。
3.2 network-interceptor.ts:唯一的 stub 层
原理简到有点不可思议:createTestHarness会把 Worker 的出站fetch()路由回 Node 进程,所以patchglobalThis.fetch就够了,不需要任何拦截库。install()的派发链:
- loopback 默认放行(
localhost/127.0.0.1/[::1]),让测试客户端直连 harness;allowLoopback: false可在安全敏感场景关闭,届时连模型发起的请求也进不了宿主服务。 allow回调放行,并强制accept-encoding: identity。这是踩过坑的:Node 的 fetch 解码压缩体后却原样转发Content-Encoding,workerd 会对明文再解一次——源码注释写明,正是这个"Gzip decompression failed"杀死了本地 eval 目标里的每一条 Anthropic 流。- handler 链:handler 是纯函数签名
(url, method, headers, request) => Response | null | Promise<...>,返回null表示"不接,下一个试",返回Response即接管。约束微妙在:handler只有先决定自己拥有该 URL 之后才允许读request——先消费 body 再返回null,会毁掉后续 handler 对同一流的读取。handler 可以是 async 的:有些要等测试在 Worker 发起请求之后才决定回什么。 - 记录 + 抛错:没有 handler 接住的请求被记成
METHOD url并立即throw new Error("Unmocked outbound request: ...")。未 mock 的调用失败测试,而不是悄悄触网——隔离保证的机制层就是这四步。
记录语义给了三个动词:getUnmockedCalls()(peek,供afterAll用)、takeUnmockedCalls(substring)(只取自己的条目、不动列表,留给afterAll继续抓剩下的)、reset()(清空)。
这里还有个"负向证明"值得一读。observer-reverification.test.ts 里,/control/fetch-probe让 fixture Worker 对https://example.com/definitely-not-mocked发起真实子请求:如果 Worker 子请求能绕过被 patch 的globalThis.fetch直奔互联网,其他所有"零逃逸"断言都是测不到的空话(它们同样会"通过")。probe 的目标刻意选一个真实可解析的 host——指向.invalid之类无论拦截是否生效都会失败,证明不了任何事。结果:请求变成合成 500(harness 代理出站请求,代理侧的失败以 500 形式返回,而不是在 Worker 内 reject),且takeUnmockedCalls(target)精确取回这一条记录。
3.3 rpc-client.ts:说浏览器的语言
测试客户端与浏览器用同一套传输:connect(baseUrl)把/api转成ws:///wss://地址,newWebSocketRpcSession<PublicApi>()开会话。其上一组 helper,每个解决一个具体问题:
signUp/logIn:密码用确定性 SHA-256 代替前端的 64 MiB argon2id——注释的理由:server 对这些字节原样存储比较、从不重新推导,确定性替身足够。nextUsernames("alice", "bob")→["alice7", "bob7"]:递增计数器保证两个测试永不撞名。Workshop 要求用户名字母开头且字母数字,所以前缀也得遵守。这是存储隔离的核心(见第四节)。waitFor():30 秒截止、25 ms 间隔轮询,用于"效果只能通过 API 最终状态观察"的场景(如账号出现在用户列表)。listConnectedAccounts():驱动subscribeConnectedAccounts()到ready(),把增量 add/remove 事件收集成快照;using声明按逆序释放——先退订(告知 server 丢弃它的副本)、再释放原始 stub——并覆盖订阅调用自身抛错的路径。accountLabel():镜像 overseer 的#describeObserverFailures的标签优先级(uniqueName || displayName || "account N"),测试断言的消息与用户读到的逐字一致。ObserverConfigRecorder:实现ObserverConfigCallback,calls数组记下每次configure()(它本身就是断言面),并从脚本化队列应答。alwaysChoose(accountId, times)的times必须显式:队列一空configure()就抛错——多出来的意外提示应当让测试失败,而不是被静默应答。常量MAX_OBSERVER_PROMPTS = 2(overseer 的MAX_CONFIG_REPROMPTS为 1,即初始提示 + 至多一次重提示)在此集中断言一处,避免每个套件重复魔法数字。stubFor():把回调对象包成可过会话的RpcStub。注释解释了为什么它必须是唯一入口——见 4.2 第 4 行。
3.4 fixture gatekeeper:一个带"旋钮"的真实 Worker
先说为什么必须有它,因为这是全套件里最可争议的设计。
overseer 的用例需要一个能按命令拒绝 observer的 gatekeeper。每个现役公开 gatekeeper 都拒绝得了,但代价会主导整个测试:OAuth 类在账号存在之前就要 mock 一整面厂商认证面;Context Library 只有在观察已被记录之后才拒绝——那需要一次 gadget 读会话(即 Worker Loader)、一次斜杠命令或一次 AI 聊天快照,而且它是单例,永远造不出某些用例要的"两个绑定同时失败"。给这些 Worker 加测试钩子("标记已观察"之类)的方案被考虑过并否决:钩子 stub 掉的正是 tracker 要维护的状态本身,测试变循环论证。
于是 test-gatekeeper.ts 是一个说着真实协议的真实 Worker,外加一个旋钮:验证结果。内部分四层:
| 类 | 形态 | 职责 |
|---|---|---|
TestControl | DurableObject | 全部控制状态的容器:验证结果按账号标签为键(outcome:${label},资源级outcome:${label}:${resourceUrl}优先于账号级,默认放行——协作者第一次打开必须能成功)、observer 事件日志(是日志而非布尔,钉住 add/remove 顺序,并发测试按resourceUrl在服务端过滤)、ambient 验证计数、动作状态机(stage/apply/discard/hold/fail-next) |
GatekeeperVendor | WorkerEntrypoint | 实现真实 vendor 协议:describe()返回autoProvisionsAccount: true;createAccount()每次调用铸造一个全新的test-<uuid>@gadgets-test.example——"每次调用一个新账号"正是让测试聚焦 overseer 而非某家 OAuth 舞会的手段 |
TestAccount/TestVerifier | WorkerEntrypoint | 实现GatekeeperUser;getGatekeeperClassFor(url)只接受https://gadgets-test.example/things/*,否则抛错;verifier 的非标准方法identify()返回账号标签——约定是 overseer 只把 verifier 交还给铸造它的 vendor,所以答案可信 |
TestGatekeeper | DurableObject,每个绑定资源一个 | 核心:addObserver()先问 verifier"谁在请求",再查控制状态,allow: false时直接throw new Error(reason)。抛错就是 gatekeeper 报告"此用户不可观察"的方式,overseer 的失败处理正是围绕这一行为构建的。readValue()走真实审批流approvalQueue.authorizeObservation()后返回固定值 42 |
Worker 自身的fetch()还挂着一排 HTTP 控制路由(/control/verify-outcome、/control/observer-events、/control/expire-credentials、/control/fetch-probe等十余条),测试经harness.fetchWorker("gatekeeper-test", ...)调用。请求体被逐字段校验,任何拼写错误得到指明字段的 400——源码注释说得直白:这不是为了安全(调用者只有本包 helper),而是为了失败模式:不校验的话,拼错的字段会给名为undefined的账号注册结果,gatekeeper 继续放行本应失败的账号,测试死在几步之后一条与真实原因毫不相干的断言上。
还有一处刻意不建模:定型拒绝(你不可读此数据)与运行性失败(凭证过期)到达 overseer 时完全一样——都是抛出的错误,overseer 无法区分。这是设计使然,因为它把所有失败都视为可修复的。所以这里只有一个控制旋钮allow,区分由 reason 字符串承载:测试用credentials expired — please reconnect与You do not have access to this thing.两种文本来演练两种叙事。
文件头注释本身也是一条规则:Worker 入口模块只能导出类和默认 handler——workerd 把每个具名导出都当 entrypoint,导出一个普通字符串常量就会得到Incorrect type for map entry 'THING_URL_PATTERN': the provided value is not of type 'function or ExportedHandler'.
3.5 编排:预构建与 watch 重建
套件能跑起来还依赖两处管线:
test:prebuild:package.json 的test:run/test:watch都先执行pnpm run test:prebuild,即 vite.config.ts 的build:test-gatekeeper任务——跑capnweb-validate build --cwd fixtures/gatekeeper-test --out .wrangler/validate(fixture 由此获得与生产 gatekeeper 相同的 RPC 校验),并dependsOn@gadgets/workshop-backend#build:integration-worker。fixture 的wrangler.jsonc的main直接指向.wrangler/validate/src/test-gatekeeper.ts,自身声明没有任何 build 步骤。- global-setup.ts:先验证两个预构建产物存在(缺失即抛错);再设置
WORKSHOP_INTEGRATION_PREBUILT=1——harness 看到它就删除config.build,因为共享的.wrangler/validate构建已完成,每个 fork 里重建只会争抢该目录。watch 模式下onTestsRerun先waitForTestRunEnd()再重建:重跑并不会取消被它替换的那次运行,若先重建,会覆盖仍在启动的 Worker 正在读取的文件;另外它用prependListener("unlink", ...)补了 vitest 的一个盲区——删除文件走onFileDelete路径时不会咨询forceRerunTriggers,把 Worker 输入文件的删除路径转手给onFileChange才能并入既有的那次重跑(重命名因此收敛为一次重跑)。
vitest.config.ts 补齐其余:include只覆盖__tests__/**/*.test.ts(套件文件全部平铺在该目录,每个文件独占一个 harness 进程,文件内用例it.concurrent并行);forceRerunTriggers指向 Worker 输入源文件;testTimeout/hookTimeout都是 120 秒——workerd 启动与真实 RPC 往返需要这个量级的余量。
四、设计取舍:硬约束与软约定
这一节是全文重心。把前述所有规则按来源分成两类:硬约束(运行时物理决定,没有商量余地)与软约定(可以选别的做法,但付出了明确代价)。
4.1 最大的取舍:fixture 而不是真实 gatekeeper
把真实 gatekeeper 拉进来测 overseer,等于用整面厂商认证面的 mock 成本去买一个"拒绝"信号;加测试钩子又是循环论证。fixture 的解法是把"拒绝"变成一次 HTTP 调用,代价是它只服务 overseer 逻辑,绝不是 per-vendor 覆盖的长期替代品。测真实 gatekeeper 才是预期演进方向——这正是 harness 接受 gatekeeper列表、interceptor 接受可插拔handler 模块的原因:未来一个gatekeeper-google套件就是"加google-handlers.ts、把 harness 指向那个包",生产代码零修改,与消费方仓库的 per-vendor 套件完全同形。
4.2 逐条对照:被放弃的方案、采纳的方案、付出的代价
| # | 取舍点(性质) | 被放弃的方案 | 采纳的方案 | 代价 |
|---|---|---|---|---|
| 1 | 时间控制(硬) | vi.useFakeTimers()——它 patch 的是测试进程的时钟,被测代码读的是 workerd 的时钟,跨进程不可见(isTokenExpired()的 30 秒 skew 就在 gatekeeper 内部求值) | 把时间敏感状态做成 fixture 可通过 HTTP 设定的状态(/control/verify-outcome一族) | 边界要说清:vitest-pool-workers下测试与被测代码同一 isolate,假定时器照常可用;此限制只针对跨进程集成测试 |
| 2 | 存储清空(硬) | server.reset()——实测约 3 秒/次,比整个套件跑一遍还久;且它重启 server,server.url变 undefined,所有已打开的 WebSocket RPC 会话以 "WebSocket connection failed" 死掉 | reset 只当 teardown;存储在整个 harness 生命周期内持续存在 | 任何测试都不得假设干净起点;独立性靠"每次取全新身份"购买(软约定,第 7 行) |
| 3 | 版本联动(硬) | wrangler 与 miniflare 各自演进 | pnpm-workspace.yaml 的 catalog 把miniflare钉死精确到预发布版(catalog 里wrangler所依赖的那一个),overrides再让@cloudflare/vitest-pool-workers>miniflare/wrangler指向同一 catalog,锁文件里只解析出一套 Wrangler/Miniflare/workerd 栈 | 升 wrangler 必须同步升 miniflare,否则装出第二套栈,harness 启动即死(见第六节) |
| 4 | capnweb 边界(硬) | 直接值导入RpcStub | stub 只能由拥有会话的那个 capnweb 实例序列化。消费方仓库装自己的 workspace加public/子模块的 workspace,是两个独立 pnpm store,capnweb会解析出两份:toolkit 的rpc-client拿子模块那份,消费方自己包的导入拿另一份 |
【免费下载链接】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),仅供参考