首页
/ opencode 的 Playwright E2E 测试规范:定位符选择、等待策略与测试卫生标准

opencode 的 Playwright E2E 测试规范:定位符选择、等待策略与测试卫生标准

2026-09-06 12:35:38作者:凌朦慧Richard

packages/app/e2e/AGENTS.md 是 opencode Web 应用(SolidJS 构建的会话界面)为 E2E 测试立下的一份质量契约:在编写、修改或评审任何 Playwright 测试前必须遵循官方最佳实践,并用一组具体的"测试卫生"规则约束定位符选择、同步等待与断言方式。读完本文,你将掌握这套规则的完整内容,并能从仓库中约 90 个 spec 文件和共享工具(waits.tsmock-server.tserrors.tssse-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.

落地方式是 mockOpenCodeServerutils/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 在页面内连续读取时间线"签名"(由 scrollTopscrollHeight、各消息行几何位置与 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_000waits.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.errorpageerrorexpectNoSmokeErrors 要求控制台错误、错误 toast、禁用文案三者全部为空数组。

4. 一个完整用例的读法

regression/review-line-comment.spec.ts 为例,可以看到全部规则如何串成一条链:

  1. 确定性数据beforeEachmockOpenCodeServer 注入固定的 project、session、vcsDiff(含统一 diff 补丁文本)与单条用户消息;
  2. 范围化定位[data-component="session-review"] 锚定 review 面板,内部再按 data-filedata-column-numbergetByRole 下钻;
  3. 先注册后触发:点击 "Changes" 标签前先挂 waitForResponse("/api/vcs/diff"),并用响应的 data.length === 1 证明只加载了预期那份 diff;
  4. 精确断言:点行 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.pollwaitForFunction 稳定性签名
就绪判断 导航/响应/可见即结束 断言下一步动作要求的 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 的要点可以概括为一句话:等具体的状态而不是等时间,定位具体的元素而不是碰运气,断言精确的结果而不是"页面没崩"。

登录后查看全文
热门项目推荐
相关项目推荐