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