// Licensed to the Software Freedom Conservancy (SFC) under one1// or more contributor license agreements. See the NOTICE file2// distributed with this work for additional information3// regarding copyright ownership. The SFC licenses this file4// to you under the Apache License, Version 2.0 (the5// "License"); you may not use this file except in compliance6// with the License. You may obtain a copy of the License at7//8// http://www.apache.org/licenses/LICENSE-2.09//10// Unless required by applicable law or agreed to in writing,11// software distributed under the License is distributed on an12// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY13// KIND, either express or implied. See the License for the14// specific language governing permissions and limitations15// under the License.1617/**18* @fileoverview Browser atom for injecting JavaScript into the page under19* test. There is no point in using this atom directly from JavaScript.20* Instead, it is intended to be used in its compiled form when injecting21* script from another language (e.g. C++).22*23* TODO: Add an example24*/2526goog.provide('bot.inject');27goog.provide('bot.inject.cache');2829goog.require('bot');30goog.require('bot.Error');31goog.require('bot.ErrorCode');32goog.require('bot.json');33/**34* @suppress {extraRequire} Used as a forward declaration which causes35* compilation errors if missing.36*/37goog.require('bot.response.ResponseObject');38goog.require('goog.array');39goog.require('goog.dom.NodeType');40goog.require('goog.object');41goog.require('goog.userAgent');424344/**45* Type definition for the WebDriver's JSON wire protocol representation46* of a DOM element.47* @typedef {{ELEMENT: string}}48* @see bot.inject.ELEMENT_KEY49* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol50*/51bot.inject.JsonElement;525354/**55* Type definition for a cached Window object that can be referenced in56* WebDriver's JSON wire protocol. Note, this is a non-standard57* representation.58* @typedef {{WINDOW: string}}59* @see bot.inject.WINDOW_KEY60*/61bot.inject.JsonWindow;626364/**65* Key used to identify DOM elements in the WebDriver wire protocol.66* @type {string}67* @const68* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol69*/70bot.inject.ELEMENT_KEY = 'ELEMENT';717273/**74* Key used to identify Window objects in the WebDriver wire protocol.75* @type {string}76* @const77*/78bot.inject.WINDOW_KEY = 'WINDOW';798081/**82* Converts an element to a JSON friendly value so that it can be83* stringified for transmission to the injector. Values are modified as84* follows:85* <ul>86* <li>booleans, numbers, strings, and null are returned as is</li>87* <li>undefined values are returned as null</li>88* <li>functions are returned as a string</li>89* <li>each element in an array is recursively processed</li>90* <li>DOM Elements are wrapped in object-literals as dictated by the91* WebDriver wire protocol</li>92* <li>all other objects will be treated as hash-maps, and will be93* recursively processed for any string and number key types (all94* other key types are discarded as they cannot be converted to JSON).95* </ul>96*97* @param {*} value The value to make JSON friendly.98* @return {*} The JSON friendly value.99* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol100*/101bot.inject.wrapValue = function (value) {102var _wrap = function (value, seen) {103switch (goog.typeOf(value)) {104case 'string':105case 'number':106case 'boolean':107return value;108109case 'function':110return value.toString();111112case 'array':113return goog.array.map(/**@type {IArrayLike}*/(value),114function (v) { return _wrap(v, seen); });115116case 'object':117// Since {*} expands to {Object|boolean|number|string|undefined}, the118// JSCompiler complains that it is too broad a type for the remainder of119// this block where {!Object} is expected. Downcast to prevent generating120// a ton of compiler warnings.121value = /**@type {!Object}*/ (value);122if (seen.indexOf(value) >= 0) {123throw new bot.Error(bot.ErrorCode.JAVASCRIPT_ERROR,124'Recursive object cannot be transferred');125}126127// Sniff out DOM elements. We're using duck-typing instead of an128// instanceof check since the instanceof might not always work129// (e.g. if the value originated from another Firefox component)130if (goog.object.containsKey(value, 'nodeType') &&131(value['nodeType'] == goog.dom.NodeType.ELEMENT ||132value['nodeType'] == goog.dom.NodeType.DOCUMENT)) {133var ret = {};134ret[bot.inject.ELEMENT_KEY] =135bot.inject.cache.addElement(/**@type {!Element}*/(value));136return ret;137}138139// Check if this is a Window140if (goog.object.containsKey(value, 'document')) {141var ret = {};142ret[bot.inject.WINDOW_KEY] =143bot.inject.cache.addElement(/**@type{!Window}*/(value));144return ret;145}146147seen.push(value);148if (goog.isArrayLike(value)) {149return goog.array.map(/**@type {IArrayLike}*/(value),150function (v) { return _wrap(v, seen); });151}152153var filtered = goog.object.filter(value, function (val, key) {154return goog.isNumber(key) || goog.isString(key);155});156return goog.object.map(filtered, function (v) { return _wrap(v, seen); });157158default: // goog.typeOf(value) == 'undefined' || 'null'159return null;160}161};162return _wrap(value, []);163};164165166/**167* Unwraps any DOM element's encoded in the given `value`.168* @param {*} value The value to unwrap.169* @param {Document=} opt_doc The document whose cache to retrieve wrapped170* elements from. Defaults to the current document.171* @return {*} The unwrapped value.172*/173bot.inject.unwrapValue = function (value, opt_doc) {174if (goog.isArray(value)) {175return goog.array.map(/**@type {IArrayLike}*/(value),176function (v) { return bot.inject.unwrapValue(v, opt_doc); });177} else if (goog.isObject(value)) {178if (typeof value == 'function') {179return value;180}181182if (goog.object.containsKey(value, bot.inject.ELEMENT_KEY)) {183return bot.inject.cache.getElement(value[bot.inject.ELEMENT_KEY],184opt_doc);185}186187if (goog.object.containsKey(value, bot.inject.WINDOW_KEY)) {188return bot.inject.cache.getElement(value[bot.inject.WINDOW_KEY],189opt_doc);190}191192return goog.object.map(value, function (val) {193return bot.inject.unwrapValue(val, opt_doc);194});195}196return value;197};198199200/**201* Recompiles `fn` in the context of another window so that the202* correct symbol table is used when the function is executed. This203* function assumes the `fn` can be decompiled to its source using204* `Function.prototype.toString` and that it only refers to symbols205* defined in the target window's context.206*207* @param {!(Function|string)} fn Either the function that should be208* recompiled, or a string defining the body of an anonymous function209* that should be compiled in the target window's context.210* @param {!Window} theWindow The window to recompile the function in.211* @return {!Function} The recompiled function.212* @private213*/214bot.inject.recompileFunction_ = function (fn, theWindow) {215if (goog.isString(fn)) {216try {217return new theWindow['Function'](fn);218} catch (ex) {219// Try to recover if in IE5-quirks mode220// Need to initialize the script engine on the passed-in window221if (goog.userAgent.IE && theWindow.execScript) {222theWindow.execScript(';');223return new theWindow['Function'](fn);224}225throw ex;226}227}228return theWindow == window ? fn : new theWindow['Function'](229'return (' + fn + ').apply(null,arguments);');230};231232233/**234* Executes an injected script. This function should never be called from235* within JavaScript itself. Instead, it is used from an external source that236* is injecting a script for execution.237*238* <p/>For example, in a WebDriver Java test, one might have:239* <pre><code>240* Object result = ((JavascriptExecutor) driver).executeScript(241* "return arguments[0] + arguments[1];", 1, 2);242* </code></pre>243*244* <p/>Once transmitted to the driver, this command would be injected into the245* page for evaluation as:246* <pre><code>247* bot.inject.executeScript(248* function() {return arguments[0] + arguments[1];},249* [1, 2]);250* </code></pre>251*252* <p/>The details of how this actually gets injected for evaluation is left253* as an implementation detail for clients of this library.254*255* @param {!(Function|string)} fn Either the function to execute, or a string256* defining the body of an anonymous function that should be executed. This257* function should only contain references to symbols defined in the context258* of the target window (`opt_window`). Any references to symbols259* defined in this context will likely generate a ReferenceError.260* @param {Array.<*>} args An array of wrapped script arguments, as defined by261* the WebDriver wire protocol.262* @param {boolean=} opt_stringify Whether the result should be returned as a263* serialized JSON string.264* @param {!Window=} opt_window The window in whose context the function should265* be invoked; defaults to the current window.266* @return {!(string|bot.response.ResponseObject)} The response object. If267* opt_stringify is true, the result will be serialized and returned in268* string format.269*/270bot.inject.executeScript = function (fn, args, opt_stringify, opt_window) {271var win = opt_window || bot.getWindow();272var ret;273try {274fn = bot.inject.recompileFunction_(fn, win);275var unwrappedArgs = /**@type {Object}*/ (bot.inject.unwrapValue(args,276win.document));277ret = bot.inject.wrapResponse(fn.apply(null, unwrappedArgs));278} catch (ex) {279ret = bot.inject.wrapError(ex);280}281return opt_stringify ? bot.json.stringify(ret) : ret;282};283284285/**286* Executes an injected script, which is expected to finish asynchronously287* before the given `timeout`. When the script finishes or an error288* occurs, the given `onDone` callback will be invoked. This callback289* will have a single argument, a {@link bot.response.ResponseObject} object.290*291* The script signals its completion by invoking a supplied callback given292* as its last argument. The callback may be invoked with a single value.293*294* The script timeout event will be scheduled with the provided window,295* ensuring the timeout is synchronized with that window's event queue.296* Furthermore, asynchronous scripts do not work across new page loads; if an297* "unload" event is fired on the window while an asynchronous script is298* pending, the script will be aborted and an error will be returned.299*300* Like `bot.inject.executeScript`, this function should only be called301* from an external source. It handles wrapping and unwrapping of input/output302* values.303*304* @param {(!Function|string)} fn Either the function to execute, or a string305* defining the body of an anonymous function that should be executed. This306* function should only contain references to symbols defined in the context307* of the target window (`opt_window`). Any references to symbols308* defined in this context will likely generate a ReferenceError.309* @param {Array.<*>} args An array of wrapped script arguments, as defined by310* the WebDriver wire protocol.311* @param {number} timeout The amount of time, in milliseconds, the script312* should be permitted to run; must be non-negative.313* @param {function(string)|function(!bot.response.ResponseObject)} onDone314* The function to call when the given `fn` invokes its callback,315* or when an exception or timeout occurs. This will always be called.316* @param {boolean=} opt_stringify Whether the result should be returned as a317* serialized JSON string.318* @param {!Window=} opt_window The window to synchronize the script with;319* defaults to the current window.320*/321bot.inject.executeAsyncScript = function (fn, args, timeout, onDone,322opt_stringify, opt_window) {323var win = opt_window || window;324var timeoutId;325var responseSent = false;326327function sendResponse(status, value) {328if (!responseSent) {329if (win.removeEventListener) {330win.removeEventListener('unload', onunload, true);331} else {332win.detachEvent('onunload', onunload);333}334335win.clearTimeout(timeoutId);336if (status != bot.ErrorCode.SUCCESS) {337var err = new bot.Error(status, value.message || value + '');338err.stack = value.stack;339value = bot.inject.wrapError(err);340} else {341value = bot.inject.wrapResponse(value);342}343onDone(opt_stringify ? bot.json.stringify(value) : value);344responseSent = true;345}346}347var sendError = goog.partial(sendResponse, bot.ErrorCode.UNKNOWN_ERROR);348349if (win.closed) {350sendError('Unable to execute script; the target window is closed.');351return;352}353354fn = bot.inject.recompileFunction_(fn, win);355356args = /** @type {Array.<*>} */ (bot.inject.unwrapValue(args, win.document));357args.push(goog.partial(sendResponse, bot.ErrorCode.SUCCESS));358359if (win.addEventListener) {360win.addEventListener('unload', onunload, true);361} else {362win.attachEvent('onunload', onunload);363}364365var startTime = goog.now();366try {367fn.apply(win, args);368369// Register our timeout *after* the function has been invoked. This will370// ensure we don't timeout on a function that invokes its callback after371// a 0-based timeout.372timeoutId = win.setTimeout(function () {373sendResponse(bot.ErrorCode.SCRIPT_TIMEOUT,374Error('Timed out waiting for asynchronous script result ' +375'after ' + (goog.now() - startTime) + ' ms'));376}, Math.max(0, timeout));377} catch (ex) {378sendResponse(ex.code || bot.ErrorCode.UNKNOWN_ERROR, ex);379}380381function onunload() {382sendResponse(bot.ErrorCode.UNKNOWN_ERROR,383Error('Detected a page unload event; asynchronous script ' +384'execution does not work across page loads.'));385}386};387388389/**390* Wraps the response to an injected script that executed successfully so it391* can be JSON-ified for transmission to the process that injected this392* script.393* @param {*} value The script result.394* @return {{status:bot.ErrorCode,value:*}} The wrapped value.395* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#responses396*/397bot.inject.wrapResponse = function (value) {398return {399'status': bot.ErrorCode.SUCCESS,400'value': bot.inject.wrapValue(value)401};402};403404405/**406* Wraps a JavaScript error in an object-literal so that it can be JSON-ified407* for transmission to the process that injected this script.408* @param {Error} err The error to wrap.409* @return {{status:bot.ErrorCode,value:*}} The wrapped error object.410* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#failed-commands411*/412bot.inject.wrapError = function (err) {413// TODO: Parse stackTrace414return {415'status': goog.object.containsKey(err, 'code') ?416err['code'] : bot.ErrorCode.UNKNOWN_ERROR,417// TODO: Parse stackTrace418'value': {419'message': err.message420}421};422};423424425/**426* The property key used to store the element cache on the DOCUMENT node427* when it is injected into the page. Since compiling each browser atom results428* in a different symbol table, we must use this known key to access the cache.429* This ensures the same object is used between injections of different atoms.430* @private {string}431* @const432*/433bot.inject.cache.CACHE_KEY_ = '$wdc_';434435436/**437* The prefix for each key stored in an cache.438* @type {string}439* @const440*/441bot.inject.cache.ELEMENT_KEY_PREFIX = ':wdc:';442443444/**445* Retrieves the cache object for the given window. Will initialize the cache446* if it does not yet exist.447* @param {Document=} opt_doc The document whose cache to retrieve. Defaults to448* the current document.449* @return {Object.<string, (Element|Window)>} The cache object.450* @private451*/452bot.inject.cache.getCache_ = function (opt_doc) {453var doc = opt_doc || document;454var cache = doc[bot.inject.cache.CACHE_KEY_];455if (!cache) {456cache = doc[bot.inject.cache.CACHE_KEY_] = {};457// Store the counter used for generated IDs in the cache so that it gets458// reset whenever the cache does.459cache.nextId = goog.now();460}461// Sometimes the nextId does not get initialized and returns NaN462// TODO: Generate UID on the fly instead.463if (!cache.nextId) {464cache.nextId = goog.now();465}466return cache;467};468469470/**471* Adds an element to its ownerDocument's cache.472* @param {(Element|Window)} el The element or Window object to add.473* @return {string} The key generated for the cached element.474*/475bot.inject.cache.addElement = function (el) {476// Check if the element already exists in the cache.477var cache = bot.inject.cache.getCache_(el.ownerDocument);478var id = goog.object.findKey(cache, function (value) {479return value == el;480});481if (!id) {482id = bot.inject.cache.ELEMENT_KEY_PREFIX + cache.nextId++;483cache[id] = el;484}485return id;486};487488489/**490* Retrieves an element from the cache. Will verify that the element is491* still attached to the DOM before returning.492* @param {string} key The element's key in the cache.493* @param {Document=} opt_doc The document whose cache to retrieve the element494* from. Defaults to the current document.495* @return {Element|Window} The cached element.496*/497bot.inject.cache.getElement = function (key, opt_doc) {498key = decodeURIComponent(key);499var doc = opt_doc || document;500var cache = bot.inject.cache.getCache_(doc);501if (!goog.object.containsKey(cache, key)) {502// Throw STALE_ELEMENT_REFERENCE instead of NO_SUCH_ELEMENT since the503// key may have been defined by a prior document's cache.504throw new bot.Error(bot.ErrorCode.STALE_ELEMENT_REFERENCE,505'Element does not exist in cache');506}507508var el = cache[key];509510// If this is a Window check if it's closed511if (goog.object.containsKey(el, 'setInterval')) {512if (el.closed) {513delete cache[key];514throw new bot.Error(bot.ErrorCode.NO_SUCH_WINDOW,515'Window has been closed.');516}517return el;518}519520// Make sure the element is still attached to the DOM before returning.521var node = el;522while (node) {523if (node == doc.documentElement) {524return el;525}526if (node.host && node.nodeType === 11) {527node = node.host;528}529node = node.parentNode;530}531delete cache[key];532throw new bot.Error(bot.ErrorCode.STALE_ELEMENT_REFERENCE,533'Element is no longer attached to the DOM');534};535536537