UNPKG

chrome-remote-interface-extra

Version:

Bringing a puppeteer like API and more to the chrome-remote-interface by cyrus-and

308 lines (275 loc) 8.28 kB
const util = require('util') class AXNode { /** * @param {Array<Object>} 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 } /** * @param {!Object} payload */ constructor (payload) { this._payload = payload /** @type {Array<AXNode>} */ this._children = [] this._richlyEditable = false this._editable = false this._focusable = false this._expanded = false this._name = this._payload.name ? this._payload.name.value : '' this._role = this._payload.role ? this._payload.role.value : 'Unknown' 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 } } /** * @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') 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 } /** * @param {function(AXNode):boolean} predicate * @return {?AXNode} */ find (predicate) { if (predicate(this)) return this for (let i = 0; i < this._children.length; i++) { const result = this._children[i].find(predicate) if (result) return result } return null } /** * @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<string>} */ 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<string>} */ 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<string>} */ 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' } /** @type {Array<string>} */ const numericalProperties = ['level', 'valuemax', 'valuemin'] for (const numericalProperty of numericalProperties) { if (!properties.has(numericalProperty)) continue node[numericalProperty] = properties.get(numericalProperty) } /** @type {Array<string>} */ 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 } /** * @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 } toJSON () { return this._payload } /** @ignore */ // eslint-disable-next-line space-before-function-paren [util.inspect.custom](depth, options) { if (depth < 0) { return options.stylize('[AXNode]', 'special') } const newOptions = Object.assign({}, options, { depth: options.depth == null ? null : options.depth - 1 }) const inner = util.inspect( { richlyEditable: this._richlyEditable, editable: this._editable, focusable: this._focusable, expanded: this._expanded, name: this._name, role: this._role, cachedHasFocusableChild: this._cachedHasFocusableChild, children: this._children }, newOptions ) return `${options.stylize('AXNode', 'special')} ${inner}` } } module.exports = AXNode