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:
parent
1471bb7177
commit
8c19f71c36
|
|
@ -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')}]
|
||||
);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
305
tests/page/selectors-role.spec.ts
Normal file
305
tests/page/selectors-role.spec.ts
Normal 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"`);
|
||||
});
|
||||
Loading…
Reference in a new issue