10. 高级页面交互技巧

10.高级页面交互技巧

页面交互是自动化测试的核心环节。前几章已经掌握了基础的选择器使用和点击、输入等操作,但在真实项目中,页面结构往往比想象中复杂:嵌套的 Shadow DOM、动态加载的组件、自定义框架封装元素……这些场景下,常规方法会显得力不从心。本章将深入探讨 Puppeteer 提供的高级交互技巧,帮助应对各种复杂定位需求。

复杂元素定位策略

Locator API 的核心理念

Puppeteer 的 Locator API 是处理复杂交互的首选方案。它不仅仅是一个元素查找工具,更是一个智能等待与动作执行的封装层。当我们调用 page.locator('button').click() 时,Locator 会自动完成一系列前置检查:确保元素在视口内、等待可见性、等待元素启用、等待动画稳定。这种设计大幅降低了测试的脆弱性。

// 最基础的点击操作,背后包含多个自动检查
await page.locator('button.submit').click();

这段代码看似简单,实际上 Locator 会在点击前自动执行四个关键检查。首先是视口检测,如果按钮在页面可视区域外,Puppeteer 会自动滚动使其可见。其次是可见性等待,确保元素不是 display: none 或 visibility: hidden 状态。第三是启用状态检查,防止点击被禁用的按钮。最后是稳定性验证,通过连续两个动画帧确认元素位置不再变化。这些检查避免了传统自动化中常见的"元素不可交互"错误。

多类型选择器混用

CSS 选择器虽然强大,但面对文本内容、可访问性属性等场景时显得捉襟见肘。Puppeteer 提供了多种内置选择器,通过伪元素语法与 CSS 无缝结合。

// XPath 选择器:定位所有 h2 标题
const element = await page.waitForSelector('::-p-xpath(//h2)');

// 文本选择器:点击包含"结账"文本的按钮
await page.locator('div ::-p-text(Checkout)').click();

// ARIA 选择器:通过可访问名称定位
await page.locator('::-p-aria(Submit)').click();

// 带属性的 ARIA 选择器
await page.locator('::-p-aria([name="Click me"][role="button"])').click();

XPath 选择器在处理复杂 DOM 结构时特别有用,比如查找父元素、兄弟节点等 CSS 难以表达的关系。文本选择器则完全摆脱了对 DOM 结构的依赖,直接根据可见文本定位,非常适合多语言网站测试。ARIA 选择器基于浏览器的无障碍树,能够识别 aria-label、aria-labelledby 等属性,对于注重可访问性的应用是绝佳选择。

需要注意的是,文本选择器会转义括号等特殊字符。如果搜索文本包含 Checkout (2 items),需要写成 ::-p-text(Checkout \\(2 items\\)),或者用引号包裹 ::-p-text("He said: \\"Hello\\"")。这种转义规则虽然繁琐,但保证了选择器语法的健壮性。

过滤器增强定位精度

当单个选择器无法精确定位时,过滤器提供了二次筛选的能力。通过 JavaScript 函数对候选元素进行额外验证,可以实现任意复杂的定位逻辑。

// 仅点击文本为"My button"的按钮
await page
  .locator('button')
  .filter(button => button.innerText === 'My button')
  .click();

这个例子中,CSS 选择器 button 可能匹配到多个元素,过滤器会遍历每个候选按钮,只保留 innerText 完全匹配的元素。过滤函数在浏览器上下文中执行,可以访问完整的 DOM API,这意味着可以进行任意复杂的验证,比如检查计算样式、数据集属性,甚至调用页面内的自定义函数。

从 Protractor 迁移的经验

许多团队从 Protractor 迁移到 Puppeteer,选择器语法的变化是首要挑战。Puppeteer 提供了对应方案,让迁移过程更平滑。

Protractor 语法 Puppeteer 等价方案
$(by.css('.class')) page.$('.class')
$$(by.css('.class')) page.$$('.class')
by.deepCss('.nested') :scope >>> .nested
by.cssContainingText('.item', 'text') .item ::-p-text(text)
by.xpath('//div') ::-p-xpath(//div)

deepCss 是 Protractor 中用于穿透 Shadow DOM 的选择器,在 Puppeteer 中对应 >>> 组合器。cssContainingText 则可以直接用文本选择器替代。这种对应关系让迁移工作有了明确的映射表,减少了学习成本。

Shadow DOM 穿透查询

Shadow DOM 的挑战

现代 Web 组件广泛使用 Shadow DOM 实现样式和结构的封装。但 Shadow DOM 的隔离特性让传统 CSS 选择器无法穿透, document.querySelector 无法访问 Shadow Tree 内部的元素。Puppeteer 为此提供了专门的组合器。

深度后代组合器 >>>

>>> 是最常用的 Shadow DOM 穿透工具,它会在所有打开的 Shadow Root 中递归查找匹配元素,类似于 CSS 的空格后代选择器。

// 在自定义元素内部查找按钮
await page.locator('my-custom-element >>> button').click();

这个选择器会首先定位 my-custom-element 元素,然后检查它是否有 Shadow Root。如果有,就在 Shadow Tree 中查找 button 元素。如果按钮又嵌套在另一个自定义元素中,会继续深入查找。这种递归机制完美契合了 Web Components 的嵌套特性。

深度子组合器 >>>>

>>>> 只查找直接子级的 Shadow Root,不会递归深入,类似于 CSS 的 > 子选择器。

// 仅在直接 Shadow Root 中查找
await page.locator('my-custom-element >>>> button').click();

如果 my-custom-element 的 Shadow Tree 中又包含另一个自定义元素,而目标按钮在第二层 Shadow DOM 中,这个选择器将无法找到。>>>> 的精确性在需要限制查找范围时非常有用,可以避免意外匹配到深层嵌套的元素。

组合选择器的实战技巧

深度组合器可以与普通 CSS 选择器、文本选择器等混合使用,构建强大的定位表达式。

// 在侧边栏中定位 Vue 组件,再查找包含特定文本的元素
await page.locator('.side-bar ::-p-vue(MyComponent) >>> ::-p-text(确认)').click();

这个复杂选择器展示了 Puppeteer 选择器系统的灵活性:先用 CSS 选择器限定范围,再用自定义 Vue 选择器定位组件,接着穿透 Shadow DOM,最后用文本选择器精确定位。这种组合能力让几乎任何元素都变得可定位。

需要注意的是,深度组合器只能在选择器的第一个深度工作,不能嵌套在 :is() 等伪类中。例如 :is(div > > a) 是无效的语法。这个限制源于 CSS 解析器的实现机制,使用时需要特别注意。

自定义选择器引擎

为什么需要自定义选择器

测试框架层出不穷,React、Vue、Angular 等都有自己的组件开发模式。虽然可以用 CSS 类名或测试 ID 定位,但这暴露了实现细节,且容易因重构而失效。自定义选择器允许直接通过框架概念定位元素,比如"查找名为 UserProfile 的 React 组件"。

注册自定义查询处理器

Puppeteer.registerCustomQueryHandler 是扩展选择器系统的入口。它需要两个参数:伪元素名称和包含 queryOne、queryAll 方法的对象。

// 注册 React 组件选择器
Puppeteer.registerCustomQueryHandler('react-component', {
  queryOne: (elementOrDocument, selector) => {
    // 在页面上下文中执行,可访问 React 内部 API
    return elementOrDocument.querySelector(`[data-react-component="${CSS.escape(selector)}"]`);
  },
  queryAll: (elementOrDocument, selector) => {
    return elementOrDocument.querySelectorAll(`[data-react-component="${CSS.escape(selector)}"]`);
  },
});

注册后,就可以使用 ::-p-react-component(ComponentName) 语法。queryOne 返回单个元素,queryAll 返回所有匹配元素。这两个函数在浏览器上下文中运行,意味着可以访问页面内的全局变量、框架 API,甚至调用组件方法。

React 组件定位实战

实际项目中,React 组件通常不会直接标记 data-react-component 属性。更可靠的方法是访问 React Fiber 树。

Puppeteer.registerCustomQueryHandler('react', {
  queryOne: (element, name) => {
    // 遍历 DOM 树,通过 React 内部属性匹配组件名
    const walker = document.createTreeWalker(element, NodeFilter.SHOW_ELEMENT);
    do {
      const currentNode = walker.currentNode;
      // React 17+ 使用 __reactFiber$ 前缀的属性
      const fiberKey = Object.keys(currentNode).find(key => 
        key.startsWith('__reactFiber$')
      );
      if (fiberKey) {
        const fiber = currentNode[fiberKey];
        if (fiber.elementType?.name === name) {
          return currentNode;
        }
      }
    } while (walker.nextNode());
    return null;
  },
});

这个实现通过 document.createTreeWalker 遍历 DOM 树,检查每个元素的 React Fiber 属性。__reactFiber$ 是 React 内部使用的键,包含组件的元数据。通过比较 elementType.name 可以精确匹配组件定义时的名称。这种方法不依赖额外的 DOM 属性,更加健壮。

Vue 组件定位方案

Vue 3 的组件实例挂载在 DOM 元素的 __vnode 属性上,利用这个特性可以实现 Vue 专用选择器。

Puppeteer.registerCustomQueryHandler('vue', {
  queryOne: (element, name) => {
    const walker = document.createTreeWalker(element, NodeFilter.SHOW_ELEMENT);
    do {
      const currentNode = walker.currentNode;
      // Vue 3 组件实例存储在 __vnode.ctx 中
      if (
        currentNode.__vnode?.ctx?.type?.name.toLowerCase() ===
        name.toLowerCase()
      ) {
        return currentNode;
      }
    } while (walker.nextNode());
    return null;
  },
});

使用方式与 React 类似:await page.$('::-p-vue(MyComponent)')。需要注意的是,这种依赖框架内部 API 的方式存在风险。Vue 或 React 的版本升级可能改变属性结构,导致选择器失效。因此,建议在测试基础设施中封装这些选择器,一旦框架升级只需修改一处。

自定义选择器的最佳实践

自定义选择器虽然强大,但滥用会导致测试代码难以维护。以下是一些经验法则:

  1. 优先使用标准选择器:只有 CSS、文本、ARIA 等无法满足需求时才考虑自定义
  2. 封装框架细节:将 registerCustomQueryHandler 调用放在单独模块,避免散落在测试代码中
  3. 版本隔离:为不同版本的框架注册不同前缀的选择器,比如 react16、react17
  4. 性能考虑:queryAll 实现应尽量高效,避免在大型页面中造成卡顿
// 在 setup 文件中集中注册
import { Puppeteer } from 'puppeteer';

export function setupCustomSelectors() {
  Puppeteer.registerCustomQueryHandler('react', {
    // 实现略
  });
  
  Puppeteer.registerCustomQueryHandler('vue', {
    // 实现略
  });
}

// 在测试文件中导入
import { setupCustomSelectors } from './test-helpers';
setupCustomSelectors();

这种集中管理的方式让自定义选择器的维护变得清晰。当团队从 React 17 升级到 React 18 时,只需修改 setupCustomSelectors 函数,所有测试用例无需改动。

高级等待条件

超越可见性等待

基础等待通常只检查元素是否存在或可见,但业务逻辑往往需要更复杂的条件。比如等待某个元素从 DOM 中移除、等待文本内容更新、等待样式变化等。

函数定位器实现任意等待

Locator API 最强大的特性之一是支持函数作为定位器。这让我们可以用 JavaScript 表达任意等待条件。

// 等待 Canvas 元素出现
await page
  .locator(() => {
    return new Promise(resolve => {
      const observer = new MutationObserver(records => {
        for (const record of records) {
          if (record.target instanceof HTMLCanvasElement) {
            resolve(record.target);
          }
        }
      });
      observer.observe(document, { childList: true, subtree: true });
    });
  })
  .wait();

这个例子使用 MutationObserver 监控整个文档树,当任何 HTMLCanvasElement 被添加时立即返回。函数定位器返回 Promise,Puppeteer 会等待其 resolve。这种模式非常适合监控动态内容,比如等待图表渲染、等待懒加载图片等。

函数定位器不仅可以等待,还可以执行动作。因为 Locator API 的所有方法(click、fill、hover)都支持函数定位器,所以可以实现"等待某条件满足后立即操作"。

配置 Locator 行为

默认的自动检查并非总是需要。有时需要点击隐藏元素,或者操作正在动画的元素。Locator 提供了链式配置方法调整行为。

// 禁用所有等待条件,立即点击
await page
  .locator('button')
  .setEnsureElementIsInTheViewport(false)
  .setVisibility(null) // null 表示不检查可见性
  .setWaitForEnabled(false)
  .setWaitForStableBoundingBox(false)
  .click();

每个配置方法都返回 Locator 实例,支持链式调用。setVisibility(null) 比较特殊,它接受 visible、hidden 或 null 三个值。设置为 null 时完全跳过可见性检查,这在测试加载状态或隐藏功能时很有用。

超时精细控制

全局超时设置可能过于粗糙,某些操作需要更长的等待时间。Locator 允许为单个操作设置超时。

// 这个操作最多等待 5 秒
await page.locator('.slow-loading-data').setTimeout(5000).wait();

// 其他操作仍使用默认超时
await page.locator('.fast-button').click();

超时时间以毫秒为单位。如果操作在指定时间内未完成,会抛出 TimeoutError。这种细粒度控制让测试既能快速失败于明显错误,又能容忍合理的加载延迟。

监听 Locator 事件

Locator 在执行动作前会触发事件,这提供了介入和调试的机会。

let willClick = false;
await page
  .locator('button')
  .on(LocatorEvent.Action, () => {
    willClick = true;
    console.log('即将点击按钮,当前时间:', Date.now());
  })
  .click();

console.assert(willClick, '应该触发 Action 事件');

LocatorEvent.Action 在 Locator 完成所有前置检查、即将执行动作时触发。如果动作失败并重试,事件会多次触发。这在调试间歇性失败的测试时非常有用,可以记录每次尝试的上下文信息。

与低级别 API 的协作

虽然 Locator 是推荐方案,但有时需要直接操作 ElementHandle。Locator 提供了 waitHandle 方法获取底层句柄。

const buttonHandle = await page.locator('button').waitHandle();
// 使用 ElementHandle 的特定方法
await buttonHandle.click({ offset: { x: 10, y: 10 } });
await buttonHandle.dispose(); // 必须手动释放

获取句柄后,可以使用 ElementHandle 的所有方法,比如指定点击偏移、拖拽等 Locator 不直接支持的操作。但必须记得调用 dispose() 释放内存,否则长期运行的测试可能耗尽资源。

等待元素消失

测试加载状态或临时通知时,经常需要等待元素从 DOM 中移除。Locator 的 wait 方法配合 setVisibility 可以实现。

// 等待加载指示器消失
await page.locator('.loading-spinner').setVisibility('hidden').wait();

设置 setVisibility('hidden') 后,wait 会等待元素变为隐藏状态。如果元素被完全移除,同样视为隐藏。这种模式在提交表单后等待处理完成时非常常见。

综合示例:复杂表单提交

将本章技巧综合起来,看一个真实场景:提交表单后,等待服务器响应,验证结果,同时处理可能出现的错误提示。

// 填写并提交表单
await page.locator('#username').fill('testuser');
await page.locator('#password').fill('securepass');
await page.locator('button[type="submit"]').click();

// 等待两种可能的结果之一
const resultLocator = page.locator(() => {
  return Promise.race([
    // 等待成功消息
    new Promise(resolve => {
      const check = () => {
        const success = document.querySelector('.success-message');
        if (success && success.offsetParent !== null) {
          resolve({ type: 'success', element: success });
        }
      };
      check();
      new MutationObserver(check).observe(document, { 
        childList: true, 
        subtree: true,
        attributes: true,
        attributeFilter: ['class']
      });
    }),
    // 等待错误消息
    new Promise(resolve => {
      const check = () => {
        const error = document.querySelector('.error-message');
        if (error && error.offsetParent !== null) {
          resolve({ type: 'error', element: error });
        }
      };
      check();
      new MutationObserver(check).observe(document, { 
        childList: true, 
        subtree: true 
      });
    })
  ]);
});

const result = await resultLocator.wait();

if (result.type === 'error') {
  const message = await result.element.innerText;
  throw new Error(`提交失败: ${message}`);
}

// 验证成功结果
const welcomeText = await page.locator('.welcome').map(el => el.innerText).wait();
expect(welcomeText).toContain('testuser');

这个示例展示了函数定位器的真正威力:同时等待多个条件,返回结构化数据。通过 Promise.race 让两个等待条件竞争,无论哪个先满足都会立即返回。错误处理分支提取错误文本并抛出异常,成功分支则继续验证欢迎信息。这种模式让异步流程的测试变得清晰可控。

高级页面交互技巧的核心在于理解 Puppeteer 的分层设计:Locator 提供智能封装,选择器系统提供灵活定位,函数定位器提供无限扩展。掌握这些工具后,再复杂的页面交互都能写出健壮、可维护的测试代码。下一章将探讨如何将这些技术应用到 Chrome 扩展的测试场景中,进一步扩展自动化能力边界。