(function(module, ns) {
var Element = ns.require('element'),
Exception = ns.require('error');
var DEFAULT_SCRIPT_TIMEOUT = 20000;
var DEFAULT_SEARCH_TIMEOUT = 20000;
var DEFAULT_WAIT_FOR_INTERVAL = 100;
var DEFAULT_WAIT_FOR_TIMEOUT = 20000;
var SCOPE_TO_METHOD = Object.freeze({
scriptTimeout: 'setScriptTimeout',
searchTimeout: 'setSearchTimeout',
context: 'setContext'
});
var assert = require('assert');
var key;
var searchMethods = {
CLASS: 'class name',
SELECTOR: 'css selector',
ID: 'id',
NAME: 'name',
LINK_TEXT: 'link text',
PARTIAL_LINK_TEXT: 'partial link text',
TAG: 'tag name',
XPATH: 'xpath'
};
function isFunction(value) {
return typeof(value) === 'function';
}
/**
* Helper to set scope and state on a given client.
*
* @private
* @param {Marionette.Client} context of a client.
* @param {String} type property of client.
* @param {Object|String|Number|Null} value of type.
*/
function setState(context, type, value) {
context._scope[type] = value;
context._state[type] = value;
}
/**
* Helper to get state of given client.
*
* @private
* @param {Marionette.Client} context of a client.
* @param {String} type property of client.
* @return {Object|String|Number|Null} value of type.
*/
function getState(context, type) {
return context._state[type];
}
/**
* Initializes client.You must create and initialize
* a driver and pass it into the client before using the client itself.
*
* Marionette JS Client supports both async and sync modes... The
* documentation reflects the sync modes but you can also pass a callback into
* most calls for the sync version. If you attempt to use callbacks with a
* sync driver they will be called but run synchronously.
*
* // all drivers conform to this api
*
* // var Marionette = require('marionette-client');
* var driver = new Marionette.Drivers.Tcp({});
* var client;
*
* driver.connect(function(err) {
* if (err) {
* // handle error case...
* }
*
* client = new Marionette.Client(driver, {
* // optional default callback can be used to implement
* // a generator interface or other non-callback based api.
* defaultCallback: function(err, result) {
* console.log('CALLBACK GOT:', err, result);
* }
* });
*
* // by default commands run in a queue.
* // assuming there is not a fatal error each command
* // will execute sequentially.
* client.startSession(function () {
* client.goUrl('http://google.com')
* .executeScript(function() {
* alert(document.title);
* })
* .deleteSession();
* });
* });
*
* // alternatively there is a lazy api which test runners can use.
*
* var client = new Client(null, { lazy: true });
*
* // accepts same arguments as normal constructor calls.
* client.resetWithDriver(driver, {});
*
*
* @class Marionette.Client
* @constructor
* @param {Marionette.Drivers.Abstract} driver fully initialized client.
* @param {Object} options options for driver.
*/
function Client(driver, options) {
// when the driver is lazily added skip
if (!driver && options && options.lazy)
return;
this.resetWithDriver(driver, options);
}
Client.prototype = {
Element: Element,
/**
* Constant for chrome context.
*
* @type {String}
* @property CHROME
*/
CHROME: 'chrome',
/**
* Constant for content context.
*
* @type {String}
* @property CONTENT
*/
CONTENT: 'content',
/**
* Object of hoooks.
*
* {
* hookName: [hook1, hook2]
* }
*
* @type {Object}
* @property _hooks
* @private
*/
_hooks: {},
/**
* The current scope of this client instance. Used with _state.
*
* // Example
* {
* scriptTimeout: 500,
* searchTimeout: 6000,
* context: 'content',
* window: 'window_id',
* frame: 'frameId'
* }
*
* @type {Object}
*/
_scope: null,
/**
* The current state of the client.
*
* // Example
* {
* scriptTimeout: 500,
* searchTimeout: 6000,
* context: 'content',
* window: 'window_id',
* frame: 'frameId'
* }
*
* @private
* @type {Object}
*/
_state: null,
/**
* Actor id for instance
*
* @property actor
* @type String
*/
actor: null,
/**
* Session id for instance.
*
* @property session
* @type String
*/
session: null,
// _state getters
/**
* @return {String} the current context.
*/
get context() {
return getState(this, 'context');
},
/**
* @return {String|Marionette.Element} frame currently focused.
*/
get frame() {
return getState(this, 'frame');
},
/**
* @return {String|Marionette.Element}
*/
get window() {
return getState(this, 'window');
},
/**
* @return {Number} current scriptTimeout.
*/
get scriptTimeout() {
return getState(this, 'scriptTimeout');
},
/**
* @return {Number} current search timeout.
*/
get searchTimeout() {
return getState(this, 'searchTimeout');
},
/**
* Resets the internal state of the client.
*
* This is only safe to do when the client has no session.
*
* @param {Marionette.Drivers.Abstract} driver fully initialized driver.
* @param {Object} options options for driver.
*/
resetWithDriver: function(driver, options) {
if (typeof(options) === 'undefined') {
options = {};
}
this.driver = driver;
this.defaultCallback =
options.defaultCallback || driver.defaultCallback || function() {};
if (driver.isSync) {
this.isSync = driver.isSync;
}
// create hooks
this._hooks = {};
// create the initial state for this client
this._state = {
context: 'content',
scriptTimeout: options.scriptTimeout || DEFAULT_SCRIPT_TIMEOUT,
searchTimeout: options.searchTimeout || DEFAULT_SEARCH_TIMEOUT
};
// give the root client a scope.
this._scope = {};
for (var key in this._state) {
this._scope[key] = this._state[key];
}
},
/**
* Adds a plugin to the client instance.
*
* // add imaginary forms plugin
* client.plugin('forms', moduleForForms, { options: true });
* client.forms.fill();
*
* // tie into common plugin interface without exposing a new api.
* client.plugin(null, module, {});
*
* // chaining
* client
* .plugin('forms', require('form-module'))
* .plguin('apps', require('apps-module'))
* .plugin('other', require('...'));
*
* client.forms.fill(...);
* client.apps.launch(...);
*
*
* @method plugin
* @param {String|Null} name to expose plugin on in the client.
* @param {Function|Object} plugin function/module.
* @param {Object} [optional] options to pass to plugin.
*/
plugin: function(name, plugin, options) {
var invokedMethod;
// don't allow overriding existing names
if (this[name])
throw new Error(name + ' - is already reserved on client');
// allow both plugin.setup and plugin to be the invoked method.
invokedMethod =
typeof plugin.setup === 'function' ? plugin.setup : plugin;
if (typeof invokedMethod !== 'function')
throw new Error('plugin must be a method or have a .setup method');
var result = invokedMethod(this, options);
// assign the plugin to a property if there is a result of invoking the
// plugin.
if (name && result)
this[name] = result;
return this;
},
/**
* Run all hooks of a given type. Hooks may be added as the result of
* running other hooks which could potentially result in an infinite loop
* without stack overflow...
*
*
* this.runHook('startSession', function(err) {
* // do something with error if there is one.
* });
*
*
* @protected
* @method runHook
* @param {String} type of hook to run.
* @param {Function} callback to run once hooks are done.
*/
runHook: function(type, callback) {
// call into execute hook to prevent stack overflow
function handleHookResponse(err) {
if (err) return callback(err);
// next hook
process.nextTick(executeHook);
}
var executeHook = function executeHook() {
// find the next hook
var hooks = this._hooks[type];
// no hooks of this type- continue
if (!hooks) {
// breaks sync driver without this invocation
if (this.isSync) {
return callback();
}
return process.nextTick(callback);
}
var hook = hooks.shift();
// last hook of this type fire callback sync so we can better test
// interactions after (more deterministic).
if (!hook)
return callback();
// pass handleHookResponse which the hook must call to continue the hook
// chain.
hook.call(this, handleHookResponse);
}.bind(this);
// start going through all hooks
executeHook();
},
/**
* Adds a hook to the stack. Hooks run in serial order until all hooks
* complete. Execution of hooks halts on first error.
*
*
* client.addHook('sessionStart', function(done) {
* // this is the client
* this.executeScript(function() {}, done);
* });
*
*
* @method addHook
* @chainable
* @param {String} type name of hook.
* @param {Function} handler for hook must take a single argument
* (see above).
*/
addHook: function(type, handler) {
if (!this._hooks[type])
this._hooks[type] = [];
this._hooks[type].push(handler);
return this;
},
/**
* This function will be invoked whenever the remote throws a
* ScriptTimeout error. The motivation is that client consumers
* can use this opportunity to log some useful state for debugging.
* By default this function logs screenshot image data.
*
* @type {Function}
*/
onScriptTimeout: function() {
console.log('Screenshot: ' + 'data:image/png;base64,' + this.screenshot());
},
/**
* Sends a command to the server.
* Adds additional information like actor and session
* to command if not present.
*
*
* @method send
* @chainable
* @param {Object} cmd to be sent over the wire.
* @param {Function} cb executed when response is sent.
*/
send: function send(cmd, cb) {
// first do scoping updates
if (this._scope && this._bypassScopeChecks !== true) {
// calling the methods may cause an infinite loop so make sure
// we don't hit this code path again inside of the loop.
this._bypassScopeChecks = true;
for (var key in this._scope) {
// !important otherwise throws infinite loop
if (this._state[key] !== this._scope[key]) {
this[SCOPE_TO_METHOD[key]](this._scope[key]);
}
}
// undo really dirty hack
this._bypassScopeChecks = false;
}
if (!cmd.to) {
cmd.to = this.actor || 'root';
}
if (this.session) {
cmd.session = cmd.session || this.session;
}
if (!cb && this.defaultCallback) {
cb = this.defaultCallback();
}
var driverSent = this.driver.send(cmd, cb);
if (this.isSync) {
return driverSent;
}
return this;
},
_handleCallback: function() {
var args = Array.prototype.slice.call(arguments),
callback = args.shift();
if (!callback) {
callback = this.defaultCallback;
}
assert(typeof callback === 'function', 'you must use functions');
// Convert first argument to an error it is possible for this to already
// be an exception so skip conversion if that is the case...
if (args[0] && !(args[0] instanceof Exception)) {
var err = new Exception(this, args[0]);
if (err.type === 'ScriptTimeout') {
this.onScriptTimeout && this.onScriptTimeout(err);
}
args[0] = err;
}
return callback.apply(this, args);
},
/**
* Sends request and formats response.
*
*
* @private
* @method _sendCommand
* @chainable
* @param {Object} command marionette command.
* @param {String} responseKey the part of the response to pass \
* unto the callback.
* @param {Object} callback wrapped callback.
*/
_sendCommand: function(command, responseKey, callback) {
var self = this;
var result;
try {
return this.send(command, function(data) {
var value;
try {
value = self._transformResultValue(data[responseKey]);
} catch (e) {
console.log('Error: unable to transform marionette response', data);
}
return self._handleCallback(callback, data.error, value);
});
} catch (e) {
// Attach current client to any host related errors too...
e.client = this;
throw e;
}
},
/**
* Finds the actor for this instance.
*
* @private
* @method _getActorId
* @param {Function} callback executed when response is sent.
*/
_getActorId: function _getActorId(callback) {
var self = this, cmd;
cmd = { name: 'getMarionetteID' };
return this._sendCommand(cmd, 'id', function(err, actor) {
self.actor = actor;
if (callback) {
callback(err, actor);
}
});
},
/**
* Starts a remote session.
*
* @private
* @method _newSession
* @param {Function} callback optional.
* @param {Object} desired capabilities
*/
_newSession: function _newSession(callback, desiredCapabilities) {
var self = this;
function newSession(data) {
self.session = data.value;
return self._handleCallback(callback, data.error, data);
}
return this.send({
name: 'newSession',
parameters: { capabilities: desiredCapabilities }
}, newSession);
},
/**
* Creates a client which has a fixed window, frame, scriptTimeout and
* searchTimeout.
*
* client.setSearchTimeout(1000).setContext('content');
*
* var timeout = client.scope({ searchTimeout: 250 });
* var chrome = client.scope({ context: 'chrome' });
*
* // executed with 250 timeout
* timeout.findElement('...');
*
* // executes in chrome context.
* chrome.executeScript();
*
* // executed in content with search timeout of 1000
* client.findElement('...');
*
*
* @method scope
* @param {Object} options for scopped client.
* @return {Marionette.Client} scoped client instance.
*/
scope: function(options) {
var scopeOptions = {};
for (var key in this._scope) {
scopeOptions[key] = this._scope[key];
}
// copy the given options
for (key in options) {
var value = options[key];
scopeOptions[key] = value;
}
// create child
var scope = Object.create(this);
// assign the new scoping
scope._scope = scopeOptions;
return scope;
},
/**
* Utility for waiting for a success condition to be met.
*
* // sync style
* client.waitFor(function() {
* return element.displayed();
* });
*
* // async style
* client.waitFor(function(done) {
* element.displayed(done);
* });
*
*
* Options:
* * (Number) interval: time between running test
* * (Number) timeout: maximum wallclock time before failing test.
*
* @method waitFor
* @param {Function} test to execute.
* @param {Object} [options] for timeout see above.
* @param {Number} [options.interval] time between running test.
* @param {Number} [options.timeout]
* maximum wallclock time before failing test.
* @param {Function} [callback] optional callback.
*/
waitFor: function(test, options, callback) {
if (typeof(options) === 'function') {
callback = options;
options = null;
}
// setup options
options = options || {};
var sync = this.isSync;
// must handle default callback case for sync code
callback = callback || this.defaultCallback;
// wallclock timer
var timeout = Date.now() + (options.timeout || DEFAULT_WAIT_FOR_TIMEOUT);
// interval between test being fired.
var interval = options.interval || DEFAULT_WAIT_FOR_INTERVAL;
var modifiedTest = test;
if (test.length === 0) {
// Give me a callback!
modifiedTest = function(done) {
done(null, test());
};
}
return (sync ? this.waitForSync : this.waitForAsync).call(
this, modifiedTest, callback, interval, timeout);
},
/**
* Poll some boolean function until it returns true synchronously.
*
* @param {Function} test some function that returns a boolean.
* @param {Function} callback function to call once our test passes or
* we time out.
* @param {number} interval how often to poll in ms.
* @param {number} timeout time at which we fail in ms.
*/
waitForSync: function(test, callback, interval, timeout) {
var err, result;
while (Date.now() < timeout) {
if (err || result) {
return callback(err);
}
test(function(_err, _result) {
err = _err;
result = _result;
});
this.executeAsyncScript(function(waitMillis) {
setTimeout(marionetteScriptFinished, waitMillis);
}, [interval]);
}
this.onScriptTimeout && this.onScriptTimeout();
callback(new Error('timeout exceeded!'));
},
/**
* Poll some boolean function until it returns true asynchronously.
*
* @param {Function} test some function that returns a boolean.
* @param {Function} callback function to call once our test passes or
* we time out.
* @param {number} interval how often to poll in ms.
* @param {number} timeout time at which we fail in ms.
*/
waitForAsync: function(test, callback, interval, timeout) {
if (Date.now() >= timeout) {
this.onScriptTimeout && this.onScriptTimeout();
return callback(new Error('timeout exceeded'));
}
test(function(err, result) {
if (err || result) {
return callback(err, result);
}
var next = this.waitForAsync.bind(
this,
test,
callback,
interval,
timeout
);
setTimeout(next, interval);
}.bind(this));
},
/**
* Finds actor and creates connection to marionette.
* This is a combination of calling getMarionetteId and then newSession.
*
* @method startSession
* @param {Function} callback executed when session is started.
* @param {Object} desired capabilities
*/
startSession: function startSession(callback, desiredCapabilities) {
var self = this;
callback = callback || this.defaultCallback;
desiredCapabilities = desiredCapabilities || {};
function runHook(err) {
if (err) return callback(err);
self.runHook('startSession', callback);
}
return this._getActorId(function() {
//actor will not be set if we send the command then
self._newSession(runHook, desiredCapabilities);
});
},
/**
* Destroys current session.
*
*
* @chainable
* @method deleteSession
* @param {Function} callback executed when session is destroyed.
*/
deleteSession: function destroySession(callback) {
var cmd = { name: 'deleteSession' };
var closeDriver = function closeDriver() {
this._sendCommand(cmd, 'ok', function(err, value) {
// clear state of the past session
this.session = null;
this.actor = null;
this.driver.close();
this._handleCallback(callback, err, value);
}.bind(this));
}.bind(this);
this.runHook('deleteSession', closeDriver);
return this;
},
/**
* Returns the capabilities of the current session.
*
* @method sessionCapabilities
* @param {Function} [callback]
* executed with capabilities of current session.
* @return {Object} A JSON representing capabilities.
*/
sessionCapabilities: function sessionCapabilities(callback) {
var cmd = { name: 'getSessionCapabilities' };
return this._sendCommand(cmd, 'value', callback);
},
/**
* Callback will receive the id of the current window.
*
* @chainable
* @method getWindow
* @param {Function} [callback] executed with id of current window.
* @return {Object} self.
*/
getWindow: function getWindow(callback) {
var cmd = { name: 'getWindow' };
return this._sendCommand(cmd, 'value', callback);
},
/**
* Callback will receive an array of window ids.
*
* @method getWindows
* @chainable
* @param {Function} [callback] executes with an array of ids.
*/
getWindows: function getWindows(callback) {
var cmd = { name: 'getWindows' };
return this._sendCommand(cmd, 'value', callback);
},
/**
* Switches context of marionette to specific window.
*
*
* @method switchToWindow
* @chainable
* @param {String} id window id you can find these with getWindow(s).
* @param {Function} callback called with boolean.
*/
switchToWindow: function switchToWindow(id, callback) {
var cmd = { name: 'switchToWindow', parameters: {value: id }};
return this._sendCommand(cmd, 'ok', callback);
},
/**
* Returns the type of current window.
*
* @method getWindowType
* @param {Function} [callback] executes with window type.
* @return {Object} self.
*/
getWindowType: function getWindowType(callback) {
var cmd = { name: 'getWindowType' };
return this._sendCommand(cmd, 'value', callback);
},
/**
* Imports a script into the marionette
* context for the duration of the session.
*
* Good for prototyping new marionette commands.
*
* @method importScript
* @chainable
* @param {String} script javascript string blob.
* @param {Function} callback called with boolean.
*/
importScript: function(script, callback) {
var cmd = { name: 'importScript', parameters: {script: script }};
return this._sendCommand(cmd, 'ok', callback);
},
/**
* Switches context of marionette to specific iframe.
*
*
* @method switchToFrame
* @chainable
* @param {String|Marionette.Element} [id] iframe id or element.
* If you call this function without an argument, it will switch to the top-level frame.
* @param {Object} [options] options to be mixed in the command parameters.
* @param {Boolean} [options.focus] If 'true', will switch the focus to the frame.
* @param {Function} callback called with boolean.
*
*/
switchToFrame: function switchToFrame(id, options, callback) {
if (typeof(id) === 'function') {
callback = id;
id = null;
options = null;
} else if (typeof(options) === 'function') {
callback = options;
options = null;
}
var cmd = { name: 'switchToFrame', parameters: {} };
if (id instanceof this.Element) {
cmd.parameters.element = id.id;
} else if (
id !== null &&
typeof(id) === 'object' &&
id.ELEMENT
) {
cmd.parameters.element = id.ELEMENT;
} else if (id) {
cmd.parameters.id = id;
}
if (options) {
for (var key in options) {
cmd.parameters[key] = options[key];
}
}
return this._sendCommand(cmd, 'ok', callback);
},
/**
* Switches context of window. The current context can be found with
* .context.
*
* // default context
* client.context === 'content';
*
* client.setContext('chrome', function() {
* // .. wait for switch
* });
*
* client.context === 'chrome';
*
*
* @method setContext
* @chainable
* @param {String} context either: 'chome' or 'content'.
* @param {Function} callback receives boolean.
*/
setContext: function setContext(context, callback) {
if (context !== this.CHROME && context !== this.CONTENT) {
throw new Error('content type must be "chrome" or "content"');
}
setState(this, 'context', context);
var cmd = { name: 'setContext', parameters: { value: context }};
return this._sendCommand(cmd, 'ok', callback);
},
/**
* Sets the script timeout
*
* @method setScriptTimeout
* @chainable
* @param {Numeric} timeout max time in ms.
* @param {Function} callback executed with boolean status.
* @return {Object} self.
*/
setScriptTimeout: function setScriptTimeout(timeout, callback) {
var cmd = { name: 'setScriptTimeout', parameters: {ms: timeout} };
setState(this, 'scriptTimeout', timeout);
this.driver.setScriptTimeout(timeout);
return this._sendCommand(cmd, 'ok', callback);
},
/**
* Sets a timeout for the find methods.
*
* When searching for an element using either Marionette.findElement or
* Marionette.findElements, the method will continue trying to locate the
* element for up to timeout ms.
*
* This can be useful if, for example, the element you’re looking for might
* not exist immediately, because it belongs to a page which is currently
* being loaded.
*
* @method setSearchTimeout
* @chainable
* @param {Numeric} timeout max time in ms.
* @param {Function} callback executed with boolean status.
* @return {Object} self.
*/
setSearchTimeout: function setSearchTimeout(timeout, callback) {
var cmd = { name: 'setSearchTimeout', parameters:{ ms: timeout }};
setState(this, 'searchTimeout', timeout);
return this._sendCommand(cmd, 'ok', callback);
},
/**
* Returns the title of current window.
*
* @method title
* @param {Function} [callback] optional receives title.
* @return {Object} self.
*/
title: function title(callback) {
var cmd = { name: 'getTitle' };
return this._sendCommand(cmd, 'value', callback);
},
/**
* Gets url location for device.
*
* @method getUrl
* @chainable
* @param {Function} callback receives url.
*/
getUrl: function getUrl(callback) {
var cmd = { name: 'getUrl' };
return this._sendCommand(cmd, 'value', callback);
},
/**
* Refreshes current window on device.
*
* @method refresh
* @param {Function} callback boolean success.
* @return {Object} self.
*/
refresh: function refresh(callback) {
var cmd = { name: 'refresh' };
return this._sendCommand(cmd, 'ok', callback);
},
/**
* Drives browser to a url.
*
* @method goUrl
* @chainable
* @param {String} url location.
* @param {Function} callback executes when finished driving browser to url.
*/
goUrl: function goUrl(url, callback) {
var cmd = { name: 'goUrl', parameters: { url: url }};
return this._sendCommand(cmd, 'ok', callback);
},
/**
* Drives window forward.
*
*
* @method goForward
* @chainable
* @param {Function} callback receives boolean.
*/
goForward: function goForward(callback) {
var cmd = { name: 'goForward' };
return this._sendCommand(cmd, 'ok', callback);
},
/**
* Drives window back.
*
* @method goBack
* @chainable
* @param {Function} callback receives boolean.
*/
goBack: function goBack(callback) {
var cmd = { name: 'goBack' };
return this._sendCommand(cmd, 'ok', callback);
},
/**
* Logs a message on marionette server.
*
*
* @method log
* @chainable
* @param {String} message log message.
* @param {String} level arbitrary log level.
* @param {Function} callback receives boolean.
* @return {Object} self.
*/
log: function log(msg, level, callback) {
var cmd = { name: 'log', parameters:{level: level, value: msg }};
return this._sendCommand(cmd, 'ok', callback);
},
/**
* Retrieves all logs on the marionette server.
* The response from marionette is an array of arrays.
*
* device.getLogs(function(err, logs){
* //logs => [
* [
* 'msg',
* 'level',
* 'Fri Apr 27 2012 11:00:32 GMT-0700 (PDT)'
* ]
* ]
* });
*
*
* @method getLogs
* @chainable
* @param {Function} callback receive an array of logs.
*/
getLogs: function getLogs(callback) {
var cmd = { name: 'getLogs' };
return this._sendCommand(cmd, 'value', callback);
},
/**
* Returns a string representation of the DOM in current page.
*
* @method pageSource
* @param {Function} [callback] optional receives the page source.
* @return {Object} self.
*/
pageSource: function pageSource(callback) {
var cmd = { name: 'getPageSource' };
return this._sendCommand(cmd, 'value', callback);
},
/**
* Creates a base64-encoded screenshot of the element, or the current frame
* if no element is specified.
*
* client.screenshot({
* element: elementToScreenshot
* });
*
* Options:
* * (Element) element: The element to take a screenshot of. If
* unspecified, will take a screenshot of the current frame
* * (Array) highlights: A list of element objects to draw a red box
* around in the returned screenshot.
*
* @method screenshot
* @param {Object} [options] (see above)
* @chainable
* @param {Function} callback
*/
screenshot: function screenshot(options, callback) {
var cmd = { name: 'screenShot', parameters: {}};
if (typeof options === 'function') {
callback = options;
options = null;
}
if (options) {
if (options.element) {
cmd.parameters.id = options.element.id;
}
if (options.highlights) {
cmd.parameters.highlights = options.highlights.map(function(elem) {
return elem.id;
});
}
}
return this._sendCommand(cmd, 'value', callback);
},
/**
* Executes a remote script will block.
* Script is *not* wrapped in a function.
*
* @method executeJsScript
* @chainable
* @param {String} script script to run.
* @param {Array} [args] optional args for script.
* @param {Array} [timeout] optional args for timeout.
* @param {Function} callback will receive result of the return \
* call in the script if there is one.
* @return {Object} self.
*/
executeJsScript: function executeJsScript(script, args, timeout, callback) {
if (typeof(timeout) === 'function') {
callback = timeout;
timeout = null;
}
if (typeof(args) === 'function') {
callback = args;
args = null;
}
timeout = (typeof(timeout) === 'boolean') ? timeout : true;
return this._executeScript({
name: 'executeJSScript',
parameters: {
script: script,
timeout: timeout,
args: args
}
}, callback || this.defaultCallback);
},
/**
* Executes a remote script will block. Script is wrapped in a function.
*
* // its is very important to remember that the contents of this
* // method are "stringified" (Function#toString) and sent over the
* // wire to execute on the device. So things like scope will not be
* // the same. If you need to pass other information in arguments
* // option should be used.
*
* // assume that this element is the result of findElement
* var element;
* var config = {
* event: 'magicCustomEvent',
* detail: { foo: true }
* };
*
* var remoteArgs = [element, details];
*
* // unlike other callbacks this one will execute _on device_
* function remoteFn(element, details) {
* // element in this context is a real dom element now.
* var event = document.createEvent('CustomEvent');
* event.initCustomEvent(config.event, true, true, event.detail);
* element.dispatchEvent(event);
*
* return { success: true };
* }
*
* client.executeJsScript(remoteFn, remoteArgs, function(err, value) {
* // value => { success: true }
* });
*
*
* @method executeScript
* @chainable
* @param {String} script script to run.
* @param {Array} [args] optional args for script.
* @param {Function} callback will receive result of the return \
* call in the script if there is one.
* @return {Object} self.
*/
executeScript: function executeScript(script, args, callback) {
if (typeof(args) === 'function') {
callback = args;
args = null;
}
return this._executeScript({
name: 'executeScript',
parameters: {
script: script,
args: args
}
}, callback || this.defaultCallback);
},
/**
* Script is wrapped in a function and will be executed asynchronously.
*
* NOTE: that setScriptTimeout _must_ be set prior to using this method
* as the timeout defaults to zero.
*
*
* function remote () {
* window.addEventListener('someevent', function() {
* // special method to notify that async script is complete.
* marionetteScriptFinished({ fromRemote: true })
* });
* }
*
* client.executeAsyncScript(remote, function(err, value) {
* // value === { fromRemote: true }
* });
*
*
* @method executeAsyncScript
* @chainable
* @param {String} script script to run.
* @param {Array} [args] optional args for script.
* @param {Function} callback will receive result of the return \
* call in the script if there is one.
* @return {Object} self.
*/
executeAsyncScript: function executeAsyncScript(script, args, callback) {
if (typeof(args) === 'function') {
callback = args;
args = null;
}
return this._executeScript({
name: 'executeAsyncScript',
parameters: {
script: script,
args: args
}
}, callback || this.defaultCallback);
},
/**
* Finds element.
*
* @method _findElement
* @private
* @param {String} type type of command to send like 'findElement'.
* @param {String} query search query.
* @param {String} method search method.
* @param {String} elementId id of element to search within.
* @param {Function} callback executes with element uuid(s).
*/
_findElement: function _findElement(type, query, method, id, callback) {
var cmd, self = this;
if (isFunction(id)) {
callback = id;
id = undefined;
}
if (isFunction(method)) {
callback = method;
method = undefined;
}
callback = callback || this.defaultCallback;
cmd = {
name: type || 'findElement',
parameters: {
value: query,
using: method || 'css selector'
}
};
// only pass element when id is given.
if (id) cmd.parameters.element = id;
if (this.searchMethods.indexOf(cmd.parameters.using) === -1) {
throw new Error(
'invalid option for using: \'' + cmd.parameters.using + '\' use one of : ' +
this.searchMethods.join(', ')
);
}
//proably should extract this function into a private
return this._sendCommand(cmd, 'value',
function processElements(err, result) {
if (result instanceof this.Element) {
return self._handleCallback(callback, err, result);
}
if (result instanceof Array) {
element = [];
result.forEach(function(el) {
if (typeof el === 'object' && el.ELEMENT) {
el = el.ELEMENT;
}
element.push(new this.Element(el, self));
}, this);
} else {
element = new this.Element(result, self);
}
return self._handleCallback(callback, err, element);
});
},
/**
* Attempts to find a dom element (via css selector, xpath, etc...)
* "elements" returned are instances of
* {{#crossLink "Marionette.Element"}}{{/crossLink}}
*
*
* // with default options
* client.findElement('#css-selector', function(err, element) {
* if (err) {
* // handle case where element was not found
* }
*
* // see element interface for all methods, etc..
* element.click(function() {
*
* });
* });
*
*
*
* @method findElement
* @chainable
* @param {String} query search query.
* @param {String} method search method.
* @param {String} elementId id of element to search within.
* @param {Function} callback executes with element uuid.
*/
findElement: function findElement() {
var args = Array.prototype.slice.call(arguments);
args.unshift('findElement');
return this._findElement.apply(this, args);
},
/**
* Finds multiple elements in the dom. This method has the same
* api signature as {{#crossLink "findElement"}}{{/crossLink}} the
* only difference is where findElement returns a single element
* this method will return an array of elements in the callback.
*
*
* // find all links in the document
* client.findElements('a[href]', function(err, element) {
* });
*
*
* @method findElements
* @chainable
* @param {String} query search query.
* @param {String} method search method.
* @param {String} elementId id of element to search within.
* @param {Function} callback executes with an array of element uuids.
*/
findElements: function findElements() {
var args = Array.prototype.slice.call(arguments);
args.unshift('findElements');
return this._findElement.apply(this, args);
},
/**
* Converts an function into a string
* that can be sent to marionette.
*
* @private
* @method _convertFunction
* @param {Function|String} fn function to call on the server.
* @return {String} function string.
*/
_convertFunction: function _convertFunction(fn) {
if (typeof(fn) === 'function') {
var str = fn.toString();
return 'return (' + str + '.apply(this, arguments));';
}
return fn;
},
/**
* Processes result of command
* if an {'ELEMENT': 'uuid'} combination
* is returned a Marionette.Element
* instance will be created and returned.
*
*
* @private
* @method _transformResultValue
* @param {Object} value original result from server.
* @return {Object|Marionette.Element} processed result.
*/
_transformResultValue: function _transformResultValue(value) {
if (value && typeof(value.ELEMENT) === 'string') {
return new this.Element(value.ELEMENT, this);
}
return value;
},
/**
* Prepares arguments for script commands.
* Formats Marionette.Element's sod
* marionette can use them in script commands.
*
*
* @private
* @method _prepareArguments
* @param {Array} arguments list of args for wrapped function.
* @return {Array} processed arguments.
*/
_prepareArguments: function _prepareArguments(args) {
if (args.map) {
return args.map(function(item) {
if (item instanceof this.Element) {
return {'ELEMENT': item.id };
}
return item;
}, this);
} else {
return args;
}
},
/**
* Executes a remote string of javascript.
* the javascript string will be wrapped in a function
* by marionette.
*
*
* @method _executeScript
* @private
* @param {Object} options objects of execute script.
* @param {String} options.type command type like 'executeScript'.
* @param {String} options.value javascript string.
* @param {String} options.args arguments for script.
* @param {Boolean} options.timeout timeout only used in 'executeJSScript'.
* @param {Function} callback executes when script finishes.
* @return {Object} self.
*/
_executeScript: function _executeScript(options, callback) {
var timeout = options.timeout,
self = this,
cmd = {
name: options.name,
parameters: {
script: this._convertFunction(options.parameters.script),
args: this._prepareArguments(options.parameters.args || [])
}
};
return this._sendCommand(cmd, 'value', callback);
}
};
//gjslint: ignore
var proto = Client.prototype;
proto.searchMethods = [];
for (key in searchMethods) {
if (searchMethods.hasOwnProperty(key)) {
Client.prototype[key] = searchMethods[key];
Client.prototype.searchMethods.push(searchMethods[key]);
}
}
module.exports = Client;
}.apply(
this,
(this.Marionette) ?
[Marionette('client'), Marionette] :
[module, require('./marionette')]
));