diff --git a/packages/data-context/src/data/ProjectConfigManager.ts b/packages/data-context/src/data/ProjectConfigManager.ts index 485248047b..904ba48450 100644 --- a/packages/data-context/src/data/ProjectConfigManager.ts +++ b/packages/data-context/src/data/ProjectConfigManager.ts @@ -2,7 +2,7 @@ import { CypressError, getError } from '@packages/errors' import { IpcHandler, LoadConfigReply, ProjectConfigIpc, SetupNodeEventsReply } from './ProjectConfigIpc' import assert from 'assert' import pDefer from 'p-defer' -import type { AllModeOptions, FoundBrowser, FullConfig, OpenProjectLaunchOptions, TestingType } from '@packages/types' +import type { AllModeOptions, FoundBrowser, FullConfig, TestingType } from '@packages/types' import debugLib from 'debug' import { ChildProcess, ForkOptions, fork } from 'child_process' import path from 'path' @@ -20,13 +20,6 @@ const CHILD_PROCESS_FILE_PATH = require.resolve('@packages/server/lib/plugins/ch const UNDEFINED_SERIALIZED = '__cypress_undefined__' -const POTENTIAL_CONFIG_FILES = [ - 'cypress.config.ts', - 'cypress.config.mjs', - 'cypress.config.cjs', - 'cypress.config.js', -] - type ProjectConfigManagerOptions = { configFile: string | false projectRoot: string @@ -42,7 +35,6 @@ type ProjectConfigManagerOptions = { onInitialConfigLoaded: (initialConfig: Cypress.ConfigOptions) => void onFinalConfigLoaded: (finalConfig: FullConfig) => Promise updateWithPluginValues: (config: FullConfig, modifiedConfig: Partial) => FullConfig - initializeActiveProject: (options?: OpenProjectLaunchOptions) => Promise setupFullConfigWithDefaults: (config: SetupFullConfigOptions) => Promise machineBrowsers: () => FoundBrowser[] | Promise } @@ -55,11 +47,10 @@ export class ProjectConfigManager { private _childProcesses = new Set() private _eventsIpc?: ProjectConfigIpc private _eventProcess: ChildProcess | undefined - private _requireWatchers: Record = {} + private _pathToWatcherRecord: Record = {} private _watchers = new Set() private _registeredEvents: Record = {} private _registeredEventsTarget: TestingType | undefined - private _initializedProject: unknown | undefined private _testingType: TestingType | undefined private _state: ConfigManagerState = 'pending' private _loadConfigPromise: Promise | undefined @@ -149,6 +140,17 @@ export class ProjectConfigManager { } } + 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.reloadConfig().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' @@ -259,20 +261,14 @@ export class ProjectConfigManager { const finalConfig = this._cachedFullConfig = this.options.updateWithPluginValues(fullConfig, result.setupConfig ?? {}) - // This happens automatically with openProjectCreate in run mode - if (!this.options.isRunMode) { - if (!this._initializedProject) { - this._initializedProject = await this.options.initializeActiveProject({}) - } else { - // TODO: modify the _initializedProject - } - } - await this.options.onFinalConfigLoaded(finalConfig) - this.watchRequires(loadConfigReply.requires) - this.watchRequires(result.requires) - this.initializeConfigWatchers() + this.watchFiles([ + ...loadConfigReply.requires, + ...result.requires, + this.configFilePath, + this.envFilePath, + ]) return result } @@ -288,17 +284,6 @@ export class ProjectConfigManager { this.loadTestingType() } - 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.reloadConfig().catch(this.onLoadError) - } else if (this._eventsIpc && !this._registeredEventsTarget && this._cachedLoadConfig) { - this.setupNodeEvents(this._cachedLoadConfig).catch(this.onLoadError) - } - } - 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 @@ -306,11 +291,8 @@ export class ProjectConfigManager { this._cleanupIpc(this._eventsIpc) } - const dfd = pDeferFulfilled() - this._eventProcess = this.forkConfigProcess() - this._eventsIpc = this.wrapConfigProcess(this._eventProcess, dfd) - this._loadConfigPromise = dfd.promise + this._loadConfigPromise = this.wrapConfigProcess(this._eventProcess) } return this._loadConfigPromise @@ -348,51 +330,58 @@ export class ProjectConfigManager { return proc } - 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) + private wrapConfigProcess (child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + // 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)) - } + 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) + let resolved = false + + child.on('error', (err) => { + this.handleChildProcessError(err, ipc, 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. + */ + ipc.on('childProcess:unhandledError', (err) => { + this.handleChildProcessError(err, ipc, resolved, reject) + reject(err) + }) + + ipc.once('loadConfig:reply', (val) => { + debug('loadConfig:reply') + resolve({ ...val, initialConfig: JSON.parse(val.initialConfig) }) + resolved = true + }) + + ipc.once('loadConfig:error', (err) => { + this.killChildProcess(child) + reject(err) + }) + + debug('trigger the load of the file') + ipc.once('ready', () => { + ipc.send('loadConfig') + }) + + this._eventsIpc = ipc }) - - /** - * 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}) { + private handleChildProcessError (err: any, ipc: ProjectConfigIpc, resolved: boolean, reject: (reason?: any) => void) { debug('plugins process error:', err.stack) this._state = 'errored' @@ -403,10 +392,10 @@ export class ProjectConfigManager { // this can sometimes trigger before the promise is fulfilled and // sometimes after, so we need to handle each case differently - if (dfd.settled) { + if (resolved) { this.options.onError(err) } else { - dfd.reject(err) + reject(err) } } @@ -475,7 +464,7 @@ export class ProjectConfigManager { this.options.onError(error, 'Error Loading Config') } - private watchRequires (paths: string[]) { + private watchFiles (paths: string[]) { if (this.options.isRunMode) { return } @@ -483,13 +472,13 @@ export class ProjectConfigManager { const filtered = paths.filter((p) => !p.includes('/node_modules/')) for (const path of filtered) { - if (!this._requireWatchers[path]) { - this._requireWatchers[path] = this.addWatcherFor(path) + if (!this._pathToWatcherRecord[path]) { + this._pathToWatcherRecord[path] = this.addWatcherFor(path) } } } - addWatcherFor (file: string) { + private addWatcherFor (file: string) { const w = this.addWatcher(file) w.on('all', (evt) => { @@ -497,10 +486,15 @@ export class ProjectConfigManager { this.reloadConfig().catch(this.onLoadError) }) + w.on('error', (err) => { + debug('error watching config files %O', err) + this.options.onWarning(getError('UNEXPECTED_INTERNAL_ERROR', err)) + }) + return w } - addWatcher (file: string | string[]) { + private addWatcher (file: string | string[]) { const w = chokidar.watch(file, { ignoreInitial: true, cwd: this.options.projectRoot, @@ -707,45 +701,14 @@ export class ProjectConfigManager { return true } - private _pathToFile (file: string) { - return path.isAbsolute(file) ? file : path.join(this.options.projectRoot, file) - } - - private initializeConfigWatchers () { - if (this.options.isRunMode) { - return - } - - const configWatchers = this.addWatcher([ - ...POTENTIAL_CONFIG_FILES.map((f) => this._pathToFile(f)), - ]) - - configWatchers.on('all', (event, file) => { - debug('WATCHER: config file event', event, file) - - this.reloadConfig().catch(this.onLoadError) - }) - - configWatchers.on('error', (err) => { - debug('error watching config files %O', err) - this.options.onWarning(getError('UNEXPECTED_INTERNAL_ERROR', err)) - }) - - const cypressEnvFileWatcher = this.addWatcher(this.envFilePath) - - cypressEnvFileWatcher.on('all', () => { - this.reloadConfig().catch(this.onLoadError) - }) - } - private closeWatchers () { - for (const watcher of [...this._watchers.values(), ...Object.values(this._requireWatchers)]) { + 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._requireWatchers = {} + this._pathToWatcherRecord = {} } destroy () { @@ -762,30 +725,3 @@ export class ProjectConfigManager { this.closeWatchers() } } - -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/ProjectLifecycleManager.ts b/packages/data-context/src/data/ProjectLifecycleManager.ts index a2f02bfd9a..1410efcf29 100644 --- a/packages/data-context/src/data/ProjectLifecycleManager.ts +++ b/packages/data-context/src/data/ProjectLifecycleManager.ts @@ -81,6 +81,7 @@ export class ProjectLifecycleManager { private _pendingInitialize?: pDefer.DeferredPromise private _cachedInitialConfig: Cypress.ConfigOptions | undefined private _cachedFullConfig: FullConfig | undefined + private _initializedProject: unknown | undefined constructor (private ctx: DataContext) { if (ctx.coreData.currentProject) { @@ -194,10 +195,11 @@ export class ProjectLifecycleManager { clearCurrentProject () { this.resetInternalState() + this._initializedProject = undefined this._projectRoot = undefined } - getPackageManagerUsed (projectRoot: string) { + private getPackageManagerUsed (projectRoot: string) { if (fs.existsSync(path.join(projectRoot, 'package-lock.json'))) { return 'npm' } @@ -251,6 +253,15 @@ export class ProjectLifecycleManager { 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({}) + } else { + // TODO: modify the _initializedProject + } + } + if (this.ctx.coreData.cliBrowser) { await this.setActiveBrowser(this.ctx.coreData.cliBrowser) } @@ -270,9 +281,6 @@ export class ProjectLifecycleManager { updateWithPluginValues: (config, modifiedConfig) => { return this.ctx._apis.configApi.updateWithPluginValues(config, modifiedConfig) }, - initializeActiveProject: async (options) => { - return this.ctx.actions.project.initializeActiveProject(options) - }, machineBrowsers: async () => { return this.ctx.browser.machineBrowsers() }, @@ -332,6 +340,7 @@ export class ProjectLifecycleManager { } this._projectRoot = projectRoot + this._initializedProject = undefined this.resetInternalState() @@ -405,6 +414,7 @@ export class ProjectLifecycleManager { return } + this._initializedProject = undefined this._currentTestingType = testingType if (!testingType) { diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts index 5df02c59fc..f44d7770de 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts @@ -619,8 +619,6 @@ export const mutation = mutationType({ ctx.actions.project.setCurrentTestingType(args.testingType) await ctx.actions.project.reconfigureProject() - // TODO: do i need to scaffold here too? - return true }, })