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