server.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. 'use strict'
  2. const SocketIO = require('socket.io')
  3. const di = require('di')
  4. const util = require('util')
  5. const spawn = require('child_process').spawn
  6. const tmp = require('tmp')
  7. const fs = require('fs')
  8. const path = require('path')
  9. const NetUtils = require('./utils/net-utils')
  10. const root = global || window || this
  11. const cfg = require('./config')
  12. const logger = require('./logger')
  13. const constant = require('./constants')
  14. const watcher = require('./watcher')
  15. const plugin = require('./plugin')
  16. const createServeFile = require('./web-server').createServeFile
  17. const createServeStaticFile = require('./web-server').createServeStaticFile
  18. const createFilesPromise = require('./web-server').createFilesPromise
  19. const createWebServer = require('./web-server').createWebServer
  20. const preprocessor = require('./preprocessor')
  21. const Launcher = require('./launcher').Launcher
  22. const FileList = require('./file-list')
  23. const reporter = require('./reporter')
  24. const helper = require('./helper')
  25. const events = require('./events')
  26. const KarmaEventEmitter = events.EventEmitter
  27. const EventEmitter = require('events').EventEmitter
  28. const Executor = require('./executor')
  29. const Browser = require('./browser')
  30. const BrowserCollection = require('./browser_collection')
  31. const EmitterWrapper = require('./emitter_wrapper')
  32. const processWrapper = new EmitterWrapper(process)
  33. function createSocketIoServer (webServer, executor, config) {
  34. const server = new SocketIO.Server(webServer, {
  35. // avoid destroying http upgrades from socket.io to get proxied websockets working
  36. destroyUpgrade: false,
  37. path: config.urlRoot + 'socket.io/',
  38. transports: config.transports,
  39. forceJSONP: config.forceJSONP,
  40. // Default is 5000 in socket.io v2.x and v3.x.
  41. pingTimeout: config.pingTimeout || 5000,
  42. // Default in v2 is 1e8 and coverage results can fail at 1e6
  43. maxHttpBufferSize: 1e8
  44. })
  45. // hack to overcome circular dependency
  46. executor.socketIoSockets = server.sockets
  47. return server
  48. }
  49. class Server extends KarmaEventEmitter {
  50. constructor (cliOptionsOrConfig, done) {
  51. super()
  52. cliOptionsOrConfig = cliOptionsOrConfig || {}
  53. this.log = logger.create('karma-server')
  54. done = helper.isFunction(done) ? done : process.exit
  55. this.loadErrors = []
  56. let config
  57. if (cliOptionsOrConfig instanceof cfg.Config) {
  58. config = cliOptionsOrConfig
  59. } else {
  60. logger.setupFromConfig({
  61. colors: cliOptionsOrConfig.colors,
  62. logLevel: cliOptionsOrConfig.logLevel
  63. })
  64. const deprecatedCliOptionsMessage =
  65. 'Passing raw CLI options to `new Server(config, done)` is ' +
  66. 'deprecated. Use ' +
  67. '`parseConfig(configFilePath, cliOptions, {promiseConfig: true, throwErrors: true})` ' +
  68. 'to prepare a processed `Config` instance and pass that as the ' +
  69. '`config` argument instead.'
  70. this.log.warn(deprecatedCliOptionsMessage)
  71. try {
  72. config = cfg.parseConfig(
  73. cliOptionsOrConfig.configFile,
  74. cliOptionsOrConfig,
  75. {
  76. promiseConfig: false,
  77. throwErrors: true
  78. }
  79. )
  80. } catch (parseConfigError) {
  81. // TODO: change how `done` falls back to exit in next major version
  82. // SEE: https://github.com/karma-runner/karma/pull/3635#discussion_r565399378
  83. done(1)
  84. return
  85. }
  86. }
  87. this.log.debug('Final config', util.inspect(config, false, /** depth **/ null))
  88. if (!config.autoWatch && !config.singleRun) {
  89. this.log.warn('`autowatch` and `singleRun` are both `false`. In order to execute tests use `karma run`.')
  90. }
  91. let modules = [{
  92. helper: ['value', helper],
  93. logger: ['value', logger],
  94. done: ['value', done || process.exit],
  95. emitter: ['value', this],
  96. server: ['value', this],
  97. watcher: ['value', watcher],
  98. launcher: ['factory', Launcher.factory],
  99. config: ['value', config],
  100. instantiatePlugin: ['factory', plugin.createInstantiatePlugin],
  101. preprocess: ['factory', preprocessor.createPriorityPreprocessor],
  102. fileList: ['factory', FileList.factory],
  103. webServer: ['factory', createWebServer],
  104. serveFile: ['factory', createServeFile],
  105. serveStaticFile: ['factory', createServeStaticFile],
  106. filesPromise: ['factory', createFilesPromise],
  107. socketServer: ['factory', createSocketIoServer],
  108. executor: ['factory', Executor.factory],
  109. // TODO: Deprecated. Remove in the next major
  110. customFileHandlers: ['value', []],
  111. reporter: ['factory', reporter.createReporters],
  112. capturedBrowsers: ['factory', BrowserCollection.factory],
  113. args: ['value', {}],
  114. timer: ['value', {
  115. setTimeout () {
  116. return setTimeout.apply(root, arguments)
  117. },
  118. clearTimeout
  119. }]
  120. }]
  121. this.on('load_error', (type, name) => {
  122. this.log.debug(`Registered a load error of type ${type} with name ${name}`)
  123. this.loadErrors.push([type, name])
  124. })
  125. modules = modules.concat(plugin.resolve(config.plugins, this))
  126. this._injector = new di.Injector(modules)
  127. }
  128. async start () {
  129. const config = this.get('config')
  130. try {
  131. this._boundServer = await NetUtils.bindAvailablePort(config.port, config.listenAddress)
  132. this._boundServer.on('connection', (socket) => {
  133. // Attach an error handler to avoid UncaughtException errors.
  134. socket.on('error', (err) => {
  135. // Errors on this socket are retried, ignore them
  136. this.log.debug('Ignoring error on webserver connection: ' + err)
  137. })
  138. })
  139. config.port = this._boundServer.address().port
  140. await this._injector.invoke(this._start, this)
  141. } catch (err) {
  142. this.log.error(`Server start failed on port ${config.port}: ${err}`)
  143. this._close(1)
  144. }
  145. }
  146. get (token) {
  147. return this._injector.get(token)
  148. }
  149. refreshFiles () {
  150. return this._fileList ? this._fileList.refresh() : Promise.resolve()
  151. }
  152. refreshFile (path) {
  153. return this._fileList ? this._fileList.changeFile(path) : Promise.resolve()
  154. }
  155. emitExitAsync (code) {
  156. const name = 'exit'
  157. let pending = this.listeners(name).length
  158. const deferred = helper.defer()
  159. function resolve () {
  160. deferred.resolve(code)
  161. }
  162. try {
  163. this.emit(name, (newCode) => {
  164. if (newCode && typeof newCode === 'number') {
  165. // Only update code if it is given and not zero
  166. code = newCode
  167. }
  168. if (!--pending) {
  169. resolve()
  170. }
  171. })
  172. if (!pending) {
  173. resolve()
  174. }
  175. } catch (err) {
  176. deferred.reject(err)
  177. }
  178. return deferred.promise
  179. }
  180. async _start (config, launcher, preprocess, fileList, capturedBrowsers, executor, done) {
  181. if (config.detached) {
  182. this._detach(config, done)
  183. return
  184. }
  185. this._fileList = fileList
  186. await Promise.all(
  187. config.frameworks.map((framework) => this._injector.get('framework:' + framework))
  188. )
  189. const webServer = this._injector.get('webServer')
  190. const socketServer = this._injector.get('socketServer')
  191. const singleRunDoneBrowsers = Object.create(null)
  192. const singleRunBrowsers = new BrowserCollection(new EventEmitter())
  193. let singleRunBrowserNotCaptured = false
  194. webServer.on('error', (err) => {
  195. this.log.error(`Webserver fail ${err}`)
  196. this._close(1)
  197. })
  198. const afterPreprocess = () => {
  199. if (config.autoWatch) {
  200. const watcher = this.get('watcher')
  201. this._injector.invoke(watcher)
  202. }
  203. webServer.listen(this._boundServer, () => {
  204. this.log.info(`Karma v${constant.VERSION} server started at ${config.protocol}//${config.hostname}:${config.port}${config.urlRoot}`)
  205. this.emit('listening', config.port)
  206. if (config.browsers && config.browsers.length) {
  207. this._injector.invoke(launcher.launch, launcher).forEach((browserLauncher) => {
  208. singleRunDoneBrowsers[browserLauncher.id] = false
  209. })
  210. }
  211. if (this.loadErrors.length > 0) {
  212. this.log.error(new Error(`Found ${this.loadErrors.length} load error${this.loadErrors.length === 1 ? '' : 's'}`))
  213. this._close(1)
  214. }
  215. })
  216. }
  217. fileList.refresh().then(afterPreprocess, (err) => {
  218. this.log.error('Error during file loading or preprocessing\n' + err.stack || err)
  219. afterPreprocess()
  220. })
  221. this.on('browsers_change', () => socketServer.sockets.emit('info', capturedBrowsers.serialize()))
  222. this.on('browser_register', (browser) => {
  223. launcher.markCaptured(browser.id)
  224. if (launcher.areAllCaptured()) {
  225. this.emit('browsers_ready')
  226. if (config.autoWatch) {
  227. executor.schedule()
  228. }
  229. }
  230. })
  231. if (config.browserConsoleLogOptions && config.browserConsoleLogOptions.path) {
  232. const configLevel = config.browserConsoleLogOptions.level || 'debug'
  233. const configFormat = config.browserConsoleLogOptions.format || '%b %T: %m'
  234. const configPath = config.browserConsoleLogOptions.path
  235. const configPathDir = path.dirname(configPath)
  236. if (!fs.existsSync(configPathDir)) fs.mkdirSync(configPathDir, { recursive: true })
  237. this.log.info(`Writing browser console to file: ${configPath}`)
  238. const browserLogFile = fs.openSync(configPath, 'w+')
  239. const levels = ['log', 'error', 'warn', 'info', 'debug']
  240. this.on('browser_log', function (browser, message, level) {
  241. if (levels.indexOf(level.toLowerCase()) > levels.indexOf(configLevel)) {
  242. return
  243. }
  244. if (!helper.isString(message)) {
  245. message = util.inspect(message, { showHidden: false, colors: false })
  246. }
  247. const logMap = { '%m': message, '%t': level.toLowerCase(), '%T': level.toUpperCase(), '%b': browser }
  248. const logString = configFormat.replace(/%[mtTb]/g, (m) => logMap[m])
  249. this.log.debug(`Writing browser console line: ${logString}`)
  250. fs.writeSync(browserLogFile, logString + '\n')
  251. })
  252. }
  253. socketServer.sockets.on('connection', (socket) => {
  254. this.log.debug(`A browser has connected on socket ${socket.id}`)
  255. const replySocketEvents = events.bufferEvents(socket, ['start', 'info', 'karma_error', 'result', 'complete'])
  256. socket.on('error', (err) => {
  257. this.log.debug('karma server socket error: ' + err)
  258. })
  259. socket.on('register', (info) => {
  260. const knownBrowser = info.id ? (capturedBrowsers.getById(info.id) || singleRunBrowsers.getById(info.id)) : null
  261. if (knownBrowser) {
  262. knownBrowser.reconnect(socket, info.isSocketReconnect)
  263. } else {
  264. const newBrowser = this._injector.createChild([{
  265. id: ['value', info.id || null],
  266. fullName: ['value', (helper.isDefined(info.displayName) ? info.displayName : info.name)],
  267. socket: ['value', socket]
  268. }]).invoke(Browser.factory)
  269. newBrowser.init()
  270. if (config.singleRun) {
  271. newBrowser.execute()
  272. singleRunBrowsers.add(newBrowser)
  273. }
  274. }
  275. replySocketEvents()
  276. })
  277. })
  278. const emitRunCompleteIfAllBrowsersDone = () => {
  279. if (Object.keys(singleRunDoneBrowsers).every((key) => singleRunDoneBrowsers[key])) {
  280. this.emit('run_complete', singleRunBrowsers, singleRunBrowsers.getResults(singleRunBrowserNotCaptured, config))
  281. }
  282. }
  283. this.on('browser_complete', (completedBrowser) => {
  284. if (completedBrowser.lastResult.disconnected && completedBrowser.disconnectsCount <= config.browserDisconnectTolerance) {
  285. this.log.info(`Restarting ${completedBrowser.name} (${completedBrowser.disconnectsCount} of ${config.browserDisconnectTolerance} attempts)`)
  286. if (!launcher.restart(completedBrowser.id)) {
  287. this.emit('browser_restart_failure', completedBrowser)
  288. }
  289. } else {
  290. this.emit('browser_complete_with_no_more_retries', completedBrowser)
  291. }
  292. })
  293. this.on('stop', (done) => {
  294. this.log.debug('Received stop event, exiting.')
  295. this._close()
  296. done()
  297. })
  298. if (config.singleRun) {
  299. this.on('browser_restart_failure', (completedBrowser) => {
  300. singleRunDoneBrowsers[completedBrowser.id] = true
  301. emitRunCompleteIfAllBrowsersDone()
  302. })
  303. // This is the normal exit trigger.
  304. this.on('browser_complete_with_no_more_retries', function (completedBrowser) {
  305. singleRunDoneBrowsers[completedBrowser.id] = true
  306. if (launcher.kill(completedBrowser.id)) {
  307. completedBrowser.remove()
  308. }
  309. emitRunCompleteIfAllBrowsersDone()
  310. })
  311. this.on('browser_process_failure', (browserLauncher) => {
  312. singleRunDoneBrowsers[browserLauncher.id] = true
  313. singleRunBrowserNotCaptured = true
  314. emitRunCompleteIfAllBrowsersDone()
  315. })
  316. this.on('run_complete', (browsers, results) => {
  317. this.log.debug('Run complete, exiting.')
  318. this._close(results.exitCode)
  319. })
  320. this.emit('run_start', singleRunBrowsers)
  321. }
  322. if (config.autoWatch) {
  323. this.on('file_list_modified', () => {
  324. this.log.debug('List of files has changed, trying to execute')
  325. if (config.restartOnFileChange) {
  326. socketServer.sockets.emit('stop')
  327. }
  328. executor.schedule()
  329. })
  330. }
  331. processWrapper.on('SIGINT', () => this._close())
  332. processWrapper.on('SIGTERM', () => this._close())
  333. const reportError = (error) => {
  334. this.log.error(error)
  335. process.emit('infrastructure_error', error)
  336. this._close(1)
  337. }
  338. processWrapper.on('unhandledRejection', (error) => {
  339. this.log.error(`UnhandledRejection: ${error.stack || error.message || String(error)}`)
  340. reportError(error)
  341. })
  342. processWrapper.on('uncaughtException', (error) => {
  343. this.log.error(`UncaughtException: ${error.stack || error.message || String(error)}`)
  344. reportError(error)
  345. })
  346. }
  347. _detach (config, done) {
  348. const tmpFile = tmp.fileSync({ keep: true })
  349. this.log.info('Starting karma detached')
  350. this.log.info('Run "karma stop" to stop the server.')
  351. this.log.debug(`Writing config to tmp-file ${tmpFile.name}`)
  352. config.detached = false
  353. try {
  354. fs.writeFileSync(tmpFile.name, JSON.stringify(config), 'utf8')
  355. } catch (e) {
  356. this.log.error("Couldn't write temporary configuration file")
  357. done(1)
  358. return
  359. }
  360. const child = spawn(process.argv[0], [path.resolve(__dirname, '../lib/detached.js'), tmpFile.name], {
  361. detached: true,
  362. stdio: 'ignore'
  363. })
  364. child.unref()
  365. }
  366. /**
  367. * Cleanup all resources allocated by Karma and call the `done` callback
  368. * with the result of the tests execution.
  369. *
  370. * @param [exitCode] - Optional exit code. If omitted will be computed by
  371. * 'exit' event listeners.
  372. */
  373. _close (exitCode) {
  374. const webServer = this._injector.get('webServer')
  375. const socketServer = this._injector.get('socketServer')
  376. const done = this._injector.get('done')
  377. const webServerCloseTimeout = 3000
  378. const sockets = socketServer.sockets.sockets
  379. Object.keys(sockets).forEach((id) => {
  380. const socket = sockets[id]
  381. socket.removeAllListeners('disconnect')
  382. if (!socket.disconnected) {
  383. process.nextTick(socket.disconnect.bind(socket))
  384. }
  385. })
  386. this.emitExitAsync(exitCode).catch((err) => {
  387. this.log.error('Error while calling exit event listeners\n' + err.stack || err)
  388. return 1
  389. }).then((code) => {
  390. socketServer.sockets.removeAllListeners()
  391. socketServer.close()
  392. let removeAllListenersDone = false
  393. const removeAllListeners = () => {
  394. if (removeAllListenersDone) {
  395. return
  396. }
  397. removeAllListenersDone = true
  398. webServer.removeAllListeners()
  399. processWrapper.removeAllListeners()
  400. done(code || 0)
  401. }
  402. const closeTimeout = setTimeout(removeAllListeners, webServerCloseTimeout)
  403. webServer.close(() => {
  404. clearTimeout(closeTimeout)
  405. removeAllListeners()
  406. })
  407. })
  408. }
  409. stop () {
  410. return this.emitAsync('stop')
  411. }
  412. }
  413. Server.prototype._start.$inject = ['config', 'launcher', 'preprocess', 'fileList', 'capturedBrowsers', 'executor', 'done']
  414. module.exports = Server