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

约翰湾
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 选择器本质上是语法上的;它们与 DOM 树的文本表示的内部运作方式密切相关,因为它们引用了 DOM 中的 ID 和类名称。因此,它们为网络开发者提供了一个不可或缺的工具,用于修改页面中的元素或添加样式,但在此上下文中,开发者拥有对页面及其 DOM 树的完全控制权。

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

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

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 负责将命令路由到正确的位置。

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

内容平台声明 (CDP)

这为实现查询功能提供了两种途径;我们可以:

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

我们实现了 3 个原型:

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

JS DOM 遍历

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

Puppeteer AXTree 遍历

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

CDP DOM 遍历

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

单元测试基准

下图比较了 3 个原型的 4 个元素查询 1,000 次的总运行时长。基准是在 3 种不同的配置中执行的,这些配置因页面大小以及是否启用了无障碍功能元素缓存而异。

基准:查询四个元素 1,000 次的总运行时

很明显,基于 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 进行查询。

后续操作

新的 aria 处理程序作为内置查询处理程序随 Puppeteer v5.4.0 一起提供。我们期待看到用户如何在测试脚本中采用该工具,也迫不及待地想要听取您的想法,了解如何使此功能更加实用!

下载预览渠道

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

与 Chrome 开发者工具团队联系

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

  • 请通过 crbug.com 向我们提交建议或反馈。
  • 使用开发者工具中的更多选项   了解详情   > Help > Report a DevTools issues,报告开发者工具问题。
  • 您可以前往 @ChromeDevTools 发 Twitter 微博。
  • 请在 YouTube 视频或“开发者工具提示”YouTube 视频中留言说明“开发者工具的新变化”。