Files
cypress/packages/server/lib/project-base.ts

797 lines
24 KiB
TypeScript

import check from 'check-more-types'
import Debug from 'debug'
import EE from 'events'
import _ from 'lodash'
import path from 'path'
import pkg from '@packages/root'
import { Automation } from './automation'
import browsers from './browsers'
import * as config from './config'
import * as errors from './errors'
import preprocessor from './plugins/preprocessor'
import runEvents from './plugins/run_events'
import Reporter from './reporter'
import * as savedState from './saved_state'
import { SocketCt } from './socket-ct'
import { SocketE2E } from './socket-e2e'
import { ensureProp } from './util/class-helpers'
import system from './util/system'
import { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ProtocolManagerShape, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording, AutomationCommands, StudioMetricsTypes } from '@packages/types'
import { DataContext, getCtx } from '@packages/data-context'
import { createHmac } from 'crypto'
import { ServerBase } from './server-base'
import type Protocol from 'devtools-protocol'
import type { ServiceWorkerClientEvent } from '@packages/proxy/lib/http/util/service-worker-manager'
import { v4 } from 'uuid'
import { StudioLifecycleManager } from './cloud/studio/StudioLifecycleManager'
import { telemetryManager } from './cloud/studio/telemetry/TelemetryManager'
import { INITIALIZATION_MARK_NAMES, INITIALIZATION_TELEMETRY_GROUP_NAMES } from './cloud/studio/telemetry/constants/initialization'
import { TelemetryReporter } from './cloud/studio/telemetry/TelemetryReporter'
import type { StudioInitOptions } from './types/studio'
export interface Cfg extends ReceivedCypressOptions {
projectId?: string
projectRoot: string
proxyServer?: Cypress.RuntimeConfigOptions['proxyUrl']
fileServerFolder?: Cypress.ResolvedConfigOptions['fileServerFolder']
testingType: TestingType
isDefaultProtocolEnabled?: boolean
hideCommandLog?: boolean
hideRunnerUi?: boolean
exit?: boolean
state?: {
firstOpened?: number | null
lastOpened?: number | null
promptsShown?: object | null
banners?: BannersState | null
}
e2e: Partial<Cfg>
component: Partial<Cfg>
additionalIgnorePattern?: string | string[]
resolved: ResolvedConfigurationOptions
}
const localCwd = process.cwd()
const debug = Debug('cypress:server:project')
const debugVerbose = Debug('cypress-verbose:server:project')
type StartWebsocketOptions = Pick<Cfg, 'socketIoCookie' | 'namespace' | 'screenshotsFolder' | 'report' | 'reporter' | 'reporterOptions' | 'projectRoot'>
export class ProjectBase extends EE {
// id is sha256 of projectRoot
public id: string
protected ctx: DataContext
protected _cfg?: Cfg
protected _server?: ServerBase<any>
protected _automation?: Automation
private _protocolManager?: ProtocolManagerShape
private _recordTests?: any = null
private _isServerOpen: boolean = false
private _isStudioInitialized: boolean = false
public videoRecording?: VideoRecording
public browser: any
public options: OpenProjectLaunchOptions
public testingType: Cypress.TestingType
public spec: FoundSpec | null
public isOpen: boolean = false
projectRoot: string
constructor ({
projectRoot,
testingType,
options = {},
}: {
projectRoot: string
testingType: Cypress.TestingType
options: OpenProjectLaunchOptions
}) {
super()
if (!projectRoot) {
throw new Error('Instantiating lib/project requires a projectRoot!')
}
if (!check.unemptyString(projectRoot)) {
throw new Error(`Expected project root path, not ${projectRoot}`)
}
this.testingType = testingType
this.projectRoot = path.resolve(projectRoot)
this.spec = null
this.browser = null
this.id = createHmac('sha256', 'secret-key').update(projectRoot).digest('hex')
this.ctx = getCtx()
debug('Project created %o', {
testingType: this.testingType,
projectRoot: this.projectRoot,
})
this.options = {
report: false,
onFocusTests () {},
onError (error) {
errors.log(error)
},
onWarning: this.ctx.onWarning,
...options,
}
}
protected ensureProp = ensureProp
setOnTestsReceived (fn) {
this._recordTests = fn
}
get server () {
return this.ensureProp(this._server, 'open')
}
get automation () {
return this.ensureProp(this._automation, 'open')
}
get cfg () {
return this._cfg!
}
get state () {
return this.cfg.state
}
get remoteStates () {
return this._server?.remoteStates
}
async open () {
debug('opening project instance %s', this.projectRoot)
debug('project open options %o', this.options)
const cfg = this.getConfig()
process.chdir(this.projectRoot)
this._server = new ServerBase(cfg)
if (!cfg.isTextTerminal && cfg.resolved.experimentalStudio?.value) {
const studioLifecycleManager = new StudioLifecycleManager()
studioLifecycleManager.initializeStudioManager({
projectId: cfg.projectId,
cloudDataSource: this.ctx.cloud,
cfg,
debugData: this.configDebugData,
ctx: this.ctx,
})
}
const [port, warning] = await this._server.open(cfg, {
getCurrentBrowser: () => this.browser,
getSpec: () => this.spec,
onError: this.options.onError,
onWarning: this.options.onWarning,
shouldCorrelatePreRequests: this.shouldCorrelatePreRequests,
testingType: this.testingType,
SocketCtor: this.testingType === 'e2e' ? SocketE2E : SocketCt,
})
this.ctx.actions.servers.setAppServerPort(port)
this._isServerOpen = true
// if we didnt have a cfg.port
// then get the port once we
// open the server
if (!cfg.port) {
cfg.port = port
// and set all the urls again
_.extend(cfg, config.setUrls(cfg))
}
cfg.proxyServer = cfg.proxyUrl
// store the cfg from
// opening the server
this._cfg = cfg
debug('project config: %o', _.omit(cfg, 'resolved'))
if (warning) {
this.options.onWarning(warning)
}
// save the last time they opened the project
// along with the first time they opened it
const now = Date.now()
const stateToSave = {
lastOpened: now,
lastProjectId: cfg.projectId ?? null,
} as any
if (!cfg.state || !cfg.state.firstOpened) {
stateToSave.firstOpened = now
}
this.startWebsockets({
onReloadBrowser: this.options.onReloadBrowser,
onFocusTests: this.options.onFocusTests,
onSpecChanged: this.options.onSpecChanged,
}, {
socketIoCookie: cfg.socketIoCookie,
namespace: cfg.namespace,
screenshotsFolder: cfg.screenshotsFolder,
report: cfg.report,
reporter: cfg.reporter,
reporterOptions: cfg.reporterOptions,
projectRoot: this.projectRoot,
})
await this.saveState(stateToSave)
if (cfg.isTextTerminal) {
return
}
if (!cfg.experimentalInteractiveRunEvents) {
return
}
const sys = await system.info()
const beforeRunDetails = {
config: cfg,
cypressVersion: pkg.version,
system: _.pick(sys, 'osName', 'osVersion'),
}
this.isOpen = true
return runEvents.execute('before:run', beforeRunDetails)
}
reset () {
debug('resetting project instance %s', this.projectRoot)
// if we're in studio mode, we need to close the protocol manager
// to ensure the config is initialized properly on browser relaunch
if (this.ctx.coreData.studioLifecycleManager) {
this.protocolManager?.close()
this.protocolManager = undefined
}
this.spec = null
this.browser = null
if (this._automation) {
this._automation.reset()
}
if (this._server) {
return this._server.reset()
}
return
}
__reset () {
preprocessor.close()
process.chdir(localCwd)
}
async close () {
debug('closing project instance %s', this.projectRoot)
this.spec = null
this.browser = null
if (!this._isServerOpen) {
return
}
this.__reset()
this.ctx.actions.servers.setAppServerPort(undefined)
this.ctx.actions.servers.setAppSocketServer(undefined)
await Promise.all([
this.server?.close(),
])
this._isServerOpen = false
this.isOpen = false
const config = this.getConfig()
if (config.isTextTerminal || !config.experimentalInteractiveRunEvents) return
return runEvents.execute('after:run')
}
initializeReporter ({
report,
reporter,
projectRoot,
reporterOptions,
}: Pick<Cfg, 'report' | 'reporter' | 'projectRoot' | 'reporterOptions'>) {
if (!report) {
return
}
try {
Reporter.loadReporter(reporter, projectRoot)
} catch (error: any) {
const paths = Reporter.getSearchPathsForReporter(reporter, projectRoot)
errors.throwErr('INVALID_REPORTER_NAME', {
paths,
error,
name: reporter,
})
}
return Reporter.create(reporter, reporterOptions, projectRoot)
}
startWebsockets (options: Omit<OpenProjectLaunchOptions, 'args'>, { socketIoCookie, namespace, screenshotsFolder, report, reporter, reporterOptions, projectRoot }: StartWebsocketOptions) {
// if we've passed down reporter
// then record these via mocha reporter
const reporterInstance = this.initializeReporter({
report,
reporter,
reporterOptions,
projectRoot,
})
const onBrowserPreRequest = async (browserPreRequest) => {
await this.server.addBrowserPreRequest(browserPreRequest)
}
const onRequestEvent = <T extends keyof AutomationCommands>(eventName: T, data: AutomationCommands[T]['dataType']): Promise<AutomationCommands[T]['returnType']> => {
this.server.emitRequestEvent(eventName, data)
return Promise.resolve()
}
const onRemoveBrowserPreRequest = (requestId: string) => {
this.server.removeBrowserPreRequest(requestId)
}
const onDownloadLinkClicked = (downloadUrl: string) => {
this.server.addPendingUrlWithoutPreRequest(downloadUrl)
}
const onServiceWorkerRegistrationUpdated = (data: Protocol.ServiceWorker.WorkerRegistrationUpdatedEvent) => {
this.server.updateServiceWorkerRegistrations(data)
}
const onServiceWorkerVersionUpdated = (data: Protocol.ServiceWorker.WorkerVersionUpdatedEvent) => {
this.server.updateServiceWorkerVersions(data)
}
const onServiceWorkerClientSideRegistrationUpdated = (data: { scriptURL: string, initiatorOrigin: string }) => {
this.server.updateServiceWorkerClientSideRegistrations(data)
}
const onServiceWorkerClientEvent = (event: ServiceWorkerClientEvent) => {
this.server.handleServiceWorkerClientEvent(event)
}
this._automation = new Automation({
cyNamespace: namespace,
cookieNamespace: socketIoCookie,
screenshotsFolder,
onBrowserPreRequest,
onRequestEvent,
onRemoveBrowserPreRequest,
onDownloadLinkClicked,
onServiceWorkerRegistrationUpdated,
onServiceWorkerVersionUpdated,
onServiceWorkerClientSideRegistrationUpdated,
onServiceWorkerClientEvent,
})
const destroyStudio = async () => {
if (!this._isStudioInitialized) {
debug('Studio is not initialized - skipping destroy')
return
}
const isStudioReady = await this.ctx.coreData.studioLifecycleManager?.isStudioReady()
if (!isStudioReady) {
debug('Studio is not ready - skipping destroy')
return
}
const studio = await this.ctx.coreData.studioLifecycleManager?.getStudio()
await studio?.destroy()
if (this.protocolManager) {
debug('Closing protocol connection')
await browsers.closeProtocolConnection({ browser: this.browser, foundBrowsers: this.options.browsers })
debug('Protocol connection closed')
this.protocolManager?.close()
this.protocolManager = undefined
}
debug('Studio destroyed')
this._isStudioInitialized = false
}
const ios = this.server.startWebsockets(this.automation, this.cfg, {
onReloadBrowser: options.onReloadBrowser,
onFocusTests: options.onFocusTests,
onSpecChanged: options.onSpecChanged,
getSavedState: this.getSavedState.bind(this),
onSavedStateChanged: this.saveState.bind(this),
closeExtraTargets: this.closeExtraTargets,
/**
* Called any time the test is run in Studio
*/
onStudioInit: async ({ sessionId }: StudioInitOptions = {}) => {
// if already initialized, tear down studio first
if (this._isStudioInitialized) {
debug('Studio is already initialized - destroying studio')
await destroyStudio()
}
debug('Initializing studio')
telemetryManager.mark(INITIALIZATION_MARK_NAMES.INITIALIZATION_START)
const endTelemetry = ({ status, canAccessStudioAI }: { status: string, canAccessStudioAI: boolean }) => {
telemetryManager.mark(INITIALIZATION_MARK_NAMES.INITIALIZATION_END)
TelemetryReporter.getInstance().reportTelemetry(INITIALIZATION_TELEMETRY_GROUP_NAMES.INITIALIZE_STUDIO, {
status,
canAccessStudioAI,
})
}
// Only need a new session if Studio is being entered from a non-opened
// state. If the test is being re-run, we use the existing session
const cloudStudioSessionId = sessionId ?? v4()
try {
const isStudioReady = this.ctx.coreData.studioLifecycleManager?.isStudioReady()
if (!isStudioReady) {
debug('User entered studio mode before cloud studio was initialized')
endTelemetry({ status: 'studio-not-ready', canAccessStudioAI: false })
return { canAccessStudioAI: false, cloudStudioSessionId }
}
const studio = await this.ctx.coreData.studioLifecycleManager?.getStudio()
// only capture studio started event if the user is accessing legacy studio
if (!this.ctx.coreData.studioLifecycleManager?.cloudStudioRequested) {
try {
studio?.captureStudioEvent({
type: StudioMetricsTypes.STUDIO_STARTED,
machineId: await this.ctx.coreData.machineId ?? '',
projectId: this.cfg.projectId,
browser: this.browser ? {
name: this.browser.name,
family: this.browser.family,
channel: this.browser.channel,
version: this.browser.version,
} : undefined,
cypressVersion: pkg.version,
})
} catch (error) {
debug('Error capturing studio event:', error)
}
}
if (this.spec && studio?.protocolManager) {
telemetryManager.mark(INITIALIZATION_MARK_NAMES.CAN_ACCESS_STUDIO_AI_START)
const canAccessStudioAI = await studio?.canAccessStudioAI(this.browser) ?? false
telemetryManager.mark(INITIALIZATION_MARK_NAMES.CAN_ACCESS_STUDIO_AI_END)
if (!canAccessStudioAI) {
endTelemetry({ status: 'success', canAccessStudioAI })
return { canAccessStudioAI, cloudStudioSessionId }
}
this.protocolManager = studio.protocolManager
this.protocolManager.setupProtocol()
this.protocolManager.beforeSpec({
...this.spec,
instanceId: v4(),
})
telemetryManager.mark(INITIALIZATION_MARK_NAMES.CONNECT_PROTOCOL_TO_BROWSER_START)
await browsers.connectProtocolToBrowser({ browser: this.browser, foundBrowsers: this.options.browsers, protocolManager: studio.protocolManager })
telemetryManager.mark(INITIALIZATION_MARK_NAMES.CONNECT_PROTOCOL_TO_BROWSER_END)
if (!studio.protocolManager.dbPath) {
debug('Protocol database path is not set after initializing protocol manager')
endTelemetry({ status: 'protocol-db-path-not-set', canAccessStudioAI: false })
return { canAccessStudioAI: false, cloudStudioSessionId }
}
telemetryManager.mark(INITIALIZATION_MARK_NAMES.INITIALIZE_STUDIO_AI_START)
await Promise.all([
studio.initializeStudioAI({
protocolDbPath: studio.protocolManager.dbPath,
}),
// Reset browser state on initialization to avoid issues
// with cached assets from previous test executions.
this.resetBrowserState(),
])
telemetryManager.mark(INITIALIZATION_MARK_NAMES.INITIALIZE_STUDIO_AI_END)
endTelemetry({ status: 'success', canAccessStudioAI: true })
this._isStudioInitialized = true
debug('Studio successfully initialized')
return { canAccessStudioAI: true, cloudStudioSessionId }
}
this.protocolManager = undefined
debug('Studio not initialized - has spec: %o, has protocol manager: %o', !!this.spec, !!this.protocolManager)
endTelemetry({ status: 'success', canAccessStudioAI: false })
return { canAccessStudioAI: false, cloudStudioSessionId }
} catch (error) {
debug('Error initializing studio', error)
endTelemetry({ status: 'exception', canAccessStudioAI: false })
return { canAccessStudioAI: false, cloudStudioSessionId }
}
},
onStudioDestroy: destroyStudio,
onCaptureVideoFrames: (data: any) => {
// TODO: move this to browser automation middleware
this.emit('capture:video:frames', data)
},
onConnect: (id: string) => {
debug('socket:connected')
this.emit('socket:connected', id)
},
onTestsReceivedAndMaybeRecord: async (runnables: unknown[], cb: () => void) => {
debug('received runnables %o', runnables)
if (reporterInstance) {
reporterInstance.setRunnables(runnables, this.getConfig())
}
if (this._recordTests) {
this._protocolManager?.addRunnables(runnables)
await this._recordTests?.(runnables, cb)
this._recordTests = null
return
}
cb()
},
onMocha: async (event, runnable) => {
// bail if we dont have a
// reporter instance
if (!reporterInstance) {
return
}
reporterInstance.emit(event, runnable)
if (event === 'test:before:run') {
debugVerbose('browserPreRequests prior to running %s: %O', runnable.title, this.server.getBrowserPreRequests())
this.emit('test:before:run', {
runnable,
previousResults: reporterInstance?.results() || {},
})
} else if (event === 'end') {
debugVerbose('browserPreRequests at the end: %O', this.server.getBrowserPreRequests())
const [stats = {}] = await Promise.all([
(reporterInstance != null ? reporterInstance.end() : undefined),
this.server.end(),
])
this.emit('end', stats)
}
return
},
})
this.ctx.actions.servers.setAppSocketServer(ios)
}
async resetBrowserTabsForNextSpec (shouldKeepTabOpen: boolean) {
return this.server.socket.resetBrowserTabsForNextSpec(shouldKeepTabOpen)
}
async resetBrowserState () {
return this.server.socket.resetBrowserState()
}
closeExtraTargets () {
return browsers.closeExtraTargets()
}
isRunnerSocketConnected () {
return this.server.socket.isRunnerSocketConnected()
}
async sendFocusBrowserMessage () {
if (this.browser.family === 'firefox') {
await browsers.setFocus()
} else {
await this.server.sendFocusBrowserMessage()
}
}
shouldCorrelatePreRequests = () => {
return !!this.browser
}
setCurrentSpecAndBrowser (spec, browser: FoundBrowser) {
this.spec = spec
this.browser = browser
if (this.browser.family !== 'chromium') {
// If we're not in chromium, our strategy for correlating service worker prerequests doesn't work in non-chromium browsers (https://github.com/cypress-io/cypress/issues/28079)
// in order to not hang for 2 seconds, we override the prerequest timeout to be 500 ms (which is what it has been historically).
this._server?.setPreRequestTimeout(500)
}
}
get protocolManager (): ProtocolManagerShape | undefined {
return this._protocolManager
}
set protocolManager (protocolManager: ProtocolManagerShape | undefined) {
this._protocolManager = protocolManager
this._server?.setProtocolManager(protocolManager)
}
getAutomation () {
return this.automation
}
async initializeConfig (): Promise<Cfg> {
this.ctx.lifecycleManager.setAndLoadCurrentTestingType(this.testingType)
let theCfg: Cfg = {
...(await this.ctx.lifecycleManager.getFullInitialConfig()),
testingType: this.testingType,
} as Cfg // ?? types are definitely wrong here I think
if (theCfg.isTextTerminal) {
this._cfg = theCfg
return this._cfg
}
const cfgWithSaved = await this._setSavedState(theCfg)
this._cfg = cfgWithSaved
return this._cfg
}
// returns project config (user settings + defaults + cypress.config.{js,ts,mjs,cjs})
// with additional object "state" which are transient things like
// window width and height, DevTools open or not, etc.
getConfig (): Cfg {
if (!this._cfg) {
throw Error('Must call #initializeConfig before accessing config.')
}
debug('project has config %o', this._cfg)
const isDefaultProtocolEnabled = this._protocolManager?.isProtocolEnabled ?? false
const hideRunnerUi = (
this.options?.args?.runnerUi === false ||
(isDefaultProtocolEnabled && this._cfg.isTextTerminal && !this.options?.args?.runnerUi)
)
// hide the command log if explicitly requested or if we are hiding the runner
const hideCommandLog = this._cfg.env?.NO_COMMAND_LOG === 1 || hideRunnerUi
return {
...this._cfg,
remote: this.remoteStates?.current() ?? {} as Cypress.RemoteState,
browser: this.browser,
testingType: this.ctx.coreData.currentTestingType ?? 'e2e',
specs: [],
isDefaultProtocolEnabled,
hideCommandLog,
hideRunnerUi,
}
}
// Saved state
async getSavedState (options: { type: 'global' | 'project' } = { type: 'project' }) {
if (!this.cfg) {
throw new Error('Missing project config trying to get saved state')
}
const state = await savedState.create(options.type === 'project' ? this.projectRoot : undefined, this.cfg.isTextTerminal)
return state.get()
}
// forces saving of project's state by first merging with argument
async saveState (stateChanges = {}, options: { type: 'global' | 'project' } = { type: 'project' }) {
if (!this.cfg) {
throw new Error('Missing project config trying to save state')
}
if (options.type === 'project' && !this.projectRoot) {
throw new Error('Missing project root')
}
let state = await savedState.create(options.type === 'project' ? this.projectRoot : undefined, this.cfg.isTextTerminal)
state.set(stateChanges)
this.cfg.state = await state.get()
return this.cfg.state
}
async _setSavedState (cfg: Cfg) {
debug('get saved state')
const state = await savedState.create(this.projectRoot, cfg.isTextTerminal)
cfg.state = await state.get()
return cfg
}
// These methods are not related to start server/sockets/runners
async getProjectId () {
return getCtx().lifecycleManager.getProjectId()
}
get configDebugData () {
return this.ctx.lifecycleManager.configDebugData
}
// For testing
// Do not use this method outside of testing
// pass all your options when you create a new instance!
__setOptions (options: OpenProjectLaunchOptions) {
this.options = options
}
__setConfig (cfg: Cfg) {
this._cfg = cfg
}
}