Skip to content

Commit 4c9097a

Browse files
authoredMar 23, 2021
feat: support asynchronous config.set() call in karma.conf.js (#3660)
The existing sync behavior co-exists with the new async behavior. * add promise support to `parseConfig` * add async config support to `cli`, `runner`, and `stopper` * Additional API for `Server()` accepting parsed config. Older API is deprecated. * update documentation for parseConfig * add warning for deprecated use of CLI options * update Server constructor, runner, and stopper docs
1 parent d3ff91a commit 4c9097a

File tree

9 files changed

+923
-115
lines changed

9 files changed

+923
-115
lines changed
 

‎docs/dev/04-public-api.md

+258-31
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,18 @@ You can, however, call Karma programmatically from your node module. Here is the
44

55
## karma.Server(options, [callback=process.exit])
66

7-
### Constructor
7+
### `constructor`
8+
9+
- **Returns:** `Server` instance.
10+
11+
#### Usage
12+
13+
Notice the capital 'S' on `require('karma').Server`.
14+
15+
##### Deprecated Behavior
16+
17+
The following still works, but the way it behaves is deprecated and will be
18+
changed in a future major version.
819

920
```javascript
1021
var Server = require('karma').Server
@@ -15,25 +26,45 @@ var server = new Server(karmaConfig, function(exitCode) {
1526
})
1627
```
1728

18-
Notice the capital 'S' on `require('karma').Server`.
29+
##### New Behavior
30+
31+
```javascript
32+
const karma = require('karma')
33+
const parseConfig = karma.config.parseConfig
34+
const Server = karma.Server
35+
36+
parseConfig(
37+
null,
38+
{ port: 9876 },
39+
{ promiseConfig: true, throwErrors: true }
40+
).then(
41+
(karmaConfig) => {
42+
const server = new Server(karmaConfig, function doneCallback(exitCode) {
43+
console.log('Karma has exited with ' + exitCode)
44+
process.exit(exitCode)
45+
})
46+
},
47+
(rejectReason) => { /* respond to the rejection reason error */ }
48+
);
49+
```
1950

20-
### **server.start()**
51+
### `server.start()`
2152

2253
Equivalent of `karma start`.
2354

2455
```javascript
2556
server.start()
2657
```
2758

28-
### **server.refreshFiles()**
59+
### `server.refreshFiles()`
2960

3061
Trigger a file list refresh. Returns a promise.
3162

3263
```javascript
3364
server.refreshFiles()
3465
```
3566

36-
### **server.refreshFile(path)**
67+
### `server.refreshFile(path)`
3768

3869
Trigger a file refresh. Returns a promise.
3970

@@ -117,10 +148,19 @@ This event gets triggered whenever all the browsers, which belong to a test run,
117148

118149
## karma.runner
119150

120-
### **runner.run(options, [callback=process.exit])**
151+
### `runner.run(options, [callback=process.exit])`
152+
153+
- **Returns:** `EventEmitter`
121154

122155
The equivalent of `karma run`.
123156

157+
#### Usage
158+
159+
##### Deprecated Behavior
160+
161+
The following still works, but the way it behaves is deprecated and will be
162+
changed in a future major version.
163+
124164
```javascript
125165
var runner = require('karma').runner
126166
runner.run({port: 9876}, function(exitCode) {
@@ -129,6 +169,35 @@ runner.run({port: 9876}, function(exitCode) {
129169
})
130170
```
131171

172+
##### New Behavior
173+
174+
```javascript
175+
const karma = require('karma')
176+
177+
karma.config.parseConfig(
178+
null,
179+
{ port: 9876 },
180+
{ promiseConfig: true, throwErrors: true }
181+
).then(
182+
(karmaConfig) => {
183+
karma.runner.run(karmaConfig, function doneCallback(exitCode, possibleErrorCode) {
184+
console.log('Karma has exited with ' + exitCode)
185+
process.exit(exitCode)
186+
})
187+
},
188+
(rejectReason) => { /* respond to the rejection reason error */ }
189+
);
190+
```
191+
192+
#### `callback` argument
193+
194+
The callback receives the exit code as the first argument.
195+
196+
If there is an error, the error code will be provided as the second parameter to
197+
the error callback.
198+
199+
#### runner Events
200+
132201
`runner.run()` returns an `EventEmitter` which emits a `progress` event passing
133202
the reporter output as a `Buffer` object.
134203

@@ -142,9 +211,17 @@ runner.run({port: 9876}).on('progress', function(data) {
142211

143212
## karma.stopper
144213

145-
### **stopper.stop(options, [callback=process.exit])**
214+
### `stopper.stop(options, [callback=process.exit])`
215+
216+
This function will signal a running server to stop. The equivalent of
217+
`karma stop`.
218+
219+
#### Usage
220+
221+
##### Deprecated Behavior
146222

147-
This function will signal a running server to stop. The equivalent of `karma stop`.
223+
The following still works, but the way it behaves is deprecated and will be
224+
changed in a future major version.
148225

149226
```javascript
150227
var stopper = require('karma').stopper
@@ -156,78 +233,228 @@ stopper.stop({port: 9876}, function(exitCode) {
156233
})
157234
```
158235

159-
## karma.config.parseConfig([configFilePath], [cliOptions])
236+
##### New Behavior
237+
238+
```javascript
239+
const karma = require('karma')
240+
241+
karma.config.parseConfig(
242+
null,
243+
{ port: 9876 },
244+
{ promiseConfig: true, throwErrors: true }
245+
).then(
246+
(karmaConfig) => {
247+
karma.stopper.stop(karmaConfig, function doneCallback(exitCode, possibleErrorCode) {
248+
if (exitCode === 0) {
249+
console.log('Server stop as initiated')
250+
}
251+
process.exit(exitCode)
252+
})
253+
},
254+
(rejectReason) => { /* respond to the rejection reason error */ }
255+
);
256+
```
257+
258+
#### `callback` argument
259+
260+
The callback receives the exit code as the first argument.
261+
262+
If there is an error, the error code will be provided as the second parameter to
263+
the error callback.
264+
265+
## karma.config
266+
267+
### `config.parseConfig([configFilePath], [cliOptions], [parseOptions])`
160268

161269
This function will load given config file and returns a filled config object.
162270
This can be useful if you want to integrate karma into another tool and want to load
163-
the karma config while honoring the karma defaults. For example, the [stryker-karma-runner](https://github.com/stryker-mutator/stryker-karma-runner)
164-
uses this to load your karma configuration and use that in the stryker configuration.
271+
the karma config while honoring the karma defaults.
272+
273+
#### Usage
274+
275+
##### Deprecated Behavior
276+
277+
The following still works, but the way it behaves is deprecated and will be
278+
changed in a future major version.
279+
280+
```javascript
281+
const cfg = require('karma').config;
282+
const path = require('path');
283+
// Read karma.conf.js, but override port with 1337
284+
const karmaConfig = cfg.parseConfig(
285+
path.resolve('./karma.conf.js'),
286+
{ port: 1337 }
287+
);
288+
```
289+
290+
The new behavior in the future will involve throwing exceptions instead of
291+
exiting the process and aynchronous config files will be supported through the
292+
use of promises.
293+
294+
##### New Behavior
165295

166296
```javascript
167297
const cfg = require('karma').config;
168298
const path = require('path');
169299
// Read karma.conf.js, but override port with 1337
170-
const karmaConfig = cfg.parseConfig(path.resolve('./karma.conf.js'), { port: 1337 } );
300+
cfg.parseConfig(
301+
path.resolve('./karma.conf.js'),
302+
{ port: 1337 },
303+
{ promiseConfig: true, throwErrors: true }
304+
).then(
305+
(karmaConfig) => { /* use the config with the public API */ },
306+
(rejectReason) => { /* respond to the rejection reason error */ }
307+
);
171308
```
172309

173-
## karma.constants
174310

175-
### **constants.VERSION**
311+
#### `configFilePath` argument
312+
313+
- **Type:** String | `null` | `undefined`
314+
- **Default Value:** `undefined`
315+
316+
A string representing a file system path pointing to the config file whose
317+
default export is a function that will be used to set Karma configuration
318+
options. This function will be passed an instance of the `Config` class as its
319+
first argument. If this option is not provided, then only the options provided
320+
by the `cliOptions` argument will be set.
321+
322+
- JavaScript must use CommonJS modules.
323+
- ECMAScript modules are not currently supported by Karma when using
324+
JavaScript.
325+
- Other formats, such as TypeScript, may support ECMAScript modules.
326+
327+
328+
#### `cliOptions` argument
329+
330+
- **Type:** Object | `null` | `undefined`
331+
- **Default Value:** `undefined`
332+
333+
An object whose values will take priority over options set in the config file.
334+
The config object passed to function exported by the config file will already
335+
have these options applied. Any changes the config file makes to these options
336+
will effectively be ignored in the final configuration.
337+
338+
Supports all the same options as the config file and is applied using the same
339+
`config.set()` method.
340+
341+
The expected source of this argument is parsed command line options, but
342+
programatic users may construct this object or leave it out entirely.
343+
344+
345+
#### `parseOptions` argument
346+
347+
- **Type:** Object | `null` | `undefined`
348+
- **Default Value:** `undefined`
349+
350+
`parseOptions` is an object whose properties are configuration options that
351+
allow additional control over parsing and opt-in access to new behaviors or
352+
features.
353+
354+
These options are only related to parsing configuration files and object and are
355+
not related to the configuration of Karma itself.
356+
357+
358+
##### `parseOptions.promiseConfig` option
359+
360+
- **Type:** Boolean
361+
- **Default Value:** `false`
362+
363+
When `parseOptions.promiseConfig === true`, then `parseConfig` will return a
364+
promise instead of a configuration object.
365+
366+
When this option is `true`, then the function exported by the config file may
367+
return a promise. The resolution of that promise indicates that all asynchronous
368+
activity has been completed. Internally, the resolved/fulfilled value is
369+
ignored. As with synchronous usage, all changes to the config object must be
370+
done with the `config.set()` method.
371+
372+
If the function exported by the config file does not return a promise, then
373+
parsing is completed and an immediately fulfilled promise is returned.
374+
375+
Whether the function exported by the config file returns a promise or not, the
376+
promise returned by `parseConfig()` will resolve with a parsed configuration
377+
object, an instance of the `Config` class, as the value.
378+
379+
_**In most cases, `parseOptions.throwErrors = true` should also be set. This
380+
disables process exiting and allows errors to result in rejected promises.**_
381+
382+
383+
##### `parseOptions.throwErrors` option
384+
385+
- **Type:** Boolean
386+
- **Default Value:** `false`
387+
388+
In the past, `parseConfig()` would call `process.exit(exitCode)` when it
389+
encountered a critical failure. This meant that your own code had no way of
390+
responding to failures before the Node.js process exited.
391+
392+
By passing `parseOptions.throwErrors = true`, `parseConfig()` will disable
393+
process exiting.
394+
395+
For synchronous usage, it will throw an exception instead of exiting the
396+
process. Your code can then catch the exception and respond how ever it needs
397+
to.
398+
399+
If the asynchronous API (`parseOptions.promiseConfig = true`) is being used,
400+
then `parseOptions.throwErrors = true` allows the promise to be rejected
401+
instead of exiting the process.
402+
403+
404+
## `karma.constants`
405+
406+
### `constants.VERSION`
176407

177408
The current version of karma
178409

179-
### **constants.DEFAULT_PORT**
410+
### `constants.DEFAULT_PORT`
180411

181412
The default port used for the karma server
182413

183-
### **constants.DEFAULT_HOSTNAME**
414+
### `constants.DEFAULT_HOSTNAME`
184415

185416
The default hostname used for the karma server
186417

187-
### **constants.DEFAULT_LISTEN_ADDR**
418+
### `constants.DEFAULT_LISTEN_ADDR`
188419

189420
The default address use for the karma server to listen on
190421

191-
### **constants.LOG_DISABLE**
422+
### `constants.LOG_DISABLE`
192423

193424
The value for disabling logs
194425

195-
### **constants.LOG_ERROR**
426+
### `constants.LOG_ERROR`
196427

197428
The value for the log `error` level
198429

199-
### **constants.LOG_WARN**
430+
### `constants.LOG_WARN`
200431

201432
The value for the log `warn` level
202433

203-
### **constants.LOG_INFO**
434+
### `constants.LOG_INFO`
204435

205436
The value for the log `info` level
206437

207-
### **constants.LOG_DEBUG**
438+
### `constants.LOG_DEBUG`
208439

209440
The value for the log `debug` level
210441

211-
### **constants.LOG_PRIORITIES**
442+
### `constants.LOG_PRIORITIES`
212443

213444
An array of log levels in descending order, i.e. `LOG_DISABLE`, `LOG_ERROR`, `LOG_WARN`, `LOG_INFO`, and `LOG_DEBUG`
214445

215-
### **constants.COLOR_PATTERN**
446+
### `constants.COLOR_PATTERN`
216447

217448
The default color pattern for log output
218449

219-
### **constants.NO_COLOR_PATTERN**
450+
### `constants.NO_COLOR_PATTERN`
220451

221452
The default pattern for log output without color
222453

223-
### **constants.CONSOLE_APPENDER**
454+
### `constants.CONSOLE_APPENDER`
224455

225456
The default console appender
226457

227-
### **constants.EXIT_CODE**
458+
### `constants.EXIT_CODE`
228459

229460
The exit code
230-
231-
## Callback function notes
232-
233-
- If there is an error, the error code will be provided as the second parameter to the error callback.

‎lib/cli.js

+45-21
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const fs = require('graceful-fs')
77
const Server = require('./server')
88
const helper = require('./helper')
99
const constant = require('./constants')
10+
const cfg = require('./config')
1011

1112
function processArgs (argv, options, fs, path) {
1213
Object.getOwnPropertyNames(argv).forEach(function (name) {
@@ -277,27 +278,50 @@ exports.process = () => {
277278
return processArgs(argv, { cmd: argv._.shift() }, fs, path)
278279
}
279280

280-
exports.run = () => {
281-
const config = exports.process()
282-
283-
switch (config.cmd) {
284-
case 'start':
285-
new Server(config).start()
286-
break
287-
case 'run':
288-
require('./runner')
289-
.run(config)
290-
.on('progress', printRunnerProgress)
291-
break
292-
case 'stop':
293-
require('./stopper').stop(config)
294-
break
295-
case 'init':
296-
require('./init').init(config)
297-
break
298-
case 'completion':
299-
require('./completion').completion(config)
300-
break
281+
exports.run = async () => {
282+
const cliOptions = exports.process()
283+
const cmd = cliOptions.cmd // prevent config from changing the command
284+
const cmdNeedsConfig = cmd === 'start' || cmd === 'run' || cmd === 'stop'
285+
if (cmdNeedsConfig) {
286+
let config
287+
try {
288+
config = await cfg.parseConfig(
289+
cliOptions.configFile,
290+
cliOptions,
291+
{
292+
promiseConfig: true,
293+
throwErrors: true
294+
}
295+
)
296+
} catch (karmaConfigException) {
297+
// The reject reason/exception isn't used to log a message since
298+
// parseConfig already calls a configured logger method with an almost
299+
// identical message.
300+
301+
// The `run` function is a private application, not a public API. We don't
302+
// need to worry about process.exit vs throw vs promise rejection here.
303+
process.exit(1)
304+
}
305+
switch (cmd) {
306+
case 'start': {
307+
const server = new Server(config)
308+
await server.start()
309+
return server
310+
}
311+
case 'run':
312+
return require('./runner')
313+
.run(config)
314+
.on('progress', printRunnerProgress)
315+
case 'stop':
316+
return require('./stopper').stop(config)
317+
}
318+
} else {
319+
switch (cmd) {
320+
case 'init':
321+
return require('./init').init(cliOptions)
322+
case 'completion':
323+
return require('./completion').completion(cliOptions)
324+
}
301325
}
302326
}
303327

‎lib/config.js

+117-23
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,9 @@ function normalizeConfig (config, configFilePath) {
268268
return config
269269
}
270270

271+
/**
272+
* @class
273+
*/
271274
class Config {
272275
constructor () {
273276
this.LOG_DISABLE = constant.LOG_DISABLE
@@ -351,12 +354,61 @@ const CONFIG_SYNTAX_HELP = ' module.exports = function(config) {\n' +
351354
' });\n' +
352355
' };\n'
353356

357+
/**
358+
* Retrieve a parsed and finalized Karma `Config` instance. This `karmaConfig`
359+
* object may be used to configure public API methods such a `Server`,
360+
* `runner.run`, and `stopper.stop`.
361+
*
362+
* @param {?string} [configFilePath=null]
363+
* A string representing a file system path pointing to the config file
364+
* whose default export is a function that will be used to set Karma
365+
* configuration options. This function will be passed an instance of the
366+
* `Config` class as its first argument. If this option is not provided,
367+
* then only the options provided by the `cliOptions` argument will be
368+
* set.
369+
* @param {Object} cliOptions
370+
* An object whose values will take priority over options set in the
371+
* config file. The config object passed to function exported by the
372+
* config file will already have these options applied. Any changes the
373+
* config file makes to these options will effectively be ignored in the
374+
* final configuration.
375+
*
376+
* `cliOptions` all the same options as the config file and is applied
377+
* using the same `config.set()` method.
378+
* @param {Object} parseOptions
379+
* @param {boolean} [parseOptions.promiseConfig=false]
380+
* When `true`, a promise that resolves to a `Config` object will be
381+
* returned. This also allows the function exported by config files (if
382+
* provided) to be asynchronous by returning a promise. Resolving this
383+
* promise indicates that all async activity has completed. The resolution
384+
* value itself is ignored, all configuration must be done with
385+
* `config.set`.
386+
* @param {boolean} [parseOptions.throwErrors=false]
387+
* When `true`, process exiting on critical failures will be disabled. In
388+
* The error will be thrown as an exception. If
389+
* `parseOptions.promiseConfig` is also `true`, then the error will
390+
* instead be used as the promise's reject reason.
391+
* @returns {Config|Promise<Config>}
392+
*/
354393
function parseConfig (configFilePath, cliOptions, parseOptions) {
394+
const promiseConfig = parseOptions && parseOptions.promiseConfig === true
395+
const throwErrors = parseOptions && parseOptions.throwErrors === true
396+
const shouldSetupLoggerEarly = promiseConfig
397+
if (shouldSetupLoggerEarly) {
398+
// `setupFromConfig` provides defaults for `colors` and `logLevel`.
399+
// `setup` provides defaults for `appenders`
400+
// The first argument MUST BE an object
401+
logger.setupFromConfig({})
402+
}
355403
function fail () {
356404
log.error(...arguments)
357-
if (parseOptions && parseOptions.throwErrors === true) {
405+
if (throwErrors) {
358406
const errorMessage = Array.from(arguments).join(' ')
359-
throw new Error(errorMessage)
407+
const err = new Error(errorMessage)
408+
if (promiseConfig) {
409+
return Promise.reject(err)
410+
}
411+
throw err
360412
} else {
361413
const warningMessage =
362414
'The `parseConfig()` function historically called `process.exit(1)`' +
@@ -411,34 +463,76 @@ function parseConfig (configFilePath, cliOptions, parseOptions) {
411463
// add the user's configuration in
412464
config.set(cliOptions)
413465

466+
let configModuleReturn
414467
try {
415-
configModule(config)
468+
configModuleReturn = configModule(config)
416469
} catch (e) {
417470
return fail('Error in config file!\n', e)
418471
}
472+
function finalizeConfig (config) {
473+
// merge the config from config file and cliOptions (precedence)
474+
config.set(cliOptions)
419475

420-
// merge the config from config file and cliOptions (precedence)
421-
config.set(cliOptions)
422-
423-
// if the user changed listenAddress, but didn't set a hostname, warn them
424-
if (config.hostname === null && config.listenAddress !== null) {
425-
log.warn(`ListenAddress was set to ${config.listenAddress} but hostname was left as the default: ` +
476+
// if the user changed listenAddress, but didn't set a hostname, warn them
477+
if (config.hostname === null && config.listenAddress !== null) {
478+
log.warn(`ListenAddress was set to ${config.listenAddress} but hostname was left as the default: ` +
426479
`${defaultHostname}. If your browsers fail to connect, consider changing the hostname option.`)
427-
}
428-
// restore values that weren't overwritten by the user
429-
if (config.hostname === null) {
430-
config.hostname = defaultHostname
431-
}
432-
if (config.listenAddress === null) {
433-
config.listenAddress = defaultListenAddress
434-
}
435-
436-
// configure the logger as soon as we can
437-
logger.setup(config.logLevel, config.colors, config.loggers)
438-
439-
log.debug(configFilePath ? `Loading config ${configFilePath}` : 'No config file specified.')
480+
}
481+
// restore values that weren't overwritten by the user
482+
if (config.hostname === null) {
483+
config.hostname = defaultHostname
484+
}
485+
if (config.listenAddress === null) {
486+
config.listenAddress = defaultListenAddress
487+
}
440488

441-
return normalizeConfig(config, configFilePath)
489+
// configure the logger as soon as we can
490+
logger.setup(config.logLevel, config.colors, config.loggers)
491+
492+
log.debug(configFilePath ? `Loading config ${configFilePath}` : 'No config file specified.')
493+
494+
return normalizeConfig(config, configFilePath)
495+
}
496+
497+
/**
498+
* Return value is a function or (non-null) object that has a `then` method.
499+
*
500+
* @type {boolean}
501+
* @see {@link https://promisesaplus.com/}
502+
*/
503+
const returnIsThenable = (
504+
(
505+
(configModuleReturn != null && typeof configModuleReturn === 'object') ||
506+
typeof configModuleReturn === 'function'
507+
) && typeof configModuleReturn.then === 'function'
508+
)
509+
if (returnIsThenable) {
510+
if (promiseConfig !== true) {
511+
const errorMessage =
512+
'The `parseOptions.promiseConfig` option must be set to `true` to ' +
513+
'enable promise return values from configuration files. ' +
514+
'Example: `parseConfig(path, cliOptions, { promiseConfig: true })`'
515+
return fail(errorMessage)
516+
}
517+
return configModuleReturn.then(
518+
function onKarmaConfigModuleFulfilled (/* ignoredResolutionValue */) {
519+
return finalizeConfig(config)
520+
},
521+
function onKarmaConfigModuleRejected (reason) {
522+
return fail('Error in config file!\n', reason)
523+
}
524+
)
525+
} else {
526+
if (promiseConfig) {
527+
try {
528+
return Promise.resolve(finalizeConfig(config))
529+
} catch (exception) {
530+
return Promise.reject(exception)
531+
}
532+
} else {
533+
return finalizeConfig(config)
534+
}
535+
}
442536
}
443537

444538
// PUBLIC API

‎lib/runner.js

+31-6
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,39 @@ function parseExitCode (buffer, defaultExitCode, failOnEmptyTestSuite) {
3232
}
3333

3434
// TODO(vojta): read config file (port, host, urlRoot)
35-
function run (config, done) {
36-
config = config || {}
37-
38-
logger.setupFromConfig(config)
39-
35+
function run (cliOptionsOrConfig, done) {
36+
cliOptionsOrConfig = cliOptionsOrConfig || {}
4037
done = helper.isFunction(done) ? done : process.exit
41-
config = cfg.parseConfig(config.configFile, config)
4238

39+
let config
40+
if (cliOptionsOrConfig instanceof cfg.Config) {
41+
config = cliOptionsOrConfig
42+
} else {
43+
logger.setupFromConfig({
44+
colors: cliOptionsOrConfig.colors,
45+
logLevel: cliOptionsOrConfig.logLevel
46+
})
47+
const deprecatedCliOptionsMessage =
48+
'Passing raw CLI options to `runner(config, done)` is deprecated. Use ' +
49+
'`parseConfig(configFilePath, cliOptions, {promiseConfig: true, throwErrors: true})` ' +
50+
'to prepare a processed `Config` instance and pass that as the ' +
51+
'`config` argument instead.'
52+
log.warn(deprecatedCliOptionsMessage)
53+
try {
54+
config = cfg.parseConfig(
55+
cliOptionsOrConfig.configFile,
56+
cliOptionsOrConfig,
57+
{
58+
promiseConfig: false,
59+
throwErrors: true
60+
}
61+
)
62+
} catch (parseConfigError) {
63+
// TODO: change how `done` falls back to exit in next major version
64+
// SEE: https://github.com/karma-runner/karma/pull/3635#discussion_r565399378
65+
done(1)
66+
}
67+
}
4368
let exitCode = 1
4469
const emitter = new EventEmitter()
4570
const options = {

‎lib/server.js

+32-11
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,43 @@ function createSocketIoServer (webServer, executor, config) {
5555
}
5656

5757
class Server extends KarmaEventEmitter {
58-
constructor (cliOptions, done) {
58+
constructor (cliOptionsOrConfig, done) {
5959
super()
60-
logger.setupFromConfig(cliOptions)
61-
60+
cliOptionsOrConfig = cliOptionsOrConfig || {}
6261
this.log = logger.create('karma-server')
63-
62+
done = helper.isFunction(done) ? done : process.exit
6463
this.loadErrors = []
6564

6665
let config
67-
try {
68-
config = cfg.parseConfig(cliOptions.configFile, cliOptions, { throwErrors: true })
69-
} catch (parseConfigError) {
70-
// TODO: change how `done` falls back to exit in next major version
71-
// SEE: https://github.com/karma-runner/karma/pull/3635#discussion_r565399378
72-
(done || process.exit)(1)
73-
return
66+
if (cliOptionsOrConfig instanceof cfg.Config) {
67+
config = cliOptionsOrConfig
68+
} else {
69+
logger.setupFromConfig({
70+
colors: cliOptionsOrConfig.colors,
71+
logLevel: cliOptionsOrConfig.logLevel
72+
})
73+
const deprecatedCliOptionsMessage =
74+
'Passing raw CLI options to `new Server(config, done)` is ' +
75+
'deprecated. Use ' +
76+
'`parseConfig(configFilePath, cliOptions, {promiseConfig: true, throwErrors: true})` ' +
77+
'to prepare a processed `Config` instance and pass that as the ' +
78+
'`config` argument instead.'
79+
this.log.warn(deprecatedCliOptionsMessage)
80+
try {
81+
config = cfg.parseConfig(
82+
cliOptionsOrConfig.configFile,
83+
cliOptionsOrConfig,
84+
{
85+
promiseConfig: false,
86+
throwErrors: true
87+
}
88+
)
89+
} catch (parseConfigError) {
90+
// TODO: change how `done` falls back to exit in next major version
91+
// SEE: https://github.com/karma-runner/karma/pull/3635#discussion_r565399378
92+
done(1)
93+
return
94+
}
7495
}
7596

7697
this.log.debug('Final config', util.inspect(config, false, /** depth **/ null))

‎lib/stopper.js

+32-4
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,40 @@ const cfg = require('./config')
33
const logger = require('./logger')
44
const helper = require('./helper')
55

6-
exports.stop = function (config, done) {
7-
config = config || {}
8-
logger.setupFromConfig(config)
6+
exports.stop = function (cliOptionsOrConfig, done) {
7+
cliOptionsOrConfig = cliOptionsOrConfig || {}
98
const log = logger.create('stopper')
109
done = helper.isFunction(done) ? done : process.exit
11-
config = cfg.parseConfig(config.configFile, config)
10+
11+
let config
12+
if (cliOptionsOrConfig instanceof cfg.Config) {
13+
config = cliOptionsOrConfig
14+
} else {
15+
logger.setupFromConfig({
16+
colors: cliOptionsOrConfig.colors,
17+
logLevel: cliOptionsOrConfig.logLevel
18+
})
19+
const deprecatedCliOptionsMessage =
20+
'Passing raw CLI options to `stopper(config, done)` is deprecated. Use ' +
21+
'`parseConfig(configFilePath, cliOptions, {promiseConfig: true, throwErrors: true})` ' +
22+
'to prepare a processed `Config` instance and pass that as the ' +
23+
'`config` argument instead.'
24+
log.warn(deprecatedCliOptionsMessage)
25+
try {
26+
config = cfg.parseConfig(
27+
cliOptionsOrConfig.configFile,
28+
cliOptionsOrConfig,
29+
{
30+
promiseConfig: false,
31+
throwErrors: true
32+
}
33+
)
34+
} catch (parseConfigError) {
35+
// TODO: change how `done` falls back to exit in next major version
36+
// SEE: https://github.com/karma-runner/karma/pull/3635#discussion_r565399378
37+
done(1)
38+
}
39+
}
1240

1341
const request = http.request({
1442
hostname: config.hostname,

‎test/unit/cli.spec.js

+249
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const path = require('path')
44
const mocks = require('mocks')
5+
const proxyquire = require('proxyquire')
56

67
const cli = require('../../lib/cli')
78
const constant = require('../../lib/constants')
@@ -206,4 +207,252 @@ describe('cli', () => {
206207
expect(args).to.deep.equal(['aa', '--bb', 'value'])
207208
})
208209
})
210+
211+
describe('run', () => {
212+
const COMMAND_COMPLETION = 'completion'
213+
const COMMAND_INIT = 'init'
214+
const COMMAND_RUN = 'run'
215+
const COMMAND_START = 'start'
216+
const COMMAND_STOP = 'stop'
217+
const consoleErrorOriginal = console.error
218+
const processExitOriginal = process.exit
219+
let cliModule
220+
let cliProcessFake = null
221+
let completionFake = null
222+
let initFake = null
223+
let parseConfigFake = null
224+
let runEmitterFake = null
225+
let runFake = null
226+
let ServerFake = null
227+
let serverStartFake = null
228+
let stopFake = null
229+
let testCommand = null
230+
let forceConfigFailure = false
231+
232+
// `cliProcessFake` is used in multiple scopes, but not needed by the top
233+
// scope. By using a factory, we can maintain one copy of the code in a
234+
// single location while still having access to scopped variables that we
235+
// need.
236+
function createCliProcessFake () {
237+
return sinon.fake(function cliProcessFake () {
238+
const cliOptions = {}
239+
if (
240+
testCommand === COMMAND_COMPLETION ||
241+
testCommand === COMMAND_INIT ||
242+
testCommand === COMMAND_RUN ||
243+
testCommand === COMMAND_START ||
244+
testCommand === COMMAND_STOP
245+
) {
246+
cliOptions.cmd = testCommand
247+
} else {
248+
const errorMessage =
249+
'cli.spec.js: A valid command must be provided when testing the' +
250+
'exported `run()` method.'
251+
throw new Error(errorMessage)
252+
}
253+
if (forceConfigFailure === true) {
254+
cliOptions.forceConfigFailure = true
255+
}
256+
return cliOptions
257+
})
258+
}
259+
260+
before(() => {
261+
proxyquire.noPreserveCache()
262+
})
263+
264+
beforeEach(() => {
265+
// Keep the test output clean
266+
console.error = sinon.spy()
267+
268+
// Keep the process from actually exiting
269+
process.exit = sinon.spy()
270+
271+
completionFake = sinon.fake()
272+
initFake = sinon.fake()
273+
parseConfigFake = sinon.fake(function parseConfigFake () {
274+
const cliOptions = arguments[1]
275+
276+
// Allow individual tests to test against success and failure without
277+
// needing to manage multiple sinon fakes.
278+
const forceConfigFailure = cliOptions && cliOptions.forceConfigFailure === true
279+
if (forceConfigFailure) {
280+
// No need to mock out the synchronous API, the CLI is not intended to
281+
// use it
282+
return Promise.reject(new Error('Intentional Failure For Testing'))
283+
}
284+
285+
// Most of our tests will ignore the actual config as the CLI passes it
286+
// on to other methods that are tested elsewhere
287+
const karmaConfig = {
288+
...cliOptions,
289+
isFakeParsedConfig: true
290+
}
291+
return Promise.resolve(karmaConfig)
292+
})
293+
runEmitterFake = {}
294+
runEmitterFake.on = sinon.fake.returns(runEmitterFake)
295+
runFake = sinon.fake.returns(runEmitterFake)
296+
serverStartFake = sinon.fake.resolves()
297+
ServerFake = sinon.fake.returns({ start: serverStartFake })
298+
stopFake = sinon.fake()
299+
cliModule = proxyquire(
300+
'../../lib/cli',
301+
{
302+
'./completion': {
303+
completion: completionFake
304+
},
305+
'./config': {
306+
parseConfig: parseConfigFake
307+
},
308+
'./init': {
309+
init: initFake
310+
},
311+
'./runner': {
312+
run: runFake
313+
},
314+
'./server': ServerFake,
315+
'./stopper': {
316+
stop: stopFake
317+
}
318+
}
319+
)
320+
})
321+
322+
afterEach(() => {
323+
// Restore globals, simultaneously removing references to the spies.
324+
console.error = consoleErrorOriginal
325+
process.exit = processExitOriginal
326+
327+
// Reset the test command
328+
testCommand = null
329+
330+
// Most tests won't be testing what happens during a configuration failure
331+
// Here we clean up after the ones that do.
332+
forceConfigFailure = false
333+
334+
// Restores all replaced properties set by sinon methods (`replace`,
335+
// `spy`, and `stub`)
336+
sinon.restore()
337+
338+
// Remove references to Fakes that were not handled above. Avoids `before`
339+
// and `beforeEach` aside effects and references not getting cleaned up
340+
// after the last test.
341+
cliModule = null
342+
cliProcessFake = null
343+
completionFake = null
344+
initFake = null
345+
parseConfigFake = null
346+
runEmitterFake = null
347+
runFake = null
348+
ServerFake = null
349+
serverStartFake = null
350+
stopFake = null
351+
})
352+
353+
after(() => {
354+
proxyquire.preserveCache()
355+
})
356+
357+
describe('commands', () => {
358+
let cliProcessOriginal
359+
beforeEach(() => {
360+
cliProcessFake = createCliProcessFake()
361+
cliProcessOriginal = cliModule.process
362+
cliModule.process = cliProcessFake
363+
})
364+
afterEach(() => {
365+
if (cliModule) {
366+
cliModule.process = cliProcessOriginal
367+
}
368+
})
369+
describe(COMMAND_COMPLETION, () => {
370+
beforeEach(() => {
371+
testCommand = COMMAND_COMPLETION
372+
})
373+
it('should configure and call the completion method of the completion module', async () => {
374+
await cliModule.run()
375+
expect(completionFake.calledOnce).to.be.true
376+
expect(completionFake.firstCall.args[0]).to.eql({
377+
cmd: COMMAND_COMPLETION
378+
})
379+
})
380+
})
381+
describe(COMMAND_INIT, () => {
382+
beforeEach(() => {
383+
testCommand = COMMAND_INIT
384+
})
385+
it('should configure and call the init method of the init module', async () => {
386+
await cliModule.run()
387+
expect(initFake.calledOnce).to.be.true
388+
expect(initFake.firstCall.args[0]).to.eql({
389+
cmd: COMMAND_INIT
390+
})
391+
})
392+
})
393+
describe(COMMAND_RUN, () => {
394+
beforeEach(() => {
395+
testCommand = COMMAND_RUN
396+
})
397+
it('should configure and call the run method of the runner module', async () => {
398+
await cliModule.run()
399+
expect(runFake.calledOnce).to.be.true
400+
expect(runFake.firstCall.args[0]).to.eql({
401+
cmd: COMMAND_RUN,
402+
isFakeParsedConfig: true
403+
})
404+
expect(runEmitterFake.on.calledOnce).to.be.true
405+
expect(runEmitterFake.on.firstCall.args[0]).to.be.equal('progress')
406+
})
407+
})
408+
describe(COMMAND_START, () => {
409+
beforeEach(() => {
410+
testCommand = COMMAND_START
411+
})
412+
it('should configure and start the server', async () => {
413+
await cliModule.run()
414+
expect(ServerFake.calledOnce).to.be.true
415+
expect(ServerFake.firstCall.args[0]).to.eql({
416+
cmd: COMMAND_START,
417+
isFakeParsedConfig: true
418+
})
419+
expect(serverStartFake.calledOnce).to.be.true
420+
})
421+
})
422+
describe(COMMAND_STOP, () => {
423+
beforeEach(() => {
424+
testCommand = COMMAND_STOP
425+
})
426+
it('should configure and call the stop method of the stopper module', async () => {
427+
await cliModule.run()
428+
expect(stopFake.calledOnce).to.be.true
429+
expect(stopFake.firstCall.args[0]).to.eql({
430+
cmd: COMMAND_STOP,
431+
isFakeParsedConfig: true
432+
})
433+
})
434+
})
435+
})
436+
describe('configuration failure', () => {
437+
let cliProcessOriginal
438+
beforeEach(() => {
439+
forceConfigFailure = true
440+
testCommand = COMMAND_START
441+
442+
cliProcessFake = createCliProcessFake()
443+
cliProcessOriginal = cliModule.process
444+
cliModule.process = cliProcessFake
445+
})
446+
afterEach(() => {
447+
if (cliModule) {
448+
cliModule.process = cliProcessOriginal
449+
}
450+
})
451+
it('should exit the process with a non-zero exit code when configuration parsing fails', async () => {
452+
await cliModule.run()
453+
expect(process.exit.calledOnce).to.be.true
454+
expect(process.exit.firstCall.args[0]).not.to.be.equal(0)
455+
})
456+
})
457+
})
209458
})

‎test/unit/config.spec.js

+122-17
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ describe('config', () => {
3030
const wrapCfg = function (cfg) {
3131
return (config) => config.set(cfg)
3232
}
33+
const wrapAsyncCfg = function (cfg) {
34+
return async (config) => config.set(cfg)
35+
}
3336

3437
beforeEach(() => {
3538
mocks = {}
@@ -52,14 +55,20 @@ describe('config', () => {
5255
'/conf/absolute.js': wrapCfg({ files: ['http://some.com', 'https://more.org/file.js'] }),
5356
'/conf/both.js': wrapCfg({ files: ['one.js', 'two.js'], exclude: ['third.js'] }),
5457
'/conf/coffee.coffee': wrapCfg({ files: ['one.js', 'two.js'] }),
55-
'/conf/default-export.js': { default: wrapCfg({ files: ['one.js', 'two.js'] }) }
58+
'/conf/default-export.js': { default: wrapCfg({ files: ['one.js', 'two.js'] }) },
59+
'/conf/default-config': function noOperations () {},
60+
'/conf/returns-promise-that-resolves.js': wrapAsyncCfg({ foo: 'bar' }),
61+
'/conf/returns-promise-that-rejects.js': () => {
62+
return Promise.reject(new Error('Unexpected Error'))
63+
}
5664
}
5765

5866
// load file under test
5967
m = loadFile(path.join(__dirname, '/../../lib/config.js'), mocks, {
6068
global: {},
6169
process: mocks.process,
6270
Error: Error, // Without this, chai's `.throw()` assertion won't correctly check against constructors.
71+
Promise: Promise,
6372
require (path) {
6473
if (mockConfigs[path]) {
6574
return mockConfigs[path]
@@ -75,10 +84,22 @@ describe('config', () => {
7584
})
7685

7786
describe('parseConfig', () => {
78-
let logSpy
87+
let logErrorStub
88+
let logWarnStub
89+
let processExitStub
7990

8091
beforeEach(() => {
81-
logSpy = sinon.spy(logger.create('config'), 'error')
92+
const log = logger.create('config')
93+
// Silence and monitor logged errors and warnings, regardless of the
94+
// `logLevel` option.
95+
logErrorStub = sinon.stub(log, 'error')
96+
logWarnStub = sinon.stub(log, 'warn')
97+
processExitStub = sinon.stub(process, 'exit')
98+
})
99+
afterEach(() => {
100+
logErrorStub.restore()
101+
logWarnStub.restore()
102+
processExitStub.restore()
82103
})
83104

84105
it('should resolve relative basePath to config directory', () => {
@@ -116,24 +137,24 @@ describe('config', () => {
116137
expect(config.exclude).to.deep.equal(actual)
117138
})
118139

119-
it('should log error and exit if file does not exist', () => {
140+
it('should log an error and exit if file does not exist', () => {
120141
e.parseConfig('/conf/not-exist.js', {})
121142

122-
expect(logSpy).to.have.been.called
123-
const event = logSpy.lastCall.args
143+
expect(logErrorStub).to.have.been.called
144+
const event = logErrorStub.lastCall.args
124145
expect(event.toString().split('\n').slice(0, 2)).to.be.deep.equal(
125146
['Error in config file!', ' Error: Cannot find module \'/conf/not-exist.js\''])
126147
expect(mocks.process.exit).to.have.been.calledWith(1)
127148
})
128149

129-
it('should log error and throw if file does not exist AND throwErrors is true', () => {
150+
it('should log an error and throw if file does not exist AND throwErrors is true', () => {
130151
function parseConfig () {
131152
e.parseConfig('/conf/not-exist.js', {}, { throwErrors: true })
132153
}
133154

134155
expect(parseConfig).to.throw(Error, 'Error in config file!\n Error: Cannot find module \'/conf/not-exist.js\'')
135-
expect(logSpy).to.have.been.called
136-
const event = logSpy.lastCall.args
156+
expect(logErrorStub).to.have.been.called
157+
const event = logErrorStub.lastCall.args
137158
expect(event.toString().split('\n').slice(0, 2)).to.be.deep.equal(
138159
['Error in config file!', ' Error: Cannot find module \'/conf/not-exist.js\''])
139160
expect(mocks.process.exit).not.to.have.been.called
@@ -142,8 +163,8 @@ describe('config', () => {
142163
it('should log an error and exit if invalid file', () => {
143164
e.parseConfig('/conf/invalid.js', {})
144165

145-
expect(logSpy).to.have.been.called
146-
const event = logSpy.lastCall.args
166+
expect(logErrorStub).to.have.been.called
167+
const event = logErrorStub.lastCall.args
147168
expect(event[0]).to.eql('Error in config file!\n')
148169
expect(event[1].message).to.eql('Unexpected token =')
149170
expect(mocks.process.exit).to.have.been.calledWith(1)
@@ -155,8 +176,8 @@ describe('config', () => {
155176
}
156177

157178
expect(parseConfig).to.throw(Error, 'Error in config file!\n SyntaxError: Unexpected token =')
158-
expect(logSpy).to.have.been.called
159-
const event = logSpy.lastCall.args
179+
expect(logErrorStub).to.have.been.called
180+
const event = logErrorStub.lastCall.args
160181
expect(event[0]).to.eql('Error in config file!\n')
161182
expect(event[1].message).to.eql('Unexpected token =')
162183
expect(mocks.process.exit).not.to.have.been.called
@@ -168,13 +189,97 @@ describe('config', () => {
168189
}
169190

170191
expect(parseConfig).to.throw(Error, 'Config file must export a function!\n')
171-
expect(logSpy).to.have.been.called
172-
const event = logSpy.lastCall.args
192+
expect(logErrorStub).to.have.been.called
193+
const event = logErrorStub.lastCall.args
173194
expect(event.toString().split('\n').slice(0, 1)).to.be.deep.equal(
174195
['Config file must export a function!'])
175196
expect(mocks.process.exit).not.to.have.been.called
176197
})
177198

199+
it('should log an error and fail when the config file\'s function returns a promise, but `parseOptions.promiseConfig` is not true', () => {
200+
function parseConfig () {
201+
e.parseConfig(
202+
'/conf/returns-promise-that-resolves.js', {}, { throwErrors: true }
203+
)
204+
}
205+
const expectedErrorMessage =
206+
'The `parseOptions.promiseConfig` option must be set to `true` to ' +
207+
'enable promise return values from configuration files. ' +
208+
'Example: `parseConfig(path, cliOptions, { promiseConfig: true })`'
209+
210+
expect(parseConfig).to.throw(Error, expectedErrorMessage)
211+
expect(logErrorStub).to.have.been.called
212+
const event = logErrorStub.lastCall.args
213+
expect(event[0]).to.be.eql(expectedErrorMessage)
214+
expect(mocks.process.exit).not.to.have.been.called
215+
})
216+
217+
describe('when `parseOptions.promiseConfig` is true', () => {
218+
it('should return a promise when promiseConfig is true', () => {
219+
// Return value should always be a promise, regardless of whether or not
220+
// the config file itself is synchronous or asynchronous and when no
221+
// config file path is provided at all.
222+
const noConfigFilePromise = e.parseConfig(
223+
null, null, { promiseConfig: true }
224+
)
225+
const syncConfigPromise = e.parseConfig(
226+
'/conf/default-config', null, { promiseConfig: true }
227+
)
228+
const asyncConfigPromise = e.parseConfig(
229+
'/conf/returns-promise-that-resolves.js',
230+
null,
231+
{ promiseConfig: true }
232+
)
233+
234+
expect(noConfigFilePromise).to.be.an.instanceof(
235+
Promise,
236+
'Expected parseConfig to return a promise when no config file path is provided.'
237+
)
238+
expect(syncConfigPromise).to.be.an.instanceof(
239+
Promise,
240+
'Expected parseConfig to return a promise when the config file DOES NOT return a promise.'
241+
)
242+
expect(asyncConfigPromise).to.be.an.instanceof(
243+
Promise,
244+
'Expected parseConfig to return a promise when the config file returns a promise.'
245+
)
246+
})
247+
248+
it('should log an error and exit if invalid file', () => {
249+
e.parseConfig('/conf/invalid.js', {}, { promiseConfig: true })
250+
251+
expect(logErrorStub).to.have.been.called
252+
const event = logErrorStub.lastCall.args
253+
expect(event[0]).to.eql('Error in config file!\n')
254+
expect(event[1].message).to.eql('Unexpected token =')
255+
expect(mocks.process.exit).to.have.been.calledWith(1)
256+
})
257+
258+
it('should log an error and reject the promise if the config file rejects the promise returned by its function AND throwErrors is true', async () => {
259+
const configThatRejects = e.parseConfig('/conf/returns-promise-that-rejects.js', {}, { promiseConfig: true, throwErrors: true }).catch((reason) => {
260+
expect(logErrorStub).to.have.been.called
261+
const event = logErrorStub.lastCall.args
262+
expect(event[0]).to.eql('Error in config file!\n')
263+
expect(event[1].message).to.eql('Unexpected Error')
264+
expect(reason.message).to.eql('Error in config file!\n Error: Unexpected Error')
265+
expect(reason).to.be.an.instanceof(Error)
266+
})
267+
return configThatRejects
268+
})
269+
270+
it('should log an error and reject the promise if invalid file AND throwErrors is true', async () => {
271+
const configThatThrows = e.parseConfig('/conf/invalid.js', {}, { promiseConfig: true, throwErrors: true }).catch((reason) => {
272+
expect(logErrorStub).to.have.been.called
273+
const event = logErrorStub.lastCall.args
274+
expect(event[0]).to.eql('Error in config file!\n')
275+
expect(event[1].message).to.eql('Unexpected token =')
276+
expect(reason.message).to.eql('Error in config file!\n SyntaxError: Unexpected token =')
277+
expect(reason).to.be.an.instanceof(Error)
278+
})
279+
return configThatThrows
280+
})
281+
})
282+
178283
it('should override config with given cli options', () => {
179284
const config = e.parseConfig('/home/config4.js', { port: 456, autoWatch: false })
180285

@@ -288,15 +393,15 @@ describe('config', () => {
288393
it('should not read config file, when null', () => {
289394
const config = e.parseConfig(null, { basePath: '/some' })
290395

291-
expect(logSpy).not.to.have.been.called
396+
expect(logErrorStub).not.to.have.been.called
292397
expect(config.basePath).to.equal(resolveWinPath('/some')) // overridden by CLI
293398
expect(config.urlRoot).to.equal('/')
294399
}) // default value
295400

296401
it('should not read config file, when null but still resolve cli basePath', () => {
297402
const config = e.parseConfig(null, { basePath: './some' })
298403

299-
expect(logSpy).not.to.have.been.called
404+
expect(logErrorStub).not.to.have.been.called
300405
expect(config.basePath).to.equal(resolveWinPath('./some'))
301406
expect(config.urlRoot).to.equal('/')
302407
}) // default value

‎test/unit/server.spec.js

+37-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const Server = require('../../lib/server')
22
const NetUtils = require('../../lib/utils/net-utils')
33
const BrowserCollection = require('../../lib/browser_collection')
44
const Browser = require('../../lib/browser')
5+
const cfg = require('../../lib/config')
56
const logger = require('../../lib/logger')
67

78
describe('server', () => {
@@ -16,7 +17,9 @@ describe('server', () => {
1617
let mockBoundServer
1718
let mockExecutor
1819
let doneStub
20+
let log
1921
let logErrorSpy
22+
let logWarnStub
2023
let server = mockConfig = browserCollection = webServerOnError = null
2124
let fileListOnResolve = fileListOnReject = mockLauncher = null
2225
let mockFileList = mockWebServer = mockSocketServer = mockExecutor = doneStub = null
@@ -28,7 +31,9 @@ describe('server', () => {
2831
this.timeout(4000)
2932
browserCollection = new BrowserCollection()
3033
doneStub = sinon.stub()
31-
logErrorSpy = sinon.spy(logger.create('karma-server'), 'error')
34+
log = logger.create('karma-server')
35+
logErrorSpy = sinon.spy(log, 'error')
36+
logWarnStub = sinon.stub(log, 'warn')
3237

3338
fileListOnResolve = fileListOnReject = null
3439

@@ -46,7 +51,6 @@ describe('server', () => {
4651
browserDisconnectTolerance: 0,
4752
browserNoActivityTimeout: 0
4853
}
49-
5054
server = new Server(mockConfig, doneStub)
5155

5256
sinon.stub(server._injector, 'invoke').returns([])
@@ -126,6 +130,37 @@ describe('server', () => {
126130
webServerOnError = null
127131
})
128132

133+
afterEach(() => {
134+
logWarnStub.restore()
135+
})
136+
137+
describe('constructor', () => {
138+
it('should log a warning when the first argument is not an instance of Config', async () => {
139+
// Reset the spy interface on the stub. It may have already been called by
140+
// code in the `before` or `beforeEach` hooks.
141+
logWarnStub.resetHistory()
142+
143+
const rawConfig = {
144+
karmaConfigForTest: true
145+
}
146+
return cfg.parseConfig(
147+
null,
148+
rawConfig,
149+
{ promiseConfig: true, throwErrors: true }
150+
).then((parsedConfig) => {
151+
const messageSubstring =
152+
'Passing raw CLI options to `new Server(config, done)` is ' +
153+
'deprecated.'
154+
155+
const serverWithParsed = new Server(parsedConfig, doneStub) // eslint-disable-line no-unused-vars
156+
expect(logWarnStub).to.not.have.been.calledWith(sinon.match(messageSubstring))
157+
158+
const serverWithRaw = new Server(rawConfig, doneStub) // eslint-disable-line no-unused-vars
159+
expect(logWarnStub).to.have.been.calledOnceWith(sinon.match(messageSubstring))
160+
})
161+
})
162+
})
163+
129164
describe('start', () => {
130165
let config
131166
beforeEach(() => {

0 commit comments

Comments
 (0)
Please sign in to comment.