UNPKG

15.8 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(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.
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 // TODO(vojta): remove
86 customFileHandlers: ['value', []],
87 // TODO(vojta): remove, once karma-dart does not rely on it
88 customScriptTypes: ['value', []],
89 reporter: ['factory', reporter.createReporters],
90 capturedBrowsers: ['factory', BrowserCollection.factory],
91 args: ['value', {}],
92 timer: ['value', {
93 setTimeout () {
94 return setTimeout.apply(root, arguments)
95 },
96 clearTimeout
97 }]
98 }]
99
100 this.on('load_error', (type, name) => {
101 this.log.debug(`Registered a load error of type ${type} with name ${name}`)
102 this.loadErrors.push([type, name])
103 })
104
105 modules = modules.concat(plugin.resolve(config.plugins, this))
106 this._injector = new di.Injector(modules)
107 }
108
109 dieOnError (error) {
110 this.log.error(error)
111 process.exitCode = 1
112 process.kill(process.pid, 'SIGINT')
113 }
114
115 async start () {
116 const config = this.get('config')
117 try {
118 this._boundServer = await NetUtils.bindAvailablePort(config.port, config.listenAddress)
119 this._boundServer.on('connection', (socket) => {
120 // Attach an error handler to avoid UncaughtException errors.
121 socket.on('error', (err) => {
122 // Errors on this socket are retried, ignore them
123 this.log.debug('Ignoring error on webserver connection: ' + err)
124 })
125 })
126 config.port = this._boundServer.address().port
127 await this._injector.invoke(this._start, this)
128 } catch (err) {
129 this.dieOnError(`Server start failed on port ${config.port}: ${err}`)
130 }
131 }
132
133 get (token) {
134 return this._injector.get(token)
135 }
136
137 refreshFiles () {
138 return this._fileList ? this._fileList.refresh() : Promise.resolve()
139 }
140
141 refreshFile (path) {
142 return this._fileList ? this._fileList.changeFile(path) : Promise.resolve()
143 }
144
145 emitExitAsync (code) {
146 const name = 'exit'
147 let pending = this.listeners(name).length
148 const deferred = helper.defer()
149
150 function resolve () {
151 deferred.resolve(code)
152 }
153
154 try {
155 this.emit(name, (newCode) => {
156 if (newCode && typeof newCode === 'number') {
157 // Only update code if it is given and not zero
158 code = newCode
159 }
160 if (!--pending) {
161 resolve()
162 }
163 })
164
165 if (!pending) {
166 resolve()
167 }
168 } catch (err) {
169 deferred.reject(err)
170 }
171 return deferred.promise
172 }
173
174 async _start (config, launcher, preprocess, fileList, capturedBrowsers, executor, done) {
175 if (config.detached) {
176 this._detach(config, done)
177 return
178 }
179
180 this._fileList = fileList
181
182 await Promise.all(
183 config.frameworks.map((framework) => this._injector.get('framework:' + framework))
184 )
185
186 const webServer = this._injector.get('webServer')
187 const socketServer = this._injector.get('socketServer')
188
189 const singleRunDoneBrowsers = Object.create(null)
190 const singleRunBrowsers = new BrowserCollection(new EventEmitter())
191 let singleRunBrowserNotCaptured = false
192
193 webServer.on('error', (err) => {
194 this.dieOnError(`Webserver fail ${err}`)
195 })
196
197 const afterPreprocess = () => {
198 if (config.autoWatch) {
199 const watcher = this.get('watcher')
200 this._injector.invoke(watcher)
201 }
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
206 this.emit('listening', config.port)
207 if (config.browsers && config.browsers.length) {
208 this._injector.invoke(launcher.launch, launcher).forEach((browserLauncher) => {
209 singleRunDoneBrowsers[browserLauncher.id] = false
210 })
211 }
212 if (this.loadErrors.length > 0) {
213 this.dieOnError(new Error(`Found ${this.loadErrors.length} load error${this.loadErrors.length === 1 ? '' : 's'}`))
214 }
215 })
216 }
217
218 fileList.refresh().then(afterPreprocess, (err) => {
219 this.log.error('Error during file loading or preprocessing\n' + err.stack || err)
220 afterPreprocess()
221 })
222
223 this.on('browsers_change', () => socketServer.sockets.emit('info', capturedBrowsers.serialize()))
224
225 this.on('browser_register', (browser) => {
226 launcher.markCaptured(browser.id)
227
228 if (launcher.areAllCaptured()) {
229 this.emit('browsers_ready')
230
231 if (config.autoWatch) {
232 executor.schedule()
233 }
234 }
235 })
236
237 if (config.browserConsoleLogOptions && config.browserConsoleLogOptions.path) {
238 const configLevel = config.browserConsoleLogOptions.level || 'debug'
239 const configFormat = config.browserConsoleLogOptions.format || '%b %T: %m'
240 const configPath = config.browserConsoleLogOptions.path
241 this.log.info(`Writing browser console to file: ${configPath}`)
242 const browserLogFile = fs.openSync(configPath, 'w+')
243 const levels = ['log', 'error', 'warn', 'info', 'debug']
244 this.on('browser_log', function (browser, message, level) {
245 if (levels.indexOf(level.toLowerCase()) > levels.indexOf(configLevel)) {
246 return
247 }
248 if (!helper.isString(message)) {
249 message = util.inspect(message, { showHidden: false, colors: false })
250 }
251 const logMap = { '%m': message, '%t': level.toLowerCase(), '%T': level.toUpperCase(), '%b': browser }
252 const logString = configFormat.replace(/%[mtTb]/g, (m) => logMap[m])
253 this.log.debug(`Writing browser console line: ${logString}`)
254 fs.writeSync(browserLogFile, logString + '\n')
255 })
256 }
257
258 socketServer.sockets.on('connection', (socket) => {
259 this.log.debug(`A browser has connected on socket ${socket.id}`)
260
261 const replySocketEvents = events.bufferEvents(socket, ['start', 'info', 'karma_error', 'result', 'complete'])
262
263 socket.on('complete', (data, ack) => ack())
264
265 socket.on('error', (err) => {
266 this.log.debug('karma server socket error: ' + err)
267 })
268
269 socket.on('register', (info) => {
270 let newBrowser = info.id ? (capturedBrowsers.getById(info.id) || singleRunBrowsers.getById(info.id)) : null
271
272 if (newBrowser) {
273 // By default if a browser disconnects while still executing, we assume that the test
274 // execution still continues because just the socket connection has been terminated. Now
275 // since we know whether this is just a socket reconnect or full client reconnect, we
276 // need to update the browser state accordingly. This is necessary because in case a
277 // browser crashed and has been restarted, we need to start with a fresh execution.
278 if (!info.isSocketReconnect) {
279 newBrowser.setState(Browser.STATE_DISCONNECTED)
280 }
281
282 newBrowser.reconnect(socket)
283
284 // Since not every reconnected browser is able to continue with its previous execution,
285 // we need to start a new execution in case a browser has restarted and is now idling.
286 if (newBrowser.state === Browser.STATE_CONNECTED && config.singleRun) {
287 newBrowser.execute(config.client)
288 }
289 } else {
290 newBrowser = this._injector.createChild([{
291 id: ['value', info.id || null],
292 fullName: ['value', (helper.isDefined(info.displayName) ? info.displayName : info.name)],
293 socket: ['value', socket]
294 }]).invoke(Browser.factory)
295
296 newBrowser.init()
297
298 if (config.singleRun) {
299 newBrowser.execute(config.client)
300 singleRunBrowsers.add(newBrowser)
301 }
302 }
303
304 replySocketEvents()
305 })
306 })
307
308 const emitRunCompleteIfAllBrowsersDone = () => {
309 if (Object.keys(singleRunDoneBrowsers).every((key) => singleRunDoneBrowsers[key])) {
310 this.emit('run_complete', singleRunBrowsers, singleRunBrowsers.getResults(singleRunBrowserNotCaptured, config))
311 }
312 }
313
314 this.on('browser_complete', (completedBrowser) => {
315 if (completedBrowser.lastResult.disconnected && completedBrowser.disconnectsCount <= config.browserDisconnectTolerance) {
316 this.log.info(`Restarting ${completedBrowser.name} (${completedBrowser.disconnectsCount} of ${config.browserDisconnectTolerance} attempts)`)
317
318 if (!launcher.restart(completedBrowser.id)) {
319 this.emit('browser_restart_failure', completedBrowser)
320 }
321 } else {
322 this.emit('browser_complete_with_no_more_retries', completedBrowser)
323 }
324 })
325
326 this.on('stop', function (done) {
327 this.log.debug('Received stop event, exiting.')
328 disconnectBrowsers()
329 done()
330 })
331
332 if (config.singleRun) {
333 this.on('browser_restart_failure', (completedBrowser) => {
334 singleRunDoneBrowsers[completedBrowser.id] = true
335 emitRunCompleteIfAllBrowsersDone()
336 })
337
338 // This is the normal exit trigger.
339 this.on('browser_complete_with_no_more_retries', function (completedBrowser) {
340 singleRunDoneBrowsers[completedBrowser.id] = true
341
342 if (launcher.kill(completedBrowser.id)) {
343 // workaround to supress "disconnect" warning
344 completedBrowser.state = Browser.STATE_DISCONNECTED
345 }
346
347 emitRunCompleteIfAllBrowsersDone()
348 })
349
350 this.on('browser_process_failure', (browserLauncher) => {
351 singleRunDoneBrowsers[browserLauncher.id] = true
352 singleRunBrowserNotCaptured = true
353
354 emitRunCompleteIfAllBrowsersDone()
355 })
356
357 this.on('run_complete', function (browsers, results) {
358 this.log.debug('Run complete, exiting.')
359 disconnectBrowsers(results.exitCode)
360 })
361
362 this.emit('run_start', singleRunBrowsers)
363 }
364
365 if (config.autoWatch) {
366 this.on('file_list_modified', () => {
367 this.log.debug('List of files has changed, trying to execute')
368 if (config.restartOnFileChange) {
369 socketServer.sockets.emit('stop')
370 }
371 executor.schedule()
372 })
373 }
374
375 const webServerCloseTimeout = 3000
376 const disconnectBrowsers = (code) => {
377 const sockets = socketServer.sockets.sockets
378
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
387 this.emitExitAsync(code).catch((err) => {
388 this.log.error('Error while calling exit event listeners\n' + err.stack || err)
389 return 1
390 }).then((code) => {
391 socketServer.sockets.removeAllListeners()
392 socketServer.close()
393
394 let removeAllListenersDone = false
395 const removeAllListeners = () => {
396 if (removeAllListenersDone) {
397 return
398 }
399 removeAllListenersDone = true
400 webServer.removeAllListeners()
401 processWrapper.removeAllListeners()
402 done(code || 0)
403 }
404
405 const closeTimeout = setTimeout(removeAllListeners, webServerCloseTimeout)
406
407 webServer.close(() => {
408 clearTimeout(closeTimeout)
409 removeAllListeners()
410 })
411 })
412 }
413
414 processWrapper.on('SIGINT', () => disconnectBrowsers(process.exitCode))
415 processWrapper.on('SIGTERM', disconnectBrowsers)
416
417 const reportError = (error) => {
418 process.emit('infrastructure_error', error)
419 disconnectBrowsers(1)
420 this.log.error(error)
421 }
422
423 processWrapper.on('unhandledRejection', (error) => {
424 this.log.error(`UnhandledRejection: ${error.message || String(error)}`)
425 reportError(error)
426 })
427
428 processWrapper.on('uncaughtException', (error) => {
429 this.log.error(`UncaughtException:: ${error.message || String(error)}`)
430 reportError(error)
431 })
432 }
433
434 _detach (config, done) {
435 const tmpFile = tmp.fileSync({ keep: true })
436 this.log.info('Starting karma detached')
437 this.log.info('Run "karma stop" to stop the server.')
438 this.log.debug(`Writing config to tmp-file ${tmpFile.name}`)
439 config.detached = false
440 try {
441 fs.writeFileSync(tmpFile.name, JSON.stringify(config), 'utf8')
442 } catch (e) {
443 this.log.error("Couldn't write temporary configuration file")
444 done(1)
445 return
446 }
447 const child = spawn(process.argv[0], [path.resolve(__dirname, '../lib/detached.js'), tmpFile.name], {
448 detached: true,
449 stdio: 'ignore'
450 })
451 child.unref()
452 }
453
454 stop () {
455 return this.emitAsync('stop')
456 }
457
458 static start (cliOptions, done) {
459 console.warn('Deprecated static method to be removed in v3.0')
460 return new Server(cliOptions, done).start()
461 }
462}
463
464Server.prototype._start.$inject = ['config', 'launcher', 'preprocess', 'fileList', 'capturedBrowsers', 'executor', 'done']
465
466module.exports = Server