From 8c19f71c360ca749f05cef24c87910a760db75eb Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 28 Mar 2022 09:24:58 -0700 Subject: [PATCH] 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. --- packages/playwright-core/src/server/dom.ts | 3 +- .../src/server/injected/componentUtils.ts | 13 +- .../src/server/injected/injectedScript.ts | 40 +-- .../src/server/injected/roleSelectorEngine.ts | 158 +++++++++ .../src/server/injected/roleUtils.ts | 137 +++++++- .../playwright-core/src/server/selectors.ts | 4 +- packages/playwright-core/src/utils/utils.ts | 4 + ...ementhandle-wait-for-element-state.spec.ts | 2 +- tests/page/selectors-role.spec.ts | 305 ++++++++++++++++++ 9 files changed, 607 insertions(+), 59 deletions(-) create mode 100644 packages/playwright-core/src/server/injected/roleSelectorEngine.ts create mode 100644 tests/page/selectors-role.spec.ts diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 4aa832d90a..6b9c5e3df9 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -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')}] ); })(); diff --git a/packages/playwright-core/src/server/injected/componentUtils.ts b/packages/playwright-core/src/server/injected/componentUtils.ts index 5b82a1ed5e..4ba5c82a43 100644 --- a/packages/playwright-core/src/server/injected/componentUtils.ts +++ b/packages/playwright-core/src/server/injected/componentUtils.ts @@ -14,10 +14,11 @@ * limitations under the License. */ -type Operator = ''|'='|'*='|'|='|'^='|'$='|'~='; +export type ParsedAttributeOperator = ''|'='|'*='|'|='|'^='|'$='|'~='; 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: '', value: null, caseSensitive: false }; + return { name: jsonPath.join('.'), jsonPath, op: '', 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 = { diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 9da3755782..91b81bcd1e 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -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 = (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; diff --git a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts new file mode 100644 index 0000000000..0d3a51712f --- /dev/null +++ b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts @@ -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 !== '' && !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, ['', '=']); + break; + } + case 'pressed': { + validateSupportedRole(attr.name, kAriaPressedRoles, role); + validateSupportedValues(attr, [true, false, 'mixed']); + validateSupportedOp(attr, ['', '=']); + break; + } + case 'selected': { + validateSupportedRole(attr.name, kAriaSelectedRoles, role); + validateSupportedValues(attr, [true, false]); + validateSupportedOp(attr, ['', '=']); + break; + } + case 'expanded': { + validateSupportedRole(attr.name, kAriaExpandedRoles, role); + validateSupportedValues(attr, [true, false]); + validateSupportedOp(attr, ['', '=']); + 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, ['', '=']); + break; + } + case 'name': { + if (attr.op !== '' && 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, ['', '=']); + 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(); + 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 === '' || !!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; + } +}; diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index 1926c641d6..9e91676a33 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -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): 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): 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)); +} diff --git a/packages/playwright-core/src/server/selectors.ts b/packages/playwright-core/src/server/selectors.ts index f23590cbe4..25d5cc78b0 100644 --- a/packages/playwright-core/src/server/selectors.ts +++ b/packages/playwright-core/src/server/selectors.ts @@ -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)) { diff --git a/packages/playwright-core/src/utils/utils.ts b/packages/playwright-core/src/utils/utils.ts index b7a3995772..ecce954d7f 100644 --- a/packages/playwright-core/src/utils/utils.ts +++ b/packages/playwright-core/src/utils/utils.ts @@ -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; diff --git a/tests/page/elementhandle-wait-for-element-state.spec.ts b/tests/page/elementhandle-wait-for-element-state.spec.ts index 0d6985c7d9..fbc5300671 100644 --- a/tests/page/elementhandle-wait-for-element-state.spec.ts +++ b/tests/page/elementhandle-wait-for-element-state.spec.ts @@ -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('
'); + await page.setContent('
'); const span = await page.$('text=Target'); let done = false; const promise = span.waitForElementState('enabled').then(() => done = true); diff --git a/tests/page/selectors-role.spec.ts b/tests/page/selectors-role.spec.ts new file mode 100644 index 0000000000..4dcaf3dbd2 --- /dev/null +++ b/tests/page/selectors-role.spec.ts @@ -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(` + + + +

Heading

+
Hello
+
I am a dialog
+ `); + expect(await page.$$eval(`role=button`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + expect(await page.$$eval(`role=listbox`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + expect(await page.$$eval(`role=combobox`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + expect(await page.$$eval(`role=heading`, els => els.map(e => e.outerHTML))).toEqual([ + `

Heading

`, + ]); + expect(await page.$$eval(`role=group`, els => els.map(e => e.outerHTML))).toEqual([ + `
Hello
`, + ]); + expect(await page.$$eval(`role=dialog`, els => els.map(e => e.outerHTML))).toEqual([ + `
I am a dialog
`, + ]); + expect(await page.$$eval(`role=menuitem`, els => els.map(e => e.outerHTML))).toEqual([ + ]); +}); + +test('should support selected', async ({ page }) => { + await page.setContent(` + +
+
Hi
+
Hello
+
+ `); + expect(await page.$$eval(`role=option[selected]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + `
Hi
`, + ]); + expect(await page.$$eval(`role=option[selected=true]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + `
Hi
`, + ]); + expect(await page.$$eval(`role=option[selected=false]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + `
Hello
`, + ]); +}); + +test('should support checked', async ({ page }) => { + await page.setContent(` + + + +
Hi
+
Hello
+
Unknown
+ `); + await page.$eval('[indeterminate]', input => (input as HTMLInputElement).indeterminate = true); + expect(await page.$$eval(`role=checkbox[checked]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + `
Hi
`, + ]); + expect(await page.$$eval(`role=checkbox[checked=true]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + `
Hi
`, + ]); + expect(await page.$$eval(`role=checkbox[checked=false]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + `
Hello
`, + `
Unknown
`, + ]); + expect(await page.$$eval(`role=checkbox`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ``, + `
Hi
`, + `
Hello
`, + `
Unknown
`, + ]); +}); + +test('should support pressed', async ({ page }) => { + await page.setContent(` + + + + + `); + expect(await page.$$eval(`role=button[pressed]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ]); + expect(await page.$$eval(`role=button[pressed=true]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + expect(await page.$$eval(`role=button[pressed=false]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ]); + expect(await page.$$eval(`role=button[pressed="mixed"]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); +}); + +test('should support expanded', async ({ page }) => { + await page.setContent(` + + + + `); + expect(await page.$$eval(`role=button[expanded]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + expect(await page.$$eval(`role=button[expanded=true]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ]); + expect(await page.$$eval(`role=button[expanded=false]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ]); +}); + +test('should support disabled', async ({ page }) => { + await page.setContent(` + + + + +
+ +
+ `); + expect(await page.$$eval(`role=button[disabled]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ``, + ]); + expect(await page.$$eval(`role=button[disabled=true]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ``, + ]); + expect(await page.$$eval(`role=button[disabled=false]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ]); +}); + +test('should support level', async ({ page }) => { + await page.setContent(` +

Hello

+

Hi

+
Bye
+ `); + expect(await page.$$eval(`role=heading[level=1]`, els => els.map(e => e.outerHTML))).toEqual([ + `

Hello

`, + ]); + expect(await page.$$eval(`role=heading[level=3]`, els => els.map(e => e.outerHTML))).toEqual([ + `

Hi

`, + ]); + expect(await page.$$eval(`role=heading[level=5]`, els => els.map(e => e.outerHTML))).toEqual([ + `
Bye
`, + ]); +}); + +test('should filter hidden, unless explicitly asked for', async ({ page }) => { + await page.setContent(` + + + + + +
+ +
+
+ +
+ +
+ + + `); + expect(await page.$$eval(`role=button`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ``, + ``, + ]); + expect(await page.$$eval(`role=button[includeHidden]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ]); + expect(await page.$$eval(`role=button[includeHidden=true]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ]); + expect(await page.$$eval(`role=button[includeHidden=false]`, els => els.map(e => e.outerHTML))).toEqual([ + ``, + ``, + ``, + ``, + ]); +}); + +test('should support name', async ({ page }) => { + await page.setContent(` +
+
+ + `); + expect(await page.$$eval(`role=button[name="Hello"]`, els => els.map(e => e.outerHTML))).toEqual([ + `
`, + ]); + expect(await page.$$eval(`role=button[name*="all"]`, els => els.map(e => e.outerHTML))).toEqual([ + `
`, + ]); + expect(await page.$$eval(`role=button[name=/^H[ae]llo$/]`, els => els.map(e => e.outerHTML))).toEqual([ + `
`, + `
`, + ]); + expect(await page.$$eval(`role=button[name=/h.*o/i]`, els => els.map(e => e.outerHTML))).toEqual([ + `
`, + `
`, + ]); + expect(await page.$$eval(`role=button[name="Hello"][includeHidden]`, els => els.map(e => e.outerHTML))).toEqual([ + `
`, + ``, + ]); +}); + +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"`); +});