(function() {
    var logger = ST.logger.forClass('orion');
 
    ST.parseQuery = function (query) {
        var paramRe = /([^&=]+)=?([^&]*)/g,
            plusRe = /\+/g,  // Regex for replacing addition symbol with a space
            ret = {},
            match, key, val, was;
 
        while (match = paramRe.exec(query)) {
            key = decodeURIComponent(match[1].replace(plusRe, ' '));
            val = decodeURIComponent(match[2].replace(plusRe, ' '));
            was = ret[key];
 
            if (typeof was === 'string') {
                val = [was, val];
            }
            else if (was) {
                was.push(val);
                continue;
            }
 
            ret[key] = val;  // a String (for one value) or String[] for multiple
        }
 
        return ret;
    };
 
    var messages = [],
        EMPTY = [], // reusable, readonly empty array
        seq = 0,
        callbacks = {},
        runConfig = ST.runConfig || {},
        baseUrl = runConfig.baseUrl || '',
        registerUrl = baseUrl + '/~orion/register?_dc=',
        messagesUrl = baseUrl + '/~orion/messages?_dc=',
        updatesUrl = baseUrl + '/~orion/updates?_dc=',
        handshakeComplete = false,
        isTestRunStarted = false,
        _updatesPending = false,
        controllers = [],
        maxRetries = 3,
        retryCount = 0,
        retryPending = false,
        terminated = false,
        hasError = false,
        urlParams = (ST.urlParams = ST.parseQuery(top.location.search.substring(1))),
        startingUrl = location.href,
        sessionStorage = window.sessionStorage,
        nonSpaceRe = /\S/,
        toString = Object.prototype.toString,
        typeofTypes = {
            number: 1,
            string: 1,
            'boolean': 1,
            'undefined': 1
        },
        toStringTypes = {
            '[object Array]'  : 'array',
            '[object Date]'   : 'date',
            '[object Boolean]': 'boolean',
            '[object Number]' : 'number',
            '[object RegExp]' : 'regexp',
            '[object String]' : 'string'
        },
        failOnError; // for catching general errors in ST.Spec runs
 
    ST.agentId = ST.agentId || urlParams.orionAgentId;
    ST.sessionId = new Date().getTime().toString();
 
    if (sessionStorage) {
        ST.sessionId = sessionStorage.getItem('orion.sessionId') || ST.sessionId;
        ST.proxyId = sessionStorage.getItem('orion.proxyId') || ST.proxyId;
        sessionStorage.setItem('orion.sessionId', ST.sessionId);
        sessionStorage.setItem('orion.proxyId', ST.proxyId);
    }
 
    function ajax(options) {
        logger.trace('.ajax');
        var url = options.url,
            data = options.data || null,
            success = options.success,
            failure = options.failure,
            scope = options.scope || this,
            params = options.params,
            queryParams = [],
            method, queryParamStr, xhr, sep;
 
        if (typeof data === "function") {
            data = data();
        }
 
        if (data && typeof data !== 'string') {
            data = JSON.stringify(data);
        }
 
        method = options.method || (data? 'POST' : 'GET');
 
        if (params) {
            for (var name in params) {
                if (params[name] != null) {
                    queryParams.push(name + "=" + encodeURIComponent(params[name]));
                }
            }
 
            queryParamStr = queryParams.join('&');
 
            if (queryParamStr !== '') {
                sep = url.indexOf('?') > -1 ? '&' : '?';
                url = url + sep + queryParamStr;
            }
        }
 
        if (typeof XMLHttpRequest !== 'undefined') {
            xhr = new XMLHttpRequest();
        } else {
            xhr = new ActiveXObject('Microsoft.XMLHTTP');
        }
 
        logger.debug(method, url);
        xhr.open(method, url);
 
        xhr.onreadystatechange = function() {
            if (xhr.readyState === 4) {
                logger.debug('HTTP response code:', xhr.status);
                if (xhr.status === 200) {
                    if (success) {
                        success.call(scope, options, xhr);
                    }
                } else {
                    if (failure) {
                        failure.call(scope, options, xhr);
                    }
                }
            }
        };
 
        xhr.send(data);
 
        return xhr;
    }
 
    function callback(seq, result) {
        var fn = callbacks[seq];
        delete callbacks[seq];
        fn(result);
    }
 
    function reload(forced) {
        var urlAgentId = urlParams.orionAgentId,
            dc = Date.now().toString(),
            search;
 
        // use != here to compare number / string
        if (ST.agentId != urlAgentId) {
            search = location.search.replace('orionAgentId=' + urlAgentId, 'orionAgentId=' + ST.agentId);
            location.search = search;
        } else if (forced) {
            if (location.search.indexOf('_dc') !== -1) {
                // update the cache buster
                search = location.search.replace('_dc=' + dc.length, 'dc=' + dc);
                location.search = search;
            } else {
                // add the cache buster
                search = location.search + '&_dc=' + dc;
                location.search= search;
            }
        } else {
            // Safari ignores forcedReload for some reason.
            location.reload(true);
        }
 
        terminated = true;
    }
 
    function redirect(url) {
        if (sessionStorage) {
            sessionStorage.clear();
        }
 
        // if we are redirecting to the parking page, or to another subject url
        ST.warnOnLeave(false);
        location.href = url;
        terminated = true;
    }
 
    var Controller = {
        startTestRun: function (message) {
            logger.trace('.startTestRun <=', 'message')
            var isRecording = message.isRecording,
                contextClasses = {
                    custom: ST.context.Custom,
                    local: ST.context.Local,
                    webdriver: ST.context.WebDriver,
                    webdriverrecorder: ST.context.WebDriverRecorder,
                    webdriverinspector: ST.context.WebDriverInspector
                },
                context, ContextClass, options;
 
            ST.runId = message.runId;
 
            ST.setupOptions(message.testOptions);
 
            if (isTestRunStarted || message.reload) {
                var pickle = {
                    runId: message.runId,
                    testOptions: message.testOptions
                };
 
                if (message.testIds) {
                    pickle.testIds = message.testIds;
                }
 
                sessionStorage.setItem('orion.autoStartTestRun', JSON.stringify(pickle));
 
                setTimeout(function() {
                    // The reason for the slight delay here is so that if a successive
                    // message arrives instructing the agent to redirect to the parking
                    // page, that one will take precedence.
                    // This can happen when the user is stopped at a breakpoint, and
                    // then initiates another test run.  The startTestRun message will be
                    // sent to the agent, but not processed because the user is still
                    // in break mode.   Then if the user attempts to initiate a test run
                    // again, Studio will detect that the agent never responded to the
                    // first message, and so it will launch a new agent and send another
                    // message to this agent instructing it to park.  If the user then
                    // returns to this agent and exits break mode, the messages will be
                    // processed, but we want the "redirect" message to take precedence - the
                    // agent should not start running tests.
                    ST.reloadPending = true;
                    if (isTestRunStarted) {
                        location.href = startingUrl;
                    }
                    reload();
                }, 100);
 
                return false; // Don't execute startTestRun on any other controllers
            }
 
            ST.testIds = message.testIds;
            options = ST.options;
            logger.debug('Creating context of type', options.contextType);
            ContextClass = contextClasses[options.contextType];
            context = new ContextClass(options);
 
            logger.debug('Driver configuration:', JSON.stringify(options.driverConfig));
 
            if (options.driverConfig) { // skip for now to let me start my own context to test ie startup on win10
                ST.testsReady.block();
                context.init().then(function (ret) {
                    ST.testsReady.unblock();
                }, function (err) {
                    logger.error(err.stack || err);
                    ST.sendMessage({
                        type: 'systemError',
                        message: ST.parseError(err)
                    });
                });
            }
 
            context.isRecording = isRecording;
            logger.debug(isRecording ? 'Context is recording' : 'Context is not recording');
 
            ST.defaultContext = context;
            isTestRunStarted = true;
        },
 
        handshake: function(message) {
            ST.agentId = message.agentId;
            ST.proxyId = message.proxyId;
            handshakeComplete = true;
            if (sessionStorage) {
                sessionStorage.setItem('orion.proxyId', ST.proxyId)
            }
            flushUpdates();
            poll();
        },
 
        error: function(message) {
            hasError = true;
            alert(message.message);
        },
 
        reload: function(message) {
            reload(message.forced);
        },
 
        redirect: function(message) {
            var url = message.url,
                port = message.port,
                page = message.page;
 
            if (!url) {
                url = location.protocol + "//" + location.hostname;
 
                if (port) {
                    url += ':' + port;
                }
 
                if (page) {
                    url += '/' + page;
                }
            }
 
            redirect(url);
        },
 
        response: function(message) {
            var seq = message.responseSeq;
            if (callbacks[seq]) {
                try {
                    callbacks[seq](message.value, message.error);
                } finally {
                    callbacks[seq] = null;
                }
            }
        },
 
        stopRecording: function() {
            logger.trace('.stopRecording');
            if (ST.defaultContext) {
                ST.defaultContext.stopRecording(function () {
                    logger.debug('Context stopped. Sending "recordingStopped".');
                    ST.sendMessage('recordingStopped');
                }, function (err) {
                    if (err.message === "Couldn't connect to selenium server") {
                        logger.debug('Selenium Server already stopped.');
                        ST.sendMessage('recordingStopped');
                    } else {
                        logger.error(err.stack || err);
                        ST.sendMessage({
                            type: 'systemError',
                            message: err.message
                        });
                    }
                });
            } else if (ST.recorder) {
                ST.warnOnLeave(false);
                ST.recorder.stop();
                ST.recorder = null;
                ST.sendMessage('recordingStopped');
            } else {
                logger.warn('No handler found for stopRecording message.');
            }
        },
        
        terminate: function() {
            logger.trace('.terminate');
            if (ST.defaultContext) {
                ST.defaultContext.stop(function () {
                    ST.sendMessage('terminated');
                }, function (err) {
                    if (err.message === "Couldn't connect to selenium server") {
                        logger.debug('Selenium Server already stopped.');
                        ST.sendMessage('terminated');
                    } else {
                        logger.error(err.stack || err);
                        ST.sendMessage({
                            type: 'systemError',
                            message: err.message
                        });
                    }
                });
            } else {
                terminated = true;
            }
        },
 
        toggleInspectEnabled: function (message) {
            return ST.defaultContext.toggleInspectEnabled(message);
        },
 
        inspectQuery: function (message) {
            return ST.defaultContext.inspectQuery(message);
        },
 
        inspectBatch: function (message) {
            return ST.defaultContext.inspectBatch(message);
        },
 
        inspectAllProperties: function (message) {
            return ST.defaultContext.inspectAllProperties(message);
        },
 
        refreshTrees: function (message) {
            return ST.defaultContext.refreshTrees(message);
        }
    };
 
    function processMessages (messages) {
        var len = messages.length,
            controllerCount = controllers.length,
            i, j, message, type, handled, controller, result, isErr;
 
        for (i = 0; i < len; i++) {
            message = messages[i];
 
            logger.trace('.processMessages', JSON.stringify(message));
 
            type = message.type;
            handled = false;
 
            for (j = 0; j < controllerCount; j++) {
                controller = controllers[j];
                if (controller[type]) {
                    handled = true;
                    try {
                        result = controller[type](message);
                        if (result === false) {
                            break;
                        }
                    } catch (err) {
                        logger.error(err.stack || err);
                        result = err;
                        isErr = true;
                    }
                    if (message.responseRequired) {
                        // TODO Polyfill for in-browser tests
                        if (result && (typeof result.then === 'function')) {
                            result.then(function (value) {
                                ST.sendMessage({
                                    type: 'response',
                                    responseSeq: message.seq,
                                    value: value
                                });
                            }, function (err) {
                                ST.sendMessage({
                                    type: 'response',
                                    responseSeq: message.seq,
                                    error: err
                                });
                            });
                        } else {
                            ST.sendMessage({
                                type: 'response',
                                responseSeq: message.seq,
                                value: isErr ? null : result,
                                error: isErr ? result : null
                            });
                        }
                    }
                }
            }
 
            if (!handled) {
                console.error('Cannot process message "' + type + '". No handler found.');
            }
        }
    }
 
    function success(options, xhr) {
        var text = xhr.responseText,
            messages = text && JSON.parse(text);
 
        retryCount = 0;
 
        // check if the agent has been terminated via reload or redirect before processing
        // messages - this prevents us from going into an infinite loop if the server
        // responds with another redirect message prior to the browser actually executing
        // the first redirect, which would result in another poll being opened which would
        // lead to another redirect message from the server... and round and round we go.
        if (!terminated) {
            if (messages && messages.length) {
                processMessages(messages);
            }
 
            poll();
        }
    }
 
    function failure(options, xhr) {
        if (++retryCount < maxRetries) {
            retryPending = true;
 
            setTimeout(function () {
                retryPending = false;
                poll();
            }, 500 * retryCount);
        } else {
            // the proxy server we were communicating with is no longer responding.
            console.log('Agent lost connection with Sencha Studio');
        }
    }
 
    function flushUpdates () {
        var buff = messages;
 
        if (buff.length && !_updatesPending && handshakeComplete && !hasError && !retryPending && !terminated) {
            _updatesPending = true;
            messages = [];
 
            ajax({
                url: updatesUrl + ST.now(),
                data: buff,
                params: {
                    agentId: ST.agentId,
                    sessionId: ST.sessionId,
                    proxyId: ST.proxyId,
                    runId: ST.runId
                },
                success: function(options, xhr){
                    _updatesPending = false;
                    var text = xhr.responseText,
                        messages = text && JSON.parse(text);
                    if (messages && messages.length) {
                        processMessages(messages);
                    }
                    flushUpdates();
                },
                failure: function(){
                    // TODO: need some retry logic here to delay the retry or give up
                    messages.unshift.apply(messages, buff);
                    _updatesPending = false;
                    retryPending = true;
                    setTimeout(function() {
                        retryPending = false;
                        flushUpdates();
                    }, 500)
                }
            });
        }
    }
 
    function poll () {
        if (!hasError && !terminated) {
            ajax({
                url: messagesUrl + ST.now(),
                params: {
                    agentId: ST.agentId,
                    sessionId: ST.sessionId,
                    proxyId: ST.proxyId,
                    runId: ST.runId
                },
                success: success,
                failure: failure
            });
        }
    }
 
    function register (force) {
        ajax({
            url: registerUrl + ST.now(),
            params: {
                agentId: ST.agentId,
                sessionId: ST.sessionId,
                proxyId: ST.proxyId,
                runnerId: ST.runnerId,
                force: force
            },
            success: function (options, xhr) {
                var messages = JSON.parse(xhr.responseText);
                processMessages(messages);
            },
            failure: function (err) {
                logger.error(err.stack || err);
            }
        });
    }
 
    ST.Element.on(window, 'load', function() {
        ST.windowLoaded = true;
    });
 
    // ----------------------------------------------------------------------------
    // Public API
 
    /**
     * Add controller
     * @param controller
     * @member ST
     * @private
     */
    ST.addController = function(controller) {
        controllers.push(controller);
    };
 
    /**
     * @member ST
     * Send message
     * @param message
     * @param callback
     * @private
     */
    ST.sendMessage = function(message, callback) {
        if (!hasError) {
            if (typeof message != 'object') {
                message = {
                    type: message
                };
            }
 
            callback = callback || message.callback;
            delete message.callback;
            message.seq = ++seq;
            if (callback) {
                callbacks[message.seq] = callback;
                message.responseRequired = true;
            }
            messages.push(message);
            flushUpdates();
        }
    };
 
    ST.getParam = function (name) {
        return urlParams[name];
    };
 
    /**
     * @member ST
     * Called before test files are loaded
     * @private
     */
    ST.beforeFiles = function() {
        // The initial call to "register" must be after all the orion files have loaded
        // but ideally before the users spec files are loaded.
        // This ensures that if there is a pending startTestRun message it does not
        // get processed until jasmine-orion is available, and also ensures that
        // if we need to reload the page because of a runnerId mismatch we can do
        // it as soon as possible without waiting for all the user's code to load.
        register();
 
        // Even though we may defer execution/evaluation of the test inventory, the
        // timing of this messages does not need to closely correspond to when tests
        // actually start being described to the Runner.
        ST.sendMessage({
            type: 'beforeFiles'
        });
    };
 
    /**
     * @member ST
     * Called after test files are loaded
     * @private
     */
    ST.afterFiles = function() {
        var extMicroloader = Ext.Microloader,
            extOnReady = Ext.onReady,
            pickle;
 
        ST.currentTestFile = null;
 
        // In case the microloader is present, block orion until it's done with its
        // job, since it's asynchronously loading more scripts
        if (extMicroloader) {
            ST.ready.block();
            extMicroloader.onMicroloaderReady(function () {
                logger.trace('.afterFiles, microloader ready');
                ST.ready.unblock();
            }); 
        }
        
        // We thought Ext JS was ready in the end of init.js, but if somebody (like jazzman)
        // decided to use the Loader, for instance, it will go back to a non-ready state.
        // Therefore, here we give a second chance for late calls that may eventually still
        // be going at this point.
        if (extOnReady) {
            ST.ready.block();
            extOnReady(function () {
                ST.defer(function() {
                    logger.trace('.afterFiles, extOnReady fired');
                    // Slightly delayed to ensure that this runs after any user onReady
                    // handlers.  This approach is preferred over using the priority option
                    // because it works with all versions of the framework.
                    ST.ready.unblock();
                }, 100);
            });
        }
 
        // If we had to reload in order to run the tests, we will have put the
        // startTestRun message in a pickle jar for now.
        pickle = JSON.parse(sessionStorage.getItem('orion.autoStartTestRun'));
        if (pickle) {
            sessionStorage.removeItem('orion.autoStartTestRun');
 
            // The type is not stored, so restore it and remove the reload option
            // (to avoid an infinite loop of reloads).
            pickle.type = 'startTestRun';
            pickle.reload = false;
 
            // And process the message as if the Runner had just sent it. Since we
            // not ready nor testsReady, this just gets things primed. In general, if
            // the user is click-happy the startTestRun message could arrive this
            // early anyway, so faking it here is not really very special.
            processMessages([ pickle ]);
        }
 
        // Because we may defer execution of the test inventory, connect this message
        // back to the Runner to the testsReady state. This is important for the Event
        // Recorder to know that the full test inventory has been described so that it
        // can determine if exactly one spec has the startRecording call in it.
        ST.testsReady.on(function () {
            ST.sendMessage({
                type: 'afterFiles'
            });
        });
 
        // We start with 1 ready blockage. Here we unblock it, which means ST itself
        // is ready to go.
        ST.ready.unblock();
    };
 
    /**
     * @member ST
     * Called before a test file is loaded
     * @param file
     * @private
     */
    ST.beforeFile = function (file) {
        ST.currentTestFile = file;
    };
 
    /**
     * @member ST
     * Called after a test file is loaded
     * @param file
     * @private
     */
    ST.afterFile = function (file) {
        ST.currentTestFile = null;
    };
 
    //-------------------------------------------------------------------------
    // Player
 
    /**
     * @member ST
     * Called when the Player throws an error
     * @private
     */
    ST.onPlayerError = function (ex) {
        ST.status.addResult({
            passed: false,
            message: ex.message || ex
        });
    };
 
    /**
     * Lazily creates and returns the shared {@link ST.event.Player event player}. This
     * is rarely called directly, but is the underlying mechanism used the inject events
     * using the {@link ST#play} and {@link ST.future.Element futures} API's.
     * @return {ST.event.Player}
     * @method player
     * @member ST
     */
    ST.player = function () {
        var player = ST._player;
 
        if (!player) {
            ST._player = player = new ST.event.Player();
 
            player.on('error', ST.onPlayerError);
        }
 
        return player;
    };
 
    ST.isPlayingEvents = function () {
        var player = ST._player;
 
        return player && player.isPlaying();
    };
 
    /**
     * Adds an array of events to the queue and optionally calls a `done` method when
     * the events have all been played.
     *
     * @param {ST.playable.Playable[]} events The events to play.
     * @param {Function} [done] Optional function to call after the events have played.
     * @param {Object} [future] Optional ST.future.Element to associate with these events.
     * // TODO what if an event changes the future??? :( How funny sounding...
     * @return {ST.playable.Playable[]} The `events` array with each element now promoted
     * from config object to `ST.playable.Playable` instance.
     * @method play
     * @member ST
     */
 
    ST.play = function (events, done) {
        ST.defaultContext.play(events,done);
    };
 
    ST.startInspector = function (done) {
        if (!ST.defaultContext.startInspector) {
            throw new Error('Inspector is not supported for this scenario type.');
        }
        ST.startRecording({startFn: ST.defaultContext.startInspector}, done);
    }
 
// TODO there is a lot of infrastructure in player/jasmine/studio reporter
// that depends on ST.startRecording()... so for now just pass a config param
    /**
     * Starts the {@link ST.event.Recorder event recorder}. Once this method is called
     * all further test execution is halted.
     *
     * This method is typically injected automatically by Sencha Test Studio when using
     * its Event Recorder and is therefore rarely called directly.
     * @param {Object} config Options to configure the {@link ST.event.Recorder}.
     * @param done
     * @method startRecording
     * @member ST
     */
    ST.startRecording = function (config, done) {
        logger.trace('.startRecording', config);
        var player = ST.player(),
            startFn;
 
        if (typeof config === 'function') {
            done = config;
            config = {};
        }
        config = config || {};
        startFn = config.startFn || ST.defaultContext.startRecording;
 
        if (ST.isPlayingEvents() && !config.skipChecks) {
            // We use ST.play() to properly inject into the event timeline. This is
            // necessary to handle cases like this:
            //
            //      ST.button('@foo')
            //          .click()
            //          .and(function () {
            //              ST.startRecording(); // Fun!
            //          })
            //          .click();
            //
            // Once it is our turn, we pause() the player to avoid any further event
            // playback.
            ST.play([{
                timeout: 0,
                remoteable: false,
                fn: function (done) {
                    ST.startRecording(ST.apply({
                        skipChecks: true
                    }, config), done);
                }
            }]);
        } else {
            // There is a bug in player.pause that doesn't pause when there are no events.
            ST.wait(0);
            player.pause();
 
            startFn.apply(ST.defaultContext);
 
            if (done) {
                done();
            }
        }
    };
 
    ST.onBeforeUnload = function (evt) {
        return evt.returnValue = 'Sencha Test does not currently support page navigation during a test scenario.';
    };
 
    ST.warnOnLeave = function (warn) {
        var el = ST.fly(window);
        if (warn) {
            if (!ST._onbeforeload) {
                ST._onbeforeload = el.on('beforeunload', ST.onBeforeUnload);
            }
        } else if(ST._onbeforeload) {
            ST._onbeforeload.destroy();
            ST._onbeforeload = null;
        }
    };
    
    /**
     * @member ST
     * Log
     * @param message
     * @private
     */
    ST.log = function(message) {
        ST.sendMessage({
            type: 'log',
            message: message
        });
    };
 
    /**
     * @class ST.Tests
     * @singleton
     * @protected
     */
    ST.Tests = {
        lastFile: null,
        queue: [],
        running: 0,
 
        enqueue: function (testFn) {
            var me = ST.Tests,
                queue = me.queue;
 
            if (!ST.options.evaluateTestsOnReady || me.running) {
                return testFn();
            }
 
            if (!queue.length) {
                // When we first defer a describe() we block the testsReady gate.
                ST.testsReady.block();
                ST.ready.on(ST.Tests.start);
            }
 
            queue.push({
                file: ST.currentTestFile,
                fn: testFn
            });
        },
 
        next: function () {
            var me = ST.Tests,
                queue = me.queue,
                record = queue.shift();
 
            if (record) {
                // Ensure top-level suites know the current test file path.
                me.setFile(record.file);
 
                ++me.running;
 
                record.fn();
 
                --me.running;
 
                if (queue.length) {
                    // Null this directly not using setCurrentFile since we may have
                    // multiple top-level tests in a file and therefore we are not really
                    // transitioning to the next file. We null this out since other async
                    // operations may fire before we get back to this and it would be
                    // incorrect to think we are in the context of this file unless we
                    // actually are processing its tests.
                    ST.currentTestFile = null;
 
                    ST.defer(me.next, 10);
                } else {
                    me.setFile(null);
 
                    // When we run the last describe() we unblock the testsReady gate.
                    ST.testsReady.unblock();
                }
            }
        },
 
        setFile: function (file) {
            var me = ST.Tests,
                lastFile = me.lastFile;
 
            if (lastFile !== file) {
                if (lastFile) {
                    ST.sendMessage({
                        type: 'afterFile',
                        file: lastFile
                    });
                }
 
                me.lastFile = lastFile = file;
 
                if (lastFile) {
                    ST.sendMessage({
                        type: 'beforeFile',
                        file: lastFile
                    });
                }
            }
 
            return ST.currentTestFile = file;
        },
 
        start: function () {
            // Must defer since enqueue() has not pushed() yet... in the rare case
            // where ST.ready is open already. Even if not, it is best to give the app
            // some room after it goes ready.
            ST.defer(ST.Tests.next, 50);
        }
    };
 
    /**
     * @class ST.Block
     * This class is created to wrap user test functions. It provides a `wrapperFn` to
     * pass to the test framework, manages a {@link ST.WatchDog watch dog} if the user's
     * code is asynchronous and coordinates with the {@link ST.event.Player event player}
     * to wait for queued events to complete.
     * @protected
     * @since 1.0.2
     */
 
    /**
     * @method constructor
     * @param {Function/Object} fn The user function or a config object to apply to `this`
     * instance. The config object must contain an `fn` property with the user function.
     * @param {Number} [timeout] The timeout for `fn` to complete. If not specified, the
     * {@link ST.options#timeout default timeout} is used.
     * @protected
     */
    ST.Block = function (fn, timeout) {
        var me = this;
 
        if (typeof fn === 'function') {
            me.fn = fn;
            me.timeout = timeout;
        } else {
            ST.apply(me, fn);
        }
 
        me.async = me.fn.length > 0;
 
        /**
         * @property {Function} wrapperFn
         * This function binds the user function `fn` to this `Block`. This function is
         * intended to be passed to the test framework. When called, this function stores
         * the `done` parameter and `this` pointer and passes control to the `invoke`
         * method of the owning `Block` instance.
         * @param {Function} done The callback provided by the test framework. This must
         * be a declarated parameter in order for test frameworks (such as Jasmine/Mocha)
         * to detect that the function is asynchronous.
         */
        me.wrapperFn = function (done) {
            // Capture the context object from the test framework.
            me._context = this;
            // And the "done" parameter.
            me._done = done;
 
            ST.currentBlock = me;
 
            me.invoke();
 
            return me.ret;
        };
    };
 
    ST.Block.prototype = {
        /**
         * @property {Object} _context
         * The `this` pointer supplied by the test framework.
         * @private
         */
        _context: null,
 
        /**
         * @property {Function} _done
         * The `done` parameter passed by the test framework. This property is set to
         * `null` after it is called.
         * @private
         */
        _done: null,
 
        /**
         * @property {Boolean} async
         * This property is `true` if the user's function is asynchronous.
         * @private
         */
        async: false,
 
        /**
         * @property {Boolean} calling
         * This property is `true` during the call to the user's function.
         * @private
         */
        calling: false,
 
        /**
         * @property {Boolean} playing
         * This property is `true` if the event player is running.
         * @private
         */
        playing: false,
 
        /**
         * @property {Object} ret
         * The value returned by the user's test function.
         * @private
         */
        ret: null,
 
        /**
         * @property {ST.WatchDog} watchDog
         * The `WatchDog` instance used to manage the timeouts for the user's code.
         * @private
         */
        watchDog: null,
 
        /**
         * Calls the user's function wrapped in a `try` / `catch` block (depending on the
         * {@link ST.options#cfg-handleExceptions test options}).
         */
        call: function () {
            var me = this,
                context = me._context,
                watchDog = me.watchDog,
                done = watchDog && watchDog.done,
                fn = me.fn,
                ret;
 
            me.calling = true; // allow the user to call through to done()
 
            if (ST.options.handleExceptions) {
                try {
                    ret = fn.call(context, done);
                } catch (err) {
                    var errorMsg = ST.parseError(err);
                    // tie-in with jasmine-post-extensions.js pending override, specDisabled indicates error
                    // is really just a pending
                    if (err.specDisabled) {
                        me.error = err;
                    } else {
                        logger.error(err.stack || err);
                        if (ST && ST.status && ST.sendMessage) {
                            ST.sendMessage({
                                type: 'systemError',
                                message: errorMsg
                            });
                        } else {
                            console.log('an error occurred ', err);
                        }
                        me.error = errorMsg;
                    }
                }
            } else {
                ret = fn.call(context, done);
            }
 
            me.calling = false; // allow the user to call through to done()
 
            me.ret = ret;
        },
 
        /**
         * This method is called to report a test failure.
         * @param {Error/String} ex The exception (`Error` instance) or error message.
         */
        failure: function (ex) {
            // If we haven't called the test framework completion method (_done) yet,
            // we can still report failures. Once we call that callback, we clear the
            // _done property so we know we are outside the scope of the test.
            if (ex && this._done) {
                if(ex.specDisabled) {
                    ST.Test.current.disabled = ex.message || 'pending';
 
                    ST.status.addResult({
                        passed: true,
                        message: ex.message,
                        disabled: true
                    });
                } else {
                    ST.status.addResult({
                        passed: false,
                        message: ex.message || ex
                    });
                }
            }
        },
 
        /**
         * This method is called to complete the test block.
         * @param {Error/String} [ex]
         */
        finish: function (ex) {
            var me = this,
                done = me._done,
                watchDog = me.watchDog,
                player;
 
            if (done) {
                me.failure(me.error || ex); // call before clearing "_done"
                me._done = null;
 
                if (watchDog) {
                    watchDog.destroy();
                    me.watchDog = null;
                }
 
                if (me.playing) {
                    me.playing = false;
                    player = ST.player();
 
                    player.un({
                        end: me.onEndPlay,
                        single: true,
                        scope: me
                    });
 
                    player.stop();
                }
 
                // If the event recorder is running we don't want to move forward,
                // so just stop here.
                if (!ST.recorder) {
                    ST.currentBlock = null;
                    done();
                }
            }
        },
 
        invoke: function () {
            var me = this,
                player;
 
            ST.Test.current.start();
 
            if (me.async) {
                me.watchDog = new ST.WatchDog(me.onWatchDog, me, me.timeout);
            }
 
            me.call();
 
            if (ST.isPlayingEvents()) {
                me.playing = true;
 
                player = ST.player();
                player.on({
                    end: me.onEndPlay,
 
                    single: true,
                    scope: me
                });
            }
 
            if (me.error || (!me.playing && !me.watchDog)) {
                me.finish();
            }
        },
 
        onEndPlay: function () {
            this.playing = false;
 
            if (!this.watchDog) {
                this.finish();
            }
        },
 
        onWatchDog: function (error) {
            var me = this;
 
            me.watchDog = null;
 
            if (error) {
                me.failure(error);
            }
 
            if (!me.playing && !me.calling) {
                // If the event player has started or we are still in the user's fn,
                // don't call done() just yet...
                me.finish();
            }
        }
    };
 
    /**
     * @class ST.Test
     * This base class for `ST.Spec` and `ST.Suite` manages a set of results and a
     * `failures` counter for an active test.
     * @since 1.0.2
     * @private
     */
    ST.Test = ST.define({
        /**
         * @property {Boolean} isTest
         * The value `true` to indicate an object is an `instanceof` this class.
         * @readonly
         * @private
         */
        isTest: true,
 
        /**
         * @property {ST.Test} current
         * The reference to the currently executing `ST.Spec` or `ST.Suite`.
         * @readonly
         * @private
         * @static
         */
 
        /**
         * @property {Number} failures
         * The number of failed results add to this test.
         * @readonly
         * @private
         */
        failures: 0,
 
        /**
         * @property {Object[]} results
         * An array of expectations/results. Each object should have at least these
         * fields:
         *
         *  * **passed** - A boolean value of `true` or `false`.
         *  * **message** - The associated message for the result.
         *
         * @readonly
         * @private
         */
        results: null,
 
        /**
         * @property {String} disabled
         * If present indicates that this test is disabled and explains why.
         *
         * @private
         */
        disabled: null,
 
        constructor: function (id, description) {
            /**
             * @property {ST.Suite} parent
             * The owning suite for this test.
             * @readonly
             * @private
             */
            this.parent = ST.Test.current;
 
            /**
             * @property id
             * The internal `id` for this test.
             * @readonly
             * @private
             */
            this.id = id;
 
            /**
             * @property {String} description
             * The test description. This is only stored here for diagnostic purposes.
             * @readonly
             * @private
             */
            this.description = description;
 
            ST.Test.current = this;
        },
 
        /**
         * Adds a result to this test and adjust `failures` count accordingly.
         * @param {Object} result
         * @param {Boolean} result.passed The pass (`true`) or failed (`false`) status.
         * @param {String} result.message The test result message.
         * @private
         */
        addResult: function (result) {
            var me = this;
 
            (me.results || (me.results = [])).push(result);
 
            result.status = result.disabled ?'disabled' : (result.passed ? 'passed' : 'failed');
 
            if (!result.passed) {
                ++me.failures;
 
                if (ST.options.breakOnFailure) {
                    debugger;
                }
            }
        },
 
        /**
         * Returns the `results` for this test and all `parent` tests.
         * @param {Boolean} [fork] Pass `true` to ensure the returned array is a copy
         * that can be safely modified. The default is to return the same `results` array
         * instance stored on this object (for efficiency).
         * @return {Object[]}
         * @private
         */
        getResults: function (fork) {
            var me = this,
                parent = me.parent,
                results = me.results || EMPTY,
                ret = results,
                n = results.length;
 
            if (parent && parent.hasResults()) {
                // Since our parent has results, we need a clone of them if we also
                // have results to append (or if our caller wanted a fork).
                ret = parent.getResults(fork || n > 0);
                if (n) {
                    ret.push.apply(ret, results);
                }
            }
            else if (fork) {
                // Even if we have no results, we must return a mutable array if we
                // are asked to fork the results.
                ret = results.slice();
            }
 
            return ret;
        },
 
        /**
         * Returns `true` if this test contains any failing expectations.
         * @return {Boolean}
         * @private
         */
        isFailure: function () {
            for (var test = this; test; test = test.parent) {
                if (test.failures) {
                    return true;
                }
            }
 
            return false;
        },
 
        /**
         * Returns `true` if this test contains any results.
         * @return {Boolean}
         * @private
         */
        hasResults: function () {
            for (var test = this; test; test = test.parent) {
                if (test.results) {
                    return true;
                }
            }
 
            return false;
        },
 
        start: function() {
            var parent = this.parent;
            if(!this.started) {
                this.started = true;
                if(parent) {
                    parent.start();
                }
                this.onStart();
            }
        },
 
        stop: function() {
            if(this.started && !this.stopped) {
                this.stopped = true;
 
                this.onStop();
 
                ST.Test.current = this.parent;
            }
        }
    });
 
    /**
     * @class ST.Suite
     * This class is an `ST.Test` container. It can also contain expectation results
     * due to methods like `beforeAll` which run outside the context of an `ST.Spec`.
     *
     * @extend ST.Test
     * @since 1.0.2
     * @private
     */
    ST.Suite = ST.define({
        extend: ST.Test,
 
        /**
         * @property {Boolean} isSuite
         * The value `true` to indicate an object is an `instanceof` this class.
         * @readonly
         */
        isSuite: true,
 
        onStart: function () {
            ST.status.suiteStarted({
                id: this.id,
                name: this.description
            });
        },
 
        onStop: function() {
            ST.status.suiteFinished({
                id: this.id,
                name: this.description,
                disabled: this.disabled
            });
        }
    });
 
    /**
     * @class ST.Spec
     * This class is a "specification" or leaf test case (not a container).
     *
     * @extend ST.Test
     * @since 1.0.2
     * @private
     */
    ST.Spec = ST.define({
        extend: ST.Test,
 
        /**
         * @property {Boolean} isSpec
         * The value `true` to indicate an object is an `instanceof` this class.
         * @readonly
         */
        isSpec: true,
 
        onStart: function () {
            failOnError = true;
 
            ST.status.testStarted({
                id: this.id,
                name: this.description
            });
        },
 
        onStop: function () {
            failOnError = false;
 
            ST.status.testFinished({
                id: this.id,
                name: this.description,
                passed: !this.isFailure(),
                expectations: this.getResults(),
                disabled: this.disabled
            });
        }
    });
 
    /**
     * @class ST.WatchDog
     * This class manages a `timeout` value for user code. Instances of `WatchDog` are
     * created to report failures if user code does not complete in the specified amount
     * of time.
     *
     * To provide this support, this class creates a `done` function that mimics the API
     * of the underlying test framework. This function is then passed to the user code
     * and is called when the test completes or fails. Failure to call this function in
     * the `timeout` period results in a failure.
     *
     * In all cases, the provided `callback` is called to report the result.
     *
     * @constructor
     * @param {Function} callback The callback to call when the user calls the returned
     * function or the `timeout` expires.
     * @param {Error/String} callback.error A timeout error message or `null` if the user
     * called the `done` function.
     * @param {Object} [scope] The `this` pointer for the `callback`.
     * @param {Number} [timeout] The timeout in milliseconds. Defaults to
     * {@link ST.options#timeout}.
     * @private
     * @since 1.0.2
     */
    ST.WatchDog = function (callback, scope, timeout) {
        var me = this,
            _logger = me._logger = logger.forObject('WatchDog');
 
        _logger.trace('.constructor', callback, scope, timeout);
 
        if (typeof scope === 'number') {
            timeout = scope;
        } else {
            me.scope = scope;
        }
 
        me.callback = callback;
 
        me.done = function () {
            me.fire(null);
        };
 
        me.done.fail = me.fail = function (e) {
            me.fire(e || new Error('Test failed'));
        };
        
        if (me.init) { // maybe for Moca?
            me.init();
        }
 
        me.set(timeout);
    };
 
    ST.WatchDog.prototype = {
        /**
         * @property {Function} done
         * This function is passed to user code and mimics the API of the test framework.
         * In Jasmine, this function has a `fail` function property that is called to
         * report failures. This function only reports succcess.
         * In Mocha, this function will instead accept an optional error parameter.
         * In all cases, calling this method with no arguments reports a success.
         * @readonly
         */
        done: null,
 
        /**
         * @method fail
         * This method is provided by the Test Framework Adapter. It is used to report
         * an asynchronous test failure.
         * @protected
         * @abstract
         * @param {String/Error} error
         */
        fail: null,
 
        /**
         * @method init
         * This method is provided by the Test Framework Adapter. It populates the `done`
         * property such that it mimics the test framework's API.
         * @protected
         * @abstract
         */
        init: null,
 
        scope: null,
 
        cancel: function () {
            var me = this,
                timer = me.timer;
            me._logger.trace('.cancel');
 
            if (timer) {
                me.timer = me.timeout = null;
                timer();
            }
        },
 
        destroy: function () {
            var me = this;
            me._logger.trace('.destroy');
            me.cancel();
            me.callback = me.scope = null;
        },
 
        fire: function (e) {
            var me = this,
                callback = me.callback;
            me._logger.trace('.fire');
 
            me.cancel();
 
            if (callback) {
                me.callback = null;
                callback.call(me.scope || me, e);
            }
        },
 
        onTick: function () {
            var me = this,
                hasTimeout = !!me.timeout,
                timeout, msg;
 
            if (me.timer) {
                timeout = me.timeout || me.timer.timeout;
                msg = 'Timeout waiting for test step to complete (' + (timeout / 1000) + ' sec).';
 
                me.timer = me.timeout = null;
 
                if (!hasTimeout) {
                    msg += ' If testing asynchronously, ensure that you have called done() in your spec.';
                }
                
                me.fire(msg);
            }
        },
 
        set: function (timeout) {
            var me = this;
            me._logger.trace('.set', timeout);
 
            me.cancel();
 
            me.timer = ST.timeout(me.onTick, me, me.timeout = timeout);
        }
    };
 
    /**
     * This class provides various methods that leverage WebDriver features. These are
     * only available when the browser is launched by WebDriver.
     * @class ST.system
     * @singleton
     * @private
     */
    ST.system = {
        /**
         * Get window handle
         * @method getWindowHandle
         * @param callback
         * @private
         */
        getWindowHandle: function(callback) {
            ST.sendMessage({
                type: 'getWindowHandle'
            }, callback);
        },
 
        /**
         * Get window handles
         * @method getWindowHandles
         * @param callback
         * @private
         */
        getWindowHandles: function(callback) {
            ST.sendMessage({
                type: 'getWindowHandles'
            }, callback);
        },
 
        /**
         * Switch to
         * @method switchTo
         * @param options
         * @param callback
         * @private
         */
        switchTo: function(options, callback) {
            options.type = 'switchTo';
            ST.sendMessage(options, callback);
        },
 
        /**
         * Close
         * @method close
         * @param callback
         * @private
         */
        close: function(callback) {
            ST.sendMessage({
                type: 'close'
            }, callback);
        },
 
        /**
         * Screenshot
         * @method screenshot
         * @param options
         * @param callback
         * @private
         */
        screenshot: function(options, callback) {
            if (typeof options === 'string') {
                options = {
                    name: options
                }
            }
            options.type = 'screenshot';
            ST.sendMessage(options, callback);
        },
        
        setViewportSize: function(width, height, callback) {
            var options = {
                type: 'setViewportSize',
                width: width,
                height: height
            };
            ST.sendMessage(options, callback);
        },
 
        /**
         * Click
         * @method click
         * @param domElement
         * @param callback
         * @private
         */
        click: function(domElement, callback) {
            ST.sendMessage({
                type: 'click',
                elementId: domElement.id
            }, callback);
        },
 
        /**
         * Send Keys
         * @method sendKeys
         * @param domElement
         * @param keys
         * @param callback
         * @private
         */
        sendKeys: function(domElement, keys, callback) {
            ST.sendMessage({
                type: 'sendKeys',
                elementId: domElement.id,
                keys: keys
            }, callback);
        },
 
        /**
         * Post coverage results
         * @method postCoverageResults
         * @param name
         * @param reset
         * @private
         */
        postCoverageResults: function(name, reset) {
            var coverage = window.__coverage__,
                filtered;
            if (coverage) {
                filtered = JSON.stringify(getCoverage(coverage));
                if (name === '__init__') {
                    resetCodeCoverage(coverage);
                    ST.sendMessage({
                        type: 'codeCoverageStructure',
                        name: name,
                        results: JSON.stringify(coverage)
                    });
                }
                ST.sendMessage({
                    type: 'codeCoverage',
                    name: name,
                    results: filtered
                });
                if (reset) {
                    resetCodeCoverage(coverage);
                }
            }
        }
    };
 
    var propNames = ['s', 'b', 'f'];
 
    function getCoverage (coverage) {
        var out = {},
            cvg, p, prop, stats, total;
 
        for (var fileName in coverage) {
            cvg = coverage[fileName];
            total = 0;
 
            for (p = 0; p < propNames.length; p++) {
                prop = propNames[p];
                stats = cvg[prop];
                for (var num in stats) {
                    var val = stats[num];
                    if (ST.isArray(val)) {
                        for (var i = 0; i < val.length; i++) {
                            total += val[i];
                        }
                    } else {
                        total += stats[num];
                    }
                }
            }
 
            if (total > 0) {
                out[fileName] = cvg;
            }
        }
        return out;
    }
 
    function resetCodeCoverage (coverage) {
        var out = {},
            cvg, p, prop, stats, statLen;
 
        for (var fileName in coverage) {
            cvg = coverage[fileName];
 
            for (p = 0; p < propNames.length; p++) {
                prop = propNames[p];
                stats = cvg[prop];
                for (var num in stats) {
                    if (prop === 'b') {
                        statLen = stats[num].length; 
                        stats[num] = Array.apply(null, Array(statLen)).map(Number.prototype.valueOf, 0);
                    }
                    else {
                        stats[num] = 0;
                    }
                }
            }
        }
    }
 
    // ----------------------------------------------------------------------------
    // Internal API used by test runners to report results and progress
 
    ST.status = {
        addResult: function (result) {
            var current = ST.Test.current;
 
            if (!current) {
                throw new Error('Not running a test - cannot report results.');
            }
 
            current.addResult(result);
        },
 
        runStarted: function (info) {
            ST.sendMessage({
                type: 'testRunStarted',
                testIds: ST.testIds
            });
        },
 
        runFinished: function (info) {
            if (ST.defaultContext) {
                ST.defaultContext.stop(function () {
                    ST.sendMessage({
                        type: 'testRunFinished'
                    });
                }, function (e) {
                    // TODO test this case!
                    ST.Test.current.addResult({
                        passed: false,
                        message: e.message || e
                    });
                    ST.sendMessage('testRunFinished');
                });
            } else {
                ST.sendMessage('testRunFinished');
            }
        },
 
        //-----------------------------
        // Structure reporting methods
 
        suiteEnter: function (info) {
            var message = {
                type: 'testSuiteEnter',
                name: info.name,
                id: info.id,
                fileName: info.fileName
            };
 
            if (info.disabled) {
                message.disabled = true;
            }
 
            ST.sendMessage(message, info.callback);
        },
 
        testAdded: function (info) {
            var message = {
                type: 'testAdded',
                name: info.name,
                id: info.id,
                testDef: info
            };
 
            if (info.disabled) {
                message.disabled = true;
            }
 
            ST.sendMessage(message, info.callback);
        },
 
        suiteLeave: function (info) {
            ST.sendMessage({
                type: 'testSuiteLeave',
                id: info.id,
                name: info.name
            }, info.callback);
        },
 
        //-----------------------------
        // Run results methods
 
        suiteStarted: function (info) {
            ST.sendMessage({
                type: 'testSuiteStarted',
                id: info.id,
                name: info.name
            }, info.callback);
        },
 
        suiteFinished: function (info) {
            var suite = ST.Test.current;
 
            if (!suite) {
                throw new Error('No current suite to finish');
            }
 
            ST.sendMessage({
                type: 'testSuiteFinished',
                id: info.id,
                name: info.name
            }, info.callback);
        },
 
        testStarted: function (info) {
            ST.sendMessage({
                type: 'testStarted',
                id: info.id,
                name: info.name
            }, info.callback);
        },
 
        testFinishedAsync: function (done) {
            ST.defaultContext.checkGlobalLeaks(done);
        },
 
        testFinished: function (info) {
            var current = ST.Test.current;
 
            if (current.expectFailure) {
                info.passed = current.isFailure();
 
                current.addResult({
                    passed: info.passed,
                    message: 'Expected test to have failures'
                });
            }
 
            ST.sendMessage({
                type: 'testFinished',
                id: info.id,
                name: info.name,
                disabled: info.disabled,
                passed: info.passed,
                expectations: info.expectations
            }, info.callback);
        },
 
        duplicateId: function (info) {
            ST.sendMessage({
                type: 'duplicateId',
                id: info.id,
                fullName: info.fullName
            });
        }
    };
 
    ST.addController(Controller);
 
    ST.Element.on(window, 'error', function(err) {
        if (failOnError) {
            ST.Test.current.addResult({
                passed: false,
                message: ST.parseError(err)
            });
        }
    });
    
    ST.isRecording = function () {
        return ST.urlParams.orionRecording || ST.runConfig && ST.runConfig.orionRecording;
    }
})();