/* 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,
        dTimeout,
        browserName,
        desiredCapabilities;
 
      logger.debug('Initializing WebdriverIO');
      driver = me.driver = ST.webdriverio
        .remote(me.driverConfig)
        .init();
 
      driver.addCommand('STmoveToObject', me._moveToObject);
      desiredCapabilities = driver.desiredCapabilities;
      if (desiredCapabilities) {
        if (desiredCapabilities.platform) {
          browserName = desiredCapabilities.platform;
        }
        else {
          browserName = desiredCapabilities.browserName;
        }
        // Condition for Mac substring index number
        // Support for WebdriverIO version 5 and for Safari version 12
        if (browserName.indexOf('Mac') >= 0
          && desiredCapabilities.browserName == 'safari'
          && parseInt(desiredCapabilities.version) >= 12) {
          dTimeout = driver.timeouts({ 'script': 10 * 1000 })
        } else {
          // support for older WebdriverIO versions
          dTimeout = driver.timeouts('script', 10 * 1000)
        }
      } else {
        dTimeout = driver.timeouts('script', 10 * 1000)
      }
 
      return dTimeout
        .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));
 
          // Ensure there is a timeout, as of ChromeDriver 76 there has to be one.
          if (!timeout || timeout < 1000) {
            timeout = 50000;
            logger.trace('Webdriver (ctx.timeout) timeout was not giving. Setting the script timeout to ' + timeout);
          }
 
          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);
 
        // Ensure there is a timeout, as of ChromeDriver 76 there has to be one.
        if (!timeout || timeout < 1000) {
          timeout = 50000;
          logger.trace('Webdriver (ctx.timeout) timeout was not giving. Setting the script timeout to ' + timeout);
        }
 
        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 {
            //Check here message type here...
            // case - vm has already finished
            ST.sendMessage({
              type: 'terminated'
            });
          }
        }
        else {
          //Terminate the process in case of RuntimeError
          //case - ERROR The test with session id xx-xx-xx 
          // has already finished, and can't receive further commands.
          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 ((>= box.right - scrollSize.width) ||
              (>= 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 (> 0) {
              canScrollX = xScroll && !atRight;
            } else if (< 0) {
              canScrollX = xScroll && !atLeft;
            }
 
            if (> 0) {
              canScrollY = yScroll && !atBottom;
            } else if (< 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(- rect.left);
            offsetY = Math.floor(- 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)
                  .leftClick(selector, playable.x, playable.y)
                  .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) {
                      var eid = webElement.ELEMENT;
                      if (!eid) {
                        try {
                          eid = webElement[Object.keys(webElement)[0]];
                        } catch (err) {
                          logger.error('Could not parse webElement id 1', err);
                        }
                      }
                      me.driver.elementIdValue(eid, text)
                        .then(resolve, reject)
                        .catch(function (err) {
                          logger.error('type chras error 1', err);
                        });
                    } else {
                      me.driver.keys(text)
                        .then(resolve, reject)
                        .catch(function (err) {
                          logger.error('type chars error 2', err);
                        });
                    }
                  });
              } else {
                if (webElement) {
                  var eid = webElement.ELEMENT;
                  if (!eid) {
                    try {
                      eid = webElement[Object.keys(webElement)[0]];
                    } catch (err) {
                      logger.error('Could not parse webElement id 2', err);
                    }
                  }
 
                  me.driver.elementIdValue(eid, text)
                    .then(resolve, reject)
                    .catch(function (err) {
                      logger.error('type chars error 3', err);
                    });
                } else {
                  me.driver.keys(text)
                    .then(resolve, reject)
                    .catch(function (err) {
                      logger.error('type chars error 4', err);
                    });
                }
              }
            });
          });
      } else if (playable.type === 'keydown') {
        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 (!== 'object' && t !== 'function') {
          ret[i] = v;
        }
        if (=== '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 (= 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)
      });
    }
  });
 
}());