Puppetaria:以无障碍功能为先的 Puppeteer 脚本

Johan Bay
Johan Bay

Puppeteer 及其对选择器的方法

Puppeteer 是一个适用于 Node 的浏览器自动化库:它可让您使用简单现代的 JavaScript API 控制浏览器。

浏览器任务最突出的当然是浏览网页。自动执行此任务,实质上就是自动执行与网页的互动。

在 Puppeteer 中,此操作是通过使用基于字符串的选择器查询 DOM 元素并执行点击元素或输入文本等操作来实现的。例如,某个脚本会打开 developer.google.com,找到搜索框并搜索 puppetaria,如下所示:

(async () => {
   const browser = await puppeteer.launch({ headless: false });
   const page = await browser.newPage();
   await page.goto('https://developers.google.com/', { waitUntil: 'load' });
   // Find the search box using a suitable CSS selector.
   const search = await page.$('devsite-search > form > div.devsite-search-container');
   // Click to expand search box and focus it.
   await search.click();
   // Enter search string and press Enter.
   await search.type('puppetaria');
   await search.press('Enter');
 })();

因此,如何使用查询选择器识别元素是 Puppeteer 体验的决定性部分。到目前为止,Puppeteer 中的选择器一直局限于 CSS 和 XPath 选择器。CSS 和 XPath 选择器虽然表现得非常强大,但在脚本中持续存在浏览器互动却存在一些缺点。

语法与语义选择器

CSS 选择器本质上是语法;它们与 DOM 树的文本表示的内部运作方式紧密相关,因为它们会引用 DOM 中的 ID 和类名称。因此,它们为网络开发者提供了修改页面元素或为其添加样式不可或缺的工具,但在这种情况下,开发者可以完全控制页面及其 DOM 树。

另一方面,Puppeteer 脚本是页面的外部观察者,因此在这种上下文中使用 CSS 选择器时,它会引入关于页面的实现方式的隐藏假设,而 Puppeteer 脚本无法控制这些假设。

结果是,此类脚本可能很脆弱,并且容易受到源代码更改的影响。例如,假设使用 Puppeteer 脚本对某个 Web 应用进行自动测试,该应用包含节点 <button>Submit</button> 作为 body 元素的第三个子级。测试用例中的一个代码段可能如下所示:

const button = await page.$('body:nth-child(3)'); // problematic selector
await button.click();

在这里,我们使用选择器 'body:nth-child(3)' 来查找提交按钮,但这与这个版本的网页密切相关。如果之后在按钮上方添加了元素,此选择器将不再起作用!

这对测试编写者而言并不是新闻:Puppeteer 用户已经尝试选择能够适应此类变化的选择器。在 Puppetaria 中,我们为用户提供了一款新工具来完成这项任务。

Puppeteer 现在随附一个基于查询无障碍树的替代查询处理程序,而不是依赖于 CSS 选择器。这里的基本原理是,如果我们要选择的具体元素没有改变,那么相应的无障碍功能节点也应该没有改变。

我们将此类选择器命名为“ARIA 选择器”,并且支持查询计算出的无障碍树的无障碍名称和角色。与 CSS 选择器相比,这些属性在本质上属于语义。它们与 DOM 的语法属性无关,而是与如何通过屏幕阅读器等辅助技术观察网页相关的描述符。

在上面的测试脚本示例中,我们可以改用选择器 aria/Submit[role="button"] 来选择所需的按钮,其中 Submit 是指元素的无障碍名称:

const button = await page.$('aria/Submit[role="button"]');
await button.click();

现在,如果我们稍后决定将按钮的文本内容从 Submit 更改为 Done,测试将再次失败,但在本例中这是所期望结果;通过更改按钮的名称,我们可以更改页面的内容,而不是其视觉呈现或它在 DOM 中的结构。我们的测试应针对此类更改发出警告,以确保此类更改是有意为之。

回到包含搜索栏的较大示例,我们可以利用新的 aria 处理程序并将

const search = await page.$('devsite-search > form > div.devsite-search-container');

,这可以通过

const search = await page.$('aria/Open search[role="button"]');

找到搜索栏!

从更笼统的角度来说,我们认为使用此类 ARIA 选择器可以为 Puppeteer 用户带来以下好处:

  • 使测试脚本中的选择器更加适应源代码更改。
  • 提高测试脚本的可读性(可访问的名称是语义描述符)。
  • 鼓励采用为元素分配无障碍属性的最佳做法。

本文的其余部分将详细介绍我们如何实施 Puppetaria 项目。

设计流程

背景

如上所示,我们希望支持按元素的无障碍名称和角色查询元素。这些是无障碍树的属性,它是常规 DOM 树的双重属性,可供屏幕阅读器等设备显示网页。

通过查看计算无障碍名称的规范,我们可以明显看出,计算元素名称是一项非常重要的任务,因此从一开始,我们就决定将 Chromium 的现有基础架构用于此用途。

我们如何着手实施

我们甚至只使用 Chromium 的无障碍功能树,可以通过多种方法在 Puppeteer 中实现 ARIA 查询。要了解原因,我们先来看看 Puppeteer 如何控制浏览器。

浏览器通过名为 Chrome 开发者工具协议 (CDP) 的协议公开调试界面。这样就可以通过一个与语言无关的界面公开某些功能,例如“重新加载网页”或“在网页中执行这段 JavaScript 代码并传回结果”。

开发者工具前端和 Puppeteer 都使用 CDP 与浏览器通信。为实现 CDP 命令,在 Chrome 的所有组件(浏览器、渲染程序等)中都有开发者工具基础架构。CDP 负责将命令路由到正确的位置。

查询、点击和评估表达式等木偶操作人员操作是通过利用 CDP 命令(如 Runtime.evaluate)来执行的,此类命令直接在页面上下文中评估 JavaScript 并传回结果。其他 Puppeteer 操作(例如模拟色觉缺陷、截取屏幕截图或捕获轨迹)会使用 CDP 直接与 Blink 渲染流程进行通信。

CDP(首次出现加注全名“CDP”)

这为我们留下了两种实现查询功能的路径;我们可以:

  • 使用 JavaScript 编写查询逻辑,并使用 Runtime.evaluate 将其注入页面;或者
  • 使用可以直接在 Blink 进程中访问和查询无障碍树的 CDP 端点。

我们实现了 3 种原型:

  • JS DOM 遍历 - 基于将 JavaScript 注入页面
  • Puppeteer AXTree 遍历 - 基于使用对无障碍功能树的现有 CDP 访问权限
  • CDP DOM 遍历 - 使用专为查询无障碍树而打造的新 CDP 端点

JS DOM 遍历

此原型会完全遍历 DOM,并使用受 ComputedAccessibilityInfo 启动标志控制的 element.computedNameelement.computedRole,在遍历过程中检索每个元素的名称和角色。

Puppeteer AXTree 遍历

在这里,我们改为通过 CDP 检索完整的无障碍树,并在 Puppeteer 中遍历它。随后,生成的无障碍节点会映射到 DOM 节点。

CDP DOM 遍历

对于此原型,我们专门实现了一个新的 CDP 端点来查询无障碍功能树。这样,查询就可以通过 C++ 实现在后端进行,而不是通过 JavaScript 在页面上下文中进行。

单元测试基准

下图比较了 3 个原型对四个元素进行 1000 次查询的总运行时间。基准测试是在 3 种不同的配置中执行的,具体取决于页面大小以及是否启用了无障碍元素缓存。

基准:查询四个元素 1000 次的总运行时间

很明显,基于 CDP 的查询机制与仅在 Puppeteer 中实现的另外两种查询机制之间存在相当大的性能差距,而且相对差异似乎会随着页面大小的增加而急剧增长。有一点很有意思,那就是 JS DOM 遍历原型能够很好地响应启用无障碍缓存。停用缓存后,系统会按需计算无障碍树,并会在网域停用后在每次互动后舍弃该树。启用网域会使 Chromium 改为缓存经过计算的树。

对于 JS DOM 遍历,我们要求遍历过程中每个元素的可访问名称和角色。因此,如果缓存被停用,Chromium 会为我们访问的每个元素计算并舍弃可访问性树。另一方面,对于基于 CDP 的方法,只有在每次调用 CDP 之间(即每个查询)才会舍弃树。这些方法还从启用缓存中受益,因为无障碍树会在 CDP 调用之间持久保留,但性能提升相对较小。

虽然在此处启用缓存看起来很不错,但这也会导致额外的内存使用量。对于记录跟踪文件等 Puppeteer 脚本,这可能会带来问题。因此,我们决定不默认启用无障碍树缓存。用户可以通过启用 CDP 无障碍功能网域来自行开启缓存。

开发者工具测试套件基准

之前的基准测试表明,在 CDP 层实现查询机制有助于提升临床单元测试场景的性能。

为了在运行完整测试套件的更现实场景中查看差异是否明显足够明显,我们修补了开发者工具端到端测试套件,以便利用基于 JavaScript 和 CDP 的原型,并比较了各个运行时。在此基准测试中,我们将总共 43 个选择器从 [aria-label=…] 更改为自定义查询处理程序 aria/…,然后我们使用每个原型实现该处理程序。

有些选择器会在测试脚本中多次使用,因此套件每次运行时,aria 查询处理程序的实际执行次数为 113。查询选择总数为 2253,因此只有一小部分查询选择是通过原型进行的。

基准:e2e 测试套件

如上图所示,总运行时间存在明显的差异。数据太嘈杂,无法得出任何具体结论,但很明显,在此场景中两个原型之间的性能差距也反映出。

新的 CDP 端点

根据上述基准,而且基于发布标记的方法通常不可取,我们决定继续实现新的 CDP 命令来查询无障碍功能树。现在,我们必须弄清楚这个新端点的接口。

对于 Puppeteer 中的用例,我们需要该端点将所谓的 RemoteObjectIds 作为参数,并且为了让我们之后能够找到相应的 DOM 元素,该端点应返回一个对象列表,其中包含 DOM 元素的 backendNodeIds

如下图所示,我们尝试了好几种方法来满足此界面的要求。由此,我们发现返回对象的大小(即是返回完整的无障碍节点还是仅返回 backendNodeIds)没有明显的差异。另一方面,我们发现,使用现有的 NextInPreOrderIncludingIgnored 在此处实现遍历逻辑不太理想,因为这会导致速度明显变慢。

基准:基于 CDP 的 AXTree 遍历原型比较

正在收尾

现在,有了 CDP 端点,我们在 Puppeteer 端实现了查询处理程序。此处工作的难点是重组查询处理代码,使查询直接通过 CDP 进行解析,而不是通过在页面上下文中评估的 JavaScript 进行查询。

后续操作

Puppeteer v5.4.0 随附的全新 aria 处理程序作为内置查询处理程序。我们期待看到用户将其应用到其测试脚本中,也迫不及待地想听听您的想法,告诉我们如何让此功能更加实用!

下载预览渠道

请考虑将 Chrome Canary开发者版Beta 版用作您的默认开发浏览器。通过这些预览渠道,您可以访问最新的开发者工具功能,测试先进的网络平台 API,并在用户之前发现您网站上的问题!

与 Chrome 开发者工具团队联系

使用以下选项讨论博文中的新功能和变更,或与开发者工具相关的任何其他内容。

  • 请通过 crbug.com 提交建议或反馈。
  • 在开发者工具中使用更多选项   了解详情   > 帮助 > 报告开发者工具问题来报告开发者工具问题。
  • 请发送电子邮件至 @ChromeDevTools
  • 请对我们的开发者工具新功能 YouTube 视频或开发者工具提示 YouTube 视频发表评论。