ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

在 workerd 里跑集成测试:cloudflare-os 端到端测试 harness 完整拆解

2026/10/5 6:42:07 拓冰建站 浏览量
在 workerd 里跑集成测试:cloudflare-os 端到端测试 harness 完整拆解 在 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-oscloudflare-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 自己维护的状态测试就变成循环论证了。所以仓库里躺着一个 fixturefixtures/gatekeeper-test/。它是个说着真实协议、跑真实 DurableObject 的 Worker但验证结果由测试通过 HTTP 控制路由说了算。记住它的定位——只服务 overseer 逻辑的测试不是 per-vendor 覆盖的替代品。凭什么说真在 workerd 里启动真实 Workerharness 的核心入口是 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由构建生成的 Workercapnweb-validate 产物还得把build.cwd钉到它自己的目录否则产物落到错误位置。清空本地 dev 变量。harness 从一个不含.dev.vars/.env的干净目录启动配置也不声明secrets。否则你机器上的CF_AI_GATEWAY_*之类的本地设置会渗进测试——轻则个人机器和 CI 行为不一致重则发出真实的 AI 流量。workshopConfig()还有两个关键调整只为套件点名的 gatekeeper 添加services绑定GATEKEEPER_binding指向对应 Workerentrypoint 固定为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就够了不需要任何拦截库。规则有四条loopbacklocalhost/127.0.0.1/[::1]默认放行让测试客户端直连 harness。安全敏感的场景可以关掉allowLoopback让模型作者发起的请求也走 handler 链彻底碰不到宿主服务。被allow放行真放出去的外部请求会强制加accept-encoding: identity。为什么Node 的 fetch 解压了压缩体却原样转发Content-Encodingworkerd 拿到明文会再解一次压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 证明拦截确实接住了——请求变成一个合成 500harness 代理出站请求代理侧失败以 500 返回而不是在 Worker 内 reject且takeUnmockedCalls(target)精确取回这一条只取自己的条目、不 reset 整个列表这样afterAll里仍能抓到并发兄弟测试放跑的逃逸。测试怎么和后端说话RPC clientrpc-client.ts 让测试用浏览器同款的方式说话——WebSocket 上的 Capn 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 2overseer 的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 Workertest-gatekeeper.ts。否决了什么在现成 gatekeeper 上加测试钩子循环论证用真实 gatekeeper 测 overseermock 成本主导测试。代价fixture 不模拟任何厂商的真实形态per-vendor 行为另有一套路径见后文。 fixture 内部几处设计直接服务于让 overseer 的失败逻辑可演练TestControlDurableObject 持有全部控制状态setVerifyOutcome按账号标签写入验证结果资源级优先于账号级默认放行保证协作者第一次打开必然成功GatekeeperVendor声明autoProvisionAccount: truecreateAccount()每次铸造全新账号而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 所给更新的 workerdharness 启动失败——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.tsallowTypeImports放行类型导入没有 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_PREBUILT1harness 看到它便删除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、provisionAccountfixture 无认证流一次 RPC 即铸造、newGadget()newGatekeeper()绑定资源再addCollaborator(bob, build)分享给 Bob。通过harness.fetchWorker调/control/verify-outcome把 Bob 的验证结果设为拒绝EXPIRED_REASONcredentials 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→ 并发下会误判甚至丢掉兄弟测试的逃逸证据放afterAllgetUnmockedCalls()一次断言。值导入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),仅供参考