fix: open browser at correct time during lifecycle (#19572)

This commit is contained in:
Tim Griesser
2022-01-12 13:41:25 -05:00
committed by GitHub
parent 139d88d91f
commit bfc032a2d4
32 changed files with 285 additions and 289 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`)

View File

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

View File

@@ -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 {

View File

@@ -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

View File

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

View File

@@ -31,6 +31,8 @@ query OpenBrowser {
currentProject {
id
currentTestingType
isLoadingConfigFile
isLoadingNodeEvents
...OpenBrowserList
}
...WarningList

View File

@@ -46,6 +46,11 @@ const { t } = useI18n()
gql`
mutation ScaffoldedFiles_completeSetup {
completeSetup {
currentProject {
id
isLoadingConfigFile
isLoadingNodeEvents
}
scaffoldedFiles {
status
}

View File

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

View File

@@ -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.

View File

@@ -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()

View File

@@ -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

View File

@@ -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)) {

View File

@@ -32,7 +32,8 @@ const execute = (event, ...args) => {
const _reset = () => {
handlers = []
getCtx().lifecycleManager.resetForTest()
return getCtx().lifecycleManager.resetForTest()
}
module.exports = {

View File

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

View File

@@ -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()

View File

@@ -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',

View File

@@ -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"