karma-runner/karma

View on GitHub
lib/server.js

Summary

Maintainability
D
2 days
Test Coverage
'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