diff --git a/src/common/AriaQueryHandler.ts b/src/common/AriaQueryHandler.ts new file mode 100644 index 00000000000..efe01ab17ef --- /dev/null +++ b/src/common/AriaQueryHandler.ts @@ -0,0 +1,122 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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 { InternalQueryHandler } from './QueryHandler.js'; +import { ElementHandle, JSHandle } from './JSHandle.js'; +import { Protocol } from 'devtools-protocol'; +import { CDPSession } from './Connection.js'; + +async function queryAXTree( + client: CDPSession, + element: ElementHandle, + accessibleName?: string, + role?: string +): Promise { + const { nodes } = await client.send('Accessibility.queryAXTree', { + objectId: element._remoteObject.objectId, + accessibleName, + role, + }); + const filteredNodes: Protocol.Accessibility.AXNode[] = nodes.filter( + (node: Protocol.Accessibility.AXNode) => node.role.value !== 'text' + ); + return filteredNodes; +} + +/* + * The selectors consist of an accessible name to query for and optionally + * further aria attributes on the form `[=]`. + * Currently, we only support the `name` and `role` attribute. + * The following examples showcase how the syntax works wrt. querying: + * - 'title[role="heading"]' queries for elements with name 'title' and role 'heading'. + * - '[role="img"]' queries for elements with role 'img' and any name. + * - 'label' queries for elements with name 'label' and any role. + * - '[name=""][role="button"]' queries for elements with no name and role 'button'. + */ +type ariaQueryOption = { name?: string; role?: string }; +function parseAriaSelector(selector: string): ariaQueryOption { + const normalize = (value: string): string => value.replace(/ +/g, ' ').trim(); + const knownAttributes = new Set(['name', 'role']); + const queryOptions: ariaQueryOption = {}; + const attributeRegexp = /\[\s*(?\w+)\s*=\s*"(?\\.|[^"\\]*)"\s*\]/; + const defaultName = selector.replace( + attributeRegexp, + (_, attribute: string, value: string) => { + attribute = attribute.trim(); + if (!knownAttributes.has(attribute)) + throw new Error( + 'Unkown aria attribute "${groups.attribute}" in selector' + ); + queryOptions[attribute] = normalize(value); + return ''; + } + ); + if (defaultName && !queryOptions.name) + queryOptions.name = normalize(defaultName); + return queryOptions; +} + +const queryOne = async ( + element: ElementHandle, + selector: string +): Promise => { + const exeCtx = element.executionContext(); + const { name, role } = parseAriaSelector(selector); + const res = await queryAXTree(exeCtx._client, element, name, role); + if (res.length < 1) { + return null; + } + return exeCtx._adoptBackendNodeId(res[0].backendDOMNodeId); +}; + +const waitFor = () => { + throw new Error('waitForSelector is not supported for aria selectors'); +}; + +const queryAll = async ( + element: ElementHandle, + selector: string +): Promise => { + const exeCtx = element.executionContext(); + const { name, role } = parseAriaSelector(selector); + const res = await queryAXTree(exeCtx._client, element, name, role); + return Promise.all( + res.map((axNode) => exeCtx._adoptBackendNodeId(axNode.backendDOMNodeId)) + ); +}; + +const queryAllArray = async ( + element: ElementHandle, + selector: string +): Promise => { + const elementHandles = await queryAll(element, selector); + const exeCtx = element.executionContext(); + const jsHandle = exeCtx.evaluateHandle( + (...elements) => elements, + ...elementHandles + ); + return jsHandle; +}; + +/** + * @internal + */ +export const ariaHandler: InternalQueryHandler = { + queryOne, + waitFor, + queryAll, + queryAllArray, +}; diff --git a/src/common/QueryHandler.ts b/src/common/QueryHandler.ts index c1e7551c2a0..9f4144e9b4a 100644 --- a/src/common/QueryHandler.ts +++ b/src/common/QueryHandler.ts @@ -16,11 +16,12 @@ import { WaitForSelectorOptions, DOMWorld } from './DOMWorld.js'; import { ElementHandle, JSHandle } from './JSHandle.js'; +import { ariaHandler } from './AriaQueryHandler.js'; /** * @internal */ -interface InternalQueryHandler { +export interface InternalQueryHandler { queryOne?: ( element: ElementHandle, selector: string @@ -93,7 +94,6 @@ function makeQueryHandler(handler: CustomQueryHandler): InternalQueryHandler { return internalHandler; } -const _queryHandlers = new Map(); const _defaultHandler = makeQueryHandler({ queryOne: (element: Element, selector: string) => element.querySelector(selector), @@ -101,6 +101,12 @@ const _defaultHandler = makeQueryHandler({ element.querySelectorAll(selector), }); +const _builtInHandlers: Array<[string, InternalQueryHandler]> = [ + ['aria', ariaHandler], +]; + +const _queryHandlers = new Map(_builtInHandlers); + export function registerCustomQueryHandler( name: string, handler: CustomQueryHandler diff --git a/test/ariaqueryhandler.spec.ts b/test/ariaqueryhandler.spec.ts new file mode 100644 index 00000000000..51fcbee966d --- /dev/null +++ b/test/ariaqueryhandler.spec.ts @@ -0,0 +1,268 @@ +/** + * Copyright 2020 Google Inc. All rights reserved. + * + * 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 expect from 'expect'; +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, + describeChromeOnly, +} from './mocha-utils'; // eslint-disable-line import/extensions + +import { ElementHandle } from '../lib/cjs/puppeteer/common/JSHandle.js'; + +describeChromeOnly('AriaQueryHandler', () => { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describe('parseAriaSelector', () => { + beforeEach(async () => { + const { page } = getTestState(); + await page.setContent( + '' + ); + }); + it('should find button', async () => { + const { page } = getTestState(); + const expectFound = async (button: ElementHandle) => { + const id = await button.evaluate((button: Element) => button.id); + expect(id).toBe('btn'); + }; + let button = await page.$( + 'aria/Submit button and some spaces[role="button"]' + ); + await expectFound(button); + button = await page.$( + 'aria/ Submit button and some spaces[role="button"]' + ); + await expectFound(button); + button = await page.$( + 'aria/Submit button and some spaces [role="button"]' + ); + await expectFound(button); + button = await page.$( + 'aria/Submit button and some spaces [ role = "button" ] ' + ); + await expectFound(button); + button = await page.$( + 'aria/[role="button"]Submit button and some spaces' + ); + await expectFound(button); + button = await page.$( + 'aria/Submit button [role="button"]and some spaces' + ); + await expectFound(button); + button = await page.$( + 'aria/[name=" Submit button and some spaces"][role="button"]' + ); + await expectFound(button); + button = await page.$( + 'aria/ignored[name="Submit button and some spaces"][role="button"]' + ); + await expectFound(button); + }); + }); + + describe('queryOne', () => { + it('should find button by role', async () => { + const { page } = getTestState(); + await page.setContent( + '
' + ); + const button = await page.$('aria/[role="button"]'); + const id = await button.evaluate((button: Element) => button.id); + expect(id).toBe('btn'); + }); + + it('should find button by name and role', async () => { + const { page } = getTestState(); + await page.setContent( + '
' + ); + const button = await page.$('aria/Submit[role="button"]'); + const id = await button.evaluate((button: Element) => button.id); + expect(id).toBe('btn'); + }); + + it('should find first matching element', async () => { + const { page } = getTestState(); + await page.setContent( + ` + + + ` + ); + const div = await page.$('aria/menu div'); + const id = await div.evaluate((div: Element) => div.id); + expect(id).toBe('mnu1'); + }); + + it('should find by name', async () => { + const { page } = getTestState(); + await page.setContent( + ` + + + ` + ); + const menu = await page.$('aria/menu-label1'); + const id = await menu.evaluate((div: Element) => div.id); + expect(id).toBe('mnu1'); + }); + + it('should find by name', async () => { + const { page } = getTestState(); + await page.setContent( + ` + + + ` + ); + const menu = await page.$('aria/menu-label2'); + const id = await menu.evaluate((div: Element) => div.id); + expect(id).toBe('mnu2'); + }); + }); + + describe('queryAll', () => { + it('should find menu by name', async () => { + const { page } = getTestState(); + await page.setContent( + ` + + + ` + ); + const divs = await page.$$('aria/menu div'); + const ids = await Promise.all( + divs.map((n) => n.evaluate((div: Element) => div.id)) + ); + expect(ids.join(', ')).toBe('mnu1, mnu2'); + }); + }); + describe('queryAllArray', () => { + it('$$eval should handle many elements', async () => { + const { page } = getTestState(); + await page.setContent(''); + await page.evaluate( + ` + for (var i = 0; i <= 10000; i++) { + const button = document.createElement('button'); + button.textContent = i; + document.body.appendChild(button); + } + ` + ); + const sum = await page.$$eval('aria/[role="button"]', (buttons) => + buttons.reduce((acc, button) => acc + Number(button.textContent), 0) + ); + expect(sum).toBe(50005000); + }); + }); + + describe('queryOne (Chromium web test)', async () => { + beforeEach(async () => { + const { page } = getTestState(); + await page.setContent( + ` +

title

+ +
+
+
+
+
+
+ + +
+
+ +

text content

+ +

text content

+ + + Accessible Name + + + + +
+
+
+
item1
+
item2
+
+
item3
+
+ +
+ ` + ); + }); + const getIds = async (elements: ElementHandle[]) => + Promise.all( + elements.map((element) => + element.evaluate((element: Element) => element.id) + ) + ); + it('should find by name "foo"', async () => { + const { page } = getTestState(); + const found = await page.$$('aria/foo'); + const ids = await getIds(found); + expect(ids).toEqual(['node3', 'node5', 'node6']); + }); + it('should find by name "bar"', async () => { + const { page } = getTestState(); + const found = await page.$$('aria/bar'); + const ids = await getIds(found); + expect(ids).toEqual(['node1', 'node2', 'node8']); + }); + it('should find treeitem by name', async () => { + const { page } = getTestState(); + const found = await page.$$('aria/item1 item2 item3'); + const ids = await getIds(found); + expect(ids).toEqual(['node30']); + }); + it('should find by role "button"', async () => { + const { page } = getTestState(); + const found = await page.$$('aria/[role="button"]'); + const ids = await getIds(found); + expect(ids).toEqual(['node5', 'node6', 'node8', 'node10', 'node21']); + }); + it('should find by role "heading"', async () => { + const { page } = getTestState(); + const found = await page.$$('aria/[role="heading"]'); + const ids = await getIds(found); + expect(ids).toEqual(['shown', 'hidden', 'node11', 'node13']); + }); + it('should find both ignored and unignored', async () => { + const { page } = getTestState(); + const found = await page.$$('aria/title'); + const ids = await getIds(found); + expect(ids).toEqual(['shown', 'hidden']); + }); + }); +});