Shadow DOM 支持与可复用组件对象
Shadow DOM 是构成 Web Components 的关键浏览器功能之一。Web Components 是一种构建可复用元素的绝佳方法,并且能够扩展到完整的 Web 应用程序。样式封装是赋予 Shadow DOM 强大功能的功能,但在进行端到端或 UI 测试时一直是一个难题。不过,事情变得稍微简单了一些,因为 WebdriverIO v5.5.0 通过两个新命令引入了对 Shadow DOM 的内置支持,即 shadow$
和 shadow$$
。让我们深入了解一下它们的功能。
历史
随着 Shadow DOM 规范的 v0 版本的发布,出现了 /deep/
选择器。这个特殊的选择器使得查询元素的 shadowRoot
内部成为可能。这里我们正在查询一个位于 my-element
自定义元素的 shadowRoot
内部的按钮。
$('body my-element /deep/ button');
/deep/
选择器生命周期短暂,并且有传言称将来会被替换。
随着 /deep/
被弃用并随后移除,开发人员找到了其他方法来访问其 Shadow 元素。典型的方法是在 WebdriverIO 中使用自定义命令。这些命令使用 execute
命令将 querySelector
和 shadowRoot.querySelector
调用串联起来以查找元素。这通常有效,因此,查询不是基本的字符串查询,而是被放入数组中。数组中的每个字符串都表示一个 Shadow 边界。使用这些命令看起来像这样
const myButton = browser.shadowDomElement(['body my-element', 'button']);
/deep/
选择器和 JavaScript 方法的缺点是,为了查找元素,查询始终需要从文档级别开始。这使得测试有点笨拙且难以维护。类似这样的代码并不少见
it('submits the form', ()=> {
const myInput = browser.shadowDomElement(BASE_SELECTOR.concat(['my-deeply-nested-element', 'input']));
const myButton = browser.shadowDomElement(BASE_SELECTOR.concat(['my-deeply-nested-element', 'button']));
myInput.setValue('test');
myButton.click();
});
shadow$
和 shadow$$
命令
这些命令利用了 WebdriverIO v5 中 $
命令使用函数选择器功能的能力。它们的工作原理与现有的 $
和 $$
命令相同,您可以在元素上调用它,但它们不是查询元素的光 DOM,而是查询元素的 Shadow DOM(如果由于某种原因您没有使用任何 polyfills,它们会回退到查询光 DOM)。
由于它们是元素命令,因此在构建查询时不再需要从根文档开始。获取元素后,调用 element.shadow$('selector')
会在该元素的 shadowRoot
中查询与给定选择器匹配的元素。从任何元素开始,您可以根据需要深度链接 $
和 shadow$
命令。
页面对象
与 $
和 $$
类似,Shadow 命令使页面对象的编写、阅读和维护变得轻而易举。假设我们正在处理一个看起来像这样的页面
<body>
<my-app>
<app-login></app-login>
</my-app>
</body>
这使用了两个自定义元素,my-app
和 app-login
。我们可以看到 my-app
位于 body
的光 DOM 中,并且在其光 DOM 内部有一个 app-login
元素。与该页面交互的页面对象的示例可能如下所示
class LoginPage {
open() {
browser.url('/login');
}
get app() {
// my-app lives in the document's light DOM
return browser.$('my-app');
}
get login() {
// app-login lives in my-app's light DOM
return this.app.$('app-login');
}
get usernameInput() {
// the username input is inside app-login's shadow DOM
return this.login.shadow$('input #username');
}
get passwordInput() {
// the password input is inside app-login's shadow DOM
return this.login.shadow$('input[type=password]');
}
get submitButton() {
// the submit button is inside app-login's shadow DOM
return this.login.shadow$('button[type=submit]');
}
login(username, password) {
this.login.setValue(username);
this.username.setValue(password);
this.submitButton.click();
}
}
在上面的示例中,您可以看到如何轻松利用页面对象的 getter 方法深入到应用程序的不同部分。这使您的选择器保持简洁和集中。例如,如果您决定移动 app-login
元素,则只需更改一个选择器即可。
组件对象
遵循页面对象模式本身就非常强大。Web Components 的最大优势在于您可以创建可复用元素。但是,仅使用页面对象的缺点是,您可能最终会在不同的页面对象中重复代码和选择器,以便能够与 Web Components 中封装的元素进行交互。
组件对象模式试图减少这种重复,并将组件的 API 移到它自己的对象中。我们知道,为了与元素的 Shadow DOM 交互,我们首先需要宿主元素。使用组件对象的基类可以使此操作非常简单。这是一个基本的组件基类,它在其构造函数中获取 host
元素,并将该元素的查询展开到浏览器对象,以便它可以在许多页面对象(或其他组件对象)中重复使用,而无需了解页面本身的任何信息
class Component {
constructor(host) {
const selectors = [];
// Crawl back to the browser object, and cache all selectors
while (host.elementId && host.parent) {
selectors.push(host.selector);
host = host.parent;
}
selectors.reverse();
this.selectors_ = selectors;
}
get host() {
// Beginning with the browser object, reselect each element
return this.selectors_.reduce((element, selector) => element.$(selector), browser);
}
}
module.exports = Component;
然后我们可以为我们的 app-login
组件编写一个子类
const Component = require('./component');
class Login extends Component {
get usernameInput() {
return this.host.shadow$('input #username');
}
get passwordInput() {
return this.host.shadow$('input[type=password]');
}
get submitButton() {
return this.login.shadow$('button[type=submit]');
}
login(username, password) {
this.usernameInput.setValue(username);
this.passwordInput.setValue(password);
this.submitButton.click();
}
}
module.exports = Login;
最后,我们可以在登录页面对象中使用组件对象
const Login = require('./components/login');
class LoginPage {
open() {
browser.url('/login');
}
get app() {
return browser.$('my-app');
}
get loginComponent() {
// return a new instance of our login component object
return new Login(this.app.$('app-login'));
}
}
现在,此组件对象可用于使用 app-login
Web Components 的应用程序的任何页面或部分的测试,而无需了解该组件的结构。如果您以后决定更改 Web Components 的内部结构,则只需更新组件对象即可。
未来
目前,WebDriver 协议未提供对 Shadow DOM 的原生支持,但已经取得了一些进展。一旦规范最终确定,WebdriverIO 将实现该规范。shadow
命令很有可能在幕后发生变化,但我非常有信心它们的使用方式将与今天相同,并且使用它们的测试代码将几乎不需要重构。
浏览器支持
IE11-Edge:IE 或 Edge 不支持 Shadow DOM,但可以使用 polyfills。Shadow 命令与 polyfills 配合使用效果很好。
Firefox:在 Firefox 中对输入字段调用 setValue(value)
会导致错误,抱怨输入“无法通过键盘访问”。目前的解决方法是使用自定义命令(或组件对象上的方法),通过 browser.execute(function)
设置输入字段的值。
Safari:WebdriverIO 有一些安全机制可以帮助减轻陈旧元素引用的问题。这是一个非常好的功能,但不幸的是,Safari 的 WebDriver 在尝试与其他浏览器中的陈旧元素引用交互时,不会提供正确的错误响应。这很遗憾,但同时,缓存元素引用通常是一种不好的做法。通过使用上面概述的页面和组件对象模式,通常可以完全缓解陈旧元素引用问题。
Chrome:它可以正常工作。🎉