/* eslint-env es6, node *//** * @class ST.context.WebDriver */(function() { var logger = ST.logger.forClass('context/WebDriver'), tick = logger.tick(), debug = ST.debug, WebDriver; ST.context.WebDriver = ST.define({ extend: ST.context.Base, /** * @cfg {Object} driverConfig * Object which should contain a desiredCapabilities property which conforms to the * webdriver desired capabilities {@link https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities} * specification. Such as: * * var ctx = new ST.context.WebDriver({ * driverConfig: { * desiredCapabilities: { * browserName: 'chrome' * } * } * }); */ /** * @cfg {String} subjectUrl * The URL to open when init() is called. */ /** * @cfg {Boolean} eventTranslation * `false` to disable event translation. If `false` events that are not supported by * the browser's event APIs will simply be skipped. * NOTE: inherited from Player */ eventTranslation: true, /** * @cfg {Boolean} visualFeedback * `false` to disable visual feedback during event playback (mouse cursor, and "gesture" * indicator) * NOTE: inherited from Player */ visualFeedback: true, _STFiles: [ 'init.js', 'logger.js', 'debug.js', 'supports.js', 'base.js', 'Version.js', 'common.js', 'cache.js', 'Timer.js', 'setup-ext.js', 'context/Base.js', 'context/Local.js', 'context/LocalWebDriver.js', 'Browser.js', 'OS.js', 'Element.js', 'KeyMap.js', 'Alert.js', 'event/Event.js', 'event/GestureQueue.js', 'event/wgxpath.install.js', 'Locator.js', 'locator/Strategy.js', 'event/Driver.js', 'event/Recorder.js', 'playable/Playable.js', 'playable/State.js', 'future/Element.js', 'future/Component.js' ], _STTailFile: 'tail.js', _STContent: [], isWebDriverContext: true, /** * Construct a new WebDriver context and pre-fetch the ST context. */ constructor: function (config) { var me = this; ST.context.WebDriver.superclass.constructor.call(me, config); ST.apply(me, config); me.targetLogs = []; // TODO read this from settings.json for manual tweaking in the field. me.logPollInterval = 500; me._cacheSeed = 0; me._prefetchSTContent(); }, /** * Opens the desired browser, initializes it, loads the subjectUrl in the browser, * and loads Sencha Test framework into target browser so that the context is ready * for use with Futures API methods. */ init: function () { logger.trace('.init'); var me = this, driver; logger.debug('Initializing WebdriverIO'); driver = me.driver = ST.webdriverio .remote(me.driverConfig) .init(); driver.addCommand('STmoveToObject', me._moveToObject); return driver .timeouts('script', 10 * 1000) .url(me.subjectUrl) .then(() => { return me.initBrowserInfo(); }) .then((browser) => { if (browser.is.Chrome) { me.LOGS_SUPPORTED = true; me.pollLogs(); } else { me.LOGS_SUPPORTED = false; } return me._loadST(); }); }, initEvent: function (event) { if (!ST.playable.Playable.isRemoteable(event)) { return this.createPlayable(event); } else { // for WebDriver we want the player to keep the "bare" Playable config // since we create the playable in the target only. return event; } }, initBrowserInfo: function () { logger.trace('.initBrowserInfo'); var me = this, getBrowserInfo = me.remoteHelpers.getBrowserInfo; logger.debug('Retrieving browser information'); return me.executeScript(getBrowserInfo) .then(function (result) { var browserInfo = result.value, userAgent = browserInfo.navigator.userAgent; logger.debug('User agent', userAgent); ST._initBrowser(userAgent); ST._initOS({ navigator: browserInfo.navigator, window: browserInfo.window }); return ST.browser; }); }, executeAsyncScript: function (context) { context.async = true; return this.executeScript.apply(this, arguments); }, executeScript: function (ctx, ...args) { logger.trace('.executeScript, ctx.name=' + ctx.name + ', ctx.id=' + ctx.id); var me = this; if (ctx.cache !== false) { let cacheFn = Promise.resolve(); if (!ctx.id) { // context doesn't have an id - it means its function is not cached ctx.id = me._cacheSeed++; ctx.description = ctx.fn.toString().substring(0, 256); let cacheFnScript = '(window.___stcache || (window.___stcache = {})).f' + ctx.id + ' = ' + ctx.fn; if (ctx.minify !== false) { // cached execution - minify by default cacheFnScript = ctx.miniFn = me._minify(cacheFnScript); } cacheFn = me._executeScript(cacheFnScript).then(function (result) { ctx.cachingTime = result.duration; return result; }); } return cacheFn.then(function () { var execFn = ctx.async ? me._executeAsyncScript : me._executeScript, timeout = ctx.timeout || (ctx.async ? 10 * 1000 : 0), execFnScript, execFnArgs; if (ctx.serializeArgs) { execFnScript = me.getExecFnScript(ctx, ...args); execFnArgs = [execFnScript]; } else { execFnScript = me.getExecFnScript(ctx); execFnArgs = [execFnScript].concat([...args]); } // function is cached, now execute it logger.trace('Executing cached remote helper', (ctx.name || ctx.description)); me.driver.timeouts('script', timeout); return execFn.apply(me, execFnArgs).then(function (result) { ctx.execTimes = (ctx.execTimes || []); ctx.execTimes.push(result.duration); ctx.execTime = (ctx.execTime || 0) + (result.duration); ctx.execCount = (ctx.execCount || 0) + 1; var value = result.value; if (value && (value.isCacheMiss || value.loadST)) { if (value.isCacheMiss) { // we had an id, but it wasn't found in the remote cache, so // it means the page navigated or refreshed - invalidate id // so it will be cached again delete ctx.id; return me.executeScript(ctx, ...args); } if (value.loadST) { logger.warn('retrying executeScript after loading ST files.'); return me._loadST().then(function (res) { return me.executeScript(ctx, ...args); // TODO how to prevent trying more than once? }) } } else { if (ctx.parseResult) { result.value = JSON.parse(value); } return result; } }); }); } else { if (!ctx.description) { ctx.description = ctx.fn.toString().substring(0, 100); } // no cache: do not minify by default if (!!ctx.minify && !ctx.miniFn) { ctx.miniFn = me._minify(ctx.fn); } let execute, fn = ctx.minify ? ctx.miniFn : ctx.fn, timeout = ctx.timeout || (ctx.async ? 10 * 1000 : 0); me.driver.timeouts('script', timeout); if (ctx.async) { execute = me._executeAsyncScript(fn, ...args); } else { execute = me._executeScript(fn, ...args); } logger.trace('Executing remote helper', (ctx.name || ctx.description)); return execute.then(function (result) { ctx.execTimes = (ctx.execTimes || []); ctx.execTimes.push(result.duration); ctx.execTime = (ctx.execTime || 0) + (result.duration); ctx.execCount = (ctx.execCount || 0) + 1; return result; }); } }, _executeScript () { var me = this, driver = me.driver, start = me._now(); return driver.execute.apply(driver, arguments) .then(function (result) { result.duration = me._now() - start; return result; }) }, _executeAsyncScript () { var me = this, driver = me.driver, start = me._now(); return driver.executeAsync.apply(driver, arguments) .then(function (result) { result.duration = me._now() - start; return result; }) }, _now () { return new Date().getTime(); }, getExecFnScript: function (ctx, ...args) { var serializeArgs = arguments.length > 1, id = ctx.id, async = ctx.async, needST = ctx.needST, script; if (serializeArgs) { script = "var a = " + JSON.stringify([...args]) + "; " + "a = a.concat([].slice.call(arguments)); " } else { script = "var a = arguments; " } if (needST) { script += "var stLoaded = window.ST && window.ST.loaded; " + "if (!stLoaded) { "; if (!async) { script += "return { loadST: true }; "; } else { script += "a[a.length-1]({ loadST: true }); "; } script += " } "; } if (!async) { script += "return "; } script += "window.___stcache && " + "___stcache.f" + id + " " + "? ___stcache.f" + id + ".apply(null, a) "; if (!async) { script += ": { isCacheMiss: true } "; } else { script += ": a[a.length-1]({ isCacheMiss: true }) "; } return script; }, _minify: function (script, filename) { if (typeof script === 'function') { script = 'return (' + script + ').apply(null, arguments);'; } var toplevel = ST.UglifyJS.parse(script, { bare_returns: true, filename: filename }), compressor = ST.UglifyJS.Compressor(), compressed_ast; toplevel.figure_out_scope(); compressed_ast = toplevel.transform(compressor); compressed_ast.figure_out_scope(); compressed_ast.compute_char_frequency(); compressed_ast.mangle_names(); return compressed_ast.print_to_string(); }, _prefetchSTContent: function () { var me = this, fs = require('fs'), path = require('path'), serveDir = ST.serveDir, content = [] logger.debug('_prefetchSTContent, _STFiles='+me._STFiles) for (var i=0; i<me._STFiles.length; i++) { content.push({ file: me._STFiles[i], data: fs.readFileSync(path.join(serveDir, me._STFiles[i]), {encoding:'utf8'}) }) } // this allows sub-classes of this class to insert files in _STFiles before _prefetchSTContent // is called but still maintain the tail.js file which unblocks the player. content.push({ file: me._STTailFile, data: fs.readFileSync(path.join(serveDir, me._STTailFile), {encoding:'utf8'}) }) this._STContent = content; }, getLogs: function () { logger.trace('.getLogs'); var me = this, driver = me.driver, logItems, logMessage, message; if (!me.logPromise && me.LOGS_SUPPORTED) { return me.logPromise = driver.log('browser').then(function (logs) { me.logPromise = null; logItems = logs.value; logItems.forEach(function (log) { logMessage = log.message; logger.trace('getLogs logMessage:', logMessage); me.targetLogs.push(log); }); me.logTargetConsole(); }, function (err) { me.logPromise = null; logger.error('WebDriver.getLogs: Log retrieval failed.', err); me.handleClientError(err); }).catch(function (err) { me.logPromise = null; me.handleClientError(err); }); } }, handleClientError: function (error) { if (error.type === 'NoSessionIdError') { // Target browser went away, sandbox still running ST.sendMessage({ type: 'terminated' }); } else if (error.type === 'RuntimeError') { if (error.seleniumStack) { if (error.seleniumStack.status === '6') { // Target browser went away, sandbox still running ST.sendMessage({ type: 'terminated' }); } } } else { logger.error(error); } }, logTargetConsole: function () { var me = this, logItems = me.targetLogs; if (!me.logPromise) { me.targetLogs = []; if (logItems.length) { logItems.forEach(function (log) { var msg = log.message || JSON.stringify(log, null, 2), level = log.level; logger[level] ? logger[level](msg) : logger.warn(msg); }); } } }, pollLogs: function () { logger.trace('.pollLogs'); var me = this; if (!me.logPoller && me.LOGS_SUPPORTED) { me.logPoller = setInterval(function () { me.getLogs(); }, me.logPollInterval); } }, isRecordingPlayable: function (playable) { return typeof playable.recorderId !== 'undefined'; }, remoteHelpers: { getBrowserInfo: { name: 'getBrowserInfo', fn: function () { return { navigator: { userAgent: navigator.userAgent, platform: navigator.platform }, window: { deviceType: window.deviceType, location: { search: window.location.search || '' } } } }, }, showLoadingModal: { name: 'showLoadingModal', fn: function (done) { var modal = document.getElementById('StudioModal'), modalDialog, modalText, appendToBody, _text = 'Loading Sencha Test, please wait...'; if (!modal) { modal = document.createElement('div'); modalDialog = document.createElement('div'); modalText = document.createElement('span'); modal.id = 'StudioModal'; modal.style.display = 'block'; modal.style.position = 'fixed'; modal.style.zIndex = '1'; modal.style.top = '0'; modal.style.left = '0'; modal.style.width = '100%'; modal.style.height = '100%'; modal.style.paddingTop = '100px'; modal.style.backgroundColor = 'rgba(0,0,0,0.4)'; modalDialog.style.margin = 'auto'; modalDialog.style.position = 'absolute'; modalDialog.style.top = '10%'; modalDialog.style.left = '10%'; modalDialog.style.width = '80%'; modalDialog.style.height = '100px'; modalDialog.style.padding = '20px'; modalDialog.style.borderWidth = '2px'; modalDialog.style.borderStyle = 'solid'; modalDialog.style.borderColor = '#025B80'; modalDialog.style.backgroundColor = '#FFFFFF'; modalDialog.style.overflow = 'auto'; modalText.innerHTML = '<p>' + _text + '</p>'; modalText.style.backgroundColor = '#FFFFFF'; modalText.style.overflow = 'auto'; modalText.style.wordWrap = 'normal'; modalText.style.textAlign = 'center'; modalDialog.appendChild(modalText); modal.appendChild(modalDialog); appendToBody = function () { document.body.appendChild(modal); done(); } if (document.readyState === 'complete') { appendToBody(); } else { document.addEventListener('readystatechange', function () { if (document.readyState === 'complete') { appendToBody(); } }); } } } }, dismissLoadingModal: { name: 'dismissLoadingModal', fn: function () { var modal = document.getElementById('StudioModal'); if (modal) { modal.style.display = 'none'; while (modal.firstChild) { modal.removeChild(modal.firstChild); } document.body.removeChild(modal); } } }, getSelectorForTarget: { name: 'getSelectorForTargets', fn: function (target) { var el = ST.Locator.find(target), attr = 'data-senchatestid', id; // make sure we found something!!! if (el) { if (!el.hasAttribute(attr)) { id = new Date().getTime(); el.setAttribute(attr, id.toString()); } // otherwise, we'll just use the one that was previously assigned else { id = el.getAttribute(attr); } // compose the selector, job done!! return "[" + attr + "='" + id + "']"; } else { // no happy results...return root return '@'; } } }, isScrollable: { name: 'isScrollable', serializeArgs: true, fn: function (x, y, el) { var el = ST.Locator.find(el) || document.body, dimEl = (el === document.body) ? document.documentElement : el, isScrollable = (el.scrollHeight > dimEl.clientHeight) || (el.scrollWidth > dimEl.clientWidth), box, scrollSize; if (isScrollable) { box = el.getBoundingClientRect(), scrollSize = ST.getScrollbarSize(); if ((x >= box.right - scrollSize.width) || (y >= box.bottom - scrollSize.height)) { return true; } } return false; } }, handleWheel: { name: 'handleWheel', fn: function (_coords, _deltas, _element) { var el = ST.Locator.find(_element) || document.elementFromPoint(_coords.x, _coords.y), oldScroll = {}; // this method inspects the element and determines if the scroll position is such that it can // respond to the passed inputs // we want to be able keep going up the node tree if the target for scrolling is already at its // min/max limits, so that the scroll can continue to "overflow" up the hierarchy, like it does // with an actual browser scroll var canScroll = function (node, x, y) { var canScrollX = false, canScrollY = false, yScroll = node.scrollHeight > node.clientHeight, xScroll = node.scrollWidth > node.clientWidth, atTop = node.scrollTop === 0, atBottom = (node.scrollHeight - node.clientHeight) === node.scrollTop, atLeft = node.scrollLeft === 0, atRight = (node.scrollWidth - node.clientWidth) === node.scrollLeft; if (x > 0) { canScrollX = xScroll && !atRight; } else if (x < 0) { canScrollX = xScroll && !atLeft; } if (y > 0) { canScrollY = yScroll && !atBottom; } else if (y < 0) { canScrollY = yScroll && !atTop; } return canScrollX || canScrollY; }; var isScrollable = function (node) { if (node === null) { return null; } if (node === document.body || canScroll(node, _deltas.x, _deltas.y)) { return node; } else { return isScrollable(node.parentNode); } }; var doScroll = function (node) { var scrollable = isScrollable(node); // the scrollable will either be the passed-in target, or the next scrollable ancestor if (scrollable) { oldScroll.top = scrollable.scrollTop; oldScroll.left = scrollable.scrollLeft; scrollable.scrollTop = scrollable.scrollTop + (_deltas.y); scrollable.scrollLeft = scrollable.scrollLeft + (_deltas.x); // if no scrolling actually occurs, we need to recurse since scrollHeights might be askew for some reason if (scrollable !== document.body && oldScroll.top === scrollable.scrollTop && oldScroll.left === scrollable.scrollLeft) { doScroll(scrollable.parentNode); } } } doScroll(el); } }, getScrollPosition: { name: 'getScrollPosition', fn: function (_pos, _element) { var x = _pos[0] || 0, y = _pos[1] || 0, el = _element && ST.fly(_element), cmp = el && el.getComponent(), scrollable; if (cmp) { scrollable = cmp.getScrollable ? cmp.getScrollable() : null; if (scrollable) { // if we have a scrollable, use its scrollTo method; should be standard in 6+, so no modern check needed scrollable.scrollTo(x, y, false) } else if (cmp.scrollBy) { // otherwise, fall back to common scrollBy cmp.scrollBy(x, y, false); } } else { var isScrollable = function (node) { if (node === null) { return null; } else if (node === document.body) { return node; } else if (node.scrollHeight > node.clientHeight || node.scrollWidth > node.clientWidth) { return node; } else { return isScrollable(node.parentNode); } }; var scrollable = isScrollable(_element); // the scrollable will either be the passed-in target, or the next scrollable ancestor if (scrollable) { scrollable.scrollTop = y; scrollable.scrollLeft = x; } } } }, getElementScroll: { name: 'getElementScroll', fn: function (_element) { return { x: _element.scrollLeft, y: _element.scrollTop }; } }, getElementOffsets: { name: 'getElementOffsets', serializeArgs: true, parseResult: true, fn: function (x, y, target) { var failOnMultiple = ST.options.failOnMultipleMatches, offsetX = x, offsetY = y, rect, el; // force no failures on multiple matches ST.options.failOnMultipleMatches = false; el = ST.Locator.find(target); // restore settings ST.options.failOnMultipleMatches = failOnMultiple; if (el) { rect = el.getBoundingClientRect(); offsetX = Math.floor(x - rect.left); offsetY = Math.floor(y - rect.top); } return JSON.stringify({ offsetX: offsetX, offsetY: offsetY }); } }, remoteCallFn: { name: 'remoteCallFn', async: true, needST: true, fn: function (event, remoteDone) { ST.logger.trace('remoteHelpers.remoteCallFn'); window.$ST = window.$ST || {}; window.$ST.call = function () { var playable = ST.defaultContext.createPlayable(event), future = playable.future, promise, start; start = new Date().getTime(); if (typeof playable.fn !== 'function') { remoteDone({ error: 'playable.fn is not a function' }); return; } var localDone = function (result) { var retValue = { playable: { webElement: playable.targetEl && playable.targetEl.dom }, future: { webElement: future.el && future.el.dom }, data: playable.future.data, locatorWebElement: future.locator && future.locator.targetEl && future.locator.targetEl.dom, duration: new Date().getTime() - start }; retValue.error = result && result.error; remoteDone(retValue); }; localDone.fail = function (err) { localDone({ error: err.toString() }); }; promise = playable.fn.call(playable, localDone); if (promise && typeof promise.then === 'function') { promise.then(function () { localDone(); }, function (err) { localDone({ error: err.toString() }); }); } else if (!playable.fn.length) { localDone(); } }; window.$ST.call(); } }, remoteReadyFn: { name: 'remoteReadyFn', async: true, needST: true, fn: function (event, done) { var start = new Date().getTime(); window.$ST = window.$ST || {}; window.$ST.ready = function () { var playable = ST.defaultContext.createPlayable(event), future = playable.future, ready = false; try { ready = playable.ready(); } catch (e) { done({ error: e.message, duration: new Date().getTime() - start }); return; } done({ playable: { webElement: playable.targetEl && playable.targetEl.dom, waitingFor: playable.waitingFor, waitingState: playable.waitingState }, future: { webElement: future.el && future.el.dom }, data: future.data, ready: ready, duration: new Date().getTime() - start }); }; window.$ST.ready(); } }, checkGlobalLeaks: { name: 'checkGlobalLeaks', async: true, needST: true, fn: function (contextGlobals, done) { window.$ST = window.$ST || {}; window.$ST.check = function () { try { var result; ST.addGlobals.apply(ST, contextGlobals); result = ST.checkGlobalLeaks(); done({ result: result }); } catch (e) { done({ error: e.message || e }); } }; window.$ST.check(); } }, loadST: { name: 'loadST', async: true, fn: function (testOptions, done) { var logger = ST.logger; ST.setupOptions(testOptions); // results in ST.initGlobals() being called. if (Ext && Ext.onReady) { // this is an ext page logger.debug('setup Ext.onReady callback'); Ext.onReady(function () { logger.debug('Ext.onReady callback is called'); ST.initExtJS(); // in case the _beforereadyhandler didn't fire // and the delayed call didn't fire at the right time either. logger.debug('after initExtJs called, ST.ready.blocks=' + ST.ready.blocks); done(); }); } else { logger.debug('no Ext and Ext.onReady so assume NOT an ext page and return'); done(); // this is a non ext page } } }, insert: { name: 'insert', async: true, fn: function (file, content, done) { var insert = function () { try { var scriptElement = document.createElement('script'), inlineScript; // For "timer-worker.js", mark as "javascript/worker" and assign an id, so the // Worker can be created inline from this inserted JS snippet. if (file === 'event/timer-worker.js') { inlineScript = document.createTextNode( content ); scriptElement.setAttribute('type', 'javascript/worker'); scriptElement.setAttribute('id', '_sttimerworker'); done(); } else { inlineScript = document.createTextNode( content + '\nwindow._stinsertdone({ file: "' + file + '", loaded: true });' + '\ndelete window._stinsertdone' ); window._stinsertdone = done; } scriptElement.appendChild(inlineScript); scriptElement.setAttribute('file', file); scriptElement.async = false; document.body.appendChild(scriptElement); } catch (e) { done({ file: file, error: e }); } }; if (document.readyState === 'complete') { insert(); } else { document.addEventListener('readystatechange', function () { if (document.readyState === 'complete') { insert(); } }); } } }, execute: { name: 'execute', async: true, needST: true, fn: function (event, fnstring, restParams, done) { window.$ST = window.$ST || {}; window.$ST.exec = function () { eval('var fn = ' + fnstring); var playable = ST.defaultContext.createPlayable(event), future = playable.future, value = future._value(), result; try { restParams.unshift(value); result = fn.apply(playable, restParams); done({ result: result }); } catch (e) { done({ error: e.message || e }); } }; window.$ST.exec(); } } }, inject: function (playable, resolve, reject) { var me = this, type = playable.type, isRP = me.isRecordingPlayable(playable), helperCtx, webElement, isModern; // for tap/focus/others... target could be me.locator and take care of this? if (playable.webElement || playable.future) { webElement = playable.webElement || playable.future.webElement; // TODO how to resolve this? } else { webElement = {}; } if (playable.args) { playable.x = playable.x || playable.args.x; playable.y = playable.y || playable.args.y; playable.button = playable.button || playable.args.button; } if (!playable.args) playable.args = {}; // NOTE: playable.targetEl is a WebJSON Element, so use .ELEMENT to provide the ID to JSONWire protocol // methods below... if (type === 'mousedown') { if (isRP) { helperCtx = me.remoteHelpers.getElementOffsets; me .executeScript(helperCtx, playable.pageX, playable.pageY, playable.target) .then(function (result) { var offsets = result.value; playable.x = offsets.offsetX; playable.y = offsets.offsetY; playable.xy = [offsets.offsetX, offsets.offsetY]; helperCtx = me.remoteHelpers.isScrollable; me .executeScript(helperCtx, playable.pageX, playable.pageY, playable.webElement) .then(function (result) { playable.isScrollbarClick = result.value; // Oh travesty! Oh abomination! But if this is click on a scrollbar, we need to store the element for later // so we can interrogate it for its scroll position. if (playable.isScrollbarClick) { me.scrollClickElement = playable.webElement; } else { // if not a scroll click, for the love of all that is holy, delete it!!! delete me.scrollClickElement; } me.driver.buttonDown().then(resolve,reject); }); }); } else { helperCtx = me.remoteHelpers.getSelectorForTarget; me .executeScript(helperCtx, playable.webElement) .then(function (result) { var selector = result.value; me.driver // TODO: x/y need to be offsets to the element, currently they are page offsets :( .STmoveToObject(selector, playable.x, playable.y) .buttonDown() .then(resolve, reject); }); } } else if (type === 'mouseup') { if (isRP) { if (me.scrollClickElement) { // if we're in the middle of a yet-to-be-resolved scrollClick, we need to retrieve the scroll position // from the initially clicked element so we can provide those positions when they are needed helperCtx = me.remoteHelpers.getElementScroll; me .executeScript(helperCtx, me.scrollClickElement) .then(function (result) { // store the scroll position on the playable so we can retrieve it later when needed playable.finalScrollPosition = result.value; me.driver.buttonUp().then(resolve,reject); }); } else { helperCtx = me.remoteHelpers.getElementOffsets; me .executeScript(helperCtx, playable.pageX, playable.pageY, playable.target) .then(function (result) { var offsets = result.value; playable.x = offsets.offsetX; playable.y = offsets.offsetY; playable.xy = [offsets.offsetX, offsets.offsetY]; me.driver.buttonUp().then(resolve,reject); }); } } else { // no scroll clicking in progress, just run the buttonUp helperCtx = me.remoteHelpers.getSelectorForTarget; me .executeScript(helperCtx, webElement) .then(function (result) { var selector = result.value; me.driver // TODO: x/y need to be offsets to the element, currently they are page offsets :( .STmoveToObject(selector, playable.x, playable.y) .buttonUp() .then(resolve, reject); }); } } else if (type === 'scroll' ) { helperCtx = me.remoteHelpers.getScrollPosition; if (!playable.pos) { playable.pos = [playable.x, playable.y]; } me .executeScript(helperCtx, playable.pos, webElement) .then(resolve, reject); } else if (type === 'wheel' ) { // if we have a web element, we'll want to use it; otherwise, let's null it out so it doesn't cause problems if (typeof webElement !== 'number') { webElement = null; } if (playable.deltaX !== 0 || playable.deltaY !== 0) { var coords = {x: playable.pageX, y:playable.pageY}, deltas = {x: playable.deltaX, y: playable.deltaY}; helperCtx = me.remoteHelpers.handleWheel; me .executeScript(helperCtx, coords, deltas, webElement) .then(resolve, reject); } } else if (type === 'tap' || type === 'click') { if (isRP) { if (!me.scrollClickElement) { resolve(); } } else { helperCtx = me.remoteHelpers.getSelectorForTarget; me .executeScript(helperCtx, playable.webElement) .then(function (result) { var selector = result.value; if (playable.button === 2) { me.driver .rightClick(selector, playable.x || 0, playable.y || 0) .then(resolve, reject); } else { me.driver .STmoveToObject(selector, playable.x, playable.y) .buttonDown() .buttonUp() .then(resolve, reject); } }); } } else if (type === 'dblclick') { isModern = false; helperCtx = me.remoteHelpers.getSelectorForTarget; // Checks if application is EXT js modern me.driver .execute(function () { return ST.isModern; }) .then(function (res) { if (!res.value) { isModern = false; } else { isModern = true; } }, function (err) { isModern = false; }) .then(function () { // If application is Ext JS Modern it triggers two taps to simulate double tap. // If application is Ext JS Classic or non-Ext JS application it calls doubleClick() api on selector. if (isModern) { // Increase maxDuration in case of delay in browser farms and reset it back. me .executeScript(helperCtx, playable.webElement) .then(function (result) { var selector = result.value; me.driver .execute(function () { Ext.event.gesture.DoubleTap.prototype._maxDuration = 5000; }) .STmoveToObject(selector) .buttonDown() .buttonUp() .STmoveToObject(selector) .buttonDown() .buttonUp() .execute(function () { Ext.event.gesture.DoubleTap.prototype._maxDuration = 300; }) .then(resolve, reject); }); } else { me .executeScript(helperCtx, playable.webElement) .then(function (result) { var selector = result.value; me.driver .doubleClick(selector) .then(resolve, reject); }); } }); } else if (type === 'rightclick') { // right click is a gesture, so will only ever playback... helperCtx = me.remoteHelpers.getSelectorForTarget; me .executeScript(helperCtx, playable.webElement) .then(function (result) { var selector = result.value; me.driver // TODO: x/y need to be offsets to the element, currently they are page offsets :( .rightClick(selector, playable.x || 0, playable.y || 0) .then(resolve, reject); }); } else if (type === 'type') { // TODO support args.caret var text = playable.args.text || playable.text, key = playable.args.key || playable.key, caret = playable.args.caret || playable.caret; text = text || key; if (key === 'Backspace') { text = String.fromCharCode(8); } else if (key === 'ArrowLeft') { text = 'Left arrow'; } else if (key === 'ArrowRight') { text = 'Right arrow'; } else if (key === 'ArrowUp') { text = 'Up arrow'; } else if (key === 'ArrowDown') { text = 'Down arrow'; } else if (key === 'CapsLock') { resolve(); return; } helperCtx = me.remoteHelpers.getSelectorForTarget; me .executeScript(helperCtx, playable.webElement) .then(function (result) { var selector = result.value; me.driver.hasFocus(selector).then(function (result) { if (!result) { me.driver .STmoveToObject(selector, playable.x, playable.y) .buttonDown() .buttonUp() .then(function () { if (webElement) { me.driver.elementIdValue(webElement.ELEMENT, text).then(resolve, reject); } else { me.driver.keys(text).then(resolve, reject); } }); } else { if (webElement) { me.driver.elementIdValue(webElement.ELEMENT, text).then(resolve, reject); } else { me.driver.keys(text).then(resolve, reject); } } }); }); } else if (playable.type === 'keydown') { var key = playable.args.key || playable.key; if (key === 'Backspace') { // TODO FnKeys me.driver.keys(String.fromCharCode(8)).then(resolve, reject); } else if (key === 'ArrowLeft') { me.driver.keys('Left arrow').then(resolve, reject); } else if (key === 'ArrowRight') { me.driver.keys('Right arrow').then(resolve, reject); } else if (key === 'ArrowUp') { me.driver.keys('Up arrow').then(resolve, reject); } else if (key === 'ArrowDown') { me.driver.keys('Down arrow').then(resolve, reject); } else if (key === 'CapsLock') { resolve(); } else { me.driver.keys(key).then(resolve, reject); } } else if (playable.type === 'keyup') { // webdriverio leaves modifier keys 'pressed' until pressed again // http://webdriver.io/api/protocol/keys.html if (playable.key === 'Shift' || playable.key === 'Control' || playable.key === 'Alt' || playable.key === 'Meta') { // TODO add other modifier keys (Fn, PgUp/Dn, etc.)? // release all the keys me.driver.keys('NULL').then(resolve, reject); } else { resolve(); } // TODO: Make this work for WD // } else if (playable.type === 'dragAndDrop') { // var ddEvents = [], // dropTarget = playable.args.drop.target, // dragTarget = playable.args.drag.target, // dropX = playable.args.drop.x, // dropY = playable.args.drop.y, // dragX = playable.args.drag.x || null, // dragY = playable.args.drag.y || null; // debugger; // helperCtx = me.remoteHelpers.getSelectorForTarget; // // if no dropTarget is defined, the drag target will be the target // if (typeof dropTarget === 'undefined') { // dropTarget = '@'; // } // me // .executeScript(helperCtx, dragTarget) // .then(function (result) { // var selector = result.value; // me.driver // //.STmoveToObject(selector) // //.pause(200) // //.leftClick(selector, dragX, dragY) // .buttonDown() // .buttonPress() // //.buttonPress() // .pause(200) // .then(function () { // me // .executeScript(helperCtx, dropTarget) // .then(function (result) { // var selector = result.value; // me.driver // .STmoveToObject(selector) // //.moveTo(null, 100, 50) // .pause(200) // .buttonUp() // //.buttonPress() // .pause(3000) // .then(resolve, reject); // }); // }) // }); } else if (playable.type && playable.type !== 'wait') { resolve(); } else { resolve(); } }, _remoteCallFn: function (playable, serialized, retry) { logger.trace('._remoteCallFn'); var me = this, driver = me.driver, ser = serialized; return new Promise(function (resolve, reject) { var start = new Date().getTime(), helperCtx = me.remoteHelpers.remoteCallFn; debug(function () { driver.timeouts('script', 30 * 60 * 1000); }); me.executeAsyncScript(helperCtx, ser).then(function (ret) { if (ST.options.webdriverPerf) { ST.status.addResult({ passed: true, message: 'WebDriver._remoteCallFn.browser.duration.'+playable.type+'='+ret.value.duration }); ST.status.addResult({ passed: true, message: 'WebDriver._remoteCallFn.sandbox.duration.'+playable.type+'='+(new Date().getTime()-start) }); } var v = ret.value, error = v.error; if (error) { logger.error('_remoteCallFn', 'returned error ' + error); reject(error); return; } logger.trace('._remoteCallFn', 'returned value ' + v); ST.apply(playable, v.playable); // apply targetDom because it might have been reconstructed ST.apply(playable.future, v.future); if (v.data && playable.future) { playable.future.setData(v.data); } if (v.locatorWebElement) { playable.future.locator.webElement = v.locatorWebElement; } resolve(ret.value); }, function (err) { logger.error('_remoteCallFn error:', err); if (err.seleniumStack && err.seleniumStack.type === 'StaleElementReference') { playable.webElement = undefined; playable.future.webElement = undefined; reject(); return; } reject(err); }).catch(function (err) { logger.error('_remoteCallFn unhandled error:', err); }); }); }, callFn: function (playable, done) { var me = this, serialized = me._serialize(playable); if (ST.playable.Playable.isRemoteable(playable)) { return me._remoteCallFn(playable, serialized, true); // one retry } else { return playable.fn.call(playable, done); } }, _serialize: function (playable) { var ret = {}; for (var i in playable) { var t = typeof playable[i]; var v = playable[i]; if (t !== 'object' && t !== 'function') { ret[i] = v; } if (t === 'object' && v === null) { ret[i] = v; } } ret.webElement = playable.webElement; if (playable.target) { if (typeof playable.target === 'string') { ret.target = playable.target; } else { ret.target = {}; ret.target.webElement = playable.target.webElement; ret.target.locatorChain = playable.target.locatorChain; } } // take relatedCmps, strip out their webElement and send that instead... ret.future = ret.future || {}; ret.futureData = ST.apply({}, playable.instanceData); if (playable.future) { var retf = ret.future, future = playable.future, related = future.related; retf.related = {}; for(var name in related) { var relatedFuture = related[name]; retf.related[name] = { webElement: relatedFuture.webElement, futureClsName: relatedFuture.$className, data: relatedFuture.data }; } if (future.locator) { retf.locator = { webElement: future.locator.webElement, locatorChain: future.locator.locatorChain }; } retf.locatorChain = ret.root = future.locatorChain; retf.webElement = future.webElement; ret.futureClsName = future.$className; retf.data = future.data; retf.futureData = future.futureData; } ret.args = playable.args; ret.instanceData = playable.instanceData; return ret; }, _remoteReadyFn: function (playable, serialized, retry) { logger.trace('._remoteReadyFn'); var me = this, driver = me.driver, ser = serialized; return new Promise(function (resolve,reject) { var start = new Date().getTime(), helperCtx = me.remoteHelpers.remoteReadyFn; debug(function () { driver.timeouts('script', 30 * 60 * 1000); }); me.executeAsyncScript(helperCtx, ser).then(function (ret) { logger.trace('._remoteReadyFn helper executed'); if (ST.options.webdriverPerf) { ST.status.addResult({ passed: true, message: 'WebDriver._remoteReadyFn.browser.duration.'+playable.type+'='+ret.value.duration }); ST.status.addResult({ passed: true, message: 'WebDriver._remoteReadyFn.sandbox.duration.'+playable.type+'='+(new Date().getTime()-start) }); } var v = ret.value, ready = v.ready, error = v.error; if (error) { logger.error('_remoteReadyFn error:', error); reject(error); } else { logger.trace('._remoteReadyFn returned:', v); ST.apply(playable, v.playable); ST.apply(playable.future, v.future); if (v.data && playable.future) { playable.future.setData(v.data); } ready ? resolve(ret) : reject(); } }, function (err) { logger.error('_remoteReadyFn error:', err); if (err.seleniumStack && err.seleniumStack.type === 'StaleElementReference') { playable.webElement = undefined; playable.future.webElement = undefined; reject(); return; } if (retry) { me._loadST().then(function () { me._remoteReadyFn(playable,ser,false).then(resolve,reject); }, reject); } else { reject(err); } }).catch(function (err) { logger.error('_remoteReadyFn unhandled error:', err); }); }); }, ready: function (playable, resolve, reject) { var me = this, serialized = me._serialize(playable); if (ST.playable.Playable.isRemoteable(playable)) { me._remoteReadyFn(playable, serialized, true /* one retry */).then(resolve, reject); } else { resolve(); } }, _checkGlobalLeaks: function (done, contextGlobals, retry) { logger.trace('._checkGlobalLeaks <=', 'done', contextGlobals, retry); var me = this, contextGlobals = contextGlobals || ST.options.contextGlobals, helperCtx = me.remoteHelpers.checkGlobalLeaks; if (typeof retry === 'undefined') { retry = true; } me.executeAsyncScript(helperCtx, contextGlobals).then(function (ret) { var value = ret.value, result = value.result, error = value.error; // NOTE: done must return what Base.checkGlobalLeaks expects, currently: // { results: [<expectations results>], addedGlobals: [<string property names>] } if (result) { done(result); } else if (error) { done({ results: [{ passed: false, message: 'Global leaks check threw an error: ' + error }] }); } else { done({ results: [{ passed: false, message: 'Global leaks check returned no results or error.' }] }); } },function (err) { done({ results: [{ passed: false, message: 'Global leaks check threw an error: ' + err.message || err }] }); }); }, _loadST: function () { logger.trace('._loadST'); logger.debug('Loading Sencha Test in target browser'); var me = this; if (me._loadSTPromise) { return me._loadSTPromise; } me._loadSTPromise = me.showLoadingModal() .then(function () { var promiseChain = Promise.resolve(), insert = me.remoteHelpers.insert; // Also insert "timer-worker.js" promiseChain = promiseChain.then(function() { var fs = require('fs'), path = require('path'), serveDir = ST.serveDir; logger.debug('Loading event/timer-worker.js'); return me.executeAsyncScript( insert, 'event/timer-worker.js', fs.readFileSync(path.join(serveDir, 'event/timer-worker.js'), {encoding:'utf8'}) ); }); for (var i=0, len=me._STContent.length; i < len; i++) { let file = me._STContent[i].file, content = me._STContent[i].data, miniContent = me._minify(content, file); (function () { var f = file; promiseChain = promiseChain.then(function () { logger.debug('Loading', f); return me.executeAsyncScript(insert, f, miniContent) .then(function (ret) { var val = ret.value; if (val && val.file) { logger.debug('Loaded', val.file); } else { logger.error(val); } }); }); }()); } return promiseChain; }) .then(function () { logger.debug('Initializing Sencha Test in target browser'); var options = {}, loadST = me.remoteHelpers.loadST; ST.apply(options, ST.options); // clear out globals and globalPatterns since we let the target manage it's own. options.globalPatterns = {}; options.globals = {}; return me.executeAsyncScript(loadST, options); }) .then(function () { return me.dismissLoadingModal(); }) .then(function () { me._loadSTPromise = null; logger.debug('Sencha Test initialized in target browser'); }); return me._loadSTPromise; }, showLoadingModal: function () { logger.trace('.showLoadingModal'); var me = this; return me.executeAsyncScript(me.remoteHelpers.showLoadingModal); }, dismissLoadingModal: function () { logger.trace('.dismissLoadingModal'); var me = this; return me.executeScript(me.remoteHelpers.dismissLoadingModal); }, cleanup: function () { logger.trace('.cleanup'); }, /** * Close the browser and shutdown the driver. * * When creating a WebDriver context manually in a test the stop method should be called in an * afterEach or afterAll method. */ stop: function (resolve, reject) { logger.trace('.stop <=', 'resolve', 'reject'); var me = this; if (me.driver && me.LOGS_SUPPORTED) { me.logPoller && clearInterval(me.logPoller); if (me.logPromise) { logger.debug('Browser logs being fetched, waiting'); me.logPromise.then(function (ret) { logger.debug('Browser logs fetched sucessfully'); me.driver.end().then(() => { logger.debug('driver.end() complete'); me.driver = null; resolve(); }, reject); }, function (err) { logger.error('Error retrieving browser logs:', err); me.driver.end().then(() => { logger.debug('driver.end() complete'); me.driver = null; resolve(); }, reject); }); } else { logger.debug('All browser logs already fetched'); me.driver.end().then(() => { logger.debug('driver.end() complete'); me.driver = null; resolve(); }, reject); } } else if (me.driver) { logger.debug('Browser log fetch not enabled'); me.driver.end().then(() => { logger.debug('driver.end() complete'); me.driver = null; resolve(); }, reject); } else { logger.debug('Driver already stopped'); resolve(); } }, onEnd: function (resolve, reject) { logger.trace('.onEnd <=', 'resolve', 'reject'); resolve(); }, startRecording: function () { logger.trace('.startRecording'); logger.debug('Starting event recorder in target browser'); var me = this; me.isRecording = true; me.driver.execute(function () { // IN-BROWSER-BEGIN ST.defaultContext.startRecording(); // IN-BROWSER-END }).then(function (ret) { logger.debug('Event recorded started in target browser'); }, function (err) { // TODO handle recorder errors logger.error((err && err.stack) || err); }); }, execute: function (playable, fn, restParams, resolve, reject, retry) { logger.trace('.execute'); var me = this, ser = me._serialize(playable), helperCtx = me.remoteHelpers.execute; if (typeof retry === 'undefined') { retry = true; } me.executeAsyncScript(helperCtx, ser, fn.toString(), restParams).then(function (ret) { if (ret.value && ret.value.error) { reject(ret.value.error); } else { resolve(ret.value.result); } }, function (err) { reject(err); }); }, getUrl: function (fn) { logger.trace('.getUrl'); this.driver.getUrl().then(fn); }, getTitle: function (fn) { logger.trace('.getTitle'); this.driver.getTitle().then(fn); }, url: function (url, done) { logger.trace('.url <=', url, 'done'); var isFragment; url = new ST.Url(url); done = typeof done === 'function' ? done : ST.emptyFn; isFragment = url.isOnlyHash(); url = isFragment ? url.getHash() : url.get(); this.driver.execute(function (url, isFragment) { if (isFragment) { window.location.hash = url; } else { window.location = url; } }, url, isFragment).then(done); }, setViewportSize: function (width, height, done) { logger.trace('.setViewportSize <=', width, height, 'done'); this.driver.setViewportSize({ width: width, height: height }).then(done); }, _screenshot: function (name, done) { logger.trace('._screenshot <=', name, 'done'); var me = this; return me.driver.saveScreenshot().then(function (screenshot) { logger.trace('._screenshot', 'took screenshot', screenshot && screenshot.length, 'bytes'); return me._compareScreenshot(name, me._arrayBufferToBase64(screenshot)); }).then(function (comparison) { done(null, comparison); }, function (err) { done(err, null); }); }, _arrayBufferToBase64: function (buffer) { logger.trace('._arrayBufferToBase64 <=', 'buffer'); var binary = '', bytes = new Uint8Array(buffer), len = bytes.byteLength, i; for (i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); } return window.btoa(binary); }, _compareScreenshot: function (name, base64Png) { logger.trace('._compareScreenshot <=', name, 'base64Png'); return new Promise(function (resolve, reject) { ST.system.screenshot({ data: base64Png, name: name }, function (comparison, err) { if (err) { logger.trace('._compareScreenshot', 'err:', err); reject(err); } else { logger.trace('._compareScreenshot', 'comparison:', comparison); resolve(comparison); } }); }) }, // in init() we sort of monkey-patch our impl over webdrivers... to fixed // non-mobile center move when offsets are not provided // this code based on webdriverio 3.4.0 lib/commands/moveToObject.js _moveToObject: function (selector, xoffset, yoffset) { logger.trace('._moveToObject <=', selector, xoffset, yoffset); /** * check for offset params */ var hasOffsetParams = true; if (typeof xoffset !== 'number' && typeof yoffset !== 'number') { hasOffsetParams = false; } if (this.isMobile || hasOffsetParams) { return this.moveToObject(selector, xoffset, yoffset) } return this.element(selector).then(function (element) { return this.elementIdSize(element.value.ELEMENT).then(function (size) { return this.elementIdLocation(element.value.ELEMENT).then(function (location) { return { element: element, size: size, location: location }; }); }); }).then(function (res) { var _xoffset = (res.size.value.width / 2); var _yoffset = (res.size.value.height / 2); var x = res.location.value.x; var y = res.location.value.y; if (hasOffsetParams) { _xoffset = xoffset _yoffset = yoffset } return this.moveToObject(selector, _xoffset, _yoffset) }); } }); }());