1 | 'use strict'
|
2 |
|
3 | const path = require('path')
|
4 | const assert = require('assert')
|
5 |
|
6 | const logger = require('./logger')
|
7 | const log = logger.create('config')
|
8 | const helper = require('./helper')
|
9 | const constant = require('./constants')
|
10 |
|
11 | const _ = require('lodash')
|
12 |
|
13 | let COFFEE_SCRIPT_AVAILABLE = false
|
14 | let LIVE_SCRIPT_AVAILABLE = false
|
15 | let TYPE_SCRIPT_AVAILABLE = false
|
16 |
|
17 | try {
|
18 | require('coffeescript').register()
|
19 | COFFEE_SCRIPT_AVAILABLE = true
|
20 | } catch (e) {}
|
21 |
|
22 |
|
23 |
|
24 | try {
|
25 | require('LiveScript')
|
26 | LIVE_SCRIPT_AVAILABLE = true
|
27 | } catch (e) {}
|
28 |
|
29 | try {
|
30 | require('ts-node').register()
|
31 | TYPE_SCRIPT_AVAILABLE = true
|
32 | } catch (e) {}
|
33 |
|
34 | class Pattern {
|
35 | constructor (pattern, served, included, watched, nocache, type, isBinary) {
|
36 | this.pattern = pattern
|
37 | this.served = helper.isDefined(served) ? served : true
|
38 | this.included = helper.isDefined(included) ? included : true
|
39 | this.watched = helper.isDefined(watched) ? watched : true
|
40 | this.nocache = helper.isDefined(nocache) ? nocache : false
|
41 | this.weight = helper.mmPatternWeight(pattern)
|
42 | this.type = type
|
43 | this.isBinary = isBinary
|
44 | }
|
45 |
|
46 | compare (other) {
|
47 | return helper.mmComparePatternWeights(this.weight, other.weight)
|
48 | }
|
49 | }
|
50 |
|
51 | class UrlPattern extends Pattern {
|
52 | constructor (url, type) {
|
53 | super(url, false, true, false, false, type)
|
54 | }
|
55 | }
|
56 |
|
57 | function createPatternObject (pattern) {
|
58 | if (pattern && helper.isString(pattern)) {
|
59 | return helper.isUrlAbsolute(pattern)
|
60 | ? new UrlPattern(pattern)
|
61 | : new Pattern(pattern)
|
62 | } else if (helper.isObject(pattern) && pattern.pattern && helper.isString(pattern.pattern)) {
|
63 | return helper.isUrlAbsolute(pattern.pattern)
|
64 | ? new UrlPattern(pattern.pattern, pattern.type)
|
65 | : new Pattern(pattern.pattern, pattern.served, pattern.included, pattern.watched, pattern.nocache, pattern.type)
|
66 | } else {
|
67 | log.warn(`Invalid pattern ${pattern}!\n\tExpected string or object with "pattern" property.`)
|
68 | return new Pattern(null, false, false, false, false)
|
69 | }
|
70 | }
|
71 |
|
72 | function normalizeUrl (url) {
|
73 | if (!url.startsWith('/')) {
|
74 | url = `/${url}`
|
75 | }
|
76 |
|
77 | if (!url.endsWith('/')) {
|
78 | url = url + '/'
|
79 | }
|
80 |
|
81 | return url
|
82 | }
|
83 |
|
84 | function normalizeUrlRoot (urlRoot) {
|
85 | const normalizedUrlRoot = normalizeUrl(urlRoot)
|
86 |
|
87 | if (normalizedUrlRoot !== urlRoot) {
|
88 | log.warn(`urlRoot normalized to "${normalizedUrlRoot}"`)
|
89 | }
|
90 |
|
91 | return normalizedUrlRoot
|
92 | }
|
93 |
|
94 | function normalizeProxyPath (proxyPath) {
|
95 | const normalizedProxyPath = normalizeUrl(proxyPath)
|
96 |
|
97 | if (normalizedProxyPath !== proxyPath) {
|
98 | log.warn(`proxyPath normalized to "${normalizedProxyPath}"`)
|
99 | }
|
100 |
|
101 | return normalizedProxyPath
|
102 | }
|
103 |
|
104 | function normalizeConfig (config, configFilePath) {
|
105 | function basePathResolve (relativePath) {
|
106 | if (helper.isUrlAbsolute(relativePath)) {
|
107 | return relativePath
|
108 | } else if (helper.isDefined(config.basePath) && helper.isDefined(relativePath)) {
|
109 | return path.resolve(config.basePath, relativePath)
|
110 | } else {
|
111 | return ''
|
112 | }
|
113 | }
|
114 |
|
115 | function createPatternMapper (resolve) {
|
116 | return (objectPattern) => Object.assign(objectPattern, { pattern: resolve(objectPattern.pattern) })
|
117 | }
|
118 |
|
119 | if (helper.isString(configFilePath)) {
|
120 | config.basePath = path.resolve(path.dirname(configFilePath), config.basePath)
|
121 | config.exclude.push(configFilePath)
|
122 | } else {
|
123 | config.basePath = path.resolve(config.basePath || '.')
|
124 | }
|
125 |
|
126 | config.files = config.files.map(createPatternObject).map(createPatternMapper(basePathResolve))
|
127 | config.exclude = config.exclude.map(basePathResolve)
|
128 | config.customContextFile = config.customContextFile && basePathResolve(config.customContextFile)
|
129 | config.customDebugFile = config.customDebugFile && basePathResolve(config.customDebugFile)
|
130 | config.customClientContextFile = config.customClientContextFile && basePathResolve(config.customClientContextFile)
|
131 |
|
132 |
|
133 | config.basePath = helper.normalizeWinPath(config.basePath)
|
134 | config.files = config.files.map(createPatternMapper(helper.normalizeWinPath))
|
135 | config.exclude = config.exclude.map(helper.normalizeWinPath)
|
136 | config.customContextFile = helper.normalizeWinPath(config.customContextFile)
|
137 | config.customDebugFile = helper.normalizeWinPath(config.customDebugFile)
|
138 | config.customClientContextFile = helper.normalizeWinPath(config.customClientContextFile)
|
139 |
|
140 |
|
141 | config.urlRoot = normalizeUrlRoot(config.urlRoot)
|
142 |
|
143 |
|
144 | if (config.upstreamProxy) {
|
145 | const proxy = config.upstreamProxy
|
146 | proxy.path = helper.isDefined(proxy.path) ? normalizeProxyPath(proxy.path) : '/'
|
147 | proxy.hostname = helper.isDefined(proxy.hostname) ? proxy.hostname : 'localhost'
|
148 | proxy.port = helper.isDefined(proxy.port) ? proxy.port : 9875
|
149 |
|
150 |
|
151 | proxy.protocol = (proxy.protocol || 'http').split(':')[0] + ':'
|
152 | if (proxy.protocol.match(/https?:/) === null) {
|
153 | log.warn(`"${proxy.protocol}" is not a supported upstream proxy protocol, defaulting to "http:"`)
|
154 | proxy.protocol = 'http:'
|
155 | }
|
156 | }
|
157 |
|
158 |
|
159 | config.protocol = (config.protocol || 'http').split(':')[0] + ':'
|
160 | if (config.protocol.match(/https?:/) === null) {
|
161 | log.warn(`"${config.protocol}" is not a supported protocol, defaulting to "http:"`)
|
162 | config.protocol = 'http:'
|
163 | }
|
164 |
|
165 | if (config.proxies && Object.prototype.hasOwnProperty.call(config.proxies, config.urlRoot)) {
|
166 | log.warn(`"${config.urlRoot}" is proxied, you should probably change urlRoot to avoid conflicts`)
|
167 | }
|
168 |
|
169 | if (config.singleRun && config.autoWatch) {
|
170 | log.debug('autoWatch set to false, because of singleRun')
|
171 | config.autoWatch = false
|
172 | }
|
173 |
|
174 | if (config.runInParent) {
|
175 | log.debug('useIframe set to false, because using runInParent')
|
176 | config.useIframe = false
|
177 | }
|
178 |
|
179 | if (!config.singleRun && !config.useIframe && config.runInParent) {
|
180 | log.debug('singleRun set to true, because using runInParent')
|
181 | config.singleRun = true
|
182 | }
|
183 |
|
184 | if (helper.isString(config.reporters)) {
|
185 | config.reporters = config.reporters.split(',')
|
186 | }
|
187 |
|
188 | if (config.client && config.client.args) {
|
189 | assert(Array.isArray(config.client.args), 'Invalid configuration: client.args must be an array of strings')
|
190 | }
|
191 |
|
192 | if (config.browsers) {
|
193 | assert(Array.isArray(config.browsers), 'Invalid configuration: browsers option must be an array')
|
194 | }
|
195 |
|
196 | if (config.formatError) {
|
197 | assert(helper.isFunction(config.formatError), 'Invalid configuration: formatError option must be a function.')
|
198 | }
|
199 |
|
200 | if (config.processKillTimeout) {
|
201 | assert(helper.isNumber(config.processKillTimeout), 'Invalid configuration: processKillTimeout option must be a number.')
|
202 | }
|
203 |
|
204 | if (config.browserSocketTimeout) {
|
205 | assert(helper.isNumber(config.browserSocketTimeout), 'Invalid configuration: browserSocketTimeout option must be a number.')
|
206 | }
|
207 |
|
208 | if (config.pingTimeout) {
|
209 | assert(helper.isNumber(config.pingTimeout), 'Invalid configuration: pingTimeout option must be a number.')
|
210 | }
|
211 |
|
212 | const defaultClient = config.defaultClient || {}
|
213 | Object.keys(defaultClient).forEach(function (key) {
|
214 | const option = config.client[key]
|
215 | config.client[key] = helper.isDefined(option) ? option : defaultClient[key]
|
216 | })
|
217 |
|
218 |
|
219 | const preprocessors = config.preprocessors || {}
|
220 | const normalizedPreprocessors = config.preprocessors = Object.create(null)
|
221 |
|
222 | Object.keys(preprocessors).forEach(function (pattern) {
|
223 | const normalizedPattern = helper.normalizeWinPath(basePathResolve(pattern))
|
224 |
|
225 | normalizedPreprocessors[normalizedPattern] = helper.isString(preprocessors[pattern])
|
226 | ? [preprocessors[pattern]] : preprocessors[pattern]
|
227 | })
|
228 |
|
229 |
|
230 | const module = Object.create(null)
|
231 | let hasSomeInlinedPlugin = false
|
232 | const types = ['launcher', 'preprocessor', 'reporter']
|
233 |
|
234 | types.forEach(function (type) {
|
235 | const definitions = config[`custom${helper.ucFirst(type)}s`] || {}
|
236 |
|
237 | Object.keys(definitions).forEach(function (name) {
|
238 | const definition = definitions[name]
|
239 |
|
240 | if (!helper.isObject(definition)) {
|
241 | return log.warn(`Can not define ${type} ${name}. Definition has to be an object.`)
|
242 | }
|
243 |
|
244 | if (!helper.isString(definition.base)) {
|
245 | return log.warn(`Can not define ${type} ${name}. Missing base ${type}.`)
|
246 | }
|
247 |
|
248 | const token = type + ':' + definition.base
|
249 | const locals = {
|
250 | args: ['value', definition]
|
251 | }
|
252 |
|
253 | module[type + ':' + name] = ['factory', function (injector) {
|
254 | const plugin = injector.createChild([locals], [token]).get(token)
|
255 | if (type === 'launcher' && helper.isDefined(definition.displayName)) {
|
256 | plugin.displayName = definition.displayName
|
257 | }
|
258 | return plugin
|
259 | }]
|
260 | hasSomeInlinedPlugin = true
|
261 | })
|
262 | })
|
263 |
|
264 | if (hasSomeInlinedPlugin) {
|
265 | config.plugins.push(module)
|
266 | }
|
267 |
|
268 | return config
|
269 | }
|
270 |
|
271 | class Config {
|
272 | constructor () {
|
273 | this.LOG_DISABLE = constant.LOG_DISABLE
|
274 | this.LOG_ERROR = constant.LOG_ERROR
|
275 | this.LOG_WARN = constant.LOG_WARN
|
276 | this.LOG_INFO = constant.LOG_INFO
|
277 | this.LOG_DEBUG = constant.LOG_DEBUG
|
278 |
|
279 |
|
280 | this.frameworks = []
|
281 | this.protocol = 'http:'
|
282 | this.port = constant.DEFAULT_PORT
|
283 | this.listenAddress = constant.DEFAULT_LISTEN_ADDR
|
284 | this.hostname = constant.DEFAULT_HOSTNAME
|
285 | this.httpsServerConfig = {}
|
286 | this.basePath = ''
|
287 | this.files = []
|
288 | this.browserConsoleLogOptions = {
|
289 | level: 'debug',
|
290 | format: '%b %T: %m',
|
291 | terminal: true
|
292 | }
|
293 | this.customContextFile = null
|
294 | this.customDebugFile = null
|
295 | this.customClientContextFile = null
|
296 | this.exclude = []
|
297 | this.logLevel = constant.LOG_INFO
|
298 | this.colors = true
|
299 | this.autoWatch = true
|
300 | this.autoWatchBatchDelay = 250
|
301 | this.restartOnFileChange = false
|
302 | this.usePolling = process.platform === 'linux'
|
303 | this.reporters = ['progress']
|
304 | this.singleRun = false
|
305 | this.browsers = []
|
306 | this.captureTimeout = 60000
|
307 | this.pingTimeout = 5000
|
308 | this.proxies = {}
|
309 | this.proxyValidateSSL = true
|
310 | this.preprocessors = {}
|
311 | this.preprocessor_priority = {}
|
312 | this.urlRoot = '/'
|
313 | this.upstreamProxy = undefined
|
314 | this.reportSlowerThan = 0
|
315 | this.loggers = [constant.CONSOLE_APPENDER]
|
316 | this.transports = ['polling', 'websocket']
|
317 | this.forceJSONP = false
|
318 | this.plugins = ['karma-*']
|
319 | this.defaultClient = this.client = {
|
320 | args: [],
|
321 | useIframe: true,
|
322 | runInParent: false,
|
323 | captureConsole: true,
|
324 | clearContext: true
|
325 | }
|
326 | this.browserDisconnectTimeout = 2000
|
327 | this.browserDisconnectTolerance = 0
|
328 | this.browserNoActivityTimeout = 30000
|
329 | this.processKillTimeout = 2000
|
330 | this.concurrency = Infinity
|
331 | this.failOnEmptyTestSuite = true
|
332 | this.retryLimit = 2
|
333 | this.detached = false
|
334 | this.crossOriginAttribute = true
|
335 | this.browserSocketTimeout = 20000
|
336 | }
|
337 |
|
338 | set (newConfig) {
|
339 | _.mergeWith(this, newConfig, (obj, src) => {
|
340 |
|
341 | if (Array.isArray(src)) {
|
342 | return src
|
343 | }
|
344 | })
|
345 | }
|
346 | }
|
347 |
|
348 | const CONFIG_SYNTAX_HELP = ' module.exports = function(config) {\n' +
|
349 | ' config.set({\n' +
|
350 | ' // your config\n' +
|
351 | ' });\n' +
|
352 | ' };\n'
|
353 |
|
354 | function parseConfig (configFilePath, cliOptions) {
|
355 | let configModule
|
356 | if (configFilePath) {
|
357 | try {
|
358 | configModule = require(configFilePath)
|
359 | if (typeof configModule === 'object' && typeof configModule.default !== 'undefined') {
|
360 | configModule = configModule.default
|
361 | }
|
362 | } catch (e) {
|
363 | log.error('Error in config file!\n ' + e.stack || e)
|
364 |
|
365 | const extension = path.extname(configFilePath)
|
366 | if (extension === '.coffee' && !COFFEE_SCRIPT_AVAILABLE) {
|
367 | log.error('You need to install CoffeeScript.\n npm install coffeescript --save-dev')
|
368 | } else if (extension === '.ls' && !LIVE_SCRIPT_AVAILABLE) {
|
369 | log.error('You need to install LiveScript.\n npm install LiveScript --save-dev')
|
370 | } else if (extension === '.ts' && !TYPE_SCRIPT_AVAILABLE) {
|
371 | log.error('You need to install TypeScript.\n npm install typescript ts-node --save-dev')
|
372 | }
|
373 | return process.exit(1)
|
374 | }
|
375 | if (!helper.isFunction(configModule)) {
|
376 | log.error('Config file must export a function!\n' + CONFIG_SYNTAX_HELP)
|
377 | return process.exit(1)
|
378 | }
|
379 | } else {
|
380 | configModule = () => {}
|
381 | }
|
382 |
|
383 | const config = new Config()
|
384 |
|
385 |
|
386 |
|
387 | const defaultHostname = config.hostname
|
388 | config.hostname = null
|
389 | const defaultListenAddress = config.listenAddress
|
390 | config.listenAddress = null
|
391 |
|
392 |
|
393 | config.set(cliOptions)
|
394 |
|
395 | try {
|
396 | configModule(config)
|
397 | } catch (e) {
|
398 | log.error('Error in config file!\n', e)
|
399 | return process.exit(1)
|
400 | }
|
401 |
|
402 |
|
403 | config.set(cliOptions)
|
404 |
|
405 |
|
406 | if (config.hostname === null && config.listenAddress !== null) {
|
407 | log.warn(`ListenAddress was set to ${config.listenAddress} but hostname was left as the default: ` +
|
408 | `${defaultHostname}. If your browsers fail to connect, consider changing the hostname option.`)
|
409 | }
|
410 |
|
411 | if (config.hostname === null) {
|
412 | config.hostname = defaultHostname
|
413 | }
|
414 | if (config.listenAddress === null) {
|
415 | config.listenAddress = defaultListenAddress
|
416 | }
|
417 |
|
418 |
|
419 | logger.setup(config.logLevel, config.colors, config.loggers)
|
420 |
|
421 | log.debug(configFilePath ? `Loading config ${configFilePath}` : 'No config file specified.')
|
422 |
|
423 | return normalizeConfig(config, configFilePath)
|
424 | }
|
425 |
|
426 |
|
427 | exports.parseConfig = parseConfig
|
428 | exports.Pattern = Pattern
|
429 | exports.createPatternObject = createPatternObject
|
430 | exports.Config = Config
|