feat(selectors): role selector engine (#12999)

This introduces `role=button[name="Click me"][pressed]` attribute-style
role selector. It is only available under `env.PLAYWRIGHT_EXPERIMENTAL_FEATURES`.

Supported attributes:
- `role` is required, for example `role=button`;
- `name` is accessible name, supports matching operators and regular expressions:
  `role=button[name=/Click(me)?/]`;
- `checked` boolean/mixed, for example `role=checkbox[checked=false]`;
- `selected` boolean, for example `role=option[selected]`;
- `expanded` boolean, for example `role=button[expanded=true]`;
- `disabled` boolean, for example `role=button[disabled]`;
- `level` number, for example `role=heading[level=3]`;
- `pressed` boolean/mixed, for example `role=button[pressed="mixed"]`;
- `includeHidden` - by default, only non-hidden elements are considered.
   Passing `role=button[includeHidden]` matches hidden elements as well.
This commit is contained in:
Dmitry Gozman 2022-03-28 09:24:58 -07:00 committed by GitHub
parent 1471bb7177
commit 8c19f71c36
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 607 additions and 59 deletions

View file

@ -28,7 +28,7 @@ import { Progress, ProgressController } from './progress';
import { SelectorInfo } from './selectors';
import * as types from './types';
import { TimeoutOptions } from '../common/types';
import { isUnderTest } from '../utils/utils';
import { experimentalFeaturesEnabled, isUnderTest } from '../utils/utils';
type SetInputFilesFiles = channels.ElementHandleSetInputFilesParams['files'];
export type InputFilesItems = { files?: SetInputFilesFiles, localPaths?: string[] };
@ -104,6 +104,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
${isUnderTest()},
${this.frame._page._delegate.rafCountForStablePosition()},
"${this.frame._page._browserContext._browser.options.name}",
${experimentalFeaturesEnabled()},
[${custom.join(',\n')}]
);
})();

View file

@ -14,10 +14,11 @@
* limitations under the License.
*/
type Operator = '<truthy>'|'='|'*='|'|='|'^='|'$='|'~=';
export type ParsedAttributeOperator = '<truthy>'|'='|'*='|'|='|'^='|'$='|'~=';
export type ParsedComponentAttribute = {
name: string,
jsonPath: string[],
op: Operator,
op: ParsedAttributeOperator,
value: any,
caseSensitive: boolean,
};
@ -152,7 +153,7 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto
return token;
}
function readOperator(): Operator {
function readOperator(): ParsedAttributeOperator {
skipSpaces();
let op = '';
if (!EOL)
@ -161,7 +162,7 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto
op += eat1();
if (!['=', '*=', '^=', '$=', '|=', '~='].includes(op))
syntaxError('parsing operator');
return (op as Operator);
return (op as ParsedAttributeOperator);
}
function readAttribute(): ParsedComponentAttribute {
@ -182,7 +183,7 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto
// check property is truthy: [enabled]
if (next() === ']') {
eat1();
return { jsonPath, op: '<truthy>', value: null, caseSensitive: false };
return { name: jsonPath.join('.'), jsonPath, op: '<truthy>', value: null, caseSensitive: false };
}
const operator = readOperator();
@ -225,7 +226,7 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto
eat1();
if (operator !== '=' && typeof value !== 'string')
throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`);
return { jsonPath, op: operator, value, caseSensitive };
return { name: jsonPath.join('.'), jsonPath, op: operator, value, caseSensitive };
}
const result: ParsedComponentSelector = {

View file

@ -18,13 +18,14 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { XPathEngine } from './xpathSelectorEngine';
import { ReactEngine } from './reactSelectorEngine';
import { VueEngine } from './vueSelectorEngine';
import { RoleEngine } from './roleSelectorEngine';
import { allEngineNames, ParsedSelector, ParsedSelectorPart, parseSelector, stringifySelector } from '../common/selectorParser';
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator';
import { CSSComplexSelectorList } from '../common/cssParser';
import { generateSelector } from './selectorGenerator';
import type * as channels from '../../protocol/channels';
import { Highlight } from './highlight';
import { getElementAccessibleName } from './roleUtils';
import { getAriaDisabled, getElementAccessibleName } from './roleUtils';
type Predicate<T> = (progress: InjectedScriptProgress) => T | symbol;
@ -79,7 +80,7 @@ export class InjectedScript {
private _highlight: Highlight | undefined;
readonly isUnderTest: boolean;
constructor(isUnderTest: boolean, stableRafCount: number, browserName: string, customEngines: { name: string, engine: SelectorEngine}[]) {
constructor(isUnderTest: boolean, stableRafCount: number, browserName: string, experimentalFeaturesEnabled: boolean, customEngines: { name: string, engine: SelectorEngine}[]) {
this.isUnderTest = isUnderTest;
this._evaluator = new SelectorEvaluatorImpl(new Map());
@ -88,6 +89,8 @@ export class InjectedScript {
this._engines.set('xpath:light', XPathEngine);
this._engines.set('_react', ReactEngine);
this._engines.set('_vue', VueEngine);
if (experimentalFeaturesEnabled)
this._engines.set('role', RoleEngine);
this._engines.set('text', this._createTextEngine(true));
this._engines.set('text:light', this._createTextEngine(false));
this._engines.set('id', this._createAttributeEngine('id', true));
@ -515,7 +518,7 @@ export class InjectedScript {
if (state === 'hidden')
return !this.isVisible(element);
const disabled = isElementDisabled(element);
const disabled = getAriaDisabled(element);
if (state === 'disabled')
return disabled;
if (state === 'enabled')
@ -1250,35 +1253,4 @@ function deepEquals(a: any, b: any): boolean {
return false;
}
function isElementDisabled(element: Element): boolean {
const isRealFormControl = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'].includes(element.nodeName);
if (isRealFormControl && element.hasAttribute('disabled'))
return true;
if (isRealFormControl && hasDisabledFieldSet(element))
return true;
if (hasAriaDisabled(element))
return true;
return false;
}
function hasDisabledFieldSet(element: Element|null): boolean {
if (!element)
return false;
if (element.tagName === 'FIELDSET' && element.hasAttribute('disabled'))
return true;
// fieldset does not work across shadow boundaries
return hasDisabledFieldSet(element.parentElement);
}
function hasAriaDisabled(element: Element|undefined): boolean {
if (!element)
return false;
const attribute = (element.getAttribute('aria-disabled') || '').toLowerCase();
if (attribute === 'true')
return true;
if (attribute === 'false')
return false;
return hasAriaDisabled(parentElementOrShadowHost(element));
}
export default InjectedScript;

View file

@ -0,0 +1,158 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { matchesAttribute, parseComponentSelector, ParsedComponentAttribute, ParsedAttributeOperator } from './componentUtils';
import { getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils';
const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'includeHidden'];
kSupportedAttributes.sort();
function validateSupportedRole(attr: string, roles: string[], role: string) {
if (!roles.includes(role))
throw new Error(`"${attr}" attribute is only supported for roles: ${roles.slice().sort().map(role => `"${role}"`).join(', ')}`);
}
function validateSupportedValues(attr: ParsedComponentAttribute, values: any[]) {
if (attr.op !== '<truthy>' && !values.includes(attr.value))
throw new Error(`"${attr.name}" must be one of ${values.map(v => JSON.stringify(v)).join(', ')}`);
}
function validateSupportedOp(attr: ParsedComponentAttribute, ops: ParsedAttributeOperator[]) {
if (!ops.includes(attr.op))
throw new Error(`"${attr.name}" does not support "${attr.op}" matcher`);
}
function validateAttributes(attrs: ParsedComponentAttribute[], role: string) {
for (const attr of attrs) {
switch (attr.name) {
case 'checked': {
validateSupportedRole(attr.name, kAriaCheckedRoles, role);
validateSupportedValues(attr, [true, false, 'mixed']);
validateSupportedOp(attr, ['<truthy>', '=']);
break;
}
case 'pressed': {
validateSupportedRole(attr.name, kAriaPressedRoles, role);
validateSupportedValues(attr, [true, false, 'mixed']);
validateSupportedOp(attr, ['<truthy>', '=']);
break;
}
case 'selected': {
validateSupportedRole(attr.name, kAriaSelectedRoles, role);
validateSupportedValues(attr, [true, false]);
validateSupportedOp(attr, ['<truthy>', '=']);
break;
}
case 'expanded': {
validateSupportedRole(attr.name, kAriaExpandedRoles, role);
validateSupportedValues(attr, [true, false]);
validateSupportedOp(attr, ['<truthy>', '=']);
break;
}
case 'level': {
validateSupportedRole(attr.name, kAriaLevelRoles, role);
if (attr.op !== '=' || typeof attr.value !== 'number')
throw new Error(`"level" attribute must be compared to a number`);
break;
}
case 'disabled': {
validateSupportedValues(attr, [true, false]);
validateSupportedOp(attr, ['<truthy>', '=']);
break;
}
case 'name': {
if (attr.op !== '<truthy>' && typeof attr.value !== 'string' && !(attr.value instanceof RegExp))
throw new Error(`"name" attribute must be a string or a regular expression`);
break;
}
case 'includeHidden': {
validateSupportedValues(attr, [true, false]);
validateSupportedOp(attr, ['<truthy>', '=']);
break;
}
default: {
throw new Error(`Unknown attribute "${attr.name}", must be one of ${kSupportedAttributes.map(a => `"${a}"`).join(', ')}.`);
}
}
}
}
export const RoleEngine: SelectorEngine = {
queryAll(scope: SelectorRoot, selector: string): Element[] {
const parsed = parseComponentSelector(selector);
const role = parsed.name.toLowerCase();
if (!role)
throw new Error(`Role must not be empty`);
validateAttributes(parsed.attributes, role);
const hiddenCache = new Map<Element, boolean>();
const result: Element[] = [];
const match = (element: Element) => {
if (getAriaRole(element) !== role)
return;
let includeHidden = false; // By default, hidden elements are excluded.
let nameAttr: ParsedComponentAttribute | undefined;
for (const attr of parsed.attributes) {
if (attr.name === 'includeHidden') {
includeHidden = attr.op === '<truthy>' || !!attr.value;
continue;
}
if (attr.name === 'name') {
nameAttr = attr;
continue;
}
let actual;
switch (attr.name) {
case 'selected': actual = getAriaSelected(element); break;
case 'checked': actual = getAriaChecked(element); break;
case 'pressed': actual = getAriaPressed(element); break;
case 'expanded': actual = getAriaExpanded(element); break;
case 'level': actual = getAriaLevel(element); break;
case 'disabled': actual = getAriaDisabled(element); break;
}
if (!matchesAttribute(actual, attr))
return;
}
if (!includeHidden) {
const isHidden = isElementHiddenForAria(element, hiddenCache);
if (isHidden)
return;
}
if (nameAttr !== undefined) {
const accessibleName = getElementAccessibleName(element, includeHidden, hiddenCache);
if (!matchesAttribute(accessibleName, nameAttr))
return;
}
result.push(element);
};
const query = (root: Element | ShadowRoot | Document) => {
const shadows: ShadowRoot[] = [];
if ((root as Element).shadowRoot)
shadows.push((root as Element).shadowRoot!);
for (const element of root.querySelectorAll('*')) {
match(element);
if (element.shadowRoot)
shadows.push(element.shadowRoot);
}
shadows.forEach(query);
};
query(scope);
return result;
}
};

View file

@ -199,8 +199,8 @@ const validRoles = allRoles.filter(role => !abstractRoles.includes(role));
function getExplicitAriaRole(element: Element): string | null {
// https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles
const explicitRole = (element.getAttribute('role') || '').trim().split(' ')[0];
return validRoles.includes(explicitRole) ? explicitRole : null;
const roles = (element.getAttribute('role') || '').split(' ').map(role => role.trim());
return roles.find(role => validRoles.includes(role)) || null;
}
function hasPresentationConflictResolution(element: Element) {
@ -209,7 +209,7 @@ function hasPresentationConflictResolution(element: Element) {
return !hasGlobalAriaAttribute(element);
}
function getAriaRole(element: Element): string | null {
export function getAriaRole(element: Element): string | null {
const explicitRole = getExplicitAriaRole(element);
if (!explicitRole)
return getImplicitAriaRole(element);
@ -226,27 +226,29 @@ function getComputedStyle(element: Element, pseudo?: string): CSSStyleDeclaratio
return element.ownerDocument && element.ownerDocument.defaultView ? element.ownerDocument.defaultView.getComputedStyle(element, pseudo) : undefined;
}
// https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion, but including "none" and "presentation" roles
// https://www.w3.org/TR/wai-aria-1.2/#aria-hidden
export function isElementHiddenForAria(element: Element, cache: Map<Element, boolean>): boolean {
if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(element.tagName))
return true;
let style: CSSStyleDeclaration | undefined = getComputedStyle(element);
const style: CSSStyleDeclaration | undefined = getComputedStyle(element);
if (!style || style.visibility === 'hidden')
return true;
return belongsToDisplayNoneOrAriaHidden(element, cache);
}
let parent: Element | undefined = element;
while (parent) {
if (!cache.has(parent)) {
if (!style)
style = getComputedStyle(parent);
const hidden = !style || style.display === 'none' || getAriaBoolean(parent.getAttribute('aria-hidden')) === true;
cache.set(parent, hidden);
function belongsToDisplayNoneOrAriaHidden(element: Element, cache: Map<Element, boolean>): boolean {
if (!cache.has(element)) {
const style = getComputedStyle(element);
let hidden = !style || style.display === 'none' || getAriaBoolean(element.getAttribute('aria-hidden')) === true;
if (!hidden) {
const parent = parentElementOrShadowHost(element);
if (parent)
hidden = hidden || belongsToDisplayNoneOrAriaHidden(parent, cache);
}
if (cache.get(parent)!)
return true;
parent = parentElementOrShadowHost(parent);
cache.set(element, hidden);
}
return false;
return cache.get(element)!;
}
function getIdRefs(element: Element, ref: string | null): Element[] {
@ -609,3 +611,106 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
options.visitedElements.add(element);
return '';
}
export const kAriaSelectedRoles = ['gridcell', 'option', 'row', 'tab', 'rowheader', 'columnheader', 'treeitem'];
export function getAriaSelected(element: Element): boolean {
// https://www.w3.org/TR/wai-aria-1.2/#aria-selected
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
if (element.tagName === 'OPTION')
return (element as HTMLOptionElement).selected;
if (kAriaSelectedRoles.includes(getAriaRole(element) || ''))
return getAriaBoolean(element.getAttribute('aria-selected')) === true;
return false;
}
export const kAriaCheckedRoles = ['checkbox', 'menuitemcheckbox', 'option', 'radio', 'switch', 'menuitemradio', 'treeitem'];
export function getAriaChecked(element: Element): boolean | 'mixed' {
// https://www.w3.org/TR/wai-aria-1.2/#aria-checked
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
if (element.tagName === 'INPUT' && (element as HTMLInputElement).indeterminate)
return 'mixed';
if (element.tagName === 'INPUT' && ['checkbox', 'radio'].includes((element as HTMLInputElement).type))
return (element as HTMLInputElement).checked;
if (kAriaCheckedRoles.includes(getAriaRole(element) || '')) {
const checked = element.getAttribute('aria-checked');
if (checked === 'true')
return true;
if (checked === 'mixed')
return 'mixed';
}
return false;
}
export const kAriaPressedRoles = ['button'];
export function getAriaPressed(element: Element): boolean | 'mixed' {
// https://www.w3.org/TR/wai-aria-1.2/#aria-pressed
if (kAriaPressedRoles.includes(getAriaRole(element) || '')) {
const pressed = element.getAttribute('aria-pressed');
if (pressed === 'true')
return true;
if (pressed === 'mixed')
return 'mixed';
}
return false;
}
export const kAriaExpandedRoles = ['application', 'button', 'checkbox', 'combobox', 'gridcell', 'link', 'listbox', 'menuitem', 'row', 'rowheader', 'tab', 'treeitem', 'columnheader', 'menuitemcheckbox', 'menuitemradio', 'rowheader', 'switch'];
export function getAriaExpanded(element: Element): boolean {
// https://www.w3.org/TR/wai-aria-1.2/#aria-expanded
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
if (element.tagName === 'DETAILS')
return (element as HTMLDetailsElement).open;
if (kAriaExpandedRoles.includes(getAriaRole(element) || ''))
return getAriaBoolean(element.getAttribute('aria-expanded')) === true;
return false;
}
export const kAriaLevelRoles = ['heading', 'listitem', 'row', 'treeitem'];
export function getAriaLevel(element: Element) {
// https://www.w3.org/TR/wai-aria-1.2/#aria-level
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
const native = { 'H1': 1, 'H2': 2, 'H3': 3, 'H4': 4, 'H5': 5, 'H6': 6 }[element.tagName];
if (native)
return native;
if (kAriaLevelRoles.includes(getAriaRole(element) || '')) {
const attr = element.getAttribute('aria-level');
const value = attr === null ? Number.NaN : Number(attr);
if (Number.isInteger(value) && value >= 1)
return value;
}
return 0;
}
export const kAriaDisabledRoles = ['application', 'button', 'composite', 'gridcell', 'group', 'input', 'link', 'menuitem', 'scrollbar', 'separator', 'tab', 'checkbox', 'columnheader', 'combobox', 'grid', 'listbox', 'menu', 'menubar', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'radiogroup', 'row', 'rowheader', 'searchbox', 'select', 'slider', 'spinbutton', 'switch', 'tablist', 'textbox', 'toolbar', 'tree', 'treegrid', 'treeitem'];
export function getAriaDisabled(element: Element): boolean {
// https://www.w3.org/TR/wai-aria-1.2/#aria-disabled
// https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings
// Note that aria-disabled applies to all descendants, so we look up the hierarchy.
const isNativeFormControl = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'].includes(element.tagName);
if (isNativeFormControl && (element.hasAttribute('disabled') || belongsToDisabledFieldSet(element)))
return true;
return hasExplicitAriaDisabled(element);
}
function belongsToDisabledFieldSet(element: Element | null): boolean {
if (!element)
return false;
if (element.tagName === 'FIELDSET' && element.hasAttribute('disabled'))
return true;
// fieldset does not work across shadow boundaries.
return belongsToDisabledFieldSet(element.parentElement);
}
function hasExplicitAriaDisabled(element: Element | undefined): boolean {
if (!element)
return false;
if (kAriaDisabledRoles.includes(getAriaRole(element) || '')) {
const attribute = (element.getAttribute('aria-disabled') || '').toLowerCase();
if (attribute === 'true')
return true;
if (attribute === 'false')
return false;
}
// aria-disabled works across shadow boundaries.
return hasExplicitAriaDisabled(parentElementOrShadowHost(element));
}

View file

@ -19,7 +19,7 @@ import * as frames from './frames';
import * as js from './javascript';
import * as types from './types';
import { allEngineNames, InvalidSelectorError, ParsedSelector, parseSelector, stringifySelector } from './common/selectorParser';
import { createGuid } from '../utils/utils';
import { createGuid, experimentalFeaturesEnabled } from '../utils/utils';
export type SelectorInfo = {
parsed: ParsedSelector,
@ -133,6 +133,8 @@ export class Selectors {
}
parseSelector(selector: string | ParsedSelector, strict: boolean): SelectorInfo {
if (experimentalFeaturesEnabled())
this._builtinEngines.add('role');
const parsed = typeof selector === 'string' ? parseSelector(selector) : selector;
let needsMainWorld = false;
for (const name of allEngineNames(parsed)) {

View file

@ -338,6 +338,10 @@ export function isUnderTest(): boolean {
return _isUnderTest;
}
export function experimentalFeaturesEnabled() {
return isUnderTest() || !!process.env.PLAYWRIGHT_EXPERIMENTAL_FEATURES;
}
export function getFromENV(name: string): string | undefined {
let value = process.env[name];
value = value === undefined ? process.env[`npm_config_${name.toLowerCase()}`] : value;

View file

@ -126,7 +126,7 @@ it('should wait for aria enabled button', async ({ page }) => {
});
it('should wait for button with an aria-disabled parent', async ({ page }) => {
await page.setContent('<div aria-disabled=true><button><span>Target</span></button></div>');
await page.setContent('<div role="group" aria-disabled=true><button><span>Target</span></button></div>');
const span = await page.$('text=Target');
let done = false;
const promise = span.waitForElementState('enabled').then(() => done = true);

View file

@ -0,0 +1,305 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test, expect } from './pageTest';
test('should detect roles', async ({ page }) => {
await page.setContent(`
<button>Hello</button>
<select multiple="" size="2"></select>
<select></select>
<h3>Heading</h3>
<details><summary>Hello</summary></details>
<div role="dialog">I am a dialog</div>
`);
expect(await page.$$eval(`role=button`, els => els.map(e => e.outerHTML))).toEqual([
`<button>Hello</button>`,
]);
expect(await page.$$eval(`role=listbox`, els => els.map(e => e.outerHTML))).toEqual([
`<select multiple="" size="2"></select>`,
]);
expect(await page.$$eval(`role=combobox`, els => els.map(e => e.outerHTML))).toEqual([
`<select></select>`,
]);
expect(await page.$$eval(`role=heading`, els => els.map(e => e.outerHTML))).toEqual([
`<h3>Heading</h3>`,
]);
expect(await page.$$eval(`role=group`, els => els.map(e => e.outerHTML))).toEqual([
`<details><summary>Hello</summary></details>`,
]);
expect(await page.$$eval(`role=dialog`, els => els.map(e => e.outerHTML))).toEqual([
`<div role="dialog">I am a dialog</div>`,
]);
expect(await page.$$eval(`role=menuitem`, els => els.map(e => e.outerHTML))).toEqual([
]);
});
test('should support selected', async ({ page }) => {
await page.setContent(`
<select>
<option>Hi</option>
<option selected>Hello</option>
</select>
<div>
<div role="option" aria-selected="true">Hi</div>
<div role="option" aria-selected="false">Hello</div>
</div>
`);
expect(await page.$$eval(`role=option[selected]`, els => els.map(e => e.outerHTML))).toEqual([
`<option selected="">Hello</option>`,
`<div role="option" aria-selected="true">Hi</div>`,
]);
expect(await page.$$eval(`role=option[selected=true]`, els => els.map(e => e.outerHTML))).toEqual([
`<option selected="">Hello</option>`,
`<div role="option" aria-selected="true">Hi</div>`,
]);
expect(await page.$$eval(`role=option[selected=false]`, els => els.map(e => e.outerHTML))).toEqual([
`<option>Hi</option>`,
`<div role="option" aria-selected="false">Hello</div>`,
]);
});
test('should support checked', async ({ page }) => {
await page.setContent(`
<input type=checkbox>
<input type=checkbox checked>
<input type=checkbox indeterminate>
<div role=checkbox aria-checked="true">Hi</div>
<div role=checkbox aria-checked="false">Hello</div>
<div role=checkbox>Unknown</div>
`);
await page.$eval('[indeterminate]', input => (input as HTMLInputElement).indeterminate = true);
expect(await page.$$eval(`role=checkbox[checked]`, els => els.map(e => e.outerHTML))).toEqual([
`<input type="checkbox" checked="">`,
`<input type="checkbox" indeterminate="">`,
`<div role="checkbox" aria-checked="true">Hi</div>`,
]);
expect(await page.$$eval(`role=checkbox[checked=true]`, els => els.map(e => e.outerHTML))).toEqual([
`<input type="checkbox" checked="">`,
`<div role="checkbox" aria-checked="true">Hi</div>`,
]);
expect(await page.$$eval(`role=checkbox[checked=false]`, els => els.map(e => e.outerHTML))).toEqual([
`<input type="checkbox">`,
`<div role="checkbox" aria-checked="false">Hello</div>`,
`<div role="checkbox">Unknown</div>`,
]);
expect(await page.$$eval(`role=checkbox`, els => els.map(e => e.outerHTML))).toEqual([
`<input type="checkbox">`,
`<input type="checkbox" checked="">`,
`<input type="checkbox" indeterminate="">`,
`<div role="checkbox" aria-checked="true">Hi</div>`,
`<div role="checkbox" aria-checked="false">Hello</div>`,
`<div role="checkbox">Unknown</div>`,
]);
});
test('should support pressed', async ({ page }) => {
await page.setContent(`
<button>Hi</button>
<button aria-pressed="true">Hello</button>
<button aria-pressed="false">Bye</button>
<button aria-pressed="mixed">Mixed</button>
`);
expect(await page.$$eval(`role=button[pressed]`, els => els.map(e => e.outerHTML))).toEqual([
`<button aria-pressed="true">Hello</button>`,
`<button aria-pressed="mixed">Mixed</button>`,
]);
expect(await page.$$eval(`role=button[pressed=true]`, els => els.map(e => e.outerHTML))).toEqual([
`<button aria-pressed="true">Hello</button>`,
]);
expect(await page.$$eval(`role=button[pressed=false]`, els => els.map(e => e.outerHTML))).toEqual([
`<button>Hi</button>`,
`<button aria-pressed="false">Bye</button>`,
]);
expect(await page.$$eval(`role=button[pressed="mixed"]`, els => els.map(e => e.outerHTML))).toEqual([
`<button aria-pressed="mixed">Mixed</button>`,
]);
});
test('should support expanded', async ({ page }) => {
await page.setContent(`
<button>Hi</button>
<button aria-expanded="true">Hello</button>
<button aria-expanded="false">Bye</button>
`);
expect(await page.$$eval(`role=button[expanded]`, els => els.map(e => e.outerHTML))).toEqual([
`<button aria-expanded="true">Hello</button>`,
]);
expect(await page.$$eval(`role=button[expanded=true]`, els => els.map(e => e.outerHTML))).toEqual([
`<button aria-expanded="true">Hello</button>`,
]);
expect(await page.$$eval(`role=button[expanded=false]`, els => els.map(e => e.outerHTML))).toEqual([
`<button>Hi</button>`,
`<button aria-expanded="false">Bye</button>`,
]);
});
test('should support disabled', async ({ page }) => {
await page.setContent(`
<button>Hi</button>
<button disabled>Bye</button>
<button aria-disabled="true">Hello</button>
<button aria-disabled="false">Oh</button>
<fieldset disabled>
<button>Yay</button>
</fieldset>
`);
expect(await page.$$eval(`role=button[disabled]`, els => els.map(e => e.outerHTML))).toEqual([
`<button disabled="">Bye</button>`,
`<button aria-disabled="true">Hello</button>`,
`<button>Yay</button>`,
]);
expect(await page.$$eval(`role=button[disabled=true]`, els => els.map(e => e.outerHTML))).toEqual([
`<button disabled="">Bye</button>`,
`<button aria-disabled="true">Hello</button>`,
`<button>Yay</button>`,
]);
expect(await page.$$eval(`role=button[disabled=false]`, els => els.map(e => e.outerHTML))).toEqual([
`<button>Hi</button>`,
`<button aria-disabled="false">Oh</button>`,
]);
});
test('should support level', async ({ page }) => {
await page.setContent(`
<h1>Hello</h1>
<h3>Hi</h3>
<div role="heading" aria-level="5">Bye</div>
`);
expect(await page.$$eval(`role=heading[level=1]`, els => els.map(e => e.outerHTML))).toEqual([
`<h1>Hello</h1>`,
]);
expect(await page.$$eval(`role=heading[level=3]`, els => els.map(e => e.outerHTML))).toEqual([
`<h3>Hi</h3>`,
]);
expect(await page.$$eval(`role=heading[level=5]`, els => els.map(e => e.outerHTML))).toEqual([
`<div role="heading" aria-level="5">Bye</div>`,
]);
});
test('should filter hidden, unless explicitly asked for', async ({ page }) => {
await page.setContent(`
<button>Hi</button>
<button hidden>Hello</button>
<button aria-hidden="true">Yay</button>
<button aria-hidden="false">Nay</button>
<button style="visibility:hidden">Bye</button>
<div style="visibility:hidden">
<button>Oh</button>
</div>
<div style="visibility:hidden">
<button style="visibility:visible">Still here</button>
</div>
<button style="display:none">Never</button>
<div id=host1></div>
<div id=host2 style="display:none"></div>
<script>
function addButton(host, text) {
const root = host.attachShadow({ mode: 'open' });
const button = document.createElement('button');
button.textContent = text;
root.appendChild(button);
}
addButton(document.getElementById('host1'), 'Shadow1');
addButton(document.getElementById('host2'), 'Shadow2');
</script>
`);
expect(await page.$$eval(`role=button`, els => els.map(e => e.outerHTML))).toEqual([
`<button>Hi</button>`,
`<button aria-hidden="false">Nay</button>`,
`<button style="visibility:visible">Still here</button>`,
`<button>Shadow1</button>`,
]);
expect(await page.$$eval(`role=button[includeHidden]`, els => els.map(e => e.outerHTML))).toEqual([
`<button>Hi</button>`,
`<button hidden="">Hello</button>`,
`<button aria-hidden="true">Yay</button>`,
`<button aria-hidden="false">Nay</button>`,
`<button style="visibility:hidden">Bye</button>`,
`<button>Oh</button>`,
`<button style="visibility:visible">Still here</button>`,
`<button style="display:none">Never</button>`,
`<button>Shadow1</button>`,
`<button>Shadow2</button>`,
]);
expect(await page.$$eval(`role=button[includeHidden=true]`, els => els.map(e => e.outerHTML))).toEqual([
`<button>Hi</button>`,
`<button hidden="">Hello</button>`,
`<button aria-hidden="true">Yay</button>`,
`<button aria-hidden="false">Nay</button>`,
`<button style="visibility:hidden">Bye</button>`,
`<button>Oh</button>`,
`<button style="visibility:visible">Still here</button>`,
`<button style="display:none">Never</button>`,
`<button>Shadow1</button>`,
`<button>Shadow2</button>`,
]);
expect(await page.$$eval(`role=button[includeHidden=false]`, els => els.map(e => e.outerHTML))).toEqual([
`<button>Hi</button>`,
`<button aria-hidden="false">Nay</button>`,
`<button style="visibility:visible">Still here</button>`,
`<button>Shadow1</button>`,
]);
});
test('should support name', async ({ page }) => {
await page.setContent(`
<div role="button" aria-label="Hello"></div>
<div role="button" aria-label="Hallo"></div>
<div role="button" aria-label="Hello" aria-hidden="true"></div>
`);
expect(await page.$$eval(`role=button[name="Hello"]`, els => els.map(e => e.outerHTML))).toEqual([
`<div role="button" aria-label="Hello"></div>`,
]);
expect(await page.$$eval(`role=button[name*="all"]`, els => els.map(e => e.outerHTML))).toEqual([
`<div role="button" aria-label="Hallo"></div>`,
]);
expect(await page.$$eval(`role=button[name=/^H[ae]llo$/]`, els => els.map(e => e.outerHTML))).toEqual([
`<div role="button" aria-label="Hello"></div>`,
`<div role="button" aria-label="Hallo"></div>`,
]);
expect(await page.$$eval(`role=button[name=/h.*o/i]`, els => els.map(e => e.outerHTML))).toEqual([
`<div role="button" aria-label="Hello"></div>`,
`<div role="button" aria-label="Hallo"></div>`,
]);
expect(await page.$$eval(`role=button[name="Hello"][includeHidden]`, els => els.map(e => e.outerHTML))).toEqual([
`<div role="button" aria-label="Hello"></div>`,
`<div role="button" aria-label="Hello" aria-hidden="true"></div>`,
]);
});
test('errors', async ({ page }) => {
const e0 = await page.$('role=[bar]').catch(e => e);
expect(e0.message).toContain(`Role must not be empty`);
const e1 = await page.$('role=foo[sElected]').catch(e => e);
expect(e1.message).toContain(`Unknown attribute "sElected", must be one of "checked", "disabled", "expanded", "includeHidden", "level", "name", "pressed", "selected"`);
const e2 = await page.$('role=foo[bar . qux=true]').catch(e => e);
expect(e2.message).toContain(`Unknown attribute "bar.qux"`);
const e3 = await page.$('role=heading[level="bar"]').catch(e => e);
expect(e3.message).toContain(`"level" attribute must be compared to a number`);
const e4 = await page.$('role=checkbox[checked="bar"]').catch(e => e);
expect(e4.message).toContain(`"checked" must be one of true, false, "mixed"`);
const e5 = await page.$('role=checkbox[checked~=true]').catch(e => e);
expect(e5.message).toContain(`cannot use ~= in attribute with non-string matching value`);
const e6 = await page.$('role=button[level=3]').catch(e => e);
expect(e6.message).toContain(`"level" attribute is only supported for roles: "heading", "listitem", "row", "treeitem"`);
});