diff --git a/docs/dev/04-public-api.md b/docs/dev/04-public-api.md index addfdd6ba..2a167ab2d 100644 --- a/docs/dev/04-public-api.md +++ b/docs/dev/04-public-api.md @@ -2,20 +2,93 @@ Most of the time, you will be using Karma directly from the command line. You can, however, call Karma programmatically from your node module. Here is the public API. -## karma.server +## karma.Server(options, [callback=process.exit]) -### **server.start(options, [callback=process.exit])** +### Constructor + +```javascript +var Server = require('karma').Server +var server = new Server({port: 9876}, function(exitCode) { + console.log('Karma has exited with ' + exitCode) + process.exit(exitCode) +}) +``` + +### **server.start()** Equivalent of `karma start`. ```javascript -var server = require('karma').server; -server.start({port: 9876}, function(exitCode) { - console.log('Karma has exited with ' + exitCode); - process.exit(exitCode); -}); +server.start() +``` + +### Events + +The `server` object is an [`EventEmitter`](https://nodejs.org/docs/latest/api/events.html#events_class_events_eventemitter). You can simply listen to events like this: + +```javascript +server.on('browser_register', function (browser) { + console.log('A new browser was registered') +}) ``` +### `browser_register` +**Arguments:** + +* `browser`: The browser instance + +A new browser was opened, but is not ready yet. + +### `browser_error` +**Arguments:** + +* `browser`: The browser instance +* `error`: The error that occured + +There was an error on this browser instance. + +### `browser_start` +**Arguments:** + +* `browser`: The browser instance +* `info`: Details about the run + +A test run is beginning in this browser. + +### `browser_complete` +**Arguments:** + +* `browser`: The browser instance +* `result`: Test results + +A test run has completed in this browser. + +### `browsers_change` +**Arguments:** + +* `browsers`: A collection of browser instances + +The list of browers has changed. + +#### `browsers_ready` + +All browsers are ready for execution + +### `run_start` +**Arguments:** + +* `browsers`: A collection of browser instances on which tests are excuted + +A test run starts. + +### `run_complete` +**Arguments:** + +* `browsers`: A collection of browser instances +* `results`: A list of results + +A test run was completed. + ## karma.runner ### **runner.run(options, [callback=process.exit])** @@ -23,13 +96,13 @@ server.start({port: 9876}, function(exitCode) { Equivalent of `karma run`. ```javascript -var runner = require('karma').runner; +var runner = require('karma').runner runner.run({port: 9876}, function(exitCode) { - console.log('Karma has exited with ' + exitCode); - process.exit(exitCode); -}); + console.log('Karma has exited with ' + exitCode) + process.exit(exitCode) +}) ``` ## Callback function notes -- If there is an error, the error code will be provided as the second parameter to the error callback. \ No newline at end of file +- If there is an error, the error code will be provided as the second parameter to the error callback. diff --git a/lib/cli.js b/lib/cli.js index a12daa123..f652af284 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -1,8 +1,10 @@ var path = require('path') var optimist = require('optimist') +var fs = require('fs') + +var Server = require('./server') var helper = require('./helper') var constant = require('./constants') -var fs = require('fs') var processArgs = function (argv, options, fs, path) { if (argv.help) { @@ -217,7 +219,7 @@ exports.run = function () { switch (config.cmd) { case 'start': - require('./server').start(config) + new Server(config).start() break case 'run': require('./runner').run(config) diff --git a/lib/index.js b/lib/index.js index f0da7f006..73b3b7f37 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,5 +1,26 @@ // index module -exports.VERSION = require('./constants').VERSION -exports.server = require('./server') -exports.runner = require('./runner') -exports.launcher = require('./launcher') + +var constants = require('./constants') +var Server = require('./server') +var runner = require('./runner') +var launcher = require('./launcher') + +// TODO: remove in 1.0 +var oldServer = { + start: function () { + throw new Error( + 'The api interface has changed. Please use \n' + + ' server = new Server(config, [done])\n' + + ' server.start()\n' + + 'instead.' + ) + } +} + +module.exports = { + VERSION: constants.VERSION, + Server: Server, + runner: runner, + launcher: launcher, + server: oldServer +} diff --git a/lib/server.js b/lib/server.js index f237f77fa..6edd381bf 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,5 +1,6 @@ -var Server = require('socket.io') +var SocketIO = require('socket.io') var di = require('di') +var util = require('util') var cfg = require('./config') var logger = require('./logger') @@ -21,12 +22,91 @@ var BrowserCollection = require('./browser_collection') var EmitterWrapper = require('./emitter_wrapper') var processWrapper = new EmitterWrapper(process) -var log = logger.create() +function createSocketIoServer (webServer, executor, config) { + var server = new SocketIO(webServer, { + // avoid destroying http upgrades from socket.io to get proxied websockets working + destroyUpgrade: false, + path: config.urlRoot + 'socket.io/', + transports: config.transports + }) + + // hack to overcome circular dependency + executor.socketIoSockets = server.sockets + + return server +} + +function setupLogger (level, colors) { + var logLevel = logLevel || constant.LOG_INFO + var logColors = helper.isDefined(colors) ? colors : true + logger.setup(logLevel, logColors, [constant.CONSOLE_APPENDER]) +} + +// Constructor +var Server = function (cliOptions, done) { + EventEmitter.call(this) + + setupLogger(cliOptions.logLevel, cliOptions.colors) + + this.log = logger.create() + + var config = cfg.parseConfig(cliOptions.configFile, cliOptions) + + var modules = [{ + helper: ['value', helper], + logger: ['value', logger], + done: ['value', done || process.exit], + emitter: ['value', this], + launcher: ['type', Launcher], + config: ['value', config], + preprocess: ['factory', preprocessor.createPreprocessor], + fileList: ['type', FileList], + webServer: ['factory', ws.create], + socketServer: ['factory', createSocketIoServer], + executor: ['type', Executor], + // TODO(vojta): remove + customFileHandlers: ['value', []], + // TODO(vojta): remove, once karma-dart does not rely on it + customScriptTypes: ['value', []], + reporter: ['factory', reporter.createReporters], + capturedBrowsers: ['type', BrowserCollection], + args: ['value', {}], + timer: ['value', {setTimeout: setTimeout, clearTimeout: clearTimeout}] + }] + + // Load the plugins + modules = modules.concat(plugin.resolve(config.plugins)) + + this._injector = new di.Injector(modules) +} + +// Inherit from events.EventEmitter +util.inherits(Server, EventEmitter) + +// Public Methods +// -------------- + +// Start the server +Server.prototype.start = function () { + this._injector.invoke(this._start, this) +} + +// Get properties from the injector +// +// token - String +Server.prototype.get = function (token) { + return this._injector.get(token) +} + +// Private Methods +// --------------- + +Server.prototype._start = function (config, launcher, preprocess, fileList, webServer, + capturedBrowsers, socketServer, executor, done) { + var self = this -var start = function (injector, config, launcher, globalEmitter, preprocess, fileList, webServer, - capturedBrowsers, socketServer, executor, done) { config.frameworks.forEach(function (framework) { - injector.get('framework:' + framework) + self._injector.get('framework:' + framework) }) // A map of launched browsers. @@ -41,7 +121,7 @@ var start = function (injector, config, launcher, globalEmitter, preprocess, fil webServer.on('error', function (e) { if (e.code === 'EADDRINUSE') { - log.warn('Port %d in use', config.port) + self.log.warn('Port %d in use', config.port) config.port++ webServer.listen(config.port) } else { @@ -51,15 +131,15 @@ var start = function (injector, config, launcher, globalEmitter, preprocess, fil var afterPreprocess = function () { if (config.autoWatch) { - injector.invoke(watcher.watch) + self._injector.invoke(watcher.watch) } webServer.listen(config.port, function () { - log.info('Karma v%s server started at http://%s:%s%s', constant.VERSION, config.hostname, + self.log.info('Karma v%s server started at http://%s:%s%s', constant.VERSION, config.hostname, config.port, config.urlRoot) if (config.browsers && config.browsers.length) { - injector.invoke(launcher.launch, launcher).forEach(function (browserLauncher) { + self._injector.invoke(launcher.launch, launcher).forEach(function (browserLauncher) { singleRunDoneBrowsers[browserLauncher.id] = false }) } @@ -68,24 +148,28 @@ var start = function (injector, config, launcher, globalEmitter, preprocess, fil fileList.refresh().then(afterPreprocess, afterPreprocess) - globalEmitter.on('browsers_change', function () { + self.on('browsers_change', function () { // TODO(vojta): send only to interested browsers socketServer.sockets.emit('info', capturedBrowsers.serialize()) }) - globalEmitter.on('browser_register', function (browser) { + self.on('browser_register', function (browser) { launcher.markCaptured(browser.id) - // TODO(vojta): This is lame, browser can get captured and then crash (before other browsers get - // captured). - if (config.autoWatch && launcher.areAllCaptured()) { - executor.schedule() + // TODO(vojta): This is lame, browser can get captured and then + // crash (before other browsers get captured). + if (launcher.areAllCaptured()) { + self.emit('browsers_ready') + + if (config.autoWatch) { + executor.schedule() + } } }) var EVENTS_TO_REPLY = ['start', 'info', 'karma_error', 'result', 'complete'] socketServer.sockets.on('connection', function (socket) { - log.debug('A browser has connected on socket ' + socket.id) + self.log.debug('A browser has connected on socket ' + socket.id) var replySocketEvents = events.bufferEvents(socket, EVENTS_TO_REPLY) @@ -110,7 +194,7 @@ var start = function (injector, config, launcher, globalEmitter, preprocess, fil newBrowser.execute(config.client) } } else { - newBrowser = injector.createChild([{ + newBrowser = self._injector.createChild([{ id: ['value', info.id || null], fullName: ['value', info.name], socket: ['value', socket] @@ -141,15 +225,15 @@ var start = function (injector, config, launcher, globalEmitter, preprocess, fil results.exitCode = 1 } - globalEmitter.emit('run_complete', singleRunBrowsers, results) + self.emit('run_complete', singleRunBrowsers, results) } } if (config.singleRun) { - globalEmitter.on('browser_complete', function (completedBrowser) { + self.on('browser_complete', function (completedBrowser) { if (completedBrowser.lastResult.disconnected && completedBrowser.disconnectsCount <= config.browserDisconnectTolerance) { - log.info('Restarting %s (%d of %d attempts)', completedBrowser.name, + self.log.info('Restarting %s (%d of %d attempts)', completedBrowser.name, completedBrowser.disconnectsCount, config.browserDisconnectTolerance) if (!launcher.restart(completedBrowser.id)) { singleRunDoneBrowsers[completedBrowser.id] = true @@ -167,24 +251,24 @@ var start = function (injector, config, launcher, globalEmitter, preprocess, fil } }) - globalEmitter.on('browser_process_failure', function (browserLauncher) { + self.on('browser_process_failure', function (browserLauncher) { singleRunDoneBrowsers[browserLauncher.id] = true singleRunBrowserNotCaptured = true emitRunCompleteIfAllBrowsersDone() }) - globalEmitter.on('run_complete', function (browsers, results) { - log.debug('Run complete, exiting.') + self.on('run_complete', function (browsers, results) { + self.log.debug('Run complete, exiting.') disconnectBrowsers(results.exitCode) }) - globalEmitter.emit('run_start', singleRunBrowsers) + self.emit('run_start', singleRunBrowsers) } if (config.autoWatch) { - globalEmitter.on('file_list_modified', function () { - log.debug('List of files has changed, trying to execute') + self.on('file_list_modified', function () { + self.log.debug('List of files has changed, trying to execute') executor.schedule() }) } @@ -217,7 +301,7 @@ var start = function (injector, config, launcher, globalEmitter, preprocess, fil done(code || 0) } - globalEmitter.emitAsync('exit').then(function () { + self.emitAsync('exit').then(function () { // don't wait forever on webServer.close() because // pending client connections prevent it from closing. var closeTimeout = setTimeout(removeAllListeners, webServerCloseTimeout) @@ -230,70 +314,18 @@ var start = function (injector, config, launcher, globalEmitter, preprocess, fil }) } - try { - processWrapper.on('SIGINT', disconnectBrowsers) - processWrapper.on('SIGTERM', disconnectBrowsers) - } catch (e) { - // Windows doesn't support signals yet, so they simply don't get this handling. - // https://github.com/joyent/node/issues/1553 - } + processWrapper.on('SIGINT', disconnectBrowsers) + processWrapper.on('SIGTERM', disconnectBrowsers) // Handle all unhandled exceptions, so we don't just exit but // disconnect the browsers before exiting. processWrapper.on('uncaughtException', function (error) { - log.error(error) + self.log.error(error) disconnectBrowsers(1) }) } -start.$inject = ['injector', 'config', 'launcher', 'emitter', 'preprocess', 'fileList', - 'webServer', 'capturedBrowsers', 'socketServer', 'executor', 'done'] - -var createSocketIoServer = function (webServer, executor, config) { - var server = new Server(webServer, { - // avoid destroying http upgrades from socket.io to get proxied websockets working - destroyUpgrade: false, - path: config.urlRoot + 'socket.io/', - transports: config.transports - }) - - // hack to overcome circular dependency - executor.socketIoSockets = server.sockets - - return server -} - -exports.start = function (cliOptions, done) { - // apply the default logger config (and config from CLI) as soon as we can - logger.setup(cliOptions.logLevel || constant.LOG_INFO, - helper.isDefined(cliOptions.colors) ? cliOptions.colors : true, [constant.CONSOLE_APPENDER]) - - var config = cfg.parseConfig(cliOptions.configFile, cliOptions) - var modules = [{ - helper: ['value', helper], - logger: ['value', logger], - done: ['value', done || process.exit], - emitter: ['type', EventEmitter], - launcher: ['type', Launcher], - config: ['value', config], - preprocess: ['factory', preprocessor.createPreprocessor], - fileList: ['type', FileList], - webServer: ['factory', ws.create], - socketServer: ['factory', createSocketIoServer], - executor: ['type', Executor], - // TODO(vojta): remove - customFileHandlers: ['value', []], - // TODO(vojta): remove, once karma-dart does not rely on it - customScriptTypes: ['value', []], - reporter: ['factory', reporter.createReporters], - capturedBrowsers: ['type', BrowserCollection], - args: ['value', {}], - timer: ['value', {setTimeout: setTimeout, clearTimeout: clearTimeout}] - }] - // load the plugins - modules = modules.concat(plugin.resolve(config.plugins)) +// Export +// ------ - var injector = new di.Injector(modules) - - injector.invoke(start) -} +module.exports = Server diff --git a/test/unit/server.spec.coffee b/test/unit/server.spec.coffee index f751c3d22..3fffb003a 100644 --- a/test/unit/server.spec.coffee +++ b/test/unit/server.spec.coffee @@ -1,28 +1,23 @@ -# TODO(vojta): -# single run -'should run tests when all browsers captured' -'should run tests when first browser captured if no browser configured' - #============================================================================== # lib/server.js module #============================================================================== + +Server = require('../../lib/server') +BrowserCollection = require('../../lib/browser_collection') + describe 'server', -> - BrowserCollection = require('../../lib/browser_collection') - EventEmitter = require('events').EventEmitter - loadFile = require('mocks').loadFile - m = mockConfig = browserCollection = emitter = injector = webServerOnError = null - fileListOnResolve = fileListOnReject = mockInjector = mockLauncher = null + server = mockConfig = browserCollection = injector = webServerOnError = null + fileListOnResolve = fileListOnReject = mockLauncher = null mockFileList = mockWebServer = mockSocketServer = mockExecutor = doneSpy = null beforeEach -> browserCollection = new BrowserCollection doneSpy = sinon.spy() - emitter = new EventEmitter fileListOnResolve = fileListOnReject = null - m = loadFile __dirname + '/../../lib/server.js' + doneSpy = sinon.spy() mockConfig = frameworks: [] @@ -32,8 +27,13 @@ describe 'server', -> urlRoot: '/' browsers: ['fake'] singleRun: true + logLevel: 'OFF' browserDisconnectTolerance: 0 + server = new Server(mockConfig, doneSpy) + + sinon.stub(server._injector, 'invoke').returns([]) + mockExecutor = schedule: -> @@ -43,13 +43,6 @@ describe 'server', -> fileListOnReject = onReject ) - mockInjector = - get: -> - invoke: sinon.spy( -> []) - createChild: -> - instantiate: -> - init: -> - mockLauncher = launch: -> markCaptured: -> @@ -75,52 +68,54 @@ describe 'server', -> webServerOnError = null + + #============================================================================ - # server.start() + # server._start() #============================================================================ - describe 'start', -> + describe '_start', -> it 'should start the web server after all files have been preprocessed successfully', -> - m.start(mockInjector, mockConfig, mockLauncher, emitter, null, mockFileList, + server._start(mockConfig, mockLauncher, null, mockFileList, mockWebServer, browserCollection, mockSocketServer, mockExecutor, doneSpy) expect(mockFileList.refresh).to.have.been.called expect(fileListOnResolve).not.to.be.null expect(mockWebServer.listen).not.to.have.been.called - expect(mockInjector.invoke).not.to.have.been.calledWith mockLauncher.launch, mockLauncher + expect(server._injector.invoke).not.to.have.been.calledWith mockLauncher.launch, mockLauncher fileListOnResolve() expect(mockWebServer.listen).to.have.been.called - expect(mockInjector.invoke).to.have.been.calledWith mockLauncher.launch, mockLauncher + expect(server._injector.invoke).to.have.been.calledWith mockLauncher.launch, mockLauncher it 'should start the web server after all files have been preprocessed with an error', -> - m.start(mockInjector, mockConfig, mockLauncher, emitter, null, mockFileList, + server._start(mockConfig, mockLauncher, null, mockFileList, mockWebServer, browserCollection, mockSocketServer, mockExecutor, doneSpy) expect(mockFileList.refresh).to.have.been.called expect(fileListOnReject).not.to.be.null expect(mockWebServer.listen).not.to.have.been.called - expect(mockInjector.invoke).not.to.have.been.calledWith mockLauncher.launch, mockLauncher + expect(server._injector.invoke).not.to.have.been.calledWith mockLauncher.launch, mockLauncher fileListOnReject() expect(mockWebServer.listen).to.have.been.called - expect(mockInjector.invoke).to.have.been.calledWith mockLauncher.launch, mockLauncher + expect(server._injector.invoke).to.have.been.calledWith mockLauncher.launch, mockLauncher it 'should launch browsers after the web server has started', -> - m.start(mockInjector, mockConfig, mockLauncher, emitter, null, mockFileList, + server._start(mockConfig, mockLauncher, null, mockFileList, mockWebServer, browserCollection, mockSocketServer, mockExecutor, doneSpy) expect(mockWebServer.listen).not.to.have.been.called - expect(mockInjector.invoke).not.to.have.been.calledWith mockLauncher.launch, mockLauncher + expect(server._injector.invoke).not.to.have.been.calledWith mockLauncher.launch, mockLauncher fileListOnResolve() expect(mockWebServer.listen).to.have.been.called - expect(mockInjector.invoke).to.have.been.calledWith mockLauncher.launch, mockLauncher + expect(server._injector.invoke).to.have.been.calledWith mockLauncher.launch, mockLauncher it 'should try next port if already in use', -> - m.start(mockInjector, mockConfig, mockLauncher, emitter, null, mockFileList, + server._start(mockConfig, mockLauncher, null, mockFileList, mockWebServer, browserCollection, mockSocketServer, mockExecutor, doneSpy) expect(mockWebServer.listen).not.to.have.been.called @@ -136,3 +131,37 @@ describe 'server', -> expect(mockWebServer.listen).to.have.been.calledWith(9877) expect(mockConfig.port).to.be.equal 9877 + + it 'should emit a browsers_ready event once all the browsers are captured', -> + server._start(mockConfig, mockLauncher, null, mockFileList, + mockWebServer, browserCollection, mockSocketServer, mockExecutor, doneSpy) + + browsersReady = sinon.spy() + server.on('browsers_ready', browsersReady) + + mockLauncher.areAllCaptured = -> false + fileListOnResolve() + expect(browsersReady).not.to.have.been.called + + mockLauncher.areAllCaptured = -> true + server.emit('browser_register', {}) + expect(browsersReady).to.have.been.called + + it 'should emit a browser_register event for each browser added', -> + server._start(mockConfig, mockLauncher, null, mockFileList, + mockWebServer, browserCollection, mockSocketServer, mockExecutor, doneSpy) + + browsersReady = sinon.spy() + server.on('browsers_ready', browsersReady) + + mockLauncher.areAllCaptured = -> false + fileListOnResolve() + expect(browsersReady).not.to.have.been.called + + mockLauncher.areAllCaptured = -> true + server.emit('browser_register', {}) + expect(browsersReady).to.have.been.called + + describe.skip 'singleRun', -> + it 'should run tests when all browsers captured', -> + it 'should run tests when first browser captured if no browser configured', ->