/* eslint-env es6, node *//** * @class ST.context.WebDriverRecorder * @private */var async = require('async');(function() { var logger = ST.logger.forClass('context/WebDriverRecorder'), debug = ST.debug; ST.context.WebDriverRecorder = ST.define({ extend: ST.context.WebDriver, constructor: function (config) { logger.trace('.constructor'); ST.context.WebDriverRecorder.superclass.constructor.call(this, config); }, init: function () { logger.trace('.init'); logger.debug('Initializing WebDriverRecorder context'); var me = this, path = require('path'), fs = require('fs'), parentDir = path.join(__dirname, '..'), driver; me.STLoads = 0; me._addRemoteHelpers(); logger.debug('Initializing native window module'); ST.nativewindow = require('native-window'); logger.debug('Initializing WebdriverIO'); driver = me.driver = ST.client = ST.webdriverio .remote(me.driverConfig) .init(); driver.addCommand('STmoveToObject', me._moveToObject); return driver .url(me.subjectUrl) .then(() => { return me.initBrowserInfo(); }) .then(() => { return me._loadST(); }) .then(() => { return me.initScrollSurface(); }) .then(() => { return me.initResizer(); }); }, _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', 'event/Event.js', 'event/GestureQueue.js', 'event/wgxpath.install.js', 'Locator.js', 'locator/Strategy.js', 'event/Driver.js', 'playable/Playable.js', 'playable/State.js', 'future/Element.js', 'future/Component.js' ], _addRemoteHelpers: function () { logger.trace('._addRemoteHelpers'); Object.assign(this.remoteHelpers, { getSelector: { name: 'getSelector', fn: function (x, y) { var el = document.elementFromPoint(x, y), id = new Date().getTime(), attr = 'data-senchatestid'; // make sure we found something!!! if (el) { // if we don't already have an attribute, add it with the value if (!el.hasAttribute(attr)) { el.setAttribute(attr, id); } // 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 '@'; } } }, initScrollSurface: { name: 'initScrollSurface', fn: function () { var el = document.getElementById('ST_scroll_surface'), h, w; w = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); h = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); if (!el) { el = document.createElement('DIV'); el.id = 'ST_scroll_surface'; el.style.height = h + 'px'; el.style.width = w + 'px'; el.style.position = 'fixed'; el.style.top = 0; el.style.left = 0; //el.style.backgroundColor = 'yellowgreen'; // useful for debugging ;P el.style.zIndex = -1000000000; document.body.appendChild(el); } window.addEventListener('resize', function () { var el = document.getElementById('ST_scroll_surface'), h, w; w = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); h = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); el.style.height = h + 'px'; el.style.width = w + 'px'; }); } }, wrapEvent: { name: 'wrapEvent', // cache: true, // minify: true, serializeArgs: true, parseResult: true, fn: function (_point, _ev) { // IN-BROWSER BEGIN var _targets = [], _strategy = new ST.locator.Strategy(), // TODO include other strategies from Recorder _element = document.elementFromPoint(_point.x, _point.y) || document.body, _id = new Date().getTime(), _attr = 'data-senchatestid', _event; // if we have a keycode, we'll assume it's a focused input element if (_ev.keyCode) { _element = document.activeElement; } // let's make sure that we NEVER allow ST_scroll_surface as a valid target; // it will NEVER be during playback :) if (_element.id && _element.id === 'ST_scroll_surface') { _element = document.body; } _strategy.locate(_element, _targets, _ev); _event = new ST.event.Event(_ev, _targets); _event.webElement = ST.cache.add(_element); // tag element with a permanent selector // if we don't already have an attribute, add it with the value if (!_element.hasAttribute(_attr)) { _element.setAttribute(_attr, _id); } // otherwise, we'll just use the one that was previously assigned else { _id = _element.getAttribute(_attr); } // compose the selector, job done!! _event.generatedSelector = "[" + _attr + "='" + _id + "']"; _event = _event.serialize(); return JSON.stringify(_event); } }, STLoaded: { name: 'STLoaded', cache: false, minify: false, fn: 'return (typeof ST !== "undefined");' } }); }, /** * Primary error handler for WebDriver calls. * * WDIO work on the global error handling has improved as of 4.x: https://github.com/webdriverio/webdriverio/issues/827 * * @param err * @private */ handleWDError: function (err) { logger.trace('.handleWDError'); var me = this; if (err.seleniumStack) { if (err.seleniumStack.status == 13 || err.seleniumStack.status == 23 || err.type === 'NoSessionIdError') { // Target browser was closed from under us, close accordingly me.stopRecording(); } } else if (err.type === 'RuntimeError') { // RuntimeError is more generic, harder to determine what happened. // Most likely fell in here because driver.moveToObject fails miserably... // no selenium stack or obvious property to check... have to grep the message property if (err.message.indexOf('UnknownError:13') !== -1) { // Most likely Target browser was closed. Close accordingly // UnknownError:13 seems to match the seleniumStack.status above... curious me.stopRecording(); } } else if (err.type === 'UnknownError') { // Most likely landed here because there was a quirk with the RuntimeError handling above. if (err.status == 13 || err.status == 23) { // Strangely matches the seleniumStack.status again... me.stopRecording(); } } else if (err.name === 'TypeError') { // This happens on OSX when the target browser window is closed and then the browser is quit // in a separate action (Cmd-Q). grep the message just to be sure. if (err.message.indexOf('Cannot read property') !== -1) { if (err.message.indexOf('of null') !== -1) { // Probably the target window was closed out-of-band. me.stopRecording(); } } } else { // Leaving this here so bugs can tell us what else could cause a problem. // The filtering so far is all I can seem to make happen locally. I'm sure // if we are communicating with a remote Selenium Grid we could get different things. // // https://github.com/webdriverio/webdriverio/blob/master/lib/helpers/constants.js // search for ERROR_CODES debugger; } }, initListeners: function () { logger.trace('.initListeners'); // handlers here are simply pass-throughs from the canvas to the target // which most likely we do not want recorded var me = this, magicCanvas = me.magicCanvas = document.getElementById('magicCanvas'), canvasLogger = logger.forClass('context/WebDriverRecorder.MagicCanvas'), lastX, lastY, mouseMovePromise, mouseMoveTimeout, mouseMoveResolve, mouseMoveReject; me.pendingEvents = Promise.resolve(); magicCanvas.onmousemove = function (e) { var x = e.clientX, y = e.clientY; if (me.isClosing || (x === lastX && y === lastY)) { return; } canvasLogger.loquacious('magicCanvas', 'onmousemove'); lastX = x; lastY = y; if (!mouseMovePromise) { mouseMovePromise = new Promise(function (resolve, reject) { mouseMoveResolve = resolve; mouseMoveReject = reject; }) me.pendingEvents = me.pendingEvents.then(() => { return mouseMovePromise; }); } clearTimeout(mouseMoveTimeout); mouseMoveTimeout = setTimeout(function() { canvasLogger.loquacious('mouseMoveTimeout'); me.STLoaded() .then(() => { canvasLogger.loquacious('driver.moveToObject()', x, y) return me.driver.moveToObject('#ST_scroll_surface', x, y); }) .then(() => { me.mousemoveFailures = 0; mouseMovePromise = null; }) .then(mouseMoveResolve) .catch(function () { canvasLogger.loquacious('mouseMoveTimeout', 'catch'); mouseMovePromise = null; mouseMoveReject(); me._loadST() .then(function () { return me.initScrollSurface(); }); }); }, 100); }.bind(me); magicCanvas.oncontextmenu = function (e) { canvasLogger.loquacious('.oncontextmenu'); var helperCtx = me.remoteHelpers.getSelector; me.executeScript(helperCtx, e.x, e.y).then(function (result) { var selector = result.value; me.driver.rightClick(selector); }).catch(function (err) { me.handleWDError(err); }); }; }, initResizer: function () { var me = this, resizerLogger = logger.forClass('context/WebDriverRecorder.Resizer'), remote = require('electron').remote, ipcRenderer = require('electron').ipcRenderer, screen = require('electron').screen, sandboxName = 'Sencha Test Sandbox', // TODO centralize access to the name among code browserName = me.driverConfig.desiredCapabilities.browserName, browserTitle, browserWindow, browserDisplay, recorderWindow, recorderNativeWindow, applyScaleFactor = (me.driverConfig.desiredCapabilities.browserName === 'internet explorer' || me.driverConfig.desiredCapabilities.browserName === 'MicrosoftEdge'), lastViewportW, lastViewportH, lastHandleX, lastHandleY; // TODO HACK to translate selenium browserName to something the OS can use to find the window // from https://github.com/SeleniumHQ/selenium/wiki/DesiredCapabilities // {android|chrome|firefox|htmlunit|internet explorer|iPhone|iPad|opera|safari} // and by inspection on OSX and Window (not Linux :() I make the translations below if (browserName == 'chrome') { browserName = 'Google Chrome' } else if (browserName == 'MicrosoftEdge') { browserName = 'Microsoft Edge' } else if (browserName == 'internet explorer') { browserName = 'Internet Explorer' } else if (browserName == 'firefox') { browserName = 'Mozilla Firefox' } if (!me.recorderWindow) { recorderWindow = me.recorderWindow = remote.getCurrentWindow(); } else { recorderWindow = me.recorderWindow; } if (ST.os.is.Win32) { // TODO explicit reference between the set title and this title to search resizerLogger.babble('look for recorderNativeWindow'); recorderNativeWindow = ST.nativewindow.findWindow(sandboxName) // no app name on windows! resizerLogger.babble('found recorderNativeWindow=' + recorderNativeWindow) recorderWindow.on('focus', function () { resizerLogger.babble('recorderWindow focus event, bringToTop(browserWindow)') ST.nativewindow.bringToTop(browserWindow) resizerLogger.babble('recorderWindow focus event, bringToTop(recorderNativeWindow)') ST.nativewindow.bringToTop(recorderNativeWindow) }) } me.resizer = setInterval(() => { resizerLogger.babble('resizer', 'setInterval'); if (!me.isClosing) { var viewportW, viewportH, handleW, handleH, handleX, handleY; me.driver.getViewportSize().then((viewportSize) => { resizerLogger.babble('viewportSize: ', JSON.stringify(viewportSize)); viewportW = viewportSize.width; viewportH = viewportSize.height; if (viewportW !== lastViewportW || viewportH !== lastViewportH) { lastViewportW = viewportW; lastViewportH = viewportH; recorderWindow.setSize(viewportW, viewportH); } return me.driver.title(); }).then((title) => { var title = title.value, browserOnTop, titleChanged = false; resizerLogger.babble('resizer') if (browserTitle != title) { titleChanged = true browserTitle = title } if (ST.os.is.MacOS && titleChanged) { resizerLogger.babble('Sending IPC message: updateRecorderFocus') ipcRenderer.sendSync('updateRecorderFocusManager', { browserTitle: browserTitle, browserName: browserName }) } if (ST.os.is.Win32) { // refresh the browserWindow handle if title has changed or if it isn't found yet if (titleChanged || !browserWindow) { resizerLogger.babble('look for window with windowName='+browserTitle+' and appName='+browserName) browserWindow = ST.nativewindow.findWindow(browserTitle,browserName) resizerLogger.babble('found browserWindow='+browserWindow) } browserOnTop = ST.nativewindow.isTopWindow(browserWindow) resizerLogger.babble('browserOnTop=' + browserOnTop) // TODO switch to OS-based event driven process to watch for browser-focus // to bring recorder to top // NOTE case of sandboxOnTop is handled by recorderWindow.on('focus') above if (browserOnTop) { resizerLogger.babble('send sandbox to top') ST.nativewindow.bringToTop(recorderNativeWindow) } else { resizerLogger.babble('sending browser to bottom') ST.nativewindow.sendToBottom(browserWindow) resizerLogger.babble('sending recorder to bottom') ST.nativewindow.sendToBottom(recorderNativeWindow) } } return me.driver.windowHandleSize(); }).then(function (handleSize) { resizerLogger.babble('handleSize='+JSON.stringify(handleSize)) handleW = handleSize.value.width; handleH = handleSize.value.height; return me.driver.windowHandlePosition(); }).then((handlePosition) => { resizerLogger.babble('handlePosition='+JSON.stringify(handlePosition.value)) var scaleFactor, x, y; handleX = handlePosition.value.x; handleY = handlePosition.value.y; if (handleX !== lastHandleX || handleY !== lastHandleY) { lastHandleX = handleX; lastHandleY = handleY; browserDisplay = screen.getDisplayMatching({ x: handleX, y: handleY, width: handleW, height: handleH }); resizerLogger.babble('browserDisplay='+JSON.stringify(browserDisplay)) scaleFactor = browserDisplay.scaleFactor; if (applyScaleFactor && scaleFactor != 1) { // scale all the values because WINDOWS handleX = Math.floor(handleX / scaleFactor); handleY = Math.floor(handleY / scaleFactor); handleW = Math.floor(handleW / scaleFactor); handleH = Math.floor(handleH / scaleFactor); } x = Math.floor(handleX + (handleW - viewportW) / 2); y = handleY + handleH - viewportH; // NOTE setPosition seems to cause EVENT_SYSTEM_FOREGROUND event on windows... if (ST.os.is.Win32) { y = y - (10 - (scaleFactor * 2)) // TODO compute from data instead of guessing } resizerLogger.babble('setPosition('+x+','+y+')') recorderWindow.setPosition(x, y); if (viewportW !== lastViewportW || viewportH !== lastViewportH) { lastViewportW = viewportW; lastViewportH = viewportH; resizerLogger.babble('setSize('+viewportW+','+viewportH+')') recorderWindow.setSize(viewportW, viewportH); } } if (!recorderWindow.__shown) { recorderWindow.show(); recorderWindow.__shown = true; } }).catch(function (err) { me.handleWDError(err); }); } }, 1000); }, /** * @private * When you use moveToObject() with the body element, webdriver will reset any scroll positions on the * document, which doesn't make scrolling very nice at all. As a workaround, we'll inject a fixed element * that will fit itself to the size of the document and act as the "surface" on which the mousemovements are tracked * This should let us avoid the scroll reset issue while still tracking the mousemovements which are necessary for scroll */ initScrollSurface: function () { logger.trace('.initScrollSurface'); var me = this, helperCtx; if (me.isScrollSurfaceIniting) return; me.isScrollSurfaceIniting = true; helperCtx = me.remoteHelpers.initScrollSurface; return me.executeScript(helperCtx).then(function () { me.isScrollSurfaceIniting = false; me.initListeners(); return Promise.resolve(); }); }, scrollSurfaceLoaded: function () { logger.trace('.scrollSurfaceLoaded'); var me = this; return new Promise(function (resolve, reject) { me.driver.element('#ST_scroll_surface').then(function (ret) { if (ret.value) { reject(ret); } else { resolve(ret); // ST already loaded in target } }).catch(function (err) { me.handleWDError(err); reject(); }); }); }, /** * Starts the {@link ST.event.Recorder event recorder}. Once this method is called * all further test execution is halted. * * This method is typically injected automatically by Sencha Test Studio when using * its Event Recorder and is therefore rarely called directly. * @method startRecording * @member ST */ startRecording: function () { logger.trace('.startRecording'); var me = this, recorder = me.recorder || (me.recorder = new ST.event.MagicCanvasRecorder()); me.isRecording = true; me.isClosing = false; recorder.throttle = 1; try { recorder.on({ scope: ST, add: function (recorder, events) { ST.sendMessage({ type: 'recordedEvents', events: events }); }, start: function () { ST.sendMessage({ type: 'recordingStarted' }); } }); recorder.start(); } catch (err) { if (recorder) { console.log(recorder); } console.error(err.message || err); console.error(err.stack || err); } }, stopRecording: function (resolve, reject) { logger.trace('.stopRecording'); var me = this; if (me._stopRecordingPromise) { logger.trace('.stopRecording => me._stopRecordingPromise'); return me._stopRecordingPromise; } clearInterval(me.resizer); // There is a timeout issue with WebDriver: calls can take some time to timeout, // so ignore if we are already trying to close if (me.isClosing) return; me.isClosing = true; if (me.recorder) me.recorder.stop(); if (me.magicCanvas && me.magicCanvas.onmousemove) me.magicCanvas.onmousemove = null; debug(function () { for (let ctxName in me.remoteHelpers) { let ctx = me.remoteHelpers[ctxName]; if (!ctx.execCount) { continue; } console.log('------------------ CONTEXT'); console.log('helper: ' + ctxName); console.log('asynchronous: ' + !!ctx.async); console.log('minified: ' + !!ctx.miniFn); console.log('pre-serialized: ' + !!ctx.serializeArgs); console.log('id: ' + (ctx.id || 'n/a')); console.log('fn: ' + ctx.description); console.log('cachingTime: ' + (ctx.cachingTime || 'n/a')); console.log('execCount: ' + ctx.execCount); console.log('execTime (total) : ' + ctx.execTime); console.log('execTime (avg) : ' + Math.floor(ctx.execTime / ctx.execCount)); console.log('execTime (series): ' + JSON.stringify(ctx.execTimes)); console.log('\n'); } }); debug(); me.isRecording = false; async.series([ function (callback) { me.stop(resolve, reject); callback(); }, function () { setTimeout(function () { if (me.recorderWindow) { me.recorderWindow.close(); } }, 1000); } ]); }, /** * Checks to see if Sencha Test is loaded in the target browser * @returns {Promise} * @private */ STLoaded: function () { logger.trace('.STLoaded'); var me = this, helperCtx = me.remoteHelpers.STLoaded; return new Promise(function (resolve, reject) { me.executeScript(helperCtx) .then(function (ret) { if (ret.value) { resolve(ret); // ST already loaded in target } else { reject(ret); } }).catch(function (err) { logger.error((err && err.stack) || err); me.handleWDError(err); reject(); }); }); }, /** * Wraps the event intercepted by the Magic Canvas into a Sencha Test Event, to be * inserted into the target by the recorder. * @returns {ST.Event} * @private */ wrapEvent: function (ev) { logger.trace('.wrapEvent'); var me = this, eventData = new ST.event.Event(ev, [], new Date().getTime()), point, helperCtx; // Get the coordinates of the mouse return me._translateCoordinates(ev).then(function (_point) { point = _point; return me.STLoaded().then(function (ret) { // Wrap the event helperCtx = me.remoteHelpers.wrapEvent; eventData = eventData.serialize(); return me.executeScript(helperCtx, point, eventData); }).catch(function (err) { // Most likely a page navigation occurred. Reload ST in the target. return me._loadST().then(function () { return me.initScrollSurface().then(function (ret) { // Return the event to the recorder anyway since it most likely // triggered navigation. The event that triggers this // is usually Enter or spacebar on a form/button. return Promise.resolve({ value: eventData.serialize() }); }); }); }); }).catch(function (err) { me.handleWDError(err); }); }, /** * Translates coordinates of an event in the MagicCanvas to the target browser under test * @param ev * @private */ _translateCoordinates: function (ev) { logger.trace('._translateCoordinates'); var me = this, screenX = ev.screenX, screenY = ev.screenY, handlePosX, handlePosY, handleSizeW, handleSizeH, viewportPosX, viewportPosY, viewportSizeW, viewportSizeH, x, y; //TODO handle caching of handleSizeW/H return me.driver.windowHandlePosition() .then(function (position) { handlePosX = position.value.x; handlePosY = position.value.y; return me.driver.windowHandleSize(); }) .then(function (size) { handleSizeW = size.value.width; handleSizeH = size.value.height; return me.driver.getViewportSize(); }) .then(function (size) { viewportSizeW = size.width; viewportSizeH = size.height; viewportPosX = handlePosX + Math.floor((handleSizeW - viewportSizeW) / 2); viewportPosY = handlePosY + (handleSizeH - viewportSizeH); x = screenX - viewportPosX; y = screenY - viewportPosY; return {x: x, y: y}; }) .catch(function (err) { me.handleWDError(err); }); } });}());