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 type { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording } from '@packages/types' import { DataContext, getCtx } from '@packages/data-context' import { createHmac } from 'crypto' import type ProtocolManager from './cloud/protocol' import { ServerBase } from './server-base' import type Protocol from 'devtools-protocol' import type { ServiceWorkerClientEvent } from '@packages/proxy/lib/http/util/service-worker-manager' export interface Cfg extends ReceivedCypressOptions { projectId?: string projectRoot: string proxyServer?: Cypress.RuntimeConfigOptions['proxyUrl'] fileServerFolder?: Cypress.ResolvedConfigOptions['fileServerFolder'] testingType: TestingType protocolEnabled?: boolean hideCommandLog?: boolean hideRunnerUi?: boolean exit?: boolean state?: { firstOpened?: number | null lastOpened?: number | null promptsShown?: object | null banners?: BannersState | null } e2e: Partial component: Partial 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 export class ProjectBase extends EE { // id is sha256 of projectRoot public id: string protected ctx: DataContext protected _cfg?: Cfg protected _server?: ServerBase protected _automation?: Automation private _protocolManager?: ProtocolManager private _recordTests?: any = null private _isServerOpen: 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() 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) 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) { 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, { 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 = (eventName, data) => { this.server.emitRequestEvent(eventName, data) } 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 ios = this.server.startWebsockets(this.automation, this.cfg, { onReloadBrowser: options.onReloadBrowser, onFocusTests: options.onFocusTests, onSpecChanged: options.onSpecChanged, onSavedStateChanged: (state: any) => this.saveState(state), closeExtraTargets: this.closeExtraTargets, 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 (): ProtocolManager | undefined { return this._protocolManager } set protocolManager (protocolManager: ProtocolManager | undefined) { this._protocolManager = protocolManager this._server?.setProtocolManager(protocolManager) } getAutomation () { return this.automation } async initializeConfig (): Promise { 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 protocolEnabled = this._protocolManager?.protocolEnabled ?? false // hide the runner if explicitly requested or if the protocol is enabled and the runner is not explicitly enabled const hideRunnerUi = this.options?.args?.runnerUi === false || (protocolEnabled && !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: [], protocolEnabled, hideCommandLog, hideRunnerUi, } } // Saved state // forces saving of project's state by first merging with argument async saveState (stateChanges = {}) { if (!this.cfg) { throw new Error('Missing project config') } if (!this.projectRoot) { throw new Error('Missing project root') } let state = await savedState.create(this.projectRoot, 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() } // 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 } }