feat(locator): Add ariaChildren Option to Locator's getByRole Method.

This commit is contained in:
Philipe Allan Almeida 2025-01-15 09:54:46 -03:00 committed by Philipe Allan Almeida
parent 275f334b58
commit 3772bcf79f
5 changed files with 219 additions and 4 deletions

View file

@ -1311,6 +1311,13 @@ Option that controls whether hidden elements are matched. By default, only non-h
Learn more about [`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden). Learn more about [`aria-hidden`](https://www.w3.org/TR/wai-aria-1.2/#aria-hidden).
## locator-get-by-role-option-ariaChildren
- `ariaChildren` <[boolean]>
Option that controls whether elements referenced by `aria-owns` and `aria-controls` are included in the search. By default, elements referenced by `aria-owns` and `aria-controls` are not included.
Learn more about [`aria-owns`](https://www.w3.org/TR/wai-aria-1.2/#aria-owns) and [`aria-controls`](https://www.w3.org/TR/wai-aria-1.2/#aria-controls).
## locator-get-by-role-option-level ## locator-get-by-role-option-level
* since: v1.27 * since: v1.27
- `level` <[int]> - `level` <[int]>
@ -1354,6 +1361,7 @@ Learn more about [`aria-selected`](https://www.w3.org/TR/wai-aria-1.2/#aria-sele
- %%-locator-get-by-role-option-disabled-%% - %%-locator-get-by-role-option-disabled-%%
- %%-locator-get-by-role-option-expanded-%% - %%-locator-get-by-role-option-expanded-%%
- %%-locator-get-by-role-option-includeHidden-%% - %%-locator-get-by-role-option-includeHidden-%%
- %%-locator-get-by-role-option-ariaChildren-%%
- %%-locator-get-by-role-option-level-%% - %%-locator-get-by-role-option-level-%%
- %%-locator-get-by-role-option-name-%% - %%-locator-get-by-role-option-name-%%
- %%-locator-get-by-role-option-pressed-%% - %%-locator-get-by-role-option-pressed-%%

View file

@ -32,9 +32,10 @@ type RoleEngineOptions = {
level?: number; level?: number;
disabled?: boolean; disabled?: boolean;
includeHidden?: boolean; includeHidden?: boolean;
ariaChildren?: boolean;
}; };
const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'include-hidden']; const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'include-hidden', 'aria-children'];
kSupportedAttributes.sort(); kSupportedAttributes.sort();
function validateSupportedRole(attr: string, roles: string[], role: string) { function validateSupportedRole(attr: string, roles: string[], role: string) {
@ -116,6 +117,11 @@ function validateAttributes(attrs: AttributeSelectorPart[], role: string): RoleE
options.includeHidden = attr.op === '<truthy>' ? true : attr.value; options.includeHidden = attr.op === '<truthy>' ? true : attr.value;
break; break;
} }
case 'aria-children': {
validateSupportedValues(attr, [true, false]);
options.ariaChildren = attr.value;
break;
}
default: { default: {
throw new Error(`Unknown attribute "${attr.name}", must be one of ${kSupportedAttributes.map(a => `"${a}"`).join(', ')}.`); throw new Error(`Unknown attribute "${attr.name}", must be one of ${kSupportedAttributes.map(a => `"${a}"`).join(', ')}.`);
} }
@ -124,9 +130,31 @@ function validateAttributes(attrs: AttributeSelectorPart[], role: string): RoleE
return options; return options;
} }
function getAriaChildren(element: Element, scope: SelectorRoot): Element[] {
const documentRoot = scope.ownerDocument || scope;
const ariaElements: Element[] = [];
const ariaAttributes = ['aria-owns', 'aria-controls'];
ariaAttributes.forEach(attr => {
const ariaValue = element.getAttribute(attr);
if (!ariaValue)
return;
const ids = ariaValue.split(/\s+/);
for (const id of ids) {
const ownedElement = documentRoot.getElementById(id);
if (ownedElement)
ariaElements.push(ownedElement);
}
});
return ariaElements;
}
function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: boolean): Element[] { function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: boolean): Element[] {
const result: Element[] = []; const result: Element[] = [];
const match = (element: Element) => { const match = (element: Element) => {
if (getAriaRole(element) !== options.role) if (getAriaRole(element) !== options.role)
return; return;
if (options.selected !== undefined && getAriaSelected(element) !== options.selected) if (options.selected !== undefined && getAriaSelected(element) !== options.selected)
@ -147,11 +175,9 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo
return; return;
} }
if (options.name !== undefined) { if (options.name !== undefined) {
// Always normalize whitespace in the accessible name.
const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, !!options.includeHidden)); const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, !!options.includeHidden));
if (typeof options.name === 'string') if (typeof options.name === 'string')
options.name = normalizeWhiteSpace(options.name); options.name = normalizeWhiteSpace(options.name);
// internal:role assumes that [name="foo"i] also means substring.
if (internal && !options.exact && options.nameOp === '=') if (internal && !options.exact && options.nameOp === '=')
options.nameOp = '*='; options.nameOp = '*=';
if (!matchesAttributePart(accessibleName, { name: '', jsonPath: [], op: options.nameOp || '=', value: options.name, caseSensitive: !!options.exact })) if (!matchesAttributePart(accessibleName, { name: '', jsonPath: [], op: options.nameOp || '=', value: options.name, caseSensitive: !!options.exact }))
@ -170,8 +196,11 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo
shadows.push(element.shadowRoot); shadows.push(element.shadowRoot);
} }
shadows.forEach(query); shadows.forEach(query);
if (options.ariaChildren && root instanceof Element) {
const ariaChildren = getAriaChildren(root, scope);
ariaChildren.forEach(match);
}
}; };
query(scope); query(scope);
return result; return result;
} }

View file

@ -22,6 +22,7 @@ export type ByRoleOptions = {
exact?: boolean; exact?: boolean;
expanded?: boolean; expanded?: boolean;
includeHidden?: boolean; includeHidden?: boolean;
ariaChildren?: boolean;
level?: number; level?: number;
name?: string | RegExp; name?: string | RegExp;
pressed?: boolean; pressed?: boolean;
@ -68,6 +69,8 @@ export function getByRoleSelector(role: string, options: ByRoleOptions = {}): st
props.push(['expanded', String(options.expanded)]); props.push(['expanded', String(options.expanded)]);
if (options.includeHidden !== undefined) if (options.includeHidden !== undefined)
props.push(['include-hidden', String(options.includeHidden)]); props.push(['include-hidden', String(options.includeHidden)]);
if (options.ariaChildren !== undefined)
props.push(['aria-children', String(options.ariaChildren)]);
if (options.level !== undefined) if (options.level !== undefined)
props.push(['level', String(options.level)]); props.push(['level', String(options.level)]);
if (options.name !== undefined) if (options.name !== undefined)

View file

@ -2915,6 +2915,15 @@ export interface Page {
* @param options * @param options
*/ */
getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: {
/**
* Option that controls whether elements referenced by `aria-owns` and `aria-controls` are included in the search. By
* default, elements referenced by `aria-owns` and `aria-controls` are not included.
*
* Learn more about [`aria-owns`](https://www.w3.org/TR/wai-aria-1.2/#aria-owns) and
* [`aria-controls`](https://www.w3.org/TR/wai-aria-1.2/#aria-controls).
*/
ariaChildren?: boolean;
/** /**
* An attribute that is usually set by `aria-checked` or native `<input type=checkbox>` controls. * An attribute that is usually set by `aria-checked` or native `<input type=checkbox>` controls.
* *
@ -6674,6 +6683,15 @@ export interface Frame {
* @param options * @param options
*/ */
getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: {
/**
* Option that controls whether elements referenced by `aria-owns` and `aria-controls` are included in the search. By
* default, elements referenced by `aria-owns` and `aria-controls` are not included.
*
* Learn more about [`aria-owns`](https://www.w3.org/TR/wai-aria-1.2/#aria-owns) and
* [`aria-controls`](https://www.w3.org/TR/wai-aria-1.2/#aria-controls).
*/
ariaChildren?: boolean;
/** /**
* An attribute that is usually set by `aria-checked` or native `<input type=checkbox>` controls. * An attribute that is usually set by `aria-checked` or native `<input type=checkbox>` controls.
* *
@ -13294,6 +13312,15 @@ export interface Locator {
* @param options * @param options
*/ */
getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: {
/**
* Option that controls whether elements referenced by `aria-owns` and `aria-controls` are included in the search. By
* default, elements referenced by `aria-owns` and `aria-controls` are not included.
*
* Learn more about [`aria-owns`](https://www.w3.org/TR/wai-aria-1.2/#aria-owns) and
* [`aria-controls`](https://www.w3.org/TR/wai-aria-1.2/#aria-controls).
*/
ariaChildren?: boolean;
/** /**
* An attribute that is usually set by `aria-checked` or native `<input type=checkbox>` controls. * An attribute that is usually set by `aria-checked` or native `<input type=checkbox>` controls.
* *
@ -19535,6 +19562,15 @@ export interface FrameLocator {
* @param options * @param options
*/ */
getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: { getByRole(role: "alert"|"alertdialog"|"application"|"article"|"banner"|"blockquote"|"button"|"caption"|"cell"|"checkbox"|"code"|"columnheader"|"combobox"|"complementary"|"contentinfo"|"definition"|"deletion"|"dialog"|"directory"|"document"|"emphasis"|"feed"|"figure"|"form"|"generic"|"grid"|"gridcell"|"group"|"heading"|"img"|"insertion"|"link"|"list"|"listbox"|"listitem"|"log"|"main"|"marquee"|"math"|"meter"|"menu"|"menubar"|"menuitem"|"menuitemcheckbox"|"menuitemradio"|"navigation"|"none"|"note"|"option"|"paragraph"|"presentation"|"progressbar"|"radio"|"radiogroup"|"region"|"row"|"rowgroup"|"rowheader"|"scrollbar"|"search"|"searchbox"|"separator"|"slider"|"spinbutton"|"status"|"strong"|"subscript"|"superscript"|"switch"|"tab"|"table"|"tablist"|"tabpanel"|"term"|"textbox"|"time"|"timer"|"toolbar"|"tooltip"|"tree"|"treegrid"|"treeitem", options?: {
/**
* Option that controls whether elements referenced by `aria-owns` and `aria-controls` are included in the search. By
* default, elements referenced by `aria-owns` and `aria-controls` are not included.
*
* Learn more about [`aria-owns`](https://www.w3.org/TR/wai-aria-1.2/#aria-owns) and
* [`aria-controls`](https://www.w3.org/TR/wai-aria-1.2/#aria-controls).
*/
ariaChildren?: boolean;
/** /**
* An attribute that is usually set by `aria-checked` or native `<input type=checkbox>` controls. * An attribute that is usually set by `aria-checked` or native `<input type=checkbox>` controls.
* *

View file

@ -0,0 +1,139 @@
/**
* 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 as it, expect } from './pageTest';
it('should handle aria-owns with elements outside the parent tree', async ({ page }) => {
await page.setContent(`
<div role="navigation" aria-owns="menu1 menu2">
<div id="menu1" role="menu">
<div role="menuitem">Home</div>
<div role="menuitem">About</div>
</div>
</div>
<div id="menu2" role="menu">
<div role="menuitem">Services</div>
<div role="menuitem">Contact</div>
</div>
`);
const menuItem = page.getByRole('navigation').getByRole('menu', { ariaChildren: true }).getByRole('menuitem', { name: 'Services' });
await expect.soft(menuItem).toHaveText(`Services`);
});
it('should handle aria-controls with elements outside the parent tree', async ({ page }) => {
await page.setContent(`
<form role="form" aria-controls="input1 input2">
<label for="input1">First Name</label>
<input id="input1" type="text">
</form>
<label for="input2">Last Name</label>
<input id="input2" type="text">
`);
await page.getByRole('form').getByRole('textbox', { name: 'Last Name', ariaChildren: true }).fill('John');
});
it('should handle aria-owns and aria-controls with elements outside the parent tree', async ({ page }) => {
await page.setContent(`
<div role="main" aria-owns="section1 section2" aria-controls="footer">
<section id="section1" role="region">
<h2>Introduction</h2>
<p>Welcome to our website.</p>
</section>
</div>
<section id="section2" role="region">
<h2>Features</h2>
<ul>
<li>Feature 1</li>
<li>Feature 2</li>
</ul>
</section>
<footer id="footer">
<p>Contact us at info@example.com</p>
</footer>
`);
await page.getByRole('main').getByRole('region', { ariaChildren: true }).getByRole('heading', { name: 'Features' }).click();
});
it('should handle nested roles with aria-owns', async ({ page }) => {
await page.setContent(`
<div role="tree" aria-owns="node1 node2">
<div id="node1" role="treeitem">Node 1</div>
<div id="node2" role="treeitem">Node 2</div>
</div>
`);
const treeItem = page.getByRole('tree').getByRole('treeitem', { name: 'Node 1' });
await expect(treeItem).toHaveText('Node 1');
});
it('should handle aria-controls with nested elements', async ({ page }) => {
await page.setContent(`
<div role="tablist" aria-controls="panel1 panel2">
<div role="tab" id="tab1">Tab 1</div>
<div role="tab" id="tab2">Tab 2</div>
</div>
<div id="panel1" role="tabpanel">Panel 1 Content</div>
<div id="panel2" role="tabpanel">Panel 2 Content</div>
`);
const tabPanel = page.getByRole('tablist').getByRole('tabpanel', { ariaChildren: true }).getByText('Panel 1 Content');
await expect(tabPanel).toHaveText('Panel 1 Content');
});
it('should handle accordion with aria-controls', async ({ page }) => {
await page.setContent(`
<div role="region">
<button aria-controls="section1">Section 1</button>
<button aria-controls="section2">Section 2</button>
</div>
<div id="section1" role="region">Section 1 Content</div>
<div id="section2" role="region">Section 2 Content</div>
`);
const section = page.getByRole('region').getByRole('button', { name: 'Section 1' }).getByRole('region', { ariaChildren: true });
await expect(section).toHaveText('Section 1 Content');
});
it('should handle aria-owns with mixed roles', async ({ page }) => {
await page.setContent(`
<div role="grid" aria-owns="row1 row2">
<div id="row1" role="row">
<div role="gridcell">Cell 1</div>
</div>
<div id="row2" role="row">
<div role="gridcell">Cell 2</div>
</div>
</div>
`);
const gridCell = page.getByRole('grid').getByRole('gridcell', { name: 'Cell 1' });
await expect(gridCell).toHaveText('Cell 1');
});
it('should handle aria-owns with role changes', async ({ page }) => {
await page.setContent(`
<div role="tablist" aria-owns="tab1 tab2">
<div id="tab1" role="tab">Tab 1</div>
<div id="tab2" role="tab">Tab 2</div>
</div>
`);
const tab = page.getByRole('tablist').getByRole('tab', { name: 'Tab 1' });
await expect(tab).toHaveText('Tab 1');
});