From e6f36cacddcdae0d5626cbbe78dbf6720b0626af Mon Sep 17 00:00:00 2001 From: Vojta Jina Date: Fri, 20 Dec 2013 14:56:46 -0800 Subject: [PATCH] refactor(launcher): extract timeout, retry, process So that we can easily reuse only some features. --- lib/launcher.js | 35 +- lib/launchers/base.js | 243 ++++------- lib/launchers/capture_timeout.js | 43 ++ lib/launchers/process.js | 143 +++++++ lib/launchers/retry.js | 32 ++ lib/temp_dir.js | 28 ++ test/unit/launcher.spec.coffee | 84 ++-- test/unit/launchers/base.spec.coffee | 401 ++++++++---------- .../launchers/capture_timeout.spec.coffee | 54 +++ test/unit/launchers/process.spec.coffee | 223 ++++++++++ test/unit/launchers/retry.spec.coffee | 82 ++++ test/unit/mocha-globals.coffee | 15 + 12 files changed, 956 insertions(+), 427 deletions(-) create mode 100644 lib/launchers/capture_timeout.js create mode 100644 lib/launchers/process.js create mode 100644 lib/launchers/retry.js create mode 100644 lib/temp_dir.js create mode 100644 test/unit/launchers/capture_timeout.spec.coffee create mode 100644 test/unit/launchers/process.spec.coffee create mode 100644 test/unit/launchers/retry.spec.coffee diff --git a/lib/launcher.js b/lib/launcher.js index c56102226..6719e877e 100644 --- a/lib/launcher.js +++ b/lib/launcher.js @@ -1,10 +1,24 @@ var log = require('./logger').create('launcher'); -var baseBrowserDecoratorFactory = require('./launchers/base').decoratorFactory; +var baseDecorator = require('./launchers/base').decoratorFactory; +var captureTimeoutDecorator = require('./launchers/capture_timeout').decoratorFactory; +var retryDecorator = require('./launchers/retry').decoratorFactory; +var processDecorator = require('./launchers/process').decoratorFactory; + + +// TODO(vojta): remove once nobody uses it +var baseBrowserDecoratorFactory = function(baseLauncherDecorator, captureTimeoutLauncherDecorator, + retryLauncherDecorator, processLauncherDecorator) { + return function(launcher) { + baseLauncherDecorator(launcher); + captureTimeoutLauncherDecorator(launcher); + retryLauncherDecorator(launcher); + processLauncherDecorator(launcher); + }; +}; var Launcher = function(emitter, injector) { var browsers = []; - var lastUrl; var lastStartTime; var getBrowserById = function(id) { @@ -19,7 +33,7 @@ var Launcher = function(emitter, injector) { this.launch = function(names, hostname, port, urlRoot) { var browser; - var url = (lastUrl = 'http://' + hostname + ':' + port + urlRoot); + var url = 'http://' + hostname + ':' + port + urlRoot; lastStartTime = Date.now(); @@ -27,6 +41,10 @@ var Launcher = function(emitter, injector) { var locals = { id: ['value', Launcher.generateId()], name: ['value', name], + baseLauncherDecorator: ['factory', baseDecorator], + captureTimeoutLauncherDecorator: ['factory', captureTimeoutDecorator], + retryLauncherDecorator: ['factory', retryDecorator], + processLauncherDecorator: ['factory', processDecorator], baseBrowserDecorator: ['factory', baseBrowserDecoratorFactory] }; @@ -48,6 +66,9 @@ var Launcher = function(emitter, injector) { return; } + // TODO(vojta): remove in v1.0 + browser.forceKill = browser.forceKill || browser.kill; + log.info('Starting browser %s', browser.name); browser.start(url); browsers.push(browser); @@ -68,7 +89,7 @@ var Launcher = function(emitter, injector) { return false; } - browser.kill(callback); + browser.forceKill().then(callback); return true; }; @@ -80,9 +101,7 @@ var Launcher = function(emitter, injector) { return false; } - browser.kill(function() { - browser.start(lastUrl); - }); + browser.restart(); return true; }; @@ -104,7 +123,7 @@ var Launcher = function(emitter, injector) { browsers.forEach(function(browser) { remaining++; - browser.kill(finish); + browser.forceKill().then(finish); }); }; diff --git a/lib/launchers/base.js b/lib/launchers/base.js index cb5891450..cdf5d88dd 100644 --- a/lib/launchers/base.js +++ b/lib/launchers/base.js @@ -1,215 +1,130 @@ -var spawn = require('child_process').spawn; -var path = require('path'); -var fs = require('fs'); -var rimraf = require('rimraf'); - +var KarmaEventEmitter = require('../events').EventEmitter; +var EventEmitter = require('events').EventEmitter; +var q = require('q'); var log = require('../logger').create('launcher'); -var env = process.env; var BEING_CAPTURED = 1; var CAPTURED = 2; var BEING_KILLED = 3; var FINISHED = 4; -var BEING_TIMEOUTED = 5; +var RESTARTING = 5; +var BEING_FORCE_KILLED = 6; -var BaseBrowser = function(id, emitter, captureTimeout, retryLimit) { - var self = this; - var capturingUrl; - var exitCallbacks = []; +/** + * Base launcher that any custom launcher extends. + */ +var BaseLauncher = function(id, emitter) { + if (this.start) { + return; + } + + // TODO(vojta): figure out how to do inheritance with DI + Object.keys(EventEmitter.prototype).forEach(function(method) { + this[method] = EventEmitter.prototype[method]; + }, this); + KarmaEventEmitter.call(this); - this.killTimeout = 2000; this.id = id; this.state = null; - this._tempDir = path.normalize((env.TMPDIR || env.TMP || env.TEMP || '/tmp') + '/karma-' + - id.toString()); - - this.start = function(url) { - capturingUrl = url; - self.state = BEING_CAPTURED; - - try { - log.debug('Creating temp dir at ' + self._tempDir); - fs.mkdirSync(self._tempDir); - } catch (e) {} + this.error = null; - self._start(capturingUrl + '?id=' + self.id); - - if (captureTimeout) { - setTimeout(self._onTimeout, captureTimeout); - } - }; + var self = this; + var killingPromise; + var previousUrl; + this.start = function(url) { + previousUrl = url; - this._start = function(url) { - self._execCommand(self._getCommand(), self._getOptions(url)); + this.error = null; + this.state = BEING_CAPTURED; + this.emit('start', url + '?id=' + this.id); }; - - this.markCaptured = function() { - if (self.state === BEING_CAPTURED) { - self.state = CAPTURED; + this.kill = function() { + // Already killed, or being killed. + if (killingPromise) { + return killingPromise; } - }; - - - this.isCaptured = function() { - return self.state === CAPTURED; - }; + killingPromise = this.emitAsync('kill').then(function() { + self.state = FINISHED; + }); - this.kill = function(callback) { - var exitCallback = callback || function() {}; + this.state = BEING_KILLED; - log.debug('Killing %s', self.name); - if (self.state === FINISHED) { - process.nextTick(exitCallback); - } else if (self.state === BEING_KILLED) { - exitCallbacks.push(exitCallback); - } else { - self.state = BEING_KILLED; - self._process.kill(); - exitCallbacks.push(exitCallback); - setTimeout(self._onKillTimeout, self.killTimeout); - } + return killingPromise; }; + this.forceKill = function() { + this.kill(); + this.state = BEING_FORCE_KILLED; - this._onKillTimeout = function() { - if (self.state !== BEING_KILLED) { - return; - } - - log.warn('%s was not killed in %d ms, sending SIGKILL.', self.name, self.killTimeout); - - self._process.kill('SIGKILL'); + return killingPromise; }; - this._onTimeout = function() { - if (self.state !== BEING_CAPTURED) { + this.restart = function() { + if (this.state === BEING_FORCE_KILLED) { return; } - log.warn('%s have not captured in %d ms, killing.', self.name, captureTimeout); - - self.state = BEING_TIMEOUTED; - self._process.kill(); - }; - - - this.toString = function() { - return self.name; - }; - - - this._getCommand = function() { - var cmd = path.normalize(env[self.ENV_CMD] || self.DEFAULT_CMD[process.platform]); - - if (!cmd) { - log.error('No binary for %s browser on your platform.\n\t' + - 'Please, set "%s" env variable.', self.name, self.ENV_CMD); - } - - return cmd; - }; - - - this._execCommand = function(cmd, args) { - // normalize the cmd, remove quotes (spawn does not like them) - if (cmd.charAt(0) === cmd.charAt(cmd.length - 1) && '\'`"'.indexOf(cmd.charAt(0)) !== -1) { - cmd = cmd.substring(1, cmd.length - 1); - log.warn('The path should not be quoted.\n Normalized the path to %s', cmd); + if (!killingPromise) { + killingPromise = this.emitAsync('kill'); } - log.debug(cmd + ' ' + args.join(' ')); - self._process = spawn(cmd, args); - - var errorOutput = ''; - - self._process.on('close', function(code) { - self._onProcessExit(code, errorOutput); - }); - - self._process.on('error', function(err) { - if (err.code === 'ENOENT') { - retryLimit = 0; - errorOutput = 'Can not find the binary ' + cmd + '\n\t' + - 'Please set env variable ' + self.ENV_CMD; + killingPromise.then(function() { + if (self.state === BEING_FORCE_KILLED) { + self.state = FINISHED; } else { - errorOutput += err.toString(); + killingPromise = null; + log.debug('Restarting %s', self.name); + self.start(previousUrl); } }); - // Node 0.8 does not emit the error - if (process.versions.node.indexOf('0.8') === 0) { - self._process.stderr.on('data', function(data) { - var msg = data.toString(); - - if (msg.indexOf('No such file or directory') !== -1) { - retryLimit = 0; - errorOutput = 'Can not find the binary ' + cmd + '\n\t' + - 'Please set env variable ' + self.ENV_CMD; - } else { - errorOutput += msg; - } - }); - } + self.state = RESTARTING; }; - - this._onProcessExit = function(code, errorOutput) { - log.debug('Process %s exitted with code %d', self.name, code); - - if (self.state === BEING_CAPTURED) { - log.error('Cannot start %s\n\t%s', self.name, errorOutput); - } - - if (self.state === CAPTURED) { - log.error('%s crashed.\n\t%s', self.name, errorOutput); + this.markCaptured = function() { + if (this.state === BEING_CAPTURED) { + this.state = CAPTURED; } + }; - retryLimit--; - - if (self.state === BEING_CAPTURED || self.state === BEING_TIMEOUTED) { - if (retryLimit > 0) { - return self._cleanUpTmp(function() { - log.info('Trying to start %s again.', self.name); - self.start(capturingUrl); - }); - } else { - emitter.emit('browser_process_failure', self); - } - } + this.isCaptured = function() { + return this.state === CAPTURED; + }; - self.state = FINISHED; - self._cleanUpTmp(function(err) { - exitCallbacks.forEach(function(exitCallback) { - exitCallback(err); - }); - exitCallbacks = []; - }); + this.toString = function() { + return this.name; }; + this._done = function(error) { + killingPromise = killingPromise || q(); - this._cleanUpTmp = function(done) { - log.debug('Cleaning temp dir %s', self._tempDir); - rimraf(self._tempDir, done); - }; + this.error = this.error || error; + this.emit('done'); + if (this.error && this.state !== BEING_FORCE_KILLED && this.state !== RESTARTING) { + emitter.emit('browser_process_failure', this); + } - this._getOptions = function(url) { - return [url]; + this.state = FINISHED; }; + + this.STATE_BEING_CAPTURED = BEING_CAPTURED; + this.STATE_CAPTURED = CAPTURED; + this.STATE_BEING_KILLED = BEING_KILLED; + this.STATE_FINISHED = FINISHED; + this.STATE_RESTARTING = RESTARTING; + this.STATE_BEING_FORCE_KILLED = BEING_FORCE_KILLED; }; -var baseBrowserDecoratorFactory = function(id, emitter, timeout) { - return function(self) { - BaseBrowser.call(self, id, emitter, timeout, 3); +BaseLauncher.decoratorFactory = function(id, emitter) { + return function(launcher) { + BaseLauncher.call(launcher, id, emitter); }; }; -baseBrowserDecoratorFactory.$inject = ['id', 'emitter', 'config.captureTimeout']; -// PUBLISH -exports.BaseBrowser = BaseBrowser; -exports.decoratorFactory = baseBrowserDecoratorFactory; +module.exports = BaseLauncher; diff --git a/lib/launchers/capture_timeout.js b/lib/launchers/capture_timeout.js new file mode 100644 index 000000000..197259483 --- /dev/null +++ b/lib/launchers/capture_timeout.js @@ -0,0 +1,43 @@ +var log = require('../logger').create('launcher'); + +/** + * Kill browser if it does not capture in given `captureTimeout`. + */ +var CaptureTimeoutLauncher = function(timer, captureTimeout) { + if (!captureTimeout) { + return; + } + + var self = this; + var pendingTimeoutId = null; + + this.on('start', function() { + pendingTimeoutId = timer.setTimeout(function() { + pendingTimeoutId = null; + if (self.state !== self.STATE_BEING_CAPTURED) { + return; + } + + log.warn('%s have not captured in %d ms, killing.', self.name, captureTimeout); + self.error = 'timeout'; + self.kill(); + }, captureTimeout); + }); + + this.on('done', function() { + if (pendingTimeoutId) { + timer.clearTimeout(pendingTimeoutId); + pendingTimeoutId = null; + } + }); +}; + + +CaptureTimeoutLauncher.decoratorFactory = function(timer, + /* config.captureTimeout */ captureTimeout) { + return function(launcher) { + CaptureTimeoutLauncher.call(launcher, timer, captureTimeout); + }; +}; + +module.exports = CaptureTimeoutLauncher; diff --git a/lib/launchers/process.js b/lib/launchers/process.js new file mode 100644 index 000000000..075761cf8 --- /dev/null +++ b/lib/launchers/process.js @@ -0,0 +1,143 @@ +var path = require('path'); +var log = require('../logger').create('launcher'); +var env = process.env; + +var ProcessLauncher = function(spawn, tempDir, timer) { + + var self = this; + var onExitCallback; + var killTimeout = 2000; + + this._tempDir = tempDir.getPath('/karma-' + this.id.toString()); + + this.on('start', function(url) { + tempDir.create(self._tempDir); + self._start(url); + }); + + this.on('kill', function(done) { + if (!self._process) { + return process.nextTick(done); + } + + onExitCallback = done; + self._process.kill(); + timer.setTimeout(self._onKillTimeout, killTimeout); + }); + + this._start = function(url) { + self._execCommand(self._getCommand(), self._getOptions(url)); + }; + + this._getCommand = function() { + return env[self.ENV_CMD] || self.DEFAULT_CMD[process.platform]; + }; + + this._getOptions = function(url) { + return [url]; + }; + + // Normalize the command, remove quotes (spawn does not like them). + this._normalizeCommand = function(cmd) { + if (cmd.charAt(0) === cmd.charAt(cmd.length - 1) && '\'`"'.indexOf(cmd.charAt(0)) !== -1) { + cmd = cmd.substring(1, cmd.length - 1); + log.warn('The path should not be quoted.\n Normalized the path to %s', cmd); + } + + return path.normalize(cmd); + }; + + this._execCommand = function(cmd, args) { + if (!cmd) { + log.error('No binary for %s browser on your platform.\n ' + + 'Please, set "%s" env variable.', self.name, self.ENV_CMD); + + self._retryLimit = -1; // disable restarting + + return self._clearTempDirAndReportDone('no binary'); + } + + cmd = this._normalizeCommand(cmd); + + log.debug(cmd + ' ' + args.join(' ')); + self._process = spawn(cmd, args); + + var errorOutput = ''; + + self._process.on('close', function(code) { + self._onProcessExit(code, errorOutput); + }); + + self._process.on('error', function(err) { + if (err.code === 'ENOENT') { + self._retryLimit = -1; + errorOutput = 'Can not find the binary ' + cmd + '\n\t' + + 'Please set env variable ' + self.ENV_CMD; + } else { + errorOutput += err.toString(); + } + }); + + // Node 0.8 does not emit the error + if (process.versions.node.indexOf('0.8') === 0) { + self._process.stderr.on('data', function(data) { + var msg = data.toString(); + + if (msg.indexOf('No such file or directory') !== -1) { + self._retryLimit = -1; + errorOutput = 'Can not find the binary ' + cmd + '\n\t' + + 'Please set env variable ' + self.ENV_CMD; + } else { + errorOutput += msg; + } + }); + } + }; + + this._onProcessExit = function(code, errorOutput) { + log.debug('Process %s exitted with code %d', self.name, code); + + var error = null; + + if (self.state === self.STATE_BEING_CAPTURED) { + log.error('Cannot start %s\n\t%s', self.name, errorOutput); + error = 'cannot start'; + } + + if (self.state === self.STATE_CAPTURED) { + log.error('%s crashed.\n\t%s', self.name, errorOutput); + error = 'crashed'; + } + + self._process = null; + self._clearTempDirAndReportDone(error); + }; + + this._clearTempDirAndReportDone = function(error) { + tempDir.remove(self._tempDir, function() { + self._done(error); + if (onExitCallback) { + onExitCallback(); + onExitCallback = null; + } + }); + }; + + this._onKillTimeout = function() { + if (self.state !== self.STATE_BEING_KILLED) { + return; + } + + log.warn('%s was not killed in %d ms, sending SIGKILL.', self.name, killTimeout); + self._process.kill('SIGKILL'); + }; +}; + +ProcessLauncher.decoratorFactory = function(timer) { + return function(launcher) { + ProcessLauncher.call(launcher, require('child_process').spawn, require('../temp_dir'), timer); + }; +}; + + +module.exports = ProcessLauncher; diff --git a/lib/launchers/retry.js b/lib/launchers/retry.js new file mode 100644 index 000000000..665bd8c92 --- /dev/null +++ b/lib/launchers/retry.js @@ -0,0 +1,32 @@ +var log = require('../logger').create('launcher'); + +var RetryLauncher = function(retryLimit) { + var self = this; + + this._retryLimit = retryLimit; + + this.on('done', function() { + if (!self.error) { + return; + } + + if (self._retryLimit > 0) { + var attempt = retryLimit - self._retryLimit + 1; + log.info('Trying to start %s again (%d/%d).', self.name, attempt, retryLimit); + self.restart(); + self._retryLimit--; + } else if (self._retryLimit === 0) { + log.error('%s failed %d times (%s). Giving up.', self.name, retryLimit, self.error); + } else { + log.debug('%s failed (%s). Not restarting.', self.name, self.error); + } + }); +}; + +RetryLauncher.decoratorFactory = function() { + return function(launcher) { + RetryLauncher.call(launcher, 2); + }; +}; + +module.exports = RetryLauncher; diff --git a/lib/temp_dir.js b/lib/temp_dir.js new file mode 100644 index 000000000..dfed5f87d --- /dev/null +++ b/lib/temp_dir.js @@ -0,0 +1,28 @@ +var path = require('path'); +var fs = require('fs'); +var TEMP_DIR = require('os').tmpdir(); +var rimraf = require('rimraf'); +var log = require('./logger').create('temp-dir'); + +module.exports = { + getPath: function(suffix) { + return path.normalize(TEMP_DIR + suffix); + }, + + create: function(path) { + log.debug('Creating temp dir at %s', path); + + try { + fs.mkdirSync(path); + } catch (e) { + log.warn('Failed to create a temp dir at %s', path); + } + + return path; + }, + + remove: function(path, done) { + log.debug('Cleaning temp dir %s', path); + rimraf(path, done); + } +}; diff --git a/test/unit/launcher.spec.coffee b/test/unit/launcher.spec.coffee index 02b223874..e8782c812 100644 --- a/test/unit/launcher.spec.coffee +++ b/test/unit/launcher.spec.coffee @@ -2,29 +2,42 @@ # lib/launcher.js module #============================================================================== describe 'launcher', -> + q = require 'q' di = require 'di' events = require '../../lib/events' logger = require '../../lib/logger' launcher = require '../../lib/launcher' + createMockTimer = require './mocks/timer' # mock out id generator lastGeneratedId = null launcher.Launcher.generateId = -> ++lastGeneratedId + # promise mock + stubPromise = (obj, method, stubAction) -> + deferred = q.defer() + sinon.stub obj, method, -> + stubAction() if stubAction + deferred.promise + obj[method].resolve = deferred.resolve + + class FakeBrowser constructor: (@id, @name, baseBrowserDecorator) -> baseBrowserDecorator @ FakeBrowser._instances.push @ - sinon.stub @, 'start', -> @state = 1 # BEING_CAPTURED - sinon.stub @, 'kill' + sinon.stub @, 'start', -> @state = @STATE_BEING_CAPTURED + stubPromise @, 'forceKill' + sinon.stub @, 'restart' class ScriptBrowser constructor: (@id, @name, baseBrowserDecorator) -> baseBrowserDecorator @ ScriptBrowser._instances.push @ - sinon.stub @, 'start', -> @state = 1 # BEING_CAPTURED - sinon.stub @, 'kill' + sinon.stub @, 'start', -> @state = @STATE_BEING_CAPTURED + stubPromise @, 'forceKill' + sinon.stub @, 'restart' beforeEach -> @@ -46,6 +59,7 @@ describe 'launcher', -> 'launcher:Script': ['type', ScriptBrowser] 'emitter': ['value', emitter] 'config': ['value', {captureTimeout: 0}] + 'timer': ['factory', createMockTimer] }] l = new launcher.Launcher emitter, injector @@ -76,16 +90,13 @@ describe 'launcher', -> describe 'restart', -> - it 'should kill and start the browser with the original url', -> + it 'should restart the browser', -> l.launch ['Fake'], 'localhost', 1234, '/root/' browser = FakeBrowser._instances.pop() - browser.start.reset() returnedValue = l.restart lastGeneratedId expect(returnedValue).to.equal true - expect(browser.kill).to.have.been.called - browser.kill.callArg 0 # killing is done - expect(browser.start).to.have.been.calledWith 'http://localhost:1234/root/' + expect(browser.restart).to.have.been.called it 'should return false if the browser was not launched by launcher (manual)', -> @@ -94,52 +105,51 @@ describe 'launcher', -> describe 'kill', -> - it 'should kill browser with given id', -> + it 'should kill browser with given id', (done) -> killSpy = sinon.spy() l.launch ['Fake'] browser = FakeBrowser._instances.pop() - l.kill browser.id, killSpy - expect(browser.kill).to.have.been.called + l.kill browser.id, done + expect(browser.forceKill).to.have.been.called - browser.kill.invokeCallback() - expect(killSpy).to.have.been.called + browser.forceKill.resolve() it 'should return false if browser does not exist, but still resolve the callback', (done) -> l.launch ['Fake'] browser = FakeBrowser._instances.pop() - expect(l.kill 'weid-id', done).to.equal false - expect(browser.kill).not.to.have.been.called + returnedValue = l.kill 'weird-id', done + expect(returnedValue).to.equal false + expect(browser.forceKill).not.to.have.been.called + - it 'should not resolve callback if none was defined', (done) -> + it 'should not require a callback', (done) -> l.launch ['Fake'] browser = FakeBrowser._instances.pop() - expect(l.kill 'weid-id').to.equal false - process.nextTick -> - done() + l.kill 'weird-id' + process.nextTick done - describe 'killAll', -> - exitSpy = null - beforeEach -> - exitSpy = sinon.spy() + describe 'killAll', -> it 'should kill all running processe', -> l.launch ['Fake', 'Fake'], 'localhost', 1234 l.killAll() browser = FakeBrowser._instances.pop() - expect(browser.kill).to.have.been.called + expect(browser.forceKill).to.have.been.called browser = FakeBrowser._instances.pop() - expect(browser.kill).to.have.been.called + expect(browser.forceKill).to.have.been.called it 'should call callback when all processes killed', -> + exitSpy = sinon.spy() + l.launch ['Fake', 'Fake'], 'localhost', 1234 l.killAll exitSpy @@ -147,14 +157,18 @@ describe 'launcher', -> # finish the first browser browser = FakeBrowser._instances.pop() - browser.kill.invokeCallback() - expect(exitSpy).not.to.have.been.called + browser.forceKill.resolve() - # finish the second browser - browser = FakeBrowser._instances.pop() - browser.kill.invokeCallback() - expect(exitSpy).to.have.been.called - # expect(browser.lastCall) + scheduleNextTick -> + expect(exitSpy).not.to.have.been.called + + scheduleNextTick -> + # finish the second browser + browser = FakeBrowser._instances.pop() + browser.forceKill.resolve() + + scheduleNextTick -> + expect(exitSpy).to.have.been.called it 'should call callback even if no browsers lanunched', (done) -> @@ -183,7 +197,7 @@ describe 'launcher', -> emitter.emitAsync('exit').then done browser = FakeBrowser._instances.pop() - browser.kill.invokeCallback() + browser.forceKill.resolve() browser = FakeBrowser._instances.pop() - browser.kill.invokeCallback() + browser.forceKill.resolve() diff --git a/test/unit/launchers/base.spec.coffee b/test/unit/launchers/base.spec.coffee index ca5900127..4afd37532 100644 --- a/test/unit/launchers/base.spec.coffee +++ b/test/unit/launchers/base.spec.coffee @@ -1,278 +1,239 @@ -describe 'launchers Base', -> - events = require 'events' - nodeMocks = require 'mocks' - loadFile = nodeMocks.loadFile - fsMock = nodeMocks.fs - path = require 'path' +describe 'launchers/base.js', -> + BaseLauncher = require '../../../lib/launchers/base' + EventEmitter = require('../../../lib/events').EventEmitter + launcher = emitter = null - setTimeoutMock = null + beforeEach -> + emitter = new EventEmitter + launcher = new BaseLauncher 'fake-id', emitter - mockSpawn = sinon.spy (cmd, args) -> - process = new events.EventEmitter - process.stderr = new events.EventEmitter - process.kill = sinon.spy() - process.exitCode = null - mockSpawn._processes.push process - process + it 'should manage state', -> + launcher.start 'http://localhost:9876/' + expect(launcher.state).to.equal launcher.STATE_BEING_CAPTURED + launcher.markCaptured() + expect(launcher.state).to.equal launcher.STATE_CAPTURED + expect(launcher.isCaptured()).to.equal true - mockRimraf = sinon.spy (p, fn) -> - mockRimraf._callbacks.push fn - mockFs = fsMock.create - tmp: - 'some.file': fsMock.file() + describe 'start', -> - mocks = - '../logger': require '../../../lib/logger' - child_process: spawn: mockSpawn - rimraf: mockRimraf - fs: mockFs + it 'should fire "start" event and pass url with id', -> + spyOnStart = sinon.spy() + launcher.on 'start', spyOnStart + launcher.start 'http://localhost:9876/' - globals = - process: - platform: 'darwin' - versions: node: '0.10.x' - env: - TMP: '/tmp' - nextTick: process.nextTick - setTimeout: (fn, delay) -> setTimeoutMock fn, delay + expect(spyOnStart).to.have.been.calledWith 'http://localhost:9876/?id=fake-id' - m = loadFile __dirname + '/../../../lib/launchers/base.js', mocks, globals + describe 'restart', -> + it 'should kill running browser and start with previous url', (done) -> + spyOnStart = sinon.spy() + spyOnKill = sinon.spy() + launcher.on 'start', spyOnStart + launcher.on 'kill', spyOnKill - beforeEach -> - setTimeoutMock = sinon.stub().callsArg 0 - mockSpawn.reset() - mockSpawn._processes = [] - mockRimraf.reset() - mockRimraf._callbacks = [] + launcher.start 'http://host:9988/' + spyOnStart.reset() - describe 'start', -> - it 'should create a temp directory', -> - browser = new m.BaseBrowser 12345 - sinon.stub browser, '_start' + launcher.restart() + expect(spyOnKill).to.have.been.called + expect(spyOnStart).to.not.have.been.called - browser.start '/some' - expect(mockFs.readdirSync '/tmp/karma-12345').to.exist + # the process (or whatever it is) actually finished + launcher._done() + spyOnKill.callArg 0 + process.nextTick -> + expect(spyOnStart).to.have.been.calledWith 'http://host:9988/?id=fake-id' + done() - it 'should not timeout if timeout = 0', -> - browser = new m.BaseBrowser 12345, null, 0 # captureTimeout - sinon.stub browser, '_start' - sinon.stub browser, '_onTimeout' - browser.start '/some' - expect(setTimeoutMock).not.to.have.been.called - expect(browser._onTimeout).not.to.have.been.called + it 'should start when already finished (crashed)', (done) -> + spyOnStart = sinon.spy() + spyOnKill = sinon.spy() + spyOnDone = sinon.spy() + launcher.on 'start', spyOnStart + launcher.on 'kill', spyOnKill + launcher.on 'done', -> launcher.restart() + launcher.on 'done', spyOnDone - it 'should append id to the url', -> - browser = new m.BaseBrowser 123 - sinon.stub browser, '_start' - browser.start '/capture/url' - expect(browser._start).to.have.been.calledWith '/capture/url?id=123' + launcher.start 'http://host:9988/' + spyOnStart.reset() + # simulate crash + # the first onDone will restart + launcher._done 'crashed' - it 'should handle spawn ENOENT error and not even retry', (done) -> - browser = new m.BaseBrowser 123, new events.EventEmitter, 0, 3 - browser.DEFAULT_CMD = darwin: '/usr/bin/browser' + process.nextTick -> + expect(spyOnKill).to.not.have.been.called + expect(spyOnStart).to.have.been.called + expect(spyOnDone).to.have.been.called + expect(spyOnDone).to.have.been.calledBefore spyOnStart + done() - error = new Error 'spawn ENOENT' - error.code = 'ENOENT' - browser.start '/capture/url' + it 'should not restart when being force killed', (done) -> + spyOnStart = sinon.spy() + spyOnKill = sinon.spy() + launcher.on 'start', spyOnStart + launcher.on 'kill', spyOnKill - spawnProcess = mockSpawn._processes[0] - mockSpawn.reset() - spawnProcess.emit 'error', error - spawnProcess.emit 'close', 1 + launcher.start 'http://host:9988/' + spyOnStart.reset() - # do not retry - expect(mockSpawn).not.to.have.been.called + onceKilled = launcher.forceKill() - browser.kill done + launcher.restart() - # do not kill already dead process - expect(spawnProcess.kill).to.not.have.been.called + # the process (or whatever it is) actually finished + launcher._done() + spyOnKill.callArg 0 + onceKilled.done -> + expect(spyOnStart).to.not.have.been.called + done() - it 'should remove quotes from the cmd', -> - browser = new m.BaseBrowser 123 - browser.DEFAULT_CMD = darwin: '"/bin/brow ser"' - browser.start '/url' - expect(mockSpawn).to.have.been.calledWith '/bin/brow ser', ['/url?id=123'] + describe 'kill', -> - browser.DEFAULT_CMD = darwin: '\'bin/brow ser\'' - browser.start '/url' - expect(mockSpawn).to.have.been.calledWith '/bin/brow ser', ['/url?id=123'] + it 'should manage state', (done) -> + onceKilled = launcher.kill() + expect(launcher.state).to.equal launcher.STATE_BEING_KILLED - browser.DEFAULT_CMD = darwin: '`bin/brow ser`' - browser.start '/url' - expect(mockSpawn).to.have.been.calledWith '/bin/brow ser', ['/url?id=123'] + onceKilled.done -> + expect(launcher.state).to.equal launcher.STATE_FINISHED + done() - describe 'kill', -> - it 'should just fire done if already killed', (done) -> - browser = new m.BaseBrowser 123, new events.EventEmitter, 0, 1 # disable retry - browser.DEFAULT_CMD = darwin: '/usr/bin/browser' - killSpy = sinon.spy done + it 'should fire "kill" and wait for all listeners to finish', (done) -> + spyOnKill1 = sinon.spy() + spyOnKill2 = sinon.spy() + spyKillDone = sinon.spy done + + launcher.on 'kill', spyOnKill1 + launcher.on 'kill', spyOnKill2 + + launcher.start 'http://localhost:9876/' + launcher.kill().then spyKillDone + expect(spyOnKill1).to.have.been.called + expect(spyOnKill2).to.have.been.called + expect(spyKillDone).to.not.have.been.called + + spyOnKill1.callArg 0 # the first listener is done + expect(spyKillDone).to.not.have.been.called + + spyOnKill2.callArg 0 # the second listener is done + + + it 'should not fire "kill" if already killed', (done) -> + spyOnKill = sinon.spy() + launcher.on 'kill', spyOnKill + + launcher.start 'http://localhost:9876/' + launcher.kill().then -> + spyOnKill.reset() + launcher.kill().then -> + expect(spyOnKill).to.not.have.been.called + done() + + spyOnKill.callArg 0 + + + it 'should not fire "kill" if already being killed, but wait for all listeners', (done) -> + spyOnKill = sinon.spy() + launcher.on 'kill', spyOnKill + + expectOnKillListenerIsAlreadyFinishedAndHasBeenOnlyCalledOnce = -> + expect(spyOnKill).to.have.been.called + expect(spyOnKill.callCount).to.equal 1 + expect(spyOnKill.finished).to.equal true + expect(launcher.state).to.equal launcher.STATE_FINISHED + + launcher.start 'http://localhost:9876/' + firstKilling = launcher.kill().then -> + expectOnKillListenerIsAlreadyFinishedAndHasBeenOnlyCalledOnce() + + secondKilling = launcher.kill().then -> + expectOnKillListenerIsAlreadyFinishedAndHasBeenOnlyCalledOnce() + + expect(launcher.state).to.equal launcher.STATE_BEING_KILLED + + process.nextTick -> + spyOnKill.finished = true + spyOnKill.callArg 0 - browser.start '/some' - mockSpawn._processes[0].emit 'close', 0 # crash the browser + # finish the test once everything is done + firstKilling.done -> secondKilling.done -> done() - browser.kill killSpy - expect(killSpy).not.to.have.been.called # must be async + it 'should not kill already crashed browser', (done) -> + spyOnKill = sinon.spy((killDone) -> killDone()) + launcher.on 'kill', spyOnKill - describe 'flow', -> - browser = emitter = null - - beforeEach -> - emitter = new events.EventEmitter() - browser = new m.BaseBrowser 12345, emitter, 1000, 3 - browser.DEFAULT_CMD = darwin: '/usr/bin/browser' + launcher._done 'crash' + launcher.kill().done -> + expect(spyOnKill).to.not.have.been.called + done() - # the most common scenario, when everything works fine - it 'start -> capture -> kill', -> - killSpy = sinon.spy() - # start the browser - browser.start 'http://localhost/' - expect(mockSpawn).to.have.been.calledWith path.normalize('/usr/bin/browser'), - ['http://localhost/?id=12345'] - mockSpawn.reset() - - # mark captured - browser.markCaptured() - - # kill it - browser.kill killSpy - expect(mockSpawn._processes[0].kill).to.have.been.called - expect(killSpy).not.to.have.been.called + describe 'forceKill', -> - mockSpawn._processes[0].emit 'close', 0 - expect(mockRimraf).to.have.been.calledWith path.normalize('/tmp/karma-12345') - mockRimraf._callbacks[0]() # rm tempdir - expect(killSpy).to.have.been.called + it 'should cancel restart', (done) -> + spyOnStart = sinon.spy() + launcher.on 'start', spyOnStart + launcher.start 'http://localhost:9876/' + spyOnStart.reset() + launcher.restart() - # when the browser fails to get captured in given timeout, it should restart - it 'start -> timeout -> restart', -> - failureSpy = sinon.spy() - emitter.on 'browser_process_failure', failureSpy - - # start - browser.start 'http://localhost/' - - # expect starting the process - expect(mockSpawn).to.have.been.calledWith path.normalize('/usr/bin/browser'), - ['http://localhost/?id=12345'] - browserProcess = mockSpawn._processes.shift() - - # timeout - expect(setTimeoutMock).to.have.been.called + launcher.forceKill().done -> + expect(launcher.state).to.equal launcher.STATE_FINISHED + expect(spyOnStart).to.not.have.been.called + done() - # expect killing browser - expect(browserProcess.kill).to.have.been.called - browserProcess.emit 'close', 0 - mockSpawn.reset() - expect(mockRimraf).to.be.calledOnce - mockRimraf._callbacks[0]() # cleanup - # expect re-starting - expect(mockSpawn).to.have.been.calledWith path.normalize('/usr/bin/browser'), - ['http://localhost/?id=12345'] - browserProcess = mockSpawn._processes.shift() + it 'should not fire "browser_process_failure" even if browser crashes', (done) -> + spyOnBrowserProcessFailure = sinon.spy() + emitter.on 'browser_process_failure', spyOnBrowserProcessFailure - expect(failureSpy).not.to.have.been.called + launcher.on 'kill', (killDone) -> + process.nextTick -> + launcher._done 'crashed' + killDone() + launcher.start 'http://localhost:9876/' + launcher.forceKill().done -> + expect(spyOnBrowserProcessFailure).to.not.have.been.called + done() - it 'start -> timeout -> 3xrestart -> failure', -> - failureSpy = sinon.spy() - emitter.on 'browser_process_failure', failureSpy - normalized = path.normalize('/usr/bin/browser') - # start - browser.start 'http://localhost/' + describe 'markCaptured', -> - # expect starting - expect(mockSpawn).to.have.been.calledWith normalized, ['http://localhost/?id=12345'] - browserProcess = mockSpawn._processes.shift() + it 'should not mark capture when killing', -> + launcher.kill() + launcher.markCaptured() + expect(launcher.state).to.not.equal launcher.STATE_CAPTURED - # timeout - expect(setTimeoutMock).to.have.been.called - # expect killing browser - expect(browserProcess.kill).to.have.been.called - browserProcess.emit 'close', 0 - mockSpawn.reset() - expect(mockRimraf).to.have.been.calledOnce - mockRimraf._callbacks.shift()() # cleanup - mockRimraf.reset() + describe '_done', -> - # expect starting - expect(mockSpawn).to.have.been.calledWith normalized, ['http://localhost/?id=12345'] - browserProcess = mockSpawn._processes.shift() + it 'should emit "browser_process_failure" if there is an error', -> + spyOnBrowserProcessFailure = sinon.spy() + emitter.on 'browser_process_failure', spyOnBrowserProcessFailure - # timeout - expect(setTimeoutMock).to.have.been.called + launcher._done 'crashed' + expect(spyOnBrowserProcessFailure).to.have.been.called + expect(spyOnBrowserProcessFailure).to.have.been.calledWith launcher - # expect killing browser - expect(browserProcess.kill).to.have.been.called - browserProcess.emit 'close', 0 - mockSpawn.reset() - expect(mockRimraf).to.have.been.calledOnce - mockRimraf._callbacks.shift()() # cleanup - mockRimraf.reset() - - # after two time-outs, still no failure - expect(failureSpy).not.to.have.been.called - - # expect starting - expect(mockSpawn).to.have.been.calledWith normalized, ['http://localhost/?id=12345'] - browserProcess = mockSpawn._processes.shift() - - # timeout - expect(setTimeoutMock).to.have.been.called - - # expect killing browser - expect(browserProcess.kill).to.have.been.called - browserProcess.emit 'close', 0 - mockSpawn.reset() - expect(mockRimraf).to.have.been.calledOnce - mockRimraf._callbacks.shift()() # cleanup - mockRimraf.reset() - - # expect failure - expect(failureSpy).to.have.been.calledWith browser - expect(mockSpawn).not.to.have.been.called - - - # when the browser fails to start, it should restart - it 'start -> crash -> restart', -> - failureSpy = sinon.spy() - emitter.on 'browser_process_failure', failureSpy - normalized = path.normalize('/usr/bin/browser') - # start - browser.start 'http://localhost/' - - # expect starting the process - expect(mockSpawn).to.have.been.calledWith normalized, ['http://localhost/?id=12345'] - browserProcess = mockSpawn._processes.shift() + it 'should not emit "browser_process_failure" when no error happend', -> + spyOnBrowserProcessFailure = sinon.spy() + emitter.on 'browser_process_failure', spyOnBrowserProcessFailure - # crash - browserProcess.emit 'close', 1 - expect(mockRimraf).to.have.been.calledOnce - mockRimraf._callbacks[0]() # cleanup - - # expect re-starting - expect(mockSpawn).to.have.been.calledWith normalized, ['http://localhost/?id=12345'] - browserProcess = mockSpawn._processes.shift() - - expect(failureSpy).not.to.have.been.called + launcher._done() + expect(spyOnBrowserProcessFailure).not.to.have.been.called diff --git a/test/unit/launchers/capture_timeout.spec.coffee b/test/unit/launchers/capture_timeout.spec.coffee new file mode 100644 index 000000000..7c2fff81d --- /dev/null +++ b/test/unit/launchers/capture_timeout.spec.coffee @@ -0,0 +1,54 @@ +describe 'launchers/capture_timeout.js', -> + BaseLauncher = require '../../../lib/launchers/base' + CaptureTimeoutLauncher = require '../../../lib/launchers/capture_timeout' + createMockTimer = require '../mocks/timer' + launcher = timer = null + + beforeEach -> + timer = createMockTimer() + launcher = new BaseLauncher 'fake-id' + + sinon.spy launcher, 'kill' + + + it 'should kill if not captured in captureTimeout', -> + CaptureTimeoutLauncher.call launcher, timer, 10 + + launcher.start() + timer.wind 20 + expect(launcher.kill).to.have.been.called + + + it 'should not kill if browser got captured', -> + CaptureTimeoutLauncher.call launcher, timer, 10 + + launcher.start() + launcher.markCaptured() + timer.wind 20 + expect(launcher.kill).not.to.have.been.called + + + it 'should not do anything if captureTimeout = 0', -> + CaptureTimeoutLauncher.call launcher, timer, 0 + + launcher.start() + timer.wind 20 + expect(launcher.kill).not.to.have.been.called + + + it 'should clear timeout between restarts', (done) -> + CaptureTimeoutLauncher.call launcher, timer, 10 + + # simulate process finished + launcher.on 'kill', (onKillDone) -> + launcher._done() + onKillDone() + + launcher.start() + timer.wind 8 + launcher.kill().done -> + launcher.kill.reset() + launcher.start() + timer.wind 8 + expect(launcher.kill).not.to.have.been.called + done() diff --git a/test/unit/launchers/process.spec.coffee b/test/unit/launchers/process.spec.coffee new file mode 100644 index 000000000..5f151d387 --- /dev/null +++ b/test/unit/launchers/process.spec.coffee @@ -0,0 +1,223 @@ +describe 'launchers/process.js', -> + path = require 'path' + BaseLauncher = require '../../../lib/launchers/base' + RetryLauncher = require '../../../lib/launchers/retry' + CaptureTimeoutLauncher = require '../../../lib/launchers/capture_timeout' + ProcessLauncher = require '../../../lib/launchers/process' + EventEmitter = require('../../../lib/events').EventEmitter + createMockTimer = require '../mocks/timer' + launcher = timer = emitter = mockSpawn = mockTempDir = null + + BROWSER_PATH = path.normalize '/usr/bin/browser' + + beforeEach -> + emitter = new EventEmitter + launcher = new BaseLauncher 'fake-id', emitter + + mockSpawn = sinon.spy (cmd, args) -> + process = new EventEmitter + process.stderr = new EventEmitter + process.kill = sinon.spy() + process.exitCode = null + mockSpawn._processes.push process + process + mockSpawn._processes = [] + + mockTempDir = + getPath: (suffix) -> '/temp' + suffix + create: sinon.spy() + remove: sinon.spy() + + it 'should create a temp directory', -> + ProcessLauncher.call launcher, mockSpawn, mockTempDir + launcher._getCommand = -> null + + launcher.start 'http://host:9988/' + expect(launcher._tempDir).to.equal '/temp/karma-fake-id' + expect(mockTempDir.create).to.have.been.calledWith '/temp/karma-fake-id' + + + it 'should remove the temp directory', (done) -> + ProcessLauncher.call launcher, mockSpawn, mockTempDir + launcher._getCommand = -> null + + launcher.start 'http://host:9988/' + launcher.kill() + + scheduleNextTick -> + expect(mockTempDir.remove).to.have.been.called + expect(mockTempDir.remove.args[0][0]).to.equal '/temp/karma-fake-id' + done() + + + describe '_normalizeCommand', -> + it 'should remove quotes from the cmd', -> + ProcessLauncher.call launcher, null, mockTempDir + + expect(launcher._normalizeCommand '"/bin/brow ser"').to.equal '/bin/brow ser' + expect(launcher._normalizeCommand '\'/bin/brow ser\'').to.equal '/bin/brow ser' + expect(launcher._normalizeCommand '`/bin/brow ser`').to.equal '/bin/brow ser' + + + describe 'with RetryLauncher', -> + it 'should handle spawn ENOENT error and not even retry', (done) -> + ProcessLauncher.call launcher, mockSpawn, mockTempDir + RetryLauncher.call launcher, 2 + launcher._getCommand = -> BROWSER_PATH + + failureSpy = sinon.spy() + emitter.on 'browser_process_failure', failureSpy + + launcher.start 'http://host:9876/' + mockSpawn._processes[0].emit 'error', {code: 'ENOENT'} + mockSpawn._processes[0].emit 'close', 1 + mockTempDir.remove.callArg 1 + + scheduleNextTick -> + expect(launcher.state).to.equal launcher.STATE_FINISHED + expect(failureSpy).to.have.been.called + done() + + + # higher level tests with Retry and CaptureTimeout launchers + describe 'flow', -> + mockTimer = failureSpy = null + + beforeEach -> + mockTimer = createMockTimer() + CaptureTimeoutLauncher.call launcher, mockTimer, 100 + ProcessLauncher.call launcher, mockSpawn, mockTempDir, mockTimer + RetryLauncher.call launcher, 2 + + launcher._getCommand = -> BROWSER_PATH + + failureSpy = sinon.spy() + emitter.on 'browser_process_failure', failureSpy + + + # the most common scenario, when everything works fine + it 'start -> capture -> kill', (done) -> + # start the browser + launcher.start 'http://localhost/' + expect(mockSpawn).to.have.been.calledWith BROWSER_PATH, ['http://localhost/?id=fake-id'] + + # mark captured + launcher.markCaptured() + + # kill it + killingLauncher = launcher.kill() + expect(launcher.state).to.equal launcher.STATE_BEING_KILLED + expect(mockSpawn._processes[0].kill).to.have.been.called + + # process exits + mockSpawn._processes[0].emit 'close', 0 + mockTempDir.remove.callArg 1 + + killingLauncher.done -> + expect(launcher.state).to.equal launcher.STATE_FINISHED + done() + + + # when the browser fails to get captured in given timeout, it should restart + it 'start -> timeout -> restart', (done) -> + # start + launcher.start 'http://localhost/' + + # expect starting the process + expect(mockSpawn).to.have.been.calledWith BROWSER_PATH, ['http://localhost/?id=fake-id'] + browserProcess = mockSpawn._processes.shift() + + # timeout + mockTimer.wind 101 + + # expect killing browser + expect(browserProcess.kill).to.have.been.called + browserProcess.emit 'close', 0 + mockTempDir.remove.callArg 1 + mockSpawn.reset() + + scheduleNextTick -> + # expect re-starting + expect(mockSpawn).to.have.been.calledWith BROWSER_PATH, ['http://localhost/?id=fake-id'] + expect(failureSpy).not.to.have.been.called + done() + + + it 'start -> timeout -> 3xrestart -> failure', (done) -> + # start + launcher.start 'http://localhost/' + + # expect starting + expect(mockSpawn).to.have.been.calledWith BROWSER_PATH, ['http://localhost/?id=fake-id'] + browserProcess = mockSpawn._processes.shift() + mockSpawn.reset() + + # timeout - first time + mockTimer.wind 101 + + # expect killing browser + expect(browserProcess.kill).to.have.been.called + browserProcess.emit 'close', 0 + mockTempDir.remove.callArg 1 + mockTempDir.remove.reset() + + scheduleNextTick -> + # expect re-starting + expect(mockSpawn).to.have.been.calledWith BROWSER_PATH, ['http://localhost/?id=fake-id'] + browserProcess = mockSpawn._processes.shift() + expect(failureSpy).not.to.have.been.called + mockSpawn.reset() + + # timeout - second time + mockTimer.wind 101 + + # expect killing browser + expect(browserProcess.kill).to.have.been.called + browserProcess.emit 'close', 0 + mockTempDir.remove.callArg 1 + mockTempDir.remove.reset() + + scheduleNextTick -> + # expect re-starting + expect(mockSpawn).to.have.been.calledWith BROWSER_PATH, ['http://localhost/?id=fake-id'] + browserProcess = mockSpawn._processes.shift() + expect(failureSpy).not.to.have.been.called + mockSpawn.reset() + + # timeout - third time + mockTimer.wind 201 + + # expect killing browser + expect(browserProcess.kill).to.have.been.called + browserProcess.emit 'close', 0 + mockTempDir.remove.callArg 1 + mockTempDir.remove.reset() + + scheduleNextTick -> + expect(mockSpawn).to.not.have.been.called + expect(failureSpy).to.have.been.called + done() + + + # when the browser fails to start, it should restart + it 'start -> crash -> restart', (done) -> + # start + launcher.start 'http://localhost/' + + # expect starting the process + expect(mockSpawn).to.have.been.calledWith BROWSER_PATH, ['http://localhost/?id=fake-id'] + browserProcess = mockSpawn._processes.shift() + mockSpawn.reset() + + # crash + browserProcess.emit 'close', 1 + mockTempDir.remove.callArg 1 + mockTempDir.remove.reset() + + scheduleNextTick -> + # expect re-starting + expect(mockSpawn).to.have.been.calledWith BROWSER_PATH, ['http://localhost/?id=fake-id'] + browserProcess = mockSpawn._processes.shift() + + expect(failureSpy).not.to.have.been.called + done() diff --git a/test/unit/launchers/retry.spec.coffee b/test/unit/launchers/retry.spec.coffee new file mode 100644 index 000000000..93370ee79 --- /dev/null +++ b/test/unit/launchers/retry.spec.coffee @@ -0,0 +1,82 @@ +describe 'launchers/retry.js', -> + BaseLauncher = require '../../../lib/launchers/base' + RetryLauncher = require '../../../lib/launchers/retry' + EventEmitter = require('../../../lib/events').EventEmitter + createMockTimer = require '../mocks/timer' + launcher = timer = emitter = null + + beforeEach -> + timer = createMockTimer() + emitter = new EventEmitter + launcher = new BaseLauncher 'fake-id', emitter + + + it 'should restart if browser crashed', (done) -> + RetryLauncher.call launcher, 2 + + launcher.start 'http://localhost:9876' + + sinon.spy launcher, 'start' + spyOnBrowserProcessFailure = sinon.spy() + emitter.on 'browser_process_failure', spyOnBrowserProcessFailure + + # simulate crash + launcher._done 'crash' + + scheduleNextTick -> + expect(launcher.start).to.have.been.called + expect(spyOnBrowserProcessFailure).not.to.have.been.called + done() + + + it 'should eventually fail with "browser_process_failure"', (done) -> + RetryLauncher.call launcher, 2 + + launcher.start 'http://localhost:9876' + + sinon.spy launcher, 'start' + spyOnBrowserProcessFailure = sinon.spy() + emitter.on 'browser_process_failure', spyOnBrowserProcessFailure + + # simulate first crash + launcher._done 'crash' + + scheduleNextTick -> + expect(launcher.start).to.have.been.called + expect(spyOnBrowserProcessFailure).not.to.have.been.called + launcher.start.reset() + + # simulate second crash + launcher._done 'crash' + + scheduleNextTick -> + expect(launcher.start).to.have.been.called + expect(spyOnBrowserProcessFailure).not.to.have.been.called + launcher.start.reset() + + # simulate third crash + launcher._done 'crash' + + scheduleNextTick -> + expect(launcher.start).not.to.have.been.called + expect(spyOnBrowserProcessFailure).to.have.been.called + done() + + + it 'should not restart if killed normally', (done) -> + RetryLauncher.call launcher, 2 + + launcher.start 'http://localhost:9876' + + sinon.spy launcher, 'start' + spyOnBrowserProcessFailure = sinon.spy() + emitter.on 'browser_process_failure', spyOnBrowserProcessFailure + + # process just exitted normally + launcher._done() + + scheduleNextTick -> + expect(launcher.start).not.to.have.been.called + expect(spyOnBrowserProcessFailure).not.to.have.been.called + expect(launcher.state).to.equal launcher.STATE_FINISHED + done() diff --git a/test/unit/mocha-globals.coffee b/test/unit/mocha-globals.coffee index 3528c8267..938faa35e 100644 --- a/test/unit/mocha-globals.coffee +++ b/test/unit/mocha-globals.coffee @@ -40,3 +40,18 @@ chai.use (chai, utils) -> "expected response status to not be set, it was '#{response._status}'" @assert response._body is null, "expected response body to not be set, it was '#{response._body}'" + + +# TODO(vojta): move it somewhere ;-) +nextTickQueue = [] +nextTickCallback = -> + nextTickQueue.shift()() + + if nextTickQueue.length then process.nextTick nextTickCallback + +global.scheduleNextTick = (action) -> + nextTickQueue.push action + if nextTickQueue.length is 1 then process.nextTick nextTickCallback + +beforeEach -> + nextTickQueue.length = 0