From a3cb16308cd264b1cf1a9109931aaa9c6d626e1c Mon Sep 17 00:00:00 2001 From: Joel Einbinder Date: Fri, 10 May 2019 02:39:42 -0400 Subject: [PATCH] feat: `root` option in page.accessibility.snapshot() (#4318) Going from `AXNode` -> `ElementHandle` is turning out to be controversial. This patch instead adds a way to go from `ElementHandle` -> `AXNode`. If the API looks good, I'll add it into Firefox as well. References #3641 --- docs/api.md | 1 + lib/Accessibility.js | 43 ++++++++++++++++++++++++---- test/accessibility.spec.js | 57 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/docs/api.md b/docs/api.md index bd7f6ffe..179af5ef 100644 --- a/docs/api.md +++ b/docs/api.md @@ -2098,6 +2098,7 @@ Most of the accessibility tree gets filtered out when converting from Blink AX T #### accessibility.snapshot([options]) - `options` <[Object]> - `interestingOnly` <[boolean]> Prune uninteresting nodes from the tree. Defaults to `true`. + - `root` <[ElementHandle]> The root DOM element for the snapshot. Defaults to the whole page. - returns: <[Promise]<[Object]>> An [AXNode] object with the following properties: - `role` <[string]> The [role](https://www.w3.org/TR/wai-aria/#usage_intro). - `name` <[string]> A human readable name for the node. diff --git a/lib/Accessibility.js b/lib/Accessibility.js index f65642bf..419e59fd 100644 --- a/lib/Accessibility.js +++ b/lib/Accessibility.js @@ -60,20 +60,36 @@ class Accessibility { } /** - * @param {{interestingOnly?: boolean}=} options + * @param {{interestingOnly?: boolean, root?: ?Puppeteer.ElementHandle}=} options * @return {!Promise} */ async snapshot(options = {}) { - const {interestingOnly = true} = options; + const { + interestingOnly = true, + root = null, + } = options; const {nodes} = await this._client.send('Accessibility.getFullAXTree'); - const root = AXNode.createTree(nodes); + let backendNodeId = null; + if (root) { + const {node} = await this._client.send('DOM.describeNode', {objectId: root._remoteObject.objectId}); + backendNodeId = node.backendNodeId; + } + const defaultRoot = AXNode.createTree(nodes); + let needle = defaultRoot; + if (backendNodeId) { + needle = defaultRoot.find(node => node._payload.backendDOMNodeId === backendNodeId); + if (!needle) + return null; + } if (!interestingOnly) - return serializeTree(root)[0]; + return serializeTree(needle)[0]; /** @type {!Set} */ const interestingNodes = new Set(); - collectInterestingNodes(interestingNodes, root, false); - return serializeTree(root, interestingNodes)[0]; + collectInterestingNodes(interestingNodes, defaultRoot, false); + if (!interestingNodes.has(needle)) + return null; + return serializeTree(needle, interestingNodes)[0]; } } @@ -179,6 +195,21 @@ class AXNode { return this._cachedHasFocusableChild; } + /** + * @param {function(AXNode):boolean} predicate + * @return {?AXNode} + */ + find(predicate) { + if (predicate(this)) + return this; + for (const child of this._children) { + const result = child.find(predicate); + if (result) + return result; + } + return null; + } + /** * @return {boolean} */ diff --git a/test/accessibility.spec.js b/test/accessibility.spec.js index c48e59f3..a284d493 100644 --- a/test/accessibility.spec.js +++ b/test/accessibility.spec.js @@ -307,6 +307,63 @@ module.exports.addTests = function({testRunner, expect, FFOX}) { const snapshot = await page.accessibility.snapshot(); expect(snapshot.children[0]).toEqual(golden); }); + + describe_fails_ffox('root option', function() { + it('should work a button', async({page}) => { + await page.setContent(``); + + const button = await page.$('button'); + expect(await page.accessibility.snapshot({root: button})).toEqual({ + role: 'button', + name: 'My Button' + }); + }); + it('should work an input', async({page}) => { + await page.setContent(``); + + const input = await page.$('input'); + expect(await page.accessibility.snapshot({root: input})).toEqual({ + role: 'textbox', + name: 'My Input', + value: 'My Value' + }); + }); + it('should work a menu', async({page}) => { + await page.setContent(` +
+
First Item
+
Second Item
+
Third Item
+
+ `); + + const menu = await page.$('div[role="menu"]'); + expect(await page.accessibility.snapshot({root: menu})).toEqual({ + role: 'menu', + name: 'My Menu', + children: + [ { role: 'menuitem', name: 'First Item' }, + { role: 'menuitem', name: 'Second Item' }, + { role: 'menuitem', name: 'Third Item' } ] + }); + }); + it('should return null when the element is no longer in DOM', async({page}) => { + await page.setContent(``); + const button = await page.$('button'); + await page.$eval('button', button => button.remove()); + expect(await page.accessibility.snapshot({root: button})).toEqual(null); + }); + it('should support the interestingOnly option', async({page}) => { + await page.setContent(`
`); + const div = await page.$('div'); + expect(await page.accessibility.snapshot({root: div})).toEqual(null); + expect(await page.accessibility.snapshot({root: div, interestingOnly: false})).toEqual({ + role: 'GenericContainer', + name: '', + children: [ { role: 'button', name: 'My Button' } ] } + ); + }); + }); }); function findFocusedNode(node) { if (node.focused)