blob: c01e43dd5e0c722a4c9751e00885c2343264213b [file] [log] [blame]
'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