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 { SelectorInfo } from './selectors';
|
||||||
import * as types from './types';
|
import * as types from './types';
|
||||||
import { TimeoutOptions } from '../common/types';
|
import { TimeoutOptions } from '../common/types';
|
||||||
import { isUnderTest } from '../utils/utils';
|
import { experimentalFeaturesEnabled, isUnderTest } from '../utils/utils';
|
||||||
|
|
||||||
type SetInputFilesFiles = channels.ElementHandleSetInputFilesParams['files'];
|
type SetInputFilesFiles = channels.ElementHandleSetInputFilesParams['files'];
|
||||||
export type InputFilesItems = { files?: SetInputFilesFiles, localPaths?: string[] };
|
export type InputFilesItems = { files?: SetInputFilesFiles, localPaths?: string[] };
|
||||||
|
|
@ -104,6 +104,7 @@ export class FrameExecutionContext extends js.ExecutionContext {
|
||||||
${isUnderTest()},
|
${isUnderTest()},
|
||||||
${this.frame._page._delegate.rafCountForStablePosition()},
|
${this.frame._page._delegate.rafCountForStablePosition()},
|
||||||
"${this.frame._page._browserContext._browser.options.name}",
|
"${this.frame._page._browserContext._browser.options.name}",
|
||||||
|
${experimentalFeaturesEnabled()},
|
||||||
[${custom.join(',\n')}]
|
[${custom.join(',\n')}]
|
||||||
);
|
);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,11 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Operator = '<truthy>'|'='|'*='|'|='|'^='|'$='|'~=';
|
export type ParsedAttributeOperator = '<truthy>'|'='|'*='|'|='|'^='|'$='|'~=';
|
||||||
export type ParsedComponentAttribute = {
|
export type ParsedComponentAttribute = {
|
||||||
|
name: string,
|
||||||
jsonPath: string[],
|
jsonPath: string[],
|
||||||
op: Operator,
|
op: ParsedAttributeOperator,
|
||||||
value: any,
|
value: any,
|
||||||
caseSensitive: boolean,
|
caseSensitive: boolean,
|
||||||
};
|
};
|
||||||
|
|
@ -152,7 +153,7 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readOperator(): Operator {
|
function readOperator(): ParsedAttributeOperator {
|
||||||
skipSpaces();
|
skipSpaces();
|
||||||
let op = '';
|
let op = '';
|
||||||
if (!EOL)
|
if (!EOL)
|
||||||
|
|
@ -161,7 +162,7 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto
|
||||||
op += eat1();
|
op += eat1();
|
||||||
if (!['=', '*=', '^=', '$=', '|=', '~='].includes(op))
|
if (!['=', '*=', '^=', '$=', '|=', '~='].includes(op))
|
||||||
syntaxError('parsing operator');
|
syntaxError('parsing operator');
|
||||||
return (op as Operator);
|
return (op as ParsedAttributeOperator);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readAttribute(): ParsedComponentAttribute {
|
function readAttribute(): ParsedComponentAttribute {
|
||||||
|
|
@ -182,7 +183,7 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto
|
||||||
// check property is truthy: [enabled]
|
// check property is truthy: [enabled]
|
||||||
if (next() === ']') {
|
if (next() === ']') {
|
||||||
eat1();
|
eat1();
|
||||||
return { jsonPath, op: '<truthy>', value: null, caseSensitive: false };
|
return { name: jsonPath.join('.'), jsonPath, op: '<truthy>', value: null, caseSensitive: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const operator = readOperator();
|
const operator = readOperator();
|
||||||
|
|
@ -225,7 +226,7 @@ export function parseComponentSelector(selector: string): ParsedComponentSelecto
|
||||||
eat1();
|
eat1();
|
||||||
if (operator !== '=' && typeof value !== 'string')
|
if (operator !== '=' && typeof value !== 'string')
|
||||||
throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`);
|
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 = {
|
const result: ParsedComponentSelector = {
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,14 @@ import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||||
import { XPathEngine } from './xpathSelectorEngine';
|
import { XPathEngine } from './xpathSelectorEngine';
|
||||||
import { ReactEngine } from './reactSelectorEngine';
|
import { ReactEngine } from './reactSelectorEngine';
|
||||||
import { VueEngine } from './vueSelectorEngine';
|
import { VueEngine } from './vueSelectorEngine';
|
||||||
|
import { RoleEngine } from './roleSelectorEngine';
|
||||||
import { allEngineNames, ParsedSelector, ParsedSelectorPart, parseSelector, stringifySelector } from '../common/selectorParser';
|
import { allEngineNames, ParsedSelector, ParsedSelectorPart, parseSelector, stringifySelector } from '../common/selectorParser';
|
||||||
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator';
|
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, TextMatcher, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator';
|
||||||
import { CSSComplexSelectorList } from '../common/cssParser';
|
import { CSSComplexSelectorList } from '../common/cssParser';
|
||||||
import { generateSelector } from './selectorGenerator';
|
import { generateSelector } from './selectorGenerator';
|
||||||
import type * as channels from '../../protocol/channels';
|
import type * as channels from '../../protocol/channels';
|
||||||
import { Highlight } from './highlight';
|
import { Highlight } from './highlight';
|
||||||
import { getElementAccessibleName } from './roleUtils';
|
import { getAriaDisabled, getElementAccessibleName } from './roleUtils';
|
||||||
|
|
||||||
type Predicate<T> = (progress: InjectedScriptProgress) => T | symbol;
|
type Predicate<T> = (progress: InjectedScriptProgress) => T | symbol;
|
||||||
|
|
||||||
|
|
@ -79,7 +80,7 @@ export class InjectedScript {
|
||||||
private _highlight: Highlight | undefined;
|
private _highlight: Highlight | undefined;
|
||||||
readonly isUnderTest: boolean;
|
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.isUnderTest = isUnderTest;
|
||||||
this._evaluator = new SelectorEvaluatorImpl(new Map());
|
this._evaluator = new SelectorEvaluatorImpl(new Map());
|
||||||
|
|
||||||
|
|
@ -88,6 +89,8 @@ export class InjectedScript {
|
||||||
this._engines.set('xpath:light', XPathEngine);
|
this._engines.set('xpath:light', XPathEngine);
|
||||||
this._engines.set('_react', ReactEngine);
|
this._engines.set('_react', ReactEngine);
|
||||||
this._engines.set('_vue', VueEngine);
|
this._engines.set('_vue', VueEngine);
|
||||||
|
if (experimentalFeaturesEnabled)
|
||||||
|
this._engines.set('role', RoleEngine);
|
||||||
this._engines.set('text', this._createTextEngine(true));
|
this._engines.set('text', this._createTextEngine(true));
|
||||||
this._engines.set('text:light', this._createTextEngine(false));
|
this._engines.set('text:light', this._createTextEngine(false));
|
||||||
this._engines.set('id', this._createAttributeEngine('id', true));
|
this._engines.set('id', this._createAttributeEngine('id', true));
|
||||||
|
|
@ -515,7 +518,7 @@ export class InjectedScript {
|
||||||
if (state === 'hidden')
|
if (state === 'hidden')
|
||||||
return !this.isVisible(element);
|
return !this.isVisible(element);
|
||||||
|
|
||||||
const disabled = isElementDisabled(element);
|
const disabled = getAriaDisabled(element);
|
||||||
if (state === 'disabled')
|
if (state === 'disabled')
|
||||||
return disabled;
|
return disabled;
|
||||||
if (state === 'enabled')
|
if (state === 'enabled')
|
||||||
|
|
@ -1250,35 +1253,4 @@ function deepEquals(a: any, b: any): boolean {
|
||||||
return false;
|
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;
|
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 {
|
function getExplicitAriaRole(element: Element): string | null {
|
||||||
// https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles
|
// https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles
|
||||||
const explicitRole = (element.getAttribute('role') || '').trim().split(' ')[0];
|
const roles = (element.getAttribute('role') || '').split(' ').map(role => role.trim());
|
||||||
return validRoles.includes(explicitRole) ? explicitRole : null;
|
return roles.find(role => validRoles.includes(role)) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasPresentationConflictResolution(element: Element) {
|
function hasPresentationConflictResolution(element: Element) {
|
||||||
|
|
@ -209,7 +209,7 @@ function hasPresentationConflictResolution(element: Element) {
|
||||||
return !hasGlobalAriaAttribute(element);
|
return !hasGlobalAriaAttribute(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAriaRole(element: Element): string | null {
|
export function getAriaRole(element: Element): string | null {
|
||||||
const explicitRole = getExplicitAriaRole(element);
|
const explicitRole = getExplicitAriaRole(element);
|
||||||
if (!explicitRole)
|
if (!explicitRole)
|
||||||
return getImplicitAriaRole(element);
|
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;
|
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 {
|
export function isElementHiddenForAria(element: Element, cache: Map<Element, boolean>): boolean {
|
||||||
if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(element.tagName))
|
if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(element.tagName))
|
||||||
return true;
|
return true;
|
||||||
|
const style: CSSStyleDeclaration | undefined = getComputedStyle(element);
|
||||||
let style: CSSStyleDeclaration | undefined = getComputedStyle(element);
|
|
||||||
if (!style || style.visibility === 'hidden')
|
if (!style || style.visibility === 'hidden')
|
||||||
return true;
|
return true;
|
||||||
|
return belongsToDisplayNoneOrAriaHidden(element, cache);
|
||||||
|
}
|
||||||
|
|
||||||
let parent: Element | undefined = element;
|
function belongsToDisplayNoneOrAriaHidden(element: Element, cache: Map<Element, boolean>): boolean {
|
||||||
while (parent) {
|
if (!cache.has(element)) {
|
||||||
if (!cache.has(parent)) {
|
const style = getComputedStyle(element);
|
||||||
if (!style)
|
let hidden = !style || style.display === 'none' || getAriaBoolean(element.getAttribute('aria-hidden')) === true;
|
||||||
style = getComputedStyle(parent);
|
if (!hidden) {
|
||||||
const hidden = !style || style.display === 'none' || getAriaBoolean(parent.getAttribute('aria-hidden')) === true;
|
const parent = parentElementOrShadowHost(element);
|
||||||
cache.set(parent, hidden);
|
if (parent)
|
||||||
|
hidden = hidden || belongsToDisplayNoneOrAriaHidden(parent, cache);
|
||||||
}
|
}
|
||||||
if (cache.get(parent)!)
|
cache.set(element, hidden);
|
||||||
return true;
|
|
||||||
parent = parentElementOrShadowHost(parent);
|
|
||||||
}
|
}
|
||||||
return false;
|
return cache.get(element)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIdRefs(element: Element, ref: string | null): Element[] {
|
function getIdRefs(element: Element, ref: string | null): Element[] {
|
||||||
|
|
@ -609,3 +611,106 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN
|
||||||
options.visitedElements.add(element);
|
options.visitedElements.add(element);
|
||||||
return '';
|
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 js from './javascript';
|
||||||
import * as types from './types';
|
import * as types from './types';
|
||||||
import { allEngineNames, InvalidSelectorError, ParsedSelector, parseSelector, stringifySelector } from './common/selectorParser';
|
import { allEngineNames, InvalidSelectorError, ParsedSelector, parseSelector, stringifySelector } from './common/selectorParser';
|
||||||
import { createGuid } from '../utils/utils';
|
import { createGuid, experimentalFeaturesEnabled } from '../utils/utils';
|
||||||
|
|
||||||
export type SelectorInfo = {
|
export type SelectorInfo = {
|
||||||
parsed: ParsedSelector,
|
parsed: ParsedSelector,
|
||||||
|
|
@ -133,6 +133,8 @@ export class Selectors {
|
||||||
}
|
}
|
||||||
|
|
||||||
parseSelector(selector: string | ParsedSelector, strict: boolean): SelectorInfo {
|
parseSelector(selector: string | ParsedSelector, strict: boolean): SelectorInfo {
|
||||||
|
if (experimentalFeaturesEnabled())
|
||||||
|
this._builtinEngines.add('role');
|
||||||
const parsed = typeof selector === 'string' ? parseSelector(selector) : selector;
|
const parsed = typeof selector === 'string' ? parseSelector(selector) : selector;
|
||||||
let needsMainWorld = false;
|
let needsMainWorld = false;
|
||||||
for (const name of allEngineNames(parsed)) {
|
for (const name of allEngineNames(parsed)) {
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,10 @@ export function isUnderTest(): boolean {
|
||||||
return _isUnderTest;
|
return _isUnderTest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function experimentalFeaturesEnabled() {
|
||||||
|
return isUnderTest() || !!process.env.PLAYWRIGHT_EXPERIMENTAL_FEATURES;
|
||||||
|
}
|
||||||
|
|
||||||
export function getFromENV(name: string): string | undefined {
|
export function getFromENV(name: string): string | undefined {
|
||||||
let value = process.env[name];
|
let value = process.env[name];
|
||||||
value = value === undefined ? process.env[`npm_config_${name.toLowerCase()}`] : value;
|
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 }) => {
|
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');
|
const span = await page.$('text=Target');
|
||||||
let done = false;
|
let done = false;
|
||||||
const promise = span.waitForElementState('enabled').then(() => done = true);
|
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