| 'use strict' |
| |
| const SocketIO = require('socket.io') |
| const di = require('di') |
| const util = require('util') |
| const Promise = require('bluebird') |
| const spawn = require('child_process').spawn |
| const tmp = require('tmp') |
| const fs = require('fs') |
| const path = require('path') |
| |
| const BundleUtils = require('./utils/bundle-utils') |
| const NetUtils = require('./utils/net-utils') |
| const root = global || window || this |
| |
| const cfg = require('./config') |
| const logger = require('./logger') |
| const constant = require('./constants') |
| const watcher = require('./watcher') |
| const plugin = require('./plugin') |
| |
| const createServeFile = require('./web-server').createServeFile |
| const createServeStaticFile = require('./web-server').createServeStaticFile |
| const createFilesPromise = require('./web-server').createFilesPromise |
| const createReadFilePromise = require('./web-server').createReadFilePromise |
| const createWebServer = require('./web-server').createWebServer |
| const preprocessor = require('./preprocessor') |
| const Launcher = require('./launcher').Launcher |
| const FileList = require('./file-list') |
| const reporter = require('./reporter') |
| const helper = require('./helper') |
| const events = require('./events') |
| const KarmaEventEmitter = events.EventEmitter |
| const EventEmitter = require('events').EventEmitter |
| const Executor = require('./executor') |
| const Browser = require('./browser') |
| const BrowserCollection = require('./browser_collection') |
| const EmitterWrapper = require('./emitter_wrapper') |
| const processWrapper = new EmitterWrapper(process) |
| |
| function createSocketIoServer (webServer, executor, config) { |
| const 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, |
| forceJSONP: config.forceJSONP |
| }) |
| |
| // hack to overcome circular dependency |
| executor.socketIoSockets = server.sockets |
| |
| return server |
| } |
| |
| class Server extends KarmaEventEmitter { |
| constructor (cliOptions, done) { |
| super() |
| logger.setupFromConfig(cliOptions) |
| |
| this.log = logger.create('karma-server') |
| |
| this.loadErrors = [] |
| |
| const config = cfg.parseConfig(cliOptions.configFile, cliOptions) |
| |
| this.log.debug('Final config', util.inspect(config, false, /** depth **/ null)) |
| |
| let modules = [{ |
| helper: ['value', helper], |
| logger: ['value', logger], |
| done: ['value', done || process.exit], |
| emitter: ['value', this], |
| server: ['value', this], |
| watcher: ['value', watcher], |
| launcher: ['type', Launcher], |
| config: ['value', config], |
| preprocess: ['factory', preprocessor.createPreprocessor], |
| fileList: ['factory', FileList.factory], |
| webServer: ['factory', createWebServer], |
| serveFile: ['factory', createServeFile], |
| serveStaticFile: ['factory', createServeStaticFile], |
| filesPromise: ['factory', createFilesPromise], |
| readFilePromise: ['factory', createReadFilePromise], |
| socketServer: ['factory', createSocketIoServer], |
| executor: ['factory', Executor.factory], |
| // TODO(vojta): remove |
| customFileHandlers: ['value', []], |
| // TODO(vojta): remove, once karma-dart does not rely on it |
| customScriptTypes: ['value', []], |
| reporter: ['factory', reporter.createReporters], |
| capturedBrowsers: ['factory', BrowserCollection.factory], |
| args: ['value', {}], |
| timer: ['value', { |
| setTimeout () { |
| return setTimeout.apply(root, arguments) |
| }, |
| clearTimeout |
| }] |
| }] |
| |
| this.on('load_error', (type, name) => { |
| this.log.debug(`Registered a load error of type ${type} with name ${name}`) |
| this.loadErrors.push([type, name]) |
| }) |
| |
| modules = modules.concat(plugin.resolve(config.plugins, this)) |
| this._injector = new di.Injector(modules) |
| } |
| |
| dieOnError (error) { |
| this.log.error(error) |
| process.exitCode = 1 |
| process.kill(process.pid, 'SIGINT') |
| } |
| |
| async start () { |
| const config = this.get('config') |
| try { |
| await Promise.all([ |
| BundleUtils.bundleResourceIfNotExist('client/main.js', 'static/karma.js'), |
| BundleUtils.bundleResourceIfNotExist('context/main.js', 'static/context.js') |
| ]) |
| this._boundServer = await NetUtils.bindAvailablePort(config.port, config.listenAddress) |
| this._boundServer.on('connection', (socket) => { |
| // Attach an error handler to avoid UncaughtException errors. |
| socket.on('error', (err) => { |
| // Errors on this socket are retried, ignore them |
| this.log.debug('Ignoring error on webserver connection: ' + err) |
| }) |
| }) |
| config.port = this._boundServer.address().port |
| this._injector.invoke(this._start, this) |
| } catch (err) { |
| this.dieOnError(`Server start failed on port ${config.port}: ${err}`) |
| } |
| } |
| |
| get (token) { |
| return this._injector.get(token) |
| } |
| |
| refreshFiles () { |
| return this._fileList ? this._fileList.refresh() : Promise.resolve() |
| } |
| |
| refreshFile (path) { |
| return this._fileList ? this._fileList.changeFile(path) : Promise.resolve() |
| } |
| |
| _start (config, launcher, preprocess, fileList, capturedBrowsers, executor, done) { |
| if (config.detached) { |
| this._detach(config, done) |
| return |
| } |
| |
| this._fileList = fileList |
| |
| config.frameworks.forEach((framework) => this._injector.get('framework:' + framework)) |
| |
| const webServer = this._injector.get('webServer') |
| const socketServer = this._injector.get('socketServer') |
| |
| const singleRunDoneBrowsers = Object.create(null) |
| const singleRunBrowsers = new BrowserCollection(new EventEmitter()) |
| let singleRunBrowserNotCaptured = false |
| |
| webServer.on('error', (err) => { |
| this.dieOnError(`Webserver fail ${err}`) |
| }) |
| |
| const afterPreprocess = () => { |
| if (config.autoWatch) { |
| const watcher = this.get('watcher') |
| this._injector.invoke(watcher) |
| } |
| |
| webServer.listen(this._boundServer, () => { |
| this.log.info(`Karma v${constant.VERSION} server started at ${config.protocol}//${config.listenAddress}:${config.port}${config.urlRoot}`) |
| |
| this.emit('listening', config.port) |
| if (config.browsers && config.browsers.length) { |
| this._injector.invoke(launcher.launch, launcher).forEach((browserLauncher) => { |
| singleRunDoneBrowsers[browserLauncher.id] = false |
| }) |
| } |
| if (this.loadErrors.length > 0) { |
| this.dieOnError(new Error(`Found ${this.loadErrors.length} load error${this.loadErrors.length === 1 ? '' : 's'}`)) |
| } |
| }) |
| } |
| |
| fileList.refresh().then(afterPreprocess, afterPreprocess) |
| |
| this.on('browsers_change', () => socketServer.sockets.emit('info', capturedBrowsers.serialize())) |
| |
| this.on('browser_register', (browser) => { |
| launcher.markCaptured(browser.id) |
| |
| if (launcher.areAllCaptured()) { |
| this.emit('browsers_ready') |
| |
| if (config.autoWatch) { |
| executor.schedule() |
| } |
| } |
| }) |
| |
| if (config.browserConsoleLogOptions && config.browserConsoleLogOptions.path) { |
| const configLevel = config.browserConsoleLogOptions.level || 'debug' |
| const configFormat = config.browserConsoleLogOptions.format || '%b %T: %m' |
| const configPath = config.browserConsoleLogOptions.path |
| this.log.info(`Writing browser console to file: ${configPath}`) |
| const browserLogFile = fs.openSync(configPath, 'w+') |
| const levels = ['log', 'error', 'warn', 'info', 'debug'] |
| this.on('browser_log', function (browser, message, level) { |
| if (levels.indexOf(level.toLowerCase()) > levels.indexOf(configLevel)) { |
| return |
| } |
| if (!helper.isString(message)) { |
| message = util.inspect(message, { showHidden: false, colors: false }) |
| } |
| const logMap = { '%m': message, '%t': level.toLowerCase(), '%T': level.toUpperCase(), '%b': browser } |
| const logString = configFormat.replace(/%[mtTb]/g, (m) => logMap[m]) |
| this.log.debug(`Writing browser console line: ${logString}`) |
| fs.writeSync(browserLogFile, logString + '\n') |
| }) |
| } |
| |
| socketServer.sockets.on('connection', (socket) => { |
| this.log.debug(`A browser has connected on socket ${socket.id}`) |
| |
| const replySocketEvents = events.bufferEvents(socket, ['start', 'info', 'karma_error', 'result', 'complete']) |
| |
| socket.on('complete', (data, ack) => ack()) |
| |
| socket.on('error', (err) => { |
| this.log.debug('karma server socket error: ' + err) |
| }) |
| |
| socket.on('register', (info) => { |
| let newBrowser = info.id ? (capturedBrowsers.getById(info.id) || singleRunBrowsers.getById(info.id)) : null |
| |
| if (newBrowser) { |
| // By default if a browser disconnects while still executing, we assume that the test |
| // execution still continues because just the socket connection has been terminated. Now |
| // since we know whether this is just a socket reconnect or full client reconnect, we |
| // need to update the browser state accordingly. This is necessary because in case a |
| // browser crashed and has been restarted, we need to start with a fresh execution. |
| if (!info.isSocketReconnect) { |
| newBrowser.setState(Browser.STATE_DISCONNECTED) |
| } |
| |
| newBrowser.reconnect(socket) |
| |
| // Since not every reconnected browser is able to continue with its previous execution, |
| // we need to start a new execution in case a browser has restarted and is now idling. |
| if (newBrowser.state === Browser.STATE_CONNECTED && config.singleRun) { |
| newBrowser.execute(config.client) |
| } |
| } else { |
| newBrowser = this._injector.createChild([{ |
| id: ['value', info.id || null], |
| fullName: ['value', (helper.isDefined(info.displayName) ? info.displayName : info.name)], |
| socket: ['value', socket] |
| }]).invoke(Browser.factory) |
| |
| newBrowser.init() |
| |
| if (config.singleRun) { |
| newBrowser.execute(config.client) |
| singleRunBrowsers.add(newBrowser) |
| } |
| } |
| |
| replySocketEvents() |
| }) |
| }) |
| |
| const emitRunCompleteIfAllBrowsersDone = () => { |
| if (Object.keys(singleRunDoneBrowsers).every((key) => singleRunDoneBrowsers[key])) { |
| this.emit('run_complete', singleRunBrowsers, singleRunBrowsers.getResults(singleRunBrowserNotCaptured, config.failOnEmptyTestSuite, config.failOnFailingTestSuite)) |
| } |
| } |
| |
| this.on('browser_complete', (completedBrowser) => { |
| if (completedBrowser.lastResult.disconnected && completedBrowser.disconnectsCount <= config.browserDisconnectTolerance) { |
| this.log.info(`Restarting ${completedBrowser.name} (${completedBrowser.disconnectsCount} of ${config.browserDisconnectTolerance} attempts)`) |
| |
| if (!launcher.restart(completedBrowser.id)) { |
| this.emit('browser_restart_failure', completedBrowser) |
| } |
| } else { |
| this.emit('browser_complete_with_no_more_retries', completedBrowser) |
| } |
| }) |
| |
| this.on('stop', function (done) { |
| this.log.debug('Received stop event, exiting.') |
| return disconnectBrowsers().then(done) |
| }) |
| |
| if (config.singleRun) { |
| this.on('browser_restart_failure', (completedBrowser) => { |
| singleRunDoneBrowsers[completedBrowser.id] = true |
| emitRunCompleteIfAllBrowsersDone() |
| }) |
| this.on('browser_complete_with_no_more_retries', function (completedBrowser) { |
| singleRunDoneBrowsers[completedBrowser.id] = true |
| |
| if (launcher.kill(completedBrowser.id)) { |
| // workaround to supress "disconnect" warning |
| completedBrowser.state = Browser.STATE_DISCONNECTED |
| } |
| |
| emitRunCompleteIfAllBrowsersDone() |
| }) |
| |
| this.on('browser_process_failure', (browserLauncher) => { |
| singleRunDoneBrowsers[browserLauncher.id] = true |
| singleRunBrowserNotCaptured = true |
| |
| emitRunCompleteIfAllBrowsersDone() |
| }) |
| |
| this.on('run_complete', function (browsers, results) { |
| this.log.debug('Run complete, exiting.') |
| disconnectBrowsers(results.exitCode) |
| }) |
| |
| this.emit('run_start', singleRunBrowsers) |
| } |
| |
| if (config.autoWatch) { |
| this.on('file_list_modified', () => { |
| this.log.debug('List of files has changed, trying to execute') |
| if (config.restartOnFileChange) { |
| socketServer.sockets.emit('stop') |
| } |
| executor.schedule() |
| }) |
| } |
| |
| const webServerCloseTimeout = 3000 |
| const disconnectBrowsers = (code) => { |
| const sockets = socketServer.sockets.sockets |
| |
| Object.keys(sockets).forEach((id) => { |
| const socket = sockets[id] |
| socket.removeAllListeners('disconnect') |
| if (!socket.disconnected) { |
| process.nextTick(socket.disconnect.bind(socket)) |
| } |
| }) |
| |
| let removeAllListenersDone = false |
| const removeAllListeners = () => { |
| if (removeAllListenersDone) { |
| return |
| } |
| removeAllListenersDone = true |
| webServer.removeAllListeners() |
| processWrapper.removeAllListeners() |
| done(code || 0) |
| } |
| |
| return this.emitAsync('exit').then(() => { |
| return new Promise((resolve, reject) => { |
| socketServer.sockets.removeAllListeners() |
| socketServer.close() |
| const closeTimeout = setTimeout(removeAllListeners, webServerCloseTimeout) |
| |
| webServer.close(() => { |
| clearTimeout(closeTimeout) |
| removeAllListeners() |
| resolve() |
| }) |
| }) |
| }) |
| } |
| |
| processWrapper.on('SIGINT', () => disconnectBrowsers(process.exitCode)) |
| processWrapper.on('SIGTERM', disconnectBrowsers) |
| |
| const reportError = (error) => { |
| process.emit('infrastructure_error', error) |
| disconnectBrowsers(1) |
| } |
| |
| processWrapper.on('unhandledRejection', (error) => { |
| this.log.error('UnhandledRejection') |
| reportError(error) |
| }) |
| |
| processWrapper.on('uncaughtException', (error) => { |
| this.log.error('UncaughtException') |
| reportError(error) |
| }) |
| } |
| |
| _detach (config, done) { |
| const tmpFile = tmp.fileSync({ keep: true }) |
| this.log.info('Starting karma detached') |
| this.log.info('Run "karma stop" to stop the server.') |
| this.log.debug(`Writing config to tmp-file ${tmpFile.name}`) |
| config.detached = false |
| try { |
| fs.writeFileSync(tmpFile.name, JSON.stringify(config), 'utf8') |
| } catch (e) { |
| this.log.error("Couldn't write temporary configuration file") |
| done(1) |
| return |
| } |
| const child = spawn(process.argv[0], [path.resolve(__dirname, '../lib/detached.js'), tmpFile.name], { |
| detached: true, |
| stdio: 'ignore' |
| }) |
| child.unref() |
| } |
| |
| stop () { |
| return this.emitAsync('stop') |
| } |
| |
| static start (cliOptions, done) { |
| console.warn('Deprecated static method to be removed in v3.0') |
| return new Server(cliOptions, done).start() |
| } |
| } |
| |
| Server.prototype._start.$inject = ['config', 'launcher', 'preprocess', 'fileList', 'capturedBrowsers', 'executor', 'done'] |
| |
| module.exports = Server |