From d557b1cd92b3c47dae01aaaddd7c6be0ceb6cb94 Mon Sep 17 00:00:00 2001 From: Johan Bay Date: Tue, 22 Sep 2020 09:12:51 +0000 Subject: [PATCH] Add queryAXTree CDP command This command takes a DOM node and a name and/or role to search for. It searches the accessibility subtree of the given node for the name and role. The command also includes ignored nodes in its result. The command is the underlying primitive for a new aria-based querying mechanism in Puppeteer. This CL is a follow-up to crrev.com/c/2366896. The feedback given there has been addressed in this CL. Doc: https://docs.google.com/document/d/1-BUEUgqAZlh26fv9oLfy1QRr0MF3ifnxGh7-NDucg9s/ Change-Id: Ibdcdb230b5d1287ab009f2dd9d25a53932072d6b Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2398547 Commit-Queue: Johan Bay Reviewed-by: Sigurd Schneider Reviewed-by: Mathias Bynens Reviewed-by: Chris Hall Reviewed-by: Alice Boxhall Reviewed-by: Alex Rudenko Reviewed-by: Andrey Kosyakov Cr-Commit-Position: refs/heads/master@{#809251} --- .../devtools_protocol/browser_protocol.pdl | 22 ++ .../inspector_accessibility_agent.cc | 75 ++++++ .../inspector_accessibility_agent.h | 8 + .../query-axtree-oopif-expected.txt | 5 + .../inspector-protocol/query-axtree-oopif.js | 47 ++++ .../resources/iframe-accessible-name.html | 3 + ...ssibility-query-axtree-errors-expected.txt | 34 +++ .../accessibility-query-axtree-errors.js | 34 +++ .../accessibility-query-axtree-expected.txt | 223 ++++++++++++++++++ .../accessibility-query-axtree.js | 212 +++++++++++++++++ 10 files changed, 663 insertions(+) create mode 100644 third_party/blink/web_tests/http/tests/inspector-protocol/query-axtree-oopif-expected.txt create mode 100644 third_party/blink/web_tests/http/tests/inspector-protocol/query-axtree-oopif.js create mode 100644 third_party/blink/web_tests/http/tests/inspector-protocol/resources/iframe-accessible-name.html create mode 100644 third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree-errors-expected.txt create mode 100644 third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree-errors.js create mode 100644 third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree-expected.txt create mode 100644 third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree.js diff --git a/third_party/blink/public/devtools_protocol/browser_protocol.pdl b/third_party/blink/public/devtools_protocol/browser_protocol.pdl index 7de309308d7926..1800949a4b617b 100644 --- a/third_party/blink/public/devtools_protocol/browser_protocol.pdl +++ b/third_party/blink/public/devtools_protocol/browser_protocol.pdl @@ -207,6 +207,28 @@ experimental domain Accessibility returns array of AXNode nodes + # Query a DOM node's accessibility subtree for accessible name and role. + # This command computes the name and role for all nodes in the subtree, including those that are + # ignored for accessibility, and returns those that mactch the specified name and role. If no DOM + # node is specified, or the DOM node does not exist, the command returns an error. If neither + # `accessibleName` or `role` is specified, it returns all the accessibility nodes in the subtree. + experimental command queryAXTree + parameters + # Identifier of the node for the root to query. + optional DOM.NodeId nodeId + # Identifier of the backend node for the root to query. + optional DOM.BackendNodeId backendNodeId + # JavaScript object id of the node wrapper for the root to query. + optional Runtime.RemoteObjectId objectId + # Find nodes with this computed name. + optional string accessibleName + # Find nodes with this computed role. + optional string role + returns + # A list of `Accessibility.AXNode` matching the specified attributes, + # including nodes that are ignored for accessibility. + array of AXNode nodes + experimental domain Animation depends on Runtime depends on DOM diff --git a/third_party/blink/renderer/modules/accessibility/inspector_accessibility_agent.cc b/third_party/blink/renderer/modules/accessibility/inspector_accessibility_agent.cc index 15f3abc7d46fcc..f3358f5a7fc626 100644 --- a/third_party/blink/renderer/modules/accessibility/inspector_accessibility_agent.cc +++ b/third_party/blink/renderer/modules/accessibility/inspector_accessibility_agent.cc @@ -839,6 +839,81 @@ void InspectorAccessibilityAgent::AddChildren( } } +namespace { + +void setNameAndRole(const AXObject& ax_object, std::unique_ptr& node) { + ax::mojom::blink::Role role = ax_object.RoleValue(); + node->setRole(CreateRoleNameValue(role)); + AXObject::NameSources name_sources; + String computed_name = ax_object.GetName(&name_sources); + std::unique_ptr name = + CreateValue(computed_name, AXValueTypeEnum::ComputedString); + node->setName(std::move(name)); +} + +} // namespace + +Response InspectorAccessibilityAgent::queryAXTree( + Maybe dom_node_id, + Maybe backend_node_id, + Maybe object_id, + Maybe accessible_name, + Maybe role, + std::unique_ptr>* nodes) { + Node* root_dom_node = nullptr; + Response response = dom_agent_->AssertNode(dom_node_id, backend_node_id, + object_id, root_dom_node); + if (!response.IsSuccess()) + return response; + Document& document = root_dom_node->GetDocument(); + + document.UpdateStyleAndLayout(DocumentUpdateReason::kInspector); + DocumentLifecycle::DisallowTransitionScope disallow_transition( + document.Lifecycle()); + AXContext ax_context(document); + + *nodes = std::make_unique>(); + auto& cache = To(ax_context.GetAXObjectCache()); + AXObject* root_ax_node = cache.GetOrCreate(root_dom_node); + + auto sought_role = ax::mojom::blink::Role::kUnknown; + if (role.isJust()) + sought_role = AXObject::AriaRoleToWebCoreRole(role.fromJust()); + const String sought_name = accessible_name.fromMaybe(""); + + HeapVector> reachable; + reachable.push_back(root_ax_node); + + while (!reachable.IsEmpty()) { + AXObject* ax_object = reachable.back(); + reachable.pop_back(); + const AXObject::AXObjectVector& children = + ax_object->ChildrenIncludingIgnored(); + reachable.AppendRange(children.rbegin(), children.rend()); + + // if querying by name: skip if name of current object does not match. + if (accessible_name.isJust() && sought_name != ax_object->ComputedName()) + continue; + // if querying by role: skip if role of current object does not match. + if (role.isJust() && sought_role != ax_object->RoleValue()) + continue; + // both name and role are OK, so we can add current object to the result. + + if (ax_object->AccessibilityIsIgnored()) { + Node* dom_node = ax_object->GetNode(); + std::unique_ptr protocol_node = + BuildObjectForIgnoredNode(dom_node, ax_object, false, *nodes, cache); + setNameAndRole(*ax_object, protocol_node); + (*nodes)->push_back(std::move(protocol_node)); + } else { + (*nodes)->push_back( + BuildProtocolAXObject(*ax_object, nullptr, false, *nodes, cache)); + } + } + + return Response::Success(); +} + void InspectorAccessibilityAgent::EnableAndReset() { enabled_.Set(true); LocalFrame* frame = inspected_frames_->Root(); diff --git a/third_party/blink/renderer/modules/accessibility/inspector_accessibility_agent.h b/third_party/blink/renderer/modules/accessibility/inspector_accessibility_agent.h index 9a09e8770e55c2..ab973dad4567c6 100644 --- a/third_party/blink/renderer/modules/accessibility/inspector_accessibility_agent.h +++ b/third_party/blink/renderer/modules/accessibility/inspector_accessibility_agent.h @@ -47,6 +47,14 @@ class MODULES_EXPORT InspectorAccessibilityAgent protocol::Response getFullAXTree( std::unique_ptr>*) override; + protocol::Response queryAXTree( + protocol::Maybe dom_node_id, + protocol::Maybe backend_node_id, + protocol::Maybe object_id, + protocol::Maybe accessibleName, + protocol::Maybe role, + std::unique_ptr>*) + override; private: // Unconditionally enables the agent, even if |enabled_.Get()==true|. diff --git a/third_party/blink/web_tests/http/tests/inspector-protocol/query-axtree-oopif-expected.txt b/third_party/blink/web_tests/http/tests/inspector-protocol/query-axtree-oopif-expected.txt new file mode 100644 index 00000000000000..4adf0cb64e3e2e --- /dev/null +++ b/third_party/blink/web_tests/http/tests/inspector-protocol/query-axtree-oopif-expected.txt @@ -0,0 +1,5 @@ +Tests that we cannot find elements inside OOPIF by accessible name + +Running test: testGetIdsForSubtreeByAccessibleNameOOPIF +node1 + diff --git a/third_party/blink/web_tests/http/tests/inspector-protocol/query-axtree-oopif.js b/third_party/blink/web_tests/http/tests/inspector-protocol/query-axtree-oopif.js new file mode 100644 index 00000000000000..4ad6cc47440b5f --- /dev/null +++ b/third_party/blink/web_tests/http/tests/inspector-protocol/query-axtree-oopif.js @@ -0,0 +1,47 @@ +// Copyright 2020 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +(async function(testRunner) { + const {dp, session} = await testRunner.startHTML( + ` +

accessible name

+ `, + 'Tests that we cannot find elements inside OOPIF by accessible name'); + session.evaluate(` + const frame = document.createElement('iframe'); + frame.setAttribute('src', 'http://devtools.oopif.test:8000/inspector-protocol/resources/iframe-accessible-name.html'); + document.body.appendChild(frame); + `); + + + const documentResp = await dp.DOM.getDocument(); + const documentId = documentResp.result.root.nodeId; + + const documentResp2 = await dp.DOM.resolveNode({nodeId: documentId}); + const documentObjId = documentResp2.result.object.objectId; + + async function testGetIdsForSubtreeByAccessibleNameOOPIF() { + const response = await dp.Accessibility.queryAXTree( + {objectId: documentObjId, accessibleName: 'accessible name'}); + await logNodes(response.result.nodes); + } + + // copied from third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree.js + async function logNodes(axNodes) { + for (const axNode of axNodes) { + const backendNodeId = axNode.backendDOMNodeId; + const response = await dp.DOM.describeNode({backendNodeId}); + const node = response.result.node; + // we can only print ids for ELEMENT_NODEs, skip TEXT_NODEs + if (node.nodeType !== Node.ELEMENT_NODE) { + continue; + } + const nodeAttributes = node.attributes; + const idIndex = nodeAttributes.indexOf('id') + 1; + testRunner.log(nodeAttributes[idIndex]); + } + } + + testRunner.runTestSuite([testGetIdsForSubtreeByAccessibleNameOOPIF]); +}); diff --git a/third_party/blink/web_tests/http/tests/inspector-protocol/resources/iframe-accessible-name.html b/third_party/blink/web_tests/http/tests/inspector-protocol/resources/iframe-accessible-name.html new file mode 100644 index 00000000000000..0474b74068107d --- /dev/null +++ b/third_party/blink/web_tests/http/tests/inspector-protocol/resources/iframe-accessible-name.html @@ -0,0 +1,3 @@ + +Iframe with accessible name +

accessible name

diff --git a/third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree-errors-expected.txt b/third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree-errors-expected.txt new file mode 100644 index 00000000000000..c97aa192ba7d8d --- /dev/null +++ b/third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree-errors-expected.txt @@ -0,0 +1,34 @@ +Tests errors when finding DOM nodes by accessible name. +{ + error : { + code : -32000 + message : Either nodeId, backendNodeId or objectId must be specified + } + id : + sessionId : +} +{ + error : { + code : -32000 + message : Could not find node with given id + } + id : + sessionId : +} +{ + error : { + code : -32000 + message : No node found for given backend id + } + id : + sessionId : +} +{ + error : { + code : -32000 + message : Invalid remote object id + } + id : + sessionId : +} + diff --git a/third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree-errors.js b/third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree-errors.js new file mode 100644 index 00000000000000..319841798a84ef --- /dev/null +++ b/third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree-errors.js @@ -0,0 +1,34 @@ +// Copyright 2020 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +(async function(testRunner) { + const {dp} = await testRunner.startBlank( + 'Tests errors when finding DOM nodes by accessible name.'); + + const wrongObjectId = 'not-a-node'; + const wrongNodeId = -1; + + // Expected: error because no node is specified + testRunner.log(await dp.Accessibility.queryAXTree({ + accessibleName: 'name', + })); + // Expected: error because nodeId is wrong. + testRunner.log(await dp.Accessibility.queryAXTree({ + nodeId: wrongNodeId, + accessibleName: 'name', + })); + // Expected: error because backendNodeId is wrong. + testRunner.log(await dp.Accessibility.queryAXTree({ + backendNodeId: wrongNodeId, + accessibleName: 'name', + })); + + // Expected: error because object ID is wrong. + testRunner.log(await dp.Accessibility.queryAXTree({ + objectId: wrongObjectId, + accessibleName: 'name', + })); + + testRunner.completeTest(); +}); diff --git a/third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree-expected.txt b/third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree-expected.txt new file mode 100644 index 00000000000000..f86438e38c4d11 --- /dev/null +++ b/third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree-expected.txt @@ -0,0 +1,223 @@ +Test finding DOM nodes by accessible name + +Running test: dumpAXNodes +dump both an ignored and an unignored axnode +{ + backendDOMNodeId : + ignored : false + name : { + sources : [ + [0] : { + attribute : aria-labelledby + type : relatedElement + } + [1] : { + attribute : aria-label + type : attribute + } + [2] : { + type : contents + value : { + type : computedString + value : title + } + } + [3] : { + attribute : title + superseded : true + type : attribute + } + ] + type : computedString + value : title + } + nodeId : + properties : [ + [0] : { + name : level + value : { + type : integer + value : 2 + } + } + ] + role : { + type : role + value : heading + } +} +{ + backendDOMNodeId : + ignored : false + name : { + sources : [ + [0] : { + type : contents + value : { + type : computedString + value : title + } + } + ] + type : computedString + value : title + } + nodeId : + properties : [ + ] + role : { + type : role + value : text + } +} +{ + backendDOMNodeId : + ignored : false + nodeId : + properties : [ + ] + role : { + type : internalRole + value : InlineTextBox + } +} +{ + backendDOMNodeId : + ignored : true + ignoredReasons : [ + [0] : { + name : ariaHiddenElement + value : { + type : boolean + value : true + } + } + ] + name : { + type : computedString + value : title + } + nodeId : + role : { + type : role + value : heading + } +} +{ + backendDOMNodeId : + ignored : true + ignoredReasons : [ + [0] : { + name : ariaHiddenSubtree + value : { + relatedNodes : [ + [0] : { + backendDOMNodeId : + idref : hidden + } + ] + type : idref + } + } + ] + name : { + type : computedString + value : title + } + nodeId : + role : { + type : role + value : text + } +} +{ + backendDOMNodeId : + ignored : true + ignoredReasons : [ + [0] : { + name : notRendered + value : { + type : boolean + value : true + } + } + ] + name : { + type : computedString + value : title + } + nodeId : + role : { + type : role + value : heading + } +} + +Running test: testGetNodesForSubtreeByAccessibleName +find all elements with accessible name "foo" +node3 +node5 +node6 +node7 +find all elements with accessible name "foo" inside container +node5 +node6 +node7 +find all elements with accessible name "bar" +node1 +node2 +node8 +find all elements with accessible name "text content" +node10 +node11 +node13 +find all elements with accessible name "Accessible Name" +node20 +node21 +node23 +node24 +find all elements with accessible name "item1 item2 item3" +node30 + +Running test: testGetNodesForSubtreeByRole +find all elements with role "button" +node5 +node6 +node7 +node8 +node10 +node21 +find all elements with role "heading" +shown +hidden +unrendered +node11 +node13 +find all elements with role "treeitem" +node30 +node31 +node32 +node33 +node34 +find all ignored nodes with role "presentation" +node12 + +Running test: testGetNodesForSubtreeByAccessibleNameAndRole +find all elements with accessible name "foo" and role "button" +node5 +node6 +node7 +find all elements with accessible name "foo" and role "button" inside container +node5 +node6 +node7 +find all elements with accessible name "text content" and role "heading" +node11 +node13 +find all elements with accessible name "text content" and role "button" +node10 +find all elements with accessible name "Accessible Name" and role "textbox" +node23 +find all elements with accessible name "Accessible Name" and role "button" +node21 + diff --git a/third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree.js b/third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree.js new file mode 100644 index 00000000000000..33bfe18839333c --- /dev/null +++ b/third_party/blink/web_tests/inspector-protocol/accessibility/accessibility-query-axtree.js @@ -0,0 +1,212 @@ +// Copyright 2020 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +(async function(testRunner) { + const {dp} = await testRunner.startHTML( + ` +

title

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

text content

+ +

text content

+ + + + Accessible Name + + + + +
+ +
+
+
item1
+
item2
+
+
item3
+
+ +
+ `, + 'Test finding DOM nodes by accessible name'); + + + const documentResp = await dp.DOM.getDocument(); + const documentId = documentResp.result.root.nodeId; + + const containerResp = + await dp.DOM.querySelector({nodeId: documentId, selector: '.container'}); + const containerId = containerResp.result.nodeId; + + // gymnastics to get remoteObjectIds from nodes + const documentResp2 = await dp.DOM.resolveNode({nodeId: documentId}); + const documentObjId = documentResp2.result.object.objectId; + const containerResp2 = await dp.DOM.resolveNode({nodeId: containerId}); + const containerObjId = containerResp2.result.object.objectId; + + async function dumpAXNodes() { + testRunner.log('dump both an ignored and an unignored axnode'); + const response = await dp.Accessibility.queryAXTree( + {objectId: documentObjId, accessibleName: 'title'}); + for (const axnode of response.result.nodes) { + testRunner.log(axnode, null, ['nodeId', 'backendDOMNodeId']); + } + } + + async function testGetNodesForSubtreeByAccessibleName() { + let response; + + testRunner.log('find all elements with accessible name "foo"'); + response = await dp.Accessibility.queryAXTree( + {objectId: documentObjId, accessibleName: 'foo'}); + await logNodes(response.result.nodes); + + testRunner.log( + 'find all elements with accessible name "foo" inside container'); + response = await dp.Accessibility.queryAXTree( + {objectId: containerObjId, accessibleName: 'foo'}); + await logNodes(response.result.nodes); + + testRunner.log('find all elements with accessible name "bar"'); + response = await dp.Accessibility.queryAXTree( + {objectId: documentObjId, accessibleName: 'bar'}); + await logNodes(response.result.nodes); + + testRunner.log('find all elements with accessible name "text content"'); + response = await dp.Accessibility.queryAXTree( + {objectId: documentObjId, accessibleName: 'text content'}); + await logNodes(response.result.nodes); + + testRunner.log('find all elements with accessible name "Accessible Name"'); + response = await dp.Accessibility.queryAXTree( + {objectId: documentObjId, accessibleName: 'Accessible Name'}); + await logNodes(response.result.nodes); + + testRunner.log( + 'find all elements with accessible name "item1 item2 item3"'); + response = await dp.Accessibility.queryAXTree( + {objectId: documentObjId, accessibleName: 'item1 item2 item3'}); + await logNodes(response.result.nodes); + } + + async function testGetNodesForSubtreeByRole() { + let response; + + testRunner.log('find all elements with role "button"'); + response = await dp.Accessibility.queryAXTree( + {objectId: documentObjId, role: 'button'}); + await logNodes(response.result.nodes); + + testRunner.log('find all elements with role "heading"'); + response = await dp.Accessibility.queryAXTree( + {objectId: documentObjId, role: 'heading'}); + await logNodes(response.result.nodes); + + testRunner.log('find all elements with role "treeitem"'); + response = await dp.Accessibility.queryAXTree( + {objectId: documentObjId, role: 'treeitem'}); + await logNodes(response.result.nodes); + + testRunner.log('find all ignored nodes with role "presentation"'); + response = await dp.Accessibility.queryAXTree( + {objectId: documentObjId, role: 'presentation'}); + await logNodes(response.result.nodes); + } + + async function testGetNodesForSubtreeByAccessibleNameAndRole() { + let response; + + testRunner.log( + 'find all elements with accessible name "foo" and role "button"'); + response = await dp.Accessibility.queryAXTree( + {objectId: documentObjId, accessibleName: 'foo', role: 'button'}); + await logNodes(response.result.nodes); + + testRunner.log( + 'find all elements with accessible name "foo" and role "button" inside container'); + response = await dp.Accessibility.queryAXTree( + {objectId: containerObjId, accessibleName: 'foo', role: 'button'}); + await logNodes(response.result.nodes); + + testRunner.log( + 'find all elements with accessible name "text content" and role "heading"'); + response = await dp.Accessibility.queryAXTree({ + objectId: documentObjId, + accessibleName: 'text content', + role: 'heading' + }); + await logNodes(response.result.nodes); + + testRunner.log( + 'find all elements with accessible name "text content" and role "button"'); + response = await dp.Accessibility.queryAXTree({ + objectId: documentObjId, + accessibleName: 'text content', + role: 'button' + }); + await logNodes(response.result.nodes); + + testRunner.log( + 'find all elements with accessible name "Accessible Name" and role "textbox"'); + response = await dp.Accessibility.queryAXTree({ + objectId: documentObjId, + accessibleName: 'Accessible Name', + role: 'textbox' + }); + await logNodes(response.result.nodes); + + testRunner.log( + 'find all elements with accessible name "Accessible Name" and role "button"'); + response = await dp.Accessibility.queryAXTree({ + objectId: documentObjId, + accessibleName: 'Accessible Name', + role: 'button' + }); + await logNodes(response.result.nodes); + } + + async function logNodes(axNodes) { + for (const axNode of axNodes) { + const backendNodeId = axNode.backendDOMNodeId; + const response = await dp.DOM.describeNode({backendNodeId}); + const node = response.result.node; + // we can only print ids for ELEMENT_NODEs, skip TEXT_NODEs + if (node.nodeType !== Node.ELEMENT_NODE) { + continue; + } + const nodeAttributes = node.attributes; + const idIndex = nodeAttributes.indexOf('id') + 1; + testRunner.log(nodeAttributes[idIndex]); + } + } + + testRunner.runTestSuite([ + dumpAXNodes, + testGetNodesForSubtreeByAccessibleName, + testGetNodesForSubtreeByRole, + testGetNodesForSubtreeByAccessibleNameAndRole, + ]); +});