Skip to content

Commit 9df3580

Browse files
feat: use CDP for a11y selectors (#4059)
Required for piercing shadow roots --------- Signed-off-by: Browser Automation Bot <browser-automation-bot@google.com> Co-authored-by: Browser Automation Bot <browser-automation-bot@google.com>
1 parent 67f8cb7 commit 9df3580

File tree

4 files changed

+167
-175
lines changed

4 files changed

+167
-175
lines changed

src/bidiMapper/modules/context/BrowsingContextImpl.ts

Lines changed: 167 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
NoSuchElementException,
2727
NoSuchFrameException,
2828
NoSuchHistoryEntryException,
29+
NoSuchNodeException,
2930
Script,
3031
Session,
3132
type UAClientHints,
@@ -43,7 +44,7 @@ import type {ContextConfigStorage} from '../browser/ContextConfigStorage.js';
4344
import type {CdpTarget} from '../cdp/CdpTarget.js';
4445
import type {Realm} from '../script/Realm.js';
4546
import type {RealmStorage} from '../script/RealmStorage.js';
46-
import {getSharedId} from '../script/SharedId.js';
47+
import {getSharedId, parseSharedId} from '../script/SharedId.js';
4748
import {WindowRealm} from '../script/WindowRealm.js';
4849
import type {EventManager} from '../session/EventManager.js';
4950

@@ -1441,17 +1442,17 @@ export class BrowsingContextImpl {
14411442
);
14421443
}
14431444

1444-
async #getLocatorDelegate(
1445-
realm: Realm,
1445+
#getLocatorDelegate(
14461446
locator: BrowsingContext.Locator,
14471447
maxNodeCount: number | undefined,
14481448
startNodes: Script.SharedReference[],
1449-
): Promise<{
1449+
): {
14501450
functionDeclaration: string;
14511451
argumentsLocalValues: Script.LocalValue[];
1452-
}> {
1452+
} {
14531453
switch (locator.type) {
14541454
case 'context':
1455+
case 'accessibility':
14551456
throw new Error('Unreachable');
14561457
case 'css':
14571458
return {
@@ -1660,125 +1661,6 @@ export class BrowsingContextImpl {
16601661
...startNodes,
16611662
],
16621663
};
1663-
case 'accessibility': {
1664-
// https://w3c.github.io/webdriver-bidi/#locate-nodes-using-accessibility-attributes
1665-
if (!locator.value.name && !locator.value.role) {
1666-
throw new InvalidSelectorException(
1667-
'Either name or role has to be specified',
1668-
);
1669-
}
1670-
1671-
// The next two commands cause a11y caches for the target to be
1672-
// preserved. We probably do not need to disable them if the
1673-
// client is using a11y features, but we could by calling
1674-
// Accessibility.disable.
1675-
await Promise.all([
1676-
this.#cdpTarget.cdpClient.sendCommand('Accessibility.enable'),
1677-
this.#cdpTarget.cdpClient.sendCommand('Accessibility.getRootAXNode'),
1678-
]);
1679-
const bindings = await realm.evaluate(
1680-
/* expression=*/ '({getAccessibleName, getAccessibleRole})',
1681-
/* awaitPromise=*/ false,
1682-
/* resultOwnership=*/ Script.ResultOwnership.Root,
1683-
/* serializationOptions= */ undefined,
1684-
/* userActivation=*/ false,
1685-
/* includeCommandLineApi=*/ true,
1686-
);
1687-
1688-
if (bindings.type !== 'success') {
1689-
throw new Error('Could not get bindings');
1690-
}
1691-
1692-
if (bindings.result.type !== 'object') {
1693-
throw new Error('Could not get bindings');
1694-
}
1695-
return {
1696-
functionDeclaration: String(
1697-
(
1698-
name: string,
1699-
role: string,
1700-
bindings: any,
1701-
maxNodeCount: number,
1702-
...startNodes: Element[]
1703-
) => {
1704-
const returnedNodes: Element[] = [];
1705-
1706-
let aborted = false;
1707-
1708-
function collect(
1709-
contextNodes: Element[],
1710-
selector: {role: string; name: string},
1711-
) {
1712-
if (aborted) {
1713-
return;
1714-
}
1715-
for (const contextNode of contextNodes) {
1716-
let match = true;
1717-
1718-
if (selector.role) {
1719-
const role = bindings.getAccessibleRole(contextNode);
1720-
if (selector.role !== role) {
1721-
match = false;
1722-
}
1723-
}
1724-
1725-
if (selector.name) {
1726-
const name = bindings.getAccessibleName(contextNode);
1727-
if (selector.name !== name) {
1728-
match = false;
1729-
}
1730-
}
1731-
1732-
if (match) {
1733-
if (
1734-
maxNodeCount !== 0 &&
1735-
returnedNodes.length === maxNodeCount
1736-
) {
1737-
aborted = true;
1738-
break;
1739-
}
1740-
1741-
returnedNodes.push(contextNode);
1742-
}
1743-
1744-
const childNodes: Element[] = [];
1745-
for (const child of contextNode.children) {
1746-
if (child instanceof HTMLElement) {
1747-
childNodes.push(child);
1748-
}
1749-
}
1750-
1751-
collect(childNodes, selector);
1752-
}
1753-
}
1754-
1755-
startNodes =
1756-
startNodes.length > 0
1757-
? startNodes
1758-
: Array.from(document.documentElement.children).filter(
1759-
(c) => c instanceof HTMLElement,
1760-
);
1761-
collect(startNodes, {
1762-
role,
1763-
name,
1764-
});
1765-
return returnedNodes;
1766-
},
1767-
),
1768-
argumentsLocalValues: [
1769-
// `name`
1770-
{type: 'string', value: locator.value.name || ''},
1771-
// `role`
1772-
{type: 'string', value: locator.value.role || ''},
1773-
// `bindings`.
1774-
{handle: bindings.result.handle!},
1775-
// `maxNodeCount` with `0` means no limit.
1776-
{type: 'number', value: maxNodeCount ?? 0},
1777-
// `startNodes`
1778-
...startNodes,
1779-
],
1780-
};
1781-
}
17821664
}
17831665
}
17841666

@@ -1790,49 +1672,25 @@ export class BrowsingContextImpl {
17901672
serializationOptions: Script.SerializationOptions | undefined,
17911673
): Promise<BrowsingContext.LocateNodesResult> {
17921674
if (locator.type === 'context') {
1793-
if (startNodes.length !== 0) {
1794-
throw new InvalidArgumentException('Start nodes are not supported');
1795-
}
1796-
const contextId = locator.value.context;
1797-
if (!contextId) {
1798-
throw new InvalidSelectorException('Invalid context');
1799-
}
1800-
const context = this.#browsingContextStorage.getContext(contextId);
1801-
const parent = context.parent;
1802-
if (!parent) {
1803-
throw new InvalidArgumentException('This context has no container');
1804-
}
1805-
try {
1806-
const {backendNodeId} = await parent.#cdpTarget.cdpClient.sendCommand(
1807-
'DOM.getFrameOwner',
1808-
{
1809-
frameId: contextId,
1810-
},
1811-
);
1812-
const {object} = await parent.#cdpTarget.cdpClient.sendCommand(
1813-
'DOM.resolveNode',
1814-
{
1815-
backendNodeId,
1816-
},
1817-
);
1818-
const locatorResult = await realm.callFunction(
1819-
`function () { return this; }`,
1820-
false,
1821-
{handle: object.objectId!},
1822-
[],
1823-
Script.ResultOwnership.None,
1824-
serializationOptions,
1825-
);
1826-
if (locatorResult.type === 'exception') {
1827-
throw new Error('Unknown exception');
1828-
}
1829-
return {nodes: [locatorResult.result as Script.NodeRemoteValue]};
1830-
} catch {
1831-
throw new InvalidArgumentException('Context does not exist');
1832-
}
1675+
return await this.#locateNodesByContextLocator(
1676+
locator,
1677+
startNodes,
1678+
realm,
1679+
serializationOptions,
1680+
);
1681+
}
1682+
1683+
if (locator.type === 'accessibility') {
1684+
return await this.#locateNodesByAccessibility(
1685+
locator,
1686+
startNodes,
1687+
maxNodeCount,
1688+
realm,
1689+
);
18331690
}
1834-
const locatorDelegate = await this.#getLocatorDelegate(
1835-
realm,
1691+
1692+
// Select by injecting a script into the realm.
1693+
const locatorDelegate = this.#getLocatorDelegate(
18361694
locator,
18371695
maxNodeCount,
18381696
startNodes,
@@ -1910,6 +1768,149 @@ export class BrowsingContextImpl {
19101768
return {nodes};
19111769
}
19121770

1771+
async #locateNodesByContextLocator(
1772+
locator: BrowsingContext.ContextLocator,
1773+
startNodes: Script.SharedReference[],
1774+
realm: Realm,
1775+
serializationOptions: Script.SerializationOptions | undefined,
1776+
): Promise<BrowsingContext.LocateNodesResult> {
1777+
if (startNodes.length !== 0) {
1778+
throw new InvalidArgumentException('Start nodes are not supported');
1779+
}
1780+
const contextId = locator.value.context;
1781+
if (!contextId) {
1782+
throw new InvalidSelectorException('Invalid context');
1783+
}
1784+
const context = this.#browsingContextStorage.getContext(contextId);
1785+
const parent = context.parent;
1786+
if (!parent) {
1787+
throw new InvalidArgumentException('This context has no container');
1788+
}
1789+
try {
1790+
const {backendNodeId} = await parent.#cdpTarget.cdpClient.sendCommand(
1791+
'DOM.getFrameOwner',
1792+
{
1793+
frameId: contextId,
1794+
},
1795+
);
1796+
const {object} = await parent.#cdpTarget.cdpClient.sendCommand(
1797+
'DOM.resolveNode',
1798+
{
1799+
backendNodeId,
1800+
},
1801+
);
1802+
const locatorResult = await realm.callFunction(
1803+
`function () { return this; }`,
1804+
false,
1805+
{handle: object.objectId!},
1806+
[],
1807+
Script.ResultOwnership.None,
1808+
serializationOptions,
1809+
);
1810+
if (locatorResult.type === 'exception') {
1811+
throw new Error('Unknown exception');
1812+
}
1813+
return {nodes: [locatorResult.result as Script.NodeRemoteValue]};
1814+
} catch {
1815+
throw new InvalidArgumentException('Context does not exist');
1816+
}
1817+
}
1818+
1819+
async #locateNodesByAccessibility(
1820+
locator: BrowsingContext.AccessibilityLocator,
1821+
startNodes: Script.SharedReference[],
1822+
maxNodeCount: number | undefined,
1823+
realm: Realm,
1824+
) {
1825+
if (!locator.value.name && !locator.value.role) {
1826+
throw new InvalidSelectorException(
1827+
'Either name or role has to be specified',
1828+
);
1829+
}
1830+
await this.#cdpTarget.cdpClient.sendCommand('Accessibility.enable');
1831+
1832+
const startBackendNodeIds: number[] = [];
1833+
if (startNodes.length === 0) {
1834+
const {root: documentRoot} =
1835+
await this.#cdpTarget.cdpClient.sendCommand('DOM.getDocument');
1836+
startBackendNodeIds.push(documentRoot.backendNodeId);
1837+
} else {
1838+
for (const node of startNodes) {
1839+
if (node.sharedId) {
1840+
const parsed = parseSharedId(node.sharedId);
1841+
if (!parsed) {
1842+
throw new NoSuchNodeException(`Invalid sharedId: ${node.sharedId}`);
1843+
}
1844+
startBackendNodeIds.push(parsed.backendNodeId);
1845+
} else {
1846+
if (node.handle) {
1847+
const {nodeId} = await this.#cdpTarget.cdpClient.sendCommand(
1848+
'DOM.requestNode',
1849+
{
1850+
objectId: node.handle,
1851+
},
1852+
);
1853+
const {node: describedNode} =
1854+
await this.#cdpTarget.cdpClient.sendCommand('DOM.describeNode', {
1855+
nodeId,
1856+
});
1857+
startBackendNodeIds.push(describedNode.backendNodeId);
1858+
} else {
1859+
throw new NoSuchNodeException(
1860+
'Start node must have sharedId or handle',
1861+
);
1862+
}
1863+
}
1864+
}
1865+
}
1866+
1867+
const matchedBackendNodeIds = new Set<number>();
1868+
for (const backendNodeId of startBackendNodeIds) {
1869+
const {nodes} = await this.#cdpTarget.cdpClient.sendCommand(
1870+
'Accessibility.queryAXTree',
1871+
{
1872+
backendNodeId,
1873+
accessibleName: locator.value.name,
1874+
role: locator.value.role,
1875+
},
1876+
);
1877+
1878+
for (const node of nodes) {
1879+
if (node.backendDOMNodeId && node.role?.type === 'role') {
1880+
matchedBackendNodeIds.add(node.backendDOMNodeId);
1881+
if (
1882+
maxNodeCount !== undefined &&
1883+
maxNodeCount > 0 &&
1884+
matchedBackendNodeIds.size >= maxNodeCount
1885+
) {
1886+
break;
1887+
}
1888+
}
1889+
}
1890+
}
1891+
1892+
const resultNodes = await Promise.all(
1893+
Array.from(matchedBackendNodeIds).map(async (backendNodeId) => {
1894+
const {object} = await this.#cdpTarget.cdpClient.sendCommand(
1895+
'DOM.resolveNode',
1896+
{
1897+
backendNodeId,
1898+
},
1899+
);
1900+
// We need to use `serializeCdpObject` to convert it to BiDi format.
1901+
// We use `Script.ResultOwnership.None` as `locateNodes` returns weak references (nodes).
1902+
return await realm.serializeCdpObject(
1903+
object,
1904+
Script.ResultOwnership.None,
1905+
);
1906+
}),
1907+
);
1908+
1909+
return {
1910+
nodes: resultNodes.filter((result) => result.type === 'node'),
1911+
};
1912+
}
1913+
19131914
#getAllRelatedCdpTargets() {
19141915
const targets = new Set<CdpTarget>();
19151916
targets.add(this.cdpTarget);
Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
11
[start_nodes.py]
22
[test_locate_with_svg_context_node[innerText-foo-expected2\]]
33
expected: FAIL
4-
5-
[test_locate_with_svg_context_node[accessibility-value3-expected3\]]
6-
expected: FAIL
Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
11
[start_nodes.py]
22
[test_locate_with_svg_context_node[innerText-foo-expected2\]]
33
expected: FAIL
4-
5-
[test_locate_with_svg_context_node[accessibility-value3-expected3\]]
6-
expected: FAIL

0 commit comments

Comments
 (0)