// 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');42goog.require('goog.utils');434445/**46* Type definition for the WebDriver's JSON wire protocol representation47* of a DOM element.48* @typedef {{ELEMENT: string}}49* @see bot.inject.ELEMENT_KEY50* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol51*/52bot.inject.JsonElement;535455/**56* Type definition for a cached Window object that can be referenced in57* WebDriver's JSON wire protocol. Note, this is a non-standard58* representation.59* @typedef {{WINDOW: string}}60* @see bot.inject.WINDOW_KEY61*/62bot.inject.JsonWindow;636465/**66* Key used to identify DOM elements in the WebDriver wire protocol.67* @type {string}68* @const69* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol70*/71bot.inject.ELEMENT_KEY = 'ELEMENT';727374/**75* Key used to identify Window objects in the WebDriver wire protocol.76* @type {string}77* @const78*/79bot.inject.WINDOW_KEY = 'WINDOW';808182/**83* Converts an element to a JSON friendly value so that it can be84* stringified for transmission to the injector. Values are modified as85* follows:86* <ul>87* <li>booleans, numbers, strings, and null are returned as is</li>88* <li>undefined values are returned as null</li>89* <li>functions are returned as a string</li>90* <li>each element in an array is recursively processed</li>91* <li>DOM Elements are wrapped in object-literals as dictated by the92* WebDriver wire protocol</li>93* <li>all other objects will be treated as hash-maps, and will be94* recursively processed for any string and number key types (all95* other key types are discarded as they cannot be converted to JSON).96* </ul>97*98* @param {*} value The value to make JSON friendly.99* @return {*} The JSON friendly value.100* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol101*/102bot.inject.wrapValue = function (value) {103var _wrap = function (value, seen) {104switch (goog.utils.typeOf(value)) {105case 'string':106case 'number':107case 'boolean':108return value;109110case 'function':111return value.toString();112113case 'array':114return goog.array.map(/**@type {IArrayLike}*/(value),115function (v) { return _wrap(v, seen); });116117case 'object':118// Since {*} expands to {Object|boolean|number|string|undefined}, the119// JSCompiler complains that it is too broad a type for the remainder of120// this block where {!Object} is expected. Downcast to prevent generating121// a ton of compiler warnings.122value = /**@type {!Object}*/ (value);123if (seen.indexOf(value) >= 0) {124throw new bot.Error(bot.ErrorCode.JAVASCRIPT_ERROR,125'Recursive object cannot be transferred');126}127128// Sniff out DOM elements. We're using duck-typing instead of an129// instanceof check since the instanceof might not always work130// (e.g. if the value originated from another Firefox component)131if (goog.object.containsKey(value, 'nodeType') &&132(value['nodeType'] == goog.dom.NodeType.ELEMENT ||133value['nodeType'] == goog.dom.NodeType.DOCUMENT)) {134var ret = {};135ret[bot.inject.ELEMENT_KEY] =136bot.inject.cache.addElement(/**@type {!Element}*/(value));137return ret;138}139140// Check if this is a Window141if (goog.object.containsKey(value, 'document')) {142var ret = {};143ret[bot.inject.WINDOW_KEY] =144bot.inject.cache.addElement(/**@type{!Window}*/(value));145return ret;146}147148seen.push(value);149if (goog.utils.isArrayLike(value)) {150return goog.array.map(/**@type {IArrayLike}*/(value),151function (v) { return _wrap(v, seen); });152}153154var filtered = goog.object.filter(value, function (val, key) {155return typeof key === 'number' || typeof key === 'string';156});157return goog.object.map(filtered, function (v) { return _wrap(v, seen); });158159default: // goog.typeOf(value) == 'undefined' || 'null'160return null;161}162};163return _wrap(value, []);164};165166167/**168* Unwraps any DOM element's encoded in the given `value`.169* @param {*} value The value to unwrap.170* @param {Document=} opt_doc The document whose cache to retrieve wrapped171* elements from. Defaults to the current document.172* @return {*} The unwrapped value.173*/174bot.inject.unwrapValue = function (value, opt_doc) {175if (Array.isArray(value)) {176return goog.array.map(/**@type {IArrayLike}*/(value),177function (v) { return bot.inject.unwrapValue(v, opt_doc); });178} else if (goog.utils.isObject(value)) {179if (typeof value == 'function') {180return value;181}182183var obj = /** @type {!Object} */ (value);184if (goog.object.containsKey(obj, bot.inject.ELEMENT_KEY)) {185return bot.inject.cache.getElement(obj[bot.inject.ELEMENT_KEY],186opt_doc);187}188189if (goog.object.containsKey(obj, bot.inject.WINDOW_KEY)) {190return bot.inject.cache.getElement(obj[bot.inject.WINDOW_KEY],191opt_doc);192}193194return goog.object.map(obj, function (val) {195return bot.inject.unwrapValue(val, opt_doc);196});197}198return value;199};200201202/**203* Recompiles `fn` in the context of another window so that the204* correct symbol table is used when the function is executed. This205* function assumes the `fn` can be decompiled to its source using206* `Function.prototype.toString` and that it only refers to symbols207* defined in the target window's context.208*209* @param {!(Function|string)} fn Either the function that should be210* recompiled, or a string defining the body of an anonymous function211* that should be compiled in the target window's context.212* @param {!Window} theWindow The window to recompile the function in.213* @return {!Function} The recompiled function.214* @private215*/216bot.inject.recompileFunction_ = function (fn, theWindow) {217if (typeof fn === 'string') {218try {219return new theWindow['Function'](fn);220} catch (ex) {221// Try to recover if in IE5-quirks mode222// Need to initialize the script engine on the passed-in window223if (goog.userAgent.IE && theWindow.execScript) {224theWindow.execScript(';');225return new theWindow['Function'](fn);226}227throw ex;228}229}230return theWindow == window ? fn : new theWindow['Function'](231'return (' + fn + ').apply(null,arguments);');232};233234235/**236* Executes an injected script. This function should never be called from237* within JavaScript itself. Instead, it is used from an external source that238* is injecting a script for execution.239*240* <p/>For example, in a WebDriver Java test, one might have:241* <pre><code>242* Object result = ((JavascriptExecutor) driver).executeScript(243* "return arguments[0] + arguments[1];", 1, 2);244* </code></pre>245*246* <p/>Once transmitted to the driver, this command would be injected into the247* page for evaluation as:248* <pre><code>249* bot.inject.executeScript(250* function() {return arguments[0] + arguments[1];},251* [1, 2]);252* </code></pre>253*254* <p/>The details of how this actually gets injected for evaluation is left255* as an implementation detail for clients of this library.256*257* @param {!(Function|string)} fn Either the function to execute, or a string258* defining the body of an anonymous function that should be executed. This259* function should only contain references to symbols defined in the context260* of the target window (`opt_window`). Any references to symbols261* defined in this context will likely generate a ReferenceError.262* @param {Array.<*>} args An array of wrapped script arguments, as defined by263* the WebDriver wire protocol.264* @param {boolean=} opt_stringify Whether the result should be returned as a265* serialized JSON string.266* @param {!Window=} opt_window The window in whose context the function should267* be invoked; defaults to the current window.268* @return {!(string|bot.response.ResponseObject)} The response object. If269* opt_stringify is true, the result will be serialized and returned in270* string format.271*/272bot.inject.executeScript = function (fn, args, opt_stringify, opt_window) {273var win = opt_window || bot.getWindow();274var ret;275try {276fn = bot.inject.recompileFunction_(fn, win);277var unwrappedArgs = /**@type {Object}*/ (bot.inject.unwrapValue(args,278win.document));279ret = bot.inject.wrapResponse(fn.apply(null, unwrappedArgs));280} catch (ex) {281ret = bot.inject.wrapError(ex);282}283return opt_stringify ? bot.json.stringify(ret) : ret;284};285286287/**288* Executes an injected script, which is expected to finish asynchronously289* before the given `timeout`. When the script finishes or an error290* occurs, the given `onDone` callback will be invoked. This callback291* will have a single argument, a {@link bot.response.ResponseObject} object.292*293* The script signals its completion by invoking a supplied callback given294* as its last argument. The callback may be invoked with a single value.295*296* The script timeout event will be scheduled with the provided window,297* ensuring the timeout is synchronized with that window's event queue.298* Furthermore, asynchronous scripts do not work across new page loads; if an299* "unload" event is fired on the window while an asynchronous script is300* pending, the script will be aborted and an error will be returned.301*302* Like `bot.inject.executeScript`, this function should only be called303* from an external source. It handles wrapping and unwrapping of input/output304* values.305*306* @param {(!Function|string)} fn Either the function to execute, or a string307* defining the body of an anonymous function that should be executed. This308* function should only contain references to symbols defined in the context309* of the target window (`opt_window`). Any references to symbols310* defined in this context will likely generate a ReferenceError.311* @param {Array.<*>} args An array of wrapped script arguments, as defined by312* the WebDriver wire protocol.313* @param {number} timeout The amount of time, in milliseconds, the script314* should be permitted to run; must be non-negative.315* @param {function(string)|function(!bot.response.ResponseObject)} onDone316* The function to call when the given `fn` invokes its callback,317* or when an exception or timeout occurs. This will always be called.318* @param {boolean=} opt_stringify Whether the result should be returned as a319* serialized JSON string.320* @param {!Window=} opt_window The window to synchronize the script with;321* defaults to the current window.322*/323bot.inject.executeAsyncScript = function (fn, args, timeout, onDone,324opt_stringify, opt_window) {325var win = opt_window || window;326var timeoutId;327var responseSent = false;328329function sendResponse(status, value) {330if (!responseSent) {331if (win.removeEventListener) {332win.removeEventListener('unload', onunload, true);333} else {334win.detachEvent('onunload', onunload);335}336337win.clearTimeout(timeoutId);338if (status != bot.ErrorCode.SUCCESS) {339var err = new bot.Error(status, value.message || value + '');340err.stack = value.stack;341value = bot.inject.wrapError(err);342} else {343value = bot.inject.wrapResponse(value);344}345onDone(opt_stringify ? bot.json.stringify(value) : value);346responseSent = true;347}348}349var sendError = goog.utils.partial(sendResponse, bot.ErrorCode.UNKNOWN_ERROR);350351if (win.closed) {352sendError('Unable to execute script; the target window is closed.');353return;354}355356fn = bot.inject.recompileFunction_(fn, win);357358args = /** @type {Array.<*>} */ (bot.inject.unwrapValue(args, win.document));359args.push(goog.utils.partial(sendResponse, bot.ErrorCode.SUCCESS));360361if (win.addEventListener) {362win.addEventListener('unload', onunload, true);363} else {364win.attachEvent('onunload', onunload);365}366367var startTime = goog.utils.now();368try {369fn.apply(win, args);370371// Register our timeout *after* the function has been invoked. This will372// ensure we don't timeout on a function that invokes its callback after373// a 0-based timeout.374timeoutId = win.setTimeout(function () {375sendResponse(bot.ErrorCode.SCRIPT_TIMEOUT,376Error('Timed out waiting for asynchronous script result ' +377'after ' + (goog.utils.now() - startTime) + ' ms'));378}, Math.max(0, timeout));379} catch (ex) {380sendResponse(ex.code || bot.ErrorCode.UNKNOWN_ERROR, ex);381}382383function onunload() {384sendResponse(bot.ErrorCode.UNKNOWN_ERROR,385Error('Detected a page unload event; asynchronous script ' +386'execution does not work across page loads.'));387}388};389390391/**392* Wraps the response to an injected script that executed successfully so it393* can be JSON-ified for transmission to the process that injected this394* script.395* @param {*} value The script result.396* @return {{status:bot.ErrorCode,value:*}} The wrapped value.397* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#responses398*/399bot.inject.wrapResponse = function (value) {400return {401'status': bot.ErrorCode.SUCCESS,402'value': bot.inject.wrapValue(value)403};404};405406407/**408* Wraps a JavaScript error in an object-literal so that it can be JSON-ified409* for transmission to the process that injected this script.410* @param {Error} err The error to wrap.411* @return {{status:bot.ErrorCode,value:*}} The wrapped error object.412* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#failed-commands413*/414bot.inject.wrapError = function (err) {415// TODO: Parse stackTrace416return {417'status': goog.object.containsKey(err, 'code') ?418err['code'] : bot.ErrorCode.UNKNOWN_ERROR,419// TODO: Parse stackTrace420'value': {421'message': err.message422}423};424};425426427/**428* The property key used to store the element cache on the DOCUMENT node429* when it is injected into the page. Since compiling each browser atom results430* in a different symbol table, we must use this known key to access the cache.431* This ensures the same object is used between injections of different atoms.432* @private {string}433* @const434*/435bot.inject.cache.CACHE_KEY_ = '$wdc_';436437438/**439* The prefix for each key stored in an cache.440* @type {string}441* @const442*/443bot.inject.cache.ELEMENT_KEY_PREFIX = ':wdc:';444445446/**447* Retrieves the cache object for the given window. Will initialize the cache448* if it does not yet exist.449* @param {Document=} opt_doc The document whose cache to retrieve. Defaults to450* the current document.451* @return {Object.<string, (Element|Window)>} The cache object.452* @private453*/454bot.inject.cache.getCache_ = function (opt_doc) {455var doc = opt_doc || document;456var cache = doc[bot.inject.cache.CACHE_KEY_];457if (!cache) {458cache = doc[bot.inject.cache.CACHE_KEY_] = {};459// Store the counter used for generated IDs in the cache so that it gets460// reset whenever the cache does.461cache.nextId = goog.utils.now();462}463// Sometimes the nextId does not get initialized and returns NaN464// TODO: Generate UID on the fly instead.465if (!cache.nextId) {466cache.nextId = goog.utils.now();467}468return cache;469};470471472/**473* Adds an element to its ownerDocument's cache.474* @param {(Element|Window)} el The element or Window object to add.475* @return {string} The key generated for the cached element.476*/477bot.inject.cache.addElement = function (el) {478// Check if the element already exists in the cache.479var cache = bot.inject.cache.getCache_(el.ownerDocument);480var id = goog.object.findKey(cache, function (value) {481return value == el;482});483if (!id) {484id = bot.inject.cache.ELEMENT_KEY_PREFIX + cache.nextId++;485cache[id] = el;486}487return id;488};489490491/**492* Retrieves an element from the cache. Will verify that the element is493* still attached to the DOM before returning.494* @param {string} key The element's key in the cache.495* @param {Document=} opt_doc The document whose cache to retrieve the element496* from. Defaults to the current document.497* @return {Element|Window} The cached element.498*/499bot.inject.cache.getElement = function (key, opt_doc) {500key = decodeURIComponent(key);501var doc = opt_doc || document;502var cache = bot.inject.cache.getCache_(doc);503if (!goog.object.containsKey(cache, key)) {504// Throw STALE_ELEMENT_REFERENCE instead of NO_SUCH_ELEMENT since the505// key may have been defined by a prior document's cache.506throw new bot.Error(bot.ErrorCode.STALE_ELEMENT_REFERENCE,507'Element does not exist in cache');508}509510var el = cache[key];511512// If this is a Window check if it's closed513if (goog.object.containsKey(el, 'setInterval')) {514if (el.closed) {515delete cache[key];516throw new bot.Error(bot.ErrorCode.NO_SUCH_WINDOW,517'Window has been closed.');518}519return el;520}521522// Make sure the element is still attached to the DOM before returning.523var node = el;524while (node) {525if (node == doc.documentElement) {526return el;527}528if (node.host && node.nodeType === 11) {529node = node.host;530}531node = node.parentNode;532}533delete cache[key];534throw new bot.Error(bot.ErrorCode.STALE_ELEMENT_REFERENCE,535'Element is no longer attached to the DOM');536};537538539