Cleanup watchers

This commit is contained in:
Ryan Manuel
2022-03-26 22:25:19 -05:00
parent a03e700ac7
commit 10394b14ae
3 changed files with 95 additions and 151 deletions

View File

@@ -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<void>
updateWithPluginValues: (config: FullConfig, modifiedConfig: Partial<Cypress.ConfigOptions>) => FullConfig
initializeActiveProject: (options?: OpenProjectLaunchOptions) => Promise<unknown>
setupFullConfigWithDefaults: (config: SetupFullConfigOptions) => Promise<FullConfig>
machineBrowsers: () => FoundBrowser[] | Promise<FoundBrowser[]>
}
@@ -55,11 +47,10 @@ export class ProjectConfigManager {
private _childProcesses = new Set<ChildProcess>()
private _eventsIpc?: ProjectConfigIpc
private _eventProcess: ChildProcess | undefined
private _requireWatchers: Record<string, chokidar.FSWatcher> = {}
private _pathToWatcherRecord: Record<string, chokidar.FSWatcher> = {}
private _watchers = new Set<chokidar.FSWatcher>()
private _registeredEvents: Record<string, Function> = {}
private _registeredEventsTarget: TestingType | undefined
private _initializedProject: unknown | undefined
private _testingType: TestingType | undefined
private _state: ConfigManagerState = 'pending'
private _loadConfigPromise: Promise<LoadConfigReply> | 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<void> {
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<LoadConfigReply>()
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<LoadConfigReply> & { 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<LoadConfigReply> {
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<any> & {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<T> (): pDefer.DeferredPromise<T> & {settled: boolean} {
const dfd = pDefer<T>()
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
},
}
}

View File

@@ -81,6 +81,7 @@ export class ProjectLifecycleManager {
private _pendingInitialize?: pDefer.DeferredPromise<FullConfig>
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) {

View File

@@ -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
},
})