cloudflare-os 集成测试源码解析:让真实 Worker 跑在 workerd 另一进程里,只 stub 出站 HTTP
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-oscloudflare-os 的 packages/integration-tests 是套只有一条规则的集成测试体系生产代码跑在 workerd 进程里测试进程用真实传输协议驱动它被 stub 掉的只有出站 HTTP。代码里所有看起来反直觉的规矩——不能用假定时器、不能清空存储、stub 必须走某个函数——都是这一条前提的推论。本文按源码把 harness、拦截器、RPC 客户端、fixture gatekeeper 的机制逐块拆完再集中回答每个取舍为什么是 A 不是 B读完可以直接把这套架构搬到你的 gatekeeper 上。一、为什么进程内单测在这里不够先明确被测对象是什么。cloudflare-os 是构建在 Cloudflare Workers 上的 agent workspaceworkshop-backend产品后端把若干 gatekeeper Worker 以 service 绑定的方式接入每个 gatekeeper 负责一个厂商资源的账号、凭证与授权浏览器与后端之间走 WebSocket 上的 Capn WebcapnwebRPC。把 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.tspatchglobalThis.fetchmock 出站请求未 mock 的调用被记录并抛错RPC 客户端src/rpc-client.ts以浏览器同款传输与/api对话注册、登录、开 workspace、记录 observer 提示fixture gatekeeperfixtures/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 套件被测 gatekeeperfixture 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.looseObjectschemaWORKER_CONFIG校验——只盯 harness 会碰的字段name、main、build、services、vars、worker_loaders等其余字段原样透传由 wrangler 在 Worker 启动时对整个文件重新校验。源码注释把动机写得很明白配置一旦损坏应当在这里带着字段名失败而不是活过一个类型转换之后在更奇怪的地方失败。两处路径改写。inline 配置没有自己的文件路径wrangler 会把相对main相对 harness 的root解析所以main必须转绝对路径对main由构建产生的 Workercapnweb-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指向对应 Workerentrypoint 固定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]让测试客户端直连 harnessallowLoopback: false可在安全敏感场景关闭届时连模型发起的请求也进不了宿主服务。allow回调放行并强制accept-encoding: identity。这是踩过坑的Node 的 fetch 解码压缩体后却原样转发Content-Encodingworkerd 会对明文再解一次——源码注释写明正是这个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之类无论拦截是否生效都会失败证明不了任何事。结果请求变成合成 500harness 代理出站请求代理侧的失败以 500 形式返回而不是在 Worker 内 reject且takeUnmockedCalls(target)精确取回这一条记录。3.3 rpc-client.ts说浏览器的语言测试客户端与浏览器用同一套传输connect(baseUrl)把/api转成ws:///wss://地址newWebSocketRpcSessionPublicApi()开会话。其上一组 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实现ObserverConfigCallbackcalls数组记下每次configure()它本身就是断言面并从脚本化队列应答。alwaysChoose(accountId, times)的times必须显式队列一空configure()就抛错——多出来的意外提示应当让测试失败而不是被静默应答。常量MAX_OBSERVER_PROMPTS 2overseer 的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外加一个旋钮验证结果。内部分四层类形态职责TestControlDurableObject全部控制状态的容器验证结果按账号标签为键outcome:${label}资源级outcome:${label}:${resourceUrl}优先于账号级默认放行——协作者第一次打开必须能成功、observer 事件日志是日志而非布尔钉住 add/remove 顺序并发测试按resourceUrl在服务端过滤、ambient 验证计数、动作状态机stage/apply/discard/hold/fail-nextGatekeeperVendorWorkerEntrypoint实现真实 vendor 协议describe()返回autoProvisionsAccount: truecreateAccount()每次调用铸造一个全新的test-uuidgadgets-test.example——每次调用一个新账号正是让测试聚焦 overseer 而非某家 OAuth 舞会的手段TestAccount/TestVerifierWorkerEntrypoint实现GatekeeperUsergetGatekeeperClassFor(url)只接受https://gadgets-test.example/things/*否则抛错verifier 的非标准方法identify()返回账号标签——约定是 overseer 只把 verifier 交还给铸造它的 vendor所以答案可信TestGatekeeperDurableObject每个绑定资源一个核心addObserver()先问 verifier谁在请求再查控制状态allow: false时直接throw new Error(reason)。抛错就是 gatekeeper 报告此用户不可观察的方式overseer 的失败处理正是围绕这一行为构建的。readValue()走真实审批流approvalQueue.authorizeObservation()后返回固定值 42Worker 自身的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:prebuildpackage.json 的test:run/test:watch都先执行pnpm run test:prebuild即 vite.config.ts 的build:test-gatekeeper任务——跑capnweb-validate build --cwd fixtures/gatekeeper-test --out .wrangler/validatefixture 由此获得与生产 gatekeeper 相同的 RPC 校验并dependsOngadgets/workshop-backend#build:integration-worker。fixture 的wrangler.jsonc的main直接指向.wrangler/validate/src/test-gatekeeper.ts自身声明没有任何 build 步骤。global-setup.ts先验证两个预构建产物存在缺失即抛错再设置WORKSHOP_INTEGRATION_PREBUILT1——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 秒/次比整个套件跑一遍还久且它重启 serverserver.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-workersminiflare/wrangler指向同一 catalog锁文件里只解析出一套 Wrangler/Miniflare/workerd 栈升 wrangler 必须同步升 miniflare否则装出第二套栈harness 启动即死见第六节4capnweb 边界硬直接值导入RpcStubstub 只能由拥有会话的那个 capnweb 实例序列化。消费方仓库装自己的 workspace加public/子模块的 workspace是两个独立 pnpm storecapnweb会解析出两份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),仅供参考