/* 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)
            });
        }
    });
 
}());