1 // Copyright 2014 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 var AutomationEvent = require('automationEvent').AutomationEvent; 6 var automationInternal = 7 require('binding').Binding.create('automationInternal').generate(); 8 var IsInteractPermitted = 9 requireNative('automationInternal').IsInteractPermitted; 10 11 var lastError = require('lastError'); 12 var logging = requireNative('logging'); 13 var schema = requireNative('automationInternal').GetSchemaAdditions(); 14 var utils = require('utils'); 15 16 /** 17 * A single node in the Automation tree. 18 * @param {AutomationRootNodeImpl} root The root of the tree. 19 * @constructor 20 */ 21 function AutomationNodeImpl(root) { 22 this.rootImpl = root; 23 this.childIds = []; 24 // Public attributes. No actual data gets set on this object. 25 this.attributes = {}; 26 // Internal object holding all attributes. 27 this.attributesInternal = {}; 28 this.listeners = {}; 29 this.location = { left: 0, top: 0, width: 0, height: 0 }; 30 } 31 32 AutomationNodeImpl.prototype = { 33 id: -1, 34 role: '', 35 state: { busy: true }, 36 isRootNode: false, 37 38 get root() { 39 return this.rootImpl.wrapper; 40 }, 41 42 parent: function() { 43 return this.rootImpl.get(this.parentID); 44 }, 45 46 firstChild: function() { 47 var node = this.rootImpl.get(this.childIds[0]); 48 return node; 49 }, 50 51 lastChild: function() { 52 var childIds = this.childIds; 53 var node = this.rootImpl.get(childIds[childIds.length - 1]); 54 return node; 55 }, 56 57 children: function() { 58 var children = []; 59 for (var i = 0, childID; childID = this.childIds[i]; i++) { 60 logging.CHECK(this.rootImpl.get(childID)); 61 children.push(this.rootImpl.get(childID)); 62 } 63 return children; 64 }, 65 66 previousSibling: function() { 67 var parent = this.parent(); 68 if (parent && this.indexInParent > 0) 69 return parent.children()[this.indexInParent - 1]; 70 return undefined; 71 }, 72 73 nextSibling: function() { 74 var parent = this.parent(); 75 if (parent && this.indexInParent < parent.children().length) 76 return parent.children()[this.indexInParent + 1]; 77 return undefined; 78 }, 79 80 doDefault: function() { 81 this.performAction_('doDefault'); 82 }, 83 84 focus: function() { 85 this.performAction_('focus'); 86 }, 87 88 makeVisible: function() { 89 this.performAction_('makeVisible'); 90 }, 91 92 setSelection: function(startIndex, endIndex) { 93 this.performAction_('setSelection', 94 { startIndex: startIndex, 95 endIndex: endIndex }); 96 }, 97 98 addEventListener: function(eventType, callback, capture) { 99 this.removeEventListener(eventType, callback); 100 if (!this.listeners[eventType]) 101 this.listeners[eventType] = []; 102 this.listeners[eventType].push({callback: callback, capture: !!capture}); 103 }, 104 105 // TODO(dtseng/aboxhall): Check this impl against spec. 106 removeEventListener: function(eventType, callback) { 107 if (this.listeners[eventType]) { 108 var listeners = this.listeners[eventType]; 109 for (var i = 0; i < listeners.length; i++) { 110 if (callback === listeners[i].callback) 111 listeners.splice(i, 1); 112 } 113 } 114 }, 115 116 dispatchEvent: function(eventType) { 117 var path = []; 118 var parent = this.parent(); 119 while (parent) { 120 path.push(parent); 121 // TODO(aboxhall/dtseng): handle unloaded parent node 122 parent = parent.parent(); 123 } 124 var event = new AutomationEvent(eventType, this.wrapper); 125 126 // Dispatch the event through the propagation path in three phases: 127 // - capturing: starting from the root and going down to the target's parent 128 // - targeting: dispatching the event on the target itself 129 // - bubbling: starting from the target's parent, going back up to the root. 130 // At any stage, a listener may call stopPropagation() on the event, which 131 // will immediately stop event propagation through this path. 132 if (this.dispatchEventAtCapturing_(event, path)) { 133 if (this.dispatchEventAtTargeting_(event, path)) 134 this.dispatchEventAtBubbling_(event, path); 135 } 136 }, 137 138 toString: function() { 139 return 'node id=' + this.id + 140 ' role=' + this.role + 141 ' state=' + $JSON.stringify(this.state) + 142 ' parentID=' + this.parentID + 143 ' childIds=' + $JSON.stringify(this.childIds) + 144 ' attributes=' + $JSON.stringify(this.attributes); 145 }, 146 147 dispatchEventAtCapturing_: function(event, path) { 148 privates(event).impl.eventPhase = Event.CAPTURING_PHASE; 149 for (var i = path.length - 1; i >= 0; i--) { 150 this.fireEventListeners_(path[i], event); 151 if (privates(event).impl.propagationStopped) 152 return false; 153 } 154 return true; 155 }, 156 157 dispatchEventAtTargeting_: function(event) { 158 privates(event).impl.eventPhase = Event.AT_TARGET; 159 this.fireEventListeners_(this.wrapper, event); 160 return !privates(event).impl.propagationStopped; 161 }, 162 163 dispatchEventAtBubbling_: function(event, path) { 164 privates(event).impl.eventPhase = Event.BUBBLING_PHASE; 165 for (var i = 0; i < path.length; i++) { 166 this.fireEventListeners_(path[i], event); 167 if (privates(event).impl.propagationStopped) 168 return false; 169 } 170 return true; 171 }, 172 173 fireEventListeners_: function(node, event) { 174 var nodeImpl = privates(node).impl; 175 var listeners = nodeImpl.listeners[event.type]; 176 if (!listeners) 177 return; 178 var eventPhase = event.eventPhase; 179 for (var i = 0; i < listeners.length; i++) { 180 if (eventPhase == Event.CAPTURING_PHASE && !listeners[i].capture) 181 continue; 182 if (eventPhase == Event.BUBBLING_PHASE && listeners[i].capture) 183 continue; 184 185 try { 186 listeners[i].callback(event); 187 } catch (e) { 188 console.error('Error in event handler for ' + event.type + 189 'during phase ' + eventPhase + ': ' + 190 e.message + '\nStack trace: ' + e.stack); 191 } 192 } 193 }, 194 195 performAction_: function(actionType, opt_args) { 196 // Not yet initialized. 197 if (this.rootImpl.processID === undefined || 198 this.rootImpl.routingID === undefined || 199 this.id === undefined) { 200 return; 201 } 202 203 // Check permissions. 204 if (!IsInteractPermitted()) { 205 throw new Error(actionType + ' requires {"desktop": true} or' + 206 ' {"interact": true} in the "automation" manifest key.'); 207 } 208 209 automationInternal.performAction({ processID: this.rootImpl.processID, 210 routingID: this.rootImpl.routingID, 211 automationNodeID: this.id, 212 actionType: actionType }, 213 opt_args || {}); 214 } 215 }; 216 217 // Maps an attribute to its default value in an invalidated node. 218 // These attributes are taken directly from the Automation idl. 219 var AutomationAttributeDefaults = { 220 'id': -1, 221 'role': '', 222 'state': {}, 223 'location': { left: 0, top: 0, width: 0, height: 0 } 224 }; 225 226 227 var AutomationAttributeTypes = [ 228 'boolAttributes', 229 'floatAttributes', 230 'htmlAttributes', 231 'intAttributes', 232 'intlistAttributes', 233 'stringAttributes' 234 ]; 235 236 237 /** 238 * Maps an attribute name to another attribute who's value is an id or an array 239 * of ids referencing an AutomationNode. 240 * @param {!Object.<string, string>} 241 * @const 242 */ 243 var ATTRIBUTE_NAME_TO_ATTRIBUTE_ID = { 244 'aria-activedescendant': 'activedescendantId', 245 'aria-controls': 'controlsIds', 246 'aria-describedby': 'describedbyIds', 247 'aria-flowto': 'flowtoIds', 248 'aria-labelledby': 'labelledbyIds', 249 'aria-owns': 'ownsIds' 250 }; 251 252 /** 253 * A set of attributes ignored in the automation API. 254 * @param {!Object.<string, boolean>} 255 * @const 256 */ 257 var ATTRIBUTE_BLACKLIST = {'activedescendantId': true, 258 'controlsIds': true, 259 'describedbyIds': true, 260 'flowtoIds': true, 261 'labelledbyIds': true, 262 'ownsIds': true 263 }; 264 265 266 /** 267 * AutomationRootNode. 268 * 269 * An AutomationRootNode is the javascript end of an AXTree living in the 270 * browser. AutomationRootNode handles unserializing incremental updates from 271 * the source AXTree. Each update contains node data that form a complete tree 272 * after applying the update. 273 * 274 * A brief note about ids used through this class. The source AXTree assigns 275 * unique ids per node and we use these ids to build a hash to the actual 276 * AutomationNode object. 277 * Thus, tree traversals amount to a lookup in our hash. 278 * 279 * The tree itself is identified by the process id and routing id of the 280 * renderer widget host. 281 * @constructor 282 */ 283 function AutomationRootNodeImpl(processID, routingID) { 284 AutomationNodeImpl.call(this, this); 285 this.processID = processID; 286 this.routingID = routingID; 287 this.axNodeDataCache_ = {}; 288 } 289 290 AutomationRootNodeImpl.prototype = { 291 __proto__: AutomationNodeImpl.prototype, 292 293 isRootNode: true, 294 295 get: function(id) { 296 if (id == undefined) 297 return undefined; 298 299 return this.axNodeDataCache_[id]; 300 }, 301 302 unserialize: function(update) { 303 var updateState = { pendingNodes: {}, newNodes: {} }; 304 var oldRootId = this.id; 305 306 if (update.nodeIdToClear < 0) { 307 logging.WARNING('Bad nodeIdToClear: ' + update.nodeIdToClear); 308 lastError.set('automation', 309 'Bad update received on automation tree', 310 null, 311 chrome); 312 return false; 313 } else if (update.nodeIdToClear > 0) { 314 var nodeToClear = this.axNodeDataCache_[update.nodeIdToClear]; 315 if (!nodeToClear) { 316 logging.WARNING('Bad nodeIdToClear: ' + update.nodeIdToClear + 317 ' (not in cache)'); 318 lastError.set('automation', 319 'Bad update received on automation tree', 320 null, 321 chrome); 322 return false; 323 } 324 if (nodeToClear === this.wrapper) { 325 this.invalidate_(nodeToClear); 326 } else { 327 var children = nodeToClear.children(); 328 for (var i = 0; i < children.length; i++) 329 this.invalidate_(children[i]); 330 var nodeToClearImpl = privates(nodeToClear).impl; 331 nodeToClearImpl.childIds = [] 332 updateState.pendingNodes[nodeToClearImpl.id] = nodeToClear; 333 } 334 } 335 336 for (var i = 0; i < update.nodes.length; i++) { 337 if (!this.updateNode_(update.nodes[i], updateState)) 338 return false; 339 } 340 341 if (Object.keys(updateState.pendingNodes).length > 0) { 342 logging.WARNING('Nodes left pending by the update: ' + 343 $JSON.stringify(updateState.pendingNodes)); 344 lastError.set('automation', 345 'Bad update received on automation tree', 346 null, 347 chrome); 348 return false; 349 } 350 return true; 351 }, 352 353 destroy: function() { 354 this.dispatchEvent(schema.EventType.destroyed); 355 this.invalidate_(this.wrapper); 356 }, 357 358 onAccessibilityEvent: function(eventParams) { 359 if (!this.unserialize(eventParams.update)) { 360 logging.WARNING('unserialization failed'); 361 return false; 362 } 363 364 var targetNode = this.get(eventParams.targetID); 365 if (targetNode) { 366 var targetNodeImpl = privates(targetNode).impl; 367 targetNodeImpl.dispatchEvent(eventParams.eventType); 368 } else { 369 logging.WARNING('Got ' + eventParams.eventType + 370 ' event on unknown node: ' + eventParams.targetID + 371 '; this: ' + this.toString()); 372 } 373 return true; 374 }, 375 376 toString: function() { 377 function toStringInternal(node, indent) { 378 if (!node) 379 return ''; 380 var output = 381 new Array(indent).join(' ') + 382 AutomationNodeImpl.prototype.toString.call(node) + 383 '\n'; 384 indent += 2; 385 for (var i = 0; i < node.children().length; i++) 386 output += toStringInternal(node.children()[i], indent); 387 return output; 388 } 389 return toStringInternal(this, 0); 390 }, 391 392 invalidate_: function(node) { 393 if (!node) 394 return; 395 var children = node.children(); 396 397 for (var i = 0, child; child = children[i]; i++) 398 this.invalidate_(child); 399 400 // Retrieve the internal AutomationNodeImpl instance for this node. 401 // This object is not accessible outside of bindings code, but we can access 402 // it here. 403 var nodeImpl = privates(node).impl; 404 var id = nodeImpl.id; 405 for (var key in AutomationAttributeDefaults) { 406 nodeImpl[key] = AutomationAttributeDefaults[key]; 407 } 408 nodeImpl.childIds = []; 409 nodeImpl.loaded = false; 410 nodeImpl.id = id; 411 delete this.axNodeDataCache_[id]; 412 }, 413 414 load: function(callback) { 415 // TODO(dtseng/aboxhall): Implement. 416 if (!this.loaded) 417 throw 'Unsupported state: root node is not loaded.'; 418 419 setTimeout(callback, 0); 420 }, 421 422 deleteOldChildren_: function(node, newChildIds) { 423 // Create a set of child ids in |src| for fast lookup, and return false 424 // if a duplicate is found; 425 var newChildIdSet = {}; 426 for (var i = 0; i < newChildIds.length; i++) { 427 var childId = newChildIds[i]; 428 if (newChildIdSet[childId]) { 429 logging.WARNING('Node ' + privates(node).impl.id + 430 ' has duplicate child id ' + childId); 431 lastError.set('automation', 432 'Bad update received on automation tree', 433 null, 434 chrome); 435 return false; 436 } 437 newChildIdSet[newChildIds[i]] = true; 438 } 439 440 // Delete the old children. 441 var nodeImpl = privates(node).impl; 442 var oldChildIds = nodeImpl.childIds; 443 for (var i = 0; i < oldChildIds.length;) { 444 var oldId = oldChildIds[i]; 445 if (!newChildIdSet[oldId]) { 446 this.invalidate_(this.axNodeDataCache_[oldId]); 447 oldChildIds.splice(i, 1); 448 } else { 449 i++; 450 } 451 } 452 nodeImpl.childIds = oldChildIds; 453 454 return true; 455 }, 456 457 createNewChildren_: function(node, newChildIds, updateState) { 458 logging.CHECK(node); 459 var success = true; 460 for (var i = 0; i < newChildIds.length; i++) { 461 var childId = newChildIds[i]; 462 var childNode = this.axNodeDataCache_[childId]; 463 if (childNode) { 464 if (childNode.parent() != node) { 465 var parentId = -1; 466 if (childNode.parent()) { 467 var parentImpl = privates(childNode.parent()).impl; 468 parentId = parentImpl.id; 469 } 470 // This is a serious error - nodes should never be reparented. 471 // If this case occurs, continue so this node isn't left in an 472 // inconsistent state, but return failure at the end. 473 logging.WARNING('Node ' + childId + ' reparented from ' + 474 parentId + ' to ' + privates(node).impl.id); 475 lastError.set('automation', 476 'Bad update received on automation tree', 477 null, 478 chrome); 479 success = false; 480 continue; 481 } 482 } else { 483 childNode = new AutomationNode(this); 484 this.axNodeDataCache_[childId] = childNode; 485 privates(childNode).impl.id = childId; 486 updateState.pendingNodes[childId] = childNode; 487 updateState.newNodes[childId] = childNode; 488 } 489 privates(childNode).impl.indexInParent = i; 490 privates(childNode).impl.parentID = privates(node).impl.id; 491 } 492 493 return success; 494 }, 495 496 setData_: function(node, nodeData) { 497 var nodeImpl = privates(node).impl; 498 for (var key in AutomationAttributeDefaults) { 499 if (key in nodeData) 500 nodeImpl[key] = nodeData[key]; 501 else 502 nodeImpl[key] = AutomationAttributeDefaults[key]; 503 } 504 for (var i = 0; i < AutomationAttributeTypes.length; i++) { 505 var attributeType = AutomationAttributeTypes[i]; 506 for (var attributeName in nodeData[attributeType]) { 507 nodeImpl.attributesInternal[attributeName] = 508 nodeData[attributeType][attributeName]; 509 if (ATTRIBUTE_BLACKLIST.hasOwnProperty(attributeName) || 510 nodeImpl.attributes.hasOwnProperty(attributeName)) { 511 continue; 512 } else if ( 513 ATTRIBUTE_NAME_TO_ATTRIBUTE_ID.hasOwnProperty(attributeName)) { 514 this.defineReadonlyAttribute_(nodeImpl, 515 attributeName, 516 true); 517 } else { 518 this.defineReadonlyAttribute_(nodeImpl, 519 attributeName); 520 } 521 } 522 } 523 }, 524 525 defineReadonlyAttribute_: function(node, attributeName, opt_isIDRef) { 526 $Object.defineProperty(node.attributes, attributeName, { 527 enumerable: true, 528 get: function() { 529 if (opt_isIDRef) { 530 var attributeId = node.attributesInternal[ 531 ATTRIBUTE_NAME_TO_ATTRIBUTE_ID[attributeName]]; 532 if (Array.isArray(attributeId)) { 533 return attributeId.map(function(current) { 534 return node.rootImpl.get(current); 535 }, this); 536 } 537 return node.rootImpl.get(attributeId); 538 } 539 return node.attributesInternal[attributeName]; 540 }.bind(this), 541 }); 542 }, 543 544 updateNode_: function(nodeData, updateState) { 545 var node = this.axNodeDataCache_[nodeData.id]; 546 var didUpdateRoot = false; 547 if (node) { 548 delete updateState.pendingNodes[privates(node).impl.id]; 549 } else { 550 if (nodeData.role != schema.RoleType.rootWebArea && 551 nodeData.role != schema.RoleType.desktop) { 552 logging.WARNING(String(nodeData.id) + 553 ' is not in the cache and not the new root.'); 554 lastError.set('automation', 555 'Bad update received on automation tree', 556 null, 557 chrome); 558 return false; 559 } 560 // |this| is an AutomationRootNodeImpl; retrieve the 561 // AutomationRootNode instance instead. 562 node = this.wrapper; 563 didUpdateRoot = true; 564 updateState.newNodes[this.id] = this.wrapper; 565 } 566 this.setData_(node, nodeData); 567 568 // TODO(aboxhall): send onChanged event? 569 logging.CHECK(node); 570 if (!this.deleteOldChildren_(node, nodeData.childIds)) { 571 if (didUpdateRoot) { 572 this.invalidate_(this.wrapper); 573 } 574 return false; 575 } 576 var nodeImpl = privates(node).impl; 577 578 var success = this.createNewChildren_(node, 579 nodeData.childIds, 580 updateState); 581 nodeImpl.childIds = nodeData.childIds; 582 this.axNodeDataCache_[nodeImpl.id] = node; 583 584 return success; 585 } 586 }; 587 588 589 var AutomationNode = utils.expose('AutomationNode', 590 AutomationNodeImpl, 591 { functions: ['parent', 592 'firstChild', 593 'lastChild', 594 'children', 595 'previousSibling', 596 'nextSibling', 597 'doDefault', 598 'focus', 599 'makeVisible', 600 'setSelection', 601 'addEventListener', 602 'removeEventListener'], 603 readonly: ['isRootNode', 604 'role', 605 'state', 606 'location', 607 'attributes', 608 'root'] }); 609 610 var AutomationRootNode = utils.expose('AutomationRootNode', 611 AutomationRootNodeImpl, 612 { superclass: AutomationNode, 613 functions: ['load'], 614 readonly: ['loaded'] }); 615 616 exports.AutomationNode = AutomationNode; 617 exports.AutomationRootNode = AutomationRootNode; 618