1 | 'use strict'
|
2 |
|
3 | const SocketIO = require('socket.io')
|
4 | const di = require('di')
|
5 | const util = require('util')
|
6 | const spawn = require('child_process').spawn
|
7 | const tmp = require('tmp')
|
8 | const fs = require('fs')
|
9 | const path = require('path')
|
10 |
|
11 | const NetUtils = require('./utils/net-utils')
|
12 | const root = global || window || this
|
13 |
|
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 |
|
20 | const createServeFile = require('./web-server').createServeFile
|
21 | const createServeStaticFile = require('./web-server').createServeStaticFile
|
22 | const createFilesPromise = require('./web-server').createFilesPromise
|
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 |
|
38 | function createSocketIoServer (webServer, executor, config) {
|
39 | const server = new SocketIO(webServer, {
|
40 |
|
41 | destroyUpgrade: false,
|
42 | path: config.urlRoot + 'socket.io/',
|
43 | transports: config.transports,
|
44 | forceJSONP: config.forceJSONP,
|
45 |
|
46 | pingTimeout: config.pingTimeout || 5000
|
47 | })
|
48 |
|
49 |
|
50 | executor.socketIoSockets = server.sockets
|
51 |
|
52 | return server
|
53 | }
|
54 |
|
55 | class 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, 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 |
|
86 | customFileHandlers: ['value', []],
|
87 |
|
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 |
|
121 | socket.on('error', (err) => {
|
122 |
|
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 |
|
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 |
|
274 |
|
275 |
|
276 |
|
277 |
|
278 | if (!info.isSocketReconnect) {
|
279 | newBrowser.setState(Browser.STATE_DISCONNECTED)
|
280 | }
|
281 |
|
282 | newBrowser.reconnect(socket)
|
283 |
|
284 |
|
285 |
|
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 |
|
339 | this.on('browser_complete_with_no_more_retries', function (completedBrowser) {
|
340 | singleRunDoneBrowsers[completedBrowser.id] = true
|
341 |
|
342 | if (launcher.kill(completedBrowser.id)) {
|
343 |
|
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 |
|
464 | Server.prototype._start.$inject = ['config', 'launcher', 'preprocess', 'fileList', 'capturedBrowsers', 'executor', 'done']
|
465 |
|
466 | module.exports = Server
|