本节摘要:工程保障舱的核心展品:mock-LLM e2e。仓库里有四套 Playwright 配置——
playwright.config.ts(默认)、playwright.mock-llm.config.ts(全链路但 LLM 是假的)、playwright.mock-llm-docker.config.ts(同一套测试打 Docker 镜像)、playwright.live.config.ts(真 LLM,手动触发)。其中 mock-llm 是杰作:它启动完整的 agent-canvas 技术栈(就是 npm 用户拿到的那个bin/agent-canvas.mjs二进制:静态前端 + static-server + uvx 拉起的 agent-server + automation 后端 + ingress 单端口代理),唯一被替换的是 LLM——一个用 openhands-sdkTestLLM写的 Python HTTP 服务器,按剧本返回"调用 terminal 工具执行 printf → 回复文本"的轨迹。于是 e2e 可以每个 PR 都跑从浏览器到 agent-server 到工具执行到结果渲染的全程,不花一个 token、不碰任何 LLM 凭据。本节拆解 mock 服务器的轨迹机制与 admin API、四套配置的差异、tests/e2e/下 44 个文件的目录设计,最后读docs/TESTING_MATRIX.md——OS × 安装方式 × Agent 类型的发布冒烟矩阵,看自动化之外的最后一道人工防线。
内容来源:原项目
playwright.mock-llm.config.ts、tests/e2e/mock-llm/scripts/mock-llm-server.py、AGENTS.md的"Live End-to-End Test Framework"与"Mock-LLM E2E Test Framework"章节、docs/TESTING_MATRIX.md,精读整理。
⚠️ 注意:mock-LLM 的测试不是上一节的 MSW。MSW 拦浏览器请求,后端可以不存在;mock-LLM 拦的是 LLM 推理调用,后端(agent-server、automation)全是真实进程。前者测前端,后者测整个产品。配置文件头部的自述说得很直接:它跑的是"the actual production path"。
阅读完本节,你应当能够:
bin/agent-canvas.mjs 而不是测试专用启动器。tests/e2e/ 的 44 个文件按功能分目录的组织,以及 test-mapping 的选择性执行机制。仓库根目录并列四个配置文件,对应 package.json 里四个 scripts:
"test:e2e": "playwright test --pass-with-no-tests", "test:e2e:live": "node --env-file-if-exists=.env tests/e2e/live/scripts/run-live-e2e.mjs", "test:e2e:mock-llm": "playwright test --config=playwright.mock-llm.config.ts", "test:e2e:mock-llm:docker": "playwright test --config=playwright.mock-llm-docker.config.ts"
| 配置 | 后端 | LLM | 用途 | 成本 |
|---|---|---|---|---|
playwright.config.ts |
浏览器级(可拦截) | 无 | 普通浏览器测试;显式忽略 **/live/** |
低 |
playwright.mock-llm.config.ts |
完整真实栈(agent-server/automation/ingress) | mock(TestLLM) | 全链路回归,每个 PR 自动跑 | 中,零 token |
playwright.mock-llm-docker.config.ts |
Docker 容器(--network host) |
mock(同一台服务器) | 验证发布镜像本身 | 中,拉已构建镜像 |
playwright.live.config.ts |
完整真实栈 | 真 LLM(CI 默认 claude-haiku) | QA 冒烟,带 live-e2e 标签或手动触发 |
高,花 token |
AGENTS.md 对 live 与 mock 的隔离有硬性规定:"live LLM-backed tests must never run as part of npm run test:e2e"——真 LLM 的不确定性(即便 temperature 0 也不完全确定,CI 只保留一次重试)与费用,决定了它只能是抽样手段;而 mock-LLM 用确定性剧本换来了"每次 PR 都跑全链路"的资格。这套组合的要义是:把贵的测试变稀,把便宜的测试变勤。
playwright.mock-llm.config.ts 的头部注释画出整幅图:
* Starts three processes: * 1. Mock LLM server (Python, using openhands-sdk TestLLM) * 2. Full agent-canvas stack via bin/agent-canvas.mjs (agent-server + * automation backend + static frontend + ingress proxy), matching the * production npm-published binary. * 3. A second static-server instance with `--auth-required` (public mode) * on a separate port, proxying to the same backend. Used by the * auth-mode E2E tests. * * The test creates an LLM profile via the UI that points at the mock server, * so no real LLM credentials are needed.
链路是:浏览器(Playwright 驱动)→ ingress(单端口 18300)→ agent-server(真实,uvx 拉起 1.44.1)→ LLM 推理请求发给 localhost:9999 的 mock 服务器。测试像普通用户一样在设置界面里创建 LLM profile,只是 base_url 指向 mock——agent-server 的 litellm 层完全按 OpenAI 协议和它对话,它返回什么,agent 就"以为"大模型说了什么。
tests/e2e/mock-llm/scripts/mock-llm-server.py 的核心是预录轨迹。默认剧本两轮:
BASH_TOKEN = "MOCK_LLM_E2E_BASH_OK" REPLY_TOKEN = "MOCK_LLM_E2E_REPLY_OK" def build_trajectory() -> list[Message | Exception]: """Build the scripted trajectory for the E2E test. Turn 1: Agent calls the terminal tool with a printf command. Turn 2: Agent replies with the expected token and finishes. """ return [ Message( role="assistant", content=[TextContent(text="")], tool_calls=[ MessageToolCall( id="call_mock_001", name="terminal", arguments=json.dumps( {"command": f"printf '{BASH_TOKEN}\\n'"} ), origin="completion", ) ], ), Message( role="assistant", content=[TextContent(text=REPLY_TOKEN)], ), ]
这个剧本精心设计成完整地走一遍 agent 循环:第一轮返回一个工具调用——让 agent 真的去执行 printf 'MOCK_LLM_E2E_BASH_OK';工具输出回灌后,第二轮返回纯文本收尾。于是测试可以断言:终端工具真的被执行(真实沙箱里跑出了 printf 的输出)、事件流里出现了工具调用与观察事件、UI 上渲染出了结果与最终回复。改代码、跑命令这类"agent 干活"的核心路径,一个 token 不花就全覆盖了。
TestLLM 是 openhands-sdk 自带的测试设施:按顺序吐出 Message 或 Exception,吐完报 TestLLMExhaustedError。mock 服务器还把 SDK 的异常类映射成 OpenAI 风格的 HTTP 错误,可以剧本化地模拟限流(429)、坏 key(401)、超上下文(400)——负样本也是剧本。
mock 服务器除了 /v1/chat/completions,还开了一组管理端点(AGENTS.md 汇总):
POST /admin/reset 重置为默认轨迹,清空请求历史 POST /admin/trajectory/register 注册命名轨迹(每个 turn 是 tool_call 或 text) POST /admin/trajectory/activate 激活某个已注册轨迹 GET /admin/requests 返回上次 reset 以来捕获的全部 completion 请求体
这让每个 spec 可以按需换剧本:automation 测试注册"工具调用 + finish 工具"的轨迹;图片上传测试用 /admin/requests 检查图片确实被转发给了 LLM(看请求体里有没有 base64 图);MCP 测试编排多轮工具调用。配套的 helper(tests/e2e/mock-llm/utils/mock-llm-helpers.ts)把这些遥控封装成 registerTrajectory()、getMockLLMRequests() 等函数。
还有一个真实世界的细节:agent-server ≥ 1.43 在保存 LLM profile 时会先发一个单 token 的 ping 预检。mock 服务器专门识别它(_is_preflight_ping()),回一个固定的 pong,不消耗剧本轮次——否则预检会吃掉剧本第一轮,后续全错位。与真实系统对戏,连"闲聊"都要记账。
💡 驾驶舱要点:mock-LLM 的本质是把"不可控且昂贵的外部智能"替换成"可控且免费的剧本",同时让其余一切保持真实。这与 MSW(替换网络)、Docker 配置(替换运行时)形成三层可替换性设计:一个成熟的 e2e 体系,应该对系统的每个外部依赖都问一句"测试时用什么替身、替到哪一层为止"。Agent Canvas 的答案是:网络可以假、LLM 可以假,但交付给用户的二进制与进程拓扑不能假。
tests/e2e/ 下共 44 个文件,分为五个顶层目录与 mock-llm 的十来个功能子目录:
tests/e2e/ live/ ← 真 LLM:real-agent-server-conversation.spec.ts + scripts/ + utils/ live-acp/ ← 真 ACP agent(Claude Code/Codex/Gemini)的 live 测试 mock-llm/ ← 主力:按功能分目录 settings/ conversations/ files/ automations/ onboarding/ backends/ home/ mcp/ skills/ canvas-extensions/ regressions/ scripts/(mock-llm-server.py、mock-acp-server.py、resolve-affected-tests.mjs) utils/(mock-llm-helpers.ts) reporters/ test-mapping.json support/ ← 跨套件 helper(onboarding-helpers.ts)
spec 的文件名自述其职:mock-llm-acp-agent.spec.ts(ACP agent 配置)、mock-llm-mcp-github.spec.ts(MCP 市场)、mock-llm-automation.spec.ts(自动化全生命周期:创建 → 派发 → COMPLETED → 会话链接可用)、mock-llm-folder-workspace.spec.ts(工作区选择)等。目录即模块,代价是每个 e2e 都要自备环境——所以每个 spec 自己建 LLM profile、afterEach 重置 mock 轨迹;测试串行跑(workers: 1),因为它们共享一个真实后端。
选择性执行是成本控制的关键一笔:test-mapping.json 把源码路径映射到测试子目录,resolve-affected-tests.mjs 读 PR 改动文件,输出该跑哪些子目录。四种裁决:改了有映射的文件 → 只跑对应子目录 + regressions;改了 spec 文件 → 跑所在子目录;改了横切文件(如 agent-server-adapter.ts、package.json)→ 跑全量;只改文档 → 跳过重活。值得注意的是 CI 不用 pull_request.paths 过滤来实现跳过——路径过滤会让"required check 永远 pending"卡住合并——而是用一个轻量 detect 任务把重任务标成"跳过但成功"。工程经验都长在这些边角上。
还有一个 mock 值得一提:mock ACP 服务器(mock-acp-server.py)。它是一个说 JSON-RPC 的最小 stdio ACP agent(处理 initialize/session/new/session/prompt,回一个带 ACP_REPLY_TOKEN 的 session/update 再 end_turn),agent-server 把它当子进程拉起。第 4 章 ACP 适配口的"万能接口"宣称,在这里被反向利用:写个 50 行假 agent 就能测 ACP 全链路——开放协议的红利是双向的,接入方可以用它,测试方也可以用它。
自动化再强也有覆盖不到的组合。docs/TESTING_MATRIX.md 开宗明义:
Priority key: P0 = must pass before any release · P1 = must pass before GA · P2 = best-effort
第一张矩阵是"安装方式 × 操作系统 × Agent 类型",每格的冒烟动作是完整用户旅程:
Each cell = smoke test: install → onboard → start conversation → agent replies.
| macOS | Linux | Windows | |
|---|---|---|---|
| npm — OpenHands | ☐ | ☐ | ☐ |
| npm — Claude Code | ☐ | ☐ | ☐ |
| npm — Codex | ☐ | ☐ | ☐ |
| npm — Gemini CLI | ☐ | ☐ | ☐ |
| npm — Custom ACP | ☐ | ☐ | ☐ |
| Docker — OpenHands | ☐ | ☐ | ☐ |
| Docker — Claude Code | ☐ | ☐ | ☐ |
| Docker — Codex | ☐ | ☐ | ☐ |
| Docker — Gemini CLI | ☐ | ☐ | ☐ |
| Docker — Custom ACP | ☐ | ☐ | ☐ |
注意矩阵的两个轴都来自前几章的能力:安装方式(npm/Docker)是第 9 章的部署形态;Agent 类型(OpenHands/Claude Code/Codex/Gemini/自定义 ACP)是第 4 章的多后端。产品承诺的每个组合,发布前都要人手点一遍。后续还有三张表:Automations × 安装 × Agent(要求 automation 后端全栈,每格 = 创建自动化 → 派发 → 到 COMPLETED → 会话链接可用)、Auth 模式(本地自动生成 key 与 --public 用户 key)、以及 npm/Docker 各一张功能清单(Onboarding、终端工具、文件编辑器、浏览器工具、secrets、MCP 安装、图片上传、key 轮换……)。
文件末尾的"Automated Coverage"表诚实地划出自动化与人工的边界:
| Suite | Install | OS | Agents | Automations |
|---|---|---|---|---|
vitest(unit) |
— | Linux | — | partial |
test:e2e:mock-llm |
npm | Linux | OpenHands, ACP(mock) | ✅ full |
test:e2e:mock-llm:docker |
Docker | Linux | OpenHands, ACP(mock) | ✅ full |
test:e2e:live |
npm | Linux | OpenHands | ❌ |
并明说 CI 尚未覆盖:真 ACP 凭据(Claude Code/Codex/Gemini)、macOS、公共认证模式、订阅登录、Windows。知道自己没测什么,与知道自己测了什么同等重要——这张"未覆盖清单"就是人工矩阵存在的理由:CI 守 Linux 上的 npm 与 Docker 两条主干,其余组合靠发布前的 P0/P1 冒烟兜底。
传统 agent 产品的 e2e 依赖真 LLM:贵(每次回归烧 token)、慢(几十秒一轮)、不稳定(同 prompt 不同输出)。结果是 e2e 沦为发布前才跑一次的奢侈品,平时回归靠单测——而单测恰恰测不到"浏览器 → ingress → agent-server → 工具 → 事件流 → 渲染"这条主干。任何一环(代理路由、静态资源 base path、WebSocket 升级、事件序列化)坏了,单测都是绿的。
mock-LLM 把这条主干变成免费且确定:剧本保证输出可断言(MOCK_LLM_E2E_BASH_OK/MOCK_LLM_E2E_REPLY_OK 两个 token),免费保证每个 PR、每次 push 都能跑,生产二进制保证测到的就是用户拿到的。Docker 配置再把同一套 spec 指向 GHCR 镜像,发布物也被同一张网罩住。于是形成完整闭环:单测守逻辑、MSW 守数据流、mock-LLM e2e 守全链路、Docker e2e 守交付物、TESTING_MATRIX 守机器守不住的组合——五层各司其职,这就是"工程保障舱"的全貌。
bin/agent-canvas.mjs 启动与 npm 发布完全一致的栈(ingress 18300 单端口、真实 agent-server 与 automation),测试经 UI 创建指向 mock 的 LLM profile,全程无真实凭据。/admin/reset、/admin/trajectory/register|activate、/admin/requests(抓 completion 请求体验证图片转发等),测试可动态换剧本;mock ACP 服务器用 50 行 JSON-RPC 假 agent 测 ACP 全链路。resolve-affected-tests.mjs 实现 PR 级选择性执行;跳过不用 paths 过滤而是 detect 任务标"skip-success",避免 required check 卡死。下一节:第 8 章 · 03 i18n 16 语言/遥测/AGENTS.md——工程保障舱的最后三个展品:1.8MB 的全量翻译文件与生成管线、898 行的 PostHog 遥测服务,以及 120KB 的 AI 协作规范 AGENTS.md。