UNPKG

leadfoot

Version:

Leadfoot. A JavaScript client library that brings cross-platform consistency to the Selenium WebDriver API.

1,695 lines (1,521 loc) 78.1 kB
/*global document:false, window:false */ /** * @module leadfoot/Session */ var Element = require('./Element'); var findDisplayed = require('./lib/findDisplayed'); var fs = require('fs'); var JsZip = require('jszip'); var lang = require('dojo/lang'); var path = require('path'); var Promise = require('dojo/Promise'); var statusCodes = require('./lib/statusCodes'); var storage = require('./lib/storage'); var strategies = require('./lib/strategies'); var util = require('./lib/util'); var waitForDeleted = require('./lib/waitForDeleted'); /** * Finds and converts serialised DOM element objects into fully-featured typed Elements. * * @private * @param session The session from which the Element was retrieved. * @param value An object or array that may be, or may contain, serialised DOM element objects. * @returns The input value, with all serialised DOM element objects converted to typed Elements. */ function convertToElements(session, value) { // TODO: Unit test elements attached to objects function convert(value) { if (Array.isArray(value)) { value = value.map(convert); } else if (typeof value === 'object' && value !== null) { if (value.ELEMENT) { value = new Element(value, session); } else { for (var k in value) { value[k] = convert(value[k]); } } } return value; } return convert(value); } /** * Delegates the HTTP request for a method to the underlying {@link module:leadfoot/Server} object. * * @private * @param {string} method * @returns {function(string, Object, Array.<string>=): Promise.<{ sessionId: string, status: number, value: any }>} */ function delegateToServer(method) { return function (path, requestData, pathParts) { var self = this; path = 'session/' + this._sessionId + (path ? ('/' + path) : ''); if (method === '_post' && !requestData && this.capabilities.brokenEmptyPost) { requestData = {}; } return new Promise(function (resolve, reject, progress, setCanceller) { var cancelled = false; setCanceller(function (reason) { cancelled = true; throw reason; }); // The promise is cleared from `_nextRequest` once it has been resolved in order to avoid // infinitely long chains of promises retaining values that are not used any more var thisRequest; function clearNextRequest() { if (self._nextRequest === thisRequest) { self._nextRequest = null; } } function runRequest() { // `runRequest` is normally called once the previous request is finished. If this request // is cancelled before the previous request is finished, then it should simply never run. // (This Promise will have been rejected already by the cancellation.) if (cancelled) { clearNextRequest(); return; } var response = self._server[method](path, requestData, pathParts).then(returnValue); response.finally(clearNextRequest); // The value of the response always needs to be taken directly from the server call // rather than from the chained `_nextRequest` promise, since if an undefined value is // returned by the server call and that value is returned through `finally(runRequest)`, // the *previous* Promise’s resolved value will be used as the resolved value, which is // wrong resolve(response); return response; } // At least ChromeDriver 2.19 will just hard close connections if parallel requests are made to the server, // so any request sent to the server for a given session must be serialised. Other servers like Selendroid // have been known to have issues with parallel requests as well, so serialisation is applied universally, // even though it has negative performance implications if (self._nextRequest) { thisRequest = self._nextRequest = self._nextRequest.finally(runRequest); } else { thisRequest = self._nextRequest = runRequest(); } }); }; } /** * As of Selenium 2.40.0 (March 2014), all drivers incorrectly transmit an UnknownError instead of a * JavaScriptError when user code fails to execute correctly. This method corrects this status code, under the * assumption that drivers will follow the spec in future. * * @private */ function fixExecuteError(error) { if (error.name === 'UnknownError') { error.status = 17; error.name = statusCodes[error.status][0]; } throw error; } function noop() { // At least ios-driver 0.6.6 returns an empty object for methods that are supposed to return no value at all, // which is not correct } /** * HTTP cookies are transmitted as semicolon-delimited strings, with a `key=value` pair giving the cookie’s name and * value, then additional information about the cookie (expiry, path, domain, etc.) as additional k-v pairs. This * method takes an Array describing the parts of a cookie (`target`), and a hash map containing the additional * information (`source`), and pushes the properties from the source object onto the target array as properly * escaped key-value strings. * * @private * @param {Array} target * @param {Object} source */ function pushCookieProperties(target, source) { Object.keys(source).forEach(function (key) { var value = source[key]; if (key === 'name' || key === 'value' || (key === 'domain' && value === 'http')) { return; } if (typeof value === 'boolean') { value && target.push(key); } // JsonWireProtocol uses the key 'expiry' but JavaScript cookies use the key 'expires' else if (key === 'expiry') { if (typeof value === 'number') { value = new Date(value * 1000); } if (value instanceof Date) { value = Date.toUTCString(); } target.push('expires=' + encodeURIComponent(value)); } else { target.push(key + '=' + encodeURIComponent(value)); } }); } /** * Returns the actual response value from the remote environment. * * @private * @param {Object} response JsonWireProtocol response object. * @returns {any} The actual response value. */ function returnValue(response) { return response.value; } /* istanbul ignore next */ /** * Simulates a keyboard event as it would occur on Safari 7. * * @private * @param {Array.<string>} keys Keys to type. */ function simulateKeys(keys) { var target = document.activeElement; function dispatch(kwArgs) { var event; if (typeof KeyboardEvent === 'function') { event = new KeyboardEvent(kwArgs.type, { bubbles: true, cancelable: kwArgs.cancelable || false, view: window, key: kwArgs.key || '', location: 3 }); } else { event = document.createEvent('KeyboardEvent'); event.initKeyboardEvent( kwArgs.type, true, kwArgs.cancelable || false, window, kwArgs.key || '', 3, '', 0, '' ); } return target.dispatchEvent(event); } function dispatchInput() { var event; if (typeof Event === 'function') { event = new Event('input', { bubbles: true, cancelable: false }); } else { event = document.createEvent('Event'); event.initEvent('input', true, false); } return target.dispatchEvent(event); } keys = Array.prototype.concat.apply([], keys.map(function (keys) { return keys.split(''); })); for (var i = 0, j = keys.length; i < j; ++i) { var key = keys[i]; var performDefault = true; performDefault = dispatch({ type: 'keydown', cancelable: true, key: key }); performDefault = performDefault && dispatch({ type: 'keypress', cancelable: true, key: key }); if (performDefault) { if ('value' in target) { target.value = target.value.slice(0, target.selectionStart) + key + target.value.slice(target.selectionEnd); dispatchInput(); } else if (target.isContentEditable) { var node = document.createTextNode(key); var selection = window.getSelection(); var range = selection.getRangeAt(0); range.deleteContents(); range.insertNode(node); range.setStartAfter(node); range.setEndAfter(node); selection.removeAllRanges(); selection.addRange(range); } } dispatch({ type: 'keyup', cancelable: true, key: key }); } } /* istanbul ignore next */ /** * Simulates a mouse event as it would occur on Safari 7. * * @private * @param {Object} kwArgs Parameters for the mouse event. */ function simulateMouse(kwArgs) { var position = kwArgs.position; function dispatch(kwArgs) { var event; if (typeof MouseEvent === 'function') { event = new MouseEvent(kwArgs.type, { bubbles: 'bubbles' in kwArgs ? kwArgs.bubbles : true, cancelable: kwArgs.cancelable || false, view: window, detail: kwArgs.detail || 0, screenX: window.screenX + position.x, screenY: window.screenY + position.y, clientX: position.x, clientY: position.y, ctrlKey: kwArgs.ctrlKey || false, shiftKey: kwArgs.shiftKey || false, altKey: kwArgs.altKey || false, metaKey: kwArgs.metaKey || false, button: kwArgs.button || 0, relatedTarget: kwArgs.relatedTarget }); } else { event = document.createEvent('MouseEvents'); event.initMouseEvent( kwArgs.type, kwArgs.bubbles || true, kwArgs.cancelable || false, window, kwArgs.detail || 0, window.screenX + position.x, window.screenY + position.y, position.x, position.y, kwArgs.ctrlKey || false, kwArgs.altKey || false, kwArgs.shiftKey || false, kwArgs.metaKey || false, kwArgs.button || 0, kwArgs.relatedTarget || null ); } return kwArgs.target.dispatchEvent(event); } function click(target, button, detail) { if (!down(target, button)) { return false; } if (!up(target, button)) { return false; } return dispatch({ button: button, cancelable: true, detail: detail, target: target, type: 'click' }); } function down(target, button) { return dispatch({ button: button, cancelable: true, target: target, type: 'mousedown' }); } function up(target, button) { return dispatch({ button: button, cancelable: true, target: target, type: 'mouseup' }); } function move(currentElement, newElement, xOffset, yOffset) { if (newElement) { var bbox = newElement.getBoundingClientRect(); if (xOffset == null) { xOffset = (bbox.right - bbox.left) * 0.5; } if (yOffset == null) { yOffset = (bbox.bottom - bbox.top) * 0.5; } position = { x: bbox.left + xOffset, y: bbox.top + yOffset }; } else { position.x += xOffset || 0; position.y += yOffset || 0; newElement = document.elementFromPoint(position.x, position.y); } if (currentElement !== newElement) { dispatch({ type: 'mouseout', target: currentElement, relatedTarget: newElement }); dispatch({ type: 'mouseleave', target: currentElement, relatedTarget: newElement, bubbles: false }); dispatch({ type: 'mouseenter', target: newElement, relatedTarget: currentElement, bubbles: false }); dispatch({ type: 'mouseover', target: newElement, relatedTarget: currentElement }); } dispatch({ type: 'mousemove', target: newElement, bubbles: true }); return position; } var target = document.elementFromPoint(position.x, position.y); if (kwArgs.action === 'mousemove') { return move(target, kwArgs.element, kwArgs.xOffset, kwArgs.yOffset); } else if (kwArgs.action === 'mousedown') { return down(target, kwArgs.button); } else if (kwArgs.action === 'mouseup') { return up(target, kwArgs.button); } else if (kwArgs.action === 'click') { return click(target, kwArgs.button, 0); } else if (kwArgs.action === 'dblclick') { if (!click(target, kwArgs.button, 0)) { return false; } if (!click(target, kwArgs.button, 1)) { return false; } return dispatch({ type: 'dblclick', target: target, button: kwArgs.button, detail: 2, cancelable: true }); } } /** * A Session represents a connection to a remote environment that can be driven programmatically. * * @constructor module:leadfoot/Session * @param {string} sessionId The ID of the session, as provided by the remote. * @param {module:leadfoot/Server} server The server that the session belongs to. * @param {Capabilities} capabilities A map of bugs and features that the remote environment exposes. */ function Session(sessionId, server, capabilities) { this._sessionId = sessionId; this._server = server; this._capabilities = capabilities; this._closedWindows = {}; this._timeouts = { script: Promise.resolve(0), implicit: Promise.resolve(0), 'page load': Promise.resolve(Infinity) }; } /** * @lends module:leadfoot/Session# */ Session.prototype = { constructor: Session, _movedToElement: false, _lastMousePosition: null, _lastAltitude: null, _closedWindows: null, // TODO: Timeouts are held so that we can fiddle with the implicit wait timeout to add efficient `waitFor` // and `waitForDeleted` convenience methods. Technically only the implicit timeout is necessary. _timeouts: {}, /** * Information about the available features and bugs in the remote environment. * * @member {Capabilities} capabilities * @memberOf module:leadfoot/Session# * @readonly */ get capabilities() { return this._capabilities; }, /** * The current session ID. * * @member {string} sessionId * @memberOf module:leadfoot/Session# * @readonly */ get sessionId() { return this._sessionId; }, /** * The Server that the session runs on. * * @member {module:leadfoot/Server} server * @memberOf module:leadfoot/Session# * @readonly */ get server() { return this._server; }, _get: delegateToServer('_get'), _post: delegateToServer('_post'), _delete: delegateToServer('_delete'), /** * Gets the current value of a timeout for the session. * * @param {string} type The type of timeout to retrieve. One of 'script', 'implicit', or 'page load'. * @returns {Promise.<number>} The timeout, in milliseconds. */ getTimeout: function (type) { return this._timeouts[type]; }, /** * Sets the value of a timeout for the session. * * @param {string} type * The type of timeout to set. One of 'script', 'implicit', or 'page load'. * * @param {number} ms * The length of time to use for the timeout, in milliseconds. A value of 0 will cause operations to time out * immediately. * * @returns {Promise.<void>} */ setTimeout: function (type, ms) { // Infinity cannot be serialised by JSON if (ms === Infinity) { // It seems that at least ChromeDriver 2.10 has a limit here that is near the 32-bit signed integer limit, // and IEDriverServer 2.42.2 has an even lower limit; 2.33 hours should be infinite enough for testing ms = Math.pow(2, 23) - 1; } // If the target doesn't support a timeout of 0, use 1. if (this.capabilities.brokenZeroTimeout && ms === 0) { ms = 1; } var self = this; var promise = this._post('timeouts', { type: type, ms: ms }).catch(function (error) { // Appium as of April 2014 complains that `timeouts` is unsupported, so try the more specific // endpoints if they exist if (error.name === 'UnknownCommand') { if (type === 'script') { return self._post('timeouts/async_script', { ms: ms }); } else if (type === 'implicit') { return self._post('timeouts/implicit_wait', { ms: ms }); } } throw error; }).then(noop); this._timeouts[type] = promise.then(function () { return ms; }); return promise; }, /** * Gets the identifier for the window that is currently focused. * * @returns {Promise.<string>} A window handle identifier that can be used with other window handling functions. */ getCurrentWindowHandle: function () { var self = this; return this._get('window_handle').then(function (handle) { if (self.capabilities.brokenDeleteWindow && self._closedWindows[handle]) { var error = new Error(); error.status = 23; error.name = statusCodes[error.status][0]; error.message = statusCodes[error.status][1]; throw error; } return handle; }); }, /** * Gets a list of identifiers for all currently open windows. * * @returns {Promise.<string[]>} */ getAllWindowHandles: function () { var self = this; return this._get('window_handles').then(function (handles) { if (self.capabilities.brokenDeleteWindow) { return handles.filter(function (handle) { return !self._closedWindows[handle]; }); } return handles; }); }, /** * Gets the URL that is loaded in the focused window/frame. * * @returns {Promise.<string>} */ getCurrentUrl: function () { return this._get('url'); }, /** * Navigates the focused window/frame to a new URL. * * @param {string} url * @returns {Promise.<void>} */ get: function (url) { this._movedToElement = false; if (this.capabilities.brokenMouseEvents) { this._lastMousePosition = { x: 0, y: 0 }; } return this._post('url', { url: url }).then(noop); }, /** * Navigates the focused window/frame forward one page using the browser’s navigation history. * * @returns {Promise.<void>} */ goForward: function () { // TODO: SPEC: Seems like this and `back` should return the newly navigated URL. return this._post('forward').then(noop); }, /** * Navigates the focused window/frame back one page using the browser’s navigation history. * * @returns {Promise.<void>} */ goBack: function () { return this._post('back').then(noop); }, /** * Reloads the current browser window/frame. * * @returns {Promise.<void>} */ refresh: function () { if (this.capabilities.brokenRefresh) { return this.execute('location.reload();'); } return this._post('refresh').then(noop); }, /** * Executes JavaScript code within the focused window/frame. The code should return a value synchronously. * * @see {@link module:leadfoot/Session#executeAsync} to execute code that returns values asynchronously. * * @param {Function|string} script * The code to execute. This function will always be converted to a string, sent to the remote environment, and * reassembled as a new anonymous function on the remote end. This means that you cannot access any variables * through closure. If your code needs to get data from variables on the local end, they should be passed using * `args`. * * @param {any[]} args * An array of arguments that will be passed to the executed code. Only values that can be serialised to JSON, plus * {@link module:leadfoot/Element} objects, can be specified as arguments. * * @returns {Promise.<any>} * The value returned by the remote code. Only values that can be serialised to JSON, plus DOM elements, can be * returned. */ execute: function (script, args) { // At least FirefoxDriver 2.40.0 will throw a confusing NullPointerException if args is not an array; // provide a friendlier error message to users that accidentally pass a non-array if (typeof args !== 'undefined' && !Array.isArray(args)) { throw new Error('Arguments passed to execute must be an array'); } var self = this; var route = this.capabilities.useExecuteSyncEndpoint ? 'execute/sync' : 'execute'; var result = this._post(route, { script: util.toExecuteString(script), args: args || [] }).then(lang.partial(convertToElements, this), fixExecuteError).catch(function (error) { if (error.detail.error === 'unknown command' && !self.capabilities.useExecuteSyncEndpoint) { self.capabilities.useExecuteSyncEndpoint = true; return self.execute(script, args); } throw error; }); if (this.capabilities.brokenExecuteUndefinedReturn) { result = result.then(function (value) { if (value === undefined) { value = null; } return value; }); } return result; }, /** * Executes JavaScript code within the focused window/frame. The code must invoke the provided callback in * order to signal that it has completed execution. * * @see {@link module:leadfoot/Session#execute} to execute code that returns values synchronously. * @see {@link module:leadfoot/Session#setExecuteAsyncTimeout} to set the time until an asynchronous script is * considered timed out. * * @param {Function|string} script * The code to execute. This function will always be converted to a string, sent to the remote environment, and * reassembled as a new anonymous function on the remote end. This means that you cannot access any variables * through closure. If your code needs to get data from variables on the local end, they should be passed using * `args`. * * @param {any[]} args * An array of arguments that will be passed to the executed code. Only values that can be serialised to JSON, plus * {@link module:leadfoot/Element} objects, can be specified as arguments. In addition to these arguments, a * callback function will always be passed as the final argument to the function specified in `script`. This * callback function must be invoked in order to signal that execution has completed. The return value of the * execution, if any, should be passed to this callback function. * * @returns {Promise.<any>} * The value returned by the remote code. Only values that can be serialised to JSON, plus DOM elements, can be * returned. */ executeAsync: function (script, args) { // At least FirefoxDriver 2.40.0 will throw a confusing NullPointerException if args is not an array; // provide a friendlier error message to users that accidentally pass a non-array if (typeof args !== 'undefined' && !Array.isArray(args)) { throw new Error('Arguments passed to executeAsync must be an array'); } return this._post('execute_async', { script: util.toExecuteString(script), args: args || [] }).then(lang.partial(convertToElements, this), fixExecuteError); }, /** * Gets a screenshot of the focused window and returns it in PNG format. * * @returns {Promise.<Buffer>} A buffer containing a PNG image. */ takeScreenshot: function () { return this._get('screenshot').then(function (data) { /*jshint node:true */ return new Buffer(data, 'base64'); }); }, /** * Gets a list of input method editor engines available to the remote environment. * As of April 2014, no known remote environments support IME functions. * * @returns {Promise.<string[]>} */ getAvailableImeEngines: function () { return this._get('ime/available_engines'); }, /** * Gets the currently active input method editor for the remote environment. * As of April 2014, no known remote environments support IME functions. * * @returns {Promise.<string>} */ getActiveImeEngine: function () { return this._get('ime/active_engine'); }, /** * Returns whether or not an input method editor is currently active in the remote environment. * As of April 2014, no known remote environments support IME functions. * * @returns {Promise.<boolean>} */ isImeActivated: function () { return this._get('ime/activated'); }, /** * Deactivates any active input method editor in the remote environment. * As of April 2014, no known remote environments support IME functions. * * @returns {Promise.<void>} */ deactivateIme: function () { return this._post('ime/deactivate'); }, /** * Activates an input method editor in the remote environment. * As of April 2014, no known remote environments support IME functions. * * @param {string} engine The type of IME to activate. * @returns {Promise.<void>} */ activateIme: function (engine) { return this._post('ime/activate', { engine: engine }); }, /** * Switches the currently focused frame to a new frame. * * @param {string|number|null|Element} id * The frame to switch to. In most environments, a number or string value corresponds to a key in the * `window.frames` object of the currently active frame. If `null`, the topmost (default) frame will be used. * If an Element is provided, it must correspond to a `<frame>` or `<iframe>` element. * * @returns {Promise.<void>} */ switchToFrame: function (id) { return this._post('frame', { id: id }).then(noop); }, /** * Switches the currently focused window to a new window. * * @param {string} handle * The handle of the window to switch to. In mobile environments and environments based on the W3C WebDriver * standard, this should be a handle as returned by {@link module:leadfoot/Session#getAllWindowHandles}. * * In environments using the JsonWireProtocol, this value corresponds to the `window.name` property of a window. * * @returns {Promise.<void>} */ switchToWindow: function (handle) { return this._post('window', { // TODO: Note that in the W3C standard, the property is 'handle' name: handle }).then(noop); }, /** * Switches the currently focused frame to the parent of the currently focused frame. * * @returns {Promise.<void>} */ switchToParentFrame: function () { var self = this; return this._post('frame/parent').catch(function (error) { // At least FirefoxDriver 2.40.0 does not implement this command, but we can fake it by retrieving // the parent frame element using JavaScript and switching to it directly by reference // At least Selendroid 0.9.0 also does not support this command, but unfortunately throws an incorrect // error so it looks like a fatal error; see https://github.com/selendroid/selendroid/issues/364 if (error.name === 'UnknownCommand' || ( self.capabilities.browserName === 'selendroid' && error.message.indexOf('Error occured while communicating with selendroid server') > -1 ) ) { if (self.capabilities.scriptedParentFrameCrashesBrowser) { throw error; } return self.execute('return window.parent.frameElement;').then(function (parent) { // TODO: Using `null` if no parent frame was returned keeps the request from being invalid, // but may be incorrect and may cause incorrect frame retargeting on certain platforms; // At least Selendroid 0.9.0 fails both commands return self.switchToFrame(parent || null); }); } throw error; }).then(noop); }, /** * Closes the currently focused window. In most environments, after the window has been closed, it is necessary * to explicitly switch to whatever window is now focused. * * @returns {Promise.<void>} */ closeCurrentWindow: function () { function manualClose() { return self.getCurrentWindowHandle().then(function (handle) { return self.execute('window.close();').then(function () { self._closedWindows[handle] = true; }); }); } var self = this; if (self.capabilities.brokenDeleteWindow) { return manualClose(); } return this._delete('window').catch(function (error) { // ios-driver 0.6.6-SNAPSHOT April 2014 does not implement close window command if (error.name === 'UnknownCommand') { self.capabilities.brokenDeleteWindow = true; return manualClose(); } throw error; }).then(noop); }, /** * Sets the dimensions of a window. * * @param {string=} windowHandle * The name of the window to resize. See {@link module:leadfoot/Session#switchToWindow} to learn about valid * window names. Omit this argument to resize the currently focused window. * * @param {number} width * The new width of the window, in CSS pixels. * * @param {number} height * The new height of the window, in CSS pixels. * * @returns {Promise.<void>} */ setWindowSize: function (windowHandle, width, height) { if (typeof height === 'undefined') { height = width; width = windowHandle; windowHandle = null; } var data = { width: width, height: height }; var self = this; function _setWindowSize() { if (self.capabilities.supportsWindowRectCommand) { data.x = null; data.y = null; return self._post('window/rect', data); } else { return self._post('window/size', data); } } if (this.capabilities.implicitWindowHandles) { if (windowHandle == null) { return _setWindowSize(); } else { // User provided a window handle; get the current handle, switch to the new one, get the size, then // switch back to the original handle. var error; return this.getCurrentWindowHandle().then(function (originalHandle) { return self.switchToWindow(windowHandle).then(function () { return _setWindowSize(); }).catch(function (_error) { error = _error; }).then(function () { return self.switchToWindow(originalHandle); }).then(function () { if (error) { throw error; } }); }); } } else { if (windowHandle == null) { windowHandle = 'current'; } return this._post('window/$0/size', { width: width, height: height }, [ windowHandle ]).then(noop); } }, /** * Gets the dimensions of a window. * * @param {string=} windowHandle * The name of the window to query. See {@link module:leadfoot/Session#switchToWindow} to learn about valid * window names. Omit this argument to query the currently focused window. * * @returns {Promise.<{ width: number, height: number }>} * An object describing the width and height of the window, in CSS pixels. */ getWindowSize: function (windowHandle) { var self = this; function _getWindowSize() { if (self.capabilities.supportsWindowRectCommand) { return self._get('window/rect').then(function (rect) { return { width: rect.width, height: rect.height }; }); } else { return self._get('window/size'); } } if (this.capabilities.implicitWindowHandles) { if (windowHandle == null) { return _getWindowSize(); } else { // User provided a window handle; get the current handle, switch to the new one, get the size, then // switch back to the original handle. var error; var size; return this.getCurrentWindowHandle().then(function (originalHandle) { return self.switchToWindow(windowHandle).then(function () { return _getWindowSize(); }).then(function (_size) { size = _size; }, function (_error) { error = _error; }).then(function () { return self.switchToWindow(originalHandle); }).then(function () { if (error) { throw error; } return size; }); }); } } else { if (typeof windowHandle === 'undefined') { windowHandle = 'current'; } return this._get('window/$0/size', null, [ windowHandle ]); } }, /** * Sets the position of a window. * * Note that this method is not part of the W3C WebDriver standard. * * @param {string=} windowHandle * The name of the window to move. See {@link module:leadfoot/Session#switchToWindow} to learn about valid * window names. Omit this argument to move the currently focused window. * * @param {number} x * The screen x-coordinate to move to, in CSS pixels, relative to the left edge of the primary monitor. * * @param {number} y * The screen y-coordinate to move to, in CSS pixels, relative to the top edge of the primary monitor. * * @returns {Promise.<void>} */ setWindowPosition: function (windowHandle, x, y) { if (typeof y === 'undefined') { y = x; x = windowHandle; windowHandle = 'current'; } return this._post('window/$0/position', { x: x, y: y }, [ windowHandle ]).then(noop); }, /** * Gets the position of a window. * * Note that this method is not part of the W3C WebDriver standard. * * @param {string=} windowHandle * The name of the window to query. See {@link module:leadfoot/Session#switchToWindow} to learn about valid * window names. Omit this argument to query the currently focused window. * * @returns {Promise.<{ x: number, y: number }>} * An object describing the position of the window, in CSS pixels, relative to the top-left corner of the * primary monitor. If a secondary monitor exists above or to the left of the primary monitor, these values * will be negative. */ getWindowPosition: function (windowHandle) { if (windowHandle == null) { windowHandle = 'current'; } return this._get('window/$0/position', null, [ windowHandle ]).then(function (position) { // At least InternetExplorerDriver 2.41.0 on IE9 returns an object containing extra properties return { x: position.x, y: position.y }; }); }, /** * Maximises a window according to the platform’s window system behaviour. * * @param {string=} windowHandle * The name of the window to resize. See {@link module:leadfoot/Session#switchToWindow} to learn about valid * window names. Omit this argument to resize the currently focused window. * * @returns {Promise.<void>} */ maximizeWindow: function (windowHandle) { if (typeof windowHandle === 'undefined') { windowHandle = 'current'; } return this._post('window/$0/maximize', null, [ windowHandle ]).then(noop); }, /** * Gets all cookies set on the current page. * * @returns {Promise.<WebDriverCookie[]>} */ getCookies: function () { return this._get('cookie').then(function (cookies) { // At least SafariDriver 2.41.0 returns cookies with extra class and hCode properties that should not // exist return cookies.map(function (badCookie) { var cookie = {}; for (var key in badCookie) { if (key === 'name' || key === 'value' || key === 'path' || key === 'domain' || key === 'secure' || key === 'httpOnly' || key === 'expiry' ) { cookie[key] = badCookie[key]; } } if (typeof cookie.expiry === 'number') { cookie.expiry = new Date(cookie.expiry * 1000); } return cookie; }); }); }, /** * Sets a cookie on the current page. * * @param {WebDriverCookie} cookie * @returns {Promise.<void>} */ setCookie: function (cookie) { var self = this; if (typeof cookie.expiry === 'string') { cookie.expiry = new Date(cookie.expiry); } if (cookie.expiry instanceof Date) { cookie.expiry = cookie.expiry.valueOf() / 1000; } return this._post('cookie', { cookie: cookie }).catch(function (error) { // At least ios-driver 0.6.0-SNAPSHOT April 2014 does not know how to set cookies if (error.name === 'UnknownCommand') { // Per RFC6265 section 4.1.1, cookie names must match `token` (any US-ASCII character except for // control characters and separators as defined in RFC2616 section 2.2) if (/[^A-Za-z0-9!#$%&'*+.^_`|~-]/.test(cookie.name)) { error = new Error(); error.status = 25; error.name = statusCodes[error.status[0]]; error.message = 'Invalid cookie name'; throw error; } if (/[^\u0021\u0023-\u002b\u002d-\u003a\u003c-\u005b\u005d-\u007e]/.test(cookie.value)) { error = new Error(); error.status = 25; error.name = statusCodes[error.status[0]]; error.message = 'Invalid cookie value'; throw error; } var cookieToSet = [ cookie.name + '=' + cookie.value ]; pushCookieProperties(cookieToSet, cookie); return self.execute(/* istanbul ignore next */ function (cookie) { document.cookie = cookie; }, [ cookieToSet.join(';') ]); } throw error; }).then(noop); }, /** * Clears all cookies for the current page. * * @returns {Promise.<void>} */ clearCookies: function () { if (this.capabilities.brokenDeleteCookie) { var self = this; return this.getCookies().then(function (cookies) { return cookies.reduce(function (promise, cookie) { var expiredCookie = [ cookie.name + '=', 'expires=Thu, 01 Jan 1970 00:00:00 GMT' ]; pushCookieProperties(expiredCookie, cookie); return promise.then(function () { return self.execute(/* istanbul ignore next */ function (expiredCookie) { document.cookie = expiredCookie + '; domain=' + encodeURIComponent(document.domain) + // Assume the cookie was created by Selenium, so it's path is '/'; at least MS Edge // requires a path to delete a cookie '; path=/'; }, [ expiredCookie.join(';') ]); }); }, Promise.resolve()); }); } return this._delete('cookie').then(noop); }, /** * Deletes a cookie on the current page. * * @param {string} name The name of the cookie to delete. * @returns {Promise.<void>} */ deleteCookie: function (name) { if (this.capabilities.brokenDeleteCookie) { var self = this; return this.getCookies().then(function (cookies) { var cookie; if (cookies.some(function (value) { if (value.name === name) { cookie = value; return true; } })) { var expiredCookie = [ cookie.name + '=', 'expires=Thu, 01 Jan 1970 00:00:00 GMT' ]; pushCookieProperties(expiredCookie, cookie); return self.execute(/* istanbul ignore next */ function (expiredCookie) { document.cookie = expiredCookie + '; domain=' + encodeURIComponent(document.domain) + // Assume the cookie was created by Selenium, so it's path is '/'; at least MS Edge requires // a path to delete a cookie '; path=/'; }, [ expiredCookie.join(';') ]); } }); } return this._delete('cookie/$0', null, [ name ]).then(noop); }, /** * Gets the HTML loaded in the focused window/frame. This markup is serialised by the remote environment so * may not exactly match the HTML provided by the Web server. * * @returns {Promise.<string>} */ getPageSource: function () { if (this.capabilities.brokenPageSource) { return this.execute(/* istanbul ignore next */ function () { return document.documentElement.outerHTML; }); } else { return this._get('source'); } }, /** * Gets the title of the top-level browsing context of the current window or tab. * * @returns {Promise.<string>} */ getPageTitle: function () { return this._get('title'); }, /** * Gets the first element from the focused window/frame that matches the given query. * * @see {@link module:leadfoot/Session#setFindTimeout} to set the amount of time it the remote environment * should spend waiting for an element that does not exist at the time of the `find` call before timing * out. * * @param {string} using * The element retrieval strategy to use. One of 'class name', 'css selector', 'id', 'name', 'link text', * 'partial link text', 'tag name', 'xpath'. * * @param {string} value * The strategy-specific value to search for. For example, if `using` is 'id', `value` should be the ID of the * element to retrieve. * * @returns {Promise.<module:leadfoot/Element>} */ find: function (using, value) { var self = this; if (this.capabilities.isWebDriver) { var locator = strategies.toW3cLocator(using, value); using = locator.using; value = locator.value; } if (using.indexOf('link text') !== -1 && ( this.capabilities.brokenWhitespaceNormalization || this.capabilities.brokenLinkTextLocator )) { return this.execute(/* istanbul ignore next */ this._manualFindByLinkText, [ using, value ]) .then(function (element) { if (!element) { var error = new Error(); error.name = 'NoSuchElement'; throw error; } return new Element(element, self); }); } return this._post('element', { using: using, value: value }).then(function (element) { return new Element(element, self); }); }, /** * Gets an array of elements from the focused window/frame that match the given query. * * @param {string} using * The element retrieval strategy to use. See {@link module:leadfoot/Session#find} for options. * * @param {string} value * The strategy-specific value to search for. See {@link module:leadfoot/Session#find} for details. * * @returns {Promise.<module:leadfoot/Element[]>} */ findAll: function (using, value) { var self = this; if (this.capabilities.isWebDriver) { var locator = strategies.toW3cLocator(using, value); using = locator.using; value = locator.value; } if (using.indexOf('link text') !== -1 && ( this.capabilities.brokenWhitespaceNormalization || this.capabilities.brokenLinkTextLocator )) { return this.execute(/* istanbul ignore next */ this._manualFindByLinkText, [ using, value, true ]) .then(function (elements) { return elements.map(function (element) { return new Element(element, self); }); }); } return this._post('elements', { using: using, value: value }).then(function (elements) { return elements.map(function (element) { return new Element(element, self); }); }); }, /** * Gets the currently focused element from the focused window/frame. * * @method * @returns {Promise.<module:leadfoot/Element>} */ getActiveElement: util.forCommand(function () { function getDocumentActiveElement() { return self.execute('return document.activeElement;'); } var self = this; if (this.capabilities.brokenActiveElement) { return getDocumentActiveElement(); } else { var activeFunc = this.capabilities.useGetForActiveElement ? this._get : this._post; return activeFunc.call(this, 'element/active').then(function (element) { if (element) { return new Element(element, self); } // The driver will return `null` if the active element is the body element; for consistency with how // the DOM `document.activeElement` property works, we’ll diverge and always return an element else { return getDocumentActiveElement(); } }).catch(function(error) { if (error.detail.error === 'unknown command' && !self.capabilities.useGetForActiveElement) { self.capabilities.useGetForActiveElement = true; return self.getActiveElement(); } throw error; }); } }, { createsContext: true }), /** * Types into the focused window/frame/element. * * @param {string|string[]} keys * The text to type in the remote environment. It is possible to type keys that do not have normal character * representations (modifier keys, function keys, etc.) as well as keys that have two different representations * on a typical US-ASCII keyboard (numpad keys); use the values from {@link module:leadfoot/keys} to type these * special characters. Any modifier keys that are activated by this call will persist until they are * deactivated. To deactivate a modifier key, type the same modifier key a second time, or send `\uE000` * ('NULL') to deactivate all currently active modifier keys. * * @returns {Promise.<void>} */ pressKeys: function (keys) { if (!Array.isArray(keys)) { keys = [ keys ]; } if (this.capabilities.brokenSendKeys || !this.capabilities.supportsKeysCommand) { return this.execute(simulateKeys, [ keys ]); } return this._post('keys', { value: keys }).then(noop); }, /** * Gets the current screen orientation. * * @returns {Promise.<string>} Either 'portrait' or 'landscape'. */ getOrientation: function () { return this._get('orientation').then(function (orientation) { return orientation.toLowerCase(); }); }, /** * Sets the screen orientation. * * @param {string} orientation Either 'portrait' or 'landscape'. * @returns {Promise.<void>} */ setOrientation: function (orientation) { orientation = orientation.toUpperCase(); return this._post('orientation', { orientation: orientation }).then(noop); }, /** * Gets the text displayed in the currently active alert pop-up. * * @returns {Promise.<string>} */ getAlertText: function () { return this._get('alert_text'); }, /** * Types into the currently active prompt pop-up. * * @param {string|string[]} text The text to type into the pop-up’s input box. * @returns {Promise.<void>} */ typeInPrompt: function (text) { if (Array.isArray(text)) { text = text.join(''); } return this._post('alert_text', { text: text }).then(noop); }, /** * Accepts an alert, prompt, or confirmation pop-up. Equivalent to clicking the 'OK' button. * * @returns {Promise.<void>} */ acceptAlert: function () { return this._post('accept_alert').then(noop); }, /** * Dismisses an alert, prompt, or confirmation pop-up. Equivalent to clicking the 'OK' button of an alert pop-up * or the 'Cancel' button of a prompt or confirmation pop-up. * * @returns {Promise.<void>} */ dismissAlert: function () { return this._post('dismiss_alert').then(noop); }, /** * Moves the remote environment’s mouse cursor to the specified element or relative position. If the element is * outside of the viewport, the remote driver will attempt to scroll it into view automatically. * * @method * @param {Element=} element * The element to move the mouse to. If x-offset and y-offset are not specified, the mouse will be moved to the * centre of the element. * * @param {number=} xOffset * The x-offset of the cursor, maybe in CSS pixels, relative to the left edge of the specified element’s * bounding client rectangle. If no element is specified, the offset is relative to the previous position of the * mouse, or to the left edge of the page’s root element if the mouse was never moved before. * * @param {number=} yOffset * The y-offset of the cursor, maybe in CSS pixels, relative to the top edge of the specified element’s bounding * client rectangle. If no element is specified, the offset is relative to the previous position of the mouse, * or to the top edge of the page’s root element if the mouse was never moved before. * * @returns {Promise.<void>} */ moveMouseTo: util.forCommand(function (element, xOffset, yOffset) { var self = this; if (typeof yOffset === 'undefined' && typeof xOffset !== 'undefined') { yOffset = xOffset; xOffset = element; element = null; } if (this.capabilities.brokenMouseEvents) { return this.execute(simulateMouse, [ { action: 'mousemove', position: self._lastMousePosition, element: element, xOffset: xOffset, yOffset: yOffset } ]).then(function (newPosition) { self._lastMousePosition = newPosition; }); } if (element) { element = element.elementId; } // If the mouse has not been moved to any element on this page yet, drivers will either throw errors // (FirefoxDriver 2.40.0) or silently fail (ChromeDriver 2.9) when trying to move the mouse cursor relative // to the "previous" position; in this case, we just assume that the mouse position defaults to the // top-left corner of the document else if (!this._movedToElement) { if (this.capabilities.brokenHtmlMouseMove) { return this.execute('return document.body;').then(function (element) { return element.getPosition().then(function (position) { return self.moveMouseTo(element, xOffset - position.x, yOffset - position.y); }); }); } else { return this.execute('return document.documentElement;').then(function (element) { return self.moveMouseTo(element, xOffset, yOffset); }); } } return this._post('moveto', { element: element, xoffset: xOffset, yoffset: yOffset }).then(function () { self._movedToElement = true; }); }, { usesElement: true }), /** * Clicks a mouse button at the point where the mouse cursor is currently positioned. This method may fail to * execute with an error if the mouse has not been moved anywhere since the page was loaded. * * @param {number=} button * The button to click. 0 corresponds to the primary mouse button, 1 to the middle mouse button, 2 to the * secondary mouse button. Numbers above 2 correspond to any additional buttons a mouse might provide. * * @returns {Promise.<void>} */ clickMouseButton: function (button) { if (this.capabilities.brokenMouseEvents) { return this.execute(simulateMouse, [ { action: 'click', button: button, position: this._lastMousePosition } ]).then(noop); } var self = this; return this._post('click', { button: button }).then(function () { // ios-driver 0.6.6-SNAPSHOT April 2014 does not wait until the default action for a click event occurs // before returning if (self.capabilities.touchEnabled) { return util.sleep(300); } }); }, /** * Depresses a mouse button without releasing it. * * @param {number=} button The button to press. See {@link module:leadfoot/Session#click} for available options. * @returns {Promise.<void>} */ pressMouseButton: function (button) { if (this.capabilities.brokenMouseEvents) { return this.execute(simulateMouse, [ { action: 'mousedown', button: button, position: this._lastMousePosition } ]).then(noop); } return this._post('buttondown', { button: button }).then(noop); }, /** * Releases a previously depressed mouse button. * * @param {number=} button The button to press. See {@link module:leadfoot/Session#click} for available options. * @returns {Promise.<void>} */ releaseMouseButton: function (button) { if (this.capabilities.brokenMouseEvents) { return this.execute(simulateMouse, [ { action: 'mouseup', button: button, position: this._lastMousePosition } ]).then(noop); } return this._post('buttonup', { button: button }).then(noop); }, /** * Double-clicks the primary mouse button. * * @returns {Promise.<void>} */ doubleClick: function () { if (this.capabilities.brokenMouseEvents) { return this.execute(simulateMouse, [ { action: 'dblclick', button: 0, position: this._lastMousePosition } ]).then(noop); } else if (this.capabilities.brokenDoubleClick) { var self = this; return this.pressMouseButton().then(function () { return self.releaseMouseButton(); }).then(function () { return self._post('doubleclick'); }); } return this._post('doubleclick').then(noop); }, /** * Taps an element on a touch screen device. If the element is outside of the viewport, the remote driver will * attempt to scroll it into view automatically. * * @metho