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.Server(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 | 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 |
|
111 | socket.on('error', (err) => {
|
112 |
|
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 |
|
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 |
|
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 |
|
392 |
|
393 |
|
394 |
|
395 |
|
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 |
|
445 | Server.prototype._start.$inject = ['config', 'launcher', 'preprocess', 'fileList', 'capturedBrowsers', 'executor', 'done']
|
446 |
|
447 | module.exports = Server
|