545 lines
18 KiB
JavaScript
545 lines
18 KiB
JavaScript
'use strict'
|
|
|
|
const path = require('path')
|
|
const assert = require('assert')
|
|
|
|
const logger = require('./logger')
|
|
const log = logger.create('config')
|
|
const helper = require('./helper')
|
|
const constant = require('./constants')
|
|
|
|
const _ = require('lodash')
|
|
|
|
let COFFEE_SCRIPT_AVAILABLE = false
|
|
let LIVE_SCRIPT_AVAILABLE = false
|
|
let TYPE_SCRIPT_AVAILABLE = false
|
|
|
|
try {
|
|
require('coffeescript').register()
|
|
COFFEE_SCRIPT_AVAILABLE = true
|
|
} catch {}
|
|
|
|
// LiveScript is required here to enable config files written in LiveScript.
|
|
// It's not directly used in this file.
|
|
try {
|
|
require('LiveScript')
|
|
LIVE_SCRIPT_AVAILABLE = true
|
|
} catch {}
|
|
|
|
try {
|
|
require('ts-node')
|
|
TYPE_SCRIPT_AVAILABLE = true
|
|
} catch {}
|
|
|
|
class Pattern {
|
|
constructor (pattern, served, included, watched, nocache, type, isBinary, integrity) {
|
|
this.pattern = pattern
|
|
this.served = helper.isDefined(served) ? served : true
|
|
this.included = helper.isDefined(included) ? included : true
|
|
this.watched = helper.isDefined(watched) ? watched : true
|
|
this.nocache = helper.isDefined(nocache) ? nocache : false
|
|
this.weight = helper.mmPatternWeight(pattern)
|
|
this.type = type
|
|
this.isBinary = isBinary
|
|
this.integrity = integrity
|
|
}
|
|
|
|
compare (other) {
|
|
return helper.mmComparePatternWeights(this.weight, other.weight)
|
|
}
|
|
}
|
|
|
|
class UrlPattern extends Pattern {
|
|
constructor (url, type, integrity) {
|
|
super(url, false, true, false, false, type, undefined, integrity)
|
|
}
|
|
}
|
|
|
|
function createPatternObject (pattern) {
|
|
if (pattern && helper.isString(pattern)) {
|
|
return helper.isUrlAbsolute(pattern)
|
|
? new UrlPattern(pattern)
|
|
: new Pattern(pattern)
|
|
} else if (helper.isObject(pattern) && pattern.pattern && helper.isString(pattern.pattern)) {
|
|
return helper.isUrlAbsolute(pattern.pattern)
|
|
? new UrlPattern(pattern.pattern, pattern.type, pattern.integrity)
|
|
: new Pattern(pattern.pattern, pattern.served, pattern.included, pattern.watched, pattern.nocache, pattern.type)
|
|
} else {
|
|
log.warn(`Invalid pattern ${pattern}!\n\tExpected string or object with "pattern" property.`)
|
|
return new Pattern(null, false, false, false, false)
|
|
}
|
|
}
|
|
|
|
function normalizeUrl (url) {
|
|
if (!url.startsWith('/')) {
|
|
url = `/${url}`
|
|
}
|
|
|
|
if (!url.endsWith('/')) {
|
|
url = url + '/'
|
|
}
|
|
|
|
return url
|
|
}
|
|
|
|
function normalizeUrlRoot (urlRoot) {
|
|
const normalizedUrlRoot = normalizeUrl(urlRoot)
|
|
|
|
if (normalizedUrlRoot !== urlRoot) {
|
|
log.warn(`urlRoot normalized to "${normalizedUrlRoot}"`)
|
|
}
|
|
|
|
return normalizedUrlRoot
|
|
}
|
|
|
|
function normalizeProxyPath (proxyPath) {
|
|
const normalizedProxyPath = normalizeUrl(proxyPath)
|
|
|
|
if (normalizedProxyPath !== proxyPath) {
|
|
log.warn(`proxyPath normalized to "${normalizedProxyPath}"`)
|
|
}
|
|
|
|
return normalizedProxyPath
|
|
}
|
|
|
|
function normalizeConfig (config, configFilePath) {
|
|
function basePathResolve (relativePath) {
|
|
if (helper.isUrlAbsolute(relativePath)) {
|
|
return relativePath
|
|
} else if (helper.isDefined(config.basePath) && helper.isDefined(relativePath)) {
|
|
return path.resolve(config.basePath, relativePath)
|
|
} else {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
function createPatternMapper (resolve) {
|
|
return (objectPattern) => Object.assign(objectPattern, { pattern: resolve(objectPattern.pattern) })
|
|
}
|
|
|
|
if (helper.isString(configFilePath)) {
|
|
config.basePath = path.resolve(path.dirname(configFilePath), config.basePath) // resolve basePath
|
|
config.exclude.push(configFilePath) // always ignore the config file itself
|
|
} else {
|
|
config.basePath = path.resolve(config.basePath || '.')
|
|
}
|
|
|
|
config.files = config.files.map(createPatternObject).map(createPatternMapper(basePathResolve))
|
|
config.exclude = config.exclude.map(basePathResolve)
|
|
config.customContextFile = config.customContextFile && basePathResolve(config.customContextFile)
|
|
config.customDebugFile = config.customDebugFile && basePathResolve(config.customDebugFile)
|
|
config.customClientContextFile = config.customClientContextFile && basePathResolve(config.customClientContextFile)
|
|
|
|
// normalize paths on windows
|
|
config.basePath = helper.normalizeWinPath(config.basePath)
|
|
config.files = config.files.map(createPatternMapper(helper.normalizeWinPath))
|
|
config.exclude = config.exclude.map(helper.normalizeWinPath)
|
|
config.customContextFile = helper.normalizeWinPath(config.customContextFile)
|
|
config.customDebugFile = helper.normalizeWinPath(config.customDebugFile)
|
|
config.customClientContextFile = helper.normalizeWinPath(config.customClientContextFile)
|
|
|
|
// normalize urlRoot
|
|
config.urlRoot = normalizeUrlRoot(config.urlRoot)
|
|
|
|
// normalize and default upstream proxy settings if given
|
|
if (config.upstreamProxy) {
|
|
const proxy = config.upstreamProxy
|
|
proxy.path = helper.isDefined(proxy.path) ? normalizeProxyPath(proxy.path) : '/'
|
|
proxy.hostname = helper.isDefined(proxy.hostname) ? proxy.hostname : 'localhost'
|
|
proxy.port = helper.isDefined(proxy.port) ? proxy.port : 9875
|
|
|
|
// force protocol to end with ':'
|
|
proxy.protocol = (proxy.protocol || 'http').split(':')[0] + ':'
|
|
if (proxy.protocol.match(/https?:/) === null) {
|
|
log.warn(`"${proxy.protocol}" is not a supported upstream proxy protocol, defaulting to "http:"`)
|
|
proxy.protocol = 'http:'
|
|
}
|
|
}
|
|
|
|
// force protocol to end with ':'
|
|
config.protocol = (config.protocol || 'http').split(':')[0] + ':'
|
|
if (config.protocol.match(/https?:/) === null) {
|
|
log.warn(`"${config.protocol}" is not a supported protocol, defaulting to "http:"`)
|
|
config.protocol = 'http:'
|
|
}
|
|
|
|
if (config.proxies && Object.prototype.hasOwnProperty.call(config.proxies, config.urlRoot)) {
|
|
log.warn(`"${config.urlRoot}" is proxied, you should probably change urlRoot to avoid conflicts`)
|
|
}
|
|
|
|
if (config.singleRun && config.autoWatch) {
|
|
log.debug('autoWatch set to false, because of singleRun')
|
|
config.autoWatch = false
|
|
}
|
|
|
|
if (config.runInParent) {
|
|
log.debug('useIframe set to false, because using runInParent')
|
|
config.useIframe = false
|
|
}
|
|
|
|
if (!config.singleRun && !config.useIframe && config.runInParent) {
|
|
log.debug('singleRun set to true, because using runInParent')
|
|
config.singleRun = true
|
|
}
|
|
|
|
if (helper.isString(config.reporters)) {
|
|
config.reporters = config.reporters.split(',')
|
|
}
|
|
|
|
if (config.client && config.client.args) {
|
|
assert(Array.isArray(config.client.args), 'Invalid configuration: client.args must be an array of strings')
|
|
}
|
|
|
|
if (config.browsers) {
|
|
assert(Array.isArray(config.browsers), 'Invalid configuration: browsers option must be an array')
|
|
}
|
|
|
|
if (config.formatError) {
|
|
assert(helper.isFunction(config.formatError), 'Invalid configuration: formatError option must be a function.')
|
|
}
|
|
|
|
if (config.processKillTimeout) {
|
|
assert(helper.isNumber(config.processKillTimeout), 'Invalid configuration: processKillTimeout option must be a number.')
|
|
}
|
|
|
|
if (config.browserSocketTimeout) {
|
|
assert(helper.isNumber(config.browserSocketTimeout), 'Invalid configuration: browserSocketTimeout option must be a number.')
|
|
}
|
|
|
|
if (config.pingTimeout) {
|
|
assert(helper.isNumber(config.pingTimeout), 'Invalid configuration: pingTimeout option must be a number.')
|
|
}
|
|
|
|
const defaultClient = config.defaultClient || {}
|
|
Object.keys(defaultClient).forEach(function (key) {
|
|
const option = config.client[key]
|
|
config.client[key] = helper.isDefined(option) ? option : defaultClient[key]
|
|
})
|
|
|
|
// normalize preprocessors
|
|
const preprocessors = config.preprocessors || {}
|
|
const normalizedPreprocessors = config.preprocessors = Object.create(null)
|
|
|
|
Object.keys(preprocessors).forEach(function (pattern) {
|
|
const normalizedPattern = helper.normalizeWinPath(basePathResolve(pattern))
|
|
|
|
normalizedPreprocessors[normalizedPattern] = helper.isString(preprocessors[pattern])
|
|
? [preprocessors[pattern]] : preprocessors[pattern]
|
|
})
|
|
|
|
// define custom launchers/preprocessors/reporters - create a new plugin
|
|
const module = Object.create(null)
|
|
let hasSomeInlinedPlugin = false
|
|
const types = ['launcher', 'preprocessor', 'reporter']
|
|
|
|
types.forEach(function (type) {
|
|
const definitions = config[`custom${helper.ucFirst(type)}s`] || {}
|
|
|
|
Object.keys(definitions).forEach(function (name) {
|
|
const definition = definitions[name]
|
|
|
|
if (!helper.isObject(definition)) {
|
|
return log.warn(`Can not define ${type} ${name}. Definition has to be an object.`)
|
|
}
|
|
|
|
if (!helper.isString(definition.base)) {
|
|
return log.warn(`Can not define ${type} ${name}. Missing base ${type}.`)
|
|
}
|
|
|
|
const token = type + ':' + definition.base
|
|
const locals = {
|
|
args: ['value', definition]
|
|
}
|
|
|
|
module[type + ':' + name] = ['factory', function (injector) {
|
|
const plugin = injector.createChild([locals], [token]).get(token)
|
|
if (type === 'launcher' && helper.isDefined(definition.displayName)) {
|
|
plugin.displayName = definition.displayName
|
|
}
|
|
return plugin
|
|
}]
|
|
hasSomeInlinedPlugin = true
|
|
})
|
|
})
|
|
|
|
if (hasSomeInlinedPlugin) {
|
|
config.plugins.push(module)
|
|
}
|
|
|
|
return config
|
|
}
|
|
|
|
/**
|
|
* @class
|
|
*/
|
|
class Config {
|
|
constructor () {
|
|
this.LOG_DISABLE = constant.LOG_DISABLE
|
|
this.LOG_ERROR = constant.LOG_ERROR
|
|
this.LOG_WARN = constant.LOG_WARN
|
|
this.LOG_INFO = constant.LOG_INFO
|
|
this.LOG_DEBUG = constant.LOG_DEBUG
|
|
|
|
// DEFAULT CONFIG
|
|
this.frameworks = []
|
|
this.protocol = 'http:'
|
|
this.port = constant.DEFAULT_PORT
|
|
this.listenAddress = constant.DEFAULT_LISTEN_ADDR
|
|
this.hostname = constant.DEFAULT_HOSTNAME
|
|
this.httpsServerConfig = {}
|
|
this.basePath = ''
|
|
this.files = []
|
|
this.browserConsoleLogOptions = {
|
|
level: 'debug',
|
|
format: '%b %T: %m',
|
|
terminal: true
|
|
}
|
|
this.customContextFile = null
|
|
this.customDebugFile = null
|
|
this.customClientContextFile = null
|
|
this.exclude = []
|
|
this.logLevel = constant.LOG_INFO
|
|
this.colors = true
|
|
this.autoWatch = true
|
|
this.autoWatchBatchDelay = 250
|
|
this.restartOnFileChange = false
|
|
this.usePolling = process.platform === 'linux'
|
|
this.reporters = ['progress']
|
|
this.singleRun = false
|
|
this.browsers = []
|
|
this.captureTimeout = 60000
|
|
this.pingTimeout = 5000
|
|
this.proxies = {}
|
|
this.proxyValidateSSL = true
|
|
this.preprocessors = {}
|
|
this.preprocessor_priority = {}
|
|
this.urlRoot = '/'
|
|
this.upstreamProxy = undefined
|
|
this.reportSlowerThan = 0
|
|
this.loggers = [constant.CONSOLE_APPENDER]
|
|
this.transports = ['polling', 'websocket']
|
|
this.forceJSONP = false
|
|
this.plugins = ['karma-*']
|
|
this.defaultClient = this.client = {
|
|
args: [],
|
|
useIframe: true,
|
|
runInParent: false,
|
|
captureConsole: true,
|
|
clearContext: true,
|
|
allowedReturnUrlPatterns: ['^https?://']
|
|
}
|
|
this.browserDisconnectTimeout = 2000
|
|
this.browserDisconnectTolerance = 0
|
|
this.browserNoActivityTimeout = 30000
|
|
this.processKillTimeout = 2000
|
|
this.concurrency = Infinity
|
|
this.failOnEmptyTestSuite = true
|
|
this.retryLimit = 2
|
|
this.detached = false
|
|
this.crossOriginAttribute = true
|
|
this.browserSocketTimeout = 20000
|
|
}
|
|
|
|
set (newConfig) {
|
|
_.mergeWith(this, newConfig, (obj, src) => {
|
|
// Overwrite arrays to keep consistent with #283
|
|
if (Array.isArray(src)) {
|
|
return src
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
const CONFIG_SYNTAX_HELP = ' module.exports = function(config) {\n' +
|
|
' config.set({\n' +
|
|
' // your config\n' +
|
|
' });\n' +
|
|
' };\n'
|
|
|
|
/**
|
|
* Retrieve a parsed and finalized Karma `Config` instance. This `karmaConfig`
|
|
* object may be used to configure public API methods such a `Server`,
|
|
* `runner.run`, and `stopper.stop`.
|
|
*
|
|
* @param {?string} [configFilePath=null]
|
|
* A string representing a file system path pointing to the config file
|
|
* whose default export is a function that will be used to set Karma
|
|
* configuration options. This function will be passed an instance of the
|
|
* `Config` class as its first argument. If this option is not provided,
|
|
* then only the options provided by the `cliOptions` argument will be
|
|
* set.
|
|
* @param {Object} cliOptions
|
|
* An object whose values will take priority over options set in the
|
|
* config file. The config object passed to function exported by the
|
|
* config file will already have these options applied. Any changes the
|
|
* config file makes to these options will effectively be ignored in the
|
|
* final configuration.
|
|
*
|
|
* `cliOptions` all the same options as the config file and is applied
|
|
* using the same `config.set()` method.
|
|
* @param {Object} parseOptions
|
|
* @param {boolean} [parseOptions.promiseConfig=false]
|
|
* When `true`, a promise that resolves to a `Config` object will be
|
|
* returned. This also allows the function exported by config files (if
|
|
* provided) to be asynchronous by returning a promise. Resolving this
|
|
* promise indicates that all async activity has completed. The resolution
|
|
* value itself is ignored, all configuration must be done with
|
|
* `config.set`.
|
|
* @param {boolean} [parseOptions.throwErrors=false]
|
|
* When `true`, process exiting on critical failures will be disabled. In
|
|
* The error will be thrown as an exception. If
|
|
* `parseOptions.promiseConfig` is also `true`, then the error will
|
|
* instead be used as the promise's reject reason.
|
|
* @returns {Config|Promise<Config>}
|
|
*/
|
|
function parseConfig (configFilePath, cliOptions, parseOptions) {
|
|
const promiseConfig = parseOptions && parseOptions.promiseConfig === true
|
|
const throwErrors = parseOptions && parseOptions.throwErrors === true
|
|
const shouldSetupLoggerEarly = promiseConfig
|
|
if (shouldSetupLoggerEarly) {
|
|
// `setupFromConfig` provides defaults for `colors` and `logLevel`.
|
|
// `setup` provides defaults for `appenders`
|
|
// The first argument MUST BE an object
|
|
logger.setupFromConfig({})
|
|
}
|
|
function fail () {
|
|
log.error(...arguments)
|
|
if (throwErrors) {
|
|
const errorMessage = Array.from(arguments).join(' ')
|
|
const err = new Error(errorMessage)
|
|
if (promiseConfig) {
|
|
return Promise.reject(err)
|
|
}
|
|
throw err
|
|
} else {
|
|
const warningMessage =
|
|
'The `parseConfig()` function historically called `process.exit(1)`' +
|
|
' when it failed. This behavior is now deprecated and function will' +
|
|
' throw an error in the next major release. To suppress this warning' +
|
|
' pass `throwErrors: true` as a third argument to opt-in into the new' +
|
|
' behavior and adjust your code to respond to the exception' +
|
|
' accordingly.' +
|
|
' Example: `parseConfig(path, cliOptions, { throwErrors: true })`'
|
|
log.warn(warningMessage)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
let configModule
|
|
if (configFilePath) {
|
|
try {
|
|
if (path.extname(configFilePath) === '.ts' && TYPE_SCRIPT_AVAILABLE) {
|
|
require('ts-node').register()
|
|
}
|
|
configModule = require(configFilePath)
|
|
if (typeof configModule === 'object' && typeof configModule.default !== 'undefined') {
|
|
configModule = configModule.default
|
|
}
|
|
} catch (e) {
|
|
const extension = path.extname(configFilePath)
|
|
if (extension === '.coffee' && !COFFEE_SCRIPT_AVAILABLE) {
|
|
log.error('You need to install CoffeeScript.\n npm install coffeescript --save-dev')
|
|
} else if (extension === '.ls' && !LIVE_SCRIPT_AVAILABLE) {
|
|
log.error('You need to install LiveScript.\n npm install LiveScript --save-dev')
|
|
} else if (extension === '.ts' && !TYPE_SCRIPT_AVAILABLE) {
|
|
log.error('You need to install TypeScript.\n npm install typescript ts-node --save-dev')
|
|
}
|
|
return fail('Error in config file!\n ' + e.stack || e)
|
|
}
|
|
if (!helper.isFunction(configModule)) {
|
|
return fail('Config file must export a function!\n' + CONFIG_SYNTAX_HELP)
|
|
}
|
|
} else {
|
|
configModule = () => {} // if no config file path is passed, we define a dummy config module.
|
|
}
|
|
|
|
const config = new Config()
|
|
|
|
// save and reset hostname and listenAddress so we can detect if the user
|
|
// changed them
|
|
const defaultHostname = config.hostname
|
|
config.hostname = null
|
|
const defaultListenAddress = config.listenAddress
|
|
config.listenAddress = null
|
|
|
|
// add the user's configuration in
|
|
config.set(cliOptions)
|
|
|
|
let configModuleReturn
|
|
try {
|
|
configModuleReturn = configModule(config)
|
|
} catch (e) {
|
|
return fail('Error in config file!\n', e)
|
|
}
|
|
function finalizeConfig (config) {
|
|
// merge the config from config file and cliOptions (precedence)
|
|
config.set(cliOptions)
|
|
|
|
// if the user changed listenAddress, but didn't set a hostname, warn them
|
|
if (config.hostname === null && config.listenAddress !== null) {
|
|
log.warn(`ListenAddress was set to ${config.listenAddress} but hostname was left as the default: ` +
|
|
`${defaultHostname}. If your browsers fail to connect, consider changing the hostname option.`)
|
|
}
|
|
// restore values that weren't overwritten by the user
|
|
if (config.hostname === null) {
|
|
config.hostname = defaultHostname
|
|
}
|
|
if (config.listenAddress === null) {
|
|
config.listenAddress = defaultListenAddress
|
|
}
|
|
|
|
// configure the logger as soon as we can
|
|
logger.setup(config.logLevel, config.colors, config.loggers)
|
|
|
|
log.debug(configFilePath ? `Loading config ${configFilePath}` : 'No config file specified.')
|
|
|
|
return normalizeConfig(config, configFilePath)
|
|
}
|
|
|
|
/**
|
|
* Return value is a function or (non-null) object that has a `then` method.
|
|
*
|
|
* @type {boolean}
|
|
* @see {@link https://promisesaplus.com/}
|
|
*/
|
|
const returnIsThenable = (
|
|
(
|
|
(configModuleReturn != null && typeof configModuleReturn === 'object') ||
|
|
typeof configModuleReturn === 'function'
|
|
) && typeof configModuleReturn.then === 'function'
|
|
)
|
|
if (returnIsThenable) {
|
|
if (promiseConfig !== true) {
|
|
const errorMessage =
|
|
'The `parseOptions.promiseConfig` option must be set to `true` to ' +
|
|
'enable promise return values from configuration files. ' +
|
|
'Example: `parseConfig(path, cliOptions, { promiseConfig: true })`'
|
|
return fail(errorMessage)
|
|
}
|
|
return configModuleReturn.then(
|
|
function onKarmaConfigModuleFulfilled (/* ignoredResolutionValue */) {
|
|
return finalizeConfig(config)
|
|
},
|
|
function onKarmaConfigModuleRejected (reason) {
|
|
return fail('Error in config file!\n', reason)
|
|
}
|
|
)
|
|
} else {
|
|
if (promiseConfig) {
|
|
try {
|
|
return Promise.resolve(finalizeConfig(config))
|
|
} catch (exception) {
|
|
return Promise.reject(exception)
|
|
}
|
|
} else {
|
|
return finalizeConfig(config)
|
|
}
|
|
}
|
|
}
|
|
|
|
// PUBLIC API
|
|
exports.parseConfig = parseConfig
|
|
exports.Pattern = Pattern
|
|
exports.createPatternObject = createPatternObject
|
|
exports.Config = Config
|