123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425 |
- /**
- * Copyright 2018 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.
- */
- /**
- * @typedef {Object} SerializedAXNode
- * @property {string} role
- *
- * @property {string=} name
- * @property {string|number=} value
- * @property {string=} description
- *
- * @property {string=} keyshortcuts
- * @property {string=} roledescription
- * @property {string=} valuetext
- *
- * @property {boolean=} disabled
- * @property {boolean=} expanded
- * @property {boolean=} focused
- * @property {boolean=} modal
- * @property {boolean=} multiline
- * @property {boolean=} multiselectable
- * @property {boolean=} readonly
- * @property {boolean=} required
- * @property {boolean=} selected
- *
- * @property {boolean|"mixed"=} checked
- * @property {boolean|"mixed"=} pressed
- *
- * @property {number=} level
- * @property {number=} valuemin
- * @property {number=} valuemax
- *
- * @property {string=} autocomplete
- * @property {string=} haspopup
- * @property {string=} invalid
- * @property {string=} orientation
- *
- * @property {Array<SerializedAXNode>=} children
- */
- class Accessibility {
- /**
- * @param {!Puppeteer.CDPSession} client
- */
- constructor(client) {
- this._client = client;
- }
- /**
- * @param {{interestingOnly?: boolean, root?: ?Puppeteer.ElementHandle}=} options
- * @return {!Promise<!SerializedAXNode>}
- */
- async snapshot(options = {}) {
- const {
- interestingOnly = true,
- root = null,
- } = options;
- const {nodes} = await this._client.send('Accessibility.getFullAXTree');
- 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(needle)[0];
- /** @type {!Set<!AXNode>} */
- const interestingNodes = new Set();
- collectInterestingNodes(interestingNodes, defaultRoot, false);
- if (!interestingNodes.has(needle))
- return null;
- return serializeTree(needle, interestingNodes)[0];
- }
- }
- /**
- * @param {!Set<!AXNode>} collection
- * @param {!AXNode} node
- * @param {boolean} insideControl
- */
- function collectInterestingNodes(collection, node, insideControl) {
- if (node.isInteresting(insideControl))
- collection.add(node);
- if (node.isLeafNode())
- return;
- insideControl = insideControl || node.isControl();
- for (const child of node._children)
- collectInterestingNodes(collection, child, insideControl);
- }
- /**
- * @param {!AXNode} node
- * @param {!Set<!AXNode>=} whitelistedNodes
- * @return {!Array<!SerializedAXNode>}
- */
- function serializeTree(node, whitelistedNodes) {
- /** @type {!Array<!SerializedAXNode>} */
- const children = [];
- for (const child of node._children)
- children.push(...serializeTree(child, whitelistedNodes));
- if (whitelistedNodes && !whitelistedNodes.has(node))
- return children;
- const serializedNode = node.serialize();
- if (children.length)
- serializedNode.children = children;
- return [serializedNode];
- }
- class AXNode {
- /**
- * @param {!Protocol.Accessibility.AXNode} payload
- */
- constructor(payload) {
- this._payload = payload;
- /** @type {!Array<!AXNode>} */
- this._children = [];
- this._richlyEditable = false;
- this._editable = false;
- this._focusable = false;
- this._expanded = false;
- this._hidden = false;
- this._name = this._payload.name ? this._payload.name.value : '';
- this._role = this._payload.role ? this._payload.role.value : 'Unknown';
- this._cachedHasFocusableChild;
- for (const property of this._payload.properties || []) {
- if (property.name === 'editable') {
- this._richlyEditable = property.value.value === 'richtext';
- this._editable = true;
- }
- if (property.name === 'focusable')
- this._focusable = property.value.value;
- if (property.name === 'expanded')
- this._expanded = property.value.value;
- if (property.name === 'hidden')
- this._hidden = property.value.value;
- }
- }
- /**
- * @return {boolean}
- */
- _isPlainTextField() {
- if (this._richlyEditable)
- return false;
- if (this._editable)
- return true;
- return this._role === 'textbox' || this._role === 'ComboBox' || this._role === 'searchbox';
- }
- /**
- * @return {boolean}
- */
- _isTextOnlyObject() {
- const role = this._role;
- return (role === 'LineBreak' || role === 'text' ||
- role === 'InlineTextBox');
- }
- /**
- * @return {boolean}
- */
- _hasFocusableChild() {
- if (this._cachedHasFocusableChild === undefined) {
- this._cachedHasFocusableChild = false;
- for (const child of this._children) {
- if (child._focusable || child._hasFocusableChild()) {
- this._cachedHasFocusableChild = true;
- break;
- }
- }
- }
- 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}
- */
- isLeafNode() {
- if (!this._children.length)
- return true;
- // These types of objects may have children that we use as internal
- // implementation details, but we want to expose them as leaves to platform
- // accessibility APIs because screen readers might be confused if they find
- // any children.
- if (this._isPlainTextField() || this._isTextOnlyObject())
- return true;
- // Roles whose children are only presentational according to the ARIA and
- // HTML5 Specs should be hidden from screen readers.
- // (Note that whilst ARIA buttons can have only presentational children, HTML5
- // buttons are allowed to have content.)
- switch (this._role) {
- case 'doc-cover':
- case 'graphics-symbol':
- case 'img':
- case 'Meter':
- case 'scrollbar':
- case 'slider':
- case 'separator':
- case 'progressbar':
- return true;
- default:
- break;
- }
- // Here and below: Android heuristics
- if (this._hasFocusableChild())
- return false;
- if (this._focusable && this._name)
- return true;
- if (this._role === 'heading' && this._name)
- return true;
- return false;
- }
- /**
- * @return {boolean}
- */
- isControl() {
- switch (this._role) {
- case 'button':
- case 'checkbox':
- case 'ColorWell':
- case 'combobox':
- case 'DisclosureTriangle':
- case 'listbox':
- case 'menu':
- case 'menubar':
- case 'menuitem':
- case 'menuitemcheckbox':
- case 'menuitemradio':
- case 'radio':
- case 'scrollbar':
- case 'searchbox':
- case 'slider':
- case 'spinbutton':
- case 'switch':
- case 'tab':
- case 'textbox':
- case 'tree':
- return true;
- default:
- return false;
- }
- }
- /**
- * @param {boolean} insideControl
- * @return {boolean}
- */
- isInteresting(insideControl) {
- const role = this._role;
- if (role === 'Ignored' || this._hidden)
- return false;
- if (this._focusable || this._richlyEditable)
- return true;
- // If it's not focusable but has a control role, then it's interesting.
- if (this.isControl())
- return true;
- // A non focusable child of a control is not interesting
- if (insideControl)
- return false;
- return this.isLeafNode() && !!this._name;
- }
- /**
- * @return {!SerializedAXNode}
- */
- serialize() {
- /** @type {!Map<string, number|string|boolean>} */
- const properties = new Map();
- for (const property of this._payload.properties || [])
- properties.set(property.name.toLowerCase(), property.value.value);
- if (this._payload.name)
- properties.set('name', this._payload.name.value);
- if (this._payload.value)
- properties.set('value', this._payload.value.value);
- if (this._payload.description)
- properties.set('description', this._payload.description.value);
- /** @type {SerializedAXNode} */
- const node = {
- role: this._role
- };
- /** @type {!Array<keyof SerializedAXNode>} */
- const userStringProperties = [
- 'name',
- 'value',
- 'description',
- 'keyshortcuts',
- 'roledescription',
- 'valuetext',
- ];
- for (const userStringProperty of userStringProperties) {
- if (!properties.has(userStringProperty))
- continue;
- node[userStringProperty] = properties.get(userStringProperty);
- }
- /** @type {!Array<keyof SerializedAXNode>} */
- const booleanProperties = [
- 'disabled',
- 'expanded',
- 'focused',
- 'modal',
- 'multiline',
- 'multiselectable',
- 'readonly',
- 'required',
- 'selected',
- ];
- for (const booleanProperty of booleanProperties) {
- // WebArea's treat focus differently than other nodes. They report whether their frame has focus,
- // not whether focus is specifically on the root node.
- if (booleanProperty === 'focused' && this._role === 'WebArea')
- continue;
- const value = properties.get(booleanProperty);
- if (!value)
- continue;
- node[booleanProperty] = value;
- }
- /** @type {!Array<keyof SerializedAXNode>} */
- const tristateProperties = [
- 'checked',
- 'pressed',
- ];
- for (const tristateProperty of tristateProperties) {
- if (!properties.has(tristateProperty))
- continue;
- const value = properties.get(tristateProperty);
- node[tristateProperty] = value === 'mixed' ? 'mixed' : value === 'true' ? true : false;
- }
- /** @type {!Array<keyof SerializedAXNode>} */
- const numericalProperties = [
- 'level',
- 'valuemax',
- 'valuemin',
- ];
- for (const numericalProperty of numericalProperties) {
- if (!properties.has(numericalProperty))
- continue;
- node[numericalProperty] = properties.get(numericalProperty);
- }
- /** @type {!Array<keyof SerializedAXNode>} */
- const tokenProperties = [
- 'autocomplete',
- 'haspopup',
- 'invalid',
- 'orientation',
- ];
- for (const tokenProperty of tokenProperties) {
- const value = properties.get(tokenProperty);
- if (!value || value === 'false')
- continue;
- node[tokenProperty] = value;
- }
- return node;
- }
- /**
- * @param {!Array<!Protocol.Accessibility.AXNode>} payloads
- * @return {!AXNode}
- */
- static createTree(payloads) {
- /** @type {!Map<string, !AXNode>} */
- const nodeById = new Map();
- for (const payload of payloads)
- nodeById.set(payload.nodeId, new AXNode(payload));
- for (const node of nodeById.values()) {
- for (const childId of node._payload.childIds || [])
- node._children.push(nodeById.get(childId));
- }
- return nodeById.values().next().value;
- }
- }
- module.exports = {Accessibility};
|