server.js 13 KB

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