feat: allow use of javascript in config file (#18061)

This commit is contained in:
Barthélémy Ledoux
2021-09-23 09:06:02 -05:00
committed by GitHub
parent 20c8c5faca
commit dedb05a0b5
13 changed files with 306 additions and 59 deletions
+6 -1
View File
@@ -490,7 +490,7 @@ const getMsgByType = function (type, arg1 = {}, arg2, arg3) {
${chalk.yellow('Assign a different port with the \'--port <port>\' argument or shut down the other running process.')}`
case 'ERROR_READING_FILE':
filePath = `\`${arg1}\``
err = `\`${arg2}\``
err = `\`${arg2.type || arg2.code || arg2.name}: ${arg2.message}\``
return stripIndent`\
Error reading from: ${chalk.blue(filePath)}
@@ -1049,6 +1049,11 @@ const clone = function (err, options = {}) {
if (options.html) {
obj.message = ansi_up.ansi_to_html(err.message)
// revert back the distorted characters
// in case there is an error in a child_process
// that contains quotes
.replace(/\&\#x27;/g, '\'')
.replace(/\&quot\;/g, '"')
} else {
obj.message = err.message
}
+11 -2
View File
@@ -83,6 +83,7 @@ export class ProjectBase<TServer extends ServerE2E | ServerCt> extends EE {
protected _server?: TServer
protected _automation?: Automation
private _recordTests?: any = null
private _isServerOpen: boolean = false
public browser: any
public options: OpenProjectLaunchOptions
@@ -220,6 +221,8 @@ export class ProjectBase<TServer extends ServerE2E | ServerCt> extends EE {
specsStore,
})
this._isServerOpen = true
// if we didnt have a cfg.port
// then get the port once we
// open the server
@@ -340,6 +343,10 @@ export class ProjectBase<TServer extends ServerE2E | ServerCt> extends EE {
this.spec = null
this.browser = null
if (!this._isServerOpen) {
return
}
const closePreprocessor = (this.testingType === 'e2e' && preprocessor.close) ?? undefined
await Promise.all([
@@ -348,6 +355,8 @@ export class ProjectBase<TServer extends ServerE2E | ServerCt> extends EE {
closePreprocessor?.(),
])
this._isServerOpen = false
process.chdir(localCwd)
const config = this.getConfig()
@@ -534,7 +543,7 @@ export class ProjectBase<TServer extends ServerE2E | ServerCt> extends EE {
}
if (configFile !== false) {
this.watchers.watch(settings.pathToConfigFile(projectRoot, { configFile }), obj)
this.watchers.watchTree(settings.pathToConfigFile(projectRoot, { configFile }), obj)
}
return this.watchers.watch(settings.pathToCypressEnvJson(projectRoot), obj)
@@ -552,7 +561,7 @@ export class ProjectBase<TServer extends ServerE2E | ServerCt> extends EE {
try {
Reporter.loadReporter(reporter, projectRoot)
} catch (err) {
} catch (err: any) {
const paths = Reporter.getSearchPathsForReporter(reporter, projectRoot)
// only include the message if this is the standard MODULE_NOT_FOUND
+93
View File
@@ -0,0 +1,93 @@
import _ from 'lodash'
import * as path from 'path'
import * as cp from 'child_process'
import * as inspector from 'inspector'
import * as util from '../plugins/util'
import * as errors from '../errors'
import { fs } from '../util/fs'
import Debug from 'debug'
const debug = Debug('cypress:server:require_async')
let requireProcess: cp.ChildProcess | null
interface RequireAsyncOptions{
projectRoot: string
loadErrorCode: string
/**
* members of the object returned that are functions and will need to be wrapped
*/
functionNames: string[]
}
interface ChildOptions{
stdio: 'inherit'
execArgv?: string[]
}
const killChildProcess = () => {
requireProcess && requireProcess.kill()
requireProcess = null
}
export async function requireAsync (filePath: string, options: RequireAsyncOptions): Promise<any> {
return new Promise((resolve, reject) => {
if (requireProcess) {
debug('kill existing config process')
killChildProcess()
}
if (/\.json$/.test(filePath)) {
fs.readJson(path.resolve(options.projectRoot, filePath)).then((result) => resolve(result)).catch(reject)
}
const childOptions: ChildOptions = {
stdio: 'inherit',
}
if (inspector.url()) {
childOptions.execArgv = _.chain(process.execArgv.slice(0))
.remove('--inspect-brk')
.push(`--inspect=${process.debugPort + 1}`)
.value()
}
const childArguments = ['--projectRoot', options.projectRoot, '--file', filePath]
debug('fork child process', path.join(__dirname, 'require_async_child.js'), childArguments, childOptions)
requireProcess = cp.fork(path.join(__dirname, 'require_async_child.js'), childArguments, childOptions)
const ipc = util.wrapIpc(requireProcess)
if (requireProcess.stdout && requireProcess.stderr) {
// manually pipe plugin stdout and stderr for dashboard capture
// @see https://github.com/cypress-io/cypress/issues/7434
requireProcess.stdout.on('data', (data) => process.stdout.write(data))
requireProcess.stderr.on('data', (data) => process.stderr.write(data))
}
ipc.on('loaded', (result) => {
debug('resolving with result %o', result)
resolve(result)
})
ipc.on('load:error', (type, ...args) => {
debug('load:error %s, rejecting', type)
killChildProcess()
const err = errors.get(type, ...args)
// if it's a non-cypress error, restore the initial error
if (!(err.message?.length)) {
err.isCypressErr = false
err.message = args[1]
err.code = type
err.name = type
}
reject(err)
})
debug('trigger the load of the file')
ipc.send('load')
})
}
@@ -0,0 +1,71 @@
require('graceful-fs').gracefulify(require('fs'))
const stripAnsi = require('strip-ansi')
const debug = require('debug')('cypress:server:require_async:child')
const util = require('../plugins/util')
const ipc = util.wrapIpc(process)
require('./suppress_warnings').suppress()
const { file, projectRoot } = require('minimist')(process.argv.slice(2))
run(ipc, file, projectRoot)
/**
* runs and returns the passed `requiredFile` file in the ipc `load` event
* @param {*} ipc Inter Process Comunication protocol
* @param {*} requiredFile the file we are trying to load
* @param {*} projectRoot the root of the typescript project (useful mainly for tsnode)
* @returns
*/
function run (ipc, requiredFile, projectRoot) {
debug('requiredFile:', requiredFile)
debug('projectRoot:', projectRoot)
if (!projectRoot) {
throw new Error('Unexpected: projectRoot should be a string')
}
process.on('uncaughtException', (err) => {
debug('uncaught exception:', util.serializeError(err))
ipc.send('error', util.serializeError(err))
return false
})
process.on('unhandledRejection', (event) => {
const err = (event && event.reason) || event
debug('unhandled rejection:', util.serializeError(err))
ipc.send('error', util.serializeError(err))
return false
})
ipc.on('load', () => {
try {
debug('try loading', requiredFile)
const exp = require(requiredFile)
const result = exp.default || exp
ipc.send('loaded', result)
debug('config %o', result)
} catch (err) {
if (err.name === 'TSError') {
// beause of this https://github.com/TypeStrong/ts-node/issues/1418
// we have to do this https://stackoverflow.com/questions/25245716/remove-all-ansi-colors-styles-from-strings/29497680
const cleanMessage = stripAnsi(err.message)
// replace the first line with better text (remove potentially misleading word TypeScript for example)
.replace(/^.*\n/g, 'Error compiling file\n')
ipc.send('load:error', err.name, requiredFile, cleanMessage)
} else {
const realErrorCode = err.code || err.name
debug('failed to load file:%s\n%s: %s', requiredFile, realErrorCode, err.message)
ipc.send('load:error', realErrorCode, requiredFile, err.message)
}
}
})
}
+62 -19
View File
@@ -4,6 +4,19 @@ const path = require('path')
const errors = require('../errors')
const log = require('../log')
const { fs } = require('../util/fs')
const { requireAsync } = require('./require_async')
const debug = require('debug')('cypress:server:settings')
function jsCode (obj) {
const objJSON = obj && !_.isEmpty(obj)
? JSON.stringify(_.omit(obj, 'configFile'), null, 2)
: `{
}`
return `module.exports = ${objJSON}
`
}
// TODO:
// think about adding another PSemaphore
@@ -78,7 +91,19 @@ module.exports = {
},
_write (file, obj = {}) {
return fs.outputJsonAsync(file, obj, { spaces: 2 })
if (/\.json$/.test(file)) {
debug('writing json file')
return fs.outputJsonAsync(file, obj, { spaces: 2 })
.return(obj)
.catch((err) => {
return this._logWriteErr(file, err)
})
}
debug('writing javascript file')
return fs.writeFileAsync(file, jsCode(obj))
.return(obj)
.catch((err) => {
return this._logWriteErr(file, err)
@@ -110,7 +135,13 @@ module.exports = {
return null
})
},
/**
* Ensures the project at this root has a config file
* that is readable and writable by the node process
* @param {string} projectRoot root of the project
* @param {object} options
* @returns
*/
exists (projectRoot, options = {}) {
const file = this.pathToConfigFile(projectRoot, options)
@@ -121,7 +152,7 @@ module.exports = {
// directory is writable
return fs.accessAsync(projectRoot, fs.W_OK)
}).catch({ code: 'ENOENT' }, () => {
// cypress.json does not exist, we missing project
// cypress.json does not exist, completely new project
log('cannot find file %s', file)
return this._err('CONFIG_FILE_NOT_FOUND', this.configFile(options), projectRoot)
@@ -144,29 +175,45 @@ module.exports = {
const file = this.pathToConfigFile(projectRoot, options)
return fs.readJsonAsync(file)
.catch({ code: 'ENOENT' }, () => {
return this._write(file, {})
}).then((json = {}) => {
if (this.isComponentTesting(options) && 'component' in json) {
json = { ...json, ...json.component }
return requireAsync(file,
{
projectRoot,
loadErrorCode: 'CONFIG_FILE_ERROR',
})
.catch((err) => {
if (err.type === 'MODULE_NOT_FOUND' || err.code === 'ENOENT') {
debug('file not found', file)
return this._write(file, {})
}
if (!this.isComponentTesting(options) && 'e2e' in json) {
json = { ...json, ...json.e2e }
return Promise.reject(err)
})
.then((configObject = {}) => {
if (this.isComponentTesting(options) && 'component' in configObject) {
configObject = { ...configObject, ...configObject.component }
}
const changed = this._applyRewriteRules(json)
if (!this.isComponentTesting(options) && 'e2e' in configObject) {
configObject = { ...configObject, ...configObject.e2e }
}
debug('resolved configObject', configObject)
const changed = this._applyRewriteRules(configObject)
// if our object is unchanged
// then just return it
if (_.isEqual(json, changed)) {
return json
if (_.isEqual(configObject, changed)) {
return configObject
}
// else write the new reduced obj
return this._write(file, changed)
.then((config) => {
return config
})
}).catch((err) => {
debug('an error occured when reading config', err)
if (errors.isCypressErr(err)) {
throw err
}
@@ -196,7 +243,7 @@ module.exports = {
return Promise.resolve({})
}
return this.read(projectRoot)
return this.read(projectRoot, options)
.then((settings) => {
_.extend(settings, obj)
@@ -206,10 +253,6 @@ module.exports = {
})
},
remove (projectRoot, options = {}) {
return fs.unlinkSync(this.pathToConfigFile(projectRoot, options))
},
pathToConfigFile (projectRoot, options = {}) {
const configFile = this.configFile(options)
+1 -1
View File
@@ -38,7 +38,7 @@
"chai": "1.10.0",
"chalk": "2.4.2",
"check-more-types": "2.24.0",
"chokidar": "3.2.2",
"chokidar": "3.5.1",
"chrome-remote-interface": "0.28.2",
"cli-table3": "0.5.1",
"coffeescript": "1.12.7",
@@ -47,4 +47,11 @@ describe('e2e config', () => {
project: Fixtures.projectPath('shadow-dom-global-inclusion'),
})
})
it('supports custom configFile in JavaScript', function () {
return e2e.exec(this, {
project: Fixtures.projectPath('config-with-custom-file-js'),
configFile: 'cypress.config.custom.js',
})
})
})
@@ -511,7 +511,11 @@ describe('lib/cypress', () => {
return fs.statAsync(path.join(this.pristinePath, 'cypress', 'integration'))
}).then(() => {
throw new Error('integration folder should not exist!')
}).catch({ code: 'ENOENT' }, () => {})
}).catch((err) => {
if (err.code !== 'ENOENT') {
throw err
}
})
})
it('scaffolds out fixtures + files if they do not exist', function () {
@@ -1795,29 +1799,26 @@ describe('lib/cypress', () => {
})
it('reads config from a custom config file', function () {
sinon.stub(fs, 'readJsonAsync')
fs.readJsonAsync.withArgs(path.join(this.pristinePath, this.filename)).resolves({
return fs.writeJson(path.join(this.pristinePath, this.filename), {
env: { foo: 'bar' },
port: 2020,
})
fs.readJsonAsync.callThrough()
return cypress.start([
`--config-file=${this.filename}`,
])
.then(() => {
const options = Events.start.firstCall.args[0]
return Events.handleEvent(options, {}, {}, 123, 'open:project', this.pristinePath)
}).then(() => {
expect(this.open).to.be.called
cypress.start([
`--config-file=${this.filename}`,
])
.then(() => {
const options = Events.start.firstCall.args[0]
const cfg = this.open.getCall(0).args[0]
return Events.handleEvent(options, {}, {}, 123, 'open:project', this.pristinePath)
}).then(() => {
expect(this.open).to.be.called
expect(cfg.env.foo).to.equal('bar')
const cfg = this.open.getCall(0).args[0]
expect(cfg.port).to.equal(2020)
expect(cfg.env.foo).to.equal('bar')
expect(cfg.port).to.equal(2020)
})
})
})
@@ -0,0 +1,7 @@
module.exports = {
pageLoadTimeout: 10000,
e2e: {
defaultCommandTimeout: 500,
videoCompression: 20,
},
}
@@ -0,0 +1,8 @@
it('overrides config', () => {
// overrides come from plugins
expect(Cypress.config('defaultCommandTimeout')).to.eq(500)
expect(Cypress.config('videoCompression')).to.eq(20)
// overrides come from CLI
expect(Cypress.config('pageLoadTimeout')).to.eq(10000)
})
+7 -3
View File
@@ -13,7 +13,7 @@ const cache = require(`${root}lib/cache`)
const config = require(`${root}lib/config`)
const scaffold = require(`${root}lib/scaffold`)
const { ServerE2E } = require(`${root}lib/server-e2e`)
const ProjectBase = require(`${root}lib/project-base`).ProjectBase
const { ProjectBase } = require(`${root}lib/project-base`)
const {
getOrgs,
paths,
@@ -532,8 +532,10 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
this.project = new ProjectBase({ projectRoot: '/_test-output/path/to/project-e2e', testingType: 'e2e' })
this.project._server = { close () {} }
this.project._isServerOpen = true
sinon.stub(this.project, 'getConfig').returns(this.config)
sinon.stub(user, 'ensureAuthToken').resolves('auth-token-123')
})
@@ -713,12 +715,14 @@ This option will not have an effect in Some-other-name. Tests that rely on web s
sinon.stub(settings, 'pathToConfigFile').returns('/path/to/cypress.json')
sinon.stub(settings, 'pathToCypressEnvJson').returns('/path/to/cypress.env.json')
this.watch = sinon.stub(this.project.watchers, 'watch')
this.watchTree = sinon.stub(this.project.watchers, 'watchTree')
})
it('watches cypress.json and cypress.env.json', function () {
this.project.watchSettings({ onSettingsChanged () {} }, {})
expect(this.watch).to.be.calledTwice
expect(this.watch).to.be.calledWith('/path/to/cypress.json')
expect(this.watch).to.be.calledOnce
expect(this.watchTree).to.be.calledOnce
expect(this.watchTree).to.be.calledWith('/path/to/cypress.json')
expect(this.watch).to.be.calledWith('/path/to/cypress.env.json')
})
@@ -222,6 +222,10 @@ describe('lib/settings', () => {
this.options = {
configFile: 'my-test-config-file.json',
}
this.optionsJs = {
configFile: 'my-test-config-file.js',
}
})
afterEach(function () {
@@ -254,5 +258,15 @@ describe('lib/settings', () => {
})
})
})
it('.read returns from configFile when its a JavaScript file', function () {
return fs.writeFile(path.join(this.projectRoot, this.optionsJs.configFile), `module.exports = { baz: 'lurman' }`)
.then(() => {
return settings.read(this.projectRoot, this.optionsJs)
.then((settings) => {
expect(settings).to.deep.equal({ baz: 'lurman' })
})
})
})
})
})
-15
View File
@@ -13682,21 +13682,6 @@ chokidar-cli@2.1.0:
lodash.throttle "^4.1.1"
yargs "^13.3.0"
chokidar@3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.2.2.tgz#a433973350021e09f2b853a2287781022c0dc935"
integrity sha512-bw3pm7kZ2Wa6+jQWYP/c7bAZy3i4GwiIiMO2EeRjrE48l8vBqC/WvFhSF0xyM8fQiPEGvwMY/5bqDG7sSEOuhg==
dependencies:
anymatch "~3.1.1"
braces "~3.0.2"
glob-parent "~5.1.0"
is-binary-path "~2.1.0"
is-glob "~4.0.1"
normalize-path "~3.0.0"
readdirp "~3.2.0"
optionalDependencies:
fsevents "~2.1.1"
chokidar@3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.0.tgz#12c0714668c55800f659e262d4962a97faf554a6"