diff --git a/packages/app/cypress/e2e/runs.cy.ts b/packages/app/cypress/e2e/runs.cy.ts index a44d674bcc..1fc8a821b7 100644 --- a/packages/app/cypress/e2e/runs.cy.ts +++ b/packages/app/cypress/e2e/runs.cy.ts @@ -2,13 +2,13 @@ import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json' import type { SinonStub } from 'sinon' describe('App: Runs', { viewportWidth: 1200 }, () => { - beforeEach(() => { - cy.scaffoldProject('component-tests') - cy.openProject('component-tests') - cy.startAppServer('component') - }) - context('Runs Page', () => { + beforeEach(() => { + cy.scaffoldProject('component-tests') + cy.openProject('component-tests') + cy.startAppServer('component') + }) + it('resolves the runs page', () => { cy.loginUser() cy.visitApp() @@ -33,6 +33,12 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { }) context('Runs - Login', () => { + beforeEach(() => { + cy.scaffoldProject('component-tests') + cy.openProject('component-tests') + cy.startAppServer('component') + }) + it('when logged out, shows call to action', () => { cy.visitApp() cy.get('[href="#/runs"]').click() @@ -131,9 +137,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { context('Runs - Connect Project', () => { it('opens Connect Project modal after clicking Connect Project button', () => { - cy.withCtx(async (ctx) => { - await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {}') - }) + cy.scaffoldProject('component-tests') + cy.openProject('component-tests', ['--config-file', 'cypressWithoutProjectId.config.js']) + cy.startAppServer('component') cy.loginUser() cy.visitApp() @@ -167,9 +173,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { context('Runs - Cannot Find Project', () => { beforeEach(() => { - cy.withCtx(async (ctx) => { - await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {\'projectId\': \'abcdef42\'}') - }) + cy.scaffoldProject('component-tests') + cy.openProject('component-tests', ['--config-file', 'cypressWithInvalidProjectId.config.js']) + cy.startAppServer('component') cy.loginUser() cy.remoteGraphQLIntercept(async (obj) => { @@ -217,9 +223,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { context('Runs - Unauthorized Project', () => { beforeEach(() => { - cy.withCtx(async (ctx) => { - await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {\'projectId\': \'abcdef\'}') - }) + cy.scaffoldProject('component-tests') + cy.openProject('component-tests') + cy.startAppServer('component') cy.loginUser() }) @@ -278,9 +284,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { context('Runs - Unauthorized Project Requested', () => { beforeEach(() => { - cy.withCtx(async (ctx) => { - await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {\'projectId\': \'abcdef\' }') - }) + cy.scaffoldProject('component-tests') + cy.openProject('component-tests') + cy.startAppServer('component') cy.loginUser() cy.remoteGraphQLIntercept(async (obj) => { @@ -310,9 +316,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { context('Runs - No Runs', () => { it('when no runs and not connected, shows connect to dashboard button', () => { - cy.withCtx(async (ctx) => { - await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {projectId: null }') - }) + cy.scaffoldProject('component-tests') + cy.openProject('component-tests', ['--config-file', 'cypressWithoutProjectId.config.js']) + cy.startAppServer('component') cy.loginUser() cy.remoteGraphQLIntercept(async (obj) => { @@ -335,9 +341,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { }) it('displays how to record prompt when connected and no runs', () => { - cy.withCtx(async (ctx) => { - await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {projectId: \'abcdef\'}') - }) + cy.scaffoldProject('component-tests') + cy.openProject('component-tests') + cy.startAppServer('component') cy.loginUser() cy.remoteGraphQLIntercept(async (obj) => { @@ -360,8 +366,11 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { }) it('displays a copy button', () => { + cy.scaffoldProject('component-tests') + cy.openProject('component-tests') + cy.startAppServer('component') + cy.withCtx(async (ctx, o) => { - await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {projectId: \'abcdef\'}') o.sinon.stub(ctx.electronApi, 'copyTextToClipboard') }) @@ -389,6 +398,12 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { }) context('Runs - Runs List', () => { + beforeEach(() => { + cy.scaffoldProject('component-tests') + cy.openProject('component-tests') + cy.startAppServer('component') + }) + it('displays a list of recorded runs if a run has been recorded', () => { cy.loginUser() cy.visitApp() @@ -446,6 +461,12 @@ describe('App: Runs', { viewportWidth: 1200 }, () => { }) describe('no internet connection', () => { + beforeEach(() => { + cy.scaffoldProject('component-tests') + cy.openProject('component-tests') + cy.startAppServer('component') + }) + afterEach(() => { cy.goOnline() }) diff --git a/packages/app/cypress/e2e/sidebar_navigation.cy.ts b/packages/app/cypress/e2e/sidebar_navigation.cy.ts index 8e913a343d..09e4a415b8 100644 --- a/packages/app/cypress/e2e/sidebar_navigation.cy.ts +++ b/packages/app/cypress/e2e/sidebar_navigation.cy.ts @@ -176,7 +176,7 @@ describe('Sidebar Navigation', () => { }) cy.withCtx((ctx, o) => { - o.sinon.stub(ctx.actions.project, 'setCurrentTestingType') + o.sinon.stub(ctx.actions.project, 'setAndLoadCurrentTestingType') o.sinon.stub(ctx.actions.project, 'reconfigureProject').resolves() }) @@ -186,7 +186,7 @@ describe('Sidebar Navigation', () => { cy.withCtx((ctx) => { expect(ctx.coreData.app.relaunchBrowser).eq(true) - expect(ctx.actions.project.setCurrentTestingType).to.have.been.calledWith('component') + expect(ctx.actions.project.setAndLoadCurrentTestingType).to.have.been.calledWith('component') expect(ctx.actions.project.reconfigureProject).to.have.been.called }) @@ -327,7 +327,7 @@ describe('Sidebar Navigation', () => { }).should('be.visible') cy.withCtx((ctx, o) => { - o.sinon.stub(ctx.actions.project, 'setCurrentTestingType') + o.sinon.stub(ctx.actions.project, 'setAndLoadCurrentTestingType') o.sinon.stub(ctx.actions.project, 'reconfigureProject').resolves() }) @@ -337,7 +337,7 @@ describe('Sidebar Navigation', () => { cy.withCtx((ctx) => { expect(ctx.coreData.app.relaunchBrowser).eq(true) - expect(ctx.actions.project.setCurrentTestingType).to.have.been.calledWith('e2e') + expect(ctx.actions.project.setAndLoadCurrentTestingType).to.have.been.calledWith('e2e') expect(ctx.actions.project.reconfigureProject).to.have.been.called }) }) diff --git a/packages/data-context/src/DataContext.ts b/packages/data-context/src/DataContext.ts index e5f484b7c0..87b35fe9c6 100644 --- a/packages/data-context/src/DataContext.ts +++ b/packages/data-context/src/DataContext.ts @@ -430,9 +430,13 @@ export class DataContext { assert(!this.coreData.hasInitializedMode) this.coreData.hasInitializedMode = this._config.mode if (this._config.mode === 'run') { - await this.lifecycleManager.initializeRunMode() + await this.lifecycleManager.initializeRunMode(this.coreData.currentTestingType) } else if (this._config.mode === 'open') { await this.initializeOpenMode() + if (this.coreData.currentTestingType && await this.lifecycleManager.waitForInitializeSuccess()) { + this.lifecycleManager.setAndLoadCurrentTestingType(this.coreData.currentTestingType) + this.lifecycleManager.scaffoldFilesIfNecessary() + } } else { throw new Error(`Missing DataContext config "mode" setting, expected run | open`) } diff --git a/packages/data-context/src/actions/MigrationActions.ts b/packages/data-context/src/actions/MigrationActions.ts index 32ee86c5b0..ba0392ba34 100644 --- a/packages/data-context/src/actions/MigrationActions.ts +++ b/packages/data-context/src/actions/MigrationActions.ts @@ -290,9 +290,8 @@ export class MigrationActions { } async finishReconfigurationWizard () { - this.ctx.lifecycleManager.initializeConfigWatchers() this.ctx.lifecycleManager.refreshMetaState() - await this.ctx.lifecycleManager.reloadConfig() + await this.ctx.lifecycleManager.refreshLifecycle() } async nextStep () { diff --git a/packages/data-context/src/actions/ProjectActions.ts b/packages/data-context/src/actions/ProjectActions.ts index 0c6eabc332..f50df5eeba 100644 --- a/packages/data-context/src/actions/ProjectActions.ts +++ b/packages/data-context/src/actions/ProjectActions.ts @@ -95,8 +95,8 @@ export class ProjectActions { execa(this.ctx.coreData.localSettings.preferences.preferredEditorBinary, [projectPath]) } - setCurrentTestingType (type: TestingType) { - this.ctx.lifecycleManager.setCurrentTestingType(type) + setAndLoadCurrentTestingType (type: TestingType) { + this.ctx.lifecycleManager.setAndLoadCurrentTestingType(type) } async setCurrentProject (projectRoot: string) { diff --git a/packages/data-context/src/data/CypressEnv.ts b/packages/data-context/src/data/CypressEnv.ts new file mode 100644 index 0000000000..4ecbac989c --- /dev/null +++ b/packages/data-context/src/data/CypressEnv.ts @@ -0,0 +1,39 @@ +import fs from 'fs-extra' +import { getError } from '@packages/errors' + +type CypressEnvOptions = { + envFilePath: string + validateConfigFile: (file: string | false, config: Cypress.ConfigOptions) => void +} + +export class CypressEnv { + constructor (private options: CypressEnvOptions) {} + + async loadCypressEnvFile (): Promise { + return this.readAndValidateCypressEnvFile() + } + + private async readAndValidateCypressEnvFile () { + const cypressEnv = await this.readCypressEnvFile() + + this.options.validateConfigFile(this.options.envFilePath, cypressEnv) + + return cypressEnv + } + + private async readCypressEnvFile (): Promise { + try { + return await fs.readJSON(this.options.envFilePath) + } catch (err: any) { + if (err.code === 'ENOENT') { + return {} + } + + if (err.isCypressErr) { + throw err + } + + throw getError('ERROR_READING_FILE', this.options.envFilePath, err) + } + } +} diff --git a/packages/data-context/src/data/EventRegistrar.ts b/packages/data-context/src/data/EventRegistrar.ts new file mode 100644 index 0000000000..4de4177960 --- /dev/null +++ b/packages/data-context/src/data/EventRegistrar.ts @@ -0,0 +1,46 @@ +import debugLib from 'debug' +import _ from 'lodash' + +const debug = debugLib(`cypress:lifecycle:EventRegistrar`) + +export class EventRegistrar { + private _registeredEvents: Record = {} + + hasNodeEvent (eventName: string) { + const isRegistered = typeof this._registeredEvents[eventName] === 'function' + + debug('plugin event registered? %o', { eventName, isRegistered }) + + return isRegistered + } + + executeNodeEvent (event: string, args: any[]) { + debug(`execute plugin event '${event}' Node '${process.version}' with args: %o %o %o`, ...args) + + const evtFn = this._registeredEvents[event] + + if (typeof evtFn !== 'function') { + throw new Error(`Missing event for ${event}`) + } + + return evtFn(...args) + } + + registerEvent (event: string, callback: Function) { + debug(`register event '${event}'`) + + if (!_.isString(event)) { + throw new Error(`The plugin register function must be called with an event as its 1st argument. You passed '${event}'.`) + } + + if (!_.isFunction(callback)) { + throw new Error(`The plugin register function must be called with a callback function as its 2nd argument. You passed '${callback}'.`) + } + + this._registeredEvents[event] = callback + } + + reset () { + this._registeredEvents = {} + } +} diff --git a/packages/data-context/src/data/LegacyPluginsIpc.ts b/packages/data-context/src/data/LegacyPluginsIpc.ts index 9f0d5111b0..8e5d3b0279 100644 --- a/packages/data-context/src/data/LegacyPluginsIpc.ts +++ b/packages/data-context/src/data/LegacyPluginsIpc.ts @@ -18,7 +18,7 @@ export class LegacyPluginsIpc extends EventEmitter { send(event: 'loadLegacyPlugins', legacyConfig: LegacyCypressConfigJson): boolean send (event: string, ...args: any[]) { - if (this.childProcess.killed) { + if (this.childProcess.killed || !this.childProcess.connected) { return false } diff --git a/packages/data-context/src/data/ProjectConfigIpc.ts b/packages/data-context/src/data/ProjectConfigIpc.ts index fe98ef6a5e..a0e7683a2f 100644 --- a/packages/data-context/src/data/ProjectConfigIpc.ts +++ b/packages/data-context/src/data/ProjectConfigIpc.ts @@ -1,9 +1,18 @@ /* eslint-disable no-dupe-class-members */ -import type { CypressError } from '@packages/errors' -import type { TestingType } from '@packages/types' -import type { ChildProcess } from 'child_process' +import { CypressError, getError } from '@packages/errors' +import type { FullConfig, TestingType } from '@packages/types' +import { ChildProcess, fork, ForkOptions } from 'child_process' import EventEmitter from 'events' +import path from 'path' +import inspector from 'inspector' +import debugLib from 'debug' import { autoBindDebug } from '../util' +import _ from 'lodash' + +const pkg = require('@packages/root') +const debug = debugLib(`cypress:lifecycle:ProjectConfigIpc`) + +const CHILD_PROCESS_FILE_PATH = require.resolve('@packages/server/lib/plugins/child/require_async_child') export type IpcHandler = (ipc: ProjectConfigIpc) => void @@ -29,18 +38,27 @@ export interface SerializedLoadConfigReply { * */ export class ProjectConfigIpc extends EventEmitter { - constructor (readonly childProcess: ChildProcess) { + private _childProcess: ChildProcess + + constructor ( + readonly nodePath: string | undefined | null, + readonly projectRoot: string, + readonly configFilePath: string, + readonly configFile: string | false, + readonly onError: (cypressError: CypressError, title?: string | undefined) => void, + readonly onWarning: (cypressError: CypressError) => void, + ) { super() - childProcess.on('error', (err) => { + this._childProcess = this.forkConfigProcess() + this._childProcess.on('error', (err) => { // this.emit('error', err) }) - childProcess.on('message', (msg: { event: string, args: any[] }) => { + this._childProcess.on('message', (msg: { event: string, args: any[] }) => { this.emit(msg.event, ...msg.args) }) - childProcess.once('disconnect', () => { - // console.log('Disconnected') + this._childProcess.once('disconnect', () => { this.emit('disconnect') }) @@ -52,11 +70,11 @@ export class ProjectConfigIpc extends EventEmitter { send(event: 'setupTestingType', testingType: TestingType, options: Cypress.PluginConfigOptions): boolean send(event: 'loadConfig'): boolean send (event: string, ...args: any[]) { - if (this.childProcess.killed) { + if (this._childProcess.killed || !this._childProcess.connected) { return false } - return this.childProcess.send({ event, args }) + return this._childProcess.send({ event, args }) } on(evt: 'childProcess:unhandledError', listener: (err: CypressError) => void): this @@ -88,4 +106,168 @@ export class ProjectConfigIpc extends EventEmitter { emit (evt: string, ...args: any[]) { return super.emit(evt, ...args) } + + loadConfig (): Promise { + return new Promise((resolve, reject) => { + if (this._childProcess.stdout && this._childProcess.stderr) { + // manually pipe plugin stdout and stderr for dashboard capture + // @see https://github.com/cypress-io/cypress/issues/7434 + this._childProcess.stdout.on('data', (data) => process.stdout.write(data)) + this._childProcess.stderr.on('data', (data) => process.stderr.write(data)) + } + + let resolved = false + + this._childProcess.on('error', (err) => { + this.handleChildProcessError(err, this, resolved, reject) + reject(err) + }) + + /** + * This reject cannot be caught anywhere?? + * + * It's supposed to be caught on lib/modes/run.js:1689, + * but it's not. + */ + this.on('childProcess:unhandledError', (err) => { + this.handleChildProcessError(err, this, resolved, reject) + reject(err) + }) + + this.once('loadConfig:reply', (val) => { + debug('loadConfig:reply') + resolve({ ...val, initialConfig: JSON.parse(val.initialConfig) }) + resolved = true + }) + + this.once('loadConfig:error', (err) => { + this.killChildProcess() + reject(err) + }) + + debug('trigger the load of the file') + this.once('ready', () => { + this.send('loadConfig') + }) + }) + } + + async callSetupNodeEventsWithConfig (testingType: TestingType, config: FullConfig, handlers: IpcHandler[]): Promise { + for (const handler of handlers) { + handler(this) + } + + const promise = this.registerSetupIpcHandlers() + + const overrides = config[testingType] ?? {} + const mergedConfig = { ...config, ...overrides } + + // alphabetize config by keys + let orderedConfig = {} as Cypress.PluginConfigOptions + + Object.keys(mergedConfig).sort().forEach((key) => { + const k = key as keyof typeof mergedConfig + + // @ts-ignore + orderedConfig[k] = mergedConfig[k] + }) + + this.send('setupTestingType', testingType, { + ...orderedConfig, + projectRoot: this.projectRoot, + configFile: this.configFilePath, + version: pkg.version, + testingType, + }) + + return promise + } + + private registerSetupIpcHandlers (): Promise { + return new Promise((resolve, reject) => { + let resolved = false + + this._childProcess.on('error', (err) => { + this.handleChildProcessError(err, this, resolved, reject) + reject(err) + }) + + // For every registration event, we want to turn into an RPC with the child process + this.once('setupTestingType:reply', (val) => { + resolved = true + resolve(val) + }) + + this.once('setupTestingType:error', (err) => { + reject(err) + }) + + const handleWarning = (warningErr: CypressError) => { + debug('plugins process warning:', warningErr.stack) + + return this.onWarning(warningErr) + } + + this.on('warning', handleWarning) + }) + } + + private forkConfigProcess () { + const configProcessArgs = ['--projectRoot', this.projectRoot, '--file', this.configFilePath] + // allow the use of ts-node in subprocesses tests by removing the env constant from it + // without this line, packages/ts/register.js never registers the ts-node module for config and + // run_plugins can't use the config module. + const { CYPRESS_INTERNAL_E2E_TESTING_SELF, ...env } = process.env + + env.NODE_OPTIONS = process.env.ORIGINAL_NODE_OPTIONS || '' + + const childOptions: ForkOptions = { + stdio: 'pipe', + cwd: path.dirname(this.configFilePath), + env, + execPath: this.nodePath ?? undefined, + } + + if (inspector.url()) { + childOptions.execArgv = _.chain(process.execArgv.slice(0)) + .remove('--inspect-brk') + .push(`--inspect=${process.debugPort + 1}`) + .value() + } + + debug('fork child process', CHILD_PROCESS_FILE_PATH, configProcessArgs, _.omit(childOptions, 'env')) + + const proc = fork(CHILD_PROCESS_FILE_PATH, configProcessArgs, childOptions) + + return proc + } + + private handleChildProcessError (err: any, ipc: ProjectConfigIpc, resolved: boolean, reject: (reason?: any) => void) { + debug('plugins process error:', err.stack) + + this.cleanupIpc() + + err = getError('CONFIG_FILE_UNEXPECTED_ERROR', this.configFile || '(unknown config file)', err) + err.title = 'Config process error' + + // this can sometimes trigger before the promise is fulfilled and + // sometimes after, so we need to handle each case differently + if (resolved) { + this.onError(err) + } else { + reject(err) + } + } + + cleanupIpc () { + this.killChildProcess() + this.removeAllListeners() + } + + private killChildProcess () { + this._childProcess.kill() + this._childProcess.stdout?.removeAllListeners() + this._childProcess.stderr?.removeAllListeners() + this._childProcess.removeAllListeners() + } } diff --git a/packages/data-context/src/data/ProjectConfigManager.ts b/packages/data-context/src/data/ProjectConfigManager.ts new file mode 100644 index 0000000000..7014310a72 --- /dev/null +++ b/packages/data-context/src/data/ProjectConfigManager.ts @@ -0,0 +1,492 @@ +import { CypressError, getError } from '@packages/errors' +import { IpcHandler, LoadConfigReply, ProjectConfigIpc, SetupNodeEventsReply } from './ProjectConfigIpc' +import assert from 'assert' +import type { AllModeOptions, FullConfig, TestingType } from '@packages/types' +import debugLib from 'debug' +import path from 'path' +import _ from 'lodash' +import chokidar from 'chokidar' +import { validate as validateConfig, validateNoBreakingConfigLaunchpad, validateNoBreakingConfigRoot, validateNoBreakingTestingTypeConfig } from '@packages/config' +import { CypressEnv } from './CypressEnv' +import { autoBindDebug } from '../util/autoBindDebug' +import type { EventRegistrar } from './EventRegistrar' +import type { DataContext } from '../DataContext' + +const debug = debugLib(`cypress:lifecycle:ProjectConfigManager`) + +const UNDEFINED_SERIALIZED = '__cypress_undefined__' + +type ProjectConfigManagerOptions = { + ctx: DataContext + configFile: string | false + projectRoot: string + handlers: IpcHandler[] + hasCypressEnvFile: boolean + eventRegistrar: EventRegistrar + onError: (cypressError: CypressError, title?: string | undefined) => void + onInitialConfigLoaded: (initialConfig: Cypress.ConfigOptions) => void + onFinalConfigLoaded: (finalConfig: FullConfig) => Promise + refreshLifecycle: () => Promise +} + +type ConfigManagerState = 'pending' | 'loadingConfig' | 'loadedConfig' | 'loadingNodeEvents' | 'ready' | 'errored' + +export class ProjectConfigManager { + private _configFilePath: string | undefined + private _cachedFullConfig: FullConfig | undefined + private _eventsIpc?: ProjectConfigIpc + private _pathToWatcherRecord: Record = {} + private _watchers = new Set() + private _registeredEventsTarget: TestingType | undefined + private _testingType: TestingType | null = null + private _state: ConfigManagerState = 'pending' + private _loadConfigPromise: Promise | undefined + private _cachedLoadConfig: LoadConfigReply | undefined + private _cypressEnv: CypressEnv + + constructor (private options: ProjectConfigManagerOptions) { + this._cypressEnv = new CypressEnv({ + envFilePath: this.envFilePath, + validateConfigFile: (filePath, config) => { + this.validateConfigFile(filePath, config) + }, + }) + + return autoBindDebug(this) + } + + get isLoadingNodeEvents () { + return this._state === 'loadingNodeEvents' + } + + get isReady () { + return this._state === 'ready' + } + + get isLoadingConfigFile () { + return this._state === 'loadingConfig' + } + + get isInError () { + return this._state === 'errored' + } + + get configFilePath () { + assert(this._configFilePath, 'configFilePath is undefined') + + return this._configFilePath + } + + set configFilePath (configFilePath) { + this._configFilePath = configFilePath + } + + setTestingType (testingType: TestingType | null) { + this._testingType = testingType + } + + private get envFilePath () { + return path.join(this.options.projectRoot, 'cypress.env.json') + } + + private get loadedConfigFile (): Partial | null { + return this._cachedLoadConfig?.initialConfig ?? null + } + + async initializeConfig (): Promise { + try { + this._state = 'loadingConfig' + + // Clean things up for a new load + this.closeWatchers() + this._cachedLoadConfig = undefined + this._cachedFullConfig = undefined + + const loadConfigReply = await this.loadConfig() + + // This is necessary as there is a weird timing issue where an error occurs and the config results get loaded + // TODO: see if this can be !== 'errored' + if (this._state === 'loadingConfig') { + debug(`config is loaded for file`, this.configFilePath, this._testingType) + this.validateConfigFile(this.configFilePath, loadConfigReply.initialConfig) + + this._state = 'loadedConfig' + this._cachedLoadConfig = loadConfigReply + + this.options.onInitialConfigLoaded(loadConfigReply.initialConfig) + + this.watchFiles([ + ...loadConfigReply.requires, + this.configFilePath, + ]) + } + + return loadConfigReply.initialConfig + } catch (error) { + debug(`catch %o`, error) + if (this._eventsIpc) { + this._eventsIpc.cleanupIpc() + } + + this._state = 'errored' + this.closeWatchers() + + throw error + } finally { + this.options.ctx.emitter.toLaunchpad() + } + } + + loadTestingType () { + // If we have set a testingType, and it's not the "target" of the + // registeredEvents (switching testing mode), we need to get a fresh + // config IPC & re-execute the setupTestingType + if (this._registeredEventsTarget && this._testingType !== this._registeredEventsTarget) { + this.options.refreshLifecycle().catch(this.onLoadError) + } else if (this._eventsIpc && !this._registeredEventsTarget && this._cachedLoadConfig) { + this.setupNodeEvents(this._cachedLoadConfig).catch(this.onLoadError) + } + } + + private async setupNodeEvents (loadConfigReply: LoadConfigReply): Promise { + assert(this._eventsIpc, 'Expected _eventsIpc to be defined at this point') + this._state = 'loadingNodeEvents' + + try { + assert(this._testingType, 'Cannot setup node events without a testing type') + this._registeredEventsTarget = this._testingType + const config = await this.getFullInitialConfig() + const setupNodeEventsReply = await this._eventsIpc?.callSetupNodeEventsWithConfig(this._testingType, config, this.options.handlers) + + await this.handleSetupTestingTypeReply(this._eventsIpc, loadConfigReply, setupNodeEventsReply) + this._state = 'ready' + } catch (error) { + debug(`catch setupNodeEvents %o`, error) + this._state = 'errored' + if (this._eventsIpc) { + this._eventsIpc.cleanupIpc() + } + + this.closeWatchers() + + throw error + } finally { + this.options.ctx.emitter.toLaunchpad() + } + } + + private async handleSetupTestingTypeReply (ipc: ProjectConfigIpc, loadConfigReply: LoadConfigReply, result: SetupNodeEventsReply) { + this.options.eventRegistrar.reset() + + for (const { event, eventId } of result.registrations) { + debug('register plugins process event', event, 'with id', eventId) + + this.options.eventRegistrar.registerEvent(event, function (...args: any[]) { + return new Promise((resolve, reject) => { + const invocationId = _.uniqueId('inv') + + debug('call event', event, 'for invocation id', invocationId) + + ipc.once(`promise:fulfilled:${invocationId}`, (err: any, value: any) => { + if (err) { + debug('promise rejected for id %s %o', invocationId, ':', err.stack) + reject(_.extend(new Error(err.message), err)) + + return + } + + if (value === UNDEFINED_SERIALIZED) { + value = undefined + } + + debug(`promise resolved for id '${invocationId}' with value`, value) + + return resolve(value) + }) + + const ids = { invocationId, eventId } + + // no argument is passed for cy.task() + // This is necessary because undefined becomes null when it is sent through ipc. + if (event === 'task' && args[1] === undefined) { + args[1] = { + __cypress_task_no_argument__: true, + } + } + + ipc.send('execute:plugins', event, ids, args) + }) + }) + } + + const cypressEnv = await this.loadCypressEnvFile() + const fullConfig = await this.buildBaseFullConfig(loadConfigReply.initialConfig, cypressEnv, this.options.ctx.modeOptions) + + const finalConfig = this._cachedFullConfig = this.options.ctx._apis.configApi.updateWithPluginValues(fullConfig, result.setupConfig ?? {}) + + await this.options.onFinalConfigLoaded(finalConfig) + + this.watchFiles([ + ...result.requires, + this.envFilePath, + ]) + + return result + } + + resetLoadingState () { + this._loadConfigPromise = undefined + this._registeredEventsTarget = undefined + this._state = 'pending' + } + + private loadConfig () { + if (!this._loadConfigPromise) { + // If there's already a dangling IPC from the previous switch of testing type, we want to clean this up + if (this._eventsIpc) { + this._eventsIpc.cleanupIpc() + } + + this._eventsIpc = new ProjectConfigIpc(this.options.ctx.nodePath, this.options.projectRoot, this.configFilePath, this.options.configFile, (cypressError: CypressError, title?: string | undefined) => { + this._state = 'errored' + this.options.ctx.onError(cypressError, title) + }, this.options.ctx.onWarning) + + this._loadConfigPromise = this._eventsIpc.loadConfig() + } + + return this._loadConfigPromise + } + + private validateConfigFile (file: string | false, config: Cypress.ConfigOptions) { + validateConfig(config, (errMsg) => { + if (_.isString(errMsg)) { + throw getError('CONFIG_VALIDATION_MSG_ERROR', 'configFile', file || null, errMsg) + } + + throw getError('CONFIG_VALIDATION_ERROR', 'configFile', file || null, errMsg) + }) + + return validateNoBreakingConfigLaunchpad( + config, + (type, obj) => { + const error = getError(type, obj) + + this.options.ctx.onWarning(error) + + return error + }, + (type, obj) => { + const error = getError(type, obj) + + this.options.onError(error) + + throw error + }, + ) + } + + onLoadError = (error: any) => { + this.closeWatchers() + this.options.onError(error, 'Error Loading Config') + } + + private watchFiles (paths: string[]) { + if (this.options.ctx.isRunMode) { + return + } + + const filtered = paths.filter((p) => !p.includes('/node_modules/')) + + for (const path of filtered) { + if (!this._pathToWatcherRecord[path]) { + this._pathToWatcherRecord[path] = this.addWatcherFor(path) + } + } + } + + private addWatcherFor (file: string) { + const w = this.addWatcher(file) + + w.on('all', (evt) => { + debug(`changed ${file}: ${evt}`) + this.options.refreshLifecycle().catch(this.onLoadError) + }) + + w.on('error', (err) => { + debug('error watching config files %O', err) + this.options.ctx.onWarning(getError('UNEXPECTED_INTERNAL_ERROR', err)) + }) + + return w + } + + private addWatcher (file: string | string[]) { + const w = chokidar.watch(file, { + ignoreInitial: true, + cwd: this.options.projectRoot, + }) + + this._watchers.add(w) + + return w + } + + private validateConfigRoot (config: Cypress.ConfigOptions) { + return validateNoBreakingConfigRoot( + config, + (type, obj) => { + return getError(type, obj) + }, + (type, obj) => { + throw getError(type, obj) + }, + ) + } + + private validateTestingTypeConfig (config: Cypress.ConfigOptions, testingType: TestingType) { + return validateNoBreakingTestingTypeConfig( + config, + testingType, + (type, ...args) => { + return getError(type, ...args) + }, + (type, ...args) => { + throw getError(type, ...args) + }, + ) + } + + private async buildBaseFullConfig (configFileContents: Cypress.ConfigOptions, envFile: Cypress.ConfigOptions, options: Partial, withBrowsers = true) { + assert(this._testingType, 'Cannot build base full config without a testing type') + this.validateConfigRoot(configFileContents) + + const testingTypeOverrides = configFileContents[this._testingType] ?? {} + const optionsOverrides = options.config?.[this._testingType] ?? {} + + this.validateTestingTypeConfig(testingTypeOverrides, this._testingType) + this.validateTestingTypeConfig(optionsOverrides, this._testingType) + + // TODO: pass in options.config overrides separately, so they are reflected in the UI + configFileContents = { ...configFileContents, ...testingTypeOverrides, ...optionsOverrides } + + // TODO: Convert this to be synchronous, it's just FS checks + let fullConfig = await this.options.ctx._apis.configApi.setupFullConfigWithDefaults({ + cliConfig: options.config ?? {}, + projectName: path.basename(this.options.projectRoot), + projectRoot: this.options.projectRoot, + config: _.cloneDeep(configFileContents), + envFile: _.cloneDeep(envFile), + options: { + ...options, + testingType: this._testingType, + }, + }) + + if (withBrowsers) { + const browsers = await this.options.ctx.browser.machineBrowsers() + + if (!fullConfig.browsers || fullConfig.browsers.length === 0) { + // @ts-ignore - we don't know if the browser is headed or headless at this point. + // this is handled in open_project#launch. + fullConfig.browsers = browsers + fullConfig.resolved.browsers = { 'value': fullConfig.browsers, 'from': 'runtime' } + } + + fullConfig.browsers = fullConfig.browsers?.map((browser) => { + if (browser.family === 'chromium' || fullConfig.chromeWebSecurity) { + return browser + } + + return { + ...browser, + warning: browser.warning || getError('CHROME_WEB_SECURITY_NOT_SUPPORTED', browser.name).message, + } + }) + + // If we have withBrowsers set to false, it means we're coming from the legacy config.get API + // in tests, which shouldn't be validating the config + this.validateConfigFile(this.options.configFile, fullConfig) + } + + return _.cloneDeep(fullConfig) + } + + async getFullInitialConfig (options: Partial = this.options.ctx.modeOptions, withBrowsers = true): Promise { + if (this._cachedFullConfig) { + return this._cachedFullConfig + } + + const [configFileContents, envFile] = await Promise.all([ + this.getConfigFileContents(), + this.reloadCypressEnvFile(), + ]) + + this._cachedFullConfig = await this.buildBaseFullConfig(configFileContents, envFile, options, withBrowsers) + + return this._cachedFullConfig + } + + async getConfigFileContents () { + if (this._cachedLoadConfig?.initialConfig) { + return this._cachedLoadConfig?.initialConfig + } + + return this.initializeConfig() + } + + async loadCypressEnvFile () { + return this._cypressEnv.loadCypressEnvFile() + } + + async reloadCypressEnvFile () { + this._cypressEnv = new CypressEnv({ + envFilePath: this.envFilePath, + validateConfigFile: (filePath, config) => { + this.validateConfigFile(filePath, config) + }, + }) + + return this._cypressEnv.loadCypressEnvFile() + } + + isTestingTypeConfigured (testingType: TestingType): boolean { + const config = this.loadedConfigFile + + if (!config) { + return false + } + + if (!_.has(config, testingType)) { + return false + } + + if (testingType === 'component') { + return Boolean(config.component?.devServer) + } + + return true + } + + private closeWatchers () { + for (const watcher of this._watchers.values()) { + // We don't care if there's an error while closing the watcher, + // the watch listener on our end is already removed synchronously by chokidar + watcher.close().catch((e) => {}) + } + this._watchers = new Set() + this._pathToWatcherRecord = {} + } + + destroy () { + if (this._eventsIpc) { + this._eventsIpc.cleanupIpc() + } + + this._state = 'pending' + this._cachedLoadConfig = undefined + this._cachedFullConfig = undefined + this._registeredEventsTarget = undefined + this.closeWatchers() + } +} diff --git a/packages/data-context/src/data/ProjectLifecycleManager.ts b/packages/data-context/src/data/ProjectLifecycleManager.ts index c451356f43..2b807258d5 100644 --- a/packages/data-context/src/data/ProjectLifecycleManager.ts +++ b/packages/data-context/src/data/ProjectLifecycleManager.ts @@ -6,37 +6,21 @@ * See `guides/app-lifecycle.md` for documentation on the project & possible * states that exist, and how they are managed. */ -import { ChildProcess, ForkOptions, fork } from 'child_process' -import chokidar, { FSWatcher } from 'chokidar' import path from 'path' -import inspector from 'inspector' import _ from 'lodash' import resolve from 'resolve' -import debugLib from 'debug' -import pDefer from 'p-defer' import fs from 'fs' import { getError, CypressError, ConfigValidationFailureInfo } from '@packages/errors' import type { DataContext } from '..' -import { LoadConfigReply, SetupNodeEventsReply, ProjectConfigIpc, IpcHandler } from './ProjectConfigIpc' import assert from 'assert' import type { AllModeOptions, FoundBrowser, FullConfig, TestingType } from '@packages/types' -import type { BreakingErrResult, BreakingOptionErrorKey } from '@packages/config' import { autoBindDebug } from '../util/autoBindDebug' import type { LegacyCypressConfigJson } from '../sources' - -const debug = debugLib(`cypress:lifecycle:ProjectLifecycleManager`) - -const CHILD_PROCESS_FILE_PATH = require.resolve('@packages/server/lib/plugins/child/require_async_child') - -const UNDEFINED_SERIALIZED = '__cypress_undefined__' - -const potentialConfigFiles = [ - 'cypress.config.ts', - 'cypress.config.mjs', - 'cypress.config.cjs', - 'cypress.config.js', -] +import { ProjectConfigManager } from './ProjectConfigManager' +import pDefer from 'p-defer' +import { EventRegistrar } from './EventRegistrar' +import { getServerPluginHandlers, resetPluginHandlers } from '../util/pluginHandlers' export interface SetupFullConfigOptions { projectName: string @@ -47,7 +31,12 @@ export interface SetupFullConfigOptions { options: Partial } -type BreakingValidationFn = (type: BreakingOptionErrorKey, val: BreakingErrResult) => T +const POTENTIAL_CONFIG_FILES = [ + 'cypress.config.ts', + 'cypress.config.mjs', + 'cypress.config.cjs', + 'cypress.config.js', +] /** * All of the APIs injected from @packages/server & @packages/config @@ -55,29 +44,10 @@ type BreakingValidationFn = (type: BreakingOptionErrorKey, val: BreakingErrRe */ export interface InjectedConfigApi { cypressVersion: string - getServerPluginHandlers: () => IpcHandler[] validateConfig(config: Partial, onErr: (errMsg: ConfigValidationFailureInfo | string) => never): T allowedConfig(config: Cypress.ConfigOptions): Cypress.ConfigOptions updateWithPluginValues(config: FullConfig, modifiedConfig: Partial): FullConfig setupFullConfigWithDefaults(config: SetupFullConfigOptions): Promise - validateRootConfigBreakingChanges(config: Partial, onWarning: BreakingValidationFn, onErr: BreakingValidationFn): void - validateLaunchpadConfigBreakingChanges(config: Partial, onWarning: BreakingValidationFn, onErr: BreakingValidationFn): void - validateTestingTypeConfigBreakingChanges(config: Partial, testingType: Cypress.TestingType, onWarning: BreakingValidationFn, onErr: BreakingValidationFn): void -} - -type State = V extends undefined ? {state: S, value?: V } : {state: S, value: V} - -type LoadingStateFor = State<'pending'> | State<'loading', Promise> | State<'loaded', V> | State<'errored', CypressError> - -type ConfigResultState = LoadingStateFor - -type EnvFileResultState = LoadingStateFor - -type SetupNodeEventsResultState = LoadingStateFor - -interface RequireWatchers { - config: Record - setupNodeEvents: Record } export interface ProjectMetaState { @@ -103,44 +73,23 @@ const PROJECT_META_STATE: ProjectMetaState = { } export class ProjectLifecycleManager { - // Registered handlers from Cypress's server, used to wrap the IPC - private _handlers: IpcHandler[] = [] - - // Config, from the cypress.config.{js|ts} - private _envFileResult: EnvFileResultState = { state: 'pending' } - private _configResult: ConfigResultState = { state: 'pending' } - private childProcesses = new Set() - private watchers = new Set() - - private _eventsIpc?: ProjectConfigIpc - private _eventsIpcResult: SetupNodeEventsResultState = { state: 'pending' } - private _registeredEvents: Record = {} - private _registeredEventsTarget: TestingType | undefined - private _eventProcess: ChildProcess | undefined private _currentTestingType: TestingType | null = null private _runModeExitEarly: ((error: Error) => void) | undefined - - private _initializedProject: unknown | undefined // open_project object private _projectRoot: string | undefined - private _configFilePath: string | undefined - private _configWatcher: FSWatcher | null = null - - private _cachedFullConfig: FullConfig | undefined - + private _configManager: ProjectConfigManager | undefined private _projectMetaState: ProjectMetaState = { ...PROJECT_META_STATE } - _pendingMigrationInitialize?: pDefer.DeferredPromise + private _pendingInitialize?: pDefer.DeferredPromise + private _cachedInitialConfig: Cypress.ConfigOptions | undefined + private _cachedFullConfig: FullConfig | undefined + private _initializedProject: unknown | undefined + private _eventRegistrar: EventRegistrar constructor (private ctx: DataContext) { - this._handlers = this.ctx._apis.configApi.getServerPluginHandlers() - this.watchers = new Set() - + this._eventRegistrar = new EventRegistrar() if (ctx.coreData.currentProject) { this.setCurrentProject(ctx.coreData.currentProject) - } else if (ctx.coreData.currentTestingType && this._projectRoot) { - this.setCurrentTestingType(ctx.coreData.currentTestingType) } - // see timers/parent.js line #93 for why this is necessary process.on('exit', this.onProcessExit) return autoBindDebug(this) @@ -160,10 +109,6 @@ export class ProjectLifecycleManager { } } - get eventsIpcResult () { - return Object.freeze(this._eventsIpcResult) - } - get metaState () { return Object.freeze(this._projectMetaState) } @@ -181,13 +126,18 @@ export class ProjectLifecycleManager { } get configFile () { - return this.ctx.modeOptions.configFile ?? path.basename(this.configFilePath) ?? 'cypress.config.js' + return this.ctx.modeOptions.configFile ?? (this._configManager?.configFilePath && path.basename(this._configManager.configFilePath)) ?? 'cypress.config.js' } get configFilePath () { - assert(this._configFilePath, 'Expected configFilePath to be found') + assert(this._configManager, 'Cannot retrieve config file path without a config manager') - return this._configFilePath + return this._configManager.configFilePath + } + + setConfigFilePath (fileName: string) { + assert(this._configManager, 'Cannot set config file path without a config manager') + this._configManager.configFilePath = this._pathToFile(fileName) } get envFilePath () { @@ -195,23 +145,27 @@ export class ProjectLifecycleManager { } get browsers () { - if (this._cachedFullConfig) { - return this._cachedFullConfig.browsers as FoundBrowser[] + if (this.loadedFullConfig) { + return this.loadedFullConfig.browsers as FoundBrowser[] } return null } get isLoadingConfigFile () { - return this._configResult.state === 'loading' + return this._configManager?.isLoadingConfigFile } get isLoadingNodeEvents () { - return this._eventsIpcResult.state === 'loading' + return this._configManager?.isLoadingNodeEvents + } + + get isReady () { + return this._configManager?.isReady } get loadedConfigFile (): Partial | null { - return this._configResult.state === 'loaded' ? this._configResult.value.initialConfig : null + return this._cachedInitialConfig ?? null } get loadedFullConfig (): FullConfig | null { @@ -244,7 +198,7 @@ export class ProjectLifecycleManager { this._projectRoot = undefined } - getPackageManagerUsed (projectRoot: string) { + private getPackageManagerUsed (projectRoot: string) { if (fs.existsSync(path.join(projectRoot, 'package-lock.json'))) { return 'npm' } @@ -260,6 +214,119 @@ export class ProjectLifecycleManager { return 'npm' } + private createConfigManager () { + return new ProjectConfigManager({ + ctx: this.ctx, + configFile: this.configFile, + projectRoot: this.projectRoot, + handlers: getServerPluginHandlers(), + hasCypressEnvFile: this._projectMetaState.hasCypressEnvFile, + eventRegistrar: this._eventRegistrar, + onError: (cypressError, title) => { + if (this.ctx.isRunMode && this._pendingInitialize) { + this._pendingInitialize.reject(cypressError) + } else { + this.ctx.onError(cypressError, title) + } + }, + onInitialConfigLoaded: (initialConfig: Cypress.ConfigOptions) => { + this._cachedInitialConfig = initialConfig + + if (this.ctx.coreData.scaffoldedFiles) { + this.ctx.coreData.scaffoldedFiles.filter((f) => { + if (f.file.absolute === this.configFilePath && f.status !== 'valid') { + f.status = 'valid' + } + }) + } + + this.ctx.emitter.toLaunchpad() + }, + onFinalConfigLoaded: async (finalConfig: FullConfig) => { + this._cachedFullConfig = finalConfig + + // This happens automatically with openProjectCreate in run mode + if (!this.ctx.isRunMode) { + if (!this._initializedProject) { + this._initializedProject = await this.ctx.actions.project.initializeActiveProject({}) + } + } + + if (this.ctx.coreData.cliBrowser) { + await this.setActiveBrowser(this.ctx.coreData.cliBrowser) + } + + if (this._currentTestingType && finalConfig.specPattern) { + await this.ctx.actions.project.setSpecsFoundBySpecPattern({ + path: this.projectRoot, + testingType: this._currentTestingType, + specPattern: this.ctx.modeOptions.spec || finalConfig.specPattern, + excludeSpecPattern: finalConfig.excludeSpecPattern, + additionalIgnorePattern: finalConfig.additionalIgnorePattern, + }) + } + + this._pendingInitialize?.resolve(finalConfig) + }, + refreshLifecycle: async () => this.refreshLifecycle(), + }) + } + + async refreshLifecycle () { + assert(this._projectRoot, 'Cannot reload config without a project root') + assert(this._configManager, 'Cannot reload config without a config manager') + + if (this.readyToInitialize(this._projectRoot)) { + this._configManager.resetLoadingState() + await this.initializeConfig() + + if (this._currentTestingType && this.isTestingTypeConfigured(this._currentTestingType)) { + this._configManager.loadTestingType() + } else { + this.setAndLoadCurrentTestingType(null) + } + + return true + } + + return false + } + + async waitForInitializeSuccess (): Promise { + if (this._configManager?.isLoadingConfigFile) { + try { + await this.initializeConfig() + + return true + } catch (error) { + return false + } + } + + return !this._configManager?.isInError + } + + async initializeConfig () { + assert(this._configManager, 'Cannot initialize config without a config manager') + + return this._configManager.initializeConfig() + } + + private async setActiveBrowser (cliBrowser: string) { + // When we're starting up, if we've chosen a browser to run with, check if it exists + this.ctx.coreData.cliBrowser = null + + try { + const browser = await this.ctx._apis.browserApi.ensureAndGetByNameOrPath(cliBrowser) + + this.ctx.coreData.chosenBrowser = browser ?? null + } catch (e) { + const error = e as CypressError + + this.ctx.onWarning(error) + } + } + /** * When we set the current project, we need to cleanup the * previous project that might have existed. We use this as the @@ -274,76 +341,101 @@ export class ProjectLifecycleManager { this._projectRoot = projectRoot this._initializedProject = undefined - this._pendingMigrationInitialize = pDefer() - this.legacyPluginGuard() - Promise.resolve(this.ctx.browser.machineBrowsers()).catch(this.onLoadError) - this.verifyProjectRoot(projectRoot) - const packageManagerUsed = this.getPackageManagerUsed(projectRoot) this.resetInternalState() + + this._configManager = this.createConfigManager() + + // Preemptively load these so that they are available when we need them later + this.ctx.browser.machineBrowsers().catch(this.onLoadError) + + const packageManagerUsed = this.getPackageManagerUsed(projectRoot) + this.ctx.update((s) => { s.currentProject = projectRoot s.packageManager = packageManagerUsed }) - const { needsCypressJsonMigration } = this.refreshMetaState() - - const legacyConfigPatah = path.join(projectRoot, this.legacyConfigFile) - - if (needsCypressJsonMigration && !this.ctx.isRunMode && this.ctx.fs.existsSync(legacyConfigPatah)) { - // we run the legacy plugins/index.js in a child process - // and mutate the config based on the return value for migration - // only used in open mode (cannot migrate via terminal) - const legacyConfig = this.ctx.fs.readJsonSync(legacyConfigPatah) as LegacyCypressConfigJson - - // should never throw, unless there existing pluginsFile errors out, - // in which case they are attempting to migrate an already broken project. - this.ctx.actions.migration.initialize(legacyConfig) - .then(this._pendingMigrationInitialize?.resolve) - .finally(() => this._pendingMigrationInitialize = undefined) - .catch(this.onLoadError) - } - - this.configFileWarningCheck() - - if (this.metaState.hasValidConfigFile) { - // at this point, there is not a cypress configuration file to initialize - // the project will be scaffolded and when the user selects the testing type - // the would like to setup - this.initializeConfig().catch(this.onLoadError) - } - - this.loadCypressEnvFile().catch(this.onLoadError) - - if (this.ctx.coreData.currentTestingType) { - this.setCurrentTestingType(this.ctx.coreData.currentTestingType) - } - - // If migration is needed only initialize the watchers - // when the migration is done. - // - // NOTE: If we watch the files while initializing, - // the config will be loaded before the migration is complete. - // The migration screen will disappear see `Main.vue` & `MigrationAction.ts` - if (!needsCypressJsonMigration) { - this.initializeConfigWatchers() + if (this.readyToInitialize(this._projectRoot)) { + this._configManager.initializeConfig().catch(this.onLoadError) } } - setRunModeExitEarly (exitEarly: ((err: Error) => void) | undefined) { - this._runModeExitEarly = exitEarly + /** + * Handles pre-initialization checks. These will display warnings or throw with errors if catastrophic. + * Returns false, if we're not ready to initialize due to needing to migrate + * + * @param projectRoot the project's root + * @returns true if we can initialize and false if not + */ + readyToInitialize (projectRoot: string): boolean { + this.verifyProjectRoot(projectRoot) + + const { needsCypressJsonMigration } = this.refreshMetaState() + + const legacyConfigPath = path.join(projectRoot, this.legacyConfigFile) + + if (needsCypressJsonMigration && !this.ctx.isRunMode && this.ctx.fs.existsSync(legacyConfigPath)) { + this.legacyMigration(legacyConfigPath).catch(this.onLoadError) + + return false + } + + this.legacyPluginGuard() + + this.configFileWarningCheck() + + return this.metaState.hasValidConfigFile + } + + async legacyMigration (legacyConfigPath: string) { + try { + // we run the legacy plugins/index.js in a child process + // and mutate the config based on the return value for migration + // only used in open mode (cannot migrate via terminal) + const legacyConfig = this.ctx.fs.readJsonSync(legacyConfigPath) as LegacyCypressConfigJson + + // should never throw, unless there existing pluginsFile errors out, + // in which case they are attempting to migrate an already broken project. + await this.ctx.actions.migration.initialize(legacyConfig) + + this.ctx.emitter.toLaunchpad() + } catch (error) { + this.onLoadError(error) + } } get runModeExitEarly () { return this._runModeExitEarly } + set runModeExitEarly (val: ((err: Error) => void) | undefined) { + this._runModeExitEarly = val + } + + /** + * Sets, but doesn't load the current testing type. This is useful + * for tests when we don't want to kick off node events + */ + setCurrentTestingType (testingType: TestingType | null) { + this.ctx.update((d) => { + d.currentTestingType = testingType + d.wizard.chosenBundler = null + d.wizard.chosenFramework = null + }) + + this._currentTestingType = testingType + + assert(this._configManager, 'Cannot set a testing type without a config manager') + this._configManager.setTestingType(testingType) + } + /** * Setting the testing type should automatically handle cleanup of existing * processes and load the config / initialize the plugin process associated * with the chosen testing type. */ - setCurrentTestingType (testingType: TestingType | null) { + setAndLoadCurrentTestingType (testingType: TestingType | null) { this.ctx.update((d) => { d.currentTestingType = testingType d.wizard.chosenBundler = null @@ -357,746 +449,73 @@ export class ProjectLifecycleManager { this._initializedProject = undefined this._currentTestingType = testingType + assert(this._configManager, 'Cannot set a testing type without a config manager') + this._configManager.setTestingType(testingType) + if (!testingType) { return } - if (this.isTestingTypeConfigured(testingType) && !(this.ctx.coreData.forceReconfigureProject && this.ctx.coreData.forceReconfigureProject[testingType])) { - this.loadTestingType() + if (this.ctx.isRunMode || (this.isTestingTypeConfigured(testingType) && !(this.ctx.coreData.forceReconfigureProject && this.ctx.coreData.forceReconfigureProject[testingType]))) { + this._configManager.loadTestingType() } } - /** - * Called after we've set the testing type. If we've change from the current - * IPC used to spawn the config, we need to get a fresh config IPC & re-execute. - */ - private loadTestingType () { - const testingType = this._currentTestingType - - assert(testingType, 'loadTestingType requires a testingType') - - // If we have set a testingType, and it's not the "target" of the - // registeredEvents (switching testing mode), we need to get a fresh - // config IPC & re-execute the setupTestingType - if (this._registeredEventsTarget && testingType !== this._registeredEventsTarget) { - this.reloadConfig().catch(this.onLoadError) - } else if (this._eventsIpc && !this._registeredEventsTarget && this._configResult.state === 'loaded') { - this.setupNodeEvents().catch(this.onLoadError) + scaffoldFilesIfNecessary () { + if (this._currentTestingType && this._projectMetaState.hasValidConfigFile && !this.isTestingTypeConfigured(this._currentTestingType) && !this.ctx.isRunMode) { + this.ctx.actions.wizard.scaffoldTestingType().catch(this.onLoadError) } } - private killChildProcesses () { - for (const proc of this.childProcesses) { - this._cleanupProcess(proc) - } - this.childProcesses = new Set() - } - - private _cleanupIpc (ipc: ProjectConfigIpc) { - this._cleanupProcess(ipc.childProcess) - ipc.removeAllListeners() - if (this._eventsIpc === ipc) { - this._eventsIpc = undefined - } - - if (this._eventProcess === ipc.childProcess) { - this._eventProcess = undefined - } - } - - private _cleanupProcess (proc: ChildProcess) { - proc.kill() - this.childProcesses.delete(proc) - } - - private closeWatchers () { - for (const watcher of this.watchers.values()) { - // We don't care if there's an error while closing the watcher, - // the watch listener on our end is already removed synchronously by chokidar - watcher.close().catch((e) => {}) - } - this.watchers = new Set() - } - private resetInternalState () { - if (this._eventsIpc) { - this._cleanupIpc(this._eventsIpc) + if (this._configManager) { + this._configManager.destroy() + this._configManager = undefined } - this.killChildProcesses() - this.closeWatchers() - this._configResult = { state: 'pending' } - this._eventsIpcResult = { state: 'pending' } - this._envFileResult = { state: 'pending' } - this._requireWatchers = { config: {}, setupNodeEvents: {} } - this._eventProcess = undefined - this._registeredEventsTarget = undefined + this.ctx.project.destroy() this._currentTestingType = null - this._configFilePath = undefined + this._cachedInitialConfig = undefined this._cachedFullConfig = undefined } - get eventProcessPid () { - return this._eventProcess?.pid - } - /** * Equivalent to the legacy "config.get()", * this sources the config from the various config sources */ async getFullInitialConfig (options: Partial = this.ctx.modeOptions, withBrowsers = true): Promise { - if (this._cachedFullConfig) { - return this._cachedFullConfig - } + assert(this._configManager, 'Cannot get full config a config manager') - const [configFileContents, envFile] = await Promise.all([ - this.getConfigFileContents(), - this.loadCypressEnvFile(), - ]) - - const fullConfig = await this.buildBaseFullConfig(configFileContents, envFile, options, withBrowsers) - - if (this._currentTestingType) { - this._cachedFullConfig = fullConfig - } - - return fullConfig + return this._configManager.getFullInitialConfig(options, withBrowsers) } - private async buildBaseFullConfig (configFileContents: Cypress.ConfigOptions, envFile: Cypress.ConfigOptions, options: Partial, withBrowsers = true) { - this.validateConfigRoot(configFileContents) - - if (this._currentTestingType) { - const testingTypeOverrides = configFileContents[this._currentTestingType] ?? {} - const optionsOverrides = options.config?.[this._currentTestingType] ?? {} - - this.validateTestingTypeConfig(testingTypeOverrides) - this.validateTestingTypeConfig(optionsOverrides) - - // TODO: pass in options.config overrides separately, so they are reflected in the UI - configFileContents = { ...configFileContents, ...testingTypeOverrides, ...optionsOverrides } - } - - // TODO: Convert this to be synchronous, it's just FS checks - let fullConfig = await this.ctx._apis.configApi.setupFullConfigWithDefaults({ - cliConfig: options.config ?? {}, - projectName: path.basename(this.projectRoot), - projectRoot: this.projectRoot, - config: _.cloneDeep(configFileContents), - envFile: _.cloneDeep(envFile), - options: { - ...options, - testingType: this._currentTestingType ?? 'e2e', - }, - }) - - if (withBrowsers) { - const browsers = await this.ctx.browser.machineBrowsers() - - if (!fullConfig.browsers || fullConfig.browsers.length === 0) { - // @ts-ignore - we don't know if the browser is headed or headless at this point. - // this is handled in open_project#launch. - fullConfig.browsers = browsers - fullConfig.resolved.browsers = { 'value': fullConfig.browsers, 'from': 'runtime' } - } - - fullConfig.browsers = fullConfig.browsers?.map((browser) => { - if (browser.family === 'chromium' || fullConfig.chromeWebSecurity) { - return browser - } - - return { - ...browser, - warning: browser.warning || getError('CHROME_WEB_SECURITY_NOT_SUPPORTED', browser.name).message, - } - }) - - // If we have withBrowsers set to false, it means we're coming from the legacy config.get API - // in tests, which shouldn't be validating the config - this.validateConfigFile(this.configFile, fullConfig) - } - - return _.cloneDeep(fullConfig) - } - - // private injectCtSpecificConfig (cfg: FullConfig) { - // cfg.resolved.testingType = { value: 'component' } - // // This value is normally set up in the `packages/server/lib/plugins/index.js#110` - // // But if we don't return it in the plugins function, it never gets set - // // Since there is no chance that it will have any other value here, we set it to "component" - // // This allows users to not return config in the `cypress/plugins/index.js` file - // // https://github.com/cypress-io/cypress/issues/16860 - // const rawJson = cfg.rawJson as Cfg - // return { - // ...cfg, - // componentTesting: true, - // viewportHeight: rawJson.viewportHeight ?? 500, - // viewportWidth: rawJson.viewportWidth ?? 500, - // } - // } - async getConfigFileContents () { - if (this._configResult.state === 'loaded') { - return this._configResult.value.initialConfig - } + assert(this._configManager, 'Cannot get config file contents without a config manager') - return this.initializeConfig() + return this._configManager.getConfigFileContents() } - /** - * Initializes the config by executing the config file. - * Returns the loaded config if we have already loaded the file - */ - initializeConfig (): Promise { - if (this._configResult.state === 'loaded') { - return Promise.resolve(this._configResult.value.initialConfig) - } + async loadCypressEnvFile () { + assert(this._configManager, 'Cannot load a cypress env file without a config manager') - if (this._configResult.state === 'loading') { - return this._configResult.value.then((v) => v.initialConfig) - } - - if (this._configResult.state === 'errored') { - return Promise.reject(this._configResult.value) - } - - assert.strictEqual(this._configResult.state, 'pending') - - const { promise, child, ipc } = this._loadConfig() - - this._cachedFullConfig = undefined - this._configResult = { state: 'loading', value: promise } - - promise.then((result) => { - if (this._configResult.value === promise) { - debug(`config is loaded for file`, this.configFilePath) - this._configResult = { state: 'loaded', value: result } - this.validateConfigFile(this.configFilePath, result.initialConfig) - this.onConfigLoaded(child, ipc, result) - } - - this.ctx.emitter.toLaunchpad() - }) - .catch((err) => { - debug(`catch %o`, err) - this._cleanupIpc(ipc) - this._configResult = { state: 'errored', value: err } - - this.onLoadError(err) - this.ctx.emitter.toLaunchpad() - }) - - return promise.then((v) => v.initialConfig) - } - - private validateTestingTypeConfig (config: Cypress.ConfigOptions) { - assert(this._currentTestingType) - - return this.ctx._apis.configApi.validateTestingTypeConfigBreakingChanges( - config, - this._currentTestingType, - (type, ...args) => { - return getError(type, ...args) - }, - (type, ...args) => { - throw getError(type, ...args) - }, - ) - } - - private validateConfigRoot (config: Cypress.ConfigOptions) { - return this.ctx._apis.configApi.validateRootConfigBreakingChanges( - config, - (type, obj) => { - return getError(type, obj) - }, - (type, obj) => { - throw getError(type, obj) - }, - ) - } - - private validateConfigFile (file: string | false, config: Cypress.ConfigOptions) { - this.ctx._apis.configApi.validateConfig(config, (errMsg) => { - if (_.isString(errMsg)) { - throw getError('CONFIG_VALIDATION_MSG_ERROR', 'configFile', file || null, errMsg) - } - - throw getError('CONFIG_VALIDATION_ERROR', 'configFile', file || null, errMsg) - }) - - return this.ctx._apis.configApi.validateLaunchpadConfigBreakingChanges( - config, - (type, obj) => { - const error = getError(type, obj) - - this.ctx.onWarning(error) - - return error - }, - (type, obj) => { - const error = getError(type, obj) - - this.ctx.onError(error) - - throw error - }, - ) - } - - /** - * Initializes the "watchers" for the current - * config for "open" mode. - */ - initializeConfigWatchers () { - if (this.ctx.isRunMode) { - return - } - - const legacyFileWatcher = this.addWatcher([ - this._pathToFile(this.legacyConfigFile), - ...potentialConfigFiles.map((f) => this._pathToFile(f)), - ]) - - legacyFileWatcher.on('all', (event, file) => { - debug('WATCHER: config file event', event, file) - let shouldReloadConfig = this.configFile === file - - if (!shouldReloadConfig) { - const metaState = this._projectMetaState - const nextMetaState = this.refreshMetaState() - - shouldReloadConfig = !_.isEqual(metaState, nextMetaState) - } - - if (shouldReloadConfig) { - this.ctx.update((coreData) => { - coreData.baseError = null - }) - - this.reloadConfig().catch(this.onLoadError) - } - }) - - legacyFileWatcher.on('error', (err) => { - debug('error watching config files %O', err) - this.ctx.onWarning(getError('UNEXPECTED_INTERNAL_ERROR', err)) - }) - - const cypressEnvFileWatcher = this.addWatcher(this.envFilePath) - - cypressEnvFileWatcher.on('all', () => { - this.ctx.update((coreData) => { - coreData.baseError = null - }) - - this.reloadCypressEnvFile().catch(this.onLoadError) - }) - } - - /** - * When we detect a change to the config file path, we call "reloadConfig". - * This sources a fresh IPC channel & reads the config. If we detect a change - * to the config or the list of imported files, we will re-execute the setupNodeEvents - */ - reloadConfig () { - if (this._configResult.state === 'errored' || this._configResult.state === 'loaded') { - this._configResult = { state: 'pending' } - debug('reloadConfig refresh') - - return this.initializeConfig() - } - - if (this._configResult.state === 'loading' || this._configResult.state === 'pending') { - debug('reloadConfig first load') - - return this.initializeConfig() - } - - throw new Error(`Unreachable state`) - } - - private _loadConfig () { - const dfd = pDeferFulfilled() - const child = this.forkConfigProcess() - const ipc = this.wrapConfigProcess(child, dfd) - - return { promise: dfd.promise, child, ipc } - } - - loadCypressEnvFile () { - if (!this._projectMetaState.hasCypressEnvFile) { - this._envFileResult = { state: 'loaded', value: {} } - } - - if (this._envFileResult.state === 'loading') { - return this._envFileResult.value - } - - if (this._envFileResult.state === 'errored') { - return Promise.reject(this._envFileResult.value) - } - - if (this._envFileResult.state === 'loaded') { - return Promise.resolve(this._envFileResult.value) - } - - assert.strictEqual(this._envFileResult.state, 'pending') - - const promise = this.readCypressEnvFile().then((value) => { - this.validateConfigFile(this.envFilePath, value) - this._envFileResult = { state: 'loaded', value } - - return value - }) - .catch((e) => { - this._envFileResult = { state: 'errored', value: e } - throw e - }) - .finally(() => { - this.ctx.emitter.toLaunchpad() - }) - - this._envFileResult = { state: 'loading', value: promise } - - return promise - } - - private async reloadCypressEnvFile () { - if (this._envFileResult.state === 'loading') { - return this._envFileResult.value - } - - this._envFileResult = { state: 'pending' } - - return this.loadCypressEnvFile() - } - - /** - * Initializes the config by reading the config file, if we - * know we have one for the project - */ - private async readCypressEnvFile (): Promise { - try { - return await this.ctx.fs.readJSON(this.envFilePath) - } catch (err: any) { - if (err.code === 'ENOENT') { - return {} - } - - if (err.isCypressErr) { - throw err - } - - throw getError('ERROR_READING_FILE', this.envFilePath, err) - } - } - - private _requireWatchers: RequireWatchers = { - config: {}, - setupNodeEvents: {}, - } - - private watchRequires (groupName: 'config' | 'setupNodeEvents', paths: string[]) { - if (this.ctx.isRunMode) { - return - } - - const filtered = paths.filter((p) => !p.includes('/node_modules/')) - const group = this._requireWatchers[groupName] - const missing = _.xor(Object.keys(group), filtered) - - for (const path of missing) { - if (!group[path]) { - group[path] = this.addWatcherFor(groupName, path) - } else { - group[path]?.close() - delete group[path] - } - } - } - - /** - * Called on the completion of the - */ - private onConfigLoaded (child: ChildProcess, ipc: ProjectConfigIpc, result: LoadConfigReply) { - this.watchRequires('config', result.requires) - - // If there's already a dangling IPC from the previous switch of testing type, we want to clean this up - if (this._eventsIpc) { - this._cleanupIpc(this._eventsIpc) - } - - this._eventProcess = child - this._eventsIpc = ipc - - if (!this._currentTestingType || this._eventsIpcResult.state === 'loading') { - return - } - - if (!this.isTestingTypeConfigured(this._currentTestingType) && !this.ctx.isRunMode) { - this.ctx.actions.wizard.scaffoldTestingType().catch(this.onLoadError) - - return - } - - if (this.ctx.coreData.scaffoldedFiles) { - this.ctx.coreData.scaffoldedFiles.filter((f) => { - if (f.file.absolute === this.configFilePath && f.status !== 'valid') { - f.status = 'valid' - this.ctx.emitter.toLaunchpad() - } - }) - } - - this.setupNodeEvents().catch(this.onLoadError) - } - - private setupNodeEvents (): Promise { - assert(this._eventsIpc, 'Expected _eventsIpc to be defined at this point') - const ipc = this._eventsIpc - const promise = this.callSetupNodeEventsWithConfig(ipc) - - this._eventsIpcResult = { state: 'loading', value: promise } - - return promise.then(async (val) => { - if (this._eventsIpcResult.value === promise) { - // If we're handling the events, we don't want any notifications - // to send to the client until the `.finally` of this block. - // TODO: Remove when GraphQL Subscriptions lands - await this.handleSetupTestingTypeReply(ipc, val) - this._eventsIpcResult = { state: 'loaded', value: val } - } - - return val - }) - .catch((err) => { - debug(`catch %o`, err) - this._cleanupIpc(ipc) - this._eventsIpcResult = { state: 'errored', value: err } - throw err - }) - .finally(() => { - this.ctx.emitter.toLaunchpad() - }) - } - - private async callSetupNodeEventsWithConfig (ipc: ProjectConfigIpc): Promise { - const config = await this.getFullInitialConfig() - - assert(config) - assert(this._currentTestingType) - - this._registeredEventsTarget = this._currentTestingType - - for (const handler of this._handlers) { - handler(ipc) - } - - const { promise } = this.registerSetupIpcHandlers(ipc) - - const overrides = config[this._currentTestingType] ?? {} - const mergedConfig = { ...config, ...overrides } - - // alphabetize config by keys - let orderedConfig = {} as Cypress.PluginConfigOptions - - Object.keys(mergedConfig).sort().forEach((key) => { - const k = key as keyof typeof mergedConfig - - // @ts-ignore - orderedConfig[k] = mergedConfig[k] - }) - - ipc.send('setupTestingType', this._currentTestingType, { - ...orderedConfig, - projectRoot: this.projectRoot, - configFile: this.configFilePath, - version: this.ctx._apis.configApi.cypressVersion, - testingType: this._currentTestingType, - }) - - return promise - } - - addWatcherFor (groupName: 'config' | 'setupNodeEvents', file: string) { - const w = this.addWatcher(file) - - w.on('all', (evt) => { - debug(`changed ${file}: ${evt}`) - // TODO: in the future, we will make this more specific to the individual process we need to load - if (groupName === 'config') { - this.reloadConfig().catch(this.onLoadError) - } else { - // If we've edited the setupNodeEvents file, we need to re-execute - // the config file to get a fresh ipc process to swap with - this.reloadConfig().catch(this.onLoadError) - } - }) - - return w - } - - addWatcher (file: string | string[]) { - const w = chokidar.watch(file, { - ignoreInitial: true, - cwd: this.projectRoot, - }) - - this.watchers.add(w) - - return w - } - - closeWatcher (watcherToClose: FSWatcher) { - for (const watcher of this.watchers.values()) { - if (watcher === watcherToClose) { - watcher.close().catch(() => {}) - this.watchers.delete(watcher) - - return - } - } - } - - registerEvent (event: string, callback: Function) { - debug(`register event '${event}'`) - - if (!_.isString(event)) { - throw new Error(`The plugin register function must be called with an event as its 1st argument. You passed '${event}'.`) - } - - if (!_.isFunction(callback)) { - throw new Error(`The plugin register function must be called with a callback function as its 2nd argument. You passed '${callback}'.`) - } - - this._registeredEvents[event] = callback + return this._configManager.loadCypressEnvFile() } reinitializeCypress () { + resetPluginHandlers() this.resetInternalState() - this._registeredEvents = {} - this._handlers = [] + } + + registerEvent (event: string, callback: Function) { + return this._eventRegistrar.registerEvent(event, callback) } hasNodeEvent (eventName: string) { - const isRegistered = typeof this._registeredEvents[eventName] === 'function' - - debug('plugin event registered? %o', { eventName, isRegistered }) - - return isRegistered + return this._eventRegistrar.hasNodeEvent(eventName) } executeNodeEvent (event: string, args: any[]) { - debug(`execute plugin event '${event}' Node '${process.version}' with args: %o %o %o`, ...args) - - const evtFn = this._registeredEvents[event] - - if (typeof evtFn !== 'function') { - throw new Error(`Missing event for ${event}`) - } - - return evtFn(...args) - } - - private forkConfigProcess () { - const configProcessArgs = ['--projectRoot', this.projectRoot, '--file', this.configFilePath] - // allow the use of ts-node in subprocesses tests by removing the env constant from it - // without this line, packages/ts/register.js never registers the ts-node module for config and - // run_plugins can't use the config module. - const { CYPRESS_INTERNAL_E2E_TESTING_SELF, ...env } = process.env - - env.NODE_OPTIONS = process.env.ORIGINAL_NODE_OPTIONS || '' - - const childOptions: ForkOptions = { - stdio: 'pipe', - cwd: path.dirname(this.configFilePath), - env, - execPath: this.ctx.nodePath ?? undefined, - } - - if (inspector.url()) { - childOptions.execArgv = _.chain(process.execArgv.slice(0)) - .remove('--inspect-brk') - .push(`--inspect=${process.debugPort + this.childProcesses.size + 1}`) - .value() - } - - debug('fork child process', CHILD_PROCESS_FILE_PATH, configProcessArgs, _.omit(childOptions, 'env')) - - const proc = fork(CHILD_PROCESS_FILE_PATH, configProcessArgs, childOptions) - - this.childProcesses.add(proc) - - return proc - } - - private killChildProcess (child: ChildProcess) { - child.kill() - child.stdout?.removeAllListeners() - child.stderr?.removeAllListeners() - child.removeAllListeners() - } - - private wrapConfigProcess (child: ChildProcess, dfd: pDefer.DeferredPromise & { settled: boolean }) { - // The "IPC" is an EventEmitter wrapping the child process, adding a "send" - // method, and re-emitting any "message" that comes through the channel through the EventEmitter - const ipc = new ProjectConfigIpc(child) - - if (child.stdout && child.stderr) { - // manually pipe plugin stdout and stderr for dashboard capture - // @see https://github.com/cypress-io/cypress/issues/7434 - child.stdout.on('data', (data) => process.stdout.write(data)) - child.stderr.on('data', (data) => process.stderr.write(data)) - } - - child.on('error', (err) => { - this.handleChildProcessError(err, ipc, dfd) - }) - - /** - * This reject cannot be caught anywhere?? - * - * It's supposed to be caught on lib/modes/run.js:1689, - * but it's not. - */ - ipc.on('childProcess:unhandledError', (err) => { - return this.handleChildProcessError(err, ipc, dfd) - }) - - ipc.once('loadConfig:reply', (val) => { - debug('loadConfig:reply') - dfd.resolve({ ...val, initialConfig: JSON.parse(val.initialConfig) }) - }) - - ipc.once('loadConfig:error', (err) => { - this.killChildProcess(child) - dfd.reject(err) - }) - - debug('trigger the load of the file') - ipc.once('ready', () => { - ipc.send('loadConfig') - }) - - return ipc - } - - private handleChildProcessError (err: any, ipc: ProjectConfigIpc, dfd: pDefer.DeferredPromise & {settled: boolean}) { - debug('plugins process error:', err.stack) - - this._cleanupIpc(ipc) - - err = getError('CONFIG_FILE_UNEXPECTED_ERROR', this.configFile || '(unknown config file)', err) - err.title = 'Config process error' - - // this can sometimes trigger before the promise is fulfilled and - // sometimes after, so we need to handle each case differently - if (dfd.settled) { - this.ctx.onError(err) - } else { - dfd.reject(err) - } + return this._eventRegistrar.executeNodeEvent(event, args) } private legacyPluginGuard () { @@ -1157,8 +576,8 @@ export class ProjectLifecycleManager { } } } else { - this._configFilePath = this._pathToFile(configFile) - if (fs.existsSync(this._configFilePath)) { + this.setConfigFilePath(configFile) + if (fs.existsSync(this.configFilePath)) { metaState.hasValidConfigFile = true } } @@ -1168,9 +587,11 @@ export class ProjectLifecycleManager { return metaState } + let configFilePathSet = false + metaState.allFoundConfigFiles = [] - for (const fileName of potentialConfigFiles) { + for (const fileName of POTENTIAL_CONFIG_FILES) { const filePath = this._pathToFile(fileName) const fileExists = fs.existsSync(filePath) @@ -1181,17 +602,19 @@ export class ProjectLifecycleManager { // We've found our first config file! We'll continue looping to make sure there's // only one. Looping over all config files is done so we can provide rich errors and warnings. - if (!this._configFilePath) { + if (!configFilePathSet) { metaState.hasValidConfigFile = true this.setConfigFilePath(fileName) + configFilePathSet = true } } } // We finished looping through all of the possible config files // And we *still* didn't find anything. Set the configFilePath to JS or TS. - if (!this._configFilePath) { + if (!configFilePathSet) { this.setConfigFilePath(`cypress.config.${metaState.hasTypescript ? 'ts' : 'js'}`) + configFilePathSet = true } if (metaState.hasLegacyCypressJson && !metaState.hasValidConfigFile) { @@ -1203,10 +626,6 @@ export class ProjectLifecycleManager { return metaState } - setConfigFilePath (fileName: string) { - this._configFilePath = this._pathToFile(fileName) - } - private _pathToFile (file: string) { return path.isAbsolute(file) ? file : path.join(this.projectRoot, file) } @@ -1221,123 +640,6 @@ export class ProjectLifecycleManager { } } - private async handleSetupTestingTypeReply (ipc: ProjectConfigIpc, result: SetupNodeEventsReply) { - this._registeredEvents = {} - this.watchRequires('setupNodeEvents', result.requires) - - for (const { event, eventId } of result.registrations) { - debug('register plugins process event', event, 'with id', eventId) - - this.registerEvent(event, function (...args: any[]) { - return new Promise((resolve, reject) => { - const invocationId = _.uniqueId('inv') - - debug('call event', event, 'for invocation id', invocationId) - - ipc.once(`promise:fulfilled:${invocationId}`, (err: any, value: any) => { - if (err) { - debug('promise rejected for id %s %o', invocationId, ':', err.stack) - reject(_.extend(new Error(err.message), err)) - - return - } - - if (value === UNDEFINED_SERIALIZED) { - value = undefined - } - - debug(`promise resolved for id '${invocationId}' with value`, value) - - return resolve(value) - }) - - const ids = { invocationId, eventId } - - // no argument is passed for cy.task() - // This is necessary because undefined becomes null when it is sent through ipc. - if (event === 'task' && args[1] === undefined) { - args[1] = { - __cypress_task_no_argument__: true, - } - } - - ipc.send('execute:plugins', event, ids, args) - }) - }) - } - - assert(this._envFileResult.state === 'loaded') - assert(this._configResult.state === 'loaded') - - const fullConfig = await this.buildBaseFullConfig(this._configResult.value.initialConfig, this._envFileResult.value, this.ctx.modeOptions) - - const finalConfig = this._cachedFullConfig = this.ctx._apis.configApi.updateWithPluginValues(fullConfig, result.setupConfig ?? {}) - - // This happens automatically with openProjectCreate in run mode - if (!this.ctx.isRunMode) { - if (!this._initializedProject) { - this._initializedProject = await this.ctx.actions.project.initializeActiveProject({}) - } else { - // TODO: modify the _initializedProject - } - } - - if (this.ctx.coreData.cliBrowser) { - await this.setActiveBrowser(this.ctx.coreData.cliBrowser) - } - - this._pendingInitialize?.resolve(finalConfig) - - if (this._currentTestingType && finalConfig.specPattern) { - return this.ctx.actions.project.setSpecsFoundBySpecPattern({ - path: this.projectRoot, - testingType: this._currentTestingType, - specPattern: this.ctx.modeOptions.spec || finalConfig.specPattern, - excludeSpecPattern: finalConfig.excludeSpecPattern, - additionalIgnorePattern: finalConfig.additionalIgnorePattern, - }) - } - - return result - } - - private async setActiveBrowser (cliBrowser: string) { - // When we're starting up, if we've chosen a browser to run with, check if it exists - this.ctx.coreData.cliBrowser = null - - try { - const browser = await this.ctx._apis.browserApi.ensureAndGetByNameOrPath(cliBrowser) - - this.ctx.coreData.chosenBrowser = browser ?? null - } catch (e) { - const error = e as CypressError - - this.ctx.onWarning(error) - } - } - - private registerSetupIpcHandlers (ipc: ProjectConfigIpc) { - const dfd = pDefer() - - ipc.childProcess.on('error', dfd.reject) - - // For every registration event, we want to turn into an RPC with the child process - ipc.once('setupTestingType:reply', dfd.resolve) - ipc.once('setupTestingType:error', (err) => { - dfd.reject(err) - }) - - const handleWarning = (warningErr: CypressError) => { - debug('plugins process warning:', warningErr.stack) - - return this.ctx.onWarning(warningErr) - } - - ipc.on('warning', handleWarning) - - return dfd - } - destroy () { this.resetInternalState() // @ts-ignore @@ -1368,19 +670,19 @@ export class ProjectLifecycleManager { return this.metaState.needsCypressJsonMigration && Boolean(legacyConfigFileExist) } - private _pendingInitialize?: pDefer.DeferredPromise - - async initializeRunMode () { + async initializeRunMode (testingType: TestingType | null) { this._pendingInitialize = pDefer() - if (!this._currentTestingType) { - // e2e is assumed to be the default testing type if - // none is passed in run mode - this.setCurrentTestingType('e2e') - } + if (await this.waitForInitializeSuccess()) { + if (!this.metaState.hasValidConfigFile) { + return this.ctx.onError(getError('NO_DEFAULT_CONFIG_FILE_FOUND', this.projectRoot)) + } - if (!this.metaState.hasValidConfigFile) { - return this.ctx.onError(getError('NO_DEFAULT_CONFIG_FILE_FOUND', this.projectRoot)) + if (testingType) { + this.setAndLoadCurrentTestingType(testingType) + } else { + this.setAndLoadCurrentTestingType('e2e') + } } return this._pendingInitialize.promise.finally(() => { @@ -1414,37 +716,10 @@ export class ProjectLifecycleManager { * for run mode */ private onLoadError = (err: any) => { - if (this.ctx.isRunMode && this._pendingInitialize) { - this._pendingInitialize.reject(err) + if (this.ctx.isRunMode && this._configManager) { + this._configManager.onLoadError(err) } else { this.ctx.onError(err, 'Error Loading Config') } } } - -function pDeferFulfilled (): pDefer.DeferredPromise & {settled: boolean} { - const dfd = pDefer() - let settled = false - const { promise, resolve, reject } = dfd - - const resolveFn = function (val: T) { - settled = true - - return resolve(val) - } - - const rejectFn = function (val: any) { - settled = true - - return reject(val) - } - - return { - promise, - resolve: resolveFn, - reject: rejectFn, - get settled () { - return settled - }, - } -} diff --git a/packages/data-context/src/data/coreDataShape.ts b/packages/data-context/src/data/coreDataShape.ts index 0264d2e8c5..1925984805 100644 --- a/packages/data-context/src/data/coreDataShape.ts +++ b/packages/data-context/src/data/coreDataShape.ts @@ -112,7 +112,7 @@ export interface CoreDataShape { cliBrowser: string | null cliTestingType: string | null chosenBrowser: FoundBrowser | null - machineBrowsers: Promise | FoundBrowser[] | null + machineBrowsers: Promise | null servers: { appServer?: Maybe appServerPort?: Maybe diff --git a/packages/data-context/src/data/index.ts b/packages/data-context/src/data/index.ts index 9204cd6c19..5b0716252f 100644 --- a/packages/data-context/src/data/index.ts +++ b/packages/data-context/src/data/index.ts @@ -1,7 +1,10 @@ /* eslint-disable padding-line-between-statements */ // created by autobarrel, do not modify directly +export * from './CypressEnv' +export * from './EventRegistrar' export * from './LegacyPluginsIpc' export * from './ProjectConfigIpc' +export * from './ProjectConfigManager' export * from './ProjectLifecycleManager' export * from './coreDataShape' diff --git a/packages/data-context/src/index.ts b/packages/data-context/src/index.ts index fd0ebc0906..7ad79cbb33 100644 --- a/packages/data-context/src/index.ts +++ b/packages/data-context/src/index.ts @@ -12,6 +12,8 @@ export type { GlobalPubSub, } from './globalPubSub' +export * from './util/pluginHandlers' + import { globalPubSub } from './globalPubSub' export { globalPubSub } diff --git a/packages/data-context/src/sources/BrowserDataSource.ts b/packages/data-context/src/sources/BrowserDataSource.ts index 5e48e9ccc5..78f43a6b17 100644 --- a/packages/data-context/src/sources/BrowserDataSource.ts +++ b/packages/data-context/src/sources/BrowserDataSource.ts @@ -32,15 +32,12 @@ export class BrowserDataSource { if (!this.ctx.coreData.machineBrowsers) { const p = this.ctx._apis.browserApi.getBrowsers() - this.ctx.coreData.machineBrowsers = p - p.then((browsers) => { - if (this.ctx.coreData.machineBrowsers === p) { - if (browsers[0]) { - this.ctx.coreData.chosenBrowser = browsers[0] - } - - this.ctx.coreData.machineBrowsers = browsers + this.ctx.coreData.machineBrowsers = p.then((browsers) => { + if (browsers[0]) { + this.ctx.coreData.chosenBrowser = browsers[0] } + + return browsers }).catch((e) => { this.ctx.update((coreData) => { coreData.machineBrowsers = null diff --git a/packages/data-context/src/sources/ProjectDataSource.ts b/packages/data-context/src/sources/ProjectDataSource.ts index e1e00a3090..bbe340b6c1 100644 --- a/packages/data-context/src/sources/ProjectDataSource.ts +++ b/packages/data-context/src/sources/ProjectDataSource.ts @@ -1,4 +1,5 @@ import os from 'os' +import chokidar from 'chokidar' import type { ResolvedFromConfig, RESOLVED_FROM, FoundSpec } from '@packages/types' import { FrontendFramework, FRONTEND_FRAMEWORKS } from '@packages/scaffold-config' import { scanFSForAvailableDependency } from 'create-cypress-tests' @@ -258,7 +259,11 @@ export class ProjectDataSource { this.ctx.actions.project.setSpecs(specs) }) - this._specWatcher = this.ctx.lifecycleManager.addWatcher(specPattern) + this._specWatcher = chokidar.watch(specPattern, { + ignoreInitial: true, + cwd: projectRoot, + }) + this._specWatcher.on('add', onSpecsChanged) this._specWatcher.on('change', onSpecsChanged) this._specWatcher.on('unlink', onSpecsChanged) @@ -319,12 +324,17 @@ export class ProjectDataSource { return false } + destroy () { + this.stopSpecWatcher() + } + stopSpecWatcher () { if (!this._specWatcher) { return } - this.ctx.lifecycleManager.closeWatcher(this._specWatcher) + this._specWatcher.close().catch(() => {}) + this._specWatcher = null } getCurrentSpecByAbsolute (absolute: string) { diff --git a/packages/data-context/src/util/autoBindDebug.ts b/packages/data-context/src/util/autoBindDebug.ts index 274109cb7e..6f6609ffc6 100644 --- a/packages/data-context/src/util/autoBindDebug.ts +++ b/packages/data-context/src/util/autoBindDebug.ts @@ -2,6 +2,15 @@ import debugLib from 'debug' const debugLibCache: Record = {} +/** + * This function enables trace logging on all function calls and setters for a given class. + * If you add: + * + * return autoBindDebug(this) + * + * to the constructor of the class for which you want to enable logging, you can then + * set DEBUG=cypress-trace: to utilize the logging + */ export function autoBindDebug (obj: T): T { const ns = `cypress-trace:${obj.constructor.name}` const debug = debugLibCache[ns] = debugLibCache[ns] || debugLib(ns) diff --git a/packages/data-context/src/util/index.ts b/packages/data-context/src/util/index.ts index 9c5c9fd014..208a59d581 100644 --- a/packages/data-context/src/util/index.ts +++ b/packages/data-context/src/util/index.ts @@ -5,4 +5,5 @@ export * from './autoBindDebug' export * from './cached' export * from './config-file-updater' export * from './file' +export * from './pluginHandlers' export * from './urqlCacheKeys' diff --git a/packages/data-context/src/util/pluginHandlers.ts b/packages/data-context/src/util/pluginHandlers.ts new file mode 100644 index 0000000000..1a1ecdb962 --- /dev/null +++ b/packages/data-context/src/util/pluginHandlers.ts @@ -0,0 +1,15 @@ +import type { IpcHandler } from '../data' + +let pluginHandlers: IpcHandler[] = [] + +export const getServerPluginHandlers = () => { + return pluginHandlers +} + +export const registerServerPluginHandler = (handler: IpcHandler) => { + pluginHandlers.push(handler) +} + +export const resetPluginHandlers = () => { + pluginHandlers = [] +} diff --git a/packages/data-context/test/unit/helper.ts b/packages/data-context/test/unit/helper.ts index 52664bc67e..363e1c11f0 100644 --- a/packages/data-context/test/unit/helper.ts +++ b/packages/data-context/test/unit/helper.ts @@ -32,9 +32,7 @@ export function createTestDataContext (mode: DataContextConfig['mode'] = 'run') appApi: {} as AppApiShape, localSettingsApi: {} as LocalSettingsApiShape, authApi: {} as AuthApiShape, - configApi: { - getServerPluginHandlers: () => [], - } as InjectedConfigApi, + configApi: {} as InjectedConfigApi, projectApi: {} as ProjectApiShape, electronApi: { copyTextToClipboard: (text) => {}, diff --git a/packages/data-context/test/unit/sources/ProjectDataSource.spec.ts b/packages/data-context/test/unit/sources/ProjectDataSource.spec.ts index f4af19578b..784bfd49b3 100644 --- a/packages/data-context/test/unit/sources/ProjectDataSource.spec.ts +++ b/packages/data-context/test/unit/sources/ProjectDataSource.spec.ts @@ -144,9 +144,7 @@ describe('findSpecs', () => { appApi: {} as AppApiShape, localSettingsApi: {} as LocalSettingsApiShape, authApi: {} as AuthApiShape, - configApi: { - getServerPluginHandlers: () => [], - } as InjectedConfigApi, + configApi: {} as InjectedConfigApi, projectApi: {} as ProjectApiShape, electronApi: {} as ElectronApiShape, browserApi: {} as BrowserApiShape, diff --git a/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts b/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts index 470562704c..ae6c064f63 100644 --- a/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts +++ b/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts @@ -225,7 +225,8 @@ function startAppServer (mode: 'component' | 'e2e' = 'e2e') { return logInternal('startAppServer', (log) => { return cy.window({ log: false }).then((win) => { return cy.withCtx(async (ctx, o) => { - ctx.actions.project.setCurrentTestingType(o.mode) + await ctx.lifecycleManager.waitForInitializeSuccess() + ctx.actions.project.setAndLoadCurrentTestingType(o.mode) const isInitialized = o.pDefer() const initializeActive = ctx.actions.project.initializeActiveProject const onErrorStub = o.sinon.stub(ctx, 'onError') diff --git a/packages/frontend-shared/src/gql-components/TestingTypePicker.vue b/packages/frontend-shared/src/gql-components/TestingTypePicker.vue index 9dd826434e..57c58a1b8d 100644 --- a/packages/frontend-shared/src/gql-components/TestingTypePicker.vue +++ b/packages/frontend-shared/src/gql-components/TestingTypePicker.vue @@ -1,10 +1,10 @@