mirror of
https://github.com/cypress-io/cypress.git
synced 2026-02-05 14:50:00 -06:00
550 lines
13 KiB
JavaScript
550 lines
13 KiB
JavaScript
const _ = require('lodash')
|
|
const la = require('lazy-ass')
|
|
const is = require('check-more-types')
|
|
const path = require('path')
|
|
const debug = require('debug')('cypress:server:args')
|
|
const minimist = require('minimist')
|
|
const { getBreakingRootKeys, getPublicConfigKeys, coerce } = require('@packages/config')
|
|
|
|
const proxyUtil = require('./proxy')
|
|
const errors = require('../errors')
|
|
|
|
const nestedObjectsInCurlyBracesRe = /\{(.+?)\}/g
|
|
const nestedArraysInSquareBracketsRe = /\[(.+?)\]/g
|
|
const everythingAfterFirstEqualRe = /=(.*)/
|
|
|
|
const allowList = [
|
|
'autoCancelAfterFailures',
|
|
'apiKey',
|
|
'appPath',
|
|
'browser',
|
|
'ci',
|
|
'ciBuildId',
|
|
'config',
|
|
'configFile',
|
|
'cwd',
|
|
'env',
|
|
'execPath',
|
|
'exit',
|
|
'exitWithCode',
|
|
'global',
|
|
'group',
|
|
'headed',
|
|
'inspectBrk',
|
|
'key',
|
|
'mode',
|
|
'outputPath',
|
|
'parallel',
|
|
'ping',
|
|
'port',
|
|
'project',
|
|
'proxySource',
|
|
'quiet',
|
|
'record',
|
|
'reporter',
|
|
'reporterOptions',
|
|
'returnPkg',
|
|
'runMode',
|
|
'runnerUi',
|
|
'noRunnerUi',
|
|
'runProject',
|
|
'smokeTest',
|
|
'spec',
|
|
'tag',
|
|
'testingType',
|
|
'updating',
|
|
'userNodePath',
|
|
'userNodeVersion',
|
|
'version',
|
|
]
|
|
|
|
// returns true if the given string has double quote character "
|
|
// only at the last position.
|
|
const hasStrayEndQuote = (s) => {
|
|
const quoteAt = s.indexOf('"')
|
|
|
|
return quoteAt === (s.length - 1)
|
|
}
|
|
|
|
const removeLastCharacter = (s) => {
|
|
return s.substr(0, s.length - 1)
|
|
}
|
|
|
|
const normalizeBackslash = (s) => {
|
|
if (hasStrayEndQuote(s)) {
|
|
return removeLastCharacter(s)
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
/**
|
|
* remove stray double quote from runProject and other path properties
|
|
* due to bug in NPM passing arguments with backslash at the end
|
|
* @see https://github.com/cypress-io/cypress/issues/535
|
|
*
|
|
*/
|
|
const normalizeBackslashes = (options) => {
|
|
// these properties are paths and likely to have backslash on Windows
|
|
const pathProperties = ['runProject', 'project', 'appPath', 'execPath', 'configFile']
|
|
|
|
pathProperties.forEach((property) => {
|
|
// sometimes a string parameter might get parsed into a boolean
|
|
// for example "--project ''" will be transformed in "project: true"
|
|
// which we should treat as undefined
|
|
if (typeof options[property] === 'string') {
|
|
options[property] = normalizeBackslash(options[property])
|
|
} else {
|
|
delete options[property]
|
|
}
|
|
})
|
|
|
|
return options
|
|
}
|
|
|
|
const stringify = (val) => {
|
|
if (_.isObject(val)) {
|
|
return JSON.stringify(val)
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
const strToArray = (str) => {
|
|
const parsed = tryJSONParse(str)
|
|
|
|
if (parsed) {
|
|
return parsed
|
|
}
|
|
|
|
return [].concat(str.split(','))
|
|
}
|
|
|
|
// swap out comma's for bars
|
|
const commasToPipes = (match) => {
|
|
return match.split(',').join('|')
|
|
}
|
|
|
|
// convert foo=bar|version=1.2.3 to
|
|
// foo=bar,version=1.2.3
|
|
const pipesToCommas = (str) => {
|
|
return str.split('|').join(',')
|
|
}
|
|
|
|
const tryJSONParse = (str) => {
|
|
try {
|
|
// https://github.com/cypress-io/cypress/issues/6891
|
|
return JSON.parse(str) === Infinity ? null : JSON.parse(str)
|
|
} catch (err) {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const JSONOrCoerce = (str) => {
|
|
// valid JSON? horray
|
|
const parsed = tryJSONParse(str)
|
|
|
|
if (parsed) {
|
|
return parsed
|
|
}
|
|
|
|
// convert bars back to commas
|
|
str = pipesToCommas(str)
|
|
|
|
// try to parse again?
|
|
const parsed2 = tryJSONParse(str)
|
|
|
|
if (parsed2) {
|
|
return parsed2
|
|
}
|
|
|
|
// nupe :-(
|
|
return coerce(str)
|
|
}
|
|
|
|
const sanitizeAndConvertNestedArgs = (str, argName) => {
|
|
la(is.unemptyString(argName), 'missing config argName to be parsed')
|
|
|
|
try {
|
|
if (typeof str === 'object') {
|
|
return str
|
|
}
|
|
|
|
// if this is valid JSON then just
|
|
// parse it and call it a day
|
|
const parsed = tryJSONParse(str)
|
|
|
|
if (parsed) {
|
|
return parsed
|
|
}
|
|
|
|
// invalid JSON, so assume mixed usage
|
|
// first find foo={a:b,b:c} and bar=[1,2,3]
|
|
// syntax and turn those into
|
|
// foo: a:b|b:c
|
|
// bar: 1|2|3
|
|
|
|
return _
|
|
.chain(str)
|
|
.replace(nestedObjectsInCurlyBracesRe, commasToPipes)
|
|
.replace(nestedArraysInSquareBracketsRe, commasToPipes)
|
|
.split(',')
|
|
.map((pair) => {
|
|
return pair.split(everythingAfterFirstEqualRe)
|
|
})
|
|
.fromPairs()
|
|
.mapValues(JSONOrCoerce)
|
|
.value()
|
|
} catch (err) {
|
|
debug('could not pass config %s value %s', argName, str)
|
|
debug('error %o', err)
|
|
|
|
return errors.throwErr('COULD_NOT_PARSE_ARGUMENTS', argName, str, 'Cannot parse as valid JSON')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses the '--spec' cli parameter to return an array of valid patterns.
|
|
*
|
|
* @param {String} pattern pattern to parse
|
|
* @returns Array of patterns
|
|
*/
|
|
const parseSpecArgv = (pattern) => {
|
|
const TOKENS = {
|
|
OPEN: ['{', '['],
|
|
CLOSE: ['}', ']'],
|
|
}
|
|
const hasToken = [...TOKENS.OPEN, ...TOKENS.CLOSE].some((t) => {
|
|
return pattern.includes(t)
|
|
})
|
|
const hasComma = pattern.includes(',')
|
|
|
|
/**
|
|
* Slice and mutate a string.
|
|
*
|
|
* @param {String} str String to slice & mutate
|
|
* @param {Number} end Index to slice to
|
|
* @returns [String, String, Number]
|
|
*/
|
|
const sliceAndMutate = (str, end) => {
|
|
return [
|
|
str.slice(0, end),
|
|
str.substring(end, str.length),
|
|
str.slice(0, end).length,
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Sanitizes a path's leftover commas.
|
|
*
|
|
* @param {String} path
|
|
* @returns String
|
|
*/
|
|
const sanitizeFinalPath = (path) => {
|
|
return path.split('')[0] === ',' ? path.substring(1, path.length) : path
|
|
}
|
|
|
|
if (!hasToken) {
|
|
return [].concat(pattern.split(','))
|
|
}
|
|
|
|
if (!hasComma) {
|
|
return [pattern]
|
|
}
|
|
|
|
// Get comma rules.
|
|
let opens = []
|
|
let closes = []
|
|
const rules = pattern
|
|
.split('')
|
|
.map((token, index) => {
|
|
if (TOKENS.OPEN.includes(token)) {
|
|
opens.push(index)
|
|
}
|
|
|
|
if (TOKENS.CLOSE.includes(token)) {
|
|
closes.push(index)
|
|
}
|
|
|
|
if (token === ',') {
|
|
const isBreakable =
|
|
(!opens.length && !closes.length) ||
|
|
index > opens[opens.length - 1] &&
|
|
index > closes[closes.length - 1] &&
|
|
opens.length === closes.length
|
|
|
|
if (isBreakable) {
|
|
return {
|
|
comma: index,
|
|
isBreakable: true,
|
|
}
|
|
}
|
|
|
|
return {
|
|
comma: index,
|
|
isBreakable: false,
|
|
}
|
|
}
|
|
|
|
return null
|
|
})
|
|
.filter(Boolean)
|
|
|
|
// Perform comma breaking logic.
|
|
let carry = pattern
|
|
let offset = 0
|
|
const partial = rules
|
|
.map((rule) => {
|
|
if (!rule.isBreakable) {
|
|
return null
|
|
}
|
|
|
|
const [res, mutated, offsettedBy] = sliceAndMutate(
|
|
carry,
|
|
rule.comma - offset,
|
|
)
|
|
|
|
offset += offsettedBy
|
|
carry = mutated
|
|
|
|
return res
|
|
})
|
|
.filter(Boolean)
|
|
.map(sanitizeFinalPath)
|
|
|
|
// In the end, carry will be left with the last path that hasn't been cut.
|
|
return [...partial, sanitizeFinalPath(carry)]
|
|
}
|
|
|
|
/*
|
|
* Certain config options (such as specPattern) are invalid at the root,
|
|
* and can only be used inside a testing type. We want to be convenient
|
|
* for the user though, so when they pass them in as CLI args, we
|
|
* assume they're for the current testing type. This function moves
|
|
* them from the root into the testing types they belong to, eg:
|
|
* { specPattern: 'foo.js' }
|
|
* ->
|
|
* { e2e: { specPattern: 'foo.js' }, component: { specPattern: 'foo.js' } }
|
|
*/
|
|
const nestInvalidRootOptions = (config) => {
|
|
getBreakingRootKeys().forEach(({ name, testingTypes }) => {
|
|
if (config[name] && testingTypes) {
|
|
testingTypes.forEach((testingType) => {
|
|
if (!config[testingType]) {
|
|
config[testingType] = {}
|
|
}
|
|
|
|
config[testingType][name] = config[name]
|
|
})
|
|
|
|
delete config[name]
|
|
}
|
|
})
|
|
}
|
|
|
|
module.exports = {
|
|
normalizeBackslashes,
|
|
|
|
toObject (argv) {
|
|
debug('argv array: %o', argv)
|
|
|
|
const alias = {
|
|
'api-key': 'apiKey',
|
|
'app-path': 'appPath',
|
|
'auto-cancel-after-failures': 'autoCancelAfterFailures',
|
|
'ci-build-id': 'ciBuildId',
|
|
'config-file': 'configFile',
|
|
'exec-path': 'execPath',
|
|
'exit-with-code': 'exitWithCode',
|
|
'inspect-brk': 'inspectBrk',
|
|
'output-path': 'outputPath',
|
|
'proxy-source': 'proxySource',
|
|
'reporter-options': 'reporterOptions',
|
|
'return-pkg': 'returnPkg',
|
|
'runner-ui': 'runnerUi',
|
|
'run-mode': 'isTextTerminal',
|
|
'run-project': 'runProject',
|
|
'smoke-test': 'smokeTest',
|
|
'testing-type': 'testingType',
|
|
}
|
|
|
|
// takes an array of args and converts
|
|
// to an object
|
|
let options = minimist(argv, {
|
|
alias,
|
|
// never cast the following CLI arguments
|
|
string: ['ci-build-id'],
|
|
})
|
|
|
|
debug('parsed argv options %o', { options })
|
|
|
|
const allowed = _.pick(argv, allowList)
|
|
|
|
// were we invoked from the CLI or directly?
|
|
const invokedFromCli = Boolean(options.cwd)
|
|
|
|
options = _
|
|
.chain(options)
|
|
.defaults(allowed)
|
|
.omit(_.keys(alias)) // remove aliases
|
|
.extend({ invokedFromCli })
|
|
.defaults({
|
|
// set in case we
|
|
// bypassed the cli
|
|
cwd: process.cwd(),
|
|
})
|
|
.mapValues(coerce)
|
|
.value()
|
|
|
|
debug('argv parsed: %o', options)
|
|
|
|
// throw new Error()
|
|
|
|
// if we are updating we may have to pluck out the
|
|
// appPath + execPath from the options._ because
|
|
// in previous versions up until 0.14.0 these args
|
|
// were not passed as dashes and instead were just
|
|
// regular arguments
|
|
if (options.updating && !options.appPath) {
|
|
// take the last two arguments that were unknown
|
|
// and apply them to both appPath + execPath
|
|
[options.appPath, options.execPath] = options._.slice(-2)
|
|
}
|
|
|
|
let { spec } = options
|
|
const { env, config, reporterOptions, outputPath, tag, testingType, autoCancelAfterFailures } = options
|
|
let project = options.project || options.runProject
|
|
|
|
// only accept project if it is a string
|
|
if (typeof project !== 'string') {
|
|
project = undefined
|
|
}
|
|
|
|
// if non-string key has been passed, set it to undefined
|
|
// https://github.com/cypress-io/cypress/issues/14571
|
|
if (typeof options.key !== 'string') {
|
|
delete options.key
|
|
}
|
|
|
|
if (spec) {
|
|
try {
|
|
const resolvePath = (p) => {
|
|
return path.resolve(options.cwd, p)
|
|
}
|
|
|
|
// https://github.com/cypress-io/cypress/issues/8818
|
|
// Sometimes spec is parsed to array. Because of that, we need check.
|
|
if (typeof spec === 'string') {
|
|
// clean up single quotes wrapping the spec for Windows users
|
|
// https://github.com/cypress-io/cypress/issues/2298
|
|
if (spec[0] === '\'' && spec[spec.length - 1] === '\'') {
|
|
spec = spec.substring(1, spec.length - 1)
|
|
}
|
|
|
|
options.spec = parseSpecArgv(spec).map(resolvePath)
|
|
} else {
|
|
options.spec = spec.map(resolvePath)
|
|
}
|
|
} catch (err) {
|
|
debug('could not parse config spec value %s', spec)
|
|
debug('error %o', err)
|
|
|
|
return errors.throwErr('COULD_NOT_PARSE_ARGUMENTS', 'spec', spec, 'spec must be a string or comma-separated list')
|
|
}
|
|
}
|
|
|
|
if (autoCancelAfterFailures && !((typeof autoCancelAfterFailures === 'number' && Number.isInteger(autoCancelAfterFailures)) || autoCancelAfterFailures === false)) {
|
|
return errors.throwErr('COULD_NOT_PARSE_ARGUMENTS', 'auto-cancel-after-failures', autoCancelAfterFailures, 'auto-cancel-after-failures must be an integer or false')
|
|
}
|
|
|
|
if (tag) {
|
|
options.tag = typeof tag === 'string' ? strToArray(tag) : tag
|
|
}
|
|
|
|
if (env) {
|
|
options.env = sanitizeAndConvertNestedArgs(env, 'env')
|
|
}
|
|
|
|
const proxySource = proxyUtil.loadSystemProxySettings()
|
|
|
|
if (process.env.HTTP_PROXY) {
|
|
if (proxySource) {
|
|
options.proxySource = proxySource
|
|
}
|
|
|
|
options.proxyServer = process.env.HTTP_PROXY
|
|
options.proxyBypassList = process.env.NO_PROXY
|
|
}
|
|
|
|
if (reporterOptions) {
|
|
options.reporterOptions = sanitizeAndConvertNestedArgs(reporterOptions, 'reporterOptions')
|
|
}
|
|
|
|
if (config) {
|
|
// convert config to an object
|
|
// and store the config
|
|
options.config = sanitizeAndConvertNestedArgs(config, 'config')
|
|
}
|
|
|
|
if (options.config == null) {
|
|
options.config = {}
|
|
}
|
|
|
|
// A user may pass in config options that are valid for
|
|
// a specific testing type, but invalid at the root level.
|
|
// We nest these automatically, making the assumption that
|
|
// we know what they meant.
|
|
nestInvalidRootOptions(options.config, testingType)
|
|
|
|
// get a list of the available config keys
|
|
const configKeys = getPublicConfigKeys()
|
|
|
|
// and if any of our options match this
|
|
const configValues = _.pick(options, configKeys)
|
|
|
|
// then set them on config
|
|
// this solves situations where we accept
|
|
// root level arguments which also can
|
|
// be set in configuration
|
|
_.extend(options.config, configValues)
|
|
|
|
// remove them from the root options object
|
|
options = _.omit(options, configKeys)
|
|
|
|
options = normalizeBackslashes(options)
|
|
debug('options %o', options)
|
|
|
|
// normalize project to projectRoot
|
|
if (project) {
|
|
options.projectRoot = path.resolve(options.cwd, project)
|
|
}
|
|
|
|
// normalize output path from previous current working directory
|
|
if (outputPath) {
|
|
options.outputPath = path.resolve(options.cwd, outputPath)
|
|
}
|
|
|
|
if (options.smokeTest) {
|
|
options.pong = options.ping
|
|
}
|
|
|
|
debug('argv options: %o', options)
|
|
|
|
return options
|
|
},
|
|
|
|
toArray (obj = {}) {
|
|
// goes in reverse, takes an object
|
|
// and converts to an array by picking
|
|
// only the allowed properties and
|
|
// mapping them to include the argument
|
|
return _
|
|
.chain(obj)
|
|
.pick(...allowList)
|
|
.mapValues((val, key) => {
|
|
return `--${key}=${stringify(val)}`
|
|
}).values()
|
|
.value()
|
|
},
|
|
}
|