我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

cloudflare-os workerd 集成测试实战:用 wrangler Test Harness 驱动真实 Worker,只 stub 出站 HTTP

cloudflare-os workerd 集成测试实战:用 wrangler Test Harness 驱动真实 Worker,只 stub 出站 HTTP cloudflare-os workerd 集成测试实战用 wrangler Test Harness 驱动真实 Worker只 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 的集成测试回答一个问题如何让workshop-backend与 gatekeeper 的生产代码路径运行在 workerd 集成测试里却只 stub 出站 HTTP不 mock 进程内的对象不共享数据库夹具——被测代码在另一个进程里。以下内容拆解packages/integration-tests的机制层harness、网络拦截器、RPC client与塑造它们的六条设计决策读完可以为任何新 gatekeeper 复刻一套同形套件且不用 fork 一行 harness 代码。 先钉死坐标系集成测试套件的两种形态docs/integration-testing.md 开篇就给出坐标系。存在两种形态的套件后续所有机制都在这张表的框架内展开本仓库的packages/integration-tests消费方仓库的 per-vendor 套件运行方式pnpm testCI 常规测试任务自己的 CI 步骤Gatekeeperfixture Worker验证结果由测试设定真实厂商 gatekeeper未经修改覆盖范围overseer 的 observer 逻辑真实过期的凭证端到端负责内容harness、interceptor、RPC client该厂商的 handlers 与 token 铸造消费方仓库指把本仓库作为public/子模块 vendor 进来、再以其工作区依赖方式消费public/packages/integration-tests的仓库。当前仓库里不存在第二种套件也没有任何东西依赖它存在。但它被详细描述是因为 toolkit 的参数化就是为它准备的harness 接受 gatekeeper列表、interceptor 接受可插拔handler 模块正是为了让套件可以在本仓库之外添加而无需 fork 二者。机制一harness 如何把两个真实 Worker 拉起来harness.ts 的startHarness()是入口。它调用 wrangler 的createTestHarness()把workshop-backend与一个或多个 gatekeeper 作为真实 Worker 启动配置是 checked-in 的wrangler.jsonc在内存中 patch 出来的。启动前它做了四件看似琐碎、实际决定套件能否复现的事用宽松的 schema 校验配置。WORKER_CONFIG是一个z.looseObjectL44-60只声明 harness 会触碰的字段name、main、services、vars等其余字段原样透传由 wrangler 在 Worker 启动时对整个文件重新校验。注释说得很直白schema 只保护本文件触碰的字段配置一旦损坏会在这里带着字段名报错而不是被强制类型转换后在更隐蔽的地方失败。把相对路径钉成绝对路径。readWorkerConfig()把main拼成绝对路径、把build.cwd钉到 Worker 自身目录L90-91。因为 inline 配置没有自己的文件路径wrangler 会把相对main相对 harness 的root解析对main由构建生成的 Workercapnweb-validate 产物不钉cwd输出会落到错误位置。杜绝本地 dev secrets 泄漏。HARNESS_ROOTL28是一个不存放任何 var 文件的目录wrangler 会把 inline 配置视为位于root/wrangler.jsonc从而加载该目录的.dev.vars/.env并让其覆盖配置变量。开发机上的CF_AI_GATEWAY_*若泄漏进来套件在个人机器与 CI 上行为会不一致甚至发出真实 AI 流量。裁剪 workshop 配置。workshopConfig()L95-123只添加套件请求的services绑定GATEKEEPER_binding→ 对应 Workerentrypoint 固定GatekeeperVendor这样buildGatekeeperVendorMap()只会发现这些 vendorobserver 配置提示不会出现意外行不设置CF_ACCESS_AUD让/api走未认证路径、开放密码注册并把ADMINS设为[admin]默认删除worker_loaders只有显式开启enableGadgetExecution时才保留。启动后返回的Harness暴露url和fetchWorker(name, ...)——后者直接向指定 Worker 自身的 HTTP entrypoint 派发请求host 永远不会被解析请求直达该 Worker所以无需routes配置。fixture 的 HTTP 控制面就是靠它调用的testControl()L201-209。机制二网络拦截器整个体系唯一被 stub 的层network-interceptor.ts 是隔离保证的机制层。原理只有一句createTestHarness会把 Worker 的出站fetch()路由回 Node 进程所以 patchglobalThis.fetch就够了不需要任何拦截库。行为规则默认放行 loopbacklocalhost/127.0.0.1/[::1]L62-68让测试客户端直连 harness安全敏感场景可关闭allowLoopback让模型作者发起的请求也走 handler 链从而无法触达宿主服务。对已放行的外部请求强制accept-encoding: identityL77-82。Node 的 fetch 解压了压缩体却原样转发其Content-Encodingworkerd 会对明文再次解压——注释记录了这个 bug 的真实代价Gzip decompression failed 杀死了本地 eval 目标里的每条 Anthropic 流。未被任何 handler 接住的请求记录并抛错L90-91。未 mock 的调用让测试失败而不是悄悄触网Unmocked outbound request: GET https://escaped.test/xhandler 是纯函数签名(url, method, headers, request) Response | nullL18-23返回null表示不接、让下一个 handler 试返回Response即接管。约束微妙handler 只有在决定自己拥有该 URL 之后才可读取request——先消费 body 再返回null会破坏后续 handler 对同一流的读取。handler 也可以是 async 的有些需要等测试在 Worker 发起请求后才决定返回什么CF Access 转移的 mock 就依赖这一点见 network-interceptor.test.ts L74-84 的 parked handler 用例。机制三RPC client 如何说浏览器同款的话rpc-client.ts 让测试像浏览器一样与 Workshop 通信。关键实现connect(baseUrl)L50-54把/api转成 ws/wss URL用 capnweb 的newWebSocketRpcSessionPublicApi()开一个 RPC 会话——与浏览器使用的传输层完全一致。signUp/logInL76-89passwordHashFor()直接 SHA-256跳过前端的 argon2id每次 64 MiB。这不是偷工减料server 对这些字节原样存储比较、从不重新推导所以确定性替身足够。waitFor()L22-3130 秒内以 25ms 间隔轮询用于效果只能通过 API 的最终状态观察的场景如账号出现在用户列表。ObserverConfigRecorderL210-261实现ObserverConfigCallback记录每次configure()调用calls数组即断言面并从脚本化队列应答。alwaysChoose(accountId, times)的times必须显式——队列空时configure()会抛错一次意外的多提示应当失败而不是被静默应答。MAX_OBSERVER_PROMPTS 2L202作为常量集中一处避免每个套件重复魔法数字overseer 侧的MAX_CONFIG_REPROMPTS是 1见 overseer.ts L8394即初始提示加至多一次重提示。accountLabel()L104-107镜像 overseer 内部#describeObserverFailures的优先级uniqueName || displayName || account N保证测试断言的消息与用户实际读到的一致。机制四fixture gatekeeper一个听指挥的真实 Workerfixtures/gatekeeper-test/src/test-gatekeeper.ts 是说着真实协议的真正 Worker其验证结果由测试通过 HTTP 控制路由设定。它内部分四层组件角色测试控制的旋钮TestControlDurableObject全部控制状态验证结果、observer 事件日志、动作状态机setVerifyOutcome(label, outcome, resourceUrl?)按账号标签为键资源级结果优先默认放行保证协作者第一次打开必然成功GatekeeperVendorWorkerEntrypoint真实 vendor 协议describe()返回autoProvisionsAccount: truecreateAccount()每次调用铸造一个全新账号test-12位hexgadgets-test.exampleTestAccountGatekeeperUser账号身份getVerifier()返回TestVerifieridentify()返回账号标签getGatekeeperClassFor(url)校验 URL 必须是https://gadgets-test.example/things/*TestGatekeeperDurableObject每绑定资源一个observer 准入addObserver()先问 verifier 谁在请求再查控制状态allow: false时throw new Error(reason)抛错就是 gatekeeper 报告此用户不可观察的方式也是 overseer 失败处理围绕的核心行为L666-677。readValue()走approvalQueue.authorizeObservation()的真实审批流后返回固定值 42L556-567。HTTP 控制面是 Workerfetch()上的一组普通路由/control/verify-outcome、/control/observer-events、/control/ambient-verification-count、/control/action-state、/control/fetch-probe等请求体被逐字段校验拼错字段会得到指明错在哪的 400。注释解释了这不是为了安全调用者只有本包内的 helper而是为了失败模式一个未校验的拼错字段会注册一个名为undefined的账号结果gatekeeper 继续放行本该失败的账号测试在若干步之后死于一个与真实原因毫不相干的断言L724-727。fixture 还刻意不建模已定型的拒绝你不可读此数据与运行性失败凭证过期的区别两者到达 overseer 时完全一样都是抛出的错误overseer 无法区分——这是设计使然因为它把所有失败都视为可修复的。所以只有一个控制旋钮allow区分由 reason 字符串承载测试通过选择不同的 reason 文本演练两种叙事L16-20。塑造全局的六条设计决策以下六条由被测代码在另一个进程里这一事实推导而来每条都否决过一个替代方案。1. 假定时器跨不了进程边界vi.useFakeTimers()patch 的是测试进程的时钟而被测代码读取的是workerd 的时钟——跨进程假时钟对它不可见。比如isTokenExpired()的 30 秒 skew 位于gatekeeper-shared内部在 Worker 内求值测试进程里的假定时器根本无法影响它。时间敏感状态只能由 fixture 控制面制造一次POST /control/expire-credentials或一次setVerifyOutcome({allow: false})就等价于凭证过期了。边界在vitest-pool-workers下测试运行在与被测代码同一个 isolate 内此时假定时器确实可用。这条限制只针对跨进程集成测试不适用于 in-isolate 单元测试docs/integration-testing.md L36-43 同时记录了这一点。2. overseer 的逻辑用一个听指挥的 fixture 测而不是真实 gatekeeperoverseer 的测试用例需要这样一个 gatekeeper能够按命令拒绝一个 observer。给现役公开 gatekeeper 做到这一点的替代方案都被否决了mock 整套 OAuth 厂商面账号存在之前就需要 mock 整个厂商认证面成本主导整个测试在真实 Worker 上加测试钩子如标记已观察那会 stub 掉 tracker 维护的状态本身使测试变成循环论证——你在验证 tracker 是否正确拒绝时却人为改写了它判定依据的状态Context Library只有在观察已被记录之后才会拒绝需要一次 gadget 读会话Worker Loader或斜杠命令调用且它是单例永远无法产生某些用例需要的两个同时失败的绑定。因此 fixture 是一个说着真实协议、结果可设定的真正 Worker。边界它只服务于 overseer 逻辑的测试绝不是 per-vendor 覆盖的替代品——文件头注释L1-21把这条边界写死了。测真实 gatekeeper 是预期演进方向而 fixture 的存在不阻塞它。3. 存储隔离靠约定因为替代方案更糟server.reset()存在实测数据一锤定音每次调用约 3 秒比整个套件跑一遍还久它还会重启 serverserver.url变为 undefined所有已打开的 WebSocket RPC 会话以 WebSocket connection failed 死掉。它不是可在测试之间使用的存储清空工具而是 teardown。 被否决的替代方案就是每个测试之间 reset慢且把并发用例全部打回串行。于是存储在整个 harness 生命周期内持续存在任何测试都不得假设干净的起点rpc-client.ts L33-35 的注释用大写写明了这条军规。测试通过每次取全新身份保持独立nextUsernames()递增计数器生成alice7、bob7这类用户名Workshop 要求用户名以字母开头且为字母数字因此前缀也必须如此资源 URL 每测试唯一账号标签由 connect/provision helper 分配而非调用方自选。 一个容易踩错的推论没有任何请求逃逸到互联网的断言必须放在afterAll而不是afterEach。在it.concurrent下某个afterEach触发时兄弟姐妹仍在运行它会检查并清空它们还在使用的状态——甚至可能丢掉一个本该由某个兄弟测试背锅的逃逸请求。4. wrangler、miniflare、workerd 三层必须步调一致本仓库不直接钉workerdwrangler与miniflare各自精确依赖一个workerd。pnpm-workspace.yaml 的catalog把miniflare精确钉在与wrangler配套的预发布版L19overrides再把cloudflare/vitest-pool-workers的传递依赖指向同一组 catalog 版本L42-43使 lockfile 只解析出一套 Wrangler/Miniflare/workerd 栈。被否决的替代方案是各自为政地升级升 wrangler 不升 miniflare或反之会装入第二套栈harness 启动失败compatibility date 对不上。规则只有一条三者联动升级不能单独动任何一个。5. capnweb 边界由 toolkit 独占回调 stub 一律经stubFor()铸造消费方仓库安装自己的工作区和public/子模块的工作区作为两个独立的 pnpm store。于是capnweb解析出两份不同副本toolkit 的rpc-client拿到子模块那份而从消费方自己包导入capnweb的代码拿到另一份。stub 只能由拥有会话的那个实例序列化混用必然失败TypeError: Cannot serialize value: [object RpcStub]陷阱在于开发机上单次pnpm install会把两份去重合并不会暴露此问题它首次出现在 CI——CI 分别执行pnpm install与pnpm --dir public install。本地复现的办法就是照 CI 做两次安装。因此 rpc-client.ts 的stubFor()L66-68是唯一的铸造口且本仓库在结构上强制执行docs/integration-testing.md L107-109 记录 vite 配置的 lint 规则把本包内capnweb的值导入限制到rpc-client.tsallowTypeImports放行类型导入。边界把RpcStub当类型导入没问题——类型在编译期擦除根本不涉及运行时副本。6. Worker 入口模块只能导出类与默认 handlerworkerd 把入口模块的每个具名导出都当作一个 entrypoint。从 fixture 导出一个普通字符串常量会得到Incorrect type for map entry THING_URL_PATTERN: the provided value is not of type function or ExportedHandler.类型导出没问题它们被擦除。任何其它值必须保持模块私有——test-gatekeeper.ts L37-38 的注释Noting but classes and the default handler may be exported就源于这次失败。边界这条只对 entry 模块成立非入口模块的导出不受此限。实操锚点如何运行这套 workerd 集成测试在仓库根目录三条命令覆盖所有入口# CI 常规路径跑全仓库测试任务见根 package.json 的 test pnpm test # 仅集成测试包预构建 一次性运行 pnpm --filter gadgets/integration-tests run test:run # 监听模式预构建 watch pnpm --filter gadgets/integration-tests run test:watch三者关系package.json 里test:runtest:prebuild vitest run。test:prebuild执行vp run -F gadgets/integration-tests build:test-gatekeeper该任务在 vite.config.ts 中声明dependsOn: [gadgets/workshop-backend#build:integration-worker]把workshop-backend与 fixture gatekeeper 的main都构建到各自的.wrangler/validate/。global-setup.ts 校验两个预构建产物存在缺失即抛错设置WORKSHOP_INTEGRATION_PREBUILT1——harness 看到它就删除config.build因为共享构建已完成每个 fork 里重建会争抢该目录。watch 模式下onTestsRerun会先waitForTestRunEnd()再重建避免覆盖仍在启动的 Worker 正在读取的文件被删除的 Worker 输入文件则通过prependListener(unlink)转成onFileChange路径触发重跑。vitest.config.ts 配套了globalSetup、forceRerunTriggers覆盖 Worker 输入源文件见 worker-inputs.ts以及 120 秒的testTimeout/hookTimeout——workerd 启动、真实 RPC 往返都需要这个量级的余量。 完整走查observer 重新验证回归测试observer-reverification.test.ts 回归验证一个真实 bug协作者的 observer 账号选择在首次成功打开后被持久化此后每次打开ensureObserver()都无提示地重新验证当验证失败凭证过期很常见时打开曾经死在 You are not permitted to observe all of the data this Gadget has accessed 且无路可走。修复后应通过ObserverConfigCallback携带失败信息重新提示重提示仍无解则指明哪条连接、哪个账号失败。以 re-prompts with the failed account when verification fails since the last openL237-259为例铸造场景。shareGadgetWithBob(publicApi, [expire])nextUsernames(alice, bob)生成不冲突身份各自signUpSHA-256 密码哈希AliceprovisionAmbientAccount(test)触发 fixture 的GatekeeperVendor.createAccount()铸造全新账号newGadget()newGatekeeper(account.id, thingUrl(expire))绑定资源addCollaborator(bob, build)共享Bob 也铸造自己的账号。failBob(reason)是一个闭包等价于POST /control/verify-outcome。首开持久化选择。bobOpensAndCloses()ObserverConfigRecorder().alwaysChoose(bobAccount.id, MAX_OBSERVER_PROMPTS)bobApi.openGadget(gadgetId, undefined, stubFor(recorder))。overseer 首次打开遇到未覆盖的绑定调recorder.configure([need])recorder 记录calls[0]并应答 Bob 的账号。打开成功后 dispose。断言callCount 1——这一次打开把 Bob 的账号选择持久化了bug 所需的状态由此就位。让验证失败。shared.failBob(EXPIRED_REASON)经harness.fetchWorker调/control/verify-outcomeTestControl.setVerifyOutcome()把{allow: false, reason}写入 KV键outcome:label。二开触发重提示。再次openGadget。选择已持久化overseer 直接重验证 →TestGatekeeper.addObserver()抛Error(EXPIRED_REASON)→ overseer 构建带failure的重提示 →recorder.configure()记录第二次调用并应答同一个仍在失败的账号重提示预算耗尽打开被拒绝。断言second.callCount 1、need.failure.accountId bobAccount.id、need.failure.reason含EXPIRED_REASON、openGadget以/could not confirm/i拒绝。走查中两个断言值得单独解释为什么 reason 文本要精心挑选。fixture 只有一个旋钮allow凭证过期与定型拒绝在 overseer 看来都是同一个抛错。EXPIRED_REASON credentials expired — please reconnect与DENIED_REASON You do not have access to this thing.两个常量测试文件 L27-30因此存在overseer 无法区分失败种类用户读到的就是 reason 本身测试必须让 reason 承载两种叙事。为什么alwaysChoose的times要显式传 2。ObserverConfigRecorder.configure()在队列空时抛错L252-261。若套件忘了排队第二个应答一次意外的多提示会被静默应答、测试假绿显式MAX_OBSERVER_PROMPTS让overseer 多提示了一次直接表现为失败。⚠️ 常见陷阱速查陷阱现象正确做法假设干净起点用例互相污染全新身份 唯一 URLserver.reset()当清空约 3 秒且会话全断只当 teardown 用逃逸断言放afterEach并发下误判、丢证据放afterAll一次断言值导入RpcStubCI 序列化失败一律stubFor()单独升 wrangler运行时栈不一致三层联动升级入口导出非类值entrypoint 类型报错保持模块私有假定时器控 Worker完全无效用控制面造状态未 mock 出站请求抛 Unmocked 错误这正是隔离保证这套体系的本质一句话代码在另一个进程里测试通过真实传输协议驱动它唯一被 stub 的是出站 HTTP。扩展点就在参数化本身——harness 接受 gatekeeper 列表、interceptor 接受可插拔 handler 模块新套件是一个 handler 模块不是 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),仅供参考
返回列表