'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 (e) {} // 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 (e) {} try { require('ts-node') TYPE_SCRIPT_AVAILABLE = true } catch (e) {} class Pattern { constructor (pattern, served, included, watched, nocache, type, isBinary) { 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 } compare (other) { return helper.mmComparePatternWeights(this.weight, other.weight) } } class UrlPattern extends Pattern { constructor (url, type) { super(url, false, true, false, false, type) } } 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) : 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 } 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} */ 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