UNPKG

@revoloo/cypress6

Version:

Cypress.io end to end testing tool

773 lines (609 loc) 21.5 kB
const _ = require('lodash') const R = require('ramda') const path = require('path') const Promise = require('bluebird') const deepDiff = require('return-deep-diff') const errors = require('./errors') const scaffold = require('./scaffold') const { fs } = require('./util/fs') const keys = require('./util/keys') const origin = require('./util/origin') const coerce = require('./util/coerce') const settings = require('./util/settings') const debug = require('debug')('cypress:server:config') const pathHelpers = require('./util/path_helpers') const findSystemNode = require('./util/find_system_node') const { options, breakingOptions } = require('./config_options') const CYPRESS_ENV_PREFIX = 'CYPRESS_' const CYPRESS_ENV_PREFIX_LENGTH = 'CYPRESS_'.length const CYPRESS_RESERVED_ENV_VARS = [ 'CYPRESS_INTERNAL_ENV', ] const CYPRESS_SPECIAL_ENV_VARS = [ 'RECORD_KEY', ] const dashesOrUnderscoresRe = /^(_-)+/ // takes an array and creates an index object of [keyKey]: [valueKey] const createIndex = (arr, keyKey, valueKey) => { return _.reduce(arr, (memo, item) => { if (item[valueKey] !== undefined) { memo[item[keyKey]] = item[valueKey] } return memo }, {}) } const publicConfigKeys = _(options).reject({ isInternal: true }).map('name').value() const breakingKeys = _.map(breakingOptions, 'name') const folders = _(options).filter({ isFolder: true }).map('name').value() const validationRules = createIndex(options, 'name', 'validation') const defaultValues = createIndex(options, 'name', 'defaultValue') const isCypressEnvLike = (key) => { return _.chain(key) .invoke('toUpperCase') .startsWith(CYPRESS_ENV_PREFIX) .value() && !_.includes(CYPRESS_RESERVED_ENV_VARS, key) } const removeEnvPrefix = (key) => { return key.slice(CYPRESS_ENV_PREFIX_LENGTH) } const convertRelativeToAbsolutePaths = (projectRoot, obj, defaults = {}) => { return _.reduce(folders, (memo, folder) => { const val = obj[folder] if ((val != null) && (val !== false)) { memo[folder] = path.resolve(projectRoot, val) } return memo } , {}) } const validateNoBreakingConfig = (cfg) => { return _.each(breakingOptions, ({ name, errorKey, newName, isWarning }) => { if (_.has(cfg, name)) { if (isWarning) { return errors.warning(errorKey, name, newName) } return errors.throw(errorKey, name, newName) } }) } const validate = (cfg, onErr) => { return _.each(cfg, (value, key) => { const validationFn = validationRules[key] // does this key have a validation rule? if (validationFn) { // and is the value different from the default? if (value !== defaultValues[key]) { const result = validationFn(key, value) if (result !== true) { return onErr(result) } } } }) } const validateFile = (file) => { return (settings) => { return validate(settings, (errMsg) => { return errors.throw('SETTINGS_VALIDATION_ERROR', file, errMsg) }) } } const hideSpecialVals = function (val, key) { if (_.includes(CYPRESS_SPECIAL_ENV_VARS, key)) { return keys.hide(val) } return val } // an object with a few utility methods // for easy stubbing from unit tests const utils = { resolveModule (name) { return require.resolve(name) }, // tries to find support or plugins file // returns: // false - if the file should not be set // string - found filename // null - if there is an error finding the file discoverModuleFile (options) { debug('discover module file %o', options) const { filename, isDefault } = options if (!isDefault) { // they have it explicitly set, so it should be there return fs.pathExists(filename) .then((found) => { if (found) { debug('file exists, assuming it will load') return filename } debug('could not find %o', { filename }) return null }) } // support or plugins file doesn't exist on disk? debug(`support file is default, check if ${path.dirname(filename)} exists`) return fs.pathExists(filename) .then((found) => { if (found) { debug('is there index.ts in the support or plugins folder %s?', filename) const tsFilename = path.join(filename, 'index.ts') return fs.pathExists(tsFilename) .then((foundTsFile) => { if (foundTsFile) { debug('found index TS file %s', tsFilename) return tsFilename } // if the directory exists, set it to false so it's ignored debug('setting support or plugins file to false') return false }) } debug('folder does not exist, set to default index.js') // otherwise, set it up to be scaffolded later return path.join(filename, 'index.js') }) }, } module.exports = { utils, getConfigKeys () { return publicConfigKeys }, isValidCypressInternalEnvValue (value) { // names of config environments, see "config/app.yml" const names = ['development', 'test', 'staging', 'production'] return _.includes(names, value) }, allowed (obj = {}) { const propertyNames = publicConfigKeys.concat(breakingKeys) return _.pick(obj, propertyNames) }, get (projectRoot, options = {}) { return Promise.all([ settings.read(projectRoot, options).then(validateFile('cypress.json')), settings.readEnv(projectRoot).then(validateFile('cypress.env.json')), ]) .spread((settings, envFile) => { return this.set({ projectName: this.getNameFromRoot(projectRoot), projectRoot, config: _.cloneDeep(settings), envFile: _.cloneDeep(envFile), options, }) }) }, set (obj = {}) { debug('setting config object') let { projectRoot, projectName, config, envFile, options } = obj // just force config to be an object // so we dont have to do as much // work in our tests if (config == null) { config = {} } debug('config is %o', config) // flatten the object's properties // into the master config object config.envFile = envFile config.projectRoot = projectRoot config.projectName = projectName return this.mergeDefaults(config, options) }, mergeDefaults (config = {}, options = {}) { const resolved = {} _.extend(config, _.pick(options, 'configFile', 'morgan', 'isTextTerminal', 'socketId', 'report', 'browsers')) debug('merged config with options, got %o', config) _ .chain(this.allowed(options)) .omit('env') .omit('browsers') .each((val, key) => { resolved[key] = 'cli' config[key] = val }).value() let url = config.baseUrl if (url) { // replace multiple slashes at the end of string to single slash // so http://localhost/// will be http://localhost/ // https://regexr.com/48rvt config.baseUrl = url.replace(/\/\/+$/, '/') } _.defaults(config, defaultValues) // split out our own app wide env from user env variables // and delete envFile config.env = this.parseEnv(config, options.env, resolved) config.cypressEnv = process.env['CYPRESS_INTERNAL_ENV'] debug('using CYPRESS_INTERNAL_ENV %s', config.cypressEnv) if (!this.isValidCypressInternalEnvValue(config.cypressEnv)) { errors.throw('INVALID_CYPRESS_INTERNAL_ENV', config.cypressEnv) } delete config.envFile // when headless if (config.isTextTerminal && !process.env.CYPRESS_INTERNAL_FORCE_FILEWATCH) { // dont ever watch for file changes config.watchForFileChanges = false // and forcibly reset numTestsKeptInMemory // to zero config.numTestsKeptInMemory = 0 } config = this.setResolvedConfigValues(config, defaultValues, resolved) if (config.port) { config = this.setUrls(config) } config = this.setAbsolutePaths(config, defaultValues) config = this.setParentTestsPaths(config) // validate config again here so that we catch // configuration errors coming from the CLI overrides // or env var overrides validate(config, (errMsg) => { return errors.throw('CONFIG_VALIDATION_ERROR', errMsg) }) validateNoBreakingConfig(config) return this.setSupportFileAndFolder(config) .then(this.setPluginsFile) .then(this.setScaffoldPaths) .then(_.partialRight(this.setNodeBinary, options.onWarning)) }, setResolvedConfigValues (config, defaults, resolved) { const obj = _.clone(config) obj.resolved = this.resolveConfigValues(config, defaults, resolved) debug('resolved config is %o', obj.resolved.browsers) return obj }, // Given an object "resolvedObj" and a list of overrides in "obj" // marks all properties from "obj" inside "resolvedObj" using // {value: obj.val, from: "plugin"} setPluginResolvedOn (resolvedObj, obj) { return _.each(obj, (val, key) => { if (_.isObject(val) && !_.isArray(val) && resolvedObj[key]) { // recurse setting overrides // inside of this nested objected return this.setPluginResolvedOn(resolvedObj[key], val) } resolvedObj[key] = { value: val, from: 'plugin', } }) }, updateWithPluginValues (cfg, overrides) { if (!overrides) { overrides = {} } debug('updateWithPluginValues %o', { cfg, overrides }) // make sure every option returned from the plugins file // passes our validation functions validate(overrides, (errMsg) => { if (cfg.pluginsFile && cfg.projectRoot) { const relativePluginsPath = path.relative(cfg.projectRoot, cfg.pluginsFile) return errors.throw('PLUGINS_CONFIG_VALIDATION_ERROR', relativePluginsPath, errMsg) } return errors.throw('CONFIG_VALIDATION_ERROR', errMsg) }) let originalResolvedBrowsers = cfg && cfg.resolved && cfg.resolved.browsers && R.clone(cfg.resolved.browsers) if (!originalResolvedBrowsers) { // have something to resolve with if plugins return nothing originalResolvedBrowsers = { value: cfg.browsers, from: 'default', } } const diffs = deepDiff(cfg, overrides, true) debug('config diffs %o', diffs) const userBrowserList = diffs && diffs.browsers && R.clone(diffs.browsers) if (userBrowserList) { debug('user browser list %o', userBrowserList) } // for each override go through // and change the resolved values of cfg // to point to the plugin if (diffs) { debug('resolved config before diffs %o', cfg.resolved) this.setPluginResolvedOn(cfg.resolved, diffs) debug('resolved config object %o', cfg.resolved) } // merge cfg into overrides const merged = _.defaultsDeep(diffs, cfg) debug('merged config object %o', merged) // the above _.defaultsDeep combines arrays, // if diffs.browsers = [1] and cfg.browsers = [1, 2] // then the merged result merged.browsers = [1, 2] // which is NOT what we want if (Array.isArray(userBrowserList) && userBrowserList.length) { merged.browsers = userBrowserList merged.resolved.browsers.value = userBrowserList } if (overrides.browsers === null) { // null breaks everything when merging lists debug('replacing null browsers with original list %o', originalResolvedBrowsers) merged.browsers = cfg.browsers if (originalResolvedBrowsers) { merged.resolved.browsers = originalResolvedBrowsers } } debug('merged plugins config %o', merged) return merged }, // combines the default configuration object with values specified in the // configuration file like "cypress.json". Values in configuration file // overwrite the defaults. resolveConfigValues (config, defaults, resolved = {}) { // pick out only known configuration keys return _ .chain(config) .pick(publicConfigKeys) .mapValues((val, key) => { let r const source = (s) => { return { value: val, from: s, } } r = resolved[key] if (r) { if (_.isObject(r)) { return r } return source(r) } if (!(!_.isEqual(config[key], defaults[key]) && key !== 'browsers')) { // "browsers" list is special, since it is dynamic by default // and can only be ovewritten via plugins file return source('default') } return source('config') }).value() }, // instead of the built-in Node process, specify a path to 3rd party Node setNodeBinary: Promise.method((obj, onWarning) => { if (obj.nodeVersion !== 'system') { obj.resolvedNodeVersion = process.versions.node return obj } return findSystemNode.findNodePathAndVersion() .then(({ path, version }) => { obj.resolvedNodePath = path obj.resolvedNodeVersion = version }).catch((err) => { onWarning(errors.get('COULD_NOT_FIND_SYSTEM_NODE', process.versions.node)) obj.resolvedNodeVersion = process.versions.node }).return(obj) }), setScaffoldPaths (obj) { obj = _.clone(obj) obj.integrationExampleName = scaffold.integrationExampleName() obj.integrationExamplePath = path.join(obj.integrationFolder, obj.integrationExampleName) debug('set scaffold paths') return scaffold.fileTree(obj) .then((fileTree) => { debug('got file tree') obj.scaffoldedFiles = fileTree return obj }) }, // async function setSupportFileAndFolder (obj) { if (!obj.supportFile) { return Promise.resolve(obj) } obj = _.clone(obj) // TODO move this logic to find support file into util/path_helpers const sf = obj.supportFile debug(`setting support file ${sf}`) debug(`for project root ${obj.projectRoot}`) return Promise .try(() => { // resolve full path with extension obj.supportFile = utils.resolveModule(sf) return debug('resolved support file %s', obj.supportFile) }).then(() => { if (pathHelpers.checkIfResolveChangedRootFolder(obj.supportFile, sf)) { debug('require.resolve switched support folder from %s to %s', sf, obj.supportFile) // this means the path was probably symlinked, like // /tmp/foo -> /private/tmp/foo // which can confuse the rest of the code // switch it back to "normal" file obj.supportFile = path.join(sf, path.basename(obj.supportFile)) return fs.pathExists(obj.supportFile) .then((found) => { if (!found) { errors.throw('SUPPORT_FILE_NOT_FOUND', obj.supportFile, obj.configFile || defaultValues.configFile) } return debug('switching to found file %s', obj.supportFile) }) } }).catch({ code: 'MODULE_NOT_FOUND' }, () => { debug('support JS module %s does not load', sf) const loadingDefaultSupportFile = sf === path.resolve(obj.projectRoot, defaultValues.supportFile) return utils.discoverModuleFile({ filename: sf, isDefault: loadingDefaultSupportFile, projectRoot: obj.projectRoot, }) .then((result) => { if (result === null) { const configFile = obj.configFile || defaultValues.configFile return errors.throw('SUPPORT_FILE_NOT_FOUND', path.resolve(obj.projectRoot, sf), configFile) } debug('setting support file to %o', { result }) obj.supportFile = result return obj }) }) .then(() => { if (obj.supportFile) { // set config.supportFolder to its directory obj.supportFolder = path.dirname(obj.supportFile) debug(`set support folder ${obj.supportFolder}`) } return obj }) }, // set pluginsFile to an absolute path with the following rules: // - do nothing if pluginsFile is falsey // - look up the absolute path via node, so 'cypress/plugins' can resolve // to 'cypress/plugins/index.js' or 'cypress/plugins/index.coffee' // - if not found // * and the pluginsFile is set to the default // - and the path to the pluginsFile directory exists // * assume the user doesn't need a pluginsFile, set it to false // so it's ignored down the pipeline // - and the path to the pluginsFile directory does not exist // * set it to cypress/plugins/index.js, it will get scaffolded // * and the pluginsFile is NOT set to the default // - throw an error, because it should be there if the user // explicitly set it setPluginsFile: Promise.method((obj) => { if (!obj.pluginsFile) { return obj } obj = _.clone(obj) const { pluginsFile, } = obj debug(`setting plugins file ${pluginsFile}`) debug(`for project root ${obj.projectRoot}`) return Promise .try(() => { // resolve full path with extension obj.pluginsFile = utils.resolveModule(pluginsFile) return debug(`set pluginsFile to ${obj.pluginsFile}`) }).catch({ code: 'MODULE_NOT_FOUND' }, () => { debug('plugins module does not exist %o', { pluginsFile }) const isLoadingDefaultPluginsFile = pluginsFile === path.resolve(obj.projectRoot, defaultValues.pluginsFile) return utils.discoverModuleFile({ filename: pluginsFile, isDefault: isLoadingDefaultPluginsFile, projectRoot: obj.projectRoot, }) .then((result) => { if (result === null) { return errors.throw('PLUGINS_FILE_ERROR', path.resolve(obj.projectRoot, pluginsFile)) } debug('setting plugins file to %o', { result }) obj.pluginsFile = result return obj }) }).return(obj) }), setParentTestsPaths (obj) { // projectRoot: "/path/to/project" // integrationFolder: "/path/to/project/cypress/integration" // componentFolder: "/path/to/project/cypress/components" // parentTestsFolder: "/path/to/project/cypress" // parentTestsFolderDisplay: "project/cypress" obj = _.clone(obj) const ptfd = (obj.parentTestsFolder = path.dirname(obj.integrationFolder)) const prd = path.dirname(obj.projectRoot != null ? obj.projectRoot : '') obj.parentTestsFolderDisplay = path.relative(prd, ptfd) return obj }, setAbsolutePaths (obj, defaults) { let pr obj = _.clone(obj) // if we have a projectRoot pr = obj.projectRoot if (pr) { // reset fileServerFolder to be absolute // obj.fileServerFolder = path.resolve(pr, obj.fileServerFolder) // and do the same for all the rest _.extend(obj, convertRelativeToAbsolutePaths(pr, obj, defaults)) } return obj }, setUrls (obj) { obj = _.clone(obj) // TODO: rename this to be proxyServer const proxyUrl = `http://localhost:${obj.port}` const rootUrl = obj.baseUrl ? origin(obj.baseUrl) : proxyUrl _.extend(obj, { proxyUrl, browserUrl: rootUrl + obj.clientRoute, reporterUrl: rootUrl + obj.reporterRoute, xhrUrl: obj.namespace + obj.xhrRoute, }) return obj }, parseEnv (cfg, envCLI, resolved = {}) { const envVars = (resolved.env = {}) const resolveFrom = (from, obj = {}) => { return _.each(obj, (val, key) => { return envVars[key] = { value: val, from, } }) } const envCfg = cfg.env != null ? cfg.env : {} const envFile = cfg.envFile != null ? cfg.envFile : {} let envProc = this.getProcessEnvVars(process.env) || {} envCLI = envCLI != null ? envCLI : {} const matchesConfigKey = function (key) { if (_.has(defaultValues, key)) { return key } key = key.toLowerCase().replace(dashesOrUnderscoresRe, '') key = _.camelCase(key) if (_.has(defaultValues, key)) { return key } } const configFromEnv = _.reduce(envProc, (memo, val, key) => { let cfgKey cfgKey = matchesConfigKey(key) if (cfgKey) { // only change the value if it hasnt been // set by the CLI. override default + config if (resolved[cfgKey] !== 'cli') { cfg[cfgKey] = val resolved[cfgKey] = { value: val, from: 'env', } } memo.push(key) } return memo } , []) envProc = _.chain(envProc) .omit(configFromEnv) .mapValues(hideSpecialVals) .value() resolveFrom('config', envCfg) resolveFrom('envFile', envFile) resolveFrom('env', envProc) resolveFrom('cli', envCLI) // envCfg is from cypress.json // envFile is from cypress.env.json // envProc is from process env vars // envCLI is from CLI arguments return _.extend(envCfg, envFile, envProc, envCLI) }, getProcessEnvVars (obj = {}) { return _.reduce(obj, (memo, value, key) => { if (isCypressEnvLike(key)) { memo[removeEnvPrefix(key)] = coerce(value) } return memo } , {}) }, getResolvedRuntimeConfig (config, runtimeConfig) { const resolvedRuntimeFields = _.mapValues(runtimeConfig, (v) => ({ value: v, from: 'runtime' })) return { ...config, ...runtimeConfig, resolved: { ...config.resolved, ...resolvedRuntimeFields }, } }, getNameFromRoot (root = '') { return path.basename(root) }, }