UNPKG

15.3 kBJavaScriptView Raw
1var SocketIO = require('socket.io')
2var di = require('di')
3var util = require('util')
4var Promise = require('bluebird')
5var spawn = require('child_process').spawn
6var tmp = require('tmp')
7var fs = require('fs')
8var path = require('path')
9var root = global || window || this
10
11var cfg = require('./config')
12var logger = require('./logger')
13var constant = require('./constants')
14var watcher = require('./watcher')
15var plugin = require('./plugin')
16
17var ws = require('./web-server')
18var preprocessor = require('./preprocessor')
19var Launcher = require('./launcher').Launcher
20var FileList = require('./file-list')
21var reporter = require('./reporter')
22var helper = require('./helper')
23var events = require('./events')
24var EventEmitter = events.EventEmitter
25var Executor = require('./executor')
26var Browser = require('./browser')
27var BrowserCollection = require('./browser_collection')
28var EmitterWrapper = require('./emitter_wrapper')
29var processWrapper = new EmitterWrapper(process)
30var browserify = require('browserify')
31
32const karmaJsPath = path.join(__dirname, '/../static/karma.js')
33const contextJsPath = path.join(__dirname, '/../static/context.js')
34
35/**
36 * Bundles a static resource using Browserify.
37 * @param {string} inPath the path to the file to browserify
38 * @param {string} outPath the path to output the bundle to
39 * @returns {Promise}
40 */
41function bundleResource (inPath, outPath) {
42 return new Promise((resolve, reject) => {
43 var bundler = browserify(inPath)
44 bundler.bundle().pipe(fs.createWriteStream(outPath))
45 .once('finish', () => {
46 resolve()
47 })
48 .once('error', (e) => {
49 reject(e)
50 })
51 })
52}
53
54function createSocketIoServer (webServer, executor, config) {
55 var server = new SocketIO(webServer, {
56 // avoid destroying http upgrades from socket.io to get proxied websockets working
57 destroyUpgrade: false,
58 path: config.urlRoot + 'socket.io/',
59 transports: config.transports,
60 forceJSONP: config.forceJSONP
61 })
62
63 // hack to overcome circular dependency
64 executor.socketIoSockets = server.sockets
65
66 return server
67}
68
69// Constructor
70var Server = function (cliOptions, done) {
71 EventEmitter.call(this)
72
73 logger.setupFromConfig(cliOptions)
74
75 this.log = logger.create()
76
77 this.loadErrors = []
78
79 var config = cfg.parseConfig(cliOptions.configFile, cliOptions)
80
81 var modules = [{
82 helper: ['value', helper],
83 logger: ['value', logger],
84 done: ['value', done || process.exit],
85 emitter: ['value', this],
86 server: ['value', this],
87 launcher: ['type', Launcher],
88 config: ['value', config],
89 preprocess: ['factory', preprocessor.createPreprocessor],
90 fileList: ['type', FileList],
91 webServer: ['factory', ws.create],
92 socketServer: ['factory', createSocketIoServer],
93 executor: ['type', Executor],
94 // TODO(vojta): remove
95 customFileHandlers: ['value', []],
96 // TODO(vojta): remove, once karma-dart does not rely on it
97 customScriptTypes: ['value', []],
98 reporter: ['factory', reporter.createReporters],
99 capturedBrowsers: ['type', BrowserCollection],
100 args: ['value', {}],
101 timer: ['value', {
102 setTimeout: function () {
103 return setTimeout.apply(root, arguments)
104 },
105 clearTimeout: function (timeoutId) {
106 clearTimeout(timeoutId)
107 }
108 }]
109 }]
110
111 this._setUpLoadErrorListener()
112 // Load the plugins
113 modules = modules.concat(plugin.resolve(config.plugins, this))
114
115 this._injector = new di.Injector(modules)
116}
117
118// Inherit from events.EventEmitter
119util.inherits(Server, EventEmitter)
120
121// Public Methods
122// --------------
123
124// Start the server
125Server.prototype.start = function () {
126 this._injector.invoke(this._start, this)
127}
128
129/**
130 * Backward-compatibility with karma-intellij bundled with WebStorm.
131 * Deprecated since version 0.13, to be removed in 0.14
132 */
133Server.start = function (cliOptions, done) {
134 var server = new Server(cliOptions, done)
135 server.start()
136}
137
138// Get properties from the injector
139//
140// token - String
141Server.prototype.get = function (token) {
142 return this._injector.get(token)
143}
144
145// Force a refresh of the file list
146Server.prototype.refreshFiles = function () {
147 if (!this._fileList) return Promise.resolve()
148
149 return this._fileList.refresh()
150}
151
152// Private Methods
153// ---------------
154
155Server.prototype._start = function (config, launcher, preprocess, fileList,
156 capturedBrowsers, executor, done) {
157 var self = this
158 if (config.detached) {
159 this._detach(config, done)
160 return
161 }
162
163 self._fileList = fileList
164
165 config.frameworks.forEach(function (framework) {
166 self._injector.get('framework:' + framework)
167 })
168
169 var webServer = self._injector.get('webServer')
170 var socketServer = self._injector.get('socketServer')
171
172 // A map of launched browsers.
173 var singleRunDoneBrowsers = Object.create(null)
174
175 // Passing fake event emitter, so that it does not emit on the global,
176 // we don't care about these changes.
177 var singleRunBrowsers = new BrowserCollection(new EventEmitter())
178
179 // Some browsers did not get captured.
180 var singleRunBrowserNotCaptured = false
181
182 webServer.on('error', function (e) {
183 if (e.code === 'EADDRINUSE') {
184 self.log.warn('Port %d in use', config.port)
185 config.port++
186 webServer.listen(config.port, config.listenAddress)
187 } else {
188 throw e
189 }
190 })
191
192 var afterPreprocess = function () {
193 if (config.autoWatch) {
194 self._injector.invoke(watcher.watch)
195 }
196
197 var startWebServer = function () {
198 webServer.listen(config.port, config.listenAddress, function () {
199 self.log.info('Karma v%s server started at %s//%s:%s%s', constant.VERSION,
200 config.protocol, config.listenAddress, config.port, config.urlRoot)
201
202 self.emit('listening', config.port)
203 if (config.browsers && config.browsers.length) {
204 self._injector.invoke(launcher.launch, launcher).forEach(function (browserLauncher) {
205 singleRunDoneBrowsers[browserLauncher.id] = false
206 })
207 }
208 var noLoadErrors = self.loadErrors.length
209 if (noLoadErrors > 0) {
210 self.log.error('Found %d load error%s', noLoadErrors, noLoadErrors === 1 ? '' : 's')
211 process.exitCode = 1
212 process.kill(process.pid, 'SIGINT')
213 }
214 })
215 }
216
217 // Check if the static files haven't been compiled
218 if (!(fs.existsSync(karmaJsPath) && fs.existsSync(contextJsPath))) {
219 self.log.info('Front-end scripts not present. Compiling...')
220 var mainPromise = bundleResource(path.join(__dirname, '/../client/main.js'), karmaJsPath)
221 var contextPromise = bundleResource(path.join(__dirname, '/../context/main.js'), contextJsPath)
222 Promise.all([mainPromise, contextPromise]).then(() => {
223 startWebServer()
224 }).catch((error) => {
225 self.log.error('Front-end script compile failed with error: ' + error)
226 process.exitCode = 1
227 process.kill(process.pid, 'SIGINT')
228 })
229 } else {
230 startWebServer()
231 }
232 }
233
234 fileList.refresh().then(afterPreprocess, afterPreprocess)
235
236 self.on('browsers_change', function () {
237 // TODO(vojta): send only to interested browsers
238 socketServer.sockets.emit('info', capturedBrowsers.serialize())
239 })
240
241 self.on('browser_register', function (browser) {
242 launcher.markCaptured(browser.id)
243
244 // TODO(vojta): This is lame, browser can get captured and then
245 // crash (before other browsers get captured).
246 if (launcher.areAllCaptured()) {
247 self.emit('browsers_ready')
248
249 if (config.autoWatch) {
250 executor.schedule()
251 }
252 }
253 })
254
255 if (config.browserConsoleLogOptions && config.browserConsoleLogOptions.path) {
256 var configLevel = config.browserConsoleLogOptions.level || 'debug'
257 var configFormat = config.browserConsoleLogOptions.format || '%b %T: %m'
258 var configPath = config.browserConsoleLogOptions.path
259 self.log.info('Writing browser console to file: %s', configPath)
260 var browserLogFile = fs.openSync(configPath, 'w+')
261 var levels = ['log', 'error', 'warn', 'info', 'debug']
262 self.on('browser_log', function (browser, message, level) {
263 if (levels.indexOf(level.toLowerCase()) > levels.indexOf(configLevel)) return
264 if (!helper.isString(message)) {
265 message = util.inspect(message, {showHidden: false, colors: false})
266 }
267 var logMap = {'%m': message, '%t': level.toLowerCase(), '%T': level.toUpperCase(), '%b': browser}
268 var logString = configFormat.replace(/%[mtTb]/g, function (m) {
269 return logMap[m]
270 })
271 self.log.debug('Writing browser console line: %s', logString)
272 fs.write(browserLogFile, logString + '\n')
273 })
274 }
275
276 var EVENTS_TO_REPLY = ['start', 'info', 'karma_error', 'result', 'complete']
277 socketServer.sockets.on('connection', function (socket) {
278 self.log.debug('A browser has connected on socket ' + socket.id)
279
280 var replySocketEvents = events.bufferEvents(socket, EVENTS_TO_REPLY)
281
282 socket.on('complete', function (data, ack) {
283 ack()
284 })
285
286 socket.on('register', function (info) {
287 var newBrowser
288 var isRestart
289
290 if (info.id) {
291 newBrowser = capturedBrowsers.getById(info.id) || singleRunBrowsers.getById(info.id)
292 }
293
294 if (newBrowser) {
295 isRestart = newBrowser.state === Browser.STATE_DISCONNECTED
296 newBrowser.reconnect(socket)
297
298 // We are restarting a previously disconnected browser.
299 if (isRestart && config.singleRun) {
300 newBrowser.execute(config.client)
301 }
302 } else {
303 newBrowser = self._injector.createChild([{
304 id: ['value', info.id || null],
305 fullName: ['value', (helper.isDefined(info.displayName) ? info.displayName : info.name)],
306 socket: ['value', socket]
307 }]).instantiate(Browser)
308
309 newBrowser.init()
310
311 // execute in this browser immediately
312 if (config.singleRun) {
313 newBrowser.execute(config.client)
314 singleRunBrowsers.add(newBrowser)
315 }
316 }
317
318 replySocketEvents()
319 })
320 })
321
322 var emitRunCompleteIfAllBrowsersDone = function () {
323 // all browsers done
324 var isDone = Object.keys(singleRunDoneBrowsers).reduce(function (isDone, id) {
325 return isDone && singleRunDoneBrowsers[id]
326 }, true)
327
328 if (isDone) {
329 var results = singleRunBrowsers.getResults()
330 if (singleRunBrowserNotCaptured) {
331 results.exitCode = 1
332 } else if (results.success + results.failed === 0 && !config.failOnEmptyTestSuite) {
333 results.exitCode = 0
334 self.log.warn('Test suite was empty.')
335 }
336 self.emit('run_complete', singleRunBrowsers, results)
337 }
338 }
339
340 self.on('browser_complete', function (completedBrowser) {
341 if (completedBrowser.lastResult.disconnected &&
342 completedBrowser.disconnectsCount <= config.browserDisconnectTolerance) {
343 self.log.info('Restarting %s (%d of %d attempts)', completedBrowser.name,
344 completedBrowser.disconnectsCount, config.browserDisconnectTolerance)
345
346 if (!launcher.restart(completedBrowser.id)) {
347 self.emit('browser_restart_failure', completedBrowser)
348 }
349 } else {
350 self.emit('browser_complete_with_no_more_retries', completedBrowser)
351 }
352 })
353
354 if (config.singleRun) {
355 self.on('browser_restart_failure', function (completedBrowser) {
356 singleRunDoneBrowsers[completedBrowser.id] = true
357 emitRunCompleteIfAllBrowsersDone()
358 })
359 self.on('browser_complete_with_no_more_retries', function (completedBrowser) {
360 singleRunDoneBrowsers[completedBrowser.id] = true
361
362 if (launcher.kill(completedBrowser.id)) {
363 // workaround to supress "disconnect" warning
364 completedBrowser.state = Browser.STATE_DISCONNECTED
365 }
366
367 emitRunCompleteIfAllBrowsersDone()
368 })
369
370 self.on('browser_process_failure', function (browserLauncher) {
371 singleRunDoneBrowsers[browserLauncher.id] = true
372 singleRunBrowserNotCaptured = true
373
374 emitRunCompleteIfAllBrowsersDone()
375 })
376
377 self.on('run_complete', function (browsers, results) {
378 self.log.debug('Run complete, exiting.')
379 disconnectBrowsers(results.exitCode)
380 })
381
382 self.emit('run_start', singleRunBrowsers)
383 }
384
385 if (config.autoWatch) {
386 self.on('file_list_modified', function () {
387 self.log.debug('List of files has changed, trying to execute')
388 if (config.restartOnFileChange) {
389 socketServer.sockets.emit('stop')
390 }
391 executor.schedule()
392 })
393 }
394
395 var webServerCloseTimeout = 3000
396 var disconnectBrowsers = function (code) {
397 // Slightly hacky way of removing disconnect listeners
398 // to suppress "browser disconnect" warnings
399 // TODO(vojta): change the client to not send the event (if disconnected by purpose)
400 var sockets = socketServer.sockets.sockets
401
402 Object.keys(sockets).forEach(function (id) {
403 var socket = sockets[id]
404 socket.removeAllListeners('disconnect')
405 if (!socket.disconnected) {
406 // Disconnect asynchronously. Socket.io mutates the `sockets.sockets` array
407 // underneath us so this would skip every other browser/socket.
408 process.nextTick(socket.disconnect.bind(socket))
409 }
410 })
411
412 var removeAllListenersDone = false
413 var removeAllListeners = function () {
414 // make sure we don't execute cleanup twice
415 if (removeAllListenersDone) {
416 return
417 }
418 removeAllListenersDone = true
419 webServer.removeAllListeners()
420 processWrapper.removeAllListeners()
421 done(code || 0)
422 }
423
424 self.emitAsync('exit').then(function () {
425 // don't wait forever on webServer.close() because
426 // pending client connections prevent it from closing.
427 var closeTimeout = setTimeout(removeAllListeners, webServerCloseTimeout)
428
429 // shutdown the server...
430 webServer.close(function () {
431 clearTimeout(closeTimeout)
432 removeAllListeners()
433 })
434 })
435 }
436
437 processWrapper.on('SIGINT', function () {
438 disconnectBrowsers(process.exitCode)
439 })
440 processWrapper.on('SIGTERM', disconnectBrowsers)
441
442 // Handle all unhandled exceptions, so we don't just exit but
443 // disconnect the browsers before exiting.
444 processWrapper.on('uncaughtException', function (error) {
445 self.log.error(error)
446 disconnectBrowsers(1)
447 })
448}
449
450Server.prototype._setUpLoadErrorListener = function () {
451 var self = this
452 self.on('load_error', function (type, name) {
453 self.log.debug('Registered a load error of type %s with name %s', type, name)
454 self.loadErrors.push([type, name])
455 })
456}
457
458Server.prototype._detach = function (config, done) {
459 var log = this.log
460 var tmpFile = tmp.fileSync({keep: true})
461 log.info('Starting karma detached')
462 log.info('Run "karma stop" to stop the server.')
463 log.debug('Writing config to tmp-file %s', tmpFile.name)
464 config.detached = false
465 try {
466 fs.writeFileSync(tmpFile.name, JSON.stringify(config), 'utf8')
467 } catch (e) {
468 log.error("Couldn't write temporary configuration file")
469 done(1)
470 return
471 }
472 var child = spawn(process.argv[0], [path.resolve(__dirname, '../lib/detached.js'), tmpFile.name], {
473 detached: true,
474 stdio: 'ignore'
475 })
476 child.unref()
477}
478
479// Export
480// ------
481
482module.exports = Server