Home | History | Annotate | Download | only in automation
      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