diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 55275d0d1177..aa76be4eb5db 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -278,6 +278,9 @@ Here is a list of the core packages in this repository with a short description, | [example](./packages/example) | `@packages/example` | Our example kitchen-sink application. | | [extension](./packages/extension) | `@packages/extension` | The Cypress Chrome browser extension | | [https-proxy](./packages/https-proxy) | `@packages/https-proxy` | This does https proxy for handling http certs and traffic. | + | [net-stubbing](./packages/net-stubbing) | `@packages/net-stubbing` | Contains server side code for Cypress' network stubbing features. | + | [network](./packages/networ ) | `@packages/network` | Various utilities related to networking. | + | [proxy](./packages/proxy) | `@packages/proxy` | Code for Cypress' network proxy layer. | | [launcher](./packages/launcher) | `@packages/launcher` | Finds and launches browsers installed on your system. | | [reporter](./packages/reporter) | `@packages/reporter` | The reporter shows the running results of the tests (The Command Log UI). | | [root](./packages/root) | `@packages/root` | Dummy package pointing at the root of the repository. | @@ -285,6 +288,7 @@ Here is a list of the core packages in this repository with a short description, | [runner-ct](./packages/runner-ct) | `@packages/runner-ct` | The runner for component testing | | [runner-shared](./packages/runner-shared) | `@packages/runner-shared` | The shared components between the `runner` and the `runner-ct` packages | | [server](./packages/server) | `@packages/server` | The <3 of Cypress. This orchestrates everything. The backend node process. | + | [server-ct](./packages/server-ct) | `@packages/server-ct` | Some Component Testing specific overrides. Mostly extends functionality from `@packages/server` | | [socket](./packages/socket) | `@packages/socket` | A wrapper around socket.io to provide common libraries. | | [static](./packages/static) | `@packages/static` | Serves static assets used in the Cypress GUI. | | [ts](./packages/ts) | `@packages/ts` | A centralized version of typescript. | @@ -295,10 +299,15 @@ Here is a list of the npm packages in this repository: | Folder Name | Package Name | Purpose | | :----------------------------------------------------- | :--------------------------------- | :--------------------------------------------------------------------------- | - | [eslint-plugin-dev](./npm/eslint-plugin-dev) | `@cypress/eslint-plugin-dev` | Eslint plugin for internal development. | - | [react](./npm/react) | `@cypress/react` | Cypress component testing for React. | - | [vue](./npm/vue) | `@cypress/vue` | Cypress component testing for Vue. | - | [webpack-preprocessor](./npm/webpack-preprocessor) | `@cypress/webpack-preprocessor` | Cypress preprocessor for bundling JavaScript via webpack. | + | [angular](./npm/angular) | `@cypress/angular` | Cypress component testing for Angular. | + | [create-cypress-tests](./npm/create-cypress-tests) | `@cypress/create-cypress-tests` | Tooling to scaffold Cypress configuration and demo test files. | + | [eslint-plugin-dev](./npm/eslint-plugin-dev) | `@cypress/eslint-plugin-dev` | Eslint plugin for internal development. | + | [mount-utils](./npm/mount-utils) | `@cypress/mount-utils` | Common functionality for Vue/React/Angular adapters. | + | [react](./npm/react) | `@cypress/react` | Cypress component testing for React. | + | [vite-dev-server](./npm/vite-dev-server) | `@cypress/vite-dev-server` | Vite powered dev server for Component Testing. | + | [webpack-preprocessor](./npm/webpack-preprocessor) | `@cypress/webpack-preprocessor` | Cypress preprocessor for bundling JavaScript via webpack. | + | [webpack-dev-server](./npm/webpack-dev-server) | `@cypress/webpack-dev-server` | Webpack powered dev server for Component Testing. | + | [vue](./npm/vue) | `@cypress/vue` | Cypress component testing for Vue. | We try to tag all issues with a `pkg/` or `npm/` tag describing the appropriate package the work is required in. For public packages, we use their qualified package name: For example, issues relating to the webpack preprocessor are tagged under [`npm: @cypress/webpack-preprocessor`](https://github.com/cypress-io/cypress/labels/npm%3A%20%40cypress%2Fwebpack-preprocessor) label and issues related to the `driver` package are tagged with the [`pkg/driver`](https://github.com/cypress-io/cypress/labels/pkg%2Fdriver) label. diff --git a/browser-versions.json b/browser-versions.json index 126f3c3d71cf..720927f9d16c 100644 --- a/browser-versions.json +++ b/browser-versions.json @@ -1,4 +1,4 @@ { - "chrome:beta": "93.0.4577.42", + "chrome:beta": "93.0.4577.51", "chrome:stable": "92.0.4515.159" } diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 131e43bbda4a..6e577cc4c2c6 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -982,7 +982,7 @@ declare namespace Cypress { * .its('contentType') * .should('eq', 'text/html') */ - document(options?: Partial): Chainable + document(options?: Partial): Chainable /** * Iterate through an array like structure (arrays or objects with a length property). diff --git a/npm/vite-dev-server/package.json b/npm/vite-dev-server/package.json index a1ed755f5a0d..8ba2c2d6ee7e 100644 --- a/npm/vite-dev-server/package.json +++ b/npm/vite-dev-server/package.json @@ -34,7 +34,8 @@ "files": [ "dist", "client", - "index.html" + "index.html", + "index.d.ts" ], "license": "MIT", "repository": { diff --git a/packages/driver/cypress/integration/commands/assertions_spec.js b/packages/driver/cypress/integration/commands/assertions_spec.js index 96e960b6a0ba..1135fbb6c836 100644 --- a/packages/driver/cypress/integration/commands/assertions_spec.js +++ b/packages/driver/cypress/integration/commands/assertions_spec.js @@ -1197,6 +1197,20 @@ describe('src/cy/commands/assertions', () => { }) }) }) + + describe('escape markdown', () => { + // https://github.com/cypress-io/cypress/issues/17357 + it('images', (done) => { + const text = 'hello world ![JSDoc example](/slides/img/jsdoc.png)' + const result = 'hello world ``![JSDoc example](/slides/img/jsdoc.png)``' + + expectMarkdown( + () => expect(text).to.equal(text), + `expected **${result}** to equal **${result}**`, + done, + ) + }) + }) }) context('chai overrides', () => { diff --git a/packages/driver/src/cy/chai.js b/packages/driver/src/cy/chai.js index 101396a9e11b..7de0ee2bdaca 100644 --- a/packages/driver/src/cy/chai.js +++ b/packages/driver/src/cy/chai.js @@ -28,6 +28,7 @@ const leadingWhitespaces = /\*\*'\s*/g const trailingWhitespaces = /\s*'\*\*/g const whitespace = /\s/g const valueHasLeadingOrTrailingWhitespaces = /\*\*'\s+|\s+'\*\*/g +const imageMarkdown = /!\[.*?\]\(.*?\)/g let assertProto = null let matchProto = null @@ -130,6 +131,10 @@ chai.use((chai, u) => { }) } + const escapeMarkdown = (message) => { + return message.replace(imageMarkdown, '``$&``') + } + const replaceArgMessages = (args, str) => { return _.reduce(args, (memo, value, index) => { if (_.isString(value)) { @@ -447,6 +452,7 @@ chai.use((chai, u) => { const actual = chaiUtils.getActual(this, customArgs) message = removeOrKeepSingleQuotesBetweenStars(message) + message = escapeMarkdown(message) try { assertProto.apply(this, args) diff --git a/packages/server/lib/config.js b/packages/server/lib/config.js deleted file mode 100644 index 4320f4a4ba94..000000000000 --- a/packages/server/lib/config.js +++ /dev/null @@ -1,771 +0,0 @@ -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 = {} - - config.rawJson = _.cloneDeep(config) - - _.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) - - 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) - }, - -} diff --git a/packages/server/lib/config.ts b/packages/server/lib/config.ts new file mode 100644 index 000000000000..37afe5e10e60 --- /dev/null +++ b/packages/server/lib/config.ts @@ -0,0 +1,765 @@ +import _ from 'lodash' +import R from 'ramda' +import path from 'path' +import Promise from 'bluebird' +import deepDiff from 'return-deep-diff' + +import errors from './errors' +import scaffold from './scaffold' +import { fs } from './util/fs' +import keys from './util/keys' +import origin from './util/origin' +import settings from './util/settings' +import Debug from 'debug' +import pathHelpers from './util/path_helpers' +import findSystemNode from './util/find_system_node' + +const debug = Debug('cypress:server:config') + +import { options, breakingOptions } from './config_options' +import { getProcessEnvVars } from './util/config' + +export const RESOLVED_FROM = ['plugin', 'env', 'default', 'runtime', 'config'] as const + +export type ResolvedConfigurationOptionSource = typeof RESOLVED_FROM[number] + +export type ResolvedFromConfig = { + from: ResolvedConfigurationOptionSource + value: ResolvedConfigurationOptionSource +} + +export type ResolvedConfigurationOptions = Partial<{ + [x in keyof Cypress.ResolvedConfigOptions]: ResolvedFromConfig +}> + +export const CYPRESS_ENV_PREFIX = 'CYPRESS_' + +export const CYPRESS_ENV_PREFIX_LENGTH = 'CYPRESS_'.length + +export const CYPRESS_RESERVED_ENV_VARS = [ + 'CYPRESS_INTERNAL_ENV', +] + +export 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: Record = createIndex(options, 'name', 'defaultValue') + +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 +export 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') + }) + }, +} + +export function getConfigKeys () { + return publicConfigKeys +} + +export function isValidCypressInternalEnvValue (value) { + // names of config environments, see "config/app.yml" + const names = ['development', 'test', 'staging', 'production'] + + return _.includes(names, value) +} + +export function allowed (obj = {}) { + const propertyNames = publicConfigKeys.concat(breakingKeys) + + return _.pick(obj, propertyNames) +} + +export function 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 set({ + projectName: getNameFromRoot(projectRoot), + projectRoot, + config: _.cloneDeep(settings), + envFile: _.cloneDeep(envFile), + options, + }) + }) +} + +export function set (obj: Record = {}) { + 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 mergeDefaults(config, options) +} + +export function mergeDefaults (config: Record = {}, options: Record = {}) { + const resolved = {} + + config.rawJson = _.cloneDeep(config) + + _.extend(config, _.pick(options, 'configFile', 'morgan', 'isTextTerminal', 'socketId', 'report', 'browsers')) + debug('merged config with options, got %o', config) + + _ + .chain(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 = parseEnv(config, options.env, resolved) + + config.cypressEnv = process.env['CYPRESS_INTERNAL_ENV'] + debug('using CYPRESS_INTERNAL_ENV %s', config.cypressEnv) + if (!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 = setResolvedConfigValues(config, defaultValues, resolved) + + if (config.port) { + config = setUrls(config) + } + + config = setAbsolutePaths(config, defaultValues) + + config = 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 setSupportFileAndFolder(config) + .then(setPluginsFile) + .then(setScaffoldPaths) + .then(_.partialRight(setNodeBinary, options.onWarning)) +} + +export function setResolvedConfigValues (config, defaults, resolved) { + const obj = _.clone(config) + + obj.resolved = 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"} +export function setPluginResolvedOn (resolvedObj: Record, obj: Record) { + return _.each(obj, (val, key) => { + if (_.isObject(val) && !_.isArray(val) && resolvedObj[key]) { + // recurse setting overrides + // inside of objected + return setPluginResolvedOn(resolvedObj[key], val) + } + + const valueFrom: ResolvedFromConfig = { + value: val, + from: 'plugin', + } + + resolvedObj[key] = valueFrom + }) +} + +export function 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', + } as ResolvedFromConfig + } + + 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) + 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. +export function resolveConfigValues (config, defaults, resolved = {}) { + // pick out only known configuration keys + return _ + .chain(config) + .pick(publicConfigKeys) + .mapValues((val, key) => { + let r + const source = (s: ResolvedConfigurationOptionSource): ResolvedFromConfig => { + 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 +export const 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) +}) + +export function setScaffoldPaths (obj) { + obj = _.clone(obj) + + debug('set scaffold paths') + + return scaffold.fileTree(obj) + .then((fileTree) => { + debug('got file tree') + obj.scaffoldedFiles = fileTree + + return obj + }) +} + +// async function +export 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)) { + return + } + + 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 +export const 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) +}) + +export function 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 +} + +export function 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 +} + +export function 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 +} + +export function parseEnv (cfg: Record, envCLI: Record, resolved: Record = {}) { + 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 = 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: string[], val, key) => { + let cfgKey: string + + 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', + } as ResolvedFromConfig + } + + 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) +} + +export function getResolvedRuntimeConfig (config, runtimeConfig) { + const resolvedRuntimeFields = _.mapValues(runtimeConfig, (v): ResolvedFromConfig => ({ value: v, from: 'runtime' })) + + return { + ...config, + ...runtimeConfig, + resolved: { ...config.resolved, ...resolvedRuntimeFields }, + } +} + +export function getNameFromRoot (root = '') { + return path.basename(root) +} diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 0e7de1850bc6..4166e9127847 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -10,7 +10,7 @@ import { ServerCt, SocketCt } from '@packages/server-ct' import { SocketE2E } from './socket-e2e' import api from './api' import { Automation } from './automation' -import config from './config' +import * as config from './config' import cwd from './cwd' import errors from './errors' import Reporter from './reporter' diff --git a/packages/server/lib/util/args.js b/packages/server/lib/util/args.js index 6fae8c8b96bc..1dd9d19ee3dd 100644 --- a/packages/server/lib/util/args.js +++ b/packages/server/lib/util/args.js @@ -118,7 +118,7 @@ const JSONOrCoerce = (str) => { } // nupe :-( - return coerceUtil(str) + return coerceUtil.coerce(str) } const sanitizeAndConvertNestedArgs = (str, argname) => { @@ -213,7 +213,7 @@ module.exports = { cwd: process.cwd(), testingType: 'e2e', }) - .mapValues(coerceUtil) + .mapValues(coerceUtil.coerce) .value() debug('argv parsed: %o', options) diff --git a/packages/server/lib/util/coerce.js b/packages/server/lib/util/coerce.ts similarity index 90% rename from packages/server/lib/util/coerce.js rename to packages/server/lib/util/coerce.ts index 885687b4a186..5eb86ef6db38 100644 --- a/packages/server/lib/util/coerce.js +++ b/packages/server/lib/util/coerce.ts @@ -1,8 +1,8 @@ -const _ = require('lodash') -const toBoolean = require('underscore.string/toBoolean') +import _ from 'lodash' +import toBoolean from 'underscore.string/toBoolean' // https://github.com/cypress-io/cypress/issues/6810 -const toArray = (value) => { +const toArray = (value: any) => { const valueIsNotStringOrArray = typeof (value) !== 'string' || (value[0] !== '[' && value[value.length - 1] !== ']') if (valueIsNotStringOrArray) { @@ -28,7 +28,7 @@ const toArray = (value) => { // toArray() above doesn't handle JSON string properly. // For example, '[{a:b,c:d},{e:f,g:h}]' isn't the parsed object but ['{a:b', 'c:d}', '{e:f', 'g:h}']. It's useless. // Because of that, we check if the value is a JSON string. -const fromJson = (value) => { +const fromJson = (value: string) => { try { return JSON.parse(value) } catch (e) { @@ -36,7 +36,7 @@ const fromJson = (value) => { } } -module.exports = (value) => { +export const coerce = (value: string) => { const num = _.toNumber(value) if (_.invoke(num, 'toString') === value) { diff --git a/packages/server/lib/util/config.js b/packages/server/lib/util/config.js deleted file mode 100644 index 33ea742473d6..000000000000 --- a/packages/server/lib/util/config.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - isDefault (config, prop) { - return config.resolved[prop].from === 'default' - }, -} diff --git a/packages/server/lib/util/config.ts b/packages/server/lib/util/config.ts new file mode 100644 index 000000000000..06bae0dbae87 --- /dev/null +++ b/packages/server/lib/util/config.ts @@ -0,0 +1,38 @@ +import _ from 'lodash' +import { + CYPRESS_ENV_PREFIX, + CYPRESS_ENV_PREFIX_LENGTH, + CYPRESS_RESERVED_ENV_VARS, +} from '../config' +import { coerce } from './coerce' + +export const isDefault = (config: Record, prop: string) => { + return config.resolved[prop].from === 'default' +} + +export const getProcessEnvVars = (obj: NodeJS.ProcessEnv) => { + return _.reduce(obj, (memo, value, key) => { + if (!value) { + return memo + } + + if (isCypressEnvLike(key)) { + memo[removeEnvPrefix(key)] = coerce(value) + } + + return memo + } + , {}) +} + +const isCypressEnvLike = (key) => { + return _.chain(key) + .invoke('toUpperCase') + .startsWith(CYPRESS_ENV_PREFIX) + .value() && + !_.includes(CYPRESS_RESERVED_ENV_VARS, key) +} + +const removeEnvPrefix = (key: string) => { + return key.slice(CYPRESS_ENV_PREFIX_LENGTH) +} diff --git a/packages/server/test/unit/coerce_spec.js b/packages/server/test/unit/coerce_spec.js index 3ced0b0309ce..d1e33d7246c2 100644 --- a/packages/server/test/unit/coerce_spec.js +++ b/packages/server/test/unit/coerce_spec.js @@ -1,7 +1,7 @@ require('../spec_helper') -const coerce = require(`${root}lib/util/coerce`) -const getProcessEnvVars = require(`${root}lib/config`).getProcessEnvVars +const { coerce } = require(`${root}lib/util/coerce`) +const { getProcessEnvVars } = require(`${root}lib/util/config`) describe('lib/util/coerce', () => { beforeEach(function () { diff --git a/packages/server/test/unit/config_spec.js b/packages/server/test/unit/config_spec.js index 2568ef80d4bc..29ff3240ccec 100644 --- a/packages/server/test/unit/config_spec.js +++ b/packages/server/test/unit/config_spec.js @@ -1483,7 +1483,7 @@ describe('lib/config', () => { }) it('sets config, envFile and env', () => { - sinon.stub(config, 'getProcessEnvVars').returns({ + sinon.stub(configUtil, 'getProcessEnvVars').returns({ quux: 'quux', RECORD_KEY: 'foobarbazquux', PROJECT_ID: 'projectId123', @@ -1885,7 +1885,7 @@ describe('lib/config', () => { context('.parseEnv', () => { it('merges together env from config, env from file, env from process, and env from CLI', () => { - sinon.stub(config, 'getProcessEnvVars').returns({ + sinon.stub(configUtil, 'getProcessEnvVars').returns({ version: '0.12.1', user: 'bob', }) @@ -1932,7 +1932,7 @@ describe('lib/config', () => { obj[`${key}version`] = '0.12.0' - expect(config.getProcessEnvVars(obj)).to.deep.eq({ + expect(configUtil.getProcessEnvVars(obj)).to.deep.eq({ host: 'http://localhost:8888', version: '0.12.0', }) @@ -1947,7 +1947,7 @@ describe('lib/config', () => { CYPRESS_PROJECT_ID: 'abc123', } - expect(config.getProcessEnvVars(obj)).to.deep.eq({ + expect(configUtil.getProcessEnvVars(obj)).to.deep.eq({ FOO: 'bar', PROJECT_ID: 'abc123', CRASH_REPORTS: 0,