blob: 501f84dd495f820cbf5a435ea39c248df34e1369 [file] [log] [blame] [edit]
// Licensed to the Software Freedom Conservancy (SFC) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The SFC licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
/**
* @fileoverview Browser atom for injecting JavaScript into the page under
* test. There is no point in using this atom directly from JavaScript.
* Instead, it is intended to be used in its compiled form when injecting
* script from another language (e.g. C++).
*
* TODO: Add an example
*/
goog.provide('bot.inject');
goog.provide('bot.inject.cache');
goog.require('bot');
goog.require('bot.Error');
goog.require('bot.ErrorCode');
goog.require('bot.json');
/**
* @suppress {extraRequire} Used as a forward declaration which causes
* compilation errors if missing.
*/
goog.require('bot.response.ResponseObject');
goog.require('goog.array');
goog.require('goog.dom.NodeType');
goog.require('goog.object');
goog.require('goog.userAgent');
/**
* Type definition for the WebDriver's JSON wire protocol representation
* of a DOM element.
* @typedef {{ELEMENT: string}}
* @see bot.inject.ELEMENT_KEY
* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol
*/
bot.inject.JsonElement;
/**
* Type definition for a cached Window object that can be referenced in
* WebDriver's JSON wire protocol. Note, this is a non-standard
* representation.
* @typedef {{WINDOW: string}}
* @see bot.inject.WINDOW_KEY
*/
bot.inject.JsonWindow;
/**
* Key used to identify DOM elements in the WebDriver wire protocol.
* @type {string}
* @const
* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol
*/
bot.inject.ELEMENT_KEY = 'ELEMENT';
/**
* Key used to identify Window objects in the WebDriver wire protocol.
* @type {string}
* @const
*/
bot.inject.WINDOW_KEY = 'WINDOW';
/**
* Converts an element to a JSON friendly value so that it can be
* stringified for transmission to the injector. Values are modified as
* follows:
* <ul>
* <li>booleans, numbers, strings, and null are returned as is</li>
* <li>undefined values are returned as null</li>
* <li>functions are returned as a string</li>
* <li>each element in an array is recursively processed</li>
* <li>DOM Elements are wrapped in object-literals as dictated by the
* WebDriver wire protocol</li>
* <li>all other objects will be treated as hash-maps, and will be
* recursively processed for any string and number key types (all
* other key types are discarded as they cannot be converted to JSON).
* </ul>
*
* @param {*} value The value to make JSON friendly.
* @return {*} The JSON friendly value.
* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol
*/
bot.inject.wrapValue = function (value) {
var _wrap = function (value, seen) {
switch (goog.typeOf(value)) {
case 'string':
case 'number':
case 'boolean':
return value;
case 'function':
return value.toString();
case 'array':
return goog.array.map(/**@type {IArrayLike}*/(value),
function (v) { return _wrap(v, seen); });
case 'object':
// Since {*} expands to {Object|boolean|number|string|undefined}, the
// JSCompiler complains that it is too broad a type for the remainder of
// this block where {!Object} is expected. Downcast to prevent generating
// a ton of compiler warnings.
value = /**@type {!Object}*/ (value);
if (seen.indexOf(value) >= 0) {
throw new bot.Error(bot.ErrorCode.JAVASCRIPT_ERROR,
'Recursive object cannot be transferred');
}
// Sniff out DOM elements. We're using duck-typing instead of an
// instanceof check since the instanceof might not always work
// (e.g. if the value originated from another Firefox component)
if (goog.object.containsKey(value, 'nodeType') &&
(value['nodeType'] == goog.dom.NodeType.ELEMENT ||
value['nodeType'] == goog.dom.NodeType.DOCUMENT)) {
var ret = {};
ret[bot.inject.ELEMENT_KEY] =
bot.inject.cache.addElement(/**@type {!Element}*/(value));
return ret;
}
// Check if this is a Window
if (goog.object.containsKey(value, 'document')) {
var ret = {};
ret[bot.inject.WINDOW_KEY] =
bot.inject.cache.addElement(/**@type{!Window}*/(value));
return ret;
}
seen.push(value);
if (goog.isArrayLike(value)) {
return goog.array.map(/**@type {IArrayLike}*/(value),
function (v) { return _wrap(v, seen); });
}
var filtered = goog.object.filter(value, function (val, key) {
return goog.isNumber(key) || goog.isString(key);
});
return goog.object.map(filtered, function (v) { return _wrap(v, seen); });
default: // goog.typeOf(value) == 'undefined' || 'null'
return null;
}
};
return _wrap(value, []);
};
/**
* Unwraps any DOM element's encoded in the given `value`.
* @param {*} value The value to unwrap.
* @param {Document=} opt_doc The document whose cache to retrieve wrapped
* elements from. Defaults to the current document.
* @return {*} The unwrapped value.
*/
bot.inject.unwrapValue = function (value, opt_doc) {
if (goog.isArray(value)) {
return goog.array.map(/**@type {IArrayLike}*/(value),
function (v) { return bot.inject.unwrapValue(v, opt_doc); });
} else if (goog.isObject(value)) {
if (typeof value == 'function') {
return value;
}
if (goog.object.containsKey(value, bot.inject.ELEMENT_KEY)) {
return bot.inject.cache.getElement(value[bot.inject.ELEMENT_KEY],
opt_doc);
}
if (goog.object.containsKey(value, bot.inject.WINDOW_KEY)) {
return bot.inject.cache.getElement(value[bot.inject.WINDOW_KEY],
opt_doc);
}
return goog.object.map(value, function (val) {
return bot.inject.unwrapValue(val, opt_doc);
});
}
return value;
};
/**
* Recompiles `fn` in the context of another window so that the
* correct symbol table is used when the function is executed. This
* function assumes the `fn` can be decompiled to its source using
* `Function.prototype.toString` and that it only refers to symbols
* defined in the target window's context.
*
* @param {!(Function|string)} fn Either the function that should be
* recompiled, or a string defining the body of an anonymous function
* that should be compiled in the target window's context.
* @param {!Window} theWindow The window to recompile the function in.
* @return {!Function} The recompiled function.
* @private
*/
bot.inject.recompileFunction_ = function (fn, theWindow) {
if (goog.isString(fn)) {
try {
return new theWindow['Function'](fn);
} catch (ex) {
// Try to recover if in IE5-quirks mode
// Need to initialize the script engine on the passed-in window
if (goog.userAgent.IE && theWindow.execScript) {
theWindow.execScript(';');
return new theWindow['Function'](fn);
}
throw ex;
}
}
return theWindow == window ? fn : new theWindow['Function'](
'return (' + fn + ').apply(null,arguments);');
};
/**
* Executes an injected script. This function should never be called from
* within JavaScript itself. Instead, it is used from an external source that
* is injecting a script for execution.
*
* <p/>For example, in a WebDriver Java test, one might have:
* <pre><code>
* Object result = ((JavascriptExecutor) driver).executeScript(
* "return arguments[0] + arguments[1];", 1, 2);
* </code></pre>
*
* <p/>Once transmitted to the driver, this command would be injected into the
* page for evaluation as:
* <pre><code>
* bot.inject.executeScript(
* function() {return arguments[0] + arguments[1];},
* [1, 2]);
* </code></pre>
*
* <p/>The details of how this actually gets injected for evaluation is left
* as an implementation detail for clients of this library.
*
* @param {!(Function|string)} fn Either the function to execute, or a string
* defining the body of an anonymous function that should be executed. This
* function should only contain references to symbols defined in the context
* of the target window (`opt_window`). Any references to symbols
* defined in this context will likely generate a ReferenceError.
* @param {Array.<*>} args An array of wrapped script arguments, as defined by
* the WebDriver wire protocol.
* @param {boolean=} opt_stringify Whether the result should be returned as a
* serialized JSON string.
* @param {!Window=} opt_window The window in whose context the function should
* be invoked; defaults to the current window.
* @return {!(string|bot.response.ResponseObject)} The response object. If
* opt_stringify is true, the result will be serialized and returned in
* string format.
*/
bot.inject.executeScript = function (fn, args, opt_stringify, opt_window) {
var win = opt_window || bot.getWindow();
var ret;
try {
fn = bot.inject.recompileFunction_(fn, win);
var unwrappedArgs = /**@type {Object}*/ (bot.inject.unwrapValue(args,
win.document));
ret = bot.inject.wrapResponse(fn.apply(null, unwrappedArgs));
} catch (ex) {
ret = bot.inject.wrapError(ex);
}
return opt_stringify ? bot.json.stringify(ret) : ret;
};
/**
* Executes an injected script, which is expected to finish asynchronously
* before the given `timeout`. When the script finishes or an error
* occurs, the given `onDone` callback will be invoked. This callback
* will have a single argument, a {@link bot.response.ResponseObject} object.
*
* The script signals its completion by invoking a supplied callback given
* as its last argument. The callback may be invoked with a single value.
*
* The script timeout event will be scheduled with the provided window,
* ensuring the timeout is synchronized with that window's event queue.
* Furthermore, asynchronous scripts do not work across new page loads; if an
* "unload" event is fired on the window while an asynchronous script is
* pending, the script will be aborted and an error will be returned.
*
* Like `bot.inject.executeScript`, this function should only be called
* from an external source. It handles wrapping and unwrapping of input/output
* values.
*
* @param {(!Function|string)} fn Either the function to execute, or a string
* defining the body of an anonymous function that should be executed. This
* function should only contain references to symbols defined in the context
* of the target window (`opt_window`). Any references to symbols
* defined in this context will likely generate a ReferenceError.
* @param {Array.<*>} args An array of wrapped script arguments, as defined by
* the WebDriver wire protocol.
* @param {number} timeout The amount of time, in milliseconds, the script
* should be permitted to run; must be non-negative.
* @param {function(string)|function(!bot.response.ResponseObject)} onDone
* The function to call when the given `fn` invokes its callback,
* or when an exception or timeout occurs. This will always be called.
* @param {boolean=} opt_stringify Whether the result should be returned as a
* serialized JSON string.
* @param {!Window=} opt_window The window to synchronize the script with;
* defaults to the current window.
*/
bot.inject.executeAsyncScript = function (fn, args, timeout, onDone,
opt_stringify, opt_window) {
var win = opt_window || window;
var timeoutId;
var responseSent = false;
function sendResponse(status, value) {
if (!responseSent) {
if (win.removeEventListener) {
win.removeEventListener('unload', onunload, true);
} else {
win.detachEvent('onunload', onunload);
}
win.clearTimeout(timeoutId);
if (status != bot.ErrorCode.SUCCESS) {
var err = new bot.Error(status, value.message || value + '');
err.stack = value.stack;
value = bot.inject.wrapError(err);
} else {
value = bot.inject.wrapResponse(value);
}
onDone(opt_stringify ? bot.json.stringify(value) : value);
responseSent = true;
}
}
var sendError = goog.partial(sendResponse, bot.ErrorCode.UNKNOWN_ERROR);
if (win.closed) {
sendError('Unable to execute script; the target window is closed.');
return;
}
fn = bot.inject.recompileFunction_(fn, win);
args = /** @type {Array.<*>} */ (bot.inject.unwrapValue(args, win.document));
args.push(goog.partial(sendResponse, bot.ErrorCode.SUCCESS));
if (win.addEventListener) {
win.addEventListener('unload', onunload, true);
} else {
win.attachEvent('onunload', onunload);
}
var startTime = goog.now();
try {
fn.apply(win, args);
// Register our timeout *after* the function has been invoked. This will
// ensure we don't timeout on a function that invokes its callback after
// a 0-based timeout.
timeoutId = win.setTimeout(function () {
sendResponse(bot.ErrorCode.SCRIPT_TIMEOUT,
Error('Timed out waiting for asynchronous script result ' +
'after ' + (goog.now() - startTime) + ' ms'));
}, Math.max(0, timeout));
} catch (ex) {
sendResponse(ex.code || bot.ErrorCode.UNKNOWN_ERROR, ex);
}
function onunload() {
sendResponse(bot.ErrorCode.UNKNOWN_ERROR,
Error('Detected a page unload event; asynchronous script ' +
'execution does not work across page loads.'));
}
};
/**
* Wraps the response to an injected script that executed successfully so it
* can be JSON-ified for transmission to the process that injected this
* script.
* @param {*} value The script result.
* @return {{status:bot.ErrorCode,value:*}} The wrapped value.
* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#responses
*/
bot.inject.wrapResponse = function (value) {
return {
'status': bot.ErrorCode.SUCCESS,
'value': bot.inject.wrapValue(value)
};
};
/**
* Wraps a JavaScript error in an object-literal so that it can be JSON-ified
* for transmission to the process that injected this script.
* @param {Error} err The error to wrap.
* @return {{status:bot.ErrorCode,value:*}} The wrapped error object.
* @see https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#failed-commands
*/
bot.inject.wrapError = function (err) {
// TODO: Parse stackTrace
return {
'status': goog.object.containsKey(err, 'code') ?
err['code'] : bot.ErrorCode.UNKNOWN_ERROR,
// TODO: Parse stackTrace
'value': {
'message': err.message
}
};
};
/**
* The property key used to store the element cache on the DOCUMENT node
* when it is injected into the page. Since compiling each browser atom results
* in a different symbol table, we must use this known key to access the cache.
* This ensures the same object is used between injections of different atoms.
* @private {string}
* @const
*/
bot.inject.cache.CACHE_KEY_ = '$wdc_';
/**
* The prefix for each key stored in an cache.
* @type {string}
* @const
*/
bot.inject.cache.ELEMENT_KEY_PREFIX = ':wdc:';
/**
* Retrieves the cache object for the given window. Will initialize the cache
* if it does not yet exist.
* @param {Document=} opt_doc The document whose cache to retrieve. Defaults to
* the current document.
* @return {Object.<string, (Element|Window)>} The cache object.
* @private
*/
bot.inject.cache.getCache_ = function (opt_doc) {
var doc = opt_doc || document;
var cache = doc[bot.inject.cache.CACHE_KEY_];
if (!cache) {
cache = doc[bot.inject.cache.CACHE_KEY_] = {};
// Store the counter used for generated IDs in the cache so that it gets
// reset whenever the cache does.
cache.nextId = goog.now();
}
// Sometimes the nextId does not get initialized and returns NaN
// TODO: Generate UID on the fly instead.
if (!cache.nextId) {
cache.nextId = goog.now();
}
return cache;
};
/**
* Adds an element to its ownerDocument's cache.
* @param {(Element|Window)} el The element or Window object to add.
* @return {string} The key generated for the cached element.
*/
bot.inject.cache.addElement = function (el) {
// Check if the element already exists in the cache.
var cache = bot.inject.cache.getCache_(el.ownerDocument);
var id = goog.object.findKey(cache, function (value) {
return value == el;
});
if (!id) {
id = bot.inject.cache.ELEMENT_KEY_PREFIX + cache.nextId++;
cache[id] = el;
}
return id;
};
/**
* Retrieves an element from the cache. Will verify that the element is
* still attached to the DOM before returning.
* @param {string} key The element's key in the cache.
* @param {Document=} opt_doc The document whose cache to retrieve the element
* from. Defaults to the current document.
* @return {Element|Window} The cached element.
*/
bot.inject.cache.getElement = function (key, opt_doc) {
key = decodeURIComponent(key);
var doc = opt_doc || document;
var cache = bot.inject.cache.getCache_(doc);
if (!goog.object.containsKey(cache, key)) {
// Throw STALE_ELEMENT_REFERENCE instead of NO_SUCH_ELEMENT since the
// key may have been defined by a prior document's cache.
throw new bot.Error(bot.ErrorCode.STALE_ELEMENT_REFERENCE,
'Element does not exist in cache');
}
var el = cache[key];
// If this is a Window check if it's closed
if (goog.object.containsKey(el, 'setInterval')) {
if (el.closed) {
delete cache[key];
throw new bot.Error(bot.ErrorCode.NO_SUCH_WINDOW,
'Window has been closed.');
}
return el;
}
// Make sure the element is still attached to the DOM before returning.
var node = el;
while (node) {
if (node == doc.documentElement) {
return el;
}
if (node.host && node.nodeType === 11) {
node = node.host;
}
node = node.parentNode;
}
delete cache[key];
throw new bot.Error(bot.ErrorCode.STALE_ELEMENT_REFERENCE,
'Element is no longer attached to the DOM');
};