Selenium WebDriver中的Shadow DOM自动化
[LOADING...]
1. 引言
Shadow DOM 是一种强大的封装技术,在开发中有许多优势。作为浏览器原生的 Web 标准,它得到了现代浏览器的广泛支持。然而,在自动化方面,它也带来了挑战。此外,Selenium 的架构进一步限制了可执行操作的范围。
本质上,Shadow DOM 是从常规 DOM 中隔离出来的 DOM 树片段,常规 DOM 也称为 light DOM。标记、样式和脚本对常规 DOM 隐藏,反之亦然(存在一些例外)。因此,只能进行有限的、有意为之的交互。
在本教程中,我们将了解如何使用 Shadow DOM。此外,我们还会看一个使用 Selenium 的示例,并比较现代方法与旧版方法。
2. 理解 Shadow DOM
首先,让我们理解为什么在自动化中要使用 Shadow DOM。我们经常在自定义元素和 UI 库中看到 Shadow DOM。在这些场景中,封装可确保全局样式或代码不会意外干扰组件的呈现和功能。尽管如此,我们经常需要与封装在其中的 Web 组件进行交互。此外,我们可能还需要验证样式和渲染后的内容。
2.1. Selenium 的处理方式
与其他流行的自动化工具相比,Selenium 处理 Shadow DOM 的方式相当手动。这一限制源于其作为 WebDriver 消息传递器 的架构。此外,用于在常规 DOM 中查找元素的标准函数不适用于 Shadow DOM。如果我们尝试使用 driver.findElement() 查找 shadow 树中的元素,就会抛出 NoSuchElementException。要穿透 shadow 边界,我们需要访问 shadow root,即 shadow 树的根元素。
算法很直接。首先,我们找到 shadow host,也就是 shadow 树所挂载到的元素,然后获取它的 shadow root。从那里,我们可以访问 Shadow DOM,并例如使用 CSS 选择器来查找元素。如果不存在 shadow root,Selenium 会抛出 NoSuchShadowRootException。
2.2. 类型
我们可以在浏览器开发者工具中检查 shadow root 的类型: [LOADING...]
在 #shadow-root 旁边括号中的文本会告诉我们其类型:
opencloseduser-agent
在 JavaScript 中,当 shadow root 不存在或处于 closed 状态时,宿主元素的 shadowRoot 属性会返回 null。不过,WebDriver 实现是通过另一种机制访问 shadow root,而不是通过这个 JavaScript 属性。由于 WebDriver 规范 没有提及 closed shadow root,其关于 null 情况的说明可以有不同解释。因此,在支持该能力的浏览器驱动中,Selenium 的 shadow root API 可以访问 closed shadow root。
在我们进行的实验中,ChromeDriver 返回了 shadow root,并允许查询 Shadow DOM。然而,与查询到的元素进行交互时抛出了 ElementNotInteractableException。相比之下,GeckoDriver 既提供了访问能力,也提供了可交互性。这些结果表明,除了 closed shadow root 支持之外,我们还需要进一步研究不同浏览器驱动中的元素可交互性。
如果无法直接访问 closed shadow root,我们可以考虑两种变通方法。当代码不是来自第三方时,我们可以请开发团队留下一个测试后门,例如全局引用钩子。此外,我们可以利用这样一个事实:closed shadow 树仍然是浏览器可访问性树和焦点树的一部分。因此,在定位到 shadow host 后,我们可以使用 Actions API 模拟用户键盘输入(例如 Tab 和 Enter 按键事件)来绕过 shadow 边界。不过,第二种方法并不稳定。
最后一种 shadow root 类型是 user-agent,用于内置元素,例如 <video>、<select> 和 <input type="range">。默认情况下,这类 shadow root 在开发者工具中显示为 closed,但我们通常可以更改浏览器配置。不过,通常情况下,我们不需要测试此类组件的浏览器实现。
2.3. XPath
由于 Shadow DOM 是一棵隔离的 DOM 树,从 shadow root 开始使用 XPath 似乎很直观。事实上,WebDriver 规范 将其列为受支持的定位策略。然而,我们无法使用 XPath 定位 shadow root 或其子元素。
截至本文撰写时,ChromeDriver 和 GeckoDriver 等主流浏览器驱动实现都不支持这一点。原因尚未公开。一个可能的技术挑战是:shadow root 并不是 Document,而是 DocumentFragment。同时,DocumentFragment 没有用于 XPath 求值的直接 API。
2.4. 嵌套
shadow 树可以嵌套,深度没有限制。当我们把一个包含 shadow root 的元素添加到另一棵 shadow 树中时,就会发生嵌套。虽然这在标准 Web 开发中很少见,但在组件库中却是常见模式。
例如,一个自定义下拉元素可以位于一个自定义表单内部:
处理嵌套的 shadow root 时,我们需要递归遍历它们,直到到达包含目标元素的 shadow root。
2.5. 事件传播
在进行问题根因分析时,能够捕获 Shadow DOM 事件非常重要。事实上,Shadow DOM 事件可以传播到常规 DOM。当其 composed 标志设置为 true 时,事件会跨越 shadow 边界。另一个重要标志是 bubbles,它表示事件是否会从目标元素向上冒泡。
对于 click、touch 或键盘事件,这两个标志都是 true。mouseenter 和 mouseleave 事件既不冒泡,也不跨越 shadow 边界。最后,capture: true 的监听器会监听不冒泡且 composed: true 的事件,例如 blur 和 focus。
跨越 shadow 边界后,事件目标会变为 shadow host。对于源自 open shadow root 内部的事件,外部监听器可以从 composedPath() 返回数组的第一个元素中获取原始目标。对于 closed root,该路径会隐藏其内部节点。此外,我们使用 JavascriptExecutor 来监听事件以及获取原始目标。
3. 使用 Selenium 实现自动化
现在,让我们看看 Selenium 的实际应用。我们将介绍两种可用方法:现代方法,以及在某些情况下仍然有用的旧版方法。
3.1. 先决条件与设置
首先,我们搭建一个简单的 Selenium 测试。为此,我们假设你对以下几个主题有基本了解:
- Java 开发环境
- 初始化 Maven 项目
- 运行测试
- JavaScript
对于示例代码,我们使用一个快速入门的 Maven 项目,并将 Selenium WebDriver、WebDriver Manager 和 JUnit 包添加到 pom.xml 文件中:
在这个例子中,我们使用 WebDriver Manager 自动下载并设置测试中所用浏览器驱动的正确版本。我们还使用 JUnit 来注解和组织示例代码。
接下来,我们在项目的 src/test/java 目录中创建一个示例测试文件。将其命名为 ShadowSearchTest.java,并添加初始化代码:
在 setup 和 teardown 函数中,我们在每个测试之前初始化 Chrome 驱动,并在每个测试之后退出驱动。
最后,我们在 src/test/resources 目录中添加一段示例网页代码,用于创建一个包含 Shadow DOM 的网页:
让我们看看结果:
[LOADING...]
正如预期,我们得到了一个位于 Shadow DOM 内部的简单 Google 搜索表单。
3.2. 现代方法
Selenium 4 通过 getShadowRoot() 函数引入了对 Shadow DOM 的原生支持。它返回 SearchContext,这是一个用于查找元素的接口,可用来在特定 shadow root 的 shadow 树内部查找元素。
让我们使用前面讨论的算法,为已设置的示例自动化执行一次 Google 搜索:
- 查找 shadow host 元素
- 获取其 shadow root
- 查找 input 元素
- 输入搜索查询
- 查找提交按钮
- 点击按钮
- 等待导航完成
接下来,我们在初始化函数之后添加一个测试:
重要的是,由于 shadow root 是动态加载的,我们对其使用了等待机制。
在此示例中,其中的元素不需要等待。此外,我们使用定时器来观察自动化步骤。
对 URL 变化的等待确认了已从示例页面导航离开,但并不能验证 Google 搜索结果是否成功。Google 可能会显示机器人验证,因此我们不应依赖其结果页面来验证此演示。
3.3. 旧版方法
Selenium 3 及更早版本只能使用 JavascriptExecutor 来处理 Shadow DOM。因此,我们运行一段脚本,查找 shadow root、查询并返回元素:
这里,arguments[0] 会解析为 shadow host。我们像往常一样使用返回的 WebElement。
在现代 Selenium 中,我们也可以使用这种旧版方法,并方便地返回 SearchContext:
这样,我们只查找 shadow root,并在返回前将其强制转换为 SearchContext。之后,我们在 Java 环境中就拥有了一个 shadow root,可以像现代方法示例中那样使用它。
相比之下,在同一个脚本中查找目标元素似乎很直观,从而进行优化,只向 WebDriver API 发送一次请求,而不是两次。在处理嵌套 Shadow DOM 时,这种方法还可以消除冗长的代码。然而,这种优化是有代价的。
我们可以使用 WebDriverWait 轮询 JavaScript 查找,包括通过嵌套 shadow root 进行的查找。对于动态挂载的 root,脚本应检查每个 root,并在目标元素存在之前返回 null。较长的脚本会让失败更难诊断,因此我们应让这些查找保持简短且可读。
我们应始终将可靠性——尤其是消除不稳定测试——置于执行速度之上。## 4. 结论
在本文中,我们了解了什么是 Shadow DOM,以及使用它时的一些细节。随后,我们使用 Selenium 实现了一个示例。最后,我们探讨了旧式方法及其现代应用。
尽管在 Selenium 中处理 shadow root 可能较为繁琐,但了解其能力与局限对于高效处理现代 Web 应用非常重要。
一如既往,所有源代码均可在 GitHub 上获取。