123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487 |
- 'use strict'
- const SocketIO = require('socket.io')
- const di = require('di')
- const util = require('util')
- const spawn = require('child_process').spawn
- const tmp = require('tmp')
- const fs = require('fs')
- const path = require('path')
- 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 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.Server(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,
- // Default is 5000 in socket.io v2.x and v3.x.
- pingTimeout: config.pingTimeout || 5000,
- // Default in v2 is 1e8 and coverage results can fail at 1e6
- maxHttpBufferSize: 1e8
- })
- // hack to overcome circular dependency
- executor.socketIoSockets = server.sockets
- return server
- }
- class Server extends KarmaEventEmitter {
- constructor (cliOptionsOrConfig, done) {
- super()
- cliOptionsOrConfig = cliOptionsOrConfig || {}
- this.log = logger.create('karma-server')
- done = helper.isFunction(done) ? done : process.exit
- this.loadErrors = []
- let config
- if (cliOptionsOrConfig instanceof cfg.Config) {
- config = cliOptionsOrConfig
- } else {
- logger.setupFromConfig({
- colors: cliOptionsOrConfig.colors,
- logLevel: cliOptionsOrConfig.logLevel
- })
- const deprecatedCliOptionsMessage =
- 'Passing raw CLI options to `new Server(config, done)` is ' +
- 'deprecated. Use ' +
- '`parseConfig(configFilePath, cliOptions, {promiseConfig: true, throwErrors: true})` ' +
- 'to prepare a processed `Config` instance and pass that as the ' +
- '`config` argument instead.'
- this.log.warn(deprecatedCliOptionsMessage)
- try {
- config = cfg.parseConfig(
- cliOptionsOrConfig.configFile,
- cliOptionsOrConfig,
- {
- promiseConfig: false,
- throwErrors: true
- }
- )
- } catch (parseConfigError) {
- // TODO: change how `done` falls back to exit in next major version
- // SEE: https://github.com/karma-runner/karma/pull/3635#discussion_r565399378
- done(1)
- return
- }
- }
- this.log.debug('Final config', util.inspect(config, false, /** depth **/ null))
- if (!config.autoWatch && !config.singleRun) {
- this.log.warn('`autowatch` and `singleRun` are both `false`. In order to execute tests use `karma run`.')
- }
- let modules = [{
- helper: ['value', helper],
- logger: ['value', logger],
- done: ['value', done || process.exit],
- emitter: ['value', this],
- server: ['value', this],
- watcher: ['value', watcher],
- launcher: ['factory', Launcher.factory],
- config: ['value', config],
- instantiatePlugin: ['factory', plugin.createInstantiatePlugin],
- preprocess: ['factory', preprocessor.createPriorityPreprocessor],
- fileList: ['factory', FileList.factory],
- webServer: ['factory', createWebServer],
- serveFile: ['factory', createServeFile],
- serveStaticFile: ['factory', createServeStaticFile],
- filesPromise: ['factory', createFilesPromise],
- socketServer: ['factory', createSocketIoServer],
- executor: ['factory', Executor.factory],
- // TODO: Deprecated. Remove in the next major
- customFileHandlers: ['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)
- }
- async start () {
- const config = this.get('config')
- try {
- 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
- await this._injector.invoke(this._start, this)
- } catch (err) {
- this.log.error(`Server start failed on port ${config.port}: ${err}`)
- this._close(1)
- }
- }
- 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()
- }
- emitExitAsync (code) {
- const name = 'exit'
- let pending = this.listeners(name).length
- const deferred = helper.defer()
- function resolve () {
- deferred.resolve(code)
- }
- try {
- this.emit(name, (newCode) => {
- if (newCode && typeof newCode === 'number') {
- // Only update code if it is given and not zero
- code = newCode
- }
- if (!--pending) {
- resolve()
- }
- })
- if (!pending) {
- resolve()
- }
- } catch (err) {
- deferred.reject(err)
- }
- return deferred.promise
- }
- async _start (config, launcher, preprocess, fileList, capturedBrowsers, executor, done) {
- if (config.detached) {
- this._detach(config, done)
- return
- }
- this._fileList = fileList
- await Promise.all(
- config.frameworks.map((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.log.error(`Webserver fail ${err}`)
- this._close(1)
- })
- 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.hostname}:${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.log.error(new Error(`Found ${this.loadErrors.length} load error${this.loadErrors.length === 1 ? '' : 's'}`))
- this._close(1)
- }
- })
- }
- fileList.refresh().then(afterPreprocess, (err) => {
- this.log.error('Error during file loading or preprocessing\n' + err.stack || err)
- 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
- const configPathDir = path.dirname(configPath)
- if (!fs.existsSync(configPathDir)) fs.mkdirSync(configPathDir, { recursive: true })
- 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('error', (err) => {
- this.log.debug('karma server socket error: ' + err)
- })
- socket.on('register', (info) => {
- const knownBrowser = info.id ? (capturedBrowsers.getById(info.id) || singleRunBrowsers.getById(info.id)) : null
- if (knownBrowser) {
- knownBrowser.reconnect(socket, info.isSocketReconnect)
- } else {
- const 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()
- singleRunBrowsers.add(newBrowser)
- }
- }
- replySocketEvents()
- })
- })
- const emitRunCompleteIfAllBrowsersDone = () => {
- if (Object.keys(singleRunDoneBrowsers).every((key) => singleRunDoneBrowsers[key])) {
- this.emit('run_complete', singleRunBrowsers, singleRunBrowsers.getResults(singleRunBrowserNotCaptured, config))
- }
- }
- 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', (done) => {
- this.log.debug('Received stop event, exiting.')
- this._close()
- done()
- })
- if (config.singleRun) {
- this.on('browser_restart_failure', (completedBrowser) => {
- singleRunDoneBrowsers[completedBrowser.id] = true
- emitRunCompleteIfAllBrowsersDone()
- })
- // This is the normal exit trigger.
- this.on('browser_complete_with_no_more_retries', function (completedBrowser) {
- singleRunDoneBrowsers[completedBrowser.id] = true
- if (launcher.kill(completedBrowser.id)) {
- completedBrowser.remove()
- }
- emitRunCompleteIfAllBrowsersDone()
- })
- this.on('browser_process_failure', (browserLauncher) => {
- singleRunDoneBrowsers[browserLauncher.id] = true
- singleRunBrowserNotCaptured = true
- emitRunCompleteIfAllBrowsersDone()
- })
- this.on('run_complete', (browsers, results) => {
- this.log.debug('Run complete, exiting.')
- this._close(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()
- })
- }
- processWrapper.on('SIGINT', () => this._close())
- processWrapper.on('SIGTERM', () => this._close())
- const reportError = (error) => {
- this.log.error(error)
- process.emit('infrastructure_error', error)
- this._close(1)
- }
- processWrapper.on('unhandledRejection', (error) => {
- this.log.error(`UnhandledRejection: ${error.stack || error.message || String(error)}`)
- reportError(error)
- })
- processWrapper.on('uncaughtException', (error) => {
- this.log.error(`UncaughtException: ${error.stack || error.message || String(error)}`)
- 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()
- }
- /**
- * Cleanup all resources allocated by Karma and call the `done` callback
- * with the result of the tests execution.
- *
- * @param [exitCode] - Optional exit code. If omitted will be computed by
- * 'exit' event listeners.
- */
- _close (exitCode) {
- const webServer = this._injector.get('webServer')
- const socketServer = this._injector.get('socketServer')
- const done = this._injector.get('done')
- const webServerCloseTimeout = 3000
- 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))
- }
- })
- this.emitExitAsync(exitCode).catch((err) => {
- this.log.error('Error while calling exit event listeners\n' + err.stack || err)
- return 1
- }).then((code) => {
- socketServer.sockets.removeAllListeners()
- socketServer.close()
- let removeAllListenersDone = false
- const removeAllListeners = () => {
- if (removeAllListenersDone) {
- return
- }
- removeAllListenersDone = true
- webServer.removeAllListeners()
- processWrapper.removeAllListeners()
- done(code || 0)
- }
- const closeTimeout = setTimeout(removeAllListeners, webServerCloseTimeout)
- webServer.close(() => {
- clearTimeout(closeTimeout)
- removeAllListeners()
- })
- })
- }
- stop () {
- return this.emitAsync('stop')
- }
- }
- Server.prototype._start.$inject = ['config', 'launcher', 'preprocess', 'fileList', 'capturedBrowsers', 'executor', 'done']
- module.exports = Server
|