opencode 的 Playwright E2E 测试规范:定位符选择、等待策略与测试卫生标准
packages/app/e2e/AGENTS.md 是 opencode Web 应用(SolidJS 构建的会话界面)为 E2E 测试立下的一份质量契约:在编写、修改或评审任何 Playwright 测试前必须遵循官方最佳实践,并用一组具体的"测试卫生"规则约束定位符选择、同步等待与断言方式。读完本文,你将掌握这套规则的完整内容,并能从仓库中约 90 个 spec 文件和共享工具(waits.ts、mock-server.ts、errors.ts、sse-transport.ts)里看到每条规则对应的真实落地方式,包括如何运行 E2E 套件、如何为异步渲染的 UI 断言"正确的就绪状态"。
1. 规则所属的测试体系
E2E 套件位于 packages/app/e2e,配套 Playwright 配置为 packages/app/playwright.config.ts。目录按测试目的分层:
| 目录 | 用途 | 典型文件 |
|---|---|---|
smoke/ |
关键路径冒烟(会话时间线渲染、滚动、标签页切换) | session-timeline.spec.ts |
regression/ |
回归测试,约 41 个 spec,覆盖 review、终端、会话列表等 | review-line-comment.spec.ts |
user-story/ |
端到端用户故事 | model-selection-flow.spec.ts |
performance/ |
性能基准与时间线稳定性矩阵,独立配置运行,默认被排除 | timeline-stability |
reproduction/ |
最小复现应用(如 timeline-suspense) | timeline-suspense.repro.ts |
utils/ |
共享工具:等待、错误追踪、mock 服务器、SSE 传输 | utils/waits.ts |
运行方式(在 packages/app 下,脚本定义见 package.json):
bun run test:e2e # playwright test
bun run test:e2e:ui # Playwright UI 模式
bun run test:e2e:report # 打开 e2e/playwright-report
bun run test:stability # 时间线稳定性套件(独立 config)
bun run test:bench # 性能基准套件(独立 config)
bun run typecheck:e2e # 对 e2e 子集做类型检查
配置文件的关键参数值得注意(见 playwright.config.ts):
timeout: 60_000:单测试 60 秒;expect.timeout: 10_000:断言默认 10 秒——这就是规范中"保持超时自适应"的基线;retries: process.env.CI ? 2 : 0:只在 CI 重试 2 次,本地失败立即暴露;配合trace: "on-first-retry"、screenshot: "only-on-failure"、video: "retain-on-failure"采集失败证据;webServer:自动拉起bun run dev(默认 3000 端口),并注入后端地址VITE_OPENCODE_SERVER_HOST/PORT(默认 127.0.0.1:4096);本地reuseExistingServer,CI 不复用;testIgnore:默认忽略performance/**,仅当OPENCODE_PERFORMANCE=1时才运行其中非.test.ts的 spec——性能与功能套件互相隔离;forbidOnly: !!process.env.CI防止test.only被误提交进 CI。
2. 必读清单:Playwright 官方指南
文档的 "Required Reading" 部分要求:在写、改、评审 E2E 测试前,总是先阅读并遵循 Playwright 官方的三篇核心指南——Best Practices(最佳实践)、Auto-waiting(自动等待)与 Assertions(断言);当问题涉及时,再读 Locators(定位符)、Network(网络)与 Test Isolation(浏览器上下文隔离)指南。AGENTS.md 中对这六篇指南给出了官方链接。
这不是泛泛的"看官方文档",而是把规范中每条卫生规则的依据锚定到了 Playwright 的官方能力上:自动等待机制(actionability)、web-first 断言、基于 role 的稳健定位、waitForResponse 等网络等待,以及 browserContext 级的测试隔离。
3. 测试卫生规则逐条解析
"Test Hygiene" 一节共 8 条规则。下面结合仓库中的真实 spec 逐一说明其含义与落地证据。
3.1 测试用户可见行为,使用隔离的确定性数据与范围化、唯一的定位符
规则原文:Test user-visible behavior with isolated, deterministic data and scoped, unique locators.
落地方式是 mockOpenCodeServer(utils/mock-server.ts):每个 spec 通过 page.route("**/*") 拦截请求,返回构造好的确定性数据——固定的目录路径(如 regression 测试里的 C:/OpenCode/ReviewLineCommentRegression)、固定的会话 ID 与消息内容,而不是连真实后端。注意第 57 行 if (url.port !== targetPort && url.port !== appPort) return route.fallback():只拦截应用端口与后端端口的流量,其余请求放行,这本身就是一种"隔离"。同时测试只断言 DOM 中用户实际可见的状态(标题、文本框、标签页文本),不触及内部 store 或网络之外的实现细节。
3.2 优先 role、label、文本与显式测试契约定位符,不要用 .first()/.last() 来"静音"严格模式
规则原文:Prefer role, label, text, and explicit test-contract locators. Do not use .first() or .last() merely to silence strictness errors.
仓库中的定位符分层很清晰,见 review-line-comment.spec.ts:
const review = page.locator('[data-component="session-review"]') // 测试契约:组件锚点
const line = review.getByText("export const value = 'after'", { exact: true }) // 精确文本
await line.click()
await expect(review.getByRole("textbox")).toBeVisible() // role
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
data-component / data-slot 这类显式属性即"测试契约定位符",是应用侧专为测试暴露的稳定钩子;其外再叠加 getByRole / getByText({ exact: true })。范围化(scoped)体现在定位符总是先锚定 review 容器再向下查找。一个值得注意的细节:同文件中 [data-column-number="1"] 使用了 .last()——从源码结构看,diff 视图中行号会跨文件重复,测试取最后一个出现是"范围化的唯一定位",而不是规则所禁止的"用 .first() 压制 strictness 报错"。两者的区别在于:定位符在去掉 .last() 后是否真的无法唯一。
3.3 禁止墙钟等待:用 locator 动作、自动等待与 web-first 断言同步
规则原文:NEVER use waitForTimeout, setTimeout, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead.
这是对 flake 来源最直接的封杀。共享工具 utils/waits.ts 只暴露两个"具名等待",且内部全部是 web-first 断言:
export const APP_READY_TIMEOUT = 30_000
export async function expectAppVisible(locator: Locator) {
await expect(locator).toBeVisible({ timeout: APP_READY_TIMEOUT })
}
export async function expectSessionTitle(page: Page, title: string) {
await expectAppVisible(page.getByRole("heading", { name: title }))
}
在冒烟套件 smoke/session-timeline.spec.ts 中,"等待历史消息分页完成"用的是对请求记录数组的 expect.poll:
await expect.poll(() => requests.some((request) => request.before && request.phase === "end")).toBe(true)
await waitForTimelineStable(page)
await expect.poll(positions).toEqual(before)
即:轮询的是"应用产生的具体结果"(分页请求的 end 相位、虚拟列表行位置回到原值),而不是等一个固定时长。
3.4 导航、网络响应、DOM 挂载或可见性都不足以证明异步 UI 就绪
规则原文:Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires.
冒烟套件为此专门实现了 waitForTimelineStable(第 658-673 行):它通过 page.waitForFunction 在页面内连续读取时间线"签名"(由 scrollTop、scrollHeight、各消息行几何位置与 ID 列表 JSON 序列化而成),连续多帧签名不变才判定稳定。随后测试还断言分页前采集的三行 data-timeline-part-id 位置在分页后 toEqual(before)——断言的正是"下一个动作实际要求的状态"(可视消息不跳动),而不是"页面能打开"。
3.5 在触发动作之前注册事件与网络等待
规则原文:Register event and network waits before the action that triggers them.
review-line-comment.spec.ts 第 151-157 行 是标准范例:
const changes = page.getByRole("tab", { name: "Changes" })
const diffResponse = page.waitForResponse(
(response) =>
response.request().method() === "GET" && response.ok() &&
new URL(response.url()).pathname === "/api/vcs/diff",
)
await changes.click() // 触发请求的动作在注册之后
expect((await (await diffResponse).json()).data).toHaveLength(1)
waitForResponse 先挂起,再点击触发;若顺序颠倒,请求可能在注册前就已发出而错过。同样的模式也出现在 SSE 场景:utils/sse-transport.ts 提供 waitForConnection / send / burst / heartbeat 等可控通道,测试先安装传输、拿到连接句柄,再驱动 UI 动作。
3.6 不重试有状态副作用的动作;重试幂等的就绪检查,然后只做一次动作并断言结果
规则原文:Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome.
对应实现是 expect(async () => { ... }).toPass()。在 review-line-comment.spec.ts 第 47-64 行:
await expect(async () => {
await lineNumber.hover() // 幂等:可重复
await expect(lineNumber).toHaveAttribute("data-hovered", "")
await expect(comment).toHaveCount(1)
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter") // 副作用动作:只执行一次
await expect(review.getByRole("textbox")).toBeVisible()
hover 与可见性检查可安全重试;press("Enter") 会真正提交评论,因此放在重试块之外只执行一次,随后断言编辑器打开。若把 press 也放进 toPass,就违反了这条规则。
3.7 保持动作与断言超时自适应,不用短超时探活、不靠重试掩盖 flake
规则原文:Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes.
从配置看:全局断言超时 10s(playwright.config.ts),应用级就绪统一走 APP_READY_TIMEOUT = 30_000(waits.ts),而不是各处散落 500ms 之类的"探活"超时;重试仅存在于 CI(2 次),配合 trace: "on-first-retry" 采集证据——重试用于收集诊断信息,而不是把失败"重试成通过"。
3.8 断言精确结果与身份,让陈旧状态、重复渲染、错元素交互无法通过
规则原文:Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass.
冒烟套件把"精确"做到了 ID 级:expectOrderedIDs(第 611-615 行)要求实际渲染的 part/message ID 序列与 fixture 期望值按顺序一致;expectCompleteScroll(第 691-711 行)则要求向上滚动到底后,全部 331 个 part ID 都出现在可见记录中、ID 集合无重复,并附带遍历采样摘要便于失败诊断。断言文本也是精确匹配("Commenting on line 2"、toHaveText("Use the existing value instead", { exact: true }))。错误追踪同样精确:utils/errors.ts 同时收集 console.error 与 pageerror,expectNoSmokeErrors 要求控制台错误、错误 toast、禁用文案三者全部为空数组。
4. 一个完整用例的读法
以 regression/review-line-comment.spec.ts 为例,可以看到全部规则如何串成一条链:
- 确定性数据:
beforeEach中mockOpenCodeServer注入固定的 project、session、vcsDiff(含统一 diff 补丁文本)与单条用户消息; - 范围化定位:
[data-component="session-review"]锚定 review 面板,内部再按data-file、data-column-number、getByRole下钻; - 先注册后触发:点击 "Changes" 标签前先挂
waitForResponse("/api/vcs/diff"),并用响应的data.length === 1证明只加载了预期那份 diff; - 精确断言:点行 2 后断言编辑器标签为
"Commenting on line 2";提交评论后不仅断言评论出现,还切到 Session 标签页断言上下文气泡包含"review.ts:2"——即评论真正落到了 prompt 上下文的正确文件与行号上,而非仅"界面上出现了文字"。
同类结构还可见于 user-story/model-selection-flow.spec.ts(用 provider() 工厂函数模拟"连接 OpenCode Go 后模型列表变化"的状态迁移,通过 onConnectKey/onInstanceDispose 回调驱动)与 smoke/session-timeline.spec.ts(虚拟列表分页 + 可视稳定性 + 标签页切换首帧绘制验证)。
5. 小结:这份规范的实际约束面
| 规则主题 | 反模式 | 仓库中的正模式 |
|---|---|---|
| 数据 | 依赖真实后端/随机数据 | mockOpenCodeServer 拦截 + 固定 fixture |
| 定位 | .first()/.last() 压 strict 报错 |
data-component/data-slot 契约 + role + 精确文本 |
| 同步 | waitForTimeout/sleep/帧计数 |
expect.poll、waitForFunction 稳定性签名 |
| 就绪判断 | 导航/响应/可见即结束 | 断言下一步动作要求的 UI 状态(位置、文本、ID 序列) |
| 事件/网络 | 先点击后挂等待 | waitForResponse 先于 click 注册 |
| 重试 | 重放副作用动作 | toPass 只包裹幂等检查,副作用动作执行一次 |
| 超时 | 短超时探活 | 全局 60s/10s 基线 + 30s 就绪常量,CI 才重试 |
| 断言 | 断"出现了" | 断精确文本、有序 ID 序列、无重复、无 console/toast 错误 |
需要说明的适用边界:该规范面向 packages/app 的浏览器端 E2E(Playwright + Vite dev server + 拦截式 mock 后端),与单测(bun test + happy-dom)、performance/ 基准套件(独立配置,OPENCODE_PERFORMANCE=1 才纳入默认运行)互不混用;e2e/tsconfig.json 也只对其中一部分 spec 启用类型检查。遵循 AGENTS.md 的要点可以概括为一句话:等具体的状态而不是等时间,定位具体的元素而不是碰运气,断言精确的结果而不是"页面没崩"。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00