diff --git a/packages/server/lib/errors.js b/packages/server/lib/errors.js index 24ea562a0cbc..e33a20dab4c1 100644 --- a/packages/server/lib/errors.js +++ b/packages/server/lib/errors.js @@ -490,7 +490,7 @@ const getMsgByType = function (type, arg1 = {}, arg2, arg3) { ${chalk.yellow('Assign a different port with the \'--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(/\"\;/g, '"') } else { obj.message = err.message } diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 51f593fd8f12..3bb01fa130e0 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -83,6 +83,7 @@ export class ProjectBase 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 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 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 extends EE { closePreprocessor?.(), ]) + this._isServerOpen = false + process.chdir(localCwd) const config = this.getConfig() @@ -534,7 +543,7 @@ export class ProjectBase 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 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 diff --git a/packages/server/lib/util/require_async.ts b/packages/server/lib/util/require_async.ts new file mode 100644 index 000000000000..dc11b7c86c4b --- /dev/null +++ b/packages/server/lib/util/require_async.ts @@ -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 { + 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') + }) +} diff --git a/packages/server/lib/util/require_async_child.js b/packages/server/lib/util/require_async_child.js new file mode 100644 index 000000000000..179fda770144 --- /dev/null +++ b/packages/server/lib/util/require_async_child.js @@ -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) + } + } + }) +} diff --git a/packages/server/lib/util/settings.js b/packages/server/lib/util/settings.js index c1d03d1f4d7b..d1e13a14c667 100644 --- a/packages/server/lib/util/settings.js +++ b/packages/server/lib/util/settings.js @@ -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, {}) + } + + return Promise.reject(err) + }) + .then((configObject = {}) => { + if (this.isComponentTesting(options) && 'component' in configObject) { + configObject = { ...configObject, ...configObject.component } } - if (!this.isComponentTesting(options) && 'e2e' in json) { - json = { ...json, ...json.e2e } + if (!this.isComponentTesting(options) && 'e2e' in configObject) { + configObject = { ...configObject, ...configObject.e2e } } - const changed = this._applyRewriteRules(json) + 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) diff --git a/packages/server/package.json b/packages/server/package.json index 1501a4afcf1b..9988d819ff7b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/test/e2e/3_config_spec.js b/packages/server/test/e2e/3_config_spec.js index efc901ce4690..e0f89be3de6a 100644 --- a/packages/server/test/e2e/3_config_spec.js +++ b/packages/server/test/e2e/3_config_spec.js @@ -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', + }) + }) }) diff --git a/packages/server/test/integration/cypress_spec.js b/packages/server/test/integration/cypress_spec.js index 356f8b5dc348..969258aa9652 100644 --- a/packages/server/test/integration/cypress_spec.js +++ b/packages/server/test/integration/cypress_spec.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([ + }).then(() => { + cypress.start([ `--config-file=${this.filename}`, - ]) - .then(() => { - const options = Events.start.firstCall.args[0] + ]) + .then(() => { + const options = Events.start.firstCall.args[0] - return Events.handleEvent(options, {}, {}, 123, 'open:project', this.pristinePath) - }).then(() => { - expect(this.open).to.be.called + return Events.handleEvent(options, {}, {}, 123, 'open:project', this.pristinePath) + }).then(() => { + expect(this.open).to.be.called - const cfg = this.open.getCall(0).args[0] + const cfg = this.open.getCall(0).args[0] - expect(cfg.env.foo).to.equal('bar') + expect(cfg.env.foo).to.equal('bar') - expect(cfg.port).to.equal(2020) + expect(cfg.port).to.equal(2020) + }) }) }) diff --git a/packages/server/test/support/fixtures/projects/config-with-custom-file-js/cypress.config.custom.js b/packages/server/test/support/fixtures/projects/config-with-custom-file-js/cypress.config.custom.js new file mode 100644 index 000000000000..d95722d5dddc --- /dev/null +++ b/packages/server/test/support/fixtures/projects/config-with-custom-file-js/cypress.config.custom.js @@ -0,0 +1,7 @@ +module.exports = { + pageLoadTimeout: 10000, + e2e: { + defaultCommandTimeout: 500, + videoCompression: 20, + }, +} diff --git a/packages/server/test/support/fixtures/projects/config-with-custom-file-js/cypress/integration/app_spec.js b/packages/server/test/support/fixtures/projects/config-with-custom-file-js/cypress/integration/app_spec.js new file mode 100644 index 000000000000..011ffed68906 --- /dev/null +++ b/packages/server/test/support/fixtures/projects/config-with-custom-file-js/cypress/integration/app_spec.js @@ -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) +}) diff --git a/packages/server/test/unit/project_spec.js b/packages/server/test/unit/project_spec.js index c19c8f5d88e8..2fd5e5244d84 100644 --- a/packages/server/test/unit/project_spec.js +++ b/packages/server/test/unit/project_spec.js @@ -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') }) diff --git a/packages/server/test/unit/settings_spec.js b/packages/server/test/unit/settings_spec.js index e1c9df1339bc..ca1e4cef8622 100644 --- a/packages/server/test/unit/settings_spec.js +++ b/packages/server/test/unit/settings_spec.js @@ -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' }) + }) + }) + }) }) }) diff --git a/yarn.lock b/yarn.lock index 3dc3d87ef31e..1467ce7af1f8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"