UNPKG

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