server.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. 'use strict'
  2. const SocketIO = require('socket.io')
  3. const di = require('di')
  4. const util = require('util')
  5. const Promise = require('bluebird')
  6. const spawn = require('child_process').spawn
  7. const tmp = require('tmp')
  8. const fs = require('fs')
  9. const path = require('path')
  10. const BundleUtils = require('./utils/bundle-utils')
  11. const NetUtils = require('./utils/net-utils')
  12. const JsonUtils = require('./utils/json-utils')
  13. const root = global || window || this
  14. const cfg = require('./config')
  15. const logger = require('./logger')
  16. const constant = require('./constants')
  17. const watcher = require('./watcher')
  18. const plugin = require('./plugin')
  19. const createServeFile = require('./web-server').createServeFile
  20. const createServeStaticFile = require('./web-server').createServeStaticFile
  21. const createFilesPromise = require('./web-server').createFilesPromise
  22. const createReadFilePromise = require('./web-server').createReadFilePromise
  23. const createWebServer = require('./web-server').createWebServer
  24. const preprocessor = require('./preprocessor')
  25. const Launcher = require('./launcher').Launcher
  26. const FileList = require('./file-list')
  27. const reporter = require('./reporter')
  28. const helper = require('./helper')
  29. const events = require('./events')
  30. const KarmaEventEmitter = events.EventEmitter
  31. const EventEmitter = require('events').EventEmitter
  32. const Executor = require('./executor')
  33. const Browser = require('./browser')
  34. const BrowserCollection = require('./browser_collection')
  35. const EmitterWrapper = require('./emitter_wrapper')
  36. const processWrapper = new EmitterWrapper(process)
  37. function createSocketIoServer (webServer, executor, config) {
  38. const server = new SocketIO(webServer, {
  39. // avoid destroying http upgrades from socket.io to get proxied websockets working
  40. destroyUpgrade: false,
  41. path: config.urlRoot + 'socket.io/',
  42. transports: config.transports,
  43. forceJSONP: config.forceJSONP
  44. })
  45. // hack to overcome circular dependency
  46. executor.socketIoSockets = server.sockets
  47. return server
  48. }
  49. class Server extends KarmaEventEmitter {
  50. constructor (cliOptions, done) {
  51. super()
  52. logger.setupFromConfig(cliOptions)
  53. this.log = logger.create('karma-server')
  54. this.loadErrors = []
  55. const config = cfg.parseConfig(cliOptions.configFile, cliOptions)
  56. this.log.debug('Final config', JsonUtils.stringify(config, null, 2))
  57. let modules = [{
  58. helper: ['value', helper],
  59. logger: ['value', logger],
  60. done: ['value', done || process.exit],
  61. emitter: ['value', this],
  62. server: ['value', this],
  63. watcher: ['value', watcher],
  64. launcher: ['type', Launcher],
  65. config: ['value', config],
  66. preprocess: ['factory', preprocessor.createPreprocessor],
  67. fileList: ['factory', FileList.factory],
  68. webServer: ['factory', createWebServer],
  69. serveFile: ['factory', createServeFile],
  70. serveStaticFile: ['factory', createServeStaticFile],
  71. filesPromise: ['factory', createFilesPromise],
  72. readFilePromise: ['factory', createReadFilePromise],
  73. socketServer: ['factory', createSocketIoServer],
  74. executor: ['factory', Executor.factory],
  75. // TODO(vojta): remove
  76. customFileHandlers: ['value', []],
  77. // TODO(vojta): remove, once karma-dart does not rely on it
  78. customScriptTypes: ['value', []],
  79. reporter: ['factory', reporter.createReporters],
  80. capturedBrowsers: ['factory', BrowserCollection.factory],
  81. args: ['value', {}],
  82. timer: ['value', {
  83. setTimeout () {
  84. return setTimeout.apply(root, arguments)
  85. },
  86. clearTimeout
  87. }]
  88. }]
  89. this.on('load_error', (type, name) => {
  90. this.log.debug(`Registered a load error of type ${type} with name ${name}`)
  91. this.loadErrors.push([type, name])
  92. })
  93. modules = modules.concat(plugin.resolve(config.plugins, this))
  94. this._injector = new di.Injector(modules)
  95. }
  96. dieOnError (error) {
  97. this.log.error(error)
  98. process.exitCode = 1
  99. process.kill(process.pid, 'SIGINT')
  100. }
  101. async start () {
  102. const config = this.get('config')
  103. try {
  104. await Promise.all([
  105. BundleUtils.bundleResourceIfNotExist('client/main.js', 'static/karma.js'),
  106. BundleUtils.bundleResourceIfNotExist('context/main.js', 'static/context.js')
  107. ])
  108. this._boundServer = await NetUtils.bindAvailablePort(config.port, config.listenAddress)
  109. config.port = this._boundServer.address().port
  110. this._injector.invoke(this._start, this)
  111. } catch (err) {
  112. this.dieOnError(`Server start failed on port ${config.port}: ${err}`)
  113. }
  114. }
  115. get (token) {
  116. return this._injector.get(token)
  117. }
  118. refreshFiles () {
  119. return this._fileList ? this._fileList.refresh() : Promise.resolve()
  120. }
  121. refreshFile (path) {
  122. return this._fileList ? this._fileList.changeFile(path) : Promise.resolve()
  123. }
  124. _start (config, launcher, preprocess, fileList, capturedBrowsers, executor, done) {
  125. if (config.detached) {
  126. this._detach(config, done)
  127. return
  128. }
  129. this._fileList = fileList
  130. config.frameworks.forEach((framework) => this._injector.get('framework:' + framework))
  131. const webServer = this._injector.get('webServer')
  132. const socketServer = this._injector.get('socketServer')
  133. const singleRunDoneBrowsers = Object.create(null)
  134. const singleRunBrowsers = new BrowserCollection(new EventEmitter())
  135. let singleRunBrowserNotCaptured = false
  136. webServer.on('error', (err) => {
  137. this.dieOnError(`Webserver fail ${err}`)
  138. })
  139. const afterPreprocess = () => {
  140. if (config.autoWatch) {
  141. const watcher = this.get('watcher')
  142. this._injector.invoke(watcher)
  143. }
  144. webServer.listen(this._boundServer, () => {
  145. this.log.info(`Karma v${constant.VERSION} server started at ${config.protocol}//${config.listenAddress}:${config.port}${config.urlRoot}`)
  146. this.emit('listening', config.port)
  147. if (config.browsers && config.browsers.length) {
  148. this._injector.invoke(launcher.launch, launcher).forEach((browserLauncher) => {
  149. singleRunDoneBrowsers[browserLauncher.id] = false
  150. })
  151. }
  152. if (this.loadErrors.length > 0) {
  153. this.dieOnError(new Error(`Found ${this.loadErrors.length} load error${this.loadErrors.length === 1 ? '' : 's'}`))
  154. }
  155. })
  156. }
  157. fileList.refresh().then(afterPreprocess, afterPreprocess)
  158. this.on('browsers_change', () => socketServer.sockets.emit('info', capturedBrowsers.serialize()))
  159. this.on('browser_register', (browser) => {
  160. launcher.markCaptured(browser.id)
  161. if (launcher.areAllCaptured()) {
  162. this.emit('browsers_ready')
  163. if (config.autoWatch) {
  164. executor.schedule()
  165. }
  166. }
  167. })
  168. if (config.browserConsoleLogOptions && config.browserConsoleLogOptions.path) {
  169. const configLevel = config.browserConsoleLogOptions.level || 'debug'
  170. const configFormat = config.browserConsoleLogOptions.format || '%b %T: %m'
  171. const configPath = config.browserConsoleLogOptions.path
  172. this.log.info(`Writing browser console to file: ${configPath}`)
  173. const browserLogFile = fs.openSync(configPath, 'w+')
  174. const levels = ['log', 'error', 'warn', 'info', 'debug']
  175. this.on('browser_log', function (browser, message, level) {
  176. if (levels.indexOf(level.toLowerCase()) > levels.indexOf(configLevel)) {
  177. return
  178. }
  179. if (!helper.isString(message)) {
  180. message = util.inspect(message, { showHidden: false, colors: false })
  181. }
  182. const logMap = {'%m': message, '%t': level.toLowerCase(), '%T': level.toUpperCase(), '%b': browser}
  183. const logString = configFormat.replace(/%[mtTb]/g, (m) => logMap[m])
  184. this.log.debug(`Writing browser console line: ${logString}`)
  185. fs.writeSync(browserLogFile, logString + '\n')
  186. })
  187. }
  188. socketServer.sockets.on('connection', (socket) => {
  189. this.log.debug(`A browser has connected on socket ${socket.id}`)
  190. const replySocketEvents = events.bufferEvents(socket, ['start', 'info', 'karma_error', 'result', 'complete'])
  191. socket.on('complete', (data, ack) => ack())
  192. socket.on('register', (info) => {
  193. let newBrowser = info.id ? (capturedBrowsers.getById(info.id) || singleRunBrowsers.getById(info.id)) : null
  194. if (newBrowser) {
  195. // By default if a browser disconnects while still executing, we assume that the test
  196. // execution still continues because just the socket connection has been terminated. Now
  197. // since we know whether this is just a socket reconnect or full client reconnect, we
  198. // need to update the browser state accordingly. This is necessary because in case a
  199. // browser crashed and has been restarted, we need to start with a fresh execution.
  200. if (!info.isSocketReconnect) {
  201. newBrowser.setState(Browser.STATE_DISCONNECTED)
  202. }
  203. newBrowser.reconnect(socket)
  204. // Since not every reconnected browser is able to continue with its previous execution,
  205. // we need to start a new execution in case a browser has restarted and is now idling.
  206. if (newBrowser.state === Browser.STATE_CONNECTED && config.singleRun) {
  207. newBrowser.execute(config.client)
  208. }
  209. } else {
  210. newBrowser = this._injector.createChild([{
  211. id: ['value', info.id || null],
  212. fullName: ['value', (helper.isDefined(info.displayName) ? info.displayName : info.name)],
  213. socket: ['value', socket]
  214. }]).invoke(Browser.factory)
  215. newBrowser.init()
  216. if (config.singleRun) {
  217. newBrowser.execute(config.client)
  218. singleRunBrowsers.add(newBrowser)
  219. }
  220. }
  221. replySocketEvents()
  222. })
  223. })
  224. const emitRunCompleteIfAllBrowsersDone = () => {
  225. if (Object.keys(singleRunDoneBrowsers).every((key) => singleRunDoneBrowsers[key])) {
  226. this.emit('run_complete', singleRunBrowsers, singleRunBrowsers.getResults(singleRunBrowserNotCaptured, config.failOnEmptyTestSuite, config.failOnFailingTestSuite))
  227. }
  228. }
  229. this.on('browser_complete', (completedBrowser) => {
  230. if (completedBrowser.lastResult.disconnected && completedBrowser.disconnectsCount <= config.browserDisconnectTolerance) {
  231. this.log.info(`Restarting ${completedBrowser.name} (${completedBrowser.disconnectsCount} of ${config.browserDisconnectTolerance} attempts)`)
  232. if (!launcher.restart(completedBrowser.id)) {
  233. this.emit('browser_restart_failure', completedBrowser)
  234. }
  235. } else {
  236. this.emit('browser_complete_with_no_more_retries', completedBrowser)
  237. }
  238. })
  239. this.on('stop', function (done) {
  240. this.log.debug('Received stop event, exiting.')
  241. return disconnectBrowsers().then(done)
  242. })
  243. if (config.singleRun) {
  244. this.on('browser_restart_failure', (completedBrowser) => {
  245. singleRunDoneBrowsers[completedBrowser.id] = true
  246. emitRunCompleteIfAllBrowsersDone()
  247. })
  248. this.on('browser_complete_with_no_more_retries', function (completedBrowser) {
  249. singleRunDoneBrowsers[completedBrowser.id] = true
  250. if (launcher.kill(completedBrowser.id)) {
  251. // workaround to supress "disconnect" warning
  252. completedBrowser.state = Browser.STATE_DISCONNECTED
  253. }
  254. emitRunCompleteIfAllBrowsersDone()
  255. })
  256. this.on('browser_process_failure', (browserLauncher) => {
  257. singleRunDoneBrowsers[browserLauncher.id] = true
  258. singleRunBrowserNotCaptured = true
  259. emitRunCompleteIfAllBrowsersDone()
  260. })
  261. this.on('run_complete', function (browsers, results) {
  262. this.log.debug('Run complete, exiting.')
  263. disconnectBrowsers(results.exitCode)
  264. })
  265. this.emit('run_start', singleRunBrowsers)
  266. }
  267. if (config.autoWatch) {
  268. this.on('file_list_modified', () => {
  269. this.log.debug('List of files has changed, trying to execute')
  270. if (config.restartOnFileChange) {
  271. socketServer.sockets.emit('stop')
  272. }
  273. executor.schedule()
  274. })
  275. }
  276. const webServerCloseTimeout = 3000
  277. const disconnectBrowsers = (code) => {
  278. const sockets = socketServer.sockets.sockets
  279. Object.keys(sockets).forEach((id) => {
  280. const socket = sockets[id]
  281. socket.removeAllListeners('disconnect')
  282. if (!socket.disconnected) {
  283. process.nextTick(socket.disconnect.bind(socket))
  284. }
  285. })
  286. let removeAllListenersDone = false
  287. const removeAllListeners = () => {
  288. if (removeAllListenersDone) {
  289. return
  290. }
  291. removeAllListenersDone = true
  292. webServer.removeAllListeners()
  293. processWrapper.removeAllListeners()
  294. done(code || 0)
  295. }
  296. return this.emitAsync('exit').then(() => {
  297. return new Promise((resolve, reject) => {
  298. socketServer.sockets.removeAllListeners()
  299. socketServer.close()
  300. const closeTimeout = setTimeout(removeAllListeners, webServerCloseTimeout)
  301. webServer.close(() => {
  302. clearTimeout(closeTimeout)
  303. removeAllListeners()
  304. resolve()
  305. })
  306. })
  307. })
  308. }
  309. processWrapper.on('SIGINT', () => disconnectBrowsers(process.exitCode))
  310. processWrapper.on('SIGTERM', disconnectBrowsers)
  311. processWrapper.on('uncaughtException', (error) => {
  312. this.log.error(error)
  313. disconnectBrowsers(1)
  314. })
  315. processWrapper.on('unhandledRejection', (error) => {
  316. this.log.error(error)
  317. disconnectBrowsers(1)
  318. })
  319. }
  320. _detach (config, done) {
  321. const tmpFile = tmp.fileSync({ keep: true })
  322. this.log.info('Starting karma detached')
  323. this.log.info('Run "karma stop" to stop the server.')
  324. this.log.debug(`Writing config to tmp-file ${tmpFile.name}`)
  325. config.detached = false
  326. try {
  327. fs.writeFileSync(tmpFile.name, JSON.stringify(config), 'utf8')
  328. } catch (e) {
  329. this.log.error("Couldn't write temporary configuration file")
  330. done(1)
  331. return
  332. }
  333. const child = spawn(process.argv[0], [path.resolve(__dirname, '../lib/detached.js'), tmpFile.name], {
  334. detached: true,
  335. stdio: 'ignore'
  336. })
  337. child.unref()
  338. }
  339. stop () {
  340. return this.emitAsync('stop')
  341. }
  342. static start (cliOptions, done) {
  343. console.warn('Deprecated static method to be removed in v3.0')
  344. return new Server(cliOptions, done).start()
  345. }
  346. }
  347. Server.prototype._start.$inject = ['config', 'launcher', 'preprocess', 'fileList', 'capturedBrowsers', 'executor', 'done']
  348. module.exports = Server