diff --git a/npm/react/examples/nextjs/cypress.config.js b/npm/react/examples/nextjs/cypress.config.js index d43adf14d1..d48ebccac7 100644 --- a/npm/react/examples/nextjs/cypress.config.js +++ b/npm/react/examples/nextjs/cypress.config.js @@ -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'), }, } diff --git a/npm/react/examples/react-scripts-folder/cypress.config.js b/npm/react/examples/react-scripts-folder/cypress.config.js index b45601a2ce..a5b90eca9d 100644 --- a/npm/react/examples/react-scripts-folder/cypress.config.js +++ b/npm/react/examples/react-scripts-folder/cypress.config.js @@ -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'), }, } diff --git a/npm/react/examples/react-scripts-typescript/cypress.config.js b/npm/react/examples/react-scripts-typescript/cypress.config.js index f494fa3a84..cd49bac34d 100644 --- a/npm/react/examples/react-scripts-typescript/cypress.config.js +++ b/npm/react/examples/react-scripts-typescript/cypress.config.js @@ -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'), }, -} +}) diff --git a/npm/react/examples/react-scripts/cypress.config.js b/npm/react/examples/react-scripts/cypress.config.js index a54e32bbee..818991b0fd 100644 --- a/npm/react/examples/react-scripts/cypress.config.js +++ b/npm/react/examples/react-scripts/cypress.config.js @@ -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'), }, -} +}) diff --git a/npm/react/examples/tailwind/cypress.config.js b/npm/react/examples/tailwind/cypress.config.js index 67e7c82b20..b36c7ee59c 100644 --- a/npm/react/examples/tailwind/cypress.config.js +++ b/npm/react/examples/tailwind/cypress.config.js @@ -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'), }, } diff --git a/npm/react/examples/using-babel-typescript/cypress.config.js b/npm/react/examples/using-babel-typescript/cypress.config.js index be5de6927d..c15e250414 100644 --- a/npm/react/examples/using-babel-typescript/cypress.config.js +++ b/npm/react/examples/using-babel-typescript/cypress.config.js @@ -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'), }, } diff --git a/npm/react/examples/webpack-file/cypress.config.js b/npm/react/examples/webpack-file/cypress.config.js index a135b90607..69ee4ad47c 100644 --- a/npm/react/examples/webpack-file/cypress.config.js +++ b/npm/react/examples/webpack-file/cypress.config.js @@ -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', }, }, -} +}) diff --git a/packages/app/cypress.config.ts b/packages/app/cypress.config.ts index 1c645c1ac4..f8ccab5aca 100644 --- a/packages/app/cypress.config.ts +++ b/packages/app/cypress.config.ts @@ -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) diff --git a/packages/app/cypress/e2e/runs.cy.ts b/packages/app/cypress/e2e/runs.cy.ts index a3ed998f44..e337f8d85a 100644 --- a/packages/app/cypress/e2e/runs.cy.ts +++ b/packages/app/cypress/e2e/runs.cy.ts @@ -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', () => { diff --git a/packages/data-context/src/DataContext.ts b/packages/data-context/src/DataContext.ts index d3e1e46cae..f5b4c45054 100644 --- a/packages/data-context/src/DataContext.ts +++ b/packages/data-context/src/DataContext.ts @@ -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(), ]) } diff --git a/packages/data-context/src/actions/ProjectActions.ts b/packages/data-context/src/actions/ProjectActions.ts index cf242bf30e..89a52046a8 100644 --- a/packages/data-context/src/actions/ProjectActions.ts +++ b/packages/data-context/src/actions/ProjectActions.ts @@ -29,7 +29,7 @@ export interface ProjectApiShape { clearLatestProjectsCache(): Promise clearProjectPreferences(projectTitle: string): Promise clearAllProjectPreferences(): Promise - closeActiveProject(): Promise + closeActiveProject(shouldCloseBrowser?: boolean): Promise 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) diff --git a/packages/data-context/src/data/ProjectLifecycleManager.ts b/packages/data-context/src/data/ProjectLifecycleManager.ts index d9cd6c5bf8..3b0ca8cce6 100644 --- a/packages/data-context/src/data/ProjectLifecycleManager.ts +++ b/packages/data-context/src/data/ProjectLifecycleManager.ts @@ -56,8 +56,6 @@ type State = V extends undefined ? {state: S, value?: V} : {st type LoadingStateFor = State<'pending'> | State<'loading', Promise> | State<'loaded', V> | State<'errored', unknown> -type BrowsersResultState = LoadingStateFor - type ConfigResultState = LoadingStateFor type EnvFileResultState = LoadingStateFor @@ -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 = this.ctx.modeOptions, withBrowsers = true): Promise { 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 { - assert(this._eventsIpc) + private setupNodeEvents (): Promise { + 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 { + 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) diff --git a/packages/data-context/src/sources/FileDataSource.ts b/packages/data-context/src/sources/FileDataSource.ts index b206953e36..03d99aedd0 100644 --- a/packages/data-context/src/sources/FileDataSource.ts +++ b/packages/data-context/src/sources/FileDataSource.ts @@ -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 (absoluteFilePath: string) { return this.jsonFileLoader.load(absoluteFilePath).catch((e) => { this.jsonFileLoader.clear(e) diff --git a/packages/data-context/src/sources/ProjectDataSource.ts b/packages/data-context/src/sources/ProjectDataSource.ts index 33bdf50a48..c2cf4fbfae 100644 --- a/packages/data-context/src/sources/ProjectDataSource.ts +++ b/packages/data-context/src/sources/ProjectDataSource.ts @@ -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) } diff --git a/packages/data-context/src/util/urqlCacheKeys.ts b/packages/data-context/src/util/urqlCacheKeys.ts index 72d6c65a42..576687b966 100644 --- a/packages/data-context/src/util/urqlCacheKeys.ts +++ b/packages/data-context/src/util/urqlCacheKeys.ts @@ -19,6 +19,7 @@ export const urqlCacheKeys: Partial = { BaseError: () => null, ProjectPreferences: (data) => data.__typename, VersionData: () => null, + ScaffoldedFile: () => null, LocalSettings: (data) => data.__typename, LocalSettingsPreferences: () => null, CloudProjectNotFound: (data) => data.__typename, diff --git a/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts b/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts index db93cbefab..4f9d980cba 100644 --- a/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts +++ b/packages/frontend-shared/cypress/e2e/e2ePluginSetup.ts @@ -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 extends Promise 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`) diff --git a/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts b/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts index f3ff26ede6..fbbd794ed1 100644 --- a/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts +++ b/packages/frontend-shared/cypress/e2e/support/e2eSupport.ts @@ -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 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) diff --git a/packages/frontend-shared/src/graphql/urqlExchangeNamedRoute.ts b/packages/frontend-shared/src/graphql/urqlExchangeNamedRoute.ts index d3715bc6f9..043f5dd259 100644 --- a/packages/frontend-shared/src/graphql/urqlExchangeNamedRoute.ts +++ b/packages/frontend-shared/src/graphql/urqlExchangeNamedRoute.ts @@ -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 { diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql index ddd3dac23a..d60951d90a 100644 --- a/packages/graphql/schemas/schema.graphql +++ b/packages/graphql/schemas/schema.graphql @@ -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 diff --git a/packages/launchpad/cypress/e2e/onboarding-flow.cy.ts b/packages/launchpad/cypress/e2e/onboarding-flow.cy.ts index 145d85d982..c47a68203e 100644 --- a/packages/launchpad/cypress/e2e/onboarding-flow.cy.ts +++ b/packages/launchpad/cypress/e2e/onboarding-flow.cy.ts @@ -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 }) }) diff --git a/packages/launchpad/src/setup/OpenBrowser.vue b/packages/launchpad/src/setup/OpenBrowser.vue index 5bc014e97e..cee0a99d3c 100644 --- a/packages/launchpad/src/setup/OpenBrowser.vue +++ b/packages/launchpad/src/setup/OpenBrowser.vue @@ -31,6 +31,8 @@ query OpenBrowser { currentProject { id currentTestingType + isLoadingConfigFile + isLoadingNodeEvents ...OpenBrowserList } ...WarningList diff --git a/packages/launchpad/src/setup/ScaffoldedFiles.vue b/packages/launchpad/src/setup/ScaffoldedFiles.vue index 5582ad1f7d..6e7d7e4ea8 100644 --- a/packages/launchpad/src/setup/ScaffoldedFiles.vue +++ b/packages/launchpad/src/setup/ScaffoldedFiles.vue @@ -46,6 +46,11 @@ const { t } = useI18n() gql` mutation ScaffoldedFiles_completeSetup { completeSetup { + currentProject { + id + isLoadingConfigFile + isLoadingNodeEvents + } scaffoldedFiles { status } diff --git a/packages/proxy/lib/http/request-middleware.ts b/packages/proxy/lib/http/request-middleware.ts index e697fff4b8..61756212c6 100644 --- a/packages/proxy/lib/http/request-middleware.ts +++ b/packages/proxy/lib/http/request-middleware.ts @@ -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) diff --git a/packages/server/lib/errors.js b/packages/server/lib/errors.js index 51d85b26b8..06016b7805 100644 --- a/packages/server/lib/errors.js +++ b/packages/server/lib/errors.js @@ -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. diff --git a/packages/server/lib/makeDataContext.ts b/packages/server/lib/makeDataContext.ts index 6be0e27765..597dc8fde7 100644 --- a/packages/server/lib/makeDataContext.ts +++ b/packages/server/lib/makeDataContext.ts @@ -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() diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index 29be2b6af6..d09810a07b 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -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 diff --git a/packages/server/lib/plugins/child/run_require_async_child.js b/packages/server/lib/plugins/child/run_require_async_child.js index deb255adf6..f0fb1b71f4 100644 --- a/packages/server/lib/plugins/child/run_require_async_child.js +++ b/packages/server/lib/plugins/child/run_require_async_child.js @@ -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)) { diff --git a/packages/server/lib/plugins/index.js b/packages/server/lib/plugins/index.js index c7351961da..147c6fe675 100644 --- a/packages/server/lib/plugins/index.js +++ b/packages/server/lib/plugins/index.js @@ -32,7 +32,8 @@ const execute = (event, ...args) => { const _reset = () => { handlers = [] - getCtx().lifecycleManager.resetForTest() + + return getCtx().lifecycleManager.resetForTest() } module.exports = { diff --git a/packages/server/lib/plugins/preprocessor.js b/packages/server/lib/plugins/preprocessor.js index d874b08975..160809a298 100644 --- a/packages/server/lib/plugins/preprocessor.js +++ b/packages/server/lib/plugins/preprocessor.js @@ -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() }, } diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index db6a88c3d2..8ecbde0d4b 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -284,6 +284,13 @@ export class ProjectBase 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 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() diff --git a/system-tests/lib/fixtures.ts b/system-tests/lib/fixtures.ts index 4d57c3a4e7..906689cde6 100644 --- a/system-tests/lib/fixtures.ts +++ b/system-tests/lib/fixtures.ts @@ -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', diff --git a/yarn.lock b/yarn.lock index a32985c649..a3fb74e66b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"