首页
/ LobeHub E2E 测试实战指南:Playwright 元素定位、调试技巧与超时问题排查

LobeHub E2E 测试实战指南:Playwright 元素定位、调试技巧与超时问题排查

2026-09-07 12:44:00作者:凤尚柏Louis

本文面向 LobeHub 开源仓库的 BDD(Cucumber + Playwright)端到端测试编写者,系统梳理聊天输入框等复杂组件的元素定位方法、失败用例的调试手段,以及 networkidle 超时、strict mode violation、输入框内容为空等高频问题的根因与解法。读完本文,你将掌握一套可复用的 LobeHub E2E 测试编写与排障方法论,能够写出稳定、不依赖网络空闲状态的用例。

LobeHub 的端到端测试代码集中在仓库根目录的 e2e/ 下,采用 Cucumber(Gherkin 特性文件)+ Playwright(浏览器自动化)+ CustomWorld(共享上下文) 的组合。Cucumber 配置见 e2e/cucumber.config.js,其中定义了默认 step 超时 timeout: 30_000parallel 并行策略与 tags: 'not @skip' 过滤规则;e2e/src/steps/hooks.ts 负责在所有用例前启动 Web 服务器、通过认证 API 预登录并缓存 Session Cookie,以跳过重复登录。下面这些测试技巧正是基于这套骨架总结出的实战经验。

页面元素定位

富文本编辑器(contenteditable)输入

LobeHub 的聊天输入框基于 @lobehub/editor 构建,它本质上是一个 contenteditable 的富文本编辑器,而非原生 <input><textarea>。这一点在源码中可以直接印证:

contenteditable 给测试脚本带来的核心差异有 3 点,必须严格遵守:

  1. 不能直接用 locator.fill()——该方法对 contenteditable 元素不生效;
  2. 需要先 click 容器让编辑器获得焦点,否则后续键盘输入会丢失;
  3. 使用 keyboard.type() 输入文本,并配合适度的输入间隔(delay)以保证稳定。

正确的输入方式如下:

await chatInputContainer.click();
await this.page.waitForTimeout(500); // 等待焦点
await this.page.keyboard.type(message, { delay: 30 });
await this.page.keyboard.press('Enter'); // 发送

仓库内的真实用例 e2e/src/steps/home/chat-input.steps.ts 正是这种模式的工程化落地。其中定义了一个 focusHomeChatInput() 辅助函数,它以候选选择器 + 可见性过滤的方式定位聊天输入框,代码值得逐段理解:

const focusHomeChatInput = async (world: CustomWorld): Promise<void> => {
  const candidates = [
    world.page.locator('[data-testid="chat-input"] textarea'),
    world.page.locator('[data-testid="chat-input"] [contenteditable="true"]'),
    world.page.getByRole('textbox'),
    world.page.locator('[data-testid="chat-input"]'),
  ];

  for (const locator of candidates) {
    const count = await locator.count();
    for (let index = 0; index < count; index += 1) {
      const item = locator.nth(index);
      const visible = await item.isVisible().catch(() => false);
      if (!visible) continue;
      await item.click({ force: true });
      return;
    }
  }
  throw new Error('Could not find a visible Home chat input to focus');
};

这段实现体现了两条重要经验:

  • 候选回退策略:先尝试 data-testid="chat-input" 容器下的 textarea,再尝试 contenteditable 节点,最后退回 getByRole('textbox') 与容器本身,覆盖了编辑器不同渲染分支;
  • isVisible() 过滤不可见元素:桌面端 / 移动端双实现会导致多个匹配,必须先过滤再点击。

实际发送的步骤同样完整保留了"聚焦 → 键入 → 回车"的关键链路,并为每次键盘输入设置了 { delay: 20 }

await focusHomeChatInput(this);
await this.page.keyboard.type(text, { delay: 20 });

添加 data-testid

对于复杂或容易随 UI 变化的组件,data-testid 是最可靠的选择器锚点,比 CSS 类名与文本内容稳定得多。在 LobeHub 源码中,聊天输入区就显式挂载了该属性(见 src/features/ChatInput/Desktop/index.tsx):

// src/features/ChatInput/Desktop/index.tsx
<ChatInput
  data-testid="chat-input"
  ...
/>

一旦组件带上 data-testid="chat-input",测试侧就能用 this.page.locator('[data-testid="chat-input"]') 精确命中,并在其上叠加 .first().nth(n) 等去重手段。测试代码里对冷启动 Home 页面的等待即依赖这一锚点:

const chatInputContainer = this.page.locator('[data-testid="chat-input"]').first();
await expect(chatInputContainer).toBeVisible({ timeout: WAIT_TIMEOUT });

调试技巧

添加步骤日志

当用例失败却无法定位到具体步骤时,可以在每个关键步骤前后打印日志。LobeHub 的实际步骤定义文件普遍采用带 Emoji 的日志约定,让执行过程在 CI 与本地终端中都清晰可读。以 Home 输入框相关步骤为例:

Given('用户进入页面', async function (this: CustomWorld) {
  console.log('   📍 Step: 导航到首页...');
  await this.page.goto('/');

  console.log('   📍 Step: 查找元素...');
  const element = this.page.locator('...');

  console.log('   ✅ 步骤完成');
});

真实仓库中的步骤代码在此基础上做得更细——用 📍 标注"正在执行",用 标注"人为注入的延迟",用 / 标注结果,例如冷启动用例中的 Delaying cold agent script已进入冷启动 Home 页面(见 e2e/src/steps/home/chat-input.steps.ts)。日志配合超时信息,能快速区分"卡在导航""卡在 mock 设置"还是"卡在元素可见性等待"。

查看失败截图

测试失败时会自动保存截图。这一行为由两条链路共同保证:

  1. CustomWorld.takeScreenshot()(见 e2e/src/support/world.ts)使用 fullPage: true 整页截图,并写入 <进程工作目录>/screenshots/ 目录——在 e2e 目录下运行即对应 e2e/screenshots/
  2. e2e/src/steps/hooks.tsAfter 钩子检测到用例状态为 FAILED 时,自动调用 takeScreenshot(),并将截图(PNG)、整页 HTML 与页面 jsErrors 一起通过 this.attach() 附加到 Cucumber 报告中。

因此排查失败时不要只盯着终端报错,还应同步检查以下三处:

  • e2e/screenshots/ 下的 PNG(命名规则为 {测试ID}-{时间戳}.png);
  • Cucumber HTML/JSON 报告(见 reports/cucumber-report.html);
  • attach 的整页 DOM 快照——当截图因为弹层或滚动而失真时,HTML 快照往往能揭示真实 DOM 状态。

非 headless 模式

默认情况下浏览器以 headless(无头)模式运行,看不到操作过程。将 HEADLESS 置为 false 即可打开真实浏览器窗口观察每一步操作,适合本地调试定位选择器或时序问题:

HEADLESS=false pnpm exec cucumber-js --config cucumber.config.js --tags "@smoke"

这一环境变量的读取逻辑位于 e2e/src/support/world.tschromium.launch({ headless: process.env.HEADLESS !== 'false' }),即只要显式设置为 "false" 就走有头模式,其余情况(包括未设置)均为无头模式。

常见问题

waitForLoadState('networkidle') 超时

原因networkidle 表示页面在 500ms 内没有任何网络请求。而在 CI 环境中,分析脚本、外部资源加载、轮询请求等持续网络活动会让这个状态几乎永远无法达成,从而导致等待超时。

典型报错形如:

page.waitForLoadState: Timeout 10000ms exceeded.
=========================== logs ===========================
  "load" event fired
============================================================

注意日志里已经说明 "load" event fired——首屏 DOM 其实早已加载完成,只是后续的网络活动阻止了 networkidle 达成。这种"页面可用却被等待状态卡死"是 E2E 最典型的误用。

解决(三条原则):

  • 避免使用 networkidle——这是不可靠的等待策略;
  • 直接等待目标元素——用 expect(element).toBeVisible({ timeout: 30_000 }) 替代对页面空闲状态的等待;
  • 如果确实要等页面加载,使用 domcontentloadedload 事件。
// ❌ 不推荐 - networkidle 在 CI 中容易超时
await this.page.waitForLoadState('networkidle', { timeout: 10_000 });
const element = this.page.locator('[data-testid="my-element"]');
await expect(element).toBeVisible();

// ✅ 推荐 - 直接等待目标元素
const element = this.page.locator('[data-testid="my-element"]');
await expect(element).toBeVisible({ timeout: 30_000 });

LobeHub 的测试基建正是围绕"等待元素而非等待网络"来设计的:CustomWorld 在初始化时为 browserContextpage 统一设置了 30_000 毫秒的 expect 默认超时(见 e2e/src/support/world.tse2e/src/support/world.ts),并导出 WAIT_TIMEOUT = 13_000 供页面跳转类等待复用(见 e2e/src/support/world.ts)。新写步骤时应优先使用这些统一超时,而不是自定义 waitForTimeout 硬等。

测试超时(function timed out)

原因:元素定位失败、或等待时间设置不足,导致 step 超过 Cucumber 的默认超时(e2e/cucumber.config.js 中为 30_000 毫秒)。

解决:按以下顺序排查:

  • 检查选择器是否正确——优先确认目标元素是否带 data-testid,是否存在 desktop/mobile 双实现导致的歧义;
  • 增加 timeout 参数——对慢路由或冷启动场景,Cucumber 支持在 step 注册时单独覆盖超时,如 When('...', { timeout: 45_000 }, async function () {...})(见 e2e/src/steps/home/chat-input.steps.ts),Cucumber 全局默认值也可在 hooks 中用 setDefaultTimeout() 调整;
  • 添加显式等待——在导航、mock 生效等异步边界后使用 waitForTimeout() 或等待 URL 变化,例如发送消息后等待跳转到 /agent/... 会话页的 waitForURL 断言。

strict mode violation(多个元素匹配)

原因:Playwright 的选择器在 strict mode 下匹配到了多个元素,典型场景是桌面端 / 移动端渲染了双组件,或列表中存在重复卡片。

解决

  • 使用 .first().nth(n) 明确取第几个匹配;
  • 使用 boundingBox() 过滤出真正可见的元素。

仓库中 focusHomeChatInput()isVisible() 过滤 + nth(index) 遍历(见 e2e/src/steps/home/chat-input.steps.ts)就是把"多个匹配"变成"取可见的那个"的典型范式,远比裸 .first() 稳健。

输入框内容为空

原因:contenteditable 编辑器的特殊性——焦点未就绪时直接键入会被丢弃,而 fill() 对 contenteditable 又无效。

解决

  • click 容器确保焦点
  • 使用 keyboard.type() 而非 fill()
  • 添加适当的等待时间,确保焦点事件与编辑器初始化完成后再输入。

对应到真实场景,冷启动用例在 page.goto('/') 之后并不会立即输入,而是先 toBeVisible() 等待输入区出现,再在输入步骤里走"点击候选节点 → keyboard.type",这正是对上述三条原则的组合应用(见 e2e/src/steps/home/chat-input.steps.ts)。

与配套实践的配合

以上技巧在 LobeHub E2E 中通常不是孤立使用的,而是与另外两份配套文档相互支撑:

  • 本地环境搭建:涉及数据库、服务器、S3 mock 等环境变量,是运行上述用例的前提,见 本地运行 E2E 测试
  • LLM 流式响应 Mock:LobeHub 通过 page.route() 拦截 /webapi/chat/openai 请求并返回 SSE 流式响应,让对话类用例无需真实模型即可稳定运行(见 e2e/src/mocks/llm/index.tsLLM Mock 实现)。其关键约束是 llmMockManager.setup(page) 必须在 page.goto() 之前调用,否则路由拦截不生效——这与本文强调的"mock 先行、导航后置"是同一类时序纪律。

小结

LobeHub 的 E2E 测试经验可以浓缩为四条铁律:对 contenteditable 用 click + keyboard.type 而非 fill优先使用 data-testid 锚点并在多匹配时做可见性过滤等待"目标元素可见"而不是等待"网络空闲"善用步骤日志、失败截图与 HEADLESS=false 缩短调试回路。遵循这些原则写出的用例,在本地与 CI 环境中的稳定性都会显著提升。

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