mirror of
https://github.com/cypress-io/cypress.git
synced 2026-03-01 20:39:30 -06:00
fix: open browser at correct time during lifecycle (#19572)
This commit is contained in:
@@ -7,12 +7,6 @@ module.exports = {
|
||||
'coverage': true,
|
||||
},
|
||||
'component': {
|
||||
setupNodeEvents (on, config) {
|
||||
const devServer = require('@cypress/react/plugins/next')
|
||||
|
||||
devServer(on, config)
|
||||
|
||||
return config
|
||||
},
|
||||
devServer: require('@cypress/react/plugins/next'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,16 +4,8 @@ module.exports = {
|
||||
'viewportWidth': 500,
|
||||
'viewportHeight': 800,
|
||||
'component': {
|
||||
setupNodeEvents (on, config) {
|
||||
// load file devServer that comes with this plugin
|
||||
// https://github.com/bahmutov/cypress-react-unit-test#install
|
||||
const devServer = require('@cypress/react/plugins/react-scripts')
|
||||
|
||||
devServer(on, config)
|
||||
|
||||
// IMPORTANT to return the config object
|
||||
// with the any changed environment variables
|
||||
return config
|
||||
},
|
||||
// load file devServer that comes with this plugin
|
||||
// https://github.com/bahmutov/cypress-react-unit-test#install
|
||||
devServer: require('@cypress/react/plugins/react-scripts'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
module.exports = {
|
||||
const { defineConfig } = require('cypress')
|
||||
|
||||
module.exports = defineConfig({
|
||||
'video': false,
|
||||
'viewportWidth': 500,
|
||||
'viewportHeight': 800,
|
||||
'component': {
|
||||
'supportFile': 'cypress/support/component.ts',
|
||||
setupNodeEvents (on, config) {
|
||||
const devServer = require('@cypress/react/plugins/react-scripts')
|
||||
|
||||
devServer(on, config)
|
||||
|
||||
return config
|
||||
},
|
||||
devServer: require('@cypress/react/plugins/react-scripts'),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
module.exports = {
|
||||
const { defineConfig } = require('cypress')
|
||||
|
||||
module.exports = defineConfig({
|
||||
'video': false,
|
||||
'viewportWidth': 500,
|
||||
'viewportHeight': 800,
|
||||
'experimentalFetchPolyfill': true,
|
||||
'component': {
|
||||
setupNodeEvents (on, config) {
|
||||
// load file devServer that comes with this plugin
|
||||
// https://github.com/bahmutov/cypress-react-unit-test#install
|
||||
const devServer = require('@cypress/react/plugins/react-scripts')
|
||||
|
||||
devServer(on, config)
|
||||
|
||||
// IMPORTANT to return the config object
|
||||
// with the any changed environment variables
|
||||
return config
|
||||
},
|
||||
devServer: require('@cypress/react/plugins/react-scripts'),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,16 +7,8 @@ module.exports = {
|
||||
'coverage': true,
|
||||
},
|
||||
component: {
|
||||
setupNodeEvents (on, config) {
|
||||
// load file devServer that comes with this plugin
|
||||
// https://github.com/bahmutov/cypress-react-unit-test#install
|
||||
const devServer = require('@cypress/react/plugins/react-scripts')
|
||||
|
||||
devServer(on, config)
|
||||
|
||||
// IMPORTANT to return the config object
|
||||
// with the any changed environment variables
|
||||
return config
|
||||
},
|
||||
// load file devServer that comes with this plugin
|
||||
// https://github.com/bahmutov/cypress-react-unit-test#install
|
||||
devServer: require('@cypress/react/plugins/react-scripts'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,17 +4,9 @@ module.exports = {
|
||||
'viewportWidth': 500,
|
||||
'viewportHeight': 500,
|
||||
'component': {
|
||||
setupNodeEvents (on, config) {
|
||||
// let's bundle spec files and the components they include using
|
||||
// the same bundling settings as the project by loading .babelrc
|
||||
// https://github.com/bahmutov/cypress-react-unit-test#install
|
||||
const devServer = require('@cypress/react/plugins/babel')
|
||||
|
||||
devServer(on, config)
|
||||
|
||||
// IMPORTANT to return the config object
|
||||
// with the any changed environment variables
|
||||
return config
|
||||
},
|
||||
// let's bundle spec files and the components they include using
|
||||
// the same bundling settings as the project by loading .babelrc
|
||||
// https://github.com/bahmutov/cypress-react-unit-test#install
|
||||
devServer: require('@cypress/react/plugins/babel'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
module.exports = {
|
||||
const { defineConfig } = require('cypress')
|
||||
|
||||
module.exports = defineConfig({
|
||||
'video': false,
|
||||
'fixturesFolder': false,
|
||||
'viewportWidth': 500,
|
||||
'viewportHeight': 500,
|
||||
'component': {
|
||||
setupNodeEvents (on, config) {
|
||||
require('@cypress/react/plugins/load-webpack')(on, config, {
|
||||
// from the root of the project (folder with cypress.config.{ts|js} file)
|
||||
webpackFilename: 'webpack.config.js',
|
||||
})
|
||||
|
||||
// IMPORTANT to return the config object
|
||||
// with the any changed environment variables
|
||||
return config
|
||||
devServer: require('@cypress/react/plugins/load-webpack'),
|
||||
devServerConfig: {
|
||||
// from the root of the project (folder with cypress.config.{ts|js} file)
|
||||
webpackFilename: 'webpack.config.js',
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -38,6 +38,7 @@ export default defineConfig({
|
||||
pluginsFile: 'cypress/e2e/plugins/index.ts',
|
||||
supportFile: 'cypress/e2e/support/e2eSupport.ts',
|
||||
async setupNodeEvents (on, config) {
|
||||
// process.env.DEBUG = '*'
|
||||
const { e2ePluginSetup } = require('@packages/frontend-shared/cypress/e2e/e2ePluginSetup')
|
||||
|
||||
return await e2ePluginSetup(on, config)
|
||||
|
||||
@@ -2,7 +2,7 @@ describe('App: Runs Page', () => {
|
||||
beforeEach(() => {
|
||||
cy.scaffoldProject('component-tests')
|
||||
cy.openProject('component-tests')
|
||||
cy.startAppServer()
|
||||
cy.startAppServer('component')
|
||||
})
|
||||
|
||||
it('resolves the runs page', () => {
|
||||
|
||||
@@ -424,15 +424,14 @@ export class DataContext {
|
||||
// this._loadingManager = new LoadingManager(this)
|
||||
// this.coreData.currentProject?.watcher
|
||||
// this._coreData = makeCoreData({}, this._loadingManager)
|
||||
// this._patches = []
|
||||
// this._patches.push([{ op: 'add', path: [], value: this._coreData }])
|
||||
this.setAppSocketServer(undefined)
|
||||
this.setGqlSocketServer(undefined)
|
||||
|
||||
return Promise.all([
|
||||
this.lifecycleManager.destroy(),
|
||||
this.cloud.reset(),
|
||||
this.util.disposeLoaders(),
|
||||
this.actions.project.clearCurrentProject(),
|
||||
// this.actions.currentProject?.clearCurrentProject(),
|
||||
this.actions.dev.dispose(),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface ProjectApiShape {
|
||||
clearLatestProjectsCache(): Promise<unknown>
|
||||
clearProjectPreferences(projectTitle: string): Promise<unknown>
|
||||
clearAllProjectPreferences(): Promise<unknown>
|
||||
closeActiveProject(): Promise<unknown>
|
||||
closeActiveProject(shouldCloseBrowser?: boolean): Promise<unknown>
|
||||
getCurrentProjectSavedState(): {} | undefined
|
||||
setPromptShown(slug: string): void
|
||||
getDevServer (): {
|
||||
@@ -47,6 +47,9 @@ export class ProjectActions {
|
||||
async clearCurrentProject () {
|
||||
this.ctx.update((d) => {
|
||||
d.currentProject = null
|
||||
d.currentTestingType = null
|
||||
d.baseError = null
|
||||
d.warnings = []
|
||||
})
|
||||
|
||||
this.ctx.lifecycleManager.clearCurrentProject()
|
||||
@@ -105,14 +108,9 @@ export class ProjectActions {
|
||||
return this.projects
|
||||
}
|
||||
|
||||
async initializeActiveProject (options: OpenProjectLaunchOptions = {}) {
|
||||
if (!this.ctx.currentProject) {
|
||||
throw Error('Cannot initialize project without an active project')
|
||||
}
|
||||
|
||||
if (!this.ctx.coreData.currentTestingType) {
|
||||
throw Error('Cannot initialize project without choosing testingType')
|
||||
}
|
||||
async initializeActiveProject (options: OpenProjectLaunchOptions = {}, shouldCloseBrowser = true) {
|
||||
assert(this.ctx.currentProject, 'Cannot initialize project without an active project')
|
||||
assert(this.ctx.coreData.currentTestingType, 'Cannot initialize project without choosing testingType')
|
||||
|
||||
const allModeOptionsWithLatest: InitializeProjectOptions = {
|
||||
...this.ctx.modeOptions,
|
||||
@@ -121,7 +119,7 @@ export class ProjectActions {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.api.closeActiveProject()
|
||||
await this.api.closeActiveProject(shouldCloseBrowser)
|
||||
await this.api.openProjectCreate(allModeOptionsWithLatest, {
|
||||
...options,
|
||||
ctx: this.ctx,
|
||||
@@ -183,7 +181,7 @@ export class ProjectActions {
|
||||
let activeSpec: FoundSpec | undefined
|
||||
|
||||
if (specPath) {
|
||||
activeSpec = await this.ctx.project.getCurrentSpecByAbsolute(specPath)
|
||||
activeSpec = this.ctx.project.getCurrentSpecByAbsolute(specPath)
|
||||
}
|
||||
|
||||
// Ensure that we have loaded browsers to choose from
|
||||
@@ -211,47 +209,6 @@ export class ProjectActions {
|
||||
return this.api.launchProject(browser, activeSpec ?? emptySpec, options)
|
||||
}
|
||||
|
||||
async launchProjectWithoutElectron () {
|
||||
if (!this.ctx.currentProject) {
|
||||
throw Error('Cannot launch project without an active project')
|
||||
}
|
||||
|
||||
const preferences = await this.api.getProjectPreferencesFromCache()
|
||||
const { browserPath, testingType } = preferences[this.ctx.lifecycleManager.projectTitle] ?? {}
|
||||
|
||||
if (!browserPath || !testingType) {
|
||||
throw Error('Cannot launch project without stored browserPath or testingType')
|
||||
}
|
||||
|
||||
this.ctx.lifecycleManager.setCurrentTestingType(testingType)
|
||||
|
||||
const spec = this.makeSpec(testingType)
|
||||
const browser = this.findBrowserByPath(browserPath)
|
||||
|
||||
if (!browser) {
|
||||
throw Error(`Cannot find specified browser at given path: ${browserPath}.`)
|
||||
}
|
||||
|
||||
this.ctx.actions.electron.hideBrowserWindow()
|
||||
|
||||
await this.initializeActiveProject()
|
||||
|
||||
return this.api.launchProject(browser, spec, {})
|
||||
}
|
||||
|
||||
private makeSpec (testingType: TestingTypeEnum): Cypress.Spec {
|
||||
return {
|
||||
name: '',
|
||||
absolute: '',
|
||||
relative: '',
|
||||
specType: testingType === 'e2e' ? 'integration' : 'component',
|
||||
}
|
||||
}
|
||||
|
||||
private findBrowserByPath (browserPath: string) {
|
||||
return this.ctx.coreData?.app?.browsers?.find((browser) => browser.path === browserPath)
|
||||
}
|
||||
|
||||
removeProject (projectRoot: string) {
|
||||
const found = this.projects.find((x) => x.projectRoot === projectRoot)
|
||||
|
||||
|
||||
@@ -56,8 +56,6 @@ type State<S, V = undefined> = V extends undefined ? {state: S, value?: V} : {st
|
||||
|
||||
type LoadingStateFor<V> = State<'pending'> | State<'loading', Promise<V>> | State<'loaded', V> | State<'errored', unknown>
|
||||
|
||||
type BrowsersResultState = LoadingStateFor<FoundBrowser[]>
|
||||
|
||||
type ConfigResultState = LoadingStateFor<LoadConfigReply>
|
||||
|
||||
type EnvFileResultState = LoadingStateFor<Cypress.ConfigOptions>
|
||||
@@ -78,7 +76,6 @@ export interface ProjectMetaState {
|
||||
hasSpecifiedConfigViaCLI: false | string
|
||||
hasMultipleConfigPaths: boolean
|
||||
needsCypressJsonMigration: boolean
|
||||
// configuredTestingTypes: TestingType[]
|
||||
}
|
||||
|
||||
const PROJECT_META_STATE: ProjectMetaState = {
|
||||
@@ -90,13 +87,11 @@ const PROJECT_META_STATE: ProjectMetaState = {
|
||||
hasSpecifiedConfigViaCLI: false,
|
||||
hasValidConfigFile: false,
|
||||
needsCypressJsonMigration: false,
|
||||
// configuredTestingTypes: [],
|
||||
}
|
||||
|
||||
export class ProjectLifecycleManager {
|
||||
// Registered handlers from Cypress's server, used to wrap the IPC
|
||||
private _handlers: IpcHandler[] = []
|
||||
private _browserResult: BrowsersResultState = { state: 'pending' }
|
||||
|
||||
// Config, from the cypress.config.{js|ts}
|
||||
private _envFileResult: EnvFileResultState = { state: 'pending' }
|
||||
@@ -137,22 +132,6 @@ export class ProjectLifecycleManager {
|
||||
return autoBindDebug(this)
|
||||
}
|
||||
|
||||
async allSettled () {
|
||||
while (
|
||||
this._browserResult.state === 'loading' ||
|
||||
this._envFileResult.state === 'loading' ||
|
||||
this._configResult.state === 'loading' ||
|
||||
this._eventsIpcResult.state === 'loading'
|
||||
) {
|
||||
await Promise.allSettled([
|
||||
this._browserResult.value,
|
||||
this._envFileResult.value,
|
||||
this._configResult.value,
|
||||
this._eventsIpcResult.value,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private onProcessExit = () => {
|
||||
this.resetInternalState()
|
||||
}
|
||||
@@ -303,7 +282,9 @@ export class ProjectLifecycleManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* When we set the "testingType", we
|
||||
* 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) {
|
||||
this.ctx.update((d) => {
|
||||
@@ -320,6 +301,8 @@ export class ProjectLifecycleManager {
|
||||
return
|
||||
}
|
||||
|
||||
// If we've chosen e2e and we don't have a config file, we can scaffold one
|
||||
// without any sort of onboarding wizard.
|
||||
if (!this.metaState.hasValidConfigFile) {
|
||||
if (testingType === 'e2e' && !this.ctx.isRunMode) {
|
||||
this.ctx.actions.wizard.scaffoldTestingType().catch(this.onLoadError)
|
||||
@@ -329,6 +312,10 @@ export class ProjectLifecycleManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -399,7 +386,7 @@ export class ProjectLifecycleManager {
|
||||
|
||||
/**
|
||||
* Equivalent to the legacy "config.get()",
|
||||
* this sources the config from all the
|
||||
* this sources the config from the various config sources
|
||||
*/
|
||||
async getFullInitialConfig (options: Partial<AllModeOptions> = this.ctx.modeOptions, withBrowsers = true): Promise<FullConfig> {
|
||||
if (this._cachedFullConfig) {
|
||||
@@ -537,9 +524,7 @@ export class ProjectLifecycleManager {
|
||||
this._configResult = { state: 'errored', value: err }
|
||||
}
|
||||
|
||||
if (this._pendingInitialize) {
|
||||
this._pendingInitialize.reject(err)
|
||||
}
|
||||
this.onLoadError(err)
|
||||
})
|
||||
.finally(() => {
|
||||
this.ctx.emitter.toLaunchpad()
|
||||
@@ -738,6 +723,8 @@ export class ProjectLifecycleManager {
|
||||
*/
|
||||
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)
|
||||
}
|
||||
@@ -767,23 +754,45 @@ export class ProjectLifecycleManager {
|
||||
this.setupNodeEvents().catch(this.onLoadError)
|
||||
}
|
||||
|
||||
private async setupNodeEvents (): Promise<SetupNodeEventsReply> {
|
||||
assert(this._eventsIpc)
|
||||
private setupNodeEvents (): Promise<SetupNodeEventsReply> {
|
||||
assert(this._eventsIpc, 'Expected _eventsIpc to be defined at this point')
|
||||
const ipc = this._eventsIpc
|
||||
const promise = this.callSetupNodeEventsWithConfig(ipc)
|
||||
|
||||
let config
|
||||
this._eventsIpcResult = { state: 'loading', value: promise }
|
||||
|
||||
try {
|
||||
config = await this.getFullInitialConfig()
|
||||
} catch (err) {
|
||||
// This is a terrible hack until we land GraphQL subscriptions which will
|
||||
// allow for more granular concurrent notifications then our current
|
||||
// notify the frontend & refetch approach
|
||||
const toLaunchpad = this.ctx.emitter.toLaunchpad
|
||||
|
||||
this.ctx.emitter.toLaunchpad = () => {}
|
||||
|
||||
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 }
|
||||
this._pendingInitialize?.reject(err)
|
||||
throw err
|
||||
})
|
||||
.finally(() => {
|
||||
this.ctx.emitter.toLaunchpad = toLaunchpad
|
||||
this.ctx.emitter.toLaunchpad()
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.reject(err)
|
||||
}
|
||||
private async callSetupNodeEventsWithConfig (ipc: ProjectConfigIpc): Promise<SetupNodeEventsReply> {
|
||||
const config = await this.getFullInitialConfig()
|
||||
|
||||
assert(config)
|
||||
assert(this._currentTestingType)
|
||||
@@ -817,22 +826,6 @@ export class ProjectLifecycleManager {
|
||||
testingType: this._currentTestingType,
|
||||
})
|
||||
|
||||
this._eventsIpcResult = { state: 'loading', value: promise }
|
||||
|
||||
promise.then(async (val) => {
|
||||
this._eventsIpcResult = { state: 'loaded', value: val }
|
||||
await this.handleSetupTestingTypeReply(ipc, val)
|
||||
})
|
||||
.catch((err) => {
|
||||
debug(`catch %o`, err)
|
||||
this._cleanupIpc(ipc)
|
||||
this._eventsIpcResult = { state: 'errored', value: err }
|
||||
this._pendingInitialize?.reject(err)
|
||||
})
|
||||
.finally(() => {
|
||||
this.ctx.emitter.toLaunchpad()
|
||||
})
|
||||
|
||||
return promise
|
||||
}
|
||||
|
||||
@@ -1187,11 +1180,19 @@ export class ProjectLifecycleManager {
|
||||
|
||||
const finalConfig = this._cachedFullConfig = this.ctx._apis.configApi.updateWithPluginValues(fullConfig, result.setupConfig ?? {})
|
||||
|
||||
// This happens automatically with openProjectCreate in run mode
|
||||
if (!this.ctx.isRunMode) {
|
||||
// Don't worry about closing the browser for refreshing the config
|
||||
await this.ctx.actions.project.initializeActiveProject({}, false)
|
||||
}
|
||||
|
||||
if (this.ctx.coreData.cliBrowser) {
|
||||
await this.setActiveBrowser(this.ctx.coreData.cliBrowser)
|
||||
}
|
||||
|
||||
this._pendingInitialize?.resolve(finalConfig)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async setActiveBrowser (cliBrowser: string) {
|
||||
@@ -1232,6 +1233,7 @@ export class ProjectLifecycleManager {
|
||||
}
|
||||
|
||||
destroy () {
|
||||
this.resetInternalState()
|
||||
// @ts-ignore
|
||||
process.removeListener('exit', this.onProcessExit)
|
||||
}
|
||||
@@ -1294,9 +1296,10 @@ export class ProjectLifecycleManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* When we have an error while "loading" a resource,
|
||||
* we handle it internally with the promise state, and therefore
|
||||
* do not
|
||||
* When there is an error during any part of the lifecycle
|
||||
* initiation, we pass it through here. This allows us to intercept
|
||||
* centrally in the e2e tests, as well as notify the "pending initialization"
|
||||
* for run mode
|
||||
*/
|
||||
private onLoadError = (err: any) => {
|
||||
this._pendingInitialize?.reject(err)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import assert from 'assert'
|
||||
import type { DataContext } from '..'
|
||||
import * as path from 'path'
|
||||
import globby, { GlobbyOptions } from 'globby'
|
||||
@@ -26,6 +27,12 @@ export class FileDataSource {
|
||||
})
|
||||
}
|
||||
|
||||
readFileInProject (relativeFilePath: string) {
|
||||
assert(this.ctx.currentProject, 'Cannot readFileInProject without currentProject')
|
||||
|
||||
return this.readFile(path.join(this.ctx.currentProject, relativeFilePath))
|
||||
}
|
||||
|
||||
readJsonFile<Result = unknown> (absoluteFilePath: string) {
|
||||
return this.jsonFileLoader.load(absoluteFilePath).catch((e) => {
|
||||
this.jsonFileLoader.clear(e)
|
||||
|
||||
@@ -213,7 +213,7 @@ export class ProjectDataSource {
|
||||
this.ctx.lifecycleManager.closeWatcher(this._specWatcher)
|
||||
}
|
||||
|
||||
async getCurrentSpecByAbsolute (absolute: string) {
|
||||
getCurrentSpecByAbsolute (absolute: string) {
|
||||
return this.ctx.project.specs.find((x) => x.absolute === absolute)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const urqlCacheKeys: Partial<CacheExchangeOpts> = {
|
||||
BaseError: () => null,
|
||||
ProjectPreferences: (data) => data.__typename,
|
||||
VersionData: () => null,
|
||||
ScaffoldedFile: () => null,
|
||||
LocalSettings: (data) => data.__typename,
|
||||
LocalSettingsPreferences: () => null,
|
||||
CloudProjectNotFound: (data) => data.__typename,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Response } from 'cross-fetch'
|
||||
|
||||
import { CloudRunQuery } from '../support/mock-graphql/stubgql-CloudTypes'
|
||||
import { getOperationName } from '@urql/core'
|
||||
import pDefer from 'p-defer'
|
||||
|
||||
interface InternalOpenProjectArgs {
|
||||
argv: string[]
|
||||
@@ -62,6 +63,7 @@ export type E2ETaskMap = ReturnType<typeof makeE2ETasks> extends Promise<infer U
|
||||
interface FixturesShape {
|
||||
scaffold (): void
|
||||
scaffoldProject (project: string): void
|
||||
scaffoldCommonNodeModules(): Promise<void>
|
||||
scaffoldWatch (): void
|
||||
remove (): void
|
||||
removeProject (name): void
|
||||
@@ -104,13 +106,15 @@ async function makeE2ETasks () {
|
||||
|
||||
const gqlPort = await makeGraphQLServer()
|
||||
|
||||
const __internal_scaffoldProject = (projectName: string) => {
|
||||
const __internal_scaffoldProject = async (projectName: string) => {
|
||||
if (fs.existsSync(Fixtures.projectPath(projectName))) {
|
||||
Fixtures.removeProject(projectName)
|
||||
}
|
||||
|
||||
Fixtures.scaffoldProject(projectName)
|
||||
|
||||
await Fixtures.scaffoldCommonNodeModules()
|
||||
|
||||
scaffoldedProjects.add(projectName)
|
||||
|
||||
return Fixtures.projectPath(projectName)
|
||||
@@ -195,7 +199,7 @@ async function makeE2ETasks () {
|
||||
},
|
||||
async __internal_addProject (opts: InternalAddProjectOpts) {
|
||||
if (!scaffoldedProjects.has(opts.projectName)) {
|
||||
__internal_scaffoldProject(opts.projectName)
|
||||
await __internal_scaffoldProject(opts.projectName)
|
||||
}
|
||||
|
||||
await ctx.actions.project.addProject({ path: Fixtures.projectPath(opts.projectName), open: opts.open })
|
||||
@@ -254,6 +258,7 @@ async function makeE2ETasks () {
|
||||
require,
|
||||
process,
|
||||
sinon,
|
||||
pDefer,
|
||||
projectDir (projectName) {
|
||||
if (!e2eProjectDirs.includes(projectName)) {
|
||||
throw new Error(`${projectName} is not a fixture project`)
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { Browser, FoundBrowser, OpenModeOptions } from '@packages/types'
|
||||
import { browsers } from '@packages/types/src/browser'
|
||||
import type { E2ETaskMap } from '../e2ePluginSetup'
|
||||
import installCustomPercyCommand from '@packages/ui-components/cypress/support/customPercyCommand'
|
||||
import type sinon from 'sinon'
|
||||
import type pDefer from 'p-defer'
|
||||
|
||||
configure({ testIdAttribute: 'data-cy' })
|
||||
|
||||
@@ -25,6 +27,8 @@ export interface WithCtxOptions extends Cypress.Loggable, Cypress.Timeoutable {
|
||||
export interface WithCtxInjected extends WithCtxOptions {
|
||||
require: typeof require
|
||||
process: typeof process
|
||||
sinon: typeof sinon
|
||||
pDefer: typeof pDefer
|
||||
testState: Record<string, any>
|
||||
projectDir(projectName: ProjectFixture): string
|
||||
}
|
||||
@@ -193,11 +197,45 @@ function startAppServer (mode: 'component' | 'e2e' = 'e2e') {
|
||||
return logInternal('startAppServer', (log) => {
|
||||
return cy.withCtx(async (ctx, o) => {
|
||||
ctx.actions.project.setCurrentTestingType(o.mode)
|
||||
// ctx.lifecycleManager.isReady()
|
||||
await ctx.actions.project.initializeActiveProject({
|
||||
skipPluginInitializeForTesting: true,
|
||||
const isInitialized = o.pDefer()
|
||||
const initializeActive = ctx.actions.project.initializeActiveProject
|
||||
const onErrorStub = o.sinon.stub(ctx, 'onError')
|
||||
// @ts-expect-error - errors b/c it's a private method
|
||||
const onLoadErrorStub = o.sinon.stub(ctx.lifecycleManager, 'onLoadError')
|
||||
const initializeActiveProjectStub = o.sinon.stub(ctx.actions.project, 'initializeActiveProject')
|
||||
|
||||
function restoreStubs () {
|
||||
onErrorStub.restore()
|
||||
onLoadErrorStub.restore()
|
||||
initializeActiveProjectStub.restore()
|
||||
}
|
||||
|
||||
function onStartAppError (e: Error) {
|
||||
isInitialized.reject(e)
|
||||
restoreStubs()
|
||||
}
|
||||
|
||||
onErrorStub.callsFake(onStartAppError)
|
||||
onLoadErrorStub.callsFake(onStartAppError)
|
||||
|
||||
initializeActiveProjectStub.callsFake(async function (this: any, ...args) {
|
||||
try {
|
||||
const result = await initializeActive.apply(this, args)
|
||||
|
||||
isInitialized.resolve(result)
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
isInitialized.reject(e)
|
||||
} finally {
|
||||
restoreStubs()
|
||||
}
|
||||
|
||||
return
|
||||
})
|
||||
|
||||
await isInitialized.promise
|
||||
|
||||
await ctx.actions.project.launchProject(o.mode, {})
|
||||
|
||||
return ctx.appServerPort
|
||||
@@ -312,10 +350,11 @@ function findBrowsers (options: FindBrowsersOptions = {}) {
|
||||
} as Browser].reduce(reducer, [])
|
||||
}
|
||||
|
||||
cy.withCtx(async (ctx, o) => {
|
||||
// @ts-ignore sinon is a global in the node process where this is executed
|
||||
sinon.stub(ctx._apis.browserApi, 'getBrowsers').resolves(o.browsers)
|
||||
}, { browsers: filteredBrowsers })
|
||||
logInternal('findBrowsers', () => {
|
||||
return cy.withCtx(async (ctx, o) => {
|
||||
o.sinon.stub(ctx._apis.browserApi, 'getBrowsers').resolves(o.browsers)
|
||||
}, { browsers: filteredBrowsers, log: false })
|
||||
})
|
||||
}
|
||||
|
||||
function remoteGraphQLIntercept (fn: RemoteGraphQLInterceptor) {
|
||||
@@ -362,17 +401,19 @@ function validateExternalLink (subject, options: ValidateExternalLinkOptions | s
|
||||
({ name, href } = options)
|
||||
}
|
||||
|
||||
cy.intercept('mutation-ExternalLink_OpenExternal', { 'data': { 'openExternal': true } }).as('OpenExternal')
|
||||
return logInternal('validateExternalLink', () => {
|
||||
cy.intercept('mutation-ExternalLink_OpenExternal', { 'data': { 'openExternal': true } }).as('OpenExternal')
|
||||
|
||||
cy.wrap(subject, { log: false }).findByRole('link', { name: name || href }).as('Link')
|
||||
.should('have.attr', 'href', href)
|
||||
.click()
|
||||
cy.wrap(subject, { log: false }).findByRole('link', { name: name || href }).as('Link')
|
||||
.should('have.attr', 'href', href)
|
||||
.click()
|
||||
|
||||
cy.wait('@OpenExternal')
|
||||
.its('request.body.variables.url')
|
||||
.should('equal', href)
|
||||
cy.wait('@OpenExternal')
|
||||
.its('request.body.variables.url')
|
||||
.should('equal', href)
|
||||
|
||||
return cy.get('@Link')
|
||||
return cy.get('@Link')
|
||||
})
|
||||
}
|
||||
|
||||
Cypress.Commands.add('scaffoldProject', scaffoldProject)
|
||||
|
||||
@@ -10,8 +10,9 @@ export const namedRouteExchange: Exchange = ({ client, forward }) => {
|
||||
return o
|
||||
}
|
||||
|
||||
// Only prefix the URL if it hasn't been already
|
||||
if (!o.context.url.endsWith('/graphql')) {
|
||||
throw new Error(`Infinite loop detected? Ping @tgriesser to help debug`)
|
||||
return o
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -297,11 +297,6 @@ type CloudUser implements Node {
|
||||
userIsViewer: Boolean!
|
||||
}
|
||||
|
||||
enum CodeGenGenResultType {
|
||||
binary
|
||||
text
|
||||
}
|
||||
|
||||
"""Glob patterns for detecting files for code gen."""
|
||||
type CodeGenGlobs implements Node {
|
||||
component: String!
|
||||
@@ -311,12 +306,6 @@ type CodeGenGlobs implements Node {
|
||||
story: String!
|
||||
}
|
||||
|
||||
enum CodeGenStatus {
|
||||
add
|
||||
overwrite
|
||||
skipped
|
||||
}
|
||||
|
||||
enum CodeGenType {
|
||||
component
|
||||
integration
|
||||
|
||||
@@ -2,36 +2,6 @@ describe('Launchpad: Onboarding Flow', () => {
|
||||
beforeEach(() => {
|
||||
cy.scaffoldProject('pristine')
|
||||
cy.openProject('pristine')
|
||||
cy.withCtx((ctx, o) => {
|
||||
ctx.actions.file.writeFileInProject('node_modules/cypress/package.json', JSON.stringify({
|
||||
name: 'cypress',
|
||||
main: 'index.js',
|
||||
}))
|
||||
|
||||
ctx.actions.file.writeFileInProject('node_modules/@cypress/webpack-dev-server/package.json', JSON.stringify({
|
||||
name: '@cypress/webpack-dev-server',
|
||||
main: 'index.js',
|
||||
}))
|
||||
|
||||
ctx.actions.file.writeFileInProject('node_modules/cypress/index.js', `
|
||||
module.exports = {
|
||||
defineConfig(o) {
|
||||
return o
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
ctx.actions.file.writeFileInProject('node_modules/@cypress/webpack-dev-server/index.js', `
|
||||
module.exports = {
|
||||
devServer(o) {
|
||||
return {
|
||||
port: 7373,
|
||||
close() {}
|
||||
}
|
||||
}
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
it('can setup component testing', () => {
|
||||
@@ -54,6 +24,12 @@ describe('Launchpad: Onboarding Flow', () => {
|
||||
cy.findByText('I\'ve installed them').click()
|
||||
cy.findByText('We added the following files to your project.')
|
||||
cy.findByText('Continue').click()
|
||||
cy.withCtx(async (ctx) => {
|
||||
return ctx.file.readFileInProject('cypress.config.js')
|
||||
}).then((str) => {
|
||||
cy.log(str)
|
||||
})
|
||||
|
||||
cy.findByText('Choose a Browser', { timeout: 10000 })
|
||||
})
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ query OpenBrowser {
|
||||
currentProject {
|
||||
id
|
||||
currentTestingType
|
||||
isLoadingConfigFile
|
||||
isLoadingNodeEvents
|
||||
...OpenBrowserList
|
||||
}
|
||||
...WarningList
|
||||
|
||||
@@ -46,6 +46,11 @@ const { t } = useI18n()
|
||||
gql`
|
||||
mutation ScaffoldedFiles_completeSetup {
|
||||
completeSetup {
|
||||
currentProject {
|
||||
id
|
||||
isLoadingConfigFile
|
||||
isLoadingNodeEvents
|
||||
}
|
||||
scaffoldedFiles {
|
||||
status
|
||||
}
|
||||
|
||||
@@ -167,14 +167,22 @@ const SendRequestOutgoing: RequestMiddleware = function () {
|
||||
}
|
||||
|
||||
const req = this.request.create(requestOptions)
|
||||
const socket = this.req.socket
|
||||
|
||||
const onSocketClose = () => {
|
||||
this.debug('request aborted')
|
||||
req.abort()
|
||||
}
|
||||
|
||||
req.on('error', this.onError)
|
||||
req.on('response', (incomingRes) => this.onResponse(incomingRes, req))
|
||||
this.req.socket.on('close', () => {
|
||||
this.debug('request aborted')
|
||||
req.abort()
|
||||
|
||||
this.req.res?.on('finish', () => {
|
||||
socket.removeListener('close', onSocketClose)
|
||||
})
|
||||
|
||||
this.req.socket.on('close', onSocketClose)
|
||||
|
||||
if (!requestBodyBuffered) {
|
||||
// pipe incoming request body, headers to new request
|
||||
this.req.pipe(req)
|
||||
|
||||
@@ -570,9 +570,26 @@ const getMsgByType = function (type, ...args) {
|
||||
It exported:`
|
||||
|
||||
return { msg, details: JSON.stringify(arg2) }
|
||||
case 'SETUP_NODE_EVENTS_DO_NOT_SUPPORT_DEV_SERVER':
|
||||
case 'COMPONENT_DEV_SERVER_IS_NOT_A_FUNCTION':
|
||||
msg = stripIndent`\
|
||||
The \`setupNodeEvents\` method do not support \`dev-server:start\`, use \`devServer\` instead:
|
||||
The \`component\`.\`devServer\` method must be a function with the following signature:
|
||||
|
||||
\`\`\`
|
||||
devServer: (cypressConfig: DevServerConfig, devServerConfig: ComponentDevServerOpts) {
|
||||
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Learn more: https://on.cypress.io/plugins-api
|
||||
|
||||
We loaded the \`devServer\` from: \`${arg1}\`
|
||||
|
||||
It exported:`
|
||||
|
||||
return { msg, details: JSON.stringify(arg2) }
|
||||
case 'SETUP_NODE_EVENTS_DO_NOT_SUPPORT_DEV_SERVER':
|
||||
return stripIndent`\
|
||||
The \`setupNodeEvents\` method does not support \`dev-server:start\`, use \`devServer\` instead:
|
||||
|
||||
\`\`\`
|
||||
devServer (cypressConfig, devServerConfig) {
|
||||
@@ -581,12 +598,7 @@ const getMsgByType = function (type, ...args) {
|
||||
\`\`\`
|
||||
|
||||
Learn more: https://on.cypress.io/plugins-api
|
||||
|
||||
We loaded the \`setupNodeEvents\` from: \`${arg1}\`
|
||||
|
||||
It exported:`
|
||||
|
||||
return { msg, details: JSON.stringify(arg2) }
|
||||
`
|
||||
case 'PLUGINS_FUNCTION_ERROR':
|
||||
msg = stripIndent`\
|
||||
The function exported by the plugins file threw an error.
|
||||
|
||||
@@ -114,11 +114,17 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext {
|
||||
removeProjectFromCache (path: string) {
|
||||
return cache.removeProject(path)
|
||||
},
|
||||
closeActiveProject () {
|
||||
return openProject.closeActiveProject()
|
||||
closeActiveProject (shouldCloseBrowser = true) {
|
||||
return openProject.closeActiveProject(shouldCloseBrowser)
|
||||
},
|
||||
getCurrentProjectSavedState () {
|
||||
return openProject.getConfig()?.state
|
||||
// TODO: See if this is the best way we should be getting this config,
|
||||
// shouldn't we have this already in the DataContext?
|
||||
try {
|
||||
return openProject.getConfig()?.state
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
setPromptShown (slug) {
|
||||
return openProject.getProject()
|
||||
|
||||
@@ -26,6 +26,7 @@ export class OpenProject {
|
||||
}
|
||||
|
||||
resetOpenProject () {
|
||||
this.projectBase?.__reset()
|
||||
this.projectBase = null
|
||||
this.relaunchBrowser = null
|
||||
}
|
||||
@@ -61,17 +62,7 @@ export class OpenProject {
|
||||
}) {
|
||||
this._ctx = getCtx()
|
||||
|
||||
if (!this.projectBase && this._ctx.currentProject) {
|
||||
await this.create(this._ctx.currentProject, {
|
||||
...this._ctx.modeOptions,
|
||||
projectRoot: this._ctx.currentProject,
|
||||
testingType: this._ctx.coreData.currentTestingType!,
|
||||
}, options)
|
||||
}
|
||||
|
||||
if (!this.projectBase) {
|
||||
throw Error('Cannot launch runner if projectBase is undefined!')
|
||||
}
|
||||
assert(this.projectBase, 'Cannot launch runner if projectBase is undefined!')
|
||||
|
||||
debug('resetting project state, preparing to launch browser %s for spec %o options %o',
|
||||
browser.name, spec, options)
|
||||
@@ -200,14 +191,14 @@ export class OpenProject {
|
||||
return browsers.close()
|
||||
}
|
||||
|
||||
closeOpenProjectAndBrowsers () {
|
||||
closeOpenProjectAndBrowsers (shouldCloseBrowser = true) {
|
||||
this.projectBase?.close().catch((e) => {
|
||||
this._ctx?.logTraceError(e)
|
||||
})
|
||||
|
||||
this.resetOpenProject()
|
||||
|
||||
return this.closeBrowser()
|
||||
return shouldCloseBrowser ? this.closeBrowser() : Promise.resolve()
|
||||
}
|
||||
|
||||
close () {
|
||||
@@ -219,8 +210,8 @@ export class OpenProject {
|
||||
// close existing open project if it exists, for example
|
||||
// if you are switching from CT to E2E or vice versa.
|
||||
// used by launchpad
|
||||
async closeActiveProject () {
|
||||
await this.closeOpenProjectAndBrowsers()
|
||||
async closeActiveProject (shouldCloseBrowser = true) {
|
||||
await this.closeOpenProjectAndBrowsers(shouldCloseBrowser)
|
||||
}
|
||||
|
||||
_ctx?: DataContext
|
||||
|
||||
@@ -64,6 +64,18 @@ function run (ipc, configFile, projectRoot) {
|
||||
return true
|
||||
}
|
||||
|
||||
const isValidDevServer = (config) => {
|
||||
const { devServer } = config
|
||||
|
||||
if (devServer && (typeof devServer.devServer === 'function' || typeof devServer === 'function' || typeof devServer.then === 'function')) {
|
||||
return true
|
||||
}
|
||||
|
||||
ipc.send('setupTestingType:error', 'COMPONENT_DEV_SERVER_IS_NOT_A_FUNCTION', configFile, config)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
ipc.on('loadConfig', () => {
|
||||
try {
|
||||
debug('try loading', configFile)
|
||||
@@ -92,18 +104,39 @@ function run (ipc, configFile, projectRoot) {
|
||||
|
||||
areSetupNodeEventsLoaded = true
|
||||
if (testingType === 'component') {
|
||||
if (!isValidSetupNodeEvents(result.component?.setupNodeEvents)) {
|
||||
if (!isValidSetupNodeEvents(result.setupNodeEvents) || !isValidDevServer((result.component || {}))) {
|
||||
return
|
||||
}
|
||||
|
||||
runPlugins.runSetupNodeEvents(options, (on, config) => {
|
||||
if (result.component?.devServer) {
|
||||
on('dev-server:start', (options) => result.component.devServer(options, result.component?.devServerConfig))
|
||||
}
|
||||
|
||||
const setupNodeEvents = result.component?.setupNodeEvents ?? ((on, config) => {})
|
||||
|
||||
return setupNodeEvents(on, config)
|
||||
const { devServer } = result.component
|
||||
|
||||
// Accounts for `devServer: require('@cypress/webpack-dev-server')
|
||||
if (typeof devServer.devServer === 'function') {
|
||||
on('dev-server:start', (options) => devServer.devServer(options, result.component?.devServerConfig))
|
||||
|
||||
return setupNodeEvents(on, config)
|
||||
}
|
||||
|
||||
// Accounts for `devServer() {}`
|
||||
if (typeof devServer === 'function') {
|
||||
on('dev-server:start', (options) => devServer(options, result.component?.devServerConfig))
|
||||
|
||||
return setupNodeEvents(on, config)
|
||||
}
|
||||
|
||||
// Accounts for `devServer: import('@cypress/webpack-dev-server')`
|
||||
if (typeof result.component.devServer.then === 'function') {
|
||||
return Promise.resolve(result.component?.devServer).then(({ devServer }) => {
|
||||
if (typeof devServer === 'function') {
|
||||
on('dev-server:start', (options) => devServer(options, result.component?.devServerConfig))
|
||||
}
|
||||
|
||||
return setupNodeEvents(on, config)
|
||||
})
|
||||
}
|
||||
})
|
||||
} else if (testingType === 'e2e') {
|
||||
if (!isValidSetupNodeEvents(result.e2e?.setupNodeEvents)) {
|
||||
|
||||
@@ -32,7 +32,8 @@ const execute = (event, ...args) => {
|
||||
|
||||
const _reset = () => {
|
||||
handlers = []
|
||||
getCtx().lifecycleManager.resetForTest()
|
||||
|
||||
return getCtx().lifecycleManager.resetForTest()
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -126,7 +126,7 @@ const API = {
|
||||
|
||||
delete fileObjects[filePath]
|
||||
|
||||
return delete fileProcessors[filePath]
|
||||
delete fileProcessors[filePath]
|
||||
},
|
||||
|
||||
close () {
|
||||
@@ -136,7 +136,7 @@ const API = {
|
||||
fileProcessors = {}
|
||||
baseEmitter.emit('close')
|
||||
|
||||
return baseEmitter.removeAllListeners()
|
||||
baseEmitter.removeAllListeners()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -284,6 +284,13 @@ export class ProjectBase<TServer extends Server> extends EE {
|
||||
return
|
||||
}
|
||||
|
||||
__reset () {
|
||||
preprocessor.close()
|
||||
devServer.close()
|
||||
|
||||
process.chdir(localCwd)
|
||||
}
|
||||
|
||||
async close () {
|
||||
debug('closing project instance %s', this.projectRoot)
|
||||
|
||||
@@ -294,19 +301,16 @@ export class ProjectBase<TServer extends Server> extends EE {
|
||||
return
|
||||
}
|
||||
|
||||
const closePreprocessor = this.testingType === 'e2e' ? preprocessor.close : undefined
|
||||
this.__reset()
|
||||
|
||||
this.ctx.setAppServerPort(undefined)
|
||||
this.ctx.setAppSocketServer(undefined)
|
||||
|
||||
await Promise.all([
|
||||
this.server?.close(),
|
||||
closePreprocessor?.(),
|
||||
])
|
||||
|
||||
this._isServerOpen = false
|
||||
|
||||
process.chdir(localCwd)
|
||||
this.isOpen = false
|
||||
|
||||
const config = this.getConfig()
|
||||
|
||||
@@ -266,6 +266,8 @@ export async function scaffoldProjectNodeModules (project: string, updateYarnLoc
|
||||
|
||||
export async function scaffoldCommonNodeModules () {
|
||||
await Promise.all([
|
||||
// Used for import { defineConfig } from 'cypress'
|
||||
'cypress',
|
||||
'@cypress/code-coverage',
|
||||
'@cypress/webpack-dev-server',
|
||||
'@packages/socket',
|
||||
|
||||
@@ -28760,7 +28760,6 @@ minipass-fetch@^1.3.0, minipass-fetch@^1.3.2:
|
||||
resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-1.3.3.tgz#34c7cea038c817a8658461bf35174551dce17a0a"
|
||||
integrity sha512-akCrLDWfbdAWkMLBxJEeWTdNsjML+dt5YgOI4gJ53vuO0vrmYQkUPxa6j6V65s9CcePIr2SSWqjT2EcrNseryQ==
|
||||
dependencies:
|
||||
encoding "^0.1.12"
|
||||
minipass "^3.1.0"
|
||||
minipass-sized "^1.0.3"
|
||||
minizlib "^2.0.0"
|
||||
@@ -42129,10 +42128,8 @@ watchpack@^1.6.0, watchpack@^1.7.4:
|
||||
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.5.tgz#1267e6c55e0b9b5be44c2023aed5437a2c26c453"
|
||||
integrity sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==
|
||||
dependencies:
|
||||
chokidar "^3.4.1"
|
||||
graceful-fs "^4.1.2"
|
||||
neo-async "^2.5.0"
|
||||
watchpack-chokidar2 "^2.0.1"
|
||||
optionalDependencies:
|
||||
chokidar "^3.4.1"
|
||||
watchpack-chokidar2 "^2.0.1"
|
||||
|
||||
Reference in New Issue
Block a user