Add testing/scripts/OWNERS
[chromium-blink-merge.git] / chrome / renderer / resources / extensions / automation / automation_node.js
blobfb851690585913880b3ba70de3c1a2f1ea4dbd09
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.
5 var AutomationEvent = require('automationEvent').AutomationEvent;
6 var automationInternal =
7     require('binding').Binding.create('automationInternal').generate();
8 var IsInteractPermitted =
9     requireNative('automationInternal').IsInteractPermitted;
11 var lastError = require('lastError');
12 var logging = requireNative('logging');
13 var schema = requireNative('automationInternal').GetSchemaAdditions();
14 var utils = require('utils');
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 };
32 AutomationNodeImpl.prototype = {
33   id: -1,
34   role: '',
35   state: { busy: true },
36   isRootNode: false,
38   get root() {
39     return this.rootImpl.wrapper;
40   },
42   parent: function() {
43     return this.rootImpl.get(this.parentID);
44   },
46   firstChild: function() {
47     var node = this.rootImpl.get(this.childIds[0]);
48     return node;
49   },
51   lastChild: function() {
52     var childIds = this.childIds;
53     var node = this.rootImpl.get(childIds[childIds.length - 1]);
54     return node;
55   },
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   },
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   },
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   },
80   doDefault: function() {
81     this.performAction_('doDefault');
82   },
84   focus: function() {
85     this.performAction_('focus');
86   },
88   makeVisible: function() {
89     this.performAction_('makeVisible');
90   },
92   setSelection: function(startIndex, endIndex) {
93     this.performAction_('setSelection',
94                         { startIndex: startIndex,
95                           endIndex: endIndex });
96   },
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   },
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   },
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);
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   },
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   },
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   },
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   },
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   },
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;
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   },
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     }
203     // Check permissions.
204     if (!IsInteractPermitted()) {
205       throw new Error(actionType + ' requires {"desktop": true} or' +
206           ' {"interact": true} in the "automation" manifest key.');
207     }
209     automationInternal.performAction({ processID: this.rootImpl.processID,
210                                        routingID: this.rootImpl.routingID,
211                                        automationNodeID: this.id,
212                                        actionType: actionType },
213                                      opt_args || {});
214   }
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 }
227 var AutomationAttributeTypes = [
228   'boolAttributes',
229   'floatAttributes',
230   'htmlAttributes',
231   'intAttributes',
232   'intlistAttributes',
233   'stringAttributes'
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'
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
267  * AutomationRootNode.
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.
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.
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_ = {};
290 AutomationRootNodeImpl.prototype = {
291   __proto__: AutomationNodeImpl.prototype,
293   isRootNode: true,
295   get: function(id) {
296     if (id == undefined)
297       return undefined;
299     return this.axNodeDataCache_[id];
300   },
302   unserialize: function(update) {
303     var updateState = { pendingNodes: {}, newNodes: {} };
304     var oldRootId = this.id;
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     }
336     for (var i = 0; i < update.nodes.length; i++) {
337       if (!this.updateNode_(update.nodes[i], updateState))
338         return false;
339     }
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   },
353   destroy: function() {
354     this.dispatchEvent(schema.EventType.destroyed);
355     this.invalidate_(this.wrapper);
356   },
358   onAccessibilityEvent: function(eventParams) {
359     if (!this.unserialize(eventParams.update)) {
360       logging.WARNING('unserialization failed');
361       return false;
362     }
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.id);
372     }
373     return true;
374   },
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   },
392   invalidate_: function(node) {
393     if (!node)
394       return;
395     var children = node.children();
397     for (var i = 0, child; child = children[i]; i++)
398       this.invalidate_(child);
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   },
414   load: function(callback) {
415     // TODO(dtseng/aboxhall): Implement.
416     if (!this.loaded)
417       throw 'Unsupported state: root node is not loaded.';
419     setTimeout(callback, 0);
420   },
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     }
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;
454     return true;
455   },
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     }
493     return success;
494   },
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   },
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   },
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);
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;
578     var success = this.createNewChildren_(node,
579                                           nodeData.childIds,
580                                           updateState);
581     nodeImpl.childIds = nodeData.childIds;
582     this.axNodeDataCache_[nodeImpl.id] = node;
584     return success;
585   }
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'] });
610 var AutomationRootNode = utils.expose('AutomationRootNode',
611                                       AutomationRootNodeImpl,
612                                       { superclass: AutomationNode,
613                                         functions: ['load'],
614                                         readonly: ['loaded'] });
616 exports.AutomationNode = AutomationNode;
617 exports.AutomationRootNode = AutomationRootNode;