Skip to content

Commit

Permalink
feat(webdriver): support ARIA selectors (#12315)
Browse files Browse the repository at this point in the history
Co-authored-by: Nikolay Vitkov <34244704+Lightning00Blade@users.noreply.github.com>
  • Loading branch information
OrKoN and Lightning00Blade committed Apr 25, 2024
1 parent feef2a3 commit 88b46ee
Show file tree
Hide file tree
Showing 8 changed files with 106 additions and 49 deletions.
9 changes: 9 additions & 0 deletions packages/puppeteer-core/src/api/ElementHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {Frame} from '../api/Frame.js';
import {getQueryHandlerAndSelector} from '../common/GetQueryHandler.js';
import {LazyArg} from '../common/LazyArg.js';
import type {
AwaitableIterable,
ElementFor,
EvaluateFuncWith,
HandleFor,
Expand Down Expand Up @@ -873,6 +874,14 @@ export abstract class ElementHandle<
...paths: string[]
): Promise<void>;

/**
* @internal
*/
abstract queryAXTree(
name?: string,
role?: string
): AwaitableIterable<ElementHandle<Node>>;

/**
* This method scrolls element into view if needed, and then uses
* {@link Touchscreen.tap} to tap in the center of the element.
Expand Down
21 changes: 21 additions & 0 deletions packages/puppeteer-core/src/bidi/ElementHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import type * as Bidi from 'chromium-bidi/lib/cjs/protocol/protocol.js';

import {ElementHandle, type AutofillData} from '../api/ElementHandle.js';
import type {AwaitableIterable} from '../common/types.js';
import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
import {throwIfDisposed} from '../util/decorators.js';

import type {BidiFrame} from './Frame.js';
Expand Down Expand Up @@ -116,4 +118,23 @@ export class BidiElementHandle<
});
await this.frame.setFiles(this, files);
}

override async *queryAXTree(
this: BidiElementHandle<HTMLElement>,
name?: string | undefined,
role?: string | undefined
): AwaitableIterable<ElementHandle<Node>> {
const results = await this.frame.locateNodes(this, {
type: 'accessibility',
value: {
role,
name,
},
});

return yield* AsyncIterableUtil.map(results, node => {
// TODO: maybe change ownership since the default ownership is probably none.
return Promise.resolve(BidiElementHandle.from(node, this.realm));
});
}
}
12 changes: 12 additions & 0 deletions packages/puppeteer-core/src/bidi/Frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,18 @@ export class BidiFrame extends Frame {
files
);
}

@throwIfDetached
async locateNodes(
element: BidiElementHandle,
locator: Bidi.BrowsingContext.Locator
): Promise<Bidi.Script.NodeRemoteValue[]> {
return await this.browsingContext.locateNodes(
locator,
// SAFETY: ElementHandles are always remote references.
[element.remoteValue() as Bidi.Script.SharedReference]
);
}
}

function isConsoleLogEntry(
Expand Down
17 changes: 17 additions & 0 deletions packages/puppeteer-core/src/bidi/core/BrowsingContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,4 +601,21 @@ export class BrowsingContext extends EventEmitter<{
})
);
}

@throwIfDisposed<BrowsingContext>(context => {
// SAFETY: Disposal implies this exists.
return context.#reason!;
})
async locateNodes(
locator: Bidi.BrowsingContext.Locator,
startNodes: [Bidi.Script.SharedReference, ...Bidi.Script.SharedReference[]]
): Promise<Bidi.Script.NodeRemoteValue[]> {
// TODO: add other locateNodes options if needed.
const result = await this.#session.send('browsingContext.locateNodes', {
context: this.id,
locator,
startNodes: startNodes.length ? startNodes : undefined,
});
return result.result.nodes;
}
}
4 changes: 4 additions & 0 deletions packages/puppeteer-core/src/bidi/core/Connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export interface Commands {
params: Bidi.BrowsingContext.GetTreeParameters;
returnType: Bidi.BrowsingContext.GetTreeResult;
};
'browsingContext.locateNodes': {
params: Bidi.BrowsingContext.LocateNodesParameters;
returnType: Bidi.BrowsingContext.LocateNodesResult;
};
'browsingContext.navigate': {
params: Bidi.BrowsingContext.NavigateParameters;
returnType: Bidi.BrowsingContext.NavigateResult;
Expand Down
42 changes: 1 addition & 41 deletions packages/puppeteer-core/src/cdp/AriaQueryHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type {Protocol} from 'devtools-protocol';

import type {CDPSession} from '../api/CDPSession.js';
import type {ElementHandle} from '../api/ElementHandle.js';
import {QueryHandler, type QuerySelector} from '../common/QueryHandler.js';
import type {AwaitableIterable} from '../common/types.js';
import {assert} from '../util/assert.js';
import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';

const NON_ELEMENT_NODE_ROLES = new Set(['StaticText', 'InlineTextBox']);

const queryAXTree = async (
client: CDPSession,
element: ElementHandle<Node>,
accessibleName?: string,
role?: string
): Promise<Protocol.Accessibility.AXNode[]> => {
const {nodes} = await client.send('Accessibility.queryAXTree', {
objectId: element.id,
accessibleName,
role,
});
return nodes.filter((node: Protocol.Accessibility.AXNode) => {
if (node.ignored) {
return false;
}
if (!node.role) {
return false;
}
if (NON_ELEMENT_NODE_ROLES.has(node.role.value)) {
return false;
}
return true;
});
};

interface ARIASelector {
name?: string;
role?: string;
Expand Down Expand Up @@ -105,17 +75,7 @@ export class ARIAQueryHandler extends QueryHandler {
selector: string
): AwaitableIterable<ElementHandle<Node>> {
const {name, role} = parseARIASelector(selector);
const results = await queryAXTree(
element.realm.environment.client,
element,
name,
role
);
yield* AsyncIterableUtil.map(results, node => {
return element.realm.adoptBackendNode(node.backendDOMNodeId) as Promise<
ElementHandle<Node>
>;
});
yield* element.queryAXTree(name, role);
}

static override queryOne = async (
Expand Down
34 changes: 34 additions & 0 deletions packages/puppeteer-core/src/cdp/ElementHandle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,19 @@ import type {Protocol} from 'devtools-protocol';

import type {CDPSession} from '../api/CDPSession.js';
import {ElementHandle, type AutofillData} from '../api/ElementHandle.js';
import type {AwaitableIterable} from '../common/types.js';
import {debugError} from '../common/util.js';
import {assert} from '../util/assert.js';
import {AsyncIterableUtil} from '../util/AsyncIterableUtil.js';
import {throwIfDisposed} from '../util/decorators.js';

import type {CdpFrame} from './Frame.js';
import type {FrameManager} from './FrameManager.js';
import type {IsolatedWorld} from './IsolatedWorld.js';
import {CdpJSHandle} from './JSHandle.js';

const NON_ELEMENT_NODE_ROLES = new Set(['StaticText', 'InlineTextBox']);

/**
* The CdpElementHandle extends ElementHandle now to keep compatibility
* with `instanceof` because of that we need to have methods for
Expand Down Expand Up @@ -169,4 +173,34 @@ export class CdpElementHandle<
card: data.creditCard,
});
}

override async *queryAXTree(
name?: string | undefined,
role?: string | undefined
): AwaitableIterable<ElementHandle<Node>> {
const {nodes} = await this.client.send('Accessibility.queryAXTree', {
objectId: this.id,
accessibleName: name,
role,
});

const results = nodes.filter(node => {
if (node.ignored) {
return false;
}
if (!node.role) {
return false;
}
if (NON_ELEMENT_NODE_ROLES.has(node.role.value)) {
return false;
}
return true;
});

return yield* AsyncIterableUtil.map(results, node => {
return this.realm.adoptBackendNode(node.backendDOMNodeId) as Promise<
ElementHandle<Node>
>;
});
}
}
16 changes: 8 additions & 8 deletions test/TestExpectations.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"platforms": ["darwin", "linux", "win32"],
"parameters": ["firefox"],
"expectations": ["SKIP"],
"comment": "TODO: add a comment explaining why this expectation is required (include links to issues)"
"comment": "Firefox crashes when a document is provided as a start node"
},
{
"testIdPattern": "[autofill.spec] *",
Expand Down Expand Up @@ -195,6 +195,13 @@
"expectations": ["SKIP"],
"comment": "TODO: add a comment explaining why this expectation is required (include links to issues)"
},
{
"testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler queryOne (Chromium web test) should find by role \"heading\"",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["webDriverBiDi"],
"expectations": ["FAIL"],
"comment": "WebDriver BiDi locateNodes does not support shadow roots so far"
},
{
"testIdPattern": "[autofill.spec] *",
"platforms": ["darwin", "linux", "win32"],
Expand Down Expand Up @@ -916,13 +923,6 @@
"expectations": ["FAIL"],
"comment": "Querying by a11y attributes is not standard behavior"
},
{
"testIdPattern": "[ariaqueryhandler.spec] AriaQueryHandler queryOne (Chromium web test) should find by role \"heading\"",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["firefox", "webDriverBiDi"],
"expectations": ["FAIL"],
"comment": "Querying by a11y attributes is not standard behavior"
},
{
"testIdPattern": "[bfcache.spec] BFCache can navigate to a BFCached page containing an OOPIF and a worker",
"platforms": ["darwin", "linux", "win32"],
Expand Down

0 comments on commit 88b46ee

Please sign in to comment.