Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(locator): Resolve Elements with aria-owns and aria-controls as Children in getByRole Method #34334

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/src/api/params.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

## 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
* since: v1.27
- `level` <[int]>
Expand Down Expand Up @@ -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-expanded-%%
- %%-locator-get-by-role-option-includeHidden-%%
- %%-locator-get-by-role-option-ariaChildren-%%
- %%-locator-get-by-role-option-level-%%
- %%-locator-get-by-role-option-name-%%
- %%-locator-get-by-role-option-pressed-%%
Expand Down
37 changes: 33 additions & 4 deletions packages/playwright-core/src/server/injected/roleSelectorEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@ type RoleEngineOptions = {
level?: number;
disabled?: 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();

function validateSupportedRole(attr: string, roles: string[], role: string) {
Expand Down Expand Up @@ -116,6 +117,11 @@ function validateAttributes(attrs: AttributeSelectorPart[], role: string): RoleE
options.includeHidden = attr.op === '<truthy>' ? true : attr.value;
break;
}
case 'aria-children': {
validateSupportedValues(attr, [true, false]);
options.ariaChildren = attr.value;
break;
}
default: {
throw new Error(`Unknown attribute "${attr.name}", must be one of ${kSupportedAttributes.map(a => `"${a}"`).join(', ')}.`);
}
Expand All @@ -124,9 +130,31 @@ function validateAttributes(attrs: AttributeSelectorPart[], role: string): RoleE
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[] {
const result: Element[] = [];
const match = (element: Element) => {

if (getAriaRole(element) !== options.role)
return;
if (options.selected !== undefined && getAriaSelected(element) !== options.selected)
Expand All @@ -147,11 +175,9 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo
return;
}
if (options.name !== undefined) {
// Always normalize whitespace in the accessible name.
const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, !!options.includeHidden));
if (typeof options.name === 'string')
options.name = normalizeWhiteSpace(options.name);
// internal:role assumes that [name="foo"i] also means substring.
if (internal && !options.exact && options.nameOp === '=')
options.nameOp = '*=';
if (!matchesAttributePart(accessibleName, { name: '', jsonPath: [], op: options.nameOp || '=', value: options.name, caseSensitive: !!options.exact }))
Expand All @@ -170,8 +196,11 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo
shadows.push(element.shadowRoot);
}
shadows.forEach(query);
if (options.ariaChildren && root instanceof Element) {
const ariaChildren = getAriaChildren(root, scope);
ariaChildren.forEach(match);
}
};

query(scope);
return result;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/playwright-core/src/utils/isomorphic/locatorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type ByRoleOptions = {
exact?: boolean;
expanded?: boolean;
includeHidden?: boolean;
ariaChildren?: boolean;
level?: number;
name?: string | RegExp;
pressed?: boolean;
Expand Down Expand Up @@ -68,6 +69,8 @@ export function getByRoleSelector(role: string, options: ByRoleOptions = {}): st
props.push(['expanded', String(options.expanded)]);
if (options.includeHidden !== undefined)
props.push(['include-hidden', String(options.includeHidden)]);
if (options.ariaChildren !== undefined)
props.push(['aria-children', String(options.ariaChildren)]);
if (options.level !== undefined)
props.push(['level', String(options.level)]);
if (options.name !== undefined)
Expand Down
36 changes: 36 additions & 0 deletions packages/playwright-core/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2915,6 +2915,15 @@ export interface Page {
* @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?: {
/**
* 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.
*
Expand Down Expand Up @@ -6674,6 +6683,15 @@ export interface Frame {
* @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?: {
/**
* 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.
*
Expand Down Expand Up @@ -13294,6 +13312,15 @@ export interface Locator {
* @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?: {
/**
* 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.
*
Expand Down Expand Up @@ -19535,6 +19562,15 @@ export interface FrameLocator {
* @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?: {
/**
* 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.
*
Expand Down
139 changes: 139 additions & 0 deletions tests/page/page-aria-children.spec.ts
Original file line number Diff line number Diff line change
@@ -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 work with 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 work with 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 work with 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 [email protected]</p>
</footer>
`);

await page.getByRole('main').getByRole('region', { ariaChildren: true }).getByRole('heading', { name: 'Features' }).click();
});

it('should work with 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 work with 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 work 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 work with 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 work with 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');
});
2 changes: 1 addition & 1 deletion tests/page/selectors-role.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,7 @@ test('errors', async ({ page }) => {
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", "include-hidden", "level", "name", "pressed", "selected"`);
expect(e1.message).toContain(`Unknown attribute "sElected", must be one of "aria-children", "checked", "disabled", "expanded", "include-hidden", "level", "name", "pressed", "selected"`);

const e2 = await page.$('role=foo[bar . qux=true]').catch(e => e);
expect(e2.message).toContain(`Unknown attribute "bar.qux"`);
Expand Down
Loading