Merge pull request #20775 from cypress-io/ryanm/feat/refactor-config-loading

This commit is contained in:
Ryan Manuel
2022-04-01 13:55:07 -05:00
committed by GitHub
49 changed files with 1354 additions and 1240 deletions
+46 -25
View File
@@ -2,13 +2,13 @@ import defaultMessages from '@packages/frontend-shared/src/locales/en-US.json'
import type { SinonStub } from 'sinon'
describe('App: Runs', { viewportWidth: 1200 }, () => {
beforeEach(() => {
cy.scaffoldProject('component-tests')
cy.openProject('component-tests')
cy.startAppServer('component')
})
context('Runs Page', () => {
beforeEach(() => {
cy.scaffoldProject('component-tests')
cy.openProject('component-tests')
cy.startAppServer('component')
})
it('resolves the runs page', () => {
cy.loginUser()
cy.visitApp()
@@ -33,6 +33,12 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
})
context('Runs - Login', () => {
beforeEach(() => {
cy.scaffoldProject('component-tests')
cy.openProject('component-tests')
cy.startAppServer('component')
})
it('when logged out, shows call to action', () => {
cy.visitApp()
cy.get('[href="#/runs"]').click()
@@ -131,9 +137,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
context('Runs - Connect Project', () => {
it('opens Connect Project modal after clicking Connect Project button', () => {
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {}')
})
cy.scaffoldProject('component-tests')
cy.openProject('component-tests', ['--config-file', 'cypressWithoutProjectId.config.js'])
cy.startAppServer('component')
cy.loginUser()
cy.visitApp()
@@ -167,9 +173,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
context('Runs - Cannot Find Project', () => {
beforeEach(() => {
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {\'projectId\': \'abcdef42\'}')
})
cy.scaffoldProject('component-tests')
cy.openProject('component-tests', ['--config-file', 'cypressWithInvalidProjectId.config.js'])
cy.startAppServer('component')
cy.loginUser()
cy.remoteGraphQLIntercept(async (obj) => {
@@ -217,9 +223,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
context('Runs - Unauthorized Project', () => {
beforeEach(() => {
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {\'projectId\': \'abcdef\'}')
})
cy.scaffoldProject('component-tests')
cy.openProject('component-tests')
cy.startAppServer('component')
cy.loginUser()
})
@@ -278,9 +284,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
context('Runs - Unauthorized Project Requested', () => {
beforeEach(() => {
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {\'projectId\': \'abcdef\' }')
})
cy.scaffoldProject('component-tests')
cy.openProject('component-tests')
cy.startAppServer('component')
cy.loginUser()
cy.remoteGraphQLIntercept(async (obj) => {
@@ -310,9 +316,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
context('Runs - No Runs', () => {
it('when no runs and not connected, shows connect to dashboard button', () => {
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {projectId: null }')
})
cy.scaffoldProject('component-tests')
cy.openProject('component-tests', ['--config-file', 'cypressWithoutProjectId.config.js'])
cy.startAppServer('component')
cy.loginUser()
cy.remoteGraphQLIntercept(async (obj) => {
@@ -335,9 +341,9 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
})
it('displays how to record prompt when connected and no runs', () => {
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {projectId: \'abcdef\'}')
})
cy.scaffoldProject('component-tests')
cy.openProject('component-tests')
cy.startAppServer('component')
cy.loginUser()
cy.remoteGraphQLIntercept(async (obj) => {
@@ -360,8 +366,11 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
})
it('displays a copy button', () => {
cy.scaffoldProject('component-tests')
cy.openProject('component-tests')
cy.startAppServer('component')
cy.withCtx(async (ctx, o) => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {projectId: \'abcdef\'}')
o.sinon.stub(ctx.electronApi, 'copyTextToClipboard')
})
@@ -389,6 +398,12 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
})
context('Runs - Runs List', () => {
beforeEach(() => {
cy.scaffoldProject('component-tests')
cy.openProject('component-tests')
cy.startAppServer('component')
})
it('displays a list of recorded runs if a run has been recorded', () => {
cy.loginUser()
cy.visitApp()
@@ -446,6 +461,12 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
})
describe('no internet connection', () => {
beforeEach(() => {
cy.scaffoldProject('component-tests')
cy.openProject('component-tests')
cy.startAppServer('component')
})
afterEach(() => {
cy.goOnline()
})
@@ -176,7 +176,7 @@ describe('Sidebar Navigation', () => {
})
cy.withCtx((ctx, o) => {
o.sinon.stub(ctx.actions.project, 'setCurrentTestingType')
o.sinon.stub(ctx.actions.project, 'setAndLoadCurrentTestingType')
o.sinon.stub(ctx.actions.project, 'reconfigureProject').resolves()
})
@@ -186,7 +186,7 @@ describe('Sidebar Navigation', () => {
cy.withCtx((ctx) => {
expect(ctx.coreData.app.relaunchBrowser).eq(true)
expect(ctx.actions.project.setCurrentTestingType).to.have.been.calledWith('component')
expect(ctx.actions.project.setAndLoadCurrentTestingType).to.have.been.calledWith('component')
expect(ctx.actions.project.reconfigureProject).to.have.been.called
})
@@ -327,7 +327,7 @@ describe('Sidebar Navigation', () => {
}).should('be.visible')
cy.withCtx((ctx, o) => {
o.sinon.stub(ctx.actions.project, 'setCurrentTestingType')
o.sinon.stub(ctx.actions.project, 'setAndLoadCurrentTestingType')
o.sinon.stub(ctx.actions.project, 'reconfigureProject').resolves()
})
@@ -337,7 +337,7 @@ describe('Sidebar Navigation', () => {
cy.withCtx((ctx) => {
expect(ctx.coreData.app.relaunchBrowser).eq(true)
expect(ctx.actions.project.setCurrentTestingType).to.have.been.calledWith('e2e')
expect(ctx.actions.project.setAndLoadCurrentTestingType).to.have.been.calledWith('e2e')
expect(ctx.actions.project.reconfigureProject).to.have.been.called
})
})
+5 -1
View File
@@ -430,9 +430,13 @@ export class DataContext {
assert(!this.coreData.hasInitializedMode)
this.coreData.hasInitializedMode = this._config.mode
if (this._config.mode === 'run') {
await this.lifecycleManager.initializeRunMode()
await this.lifecycleManager.initializeRunMode(this.coreData.currentTestingType)
} else if (this._config.mode === 'open') {
await this.initializeOpenMode()
if (this.coreData.currentTestingType && await this.lifecycleManager.waitForInitializeSuccess()) {
this.lifecycleManager.setAndLoadCurrentTestingType(this.coreData.currentTestingType)
this.lifecycleManager.scaffoldFilesIfNecessary()
}
} else {
throw new Error(`Missing DataContext config "mode" setting, expected run | open`)
}
@@ -290,9 +290,8 @@ export class MigrationActions {
}
async finishReconfigurationWizard () {
this.ctx.lifecycleManager.initializeConfigWatchers()
this.ctx.lifecycleManager.refreshMetaState()
await this.ctx.lifecycleManager.reloadConfig()
await this.ctx.lifecycleManager.refreshLifecycle()
}
async nextStep () {
@@ -95,8 +95,8 @@ export class ProjectActions {
execa(this.ctx.coreData.localSettings.preferences.preferredEditorBinary, [projectPath])
}
setCurrentTestingType (type: TestingType) {
this.ctx.lifecycleManager.setCurrentTestingType(type)
setAndLoadCurrentTestingType (type: TestingType) {
this.ctx.lifecycleManager.setAndLoadCurrentTestingType(type)
}
async setCurrentProject (projectRoot: string) {
@@ -0,0 +1,39 @@
import fs from 'fs-extra'
import { getError } from '@packages/errors'
type CypressEnvOptions = {
envFilePath: string
validateConfigFile: (file: string | false, config: Cypress.ConfigOptions) => void
}
export class CypressEnv {
constructor (private options: CypressEnvOptions) {}
async loadCypressEnvFile (): Promise<Cypress.ConfigOptions> {
return this.readAndValidateCypressEnvFile()
}
private async readAndValidateCypressEnvFile () {
const cypressEnv = await this.readCypressEnvFile()
this.options.validateConfigFile(this.options.envFilePath, cypressEnv)
return cypressEnv
}
private async readCypressEnvFile (): Promise<Cypress.ConfigOptions> {
try {
return await fs.readJSON(this.options.envFilePath)
} catch (err: any) {
if (err.code === 'ENOENT') {
return {}
}
if (err.isCypressErr) {
throw err
}
throw getError('ERROR_READING_FILE', this.options.envFilePath, err)
}
}
}
@@ -0,0 +1,46 @@
import debugLib from 'debug'
import _ from 'lodash'
const debug = debugLib(`cypress:lifecycle:EventRegistrar`)
export class EventRegistrar {
private _registeredEvents: Record<string, Function> = {}
hasNodeEvent (eventName: string) {
const isRegistered = typeof this._registeredEvents[eventName] === 'function'
debug('plugin event registered? %o', { eventName, isRegistered })
return isRegistered
}
executeNodeEvent (event: string, args: any[]) {
debug(`execute plugin event '${event}' Node '${process.version}' with args: %o %o %o`, ...args)
const evtFn = this._registeredEvents[event]
if (typeof evtFn !== 'function') {
throw new Error(`Missing event for ${event}`)
}
return evtFn(...args)
}
registerEvent (event: string, callback: Function) {
debug(`register event '${event}'`)
if (!_.isString(event)) {
throw new Error(`The plugin register function must be called with an event as its 1st argument. You passed '${event}'.`)
}
if (!_.isFunction(callback)) {
throw new Error(`The plugin register function must be called with a callback function as its 2nd argument. You passed '${callback}'.`)
}
this._registeredEvents[event] = callback
}
reset () {
this._registeredEvents = {}
}
}
@@ -18,7 +18,7 @@ export class LegacyPluginsIpc extends EventEmitter {
send(event: 'loadLegacyPlugins', legacyConfig: LegacyCypressConfigJson): boolean
send (event: string, ...args: any[]) {
if (this.childProcess.killed) {
if (this.childProcess.killed || !this.childProcess.connected) {
return false
}
@@ -1,9 +1,18 @@
/* eslint-disable no-dupe-class-members */
import type { CypressError } from '@packages/errors'
import type { TestingType } from '@packages/types'
import type { ChildProcess } from 'child_process'
import { CypressError, getError } from '@packages/errors'
import type { FullConfig, TestingType } from '@packages/types'
import { ChildProcess, fork, ForkOptions } from 'child_process'
import EventEmitter from 'events'
import path from 'path'
import inspector from 'inspector'
import debugLib from 'debug'
import { autoBindDebug } from '../util'
import _ from 'lodash'
const pkg = require('@packages/root')
const debug = debugLib(`cypress:lifecycle:ProjectConfigIpc`)
const CHILD_PROCESS_FILE_PATH = require.resolve('@packages/server/lib/plugins/child/require_async_child')
export type IpcHandler = (ipc: ProjectConfigIpc) => void
@@ -29,18 +38,27 @@ export interface SerializedLoadConfigReply {
*
*/
export class ProjectConfigIpc extends EventEmitter {
constructor (readonly childProcess: ChildProcess) {
private _childProcess: ChildProcess
constructor (
readonly nodePath: string | undefined | null,
readonly projectRoot: string,
readonly configFilePath: string,
readonly configFile: string | false,
readonly onError: (cypressError: CypressError, title?: string | undefined) => void,
readonly onWarning: (cypressError: CypressError) => void,
) {
super()
childProcess.on('error', (err) => {
this._childProcess = this.forkConfigProcess()
this._childProcess.on('error', (err) => {
// this.emit('error', err)
})
childProcess.on('message', (msg: { event: string, args: any[] }) => {
this._childProcess.on('message', (msg: { event: string, args: any[] }) => {
this.emit(msg.event, ...msg.args)
})
childProcess.once('disconnect', () => {
// console.log('Disconnected')
this._childProcess.once('disconnect', () => {
this.emit('disconnect')
})
@@ -52,11 +70,11 @@ export class ProjectConfigIpc extends EventEmitter {
send(event: 'setupTestingType', testingType: TestingType, options: Cypress.PluginConfigOptions): boolean
send(event: 'loadConfig'): boolean
send (event: string, ...args: any[]) {
if (this.childProcess.killed) {
if (this._childProcess.killed || !this._childProcess.connected) {
return false
}
return this.childProcess.send({ event, args })
return this._childProcess.send({ event, args })
}
on(evt: 'childProcess:unhandledError', listener: (err: CypressError) => void): this
@@ -88,4 +106,168 @@ export class ProjectConfigIpc extends EventEmitter {
emit (evt: string, ...args: any[]) {
return super.emit(evt, ...args)
}
loadConfig (): Promise<LoadConfigReply> {
return new Promise((resolve, reject) => {
if (this._childProcess.stdout && this._childProcess.stderr) {
// manually pipe plugin stdout and stderr for dashboard capture
// @see https://github.com/cypress-io/cypress/issues/7434
this._childProcess.stdout.on('data', (data) => process.stdout.write(data))
this._childProcess.stderr.on('data', (data) => process.stderr.write(data))
}
let resolved = false
this._childProcess.on('error', (err) => {
this.handleChildProcessError(err, this, resolved, reject)
reject(err)
})
/**
* This reject cannot be caught anywhere??
*
* It's supposed to be caught on lib/modes/run.js:1689,
* but it's not.
*/
this.on('childProcess:unhandledError', (err) => {
this.handleChildProcessError(err, this, resolved, reject)
reject(err)
})
this.once('loadConfig:reply', (val) => {
debug('loadConfig:reply')
resolve({ ...val, initialConfig: JSON.parse(val.initialConfig) })
resolved = true
})
this.once('loadConfig:error', (err) => {
this.killChildProcess()
reject(err)
})
debug('trigger the load of the file')
this.once('ready', () => {
this.send('loadConfig')
})
})
}
async callSetupNodeEventsWithConfig (testingType: TestingType, config: FullConfig, handlers: IpcHandler[]): Promise<SetupNodeEventsReply> {
for (const handler of handlers) {
handler(this)
}
const promise = this.registerSetupIpcHandlers()
const overrides = config[testingType] ?? {}
const mergedConfig = { ...config, ...overrides }
// alphabetize config by keys
let orderedConfig = {} as Cypress.PluginConfigOptions
Object.keys(mergedConfig).sort().forEach((key) => {
const k = key as keyof typeof mergedConfig
// @ts-ignore
orderedConfig[k] = mergedConfig[k]
})
this.send('setupTestingType', testingType, {
...orderedConfig,
projectRoot: this.projectRoot,
configFile: this.configFilePath,
version: pkg.version,
testingType,
})
return promise
}
private registerSetupIpcHandlers (): Promise<SetupNodeEventsReply> {
return new Promise((resolve, reject) => {
let resolved = false
this._childProcess.on('error', (err) => {
this.handleChildProcessError(err, this, resolved, reject)
reject(err)
})
// For every registration event, we want to turn into an RPC with the child process
this.once('setupTestingType:reply', (val) => {
resolved = true
resolve(val)
})
this.once('setupTestingType:error', (err) => {
reject(err)
})
const handleWarning = (warningErr: CypressError) => {
debug('plugins process warning:', warningErr.stack)
return this.onWarning(warningErr)
}
this.on('warning', handleWarning)
})
}
private forkConfigProcess () {
const configProcessArgs = ['--projectRoot', this.projectRoot, '--file', this.configFilePath]
// allow the use of ts-node in subprocesses tests by removing the env constant from it
// without this line, packages/ts/register.js never registers the ts-node module for config and
// run_plugins can't use the config module.
const { CYPRESS_INTERNAL_E2E_TESTING_SELF, ...env } = process.env
env.NODE_OPTIONS = process.env.ORIGINAL_NODE_OPTIONS || ''
const childOptions: ForkOptions = {
stdio: 'pipe',
cwd: path.dirname(this.configFilePath),
env,
execPath: this.nodePath ?? undefined,
}
if (inspector.url()) {
childOptions.execArgv = _.chain(process.execArgv.slice(0))
.remove('--inspect-brk')
.push(`--inspect=${process.debugPort + 1}`)
.value()
}
debug('fork child process', CHILD_PROCESS_FILE_PATH, configProcessArgs, _.omit(childOptions, 'env'))
const proc = fork(CHILD_PROCESS_FILE_PATH, configProcessArgs, childOptions)
return proc
}
private handleChildProcessError (err: any, ipc: ProjectConfigIpc, resolved: boolean, reject: (reason?: any) => void) {
debug('plugins process error:', err.stack)
this.cleanupIpc()
err = getError('CONFIG_FILE_UNEXPECTED_ERROR', this.configFile || '(unknown config file)', err)
err.title = 'Config process error'
// this can sometimes trigger before the promise is fulfilled and
// sometimes after, so we need to handle each case differently
if (resolved) {
this.onError(err)
} else {
reject(err)
}
}
cleanupIpc () {
this.killChildProcess()
this.removeAllListeners()
}
private killChildProcess () {
this._childProcess.kill()
this._childProcess.stdout?.removeAllListeners()
this._childProcess.stderr?.removeAllListeners()
this._childProcess.removeAllListeners()
}
}
@@ -0,0 +1,492 @@
import { CypressError, getError } from '@packages/errors'
import { IpcHandler, LoadConfigReply, ProjectConfigIpc, SetupNodeEventsReply } from './ProjectConfigIpc'
import assert from 'assert'
import type { AllModeOptions, FullConfig, TestingType } from '@packages/types'
import debugLib from 'debug'
import path from 'path'
import _ from 'lodash'
import chokidar from 'chokidar'
import { validate as validateConfig, validateNoBreakingConfigLaunchpad, validateNoBreakingConfigRoot, validateNoBreakingTestingTypeConfig } from '@packages/config'
import { CypressEnv } from './CypressEnv'
import { autoBindDebug } from '../util/autoBindDebug'
import type { EventRegistrar } from './EventRegistrar'
import type { DataContext } from '../DataContext'
const debug = debugLib(`cypress:lifecycle:ProjectConfigManager`)
const UNDEFINED_SERIALIZED = '__cypress_undefined__'
type ProjectConfigManagerOptions = {
ctx: DataContext
configFile: string | false
projectRoot: string
handlers: IpcHandler[]
hasCypressEnvFile: boolean
eventRegistrar: EventRegistrar
onError: (cypressError: CypressError, title?: string | undefined) => void
onInitialConfigLoaded: (initialConfig: Cypress.ConfigOptions) => void
onFinalConfigLoaded: (finalConfig: FullConfig) => Promise<void>
refreshLifecycle: () => Promise<boolean>
}
type ConfigManagerState = 'pending' | 'loadingConfig' | 'loadedConfig' | 'loadingNodeEvents' | 'ready' | 'errored'
export class ProjectConfigManager {
private _configFilePath: string | undefined
private _cachedFullConfig: FullConfig | undefined
private _eventsIpc?: ProjectConfigIpc
private _pathToWatcherRecord: Record<string, chokidar.FSWatcher> = {}
private _watchers = new Set<chokidar.FSWatcher>()
private _registeredEventsTarget: TestingType | undefined
private _testingType: TestingType | null = null
private _state: ConfigManagerState = 'pending'
private _loadConfigPromise: Promise<LoadConfigReply> | undefined
private _cachedLoadConfig: LoadConfigReply | undefined
private _cypressEnv: CypressEnv
constructor (private options: ProjectConfigManagerOptions) {
this._cypressEnv = new CypressEnv({
envFilePath: this.envFilePath,
validateConfigFile: (filePath, config) => {
this.validateConfigFile(filePath, config)
},
})
return autoBindDebug(this)
}
get isLoadingNodeEvents () {
return this._state === 'loadingNodeEvents'
}
get isReady () {
return this._state === 'ready'
}
get isLoadingConfigFile () {
return this._state === 'loadingConfig'
}
get isInError () {
return this._state === 'errored'
}
get configFilePath () {
assert(this._configFilePath, 'configFilePath is undefined')
return this._configFilePath
}
set configFilePath (configFilePath) {
this._configFilePath = configFilePath
}
setTestingType (testingType: TestingType | null) {
this._testingType = testingType
}
private get envFilePath () {
return path.join(this.options.projectRoot, 'cypress.env.json')
}
private get loadedConfigFile (): Partial<Cypress.ConfigOptions> | null {
return this._cachedLoadConfig?.initialConfig ?? null
}
async initializeConfig (): Promise<LoadConfigReply['initialConfig']> {
try {
this._state = 'loadingConfig'
// Clean things up for a new load
this.closeWatchers()
this._cachedLoadConfig = undefined
this._cachedFullConfig = undefined
const loadConfigReply = await this.loadConfig()
// This is necessary as there is a weird timing issue where an error occurs and the config results get loaded
// TODO: see if this can be !== 'errored'
if (this._state === 'loadingConfig') {
debug(`config is loaded for file`, this.configFilePath, this._testingType)
this.validateConfigFile(this.configFilePath, loadConfigReply.initialConfig)
this._state = 'loadedConfig'
this._cachedLoadConfig = loadConfigReply
this.options.onInitialConfigLoaded(loadConfigReply.initialConfig)
this.watchFiles([
...loadConfigReply.requires,
this.configFilePath,
])
}
return loadConfigReply.initialConfig
} catch (error) {
debug(`catch %o`, error)
if (this._eventsIpc) {
this._eventsIpc.cleanupIpc()
}
this._state = 'errored'
this.closeWatchers()
throw error
} finally {
this.options.ctx.emitter.toLaunchpad()
}
}
loadTestingType () {
// If we have set a testingType, and it's not the "target" of the
// registeredEvents (switching testing mode), we need to get a fresh
// config IPC & re-execute the setupTestingType
if (this._registeredEventsTarget && this._testingType !== this._registeredEventsTarget) {
this.options.refreshLifecycle().catch(this.onLoadError)
} else if (this._eventsIpc && !this._registeredEventsTarget && this._cachedLoadConfig) {
this.setupNodeEvents(this._cachedLoadConfig).catch(this.onLoadError)
}
}
private async setupNodeEvents (loadConfigReply: LoadConfigReply): Promise<void> {
assert(this._eventsIpc, 'Expected _eventsIpc to be defined at this point')
this._state = 'loadingNodeEvents'
try {
assert(this._testingType, 'Cannot setup node events without a testing type')
this._registeredEventsTarget = this._testingType
const config = await this.getFullInitialConfig()
const setupNodeEventsReply = await this._eventsIpc?.callSetupNodeEventsWithConfig(this._testingType, config, this.options.handlers)
await this.handleSetupTestingTypeReply(this._eventsIpc, loadConfigReply, setupNodeEventsReply)
this._state = 'ready'
} catch (error) {
debug(`catch setupNodeEvents %o`, error)
this._state = 'errored'
if (this._eventsIpc) {
this._eventsIpc.cleanupIpc()
}
this.closeWatchers()
throw error
} finally {
this.options.ctx.emitter.toLaunchpad()
}
}
private async handleSetupTestingTypeReply (ipc: ProjectConfigIpc, loadConfigReply: LoadConfigReply, result: SetupNodeEventsReply) {
this.options.eventRegistrar.reset()
for (const { event, eventId } of result.registrations) {
debug('register plugins process event', event, 'with id', eventId)
this.options.eventRegistrar.registerEvent(event, function (...args: any[]) {
return new Promise((resolve, reject) => {
const invocationId = _.uniqueId('inv')
debug('call event', event, 'for invocation id', invocationId)
ipc.once(`promise:fulfilled:${invocationId}`, (err: any, value: any) => {
if (err) {
debug('promise rejected for id %s %o', invocationId, ':', err.stack)
reject(_.extend(new Error(err.message), err))
return
}
if (value === UNDEFINED_SERIALIZED) {
value = undefined
}
debug(`promise resolved for id '${invocationId}' with value`, value)
return resolve(value)
})
const ids = { invocationId, eventId }
// no argument is passed for cy.task()
// This is necessary because undefined becomes null when it is sent through ipc.
if (event === 'task' && args[1] === undefined) {
args[1] = {
__cypress_task_no_argument__: true,
}
}
ipc.send('execute:plugins', event, ids, args)
})
})
}
const cypressEnv = await this.loadCypressEnvFile()
const fullConfig = await this.buildBaseFullConfig(loadConfigReply.initialConfig, cypressEnv, this.options.ctx.modeOptions)
const finalConfig = this._cachedFullConfig = this.options.ctx._apis.configApi.updateWithPluginValues(fullConfig, result.setupConfig ?? {})
await this.options.onFinalConfigLoaded(finalConfig)
this.watchFiles([
...result.requires,
this.envFilePath,
])
return result
}
resetLoadingState () {
this._loadConfigPromise = undefined
this._registeredEventsTarget = undefined
this._state = 'pending'
}
private loadConfig () {
if (!this._loadConfigPromise) {
// If there's already a dangling IPC from the previous switch of testing type, we want to clean this up
if (this._eventsIpc) {
this._eventsIpc.cleanupIpc()
}
this._eventsIpc = new ProjectConfigIpc(this.options.ctx.nodePath, this.options.projectRoot, this.configFilePath, this.options.configFile, (cypressError: CypressError, title?: string | undefined) => {
this._state = 'errored'
this.options.ctx.onError(cypressError, title)
}, this.options.ctx.onWarning)
this._loadConfigPromise = this._eventsIpc.loadConfig()
}
return this._loadConfigPromise
}
private validateConfigFile (file: string | false, config: Cypress.ConfigOptions) {
validateConfig(config, (errMsg) => {
if (_.isString(errMsg)) {
throw getError('CONFIG_VALIDATION_MSG_ERROR', 'configFile', file || null, errMsg)
}
throw getError('CONFIG_VALIDATION_ERROR', 'configFile', file || null, errMsg)
})
return validateNoBreakingConfigLaunchpad(
config,
(type, obj) => {
const error = getError(type, obj)
this.options.ctx.onWarning(error)
return error
},
(type, obj) => {
const error = getError(type, obj)
this.options.onError(error)
throw error
},
)
}
onLoadError = (error: any) => {
this.closeWatchers()
this.options.onError(error, 'Error Loading Config')
}
private watchFiles (paths: string[]) {
if (this.options.ctx.isRunMode) {
return
}
const filtered = paths.filter((p) => !p.includes('/node_modules/'))
for (const path of filtered) {
if (!this._pathToWatcherRecord[path]) {
this._pathToWatcherRecord[path] = this.addWatcherFor(path)
}
}
}
private addWatcherFor (file: string) {
const w = this.addWatcher(file)
w.on('all', (evt) => {
debug(`changed ${file}: ${evt}`)
this.options.refreshLifecycle().catch(this.onLoadError)
})
w.on('error', (err) => {
debug('error watching config files %O', err)
this.options.ctx.onWarning(getError('UNEXPECTED_INTERNAL_ERROR', err))
})
return w
}
private addWatcher (file: string | string[]) {
const w = chokidar.watch(file, {
ignoreInitial: true,
cwd: this.options.projectRoot,
})
this._watchers.add(w)
return w
}
private validateConfigRoot (config: Cypress.ConfigOptions) {
return validateNoBreakingConfigRoot(
config,
(type, obj) => {
return getError(type, obj)
},
(type, obj) => {
throw getError(type, obj)
},
)
}
private validateTestingTypeConfig (config: Cypress.ConfigOptions, testingType: TestingType) {
return validateNoBreakingTestingTypeConfig(
config,
testingType,
(type, ...args) => {
return getError(type, ...args)
},
(type, ...args) => {
throw getError(type, ...args)
},
)
}
private async buildBaseFullConfig (configFileContents: Cypress.ConfigOptions, envFile: Cypress.ConfigOptions, options: Partial<AllModeOptions>, withBrowsers = true) {
assert(this._testingType, 'Cannot build base full config without a testing type')
this.validateConfigRoot(configFileContents)
const testingTypeOverrides = configFileContents[this._testingType] ?? {}
const optionsOverrides = options.config?.[this._testingType] ?? {}
this.validateTestingTypeConfig(testingTypeOverrides, this._testingType)
this.validateTestingTypeConfig(optionsOverrides, this._testingType)
// TODO: pass in options.config overrides separately, so they are reflected in the UI
configFileContents = { ...configFileContents, ...testingTypeOverrides, ...optionsOverrides }
// TODO: Convert this to be synchronous, it's just FS checks
let fullConfig = await this.options.ctx._apis.configApi.setupFullConfigWithDefaults({
cliConfig: options.config ?? {},
projectName: path.basename(this.options.projectRoot),
projectRoot: this.options.projectRoot,
config: _.cloneDeep(configFileContents),
envFile: _.cloneDeep(envFile),
options: {
...options,
testingType: this._testingType,
},
})
if (withBrowsers) {
const browsers = await this.options.ctx.browser.machineBrowsers()
if (!fullConfig.browsers || fullConfig.browsers.length === 0) {
// @ts-ignore - we don't know if the browser is headed or headless at this point.
// this is handled in open_project#launch.
fullConfig.browsers = browsers
fullConfig.resolved.browsers = { 'value': fullConfig.browsers, 'from': 'runtime' }
}
fullConfig.browsers = fullConfig.browsers?.map((browser) => {
if (browser.family === 'chromium' || fullConfig.chromeWebSecurity) {
return browser
}
return {
...browser,
warning: browser.warning || getError('CHROME_WEB_SECURITY_NOT_SUPPORTED', browser.name).message,
}
})
// If we have withBrowsers set to false, it means we're coming from the legacy config.get API
// in tests, which shouldn't be validating the config
this.validateConfigFile(this.options.configFile, fullConfig)
}
return _.cloneDeep(fullConfig)
}
async getFullInitialConfig (options: Partial<AllModeOptions> = this.options.ctx.modeOptions, withBrowsers = true): Promise<FullConfig> {
if (this._cachedFullConfig) {
return this._cachedFullConfig
}
const [configFileContents, envFile] = await Promise.all([
this.getConfigFileContents(),
this.reloadCypressEnvFile(),
])
this._cachedFullConfig = await this.buildBaseFullConfig(configFileContents, envFile, options, withBrowsers)
return this._cachedFullConfig
}
async getConfigFileContents () {
if (this._cachedLoadConfig?.initialConfig) {
return this._cachedLoadConfig?.initialConfig
}
return this.initializeConfig()
}
async loadCypressEnvFile () {
return this._cypressEnv.loadCypressEnvFile()
}
async reloadCypressEnvFile () {
this._cypressEnv = new CypressEnv({
envFilePath: this.envFilePath,
validateConfigFile: (filePath, config) => {
this.validateConfigFile(filePath, config)
},
})
return this._cypressEnv.loadCypressEnvFile()
}
isTestingTypeConfigured (testingType: TestingType): boolean {
const config = this.loadedConfigFile
if (!config) {
return false
}
if (!_.has(config, testingType)) {
return false
}
if (testingType === 'component') {
return Boolean(config.component?.devServer)
}
return true
}
private closeWatchers () {
for (const watcher of this._watchers.values()) {
// We don't care if there's an error while closing the watcher,
// the watch listener on our end is already removed synchronously by chokidar
watcher.close().catch((e) => {})
}
this._watchers = new Set()
this._pathToWatcherRecord = {}
}
destroy () {
if (this._eventsIpc) {
this._eventsIpc.cleanupIpc()
}
this._state = 'pending'
this._cachedLoadConfig = undefined
this._cachedFullConfig = undefined
this._registeredEventsTarget = undefined
this.closeWatchers()
}
}
File diff suppressed because it is too large Load Diff
@@ -112,7 +112,7 @@ export interface CoreDataShape {
cliBrowser: string | null
cliTestingType: string | null
chosenBrowser: FoundBrowser | null
machineBrowsers: Promise<FoundBrowser[]> | FoundBrowser[] | null
machineBrowsers: Promise<FoundBrowser[]> | null
servers: {
appServer?: Maybe<Server>
appServerPort?: Maybe<number>
+3
View File
@@ -1,7 +1,10 @@
/* eslint-disable padding-line-between-statements */
// created by autobarrel, do not modify directly
export * from './CypressEnv'
export * from './EventRegistrar'
export * from './LegacyPluginsIpc'
export * from './ProjectConfigIpc'
export * from './ProjectConfigManager'
export * from './ProjectLifecycleManager'
export * from './coreDataShape'
+2
View File
@@ -12,6 +12,8 @@ export type {
GlobalPubSub,
} from './globalPubSub'
export * from './util/pluginHandlers'
import { globalPubSub } from './globalPubSub'
export { globalPubSub }
@@ -32,15 +32,12 @@ export class BrowserDataSource {
if (!this.ctx.coreData.machineBrowsers) {
const p = this.ctx._apis.browserApi.getBrowsers()
this.ctx.coreData.machineBrowsers = p
p.then((browsers) => {
if (this.ctx.coreData.machineBrowsers === p) {
if (browsers[0]) {
this.ctx.coreData.chosenBrowser = browsers[0]
}
this.ctx.coreData.machineBrowsers = browsers
this.ctx.coreData.machineBrowsers = p.then((browsers) => {
if (browsers[0]) {
this.ctx.coreData.chosenBrowser = browsers[0]
}
return browsers
}).catch((e) => {
this.ctx.update((coreData) => {
coreData.machineBrowsers = null
@@ -1,4 +1,5 @@
import os from 'os'
import chokidar from 'chokidar'
import type { ResolvedFromConfig, RESOLVED_FROM, FoundSpec } from '@packages/types'
import { FrontendFramework, FRONTEND_FRAMEWORKS } from '@packages/scaffold-config'
import { scanFSForAvailableDependency } from 'create-cypress-tests'
@@ -258,7 +259,11 @@ export class ProjectDataSource {
this.ctx.actions.project.setSpecs(specs)
})
this._specWatcher = this.ctx.lifecycleManager.addWatcher(specPattern)
this._specWatcher = chokidar.watch(specPattern, {
ignoreInitial: true,
cwd: projectRoot,
})
this._specWatcher.on('add', onSpecsChanged)
this._specWatcher.on('change', onSpecsChanged)
this._specWatcher.on('unlink', onSpecsChanged)
@@ -319,12 +324,17 @@ export class ProjectDataSource {
return false
}
destroy () {
this.stopSpecWatcher()
}
stopSpecWatcher () {
if (!this._specWatcher) {
return
}
this.ctx.lifecycleManager.closeWatcher(this._specWatcher)
this._specWatcher.close().catch(() => {})
this._specWatcher = null
}
getCurrentSpecByAbsolute (absolute: string) {
@@ -2,6 +2,15 @@ import debugLib from 'debug'
const debugLibCache: Record<string, debugLib.Debugger> = {}
/**
* This function enables trace logging on all function calls and setters for a given class.
* If you add:
*
* return autoBindDebug(this)
*
* to the constructor of the class for which you want to enable logging, you can then
* set DEBUG=cypress-trace:<ClassName> to utilize the logging
*/
export function autoBindDebug <T extends object> (obj: T): T {
const ns = `cypress-trace:${obj.constructor.name}`
const debug = debugLibCache[ns] = debugLibCache[ns] || debugLib(ns)
+1
View File
@@ -5,4 +5,5 @@ export * from './autoBindDebug'
export * from './cached'
export * from './config-file-updater'
export * from './file'
export * from './pluginHandlers'
export * from './urqlCacheKeys'
@@ -0,0 +1,15 @@
import type { IpcHandler } from '../data'
let pluginHandlers: IpcHandler[] = []
export const getServerPluginHandlers = () => {
return pluginHandlers
}
export const registerServerPluginHandler = (handler: IpcHandler) => {
pluginHandlers.push(handler)
}
export const resetPluginHandlers = () => {
pluginHandlers = []
}
+1 -3
View File
@@ -32,9 +32,7 @@ export function createTestDataContext (mode: DataContextConfig['mode'] = 'run')
appApi: {} as AppApiShape,
localSettingsApi: {} as LocalSettingsApiShape,
authApi: {} as AuthApiShape,
configApi: {
getServerPluginHandlers: () => [],
} as InjectedConfigApi,
configApi: {} as InjectedConfigApi,
projectApi: {} as ProjectApiShape,
electronApi: {
copyTextToClipboard: (text) => {},
@@ -144,9 +144,7 @@ describe('findSpecs', () => {
appApi: {} as AppApiShape,
localSettingsApi: {} as LocalSettingsApiShape,
authApi: {} as AuthApiShape,
configApi: {
getServerPluginHandlers: () => [],
} as InjectedConfigApi,
configApi: {} as InjectedConfigApi,
projectApi: {} as ProjectApiShape,
electronApi: {} as ElectronApiShape,
browserApi: {} as BrowserApiShape,
@@ -225,7 +225,8 @@ function startAppServer (mode: 'component' | 'e2e' = 'e2e') {
return logInternal('startAppServer', (log) => {
return cy.window({ log: false }).then((win) => {
return cy.withCtx(async (ctx, o) => {
ctx.actions.project.setCurrentTestingType(o.mode)
await ctx.lifecycleManager.waitForInitializeSuccess()
ctx.actions.project.setAndLoadCurrentTestingType(o.mode)
const isInitialized = o.pDefer()
const initializeActive = ctx.actions.project.initializeActiveProject
const onErrorStub = o.sinon.stub(ctx, 'onError')
@@ -1,10 +1,10 @@
<template>
<div
v-if="props.gql.currentProject"
class="flex justify-center m-24px"
class="flex m-24px justify-center"
>
<Card
v-for="tt in TESTING_TYPES"
v-for="tt in testingTypes"
:key="tt.key"
:data-cy-testingType="tt.key"
:title="tt.name"
@@ -68,24 +68,26 @@ const emits = defineEmits<{
(eventName: 'pick', testingType: TestingTypeEnum, currentTestingType: TestingTypeEnum): void
}>()
const TESTING_TYPES = [
{
key: 'e2e',
name: t('testingType.e2e.name'),
description: t('testingType.e2e.description'),
icon: IconE2E,
iconSolid: IconE2ESolid,
configured: props.gql.currentProject?.isE2EConfigured,
},
{
key: 'component',
name: t('testingType.component.name'),
description: t('testingType.component.description'),
icon: IconComponent,
iconSolid: IconComponentSolid,
configured: props.gql.currentProject?.isCTConfigured,
},
] as const
const testingTypes = computed(() => {
return [
{
key: 'e2e',
name: t('testingType.e2e.name'),
description: t('testingType.e2e.description'),
icon: IconE2E,
iconSolid: IconE2ESolid,
configured: props.gql.currentProject?.isE2EConfigured,
},
{
key: 'component',
name: t('testingType.component.name'),
description: t('testingType.component.description'),
icon: IconComponent,
iconSolid: IconComponentSolid,
configured: props.gql.currentProject?.isCTConfigured,
},
] as const
})
const currentTestingType = computed(() => props.gql.currentProject?.currentTestingType as TestingTypeEnum)
+4 -7
View File
@@ -1012,9 +1012,6 @@ type Mutation {
"""While migrating to 10+ skip manual rename step"""
migrateSkipManualRename: Query
"""Initialize the migration wizard to the first step"""
migrateStart: Query
"""Open a path in preferred IDE"""
openDirectoryInIDE(path: String!): Boolean
openExternal(includeGraphqlPort: Boolean, url: String!): Boolean
@@ -1031,15 +1028,15 @@ type Mutation {
"""show the launchpad windows"""
reconfigureProject: Boolean!
"""Re-initializes Cypress from the initial CLI options"""
reinitializeCypress: Query
"""Remove project from projects array and cache"""
removeProject(path: String!): Query
"""Reset the Auth State"""
resetAuthState: Query!
"""Resets errors and attempts to reload the config"""
resetErrorsAndLoadConfig: Query
"""
Resets the latest version call to capture additional telemetry for the current user
"""
@@ -1049,10 +1046,10 @@ type Mutation {
resetWizard: Boolean!
scaffoldIntegration: [ScaffoldedFile!]!
scaffoldTestingType: Query
setAndLoadCurrentTestingType(testingType: TestingTypeEnum!): Query
"""Set active project to run tests on"""
setCurrentProject(path: String!): Query
setCurrentTestingType(testingType: TestingTypeEnum!): Query
"""
Update local preferences (also known as appData). The payload, `value`, should be a `JSON.stringified()` object of the new values you'd like to persist. Example: `setPreferences (value: JSON.stringify({ lastOpened: Date.now() }))`
@@ -24,12 +24,17 @@ export const mutation = mutationType({
},
})
t.field('reinitializeCypress', {
t.field('resetErrorsAndLoadConfig', {
type: Query,
description: 'Re-initializes Cypress from the initial CLI options',
description: 'Resets errors and attempts to reload the config',
resolve: async (_, args, ctx) => {
await ctx.reinitializeCypress(ctx.modeOptions)
await ctx.initializeMode()
ctx.update((d) => {
d.baseError = null
d.warnings = []
})
// Wait for the project config to be reloaded
await ctx.lifecycleManager.refreshLifecycle()
return {}
},
@@ -150,24 +155,28 @@ export const mutation = mutationType({
t.field('clearCurrentTestingType', {
type: 'Query',
resolve: async (_, args, ctx) => {
ctx.lifecycleManager.setCurrentTestingType(null)
ctx.lifecycleManager.setAndLoadCurrentTestingType(null)
return {}
},
})
t.field('setCurrentTestingType', {
t.field('setAndLoadCurrentTestingType', {
type: 'Query',
args: {
testingType: nonNull(arg({ type: TestingTypeEnum })),
},
resolve: async (source, args, ctx) => {
ctx.actions.project.setCurrentTestingType(args.testingType)
ctx.actions.project.setAndLoadCurrentTestingType(args.testingType)
// if necessary init the wizard for configuration
if (ctx.coreData.currentTestingType
&& !ctx.lifecycleManager.isTestingTypeConfigured(ctx.coreData.currentTestingType)) {
await ctx.actions.wizard.initialize()
if (ctx.wizardData.chosenLanguage === 'ts') {
ctx.lifecycleManager.scaffoldFilesIfNecessary()
}
}
return {}
@@ -472,16 +481,6 @@ export const mutation = mutationType({
},
})
t.field('migrateStart', {
description: 'Initialize the migration wizard to the first step',
type: Query,
resolve: async (_, args, ctx) => {
await ctx.lifecycleManager._pendingMigrationInitialize?.promise
return {}
},
})
t.field('migrateRenameSpecs', {
description: 'While migrating to 10+ renames files to match the new .cy pattern',
type: Query,
@@ -593,7 +592,7 @@ export const mutation = mutationType({
}
// Wait for the project config to be reloaded
await ctx.lifecycleManager.reloadConfig()
await ctx.lifecycleManager.refreshLifecycle()
return {}
},
@@ -617,7 +616,7 @@ export const mutation = mutationType({
},
resolve: async (source, args, ctx) => {
ctx.project.setRelaunchBrowser(true)
ctx.actions.project.setCurrentTestingType(args.testingType)
ctx.actions.project.setAndLoadCurrentTestingType(args.testingType)
await ctx.actions.project.reconfigureProject()
return true
@@ -633,7 +632,7 @@ export const mutation = mutationType({
},
resolve: async (source, args, ctx) => {
ctx.actions.project.setForceReconfigureProjectByTestingType({ forceReconfigureProject: true, testingType: args.testingType })
ctx.actions.project.setCurrentTestingType(args.testingType)
ctx.actions.project.setAndLoadCurrentTestingType(args.testingType)
if (args.isApp) {
await ctx.actions.project.reconfigureProject()
@@ -35,7 +35,7 @@ export const Query = objectType({
t.field('migration', {
type: Migration,
description: 'Metadata about the migration, null if we aren\'t showing it',
resolve: (root, args, ctx) => ctx.coreData.migration,
resolve: (root, args, ctx) => ctx.coreData.migration.legacyConfigForMigration ? ctx.coreData.migration : null,
})
t.nonNull.field('dev', {
@@ -281,6 +281,33 @@ describe('Choose a Browser Page', () => {
cy.contains('button', 'Start E2E Testing in Firefox').should('be.visible')
cy.findByRole('radio', { name: 'Firefox v5', checked: true }).should('be.visible')
})
it('should return to welcome screen if user modifies the config file to not include the current testing type and recover', () => {
cy.openProject('launchpad', ['--e2e'])
cy.visitLaunchpad()
cy.get('h1').should('contain', 'Choose a Browser')
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {}')
})
cy.get('h1').should('contain', 'Welcome to Cypress!')
cy.contains('[data-cy-testingtype="e2e"]', 'Not Configured')
cy.withCtx(async (ctx) => {
await ctx.actions.file.writeFileInProject('cypress.config.js',
`module.exports = {
e2e: {
setupNodeEvents: (on, config) => config,
supportFile: false,
},
}`)
})
cy.get('h1').should('contain', 'Welcome to Cypress!')
cy.get('[data-cy-testingtype="e2e"]').should('not.contain', 'Not Configured')
})
})
describe('No System Browsers Detected', () => {
@@ -27,6 +27,8 @@ describe('Config files error handling', () => {
await ctx.actions.file.removeFileInProject('cypress.config.js')
})
cy.findByRole('button', { name: 'Try again' }).click()
cy.get('h1').should('contain', 'Welcome to Cypress')
})
@@ -61,6 +63,8 @@ describe('Config files error handling', () => {
await ctx.actions.file.removeFileInProject('cypress.json')
})
cy.findByRole('button', { name: 'Try again' }).click()
cy.get('h1').should('contain', 'Welcome to Cypress')
})
@@ -80,6 +84,8 @@ describe('Config files error handling', () => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = { e2e: { supportFile: false } }')
})
cy.findByRole('button', { name: 'Try again' }).click()
cy.get('h1').should('contain', 'Choose a Browser')
})
})
@@ -20,6 +20,8 @@ describe('Error handling', () => {
await ctx.actions.file.writeFileInProject('cypress.config.js', `module.exports = { e2e: { baseUrl: 'https://cypress.com', supportFile: false } }`)
})
cy.findByRole('button', { name: 'Try again' }).click()
cy.get('body')
.should('not.contain.text', 'Error Loading Config')
})
@@ -54,6 +56,8 @@ describe('Error handling', () => {
await ctx.actions.file.writeFileInProject('cypress.config.js', 'module.exports = {}')
})
cy.findByRole('button', { name: 'Try again' }).click()
cy.get('body')
.should('not.contain.text', 'Error Loading Config')
})
+20 -1
View File
@@ -1,4 +1,5 @@
import type { e2eProjectDirs } from '@packages/frontend-shared/cypress/e2e/support/e2eProjectDirs'
import { decodeBase64Unicode } from '@packages/frontend-shared/src/utils/base64'
const renameAutoStep = `[data-cy="migration-step renameAuto"]`
const renameManualStep = `[data-cy="migration-step renameManual"]`
@@ -6,6 +7,16 @@ const renameSupportStep = `[data-cy="migration-step renameSupport"]`
const configFileStep = `[data-cy="migration-step configFile"]`
const setupComponentStep = `[data-cy="migration-step setupComponent"]`
function getPathForPlatform (posixPath: string) {
// @ts-ignore
const cy = window.Cypress
const platform = cy?.platform || JSON.parse(decodeBase64Unicode(window.__CYPRESS_CONFIG__.base64Config)).platform
if (platform === 'win32') return posixPath.replaceAll('/', '\\')
return posixPath
}
declare global {
namespace Cypress {
interface Chainable {
@@ -927,7 +938,7 @@ describe('Full migration flow for each project', { retries: { openMode: 2, runMo
})
})
it('completes journey for migration-e2e-legacy-plugins-throws-error', () => {
it('completes journey for migration-e2e-legacy-plugins-throws-error and recovers', () => {
scaffoldAndVisitLaunchpad('migration-e2e-legacy-plugins-throws-error')
// no steps are shown - we show the error that surfaced when executing pluginsFile.
cy.get(renameAutoStep).should('not.exist')
@@ -941,6 +952,14 @@ describe('Full migration flow for each project', { retries: { openMode: 2, runMo
cy.get('[data-testid="error-code-frame"]').contains(`cypress/plugins/index.js:2:9`)
// correct error from pluginsFile
cy.contains(`throw Error('Uh oh, there was an error!')`)
cy.withCtx(async (ctx, o) => {
await ctx.actions.file.writeFileInProject(o.path, 'module.exports = (on, config) => {}')
}, { path: getPathForPlatform('cypress/plugins/index.js') })
cy.findByRole('button', { name: 'Try again' }).click()
cy.waitForWizard()
})
})
+6 -6
View File
@@ -7,7 +7,7 @@
<BaseError
v-if="query.data.value.baseError"
:gql="query.data.value.baseError"
:retry="reinitializeCypress"
:retry="resetErrorsAndLoadConfig"
/>
<GlobalPage
v-else-if="query.data.value.isInGlobalMode || !query.data.value?.currentProject"
@@ -74,7 +74,7 @@
<script lang="ts" setup>
import { gql, useMutation, useQuery } from '@urql/vue'
import { MainLaunchpadQueryDocument, Main_ReinitializeCypressDocument } from './generated/graphql'
import { MainLaunchpadQueryDocument, Main_ResetErrorsAndLoadConfigDocument } from './generated/graphql'
import TestingTypeCards from './setup/TestingTypeCards.vue'
import Wizard from './setup/Wizard.vue'
import ScaffoldLanguageSelect from './setup/ScaffoldLanguageSelect.vue'
@@ -127,16 +127,16 @@ query MainLaunchpadQuery {
`
gql`
mutation Main_ReinitializeCypress {
reinitializeCypress {
mutation Main_ResetErrorsAndLoadConfig {
resetErrorsAndLoadConfig {
...MainLaunchpadQueryData
}
}
`
const mutation = useMutation(Main_ReinitializeCypressDocument)
const mutation = useMutation(Main_ResetErrorsAndLoadConfigDocument)
const reinitializeCypress = () => {
const resetErrorsAndLoadConfig = () => {
if (!mutation.fetching.value) {
mutation.executeMutation({})
}
@@ -161,7 +161,6 @@ import {
MigrationWizard_RenameSpecsDocument,
MigrationWizard_RenameSupportDocument,
MigrationWizard_SkipManualRenameDocument,
MigrationWizard_StartDocument,
MigrationWizard_RenameSpecsFolderDocument,
} from '../generated/graphql'
import { useI18n } from '@cy/i18n'
@@ -204,25 +203,7 @@ const query = useQuery({ query: MigrationWizardQueryDocument, requestPolicy: 'ca
const migration = computed(() => query.data.value?.migration)
const steps = computed(() => migration.value?.filteredSteps || [])
// start migration
gql`
mutation MigrationWizard_Start {
migrateStart {
migration {
filteredSteps {
id
...MigrationStep
}
}
}
}
`
const start = useMutation(MigrationWizard_StartDocument)
onBeforeMount(async () => {
await start.executeMutation({ })
await query.executeQuery()
})
@@ -21,7 +21,7 @@ fragment TestingTypeCards on Query {
gql`
mutation TestingTypeSelection($testingType: TestingTypeEnum!) {
setCurrentTestingType(testingType: $testingType) {
setAndLoadCurrentTestingType(testingType: $testingType) {
currentProject {
id
currentTestingType
+1 -15
View File
@@ -3,7 +3,7 @@ import Debug from 'debug'
import _ from 'lodash'
import path from 'path'
import deepDiff from 'return-deep-diff'
import type { ResolvedFromConfig, ResolvedConfigurationOptionSource, AllModeOptions, FullConfig } from '@packages/types'
import type { ResolvedFromConfig, ResolvedConfigurationOptionSource } from '@packages/types'
import * as configUtils from '@packages/config'
import * as errors from './errors'
import { getProcessEnvVars, CYPRESS_SPECIAL_ENV_VARS } from './util/config'
@@ -78,20 +78,6 @@ export function isValidCypressInternalEnvValue (value) {
return _.includes(names, value)
}
export async function get (
projectRoot,
// Options are only used in testing
options?: Partial<AllModeOptions>,
): Promise<FullConfig> {
const ctx = getCtx()
options ??= ctx.modeOptions
ctx.lifecycleManager.setCurrentProject(projectRoot)
return ctx.lifecycleManager.getFullInitialConfig(options, false)
}
export function setupFullConfigWithDefaults (obj: Record<string, any> = {}) {
debug('setting config object %o', obj)
let { projectRoot, projectName, config, envFile, options, cliConfig } = obj
-5
View File
@@ -26,7 +26,6 @@ import { openExternal } from '@packages/server/lib/gui/links'
import { getUserEditor } from './util/editors'
import * as savedState from './saved_state'
import appData from './util/app_data'
import plugins from './plugins'
import browsers from './browsers'
import devServer from './plugins/dev-server'
@@ -56,15 +55,11 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext {
},
},
configApi: {
getServerPluginHandlers: plugins.getServerPluginHandlers,
allowedConfig: configUtils.allowed,
cypressVersion: pkg.version,
validateConfig: configUtils.validate,
updateWithPluginValues: config.updateWithPluginValues,
setupFullConfigWithDefaults: config.setupFullConfigWithDefaults,
validateRootConfigBreakingChanges: configUtils.validateNoBreakingConfigRoot,
validateLaunchpadConfigBreakingChanges: configUtils.validateNoBreakingConfigLaunchpad,
validateTestingTypeConfigBreakingChanges: configUtils.validateNoBreakingTestingTypeConfig,
},
appApi: {
appData,
+1 -1
View File
@@ -253,7 +253,7 @@ export class OpenProject {
const testingType = args.testingType === 'component' ? 'component' : 'e2e'
this._ctx.lifecycleManager.setRunModeExitEarly(options.onError ?? undefined)
this._ctx.lifecycleManager.runModeExitEarly = options.onError ?? undefined
// store the currently open project
this.projectBase = new ProjectBase({
+3 -12
View File
@@ -1,4 +1,4 @@
const { getCtx } = require('@packages/data-context')
const { getCtx, registerServerPluginHandler, getServerPluginHandlers: getPluginHandlers } = require('@packages/data-context')
const registerEvent = (event, callback) => {
getCtx().lifecycleManager.registerEvent(event, callback)
@@ -8,18 +8,12 @@ const getPluginPid = () => {
return getCtx().lifecycleManager.eventProcessPid
}
let handlers = []
const registerHandler = (handler) => {
handlers.push(handler)
registerServerPluginHandler(handler)
}
const getServerPluginHandlers = () => {
return handlers
}
const init = (config, options) => {
// return getCtx().lifecycleManager.ready()
return getPluginHandlers()
}
const has = (event) => {
@@ -31,8 +25,6 @@ const execute = (event, ...args) => {
}
const _reset = () => {
handlers = []
return getCtx().lifecycleManager.reinitializeCypress()
}
@@ -40,7 +32,6 @@ module.exports = {
getPluginPid,
execute,
has,
init,
registerEvent,
registerHandler,
getServerPluginHandlers,
+1 -1
View File
@@ -497,7 +497,7 @@ export class ProjectBase<TServer extends Server> extends EE {
}
async initializeConfig (): Promise<Cfg> {
this.ctx.lifecycleManager.setCurrentTestingType(this.testingType)
this.ctx.lifecycleManager.setAndLoadCurrentTestingType(this.testingType)
let theCfg: Cfg = {
...(await this.ctx.lifecycleManager.getFullInitialConfig()),
testingType: this.testingType,
@@ -27,10 +27,8 @@ const runMode = require(`../../lib/modes/run`)
const api = require(`../../lib/api`)
const cwd = require(`../../lib/cwd`)
const user = require(`../../lib/user`)
const config = require(`../../lib/config`)
const cache = require(`../../lib/cache`)
const errors = require(`../../lib/errors`)
const plugins = require(`../../lib/plugins`)
const cypress = require(`../../lib/cypress`)
const ProjectBase = require(`../../lib/project-base`).ProjectBase
const { ServerE2E } = require(`../../lib/server-e2e`)
@@ -127,7 +125,6 @@ describe('lib/cypress', () => {
// force cypress to call directly into main without
// spawning a separate process
sinon.stub(videoCapture, 'start').resolves({})
sinon.stub(plugins, 'init').resolves(undefined)
sinon.stub(electronApp, 'isRunning').returns(true)
sinon.stub(extension, 'setHostAndPath').resolves()
sinon.stub(detect, 'detect').resolves(TYPICAL_BROWSERS)
@@ -457,7 +454,7 @@ describe('lib/cypress', () => {
ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(this.pristineWithConfigPath)
return config.get(this.pristineWithConfigPath)
return ctx.lifecycleManager.getFullInitialConfig()
.then(() => {
return fs.rmdir(supportFolder, { recursive: true })
}).then(() => {
@@ -480,7 +477,7 @@ describe('lib/cypress', () => {
it.skip('removes fixtures when they exist and fixturesFolder is false', function (done) {
ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(this.idsPath)
config.get(this.idsPath)
ctx.lifecycleManager.getFullInitialConfig()
.then((cfg) => {
this.cfg = cfg
@@ -540,7 +537,7 @@ describe('lib/cypress', () => {
ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(this.idsPath)
return config.get(this.idsPath)
return ctx.lifecycleManager.getFullInitialConfig()
.then((cfg) => {
this.cfg = cfg
@@ -897,8 +894,6 @@ describe('lib/cypress', () => {
})
it('can override values in plugins', function () {
plugins.init.restore()
return cypress.start([
`--run-project=${this.pluginConfig}`, '--config=requestTimeout=1234,videoCompression=false',
'--env=foo=foo,bar=bar',
@@ -940,7 +935,6 @@ describe('lib/cypress', () => {
describe('plugins', () => {
beforeEach(() => {
plugins.init.restore()
browsers.open.restore()
const ee = new EE()
@@ -19,7 +19,6 @@ const SseStream = require('ssestream')
const EventSource = require('eventsource')
const config = require(`../../lib/config`)
const { ServerE2E } = require(`../../lib/server-e2e`)
const ProjectBase = require(`../../lib/project-base`).ProjectBase
const pluginsModule = require(`../../lib/plugins`)
const preprocessor = require(`../../lib/plugins/preprocessor`)
const resolve = require(`../../lib/util/resolve`)
@@ -103,7 +102,7 @@ describe('Routes', () => {
obj.projectRoot = Fixtures.projectPath('e2e')
}
ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(obj.projectRoot)
ctx.lifecycleManager.setCurrentProject(obj.projectRoot)
// get all the config defaults
// and allow us to override them
@@ -151,48 +150,47 @@ describe('Routes', () => {
}
const open = () => {
this.project = new ProjectBase({ projectRoot: Fixtures.projectPath('e2e'), testingType: 'e2e' })
cfg.pluginsFile = false
return Promise.all([
return ctx.lifecycleManager.waitForInitializeSuccess()
.then(() => {
return Promise.all([
// open our https server
httpsServer.start(8443),
httpsServer.start(8443),
// and open our cypress server
(this.server = new ServerE2E()),
// and open our cypress server
(this.server = new ServerE2E()),
this.server.open(cfg, {
SocketCtor: SocketE2E,
getSpec: () => spec,
getCurrentBrowser: () => null,
createRoutes,
testingType: 'e2e',
exit: false,
})
.spread(async (port) => {
const automationStub = {
use: () => { },
}
this.server.open(cfg, {
SocketCtor: SocketE2E,
getSpec: () => spec,
getCurrentBrowser: () => null,
createRoutes,
testingType: 'e2e',
exit: false,
})
.spread(async (port) => {
const automationStub = {
use: () => { },
}
await this.server.startWebsockets(automationStub, config, {})
await this.server.startWebsockets(automationStub, config, {})
if (initialUrl) {
this.server._onDomainSet(initialUrl)
}
if (initialUrl) {
this.server._onDomainSet(initialUrl)
}
this.srv = this.server.getHttpServer()
this.srv = this.server.getHttpServer()
this.session = session(this.srv)
this.session = session(this.srv)
this.proxy = `http://localhost:${port}`
}),
// pluginsModule.init(cfg, {
// projectRoot: cfg.projectRoot,
// testingType: 'e2e',
// }, ctx),
])
this.proxy = `http://localhost:${port}`
}),
])
})
.then(() => {
ctx.lifecycleManager.setAndLoadCurrentTestingType('e2e')
})
}
if (this.server) {
@@ -450,7 +448,7 @@ describe('Routes', () => {
let i = 0
const interval = setInterval(() => {
if (ctx.lifecycleManager.eventsIpcResult.state === 'loaded') {
if (ctx.lifecycleManager.isReady) {
clearInterval(interval)
resolve()
}
@@ -33,7 +33,7 @@ describe('Web Sockets', () => {
ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(this.idsPath)
return config.get(this.idsPath, { port: cyPort })
return ctx.lifecycleManager.getFullInitialConfig({ port: cyPort })
.then((cfg) => {
this.cfg = cfg
this.ws = new ws.Server({ port: wsPort })
+15 -13
View File
@@ -58,23 +58,25 @@ describe('lib/config', () => {
context('.get', () => {
beforeEach(function () {
const ctx = getCtx()
this.ctx = getCtx()
this.projectRoot = '/_test-output/path/to/project'
ctx.lifecycleManager.setCurrentTestingType('e2e')
sinon.stub(ctx.lifecycleManager, 'verifyProjectRoot').returns(undefined)
sinon.stub(this.ctx.lifecycleManager, 'verifyProjectRoot').returns(undefined)
this.ctx.lifecycleManager.setCurrentProject(this.projectRoot)
this.ctx.lifecycleManager.setCurrentTestingType('e2e')
this.setup = (cypressJson = {}, cypressEnvJson = {}) => {
sinon.stub(ctx.lifecycleManager, 'getConfigFileContents').resolves({ ...cypressJson, e2e: cypressJson.e2e ?? { supportFile: false } })
sinon.stub(ctx.lifecycleManager, 'loadCypressEnvFile').resolves(cypressEnvJson)
sinon.stub(this.ctx.lifecycleManager._configManager, 'getConfigFileContents').resolves({ ...cypressJson, e2e: cypressJson.e2e ?? { supportFile: false } })
sinon.stub(this.ctx.lifecycleManager._configManager, 'reloadCypressEnvFile').resolves(cypressEnvJson)
}
})
it('sets projectRoot', function () {
this.setup({}, { foo: 'bar' })
return config.get(this.projectRoot)
return this.ctx.lifecycleManager.getFullInitialConfig()
.then((obj) => {
expect(obj.projectRoot).to.eq(this.projectRoot)
@@ -85,7 +87,7 @@ describe('lib/config', () => {
it('sets projectName', function () {
this.setup({}, { foo: 'bar' })
return config.get(this.projectRoot)
return this.ctx.lifecycleManager.getFullInitialConfig()
.then((obj) => {
expect(obj.projectName).to.eq('project')
})
@@ -97,7 +99,7 @@ describe('lib/config', () => {
this.setup(settings, envSettings)
return config.get(this.projectRoot)
return this.ctx.lifecycleManager.getFullInitialConfig()
.then(() => {
expect(settings).to.deep.equal({ foo: 'bar' })
expect(envSettings).to.deep.equal({ baz: 'qux' })
@@ -110,21 +112,21 @@ describe('lib/config', () => {
})
it('can override default port', function () {
return config.get(this.projectRoot, { port: 8080 })
return this.ctx.lifecycleManager.getFullInitialConfig({ port: 8080 })
.then((obj) => {
expect(obj.port).to.eq(8080)
})
})
it('updates browserUrl', function () {
return config.get(this.projectRoot, { port: 8080 })
return this.ctx.lifecycleManager.getFullInitialConfig({ port: 8080 })
.then((obj) => {
expect(obj.browserUrl).to.eq('http://localhost:8080/__/')
})
})
it('updates proxyUrl', function () {
return config.get(this.projectRoot, { port: 8080 })
return this.ctx.lifecycleManager.getFullInitialConfig({ port: 8080 })
.then((obj) => {
expect(obj.proxyUrl).to.eq('http://localhost:8080')
})
@@ -134,11 +136,11 @@ describe('lib/config', () => {
context('validation', () => {
beforeEach(function () {
this.expectValidationPasses = () => {
return config.get(this.projectRoot) // shouldn't throw
return this.ctx.lifecycleManager.getFullInitialConfig() // shouldn't throw
}
this.expectValidationFails = (errorMessage = 'validation error') => {
return config.get(this.projectRoot)
return this.ctx.lifecycleManager.getFullInitialConfig()
.then(() => {
throw new Error('should throw validation error')
}).catch((err) => {
+1 -2
View File
@@ -1,7 +1,6 @@
require('../spec_helper')
const files = require('../../lib/files')
const config = require('../../lib/config')
const FixturesHelper = require('@tooling/system-tests/lib/fixtures')
const { getCtx } = require('../../lib/makeDataContext')
@@ -16,7 +15,7 @@ describe('lib/files', () => {
ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(this.todosPath)
return config.get(this.todosPath).then((cfg) => {
return ctx.lifecycleManager.getFullInitialConfig().then((cfg) => {
this.config = cfg;
({ projectRoot: this.projectRoot } = cfg)
ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(this.projectRoot)
+2 -3
View File
@@ -2,7 +2,6 @@ require('../spec_helper')
const path = require('path')
const Promise = require('bluebird')
const config = require(`../../lib/config`)
const fixture = require(`../../lib/fixture`)
const { fs } = require(`../../lib/util/fs`)
const FixturesHelper = require('@tooling/system-tests/lib/fixtures')
@@ -28,7 +27,7 @@ describe('lib/fixture', () => {
ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(this.todosPath)
return config.get(this.todosPath)
return ctx.lifecycleManager.getFullInitialConfig()
.then((cfg) => {
({ fixturesFolder: this.fixturesFolder } = cfg)
})
@@ -181,7 +180,7 @@ Expecting 'EOF', '}', ':', ',', ']', got 'STRING'\
ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(projectPath)
return config.get(projectPath)
return ctx.lifecycleManager.getFullInitialConfig()
.then((cfg) => {
return fixture.get(cfg.fixturesFolder, 'foo')
.then((result) => {
+3 -3
View File
@@ -138,7 +138,7 @@ describe.skip('lib/project-base', () => {
const supportFile = 'foo/bar/baz'
beforeEach(function () {
sinon.stub(config, 'get').withArgs(this.todosPath, { foo: 'bar', configFile: 'cypress.config.js' })
sinon.stub(ctx.lifecycleManager, 'getFullInitialConfig').withArgs({ foo: 'bar', configFile: 'cypress.config.js' })
.resolves({ baz: 'quux', supportFile, browsers: [] })
})
@@ -176,7 +176,7 @@ describe.skip('lib/project-base', () => {
it('does not set cfg.isNewProject when cfg.isTextTerminal', function () {
const cfg = { isTextTerminal: true, browsers: [] }
config.get.resolves(cfg)
ctx.lifecycleManager.getFullInitialConfig.resolves(cfg)
sinon.stub(this.project, '_setSavedState').resolves(cfg)
@@ -193,7 +193,7 @@ describe.skip('lib/project-base', () => {
chromeWebSecurity: false,
})
config.get.restore()
ctx.lifecycleManager.getFullInitialConfig.restore()
sinon.stub(config, 'get').returns(cfg)
await this.project.initializeConfig()
@@ -7,7 +7,6 @@ const { Buffer } = require('buffer')
const dataUriToBuffer = require('data-uri-to-buffer')
const sizeOf = require('image-size')
const Fixtures = require('@tooling/system-tests/lib/fixtures')
const config = require(`../../lib/config`)
const screenshots = require(`../../lib/screenshots`)
const { fs } = require(`../../lib/util/fs`)
const plugins = require(`../../lib/plugins`)
@@ -62,7 +61,7 @@ describe('lib/screenshots', () => {
ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(this.todosPath)
return config.get(this.todosPath)
return ctx.lifecycleManager.getFullInitialConfig()
.then((config1) => {
this.config = config1
})
+1 -2
View File
@@ -7,7 +7,6 @@ const socketIo = require('@packages/socket/lib/browser')
const httpsAgent = require('https-proxy-agent')
const errors = require(`../../lib/errors`)
const config = require(`../../lib/config`)
const { SocketE2E } = require(`../../lib/socket-e2e`)
const { ServerE2E } = require(`../../lib/server-e2e`)
const { Automation } = require(`../../lib/automation`)
@@ -38,7 +37,7 @@ describe('lib/socket', () => {
ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(this.todosPath)
return config.get(this.todosPath)
return ctx.lifecycleManager.getFullInitialConfig()
.then((cfg) => {
this.cfg = cfg
})
@@ -0,0 +1,24 @@
const { devServer } = require('@cypress/webpack-dev-server')
module.exports = {
'projectId': 'abcdef42',
e2e: {
specPattern: 'cypress/e2e/**/*',
},
component: {
specPattern: 'cypress/component-tests/**/*',
devServer,
devServerConfig: {
webpackConfig: {
output: {
publicPath: '/',
},
},
},
setupNodeEvents (on, config) {
require('@cypress/code-coverage/task')(on, config)
return config
},
},
}
@@ -1,6 +1,7 @@
module.exports = {
'cypress': {
'integrationFolder': 'my-tests',
'fileServerFolder': 'dev',
e2e: {
specPattern: 'my-tests/**/*',
supportFile: 'helpers/includes.js',
},
fileServerFolder: 'dev',
}