mirror of
https://github.com/cypress-io/cypress.git
synced 2026-02-15 03:31:18 -06:00
514 lines
12 KiB
TypeScript
514 lines
12 KiB
TypeScript
import type { AllModeOptions } from '@packages/types'
|
|
import fsExtra from 'fs-extra'
|
|
import path from 'path'
|
|
import util from 'util'
|
|
import chalk from 'chalk'
|
|
import assert from 'assert'
|
|
import str from 'underscore.string'
|
|
import _ from 'lodash'
|
|
|
|
import 'server-destroy'
|
|
|
|
import { AppApiShape, CohortsApiShape, DataEmitterActions, LocalSettingsApiShape, ProjectApiShape } from './actions'
|
|
import type { NexusGenAbstractTypeMembers } from '@packages/graphql/src/gen/nxs.gen'
|
|
import type { AuthApiShape } from './actions/AuthActions'
|
|
import type { ElectronApiShape } from './actions/ElectronActions'
|
|
import debugLib from 'debug'
|
|
import { CoreDataShape, makeCoreData } from './data/coreDataShape'
|
|
import { DataActions } from './DataActions'
|
|
import {
|
|
FileDataSource,
|
|
ProjectDataSource,
|
|
WizardDataSource,
|
|
BrowserDataSource,
|
|
CloudDataSource,
|
|
EnvDataSource,
|
|
HtmlDataSource,
|
|
UtilDataSource,
|
|
BrowserApiShape,
|
|
MigrationDataSource,
|
|
} from './sources/'
|
|
import { cached } from './util/cached'
|
|
import type { GraphQLSchema, OperationTypeNode, DocumentNode } from 'graphql'
|
|
import type { IncomingHttpHeaders, Server } from 'http'
|
|
import type { AddressInfo } from 'net'
|
|
import type { App as ElectronApp } from 'electron'
|
|
import { VersionsDataSource } from './sources/VersionsDataSource'
|
|
import type { SocketIONamespace, SocketIOServer } from '@packages/socket'
|
|
import { globalPubSub } from '.'
|
|
import { ProjectLifecycleManager } from './data/ProjectLifecycleManager'
|
|
import type { CypressError } from '@packages/errors'
|
|
import { ErrorDataSource } from './sources/ErrorDataSource'
|
|
import { GraphQLDataSource } from './sources/GraphQLDataSource'
|
|
import { RemoteRequestDataSource } from './sources/RemoteRequestDataSource'
|
|
import { resetIssuedWarnings } from '@packages/config'
|
|
import { RemotePollingDataSource } from './sources/RemotePollingDataSource'
|
|
|
|
const IS_DEV_ENV = process.env.CYPRESS_INTERNAL_ENV !== 'production'
|
|
|
|
export type Updater = (proj: CoreDataShape) => void | undefined | CoreDataShape
|
|
|
|
export type CurrentProjectUpdater = (proj: Exclude<CoreDataShape['currentProject'], null | undefined>) => void | undefined | CoreDataShape['currentProject']
|
|
|
|
export interface InternalDataContextOptions {
|
|
loadCachedProjects: boolean
|
|
}
|
|
|
|
export interface DataContextConfig {
|
|
schema: GraphQLSchema
|
|
schemaCloud: GraphQLSchema
|
|
mode: 'run' | 'open'
|
|
modeOptions: Partial<AllModeOptions>
|
|
electronApp?: ElectronApp
|
|
coreData?: CoreDataShape
|
|
/**
|
|
* Injected from the server
|
|
*/
|
|
appApi: AppApiShape
|
|
localSettingsApi: LocalSettingsApiShape
|
|
authApi: AuthApiShape
|
|
projectApi: ProjectApiShape
|
|
electronApi: ElectronApiShape
|
|
browserApi: BrowserApiShape
|
|
cohortsApi: CohortsApiShape
|
|
}
|
|
|
|
export interface GraphQLRequestInfo {
|
|
app: 'app' | 'launchpad'
|
|
operationName: string | null
|
|
document: DocumentNode
|
|
operation: OperationTypeNode
|
|
variables: Record<string, any> | null
|
|
headers: IncomingHttpHeaders
|
|
}
|
|
|
|
export class DataContext {
|
|
readonly graphqlRequestInfo?: GraphQLRequestInfo
|
|
private _config: Omit<DataContextConfig, 'modeOptions'>
|
|
private _modeOptions: Readonly<Partial<AllModeOptions>>
|
|
private _coreData: CoreDataShape
|
|
readonly lifecycleManager: ProjectLifecycleManager
|
|
|
|
constructor (_config: DataContextConfig) {
|
|
const { modeOptions, ...rest } = _config
|
|
|
|
this._config = rest
|
|
this._modeOptions = modeOptions ?? {} // {} For legacy tests
|
|
this._coreData = _config.coreData ?? makeCoreData(this._modeOptions)
|
|
this.lifecycleManager = new ProjectLifecycleManager(this)
|
|
}
|
|
|
|
get schema () {
|
|
return this._config.schema
|
|
}
|
|
|
|
get schemaCloud () {
|
|
return this._config.schemaCloud
|
|
}
|
|
|
|
get isRunMode () {
|
|
return this._config.mode === 'run'
|
|
}
|
|
|
|
@cached
|
|
get graphql () {
|
|
return new GraphQLDataSource()
|
|
}
|
|
|
|
@cached
|
|
get remoteRequest () {
|
|
return new RemoteRequestDataSource()
|
|
}
|
|
|
|
get electronApp () {
|
|
return this._config.electronApp
|
|
}
|
|
|
|
get electronApi () {
|
|
return this._config.electronApi
|
|
}
|
|
|
|
get localSettingsApi () {
|
|
return this._config.localSettingsApi
|
|
}
|
|
|
|
get cohortsApi () {
|
|
return this._config.cohortsApi
|
|
}
|
|
|
|
get isGlobalMode () {
|
|
return this.appData.isGlobalMode
|
|
}
|
|
|
|
get modeOptions () {
|
|
return this._modeOptions
|
|
}
|
|
|
|
get coreData () {
|
|
return this._coreData
|
|
}
|
|
|
|
get user () {
|
|
return this.coreData.user
|
|
}
|
|
|
|
get browserList () {
|
|
return this.coreData.app.browsers
|
|
}
|
|
|
|
get nodePath () {
|
|
return this.coreData.app.nodePath
|
|
}
|
|
|
|
get baseError () {
|
|
return this.coreData.diagnostics.error
|
|
}
|
|
|
|
get warnings () {
|
|
return this.coreData.diagnostics.warnings
|
|
}
|
|
|
|
@cached
|
|
get file () {
|
|
return new FileDataSource(this)
|
|
}
|
|
|
|
@cached
|
|
get versions () {
|
|
return new VersionsDataSource(this)
|
|
}
|
|
|
|
@cached
|
|
get browser () {
|
|
return new BrowserDataSource(this)
|
|
}
|
|
|
|
/**
|
|
* All mutations (update / delete / create), fs writes, etc.
|
|
* should run through this namespace. Everything else should be a "getter"
|
|
*/
|
|
@cached
|
|
get actions () {
|
|
return new DataActions(this)
|
|
}
|
|
|
|
get appData () {
|
|
return this.coreData.app
|
|
}
|
|
|
|
@cached
|
|
get wizard () {
|
|
return new WizardDataSource(this)
|
|
}
|
|
|
|
get wizardData () {
|
|
return this.coreData.wizard
|
|
}
|
|
|
|
get currentProject () {
|
|
return this.coreData.currentProject
|
|
}
|
|
|
|
@cached
|
|
get project () {
|
|
return new ProjectDataSource(this)
|
|
}
|
|
|
|
@cached
|
|
get remotePolling () {
|
|
return new RemotePollingDataSource(this)
|
|
}
|
|
|
|
@cached
|
|
get cloud () {
|
|
return new CloudDataSource({
|
|
fetch: (...args) => this.util.fetch(...args),
|
|
getUser: () => this.user,
|
|
logout: () => this.actions.auth.logout().catch(this.logTraceError),
|
|
invalidateClientUrqlCache: () => this.graphql.invalidateClientUrqlCache(this),
|
|
})
|
|
}
|
|
|
|
@cached
|
|
get env () {
|
|
return new EnvDataSource(this)
|
|
}
|
|
|
|
@cached
|
|
get emitter () {
|
|
return new DataEmitterActions(this)
|
|
}
|
|
|
|
@cached
|
|
get html () {
|
|
return new HtmlDataSource(this)
|
|
}
|
|
|
|
@cached
|
|
get error () {
|
|
return new ErrorDataSource(this)
|
|
}
|
|
|
|
@cached
|
|
get util () {
|
|
return new UtilDataSource(this)
|
|
}
|
|
|
|
@cached
|
|
get migration () {
|
|
return new MigrationDataSource(this)
|
|
}
|
|
|
|
get projectsList () {
|
|
return this.coreData.app.projects
|
|
}
|
|
|
|
// Servers
|
|
|
|
setAppServerPort (port: number | undefined) {
|
|
this.update((d) => {
|
|
d.servers.appServerPort = port ?? null
|
|
})
|
|
}
|
|
|
|
setAppSocketServer (socketServer: SocketIOServer | undefined) {
|
|
this.update((d) => {
|
|
d.servers.appSocketServer?.disconnectSockets(true)
|
|
d.servers.appSocketNamespace?.disconnectSockets(true)
|
|
d.servers.appSocketServer = socketServer
|
|
d.servers.appSocketNamespace = socketServer?.of('/data-context')
|
|
})
|
|
}
|
|
|
|
setGqlServer (srv: Server) {
|
|
this.update((d) => {
|
|
d.servers.gqlServer = srv
|
|
d.servers.gqlServerPort = (srv.address() as AddressInfo).port
|
|
})
|
|
}
|
|
|
|
setGqlSocketServer (socketServer: SocketIONamespace | undefined) {
|
|
this.update((d) => {
|
|
d.servers.gqlSocketServer?.disconnectSockets(true)
|
|
d.servers.gqlSocketServer = socketServer
|
|
})
|
|
}
|
|
|
|
/**
|
|
* This will be replaced with Immer, for immutable state updates.
|
|
*/
|
|
update = (updater: Updater): void => {
|
|
updater(this._coreData)
|
|
}
|
|
|
|
get appServerPort () {
|
|
return this.coreData.servers.appServerPort
|
|
}
|
|
|
|
get gqlServerPort () {
|
|
return this.coreData.servers.gqlServerPort
|
|
}
|
|
|
|
// Utilities
|
|
|
|
@cached
|
|
get fs () {
|
|
return fsExtra
|
|
}
|
|
|
|
@cached
|
|
get path () {
|
|
return path
|
|
}
|
|
|
|
get _apis () {
|
|
return {
|
|
appApi: this._config.appApi,
|
|
authApi: this._config.authApi,
|
|
browserApi: this._config.browserApi,
|
|
projectApi: this._config.projectApi,
|
|
electronApi: this._config.electronApi,
|
|
localSettingsApi: this._config.localSettingsApi,
|
|
cohortsApi: this._config.cohortsApi,
|
|
}
|
|
}
|
|
|
|
makeId<T extends NexusGenAbstractTypeMembers['Node']> (typeName: T, nodeString: string) {
|
|
return Buffer.from(`${typeName}:${nodeString}`).toString('base64')
|
|
}
|
|
|
|
// TODO(tim): type check
|
|
fromId (str: string, accepted: NexusGenAbstractTypeMembers['Node']): string {
|
|
const result = Buffer.from(str, 'base64').toString('utf-8')
|
|
|
|
const [type, val] = result.split(':') as [string, string]
|
|
|
|
if (type !== accepted) {
|
|
throw new Error(`Expecting node with type ${accepted} saw ${type}`)
|
|
}
|
|
|
|
return val
|
|
}
|
|
|
|
debug = debugLib('cypress:data-context')
|
|
|
|
private _debugCache: Record<string, debug.Debugger> = {}
|
|
|
|
debugNs = (ns: string, evt: string, ...args: any[]) => {
|
|
const _debug = this._debugCache[ns] ??= debugLib(`cypress:data-context:${ns}`)
|
|
|
|
_debug(evt, ...args)
|
|
}
|
|
|
|
logTraceError (e: unknown) {
|
|
// TODO(tim): handle this consistently
|
|
// eslint-disable-next-line no-console
|
|
console.error(e)
|
|
}
|
|
|
|
onError = (cypressError: CypressError, title: string = 'Unexpected Error') => {
|
|
if (this.isRunMode) {
|
|
if (this.lifecycleManager?.runModeExitEarly) {
|
|
this.lifecycleManager.runModeExitEarly(cypressError)
|
|
} else {
|
|
throw cypressError
|
|
}
|
|
} else {
|
|
const err = {
|
|
id: _.uniqueId('Error'),
|
|
title,
|
|
cypressError,
|
|
}
|
|
|
|
this.update((d) => {
|
|
if (d.diagnostics) {
|
|
d.diagnostics.error = err
|
|
}
|
|
})
|
|
|
|
this.emitter.errorWarningChange()
|
|
}
|
|
}
|
|
|
|
onWarning = (err: CypressError) => {
|
|
if (this.isRunMode) {
|
|
// eslint-disable-next-line
|
|
console.log(chalk.yellow(err.message))
|
|
} else {
|
|
const warning = {
|
|
id: _.uniqueId('Warning'),
|
|
title: `Warning: ${str.titleize(str.humanize(err.type ?? ''))}`,
|
|
cypressError: err,
|
|
}
|
|
|
|
this.update((d) => {
|
|
d.diagnostics.warnings.push(warning)
|
|
})
|
|
|
|
this.emitter.errorWarningChange()
|
|
}
|
|
}
|
|
|
|
async destroy () {
|
|
let destroyGqlServer = () => Promise.resolve()
|
|
|
|
if (this.coreData.servers.gqlServer?.destroy) {
|
|
destroyGqlServer = util.promisify(this.coreData.servers.gqlServer.destroy)
|
|
}
|
|
|
|
return Promise.all([
|
|
destroyGqlServer(),
|
|
this._reset(),
|
|
])
|
|
}
|
|
|
|
/**
|
|
* Resets all of the state for the data context,
|
|
* so we can initialize fresh for each E2E test
|
|
*/
|
|
async reinitializeCypress (modeOptions: Partial<AllModeOptions> = {}) {
|
|
await this._reset()
|
|
|
|
this._modeOptions = modeOptions
|
|
this._coreData = makeCoreData(modeOptions)
|
|
// @ts-expect-error - we've already cleaned up, this is for testing only
|
|
this.lifecycleManager = new ProjectLifecycleManager(this)
|
|
|
|
globalPubSub.emit('reset:data-context', this)
|
|
}
|
|
|
|
_reset () {
|
|
this.setAppSocketServer(undefined)
|
|
this.setGqlSocketServer(undefined)
|
|
|
|
resetIssuedWarnings()
|
|
|
|
return Promise.all([
|
|
this.lifecycleManager.destroy(),
|
|
this.cloud.reset(),
|
|
this.actions.project.clearCurrentProject(),
|
|
this.actions.dev.dispose(),
|
|
])
|
|
}
|
|
|
|
async initializeMode () {
|
|
assert(!this.coreData.hasInitializedMode)
|
|
this.coreData.hasInitializedMode = this._config.mode
|
|
if (this._config.mode === 'run') {
|
|
await this.lifecycleManager.initializeRunMode(this.coreData.currentTestingType)
|
|
} else if (this._config.mode === 'open') {
|
|
await this.initializeOpenMode()
|
|
await this.lifecycleManager.initializeOpenMode(this.coreData.currentTestingType)
|
|
} else {
|
|
throw new Error(`Missing DataContext config "mode" setting, expected run | open`)
|
|
}
|
|
}
|
|
|
|
private async initializeOpenMode () {
|
|
if (IS_DEV_ENV && !process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF) {
|
|
this.actions.dev.watchForRelaunch()
|
|
}
|
|
|
|
// We want to fetch the user immediately, but we don't need to block the UI on this
|
|
this.actions.auth.getUser().catch((e) => {
|
|
// This error should never happen, since it's internally handled by getUser
|
|
// Log anyway, just incase
|
|
this.logTraceError(e)
|
|
})
|
|
|
|
const toAwait: Promise<any>[] = [
|
|
this.actions.localSettings.refreshLocalSettings(),
|
|
]
|
|
|
|
// load projects from cache on start
|
|
toAwait.push(this.actions.project.loadProjects())
|
|
|
|
await Promise.all(toAwait)
|
|
}
|
|
|
|
static #activeRequestCount = 0
|
|
static #awaitingEmptyRequestCount: Function[] = []
|
|
|
|
static addActiveRequest () {
|
|
this.#activeRequestCount++
|
|
}
|
|
|
|
static finishActiveRequest () {
|
|
this.#activeRequestCount--
|
|
if (this.#activeRequestCount === 0) {
|
|
this.#awaitingEmptyRequestCount.forEach((fn) => fn())
|
|
this.#awaitingEmptyRequestCount = []
|
|
}
|
|
}
|
|
|
|
static async waitForActiveRequestsToFlush () {
|
|
if (this.#activeRequestCount === 0) {
|
|
return
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
this.#awaitingEmptyRequestCount.push(resolve)
|
|
})
|
|
}
|
|
}
|