From 2f776b80ce09e970afdeb4d5df3bdf9e261a372f Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 16 May 2025 09:16:04 -0500 Subject: [PATCH 01/23] feat: cy prompt infrastructure --- cli/types/cypress.d.ts | 5 + packages/config/src/options.ts | 6 + packages/config/test/project/utils.spec.ts | 1 + .../data-context/src/data/coreDataShape.ts | 3 +- packages/driver/package.json | 1 + packages/driver/src/cy/commands/index.ts | 3 + .../driver/src/cy/commands/prompt/index.ts | 54 +++++ .../cy/commands/prompt/prompt-driver-types.ts | 3 + .../driver/types/internal-types-lite.d.ts | 2 +- .../frontend-shared/src/locales/en-US.json | 4 + .../server/lib/CyPromptLifecycleManager.ts | 212 ++++++++++++++++++ .../api/cy-prompt/get_cy_prompt_bundle.ts | 64 ++++++ .../api/cy-prompt/post_cy_prompt_session.ts | 44 ++++ .../cloud/api/studio/post_studio_session.ts | 4 +- packages/server/lib/cloud/cy-prompt.ts | 113 ++++++++++ packages/server/lib/cloud/routes.ts | 2 + packages/server/lib/experiments.ts | 2 + packages/server/lib/project-base.ts | 13 ++ packages/server/lib/routes.ts | 15 +- packages/server/lib/socket-base.ts | 18 +- packages/types/src/config.ts | 2 +- .../src/cy-prompt/cy-prompt-server-types.ts | 43 ++++ packages/types/src/cy-prompt/index.ts | 17 ++ packages/types/src/index.ts | 2 + 24 files changed, 625 insertions(+), 8 deletions(-) create mode 100644 packages/driver/src/cy/commands/prompt/index.ts create mode 100644 packages/driver/src/cy/commands/prompt/prompt-driver-types.ts create mode 100644 packages/server/lib/CyPromptLifecycleManager.ts create mode 100644 packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts create mode 100644 packages/server/lib/cloud/api/cy-prompt/post_cy_prompt_session.ts create mode 100644 packages/server/lib/cloud/cy-prompt.ts create mode 100644 packages/types/src/cy-prompt/cy-prompt-server-types.ts create mode 100644 packages/types/src/cy-prompt/index.ts diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 65f51f96722e..c87f4f26bc21 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -3158,6 +3158,11 @@ declare namespace Cypress { * @default false */ experimentalStudio: boolean + /** + * Enables the Cy-Prompt feature. + * @default false + */ + experimentalCyPrompt: boolean /** * Adds support for testing in the WebKit browser engine used by Safari. See https://on.cypress.io/webkit-experiment for more information. * @default false diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index e86be8c01354..f78a7b437528 100644 --- a/packages/config/src/options.ts +++ b/packages/config/src/options.ts @@ -259,6 +259,12 @@ const driverConfigOptions: Array = [ validation: validate.isBoolean, isExperimental: true, requireRestartOnChange: 'browser', + }, { + name: 'experimentalCyPrompt', + defaultValue: false, + validation: validate.isBoolean, + isExperimental: true, + requireRestartOnChange: 'server', }, { name: 'experimentalWebKitSupport', defaultValue: false, diff --git a/packages/config/test/project/utils.spec.ts b/packages/config/test/project/utils.spec.ts index ab47b455d667..6090623a8358 100644 --- a/packages/config/test/project/utils.spec.ts +++ b/packages/config/test/project/utils.spec.ts @@ -1197,6 +1197,7 @@ describe('config/src/project/utils', () => { experimentalRunAllSpecs: { value: false, from: 'default' }, experimentalSingleTabRunMode: { value: false, from: 'default' }, experimentalStudio: { value: false, from: 'default' }, + experimentalCyPrompt: { value: false, from: 'default' }, experimentalSourceRewriting: { value: false, from: 'default' }, experimentalWebKitSupport: { value: false, from: 'default' }, fileServerFolder: { value: '', from: 'default' }, diff --git a/packages/data-context/src/data/coreDataShape.ts b/packages/data-context/src/data/coreDataShape.ts index 73613a8233f1..1c6a2b993000 100644 --- a/packages/data-context/src/data/coreDataShape.ts +++ b/packages/data-context/src/data/coreDataShape.ts @@ -1,4 +1,4 @@ -import { FoundBrowser, Editor, AllowedState, AllModeOptions, TestingType, BrowserStatus, PACKAGE_MANAGERS, AuthStateName, MIGRATION_STEPS, MigrationStep, StudioLifecycleManagerShape } from '@packages/types' +import { FoundBrowser, Editor, AllowedState, AllModeOptions, TestingType, BrowserStatus, PACKAGE_MANAGERS, AuthStateName, MIGRATION_STEPS, MigrationStep, StudioLifecycleManagerShape, CyPromptLifecycleManagerShape } from '@packages/types' import { WizardBundler, CT_FRAMEWORKS, resolveComponentFrameworkDefinition, ErroredFramework } from '@packages/scaffold-config' import type { NexusGenObjects } from '@packages/graphql/src/gen/nxs.gen' // tslint:disable-next-line no-implicit-dependencies - electron dep needs to be defined @@ -165,6 +165,7 @@ export interface CoreDataShape { eventCollectorSource: EventCollectorSource | null didBrowserPreviouslyHaveUnexpectedExit: boolean studioLifecycleManager?: StudioLifecycleManagerShape + cyPromptLifecycleManager?: CyPromptLifecycleManagerShape } /** diff --git a/packages/driver/package.json b/packages/driver/package.json index 7739af9b77a8..c00cad712b5d 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -22,6 +22,7 @@ "@cypress/unique-selector": "0.0.5", "@cypress/webpack-dev-server": "0.0.0-development", "@cypress/webpack-preprocessor": "0.0.0-development", + "@module-federation/runtime": "^0.8.11", "@packages/config": "0.0.0-development", "@packages/errors": "0.0.0-development", "@packages/net-stubbing": "0.0.0-development", diff --git a/packages/driver/src/cy/commands/index.ts b/packages/driver/src/cy/commands/index.ts index 8131a1648570..cae64dad1aae 100644 --- a/packages/driver/src/cy/commands/index.ts +++ b/packages/driver/src/cy/commands/index.ts @@ -52,6 +52,8 @@ import Window from './window' import * as Xhr from './xhr' +import * as Prompt from './prompt' + export const allCommands = { ...Actions, Agents, @@ -70,6 +72,7 @@ export const allCommands = { Misc, Origin, Popups, + Prompt, Navigation, ...Querying, Request, diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts new file mode 100644 index 000000000000..7169f1b77fba --- /dev/null +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -0,0 +1,54 @@ +import { init, loadRemote } from '@module-federation/runtime' +import { CyPromptDriverDefaultShape } from './prompt-driver-types' + +interface CyPromptDriver { default: CyPromptDriverDefaultShape } + +let initializedCyPrompt: CyPromptDriverDefaultShape | null = null +const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise => { + const { success } = await Cypress.backend('wait:for:cy:prompt:ready') + + if (!success) { + throw new Error('CyPromptDriver not found') + } + + init({ + remotes: [{ + alias: 'cy-prompt', + type: 'module', + name: 'cy-prompt', + entryGlobalName: 'cy-prompt', + entry: '/__cypress-cy-prompt/cy-prompt.js', + shareScope: 'default', + }], + name: 'driver', + }) + + const module = await loadRemote('cy-prompt') + + if (!module?.default) { + throw new Error('CyPromptDriver not found') + } + + initializedCyPrompt = module.default + + return module.default +} + +export default (Commands, Cypress, cy) => { + Commands.addAll({ + async prompt (message: string) { + try { + let cloud = initializedCyPrompt + + if (!cloud) { + cloud = await initializeCloudCyPrompt(Cypress) + } + + return await cloud.cyPrompt(Cypress, message) + } catch (error) { + // TODO: handle this better + throw new Error('CyPromptDriver not found') + } + }, + }) +} diff --git a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts new file mode 100644 index 000000000000..589a765e3be9 --- /dev/null +++ b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts @@ -0,0 +1,3 @@ +export interface CyPromptDriverDefaultShape { + cyPrompt: (Cypress: any, text: string) => Promise +} diff --git a/packages/driver/types/internal-types-lite.d.ts b/packages/driver/types/internal-types-lite.d.ts index 5229aaee0b3a..c41aef97cf6a 100644 --- a/packages/driver/types/internal-types-lite.d.ts +++ b/packages/driver/types/internal-types-lite.d.ts @@ -1,6 +1,5 @@ /// /// - // All of the types needed by packages/app, without any of the additional APIs used in the driver only declare namespace Cypress { @@ -41,6 +40,7 @@ declare namespace Cypress { (task: 'protocol:test:before:after:run:async', attributes: any, options: any): Promise (task: 'protocol:url:changed', input: any): Promise (task: 'protocol:page:loading', input: any): Promise + (task: 'wait:for:cy:prompt:ready'): Promise<{ success: boolean }> } interface Devices { diff --git a/packages/frontend-shared/src/locales/en-US.json b/packages/frontend-shared/src/locales/en-US.json index 15f7658290ec..4fc3a1d3669d 100644 --- a/packages/frontend-shared/src/locales/en-US.json +++ b/packages/frontend-shared/src/locales/en-US.json @@ -613,6 +613,10 @@ "name": "Studio", "description": "Generate and save commands directly to your test suite by interacting with your app as an end user would." }, + "experimentalCyPrompt": { + "name": "cy.prompt", + "description": "Enables support for the `cy.prompt()` command." + }, "experimentalWebKitSupport": { "name": "WebKit Support", "description": "Adds support for testing in the WebKit browser engine used by Safari. See https://on.cypress.io/webkit-experiment for more information." diff --git a/packages/server/lib/CyPromptLifecycleManager.ts b/packages/server/lib/CyPromptLifecycleManager.ts new file mode 100644 index 000000000000..4367b07e7472 --- /dev/null +++ b/packages/server/lib/CyPromptLifecycleManager.ts @@ -0,0 +1,212 @@ +import { CyPromptManager } from './cloud/cy-prompt' +import Debug from 'debug' +import type { CloudDataSource } from '@packages/data-context/src/sources' +import type { Cfg } from './project-base' +import type { DataContext } from '@packages/data-context' +import { CloudRequest } from './cloud/api/cloud_request' +import { isRetryableError } from './cloud/network/is_retryable_error' +import { asyncRetry } from './util/async_retry' +import { postCyPromptSession } from './cloud/api/cy-prompt/post_cy_prompt_session' +import type { CyPromptStatus } from '@packages/types' +import { getCyPromptBundle } from './cloud/api/cy-prompt/get_cy_prompt_bundle' +import path from 'path' +import os from 'os' +import tar from 'tar' +import { ensureDir, copy, readFile, remove } from 'fs-extra' + +const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') + +export const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt') + +const bundlePath = path.join(cyPromptPath, 'bundle.tar') +const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') + +export class CyPromptLifecycleManager { + private cyPromptManagerPromise?: Promise + private cyPromptManager?: CyPromptManager + private listeners: ((cyPromptManager: CyPromptManager) => void)[] = [] + private ctx?: DataContext + private lastStatus?: CyPromptStatus + + /** + * Initialize the cy prompt manager. + * Also registers this instance in the data context. + * @param projectId The project ID + * @param cloudDataSource The cloud data source + * @param cfg The project configuration + * @param debugData Debug data for the configuration + * @param ctx Data context to register this instance with + */ + initializeCyPromptManager ({ + projectId, + cloudDataSource, + cfg, + debugData, + ctx, + }: { + projectId: string + cloudDataSource: CloudDataSource + cfg: Cfg + debugData: any + ctx: DataContext + }): void { + // Register this instance in the data context + ctx.update((data) => { + data.cyPromptLifecycleManager = this + }) + + this.ctx = ctx + + const cyPromptManagerPromise = this.createCyPromptManager({ + projectId, + cloudDataSource, + cfg, + debugData, + }).catch(async (error) => { + debug('Error during cy prompt manager setup: %o', error) + + // const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production' + // const cloudUrl = ctx.cloud.getCloudUrl(cloudEnv) + // const cloudHeaders = await ctx.cloud.additionalHeaders() + + // TODO: reportCyPromptError + // reportCyPromptError({ + // cloudApi: { + // cloudUrl, + // cloudHeaders, + // CloudRequest, + // isRetryableError, + // asyncRetry, + // }, + // cyPromptHash: projectId, + // projectSlug: cfg.projectId, + // error, + // cyPromptMethod: 'initializeCyPromptManager', + // cyPromptMethodArgs: [], + // }) + + // Clean up any registered listeners + this.listeners = [] + + return null + }) + + this.cyPromptManagerPromise = cyPromptManagerPromise + } + + isCyPromptReady (): boolean { + return !!this.cyPromptManager + } + + async getCyPrompt () { + if (!this.cyPromptManagerPromise) { + throw new Error('cy prompt manager has not been initialized') + } + + const cyPromptManager = await this.cyPromptManagerPromise + + return cyPromptManager + } + + private async createCyPromptManager ({ + projectId, + cloudDataSource, + cfg, + debugData, + }: { + projectId: string + cloudDataSource: CloudDataSource + cfg: Cfg + debugData: any + }): Promise { + const cyPromptSession = await postCyPromptSession({ + projectId, + }) + + // First remove cyPromptPath to ensure we have a clean slate + await remove(cyPromptPath) + await ensureDir(cyPromptPath) + + let cyPromptHash: string | undefined + + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + await getCyPromptBundle({ + cyPromptUrl: cyPromptSession.cyPromptUrl, + projectId, + bundlePath, + }) + + await tar.extract({ + file: bundlePath, + cwd: cyPromptPath, + }) + + // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension + cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] + } else { + const driverPath = path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'driver') + const serverPath = path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server') + + await copy(driverPath, path.join(cyPromptPath, 'driver')) + await copy(serverPath, path.join(cyPromptPath, 'server')) + } + + const script = await readFile(serverFilePath, 'utf8') + + const cyPromptManager = new CyPromptManager() + + const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production' + const cloudUrl = cloudDataSource.getCloudUrl(cloudEnv) + const cloudHeaders = await cloudDataSource.additionalHeaders() + + await cyPromptManager.setup({ + script, + cyPromptPath, + cyPromptHash, + projectSlug: projectId, + cloudApi: { + cloudUrl, + cloudHeaders, + CloudRequest, + isRetryableError, + asyncRetry, + }, + }) + + debug('cy prompt is ready') + this.cyPromptManager = cyPromptManager + this.callRegisteredListeners() + + return cyPromptManager + } + + private callRegisteredListeners () { + if (!this.cyPromptManager) { + throw new Error('cy prompt manager has not been initialized') + } + + const cyPromptManager = this.cyPromptManager + + debug('Calling all cy prompt ready listeners') + this.listeners.forEach((listener) => { + listener(cyPromptManager) + }) + + this.listeners = [] + } + + /** + * Register a listener that will be called when the cy prompt manager is ready + * @param listener Function to call when cy prompt manager is ready + */ + registerCyPromptReadyListener (listener: (cyPromptManager: CyPromptManager) => void): void { + // if there is already a cy prompt manager, call the listener immediately + if (this.cyPromptManager) { + debug('cy prompt ready - calling listener immediately') + listener(this.cyPromptManager) + } else { + debug('cy prompt not ready - registering cy prompt ready listener') + this.listeners.push(listener) + } + } +} diff --git a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts new file mode 100644 index 000000000000..e78648242852 --- /dev/null +++ b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts @@ -0,0 +1,64 @@ +import { asyncRetry, linearDelay } from '../../../util/async_retry' +import { isRetryableError } from '../../network/is_retryable_error' +import fetch from 'cross-fetch' +import os from 'os' +import { agent } from '@packages/network' +import { PUBLIC_KEY_VERSION } from '../../constants' +import fs from 'fs' +import { verifySignatureFromFile } from '../../encryption' + +const pkg = require('@packages/root') +const _delay = linearDelay(500) + +export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId: string, bundlePath: string }) => { + let responseSignature: string | null = null + + await (asyncRetry(async () => { + const response = await fetch(cyPromptUrl, { + // @ts-expect-error - this is supported + agent, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': PUBLIC_KEY_VERSION, + ...(projectId ? { 'x-cypress-project-slug': projectId } : {}), + 'x-cypress-cy-prompt-mount-version': '1', + 'x-os-name': os.platform(), + 'x-cypress-version': pkg.version, + }, + encrypt: 'signed', + }) + + if (!response.ok) { + throw new Error(`Failed to download cy-prompt bundle: ${response.statusText}`) + } + + responseSignature = response.headers.get('x-cypress-signature') + + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(bundlePath) + + writeStream.on('error', reject) + writeStream.on('finish', () => { + resolve() + }) + + // @ts-expect-error - this is supported + response.body?.pipe(writeStream) + }) + }, { + maxAttempts: 3, + retryDelay: _delay, + shouldRetry: isRetryableError, + }))() + + if (!responseSignature) { + throw new Error('Unable to get studio signature') + } + + const verified = await verifySignatureFromFile(bundlePath, responseSignature) + + if (!verified) { + throw new Error('Unable to verify studio signature') + } +} diff --git a/packages/server/lib/cloud/api/cy-prompt/post_cy_prompt_session.ts b/packages/server/lib/cloud/api/cy-prompt/post_cy_prompt_session.ts new file mode 100644 index 000000000000..44c13fea4c8a --- /dev/null +++ b/packages/server/lib/cloud/api/cy-prompt/post_cy_prompt_session.ts @@ -0,0 +1,44 @@ +import { asyncRetry, linearDelay } from '../../../util/async_retry' +import { isRetryableError } from '../../network/is_retryable_error' +import fetch from 'cross-fetch' +import os from 'os' +import { agent } from '@packages/network' + +const pkg = require('@packages/root') +const routes = require('../../routes') as typeof import('../../routes') + +interface PostCyPromptSessionOptions { + projectId?: string +} + +const _delay = linearDelay(500) + +export const postCyPromptSession = async ({ projectId }: PostCyPromptSessionOptions) => { + return await (asyncRetry(async () => { + const response = await fetch(routes.apiRoutes.cyPromptSession(), { + // @ts-expect-error - this is supported + agent, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-os-name': os.platform(), + 'x-cypress-version': pkg.version, + }, + body: JSON.stringify({ projectSlug: projectId, cyPromptMountVersion: 1 }), + }) + + if (!response.ok) { + throw new Error(`Failed to create cy-prompt session: ${response.statusText}`) + } + + const data = await response.json() + + return { + cyPromptUrl: data.cyPromptUrl, + } + }, { + maxAttempts: 3, + retryDelay: _delay, + shouldRetry: isRetryableError, + }))() +} diff --git a/packages/server/lib/cloud/api/studio/post_studio_session.ts b/packages/server/lib/cloud/api/studio/post_studio_session.ts index 21ad20150f2d..7ec93b306cc4 100644 --- a/packages/server/lib/cloud/api/studio/post_studio_session.ts +++ b/packages/server/lib/cloud/api/studio/post_studio_session.ts @@ -7,13 +7,13 @@ import { agent } from '@packages/network' const pkg = require('@packages/root') const routes = require('../../routes') as typeof import('../../routes') -interface GetStudioSessionOptions { +interface PostStudioSessionOptions { projectId?: string } const _delay = linearDelay(500) -export const postStudioSession = async ({ projectId }: GetStudioSessionOptions) => { +export const postStudioSession = async ({ projectId }: PostStudioSessionOptions) => { return await (asyncRetry(async () => { const response = await fetch(routes.apiRoutes.studioSession(), { // @ts-expect-error - this is supported diff --git a/packages/server/lib/cloud/cy-prompt.ts b/packages/server/lib/cloud/cy-prompt.ts new file mode 100644 index 000000000000..1a781ba06b36 --- /dev/null +++ b/packages/server/lib/cloud/cy-prompt.ts @@ -0,0 +1,113 @@ +import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, CyPromptServerShape, CyPromptCloudApi } from '@packages/types' +import type { Router } from 'express' +import Debug from 'debug' +import { requireScript } from './require_script' + +interface CyPromptServer { default: CyPromptServerDefaultShape } + +interface SetupOptions { + script: string + cyPromptPath: string + cyPromptHash?: string + projectSlug?: string + cloudApi: CyPromptCloudApi +} + +const debug = Debug('cypress:server:cy-prompt') + +export class CyPromptManager implements CyPromptManagerShape { + status: CyPromptStatus = 'NOT_INITIALIZED' + private _cyPromptServer: CyPromptServerShape | undefined + + async setup ({ script, cyPromptPath, cyPromptHash, projectSlug, cloudApi }: SetupOptions): Promise { + const { createCyPromptServer } = requireScript(script).default + + this._cyPromptServer = await createCyPromptServer({ + cyPromptHash, + cyPromptPath, + projectSlug, + cloudApi, + }) + + this.status = 'INITIALIZED' + } + + initializeRoutes (router: Router): void { + if (this._cyPromptServer) { + this.invokeSync('initializeRoutes', { isEssential: true }, router) + } + } + + async handleBackendRequest (eventName: string, ...args: any[]): Promise { + if (this._cyPromptServer) { + return this.invokeAsync('handleBackendRequest', { isEssential: true }, eventName, ...args) + } + } + + /** + * Abstracts invoking a synchronous method on the CyPromptServer instance, so we can handle + * errors in a uniform way + */ + private invokeSync (method: K, { isEssential }: { isEssential: boolean }, ...args: Parameters): any | void { + if (!this._cyPromptServer) { + return + } + + try { + return this._cyPromptServer[method].apply(this._cyPromptServer, args) + } catch (error: unknown) { + let actualError: Error + + if (!(error instanceof Error)) { + actualError = new Error(String(error)) + } else { + actualError = error + } + + this.status = 'IN_ERROR' + + // TODO: report error + debug('Error invoking cy prompt server method %s: %o', method, actualError) + } + } + /** + * Abstracts invoking an asynchronous method on the CyPromptServer instance, so we can handle + * errors in a uniform way + */ + private async invokeAsync (method: K, { isEssential }: { isEssential: boolean }, ...args: Parameters): Promise | undefined> { + if (!this._cyPromptServer) { + return undefined + } + + try { + return await this._cyPromptServer[method].apply(this._cyPromptServer, args) + } catch (error: unknown) { + let actualError: Error + + if (!(error instanceof Error)) { + actualError = new Error(String(error)) + } else { + actualError = error + } + + // only set error state if this request is essential + if (isEssential) { + this.status = 'IN_ERROR' + } + + // TODO: report error + debug('Error invoking cy prompt server method %s: %o', method, actualError) + + return undefined + } + } +} + +// Helper types for invokeSync / invokeAsync +type CyPromptServerSyncMethods = { + [K in keyof CyPromptServerShape]: ReturnType extends Promise ? never : K +}[keyof CyPromptServerShape] + +type CyPromptServerAsyncMethods = { + [K in keyof CyPromptServerShape]: ReturnType extends Promise ? K : never +}[keyof CyPromptServerShape] diff --git a/packages/server/lib/cloud/routes.ts b/packages/server/lib/cloud/routes.ts index e675cde7b4a6..eb4a73e8cebb 100644 --- a/packages/server/lib/cloud/routes.ts +++ b/packages/server/lib/cloud/routes.ts @@ -18,6 +18,8 @@ const CLOUD_ENDPOINTS = { captureProtocolErrors: 'capture-protocol/errors', studioSession: 'studio/session', studioErrors: 'studio/errors', + cyPromptSession: 'cy-prompt/session', + cyPromptErrors: 'cy-prompt/errors', exceptions: 'exceptions', telemetry: 'telemetry', } as const diff --git a/packages/server/lib/experiments.ts b/packages/server/lib/experiments.ts index b043ec1ef068..921483d7773b 100644 --- a/packages/server/lib/experiments.ts +++ b/packages/server/lib/experiments.ts @@ -60,6 +60,7 @@ const _summaries: StringValues = { experimentalRunAllSpecs: 'Enables the "Run All Specs" UI feature, allowing the execution of multiple specs sequentially', experimentalOriginDependencies: 'Enables support for `Cypress.require()` for including dependencies within the `cy.origin()` callback.', experimentalMemoryManagement: 'Enables support for improved memory management within Chromium-based browsers.', + experimentalCyPrompt: 'Enables support for the `cy.prompt()` command.', } /** @@ -82,6 +83,7 @@ const _names: StringValues = { experimentalRunAllSpecs: 'Run All Specs', experimentalOriginDependencies: 'Origin Dependencies', experimentalMemoryManagement: 'Memory Management', + experimentalCyPrompt: 'cy.prompt', } /** diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 163342fb997a..36a04fd53612 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -29,6 +29,7 @@ import { reportStudioError } from './cloud/api/studio/report_studio_error' import { CloudRequest } from './cloud/api/cloud_request' import { isRetryableError } from './cloud/network/is_retryable_error' import { asyncRetry } from './util/async_retry' +import { CyPromptLifecycleManager } from './CyPromptLifecycleManager' export interface Cfg extends ReceivedCypressOptions { projectId?: string @@ -157,6 +158,18 @@ export class ProjectBase extends EE { this._server = new ServerBase(cfg) + if (cfg.projectId && cfg.experimentalCyPrompt) { + const cyPromptLifecycleManager = new CyPromptLifecycleManager() + + cyPromptLifecycleManager.initializeCyPromptManager({ + projectId: cfg.projectId, + cloudDataSource: this.ctx.cloud, + cfg, + debugData: this.configDebugData, + ctx: this.ctx, + }) + } + if (!cfg.isTextTerminal) { const studioLifecycleManager = new StudioLifecycleManager() diff --git a/packages/server/lib/routes.ts b/packages/server/lib/routes.ts index bdf4d8f1f1b5..1c7f9fdfd487 100644 --- a/packages/server/lib/routes.ts +++ b/packages/server/lib/routes.ts @@ -103,22 +103,33 @@ export const createCommonRoutes = ({ next() }) - // If we are in cypress in cypress we need to pass along the studio routes + // If we are in cypress in cypress we need to pass along the studio and cy-prompt routes // to the child project. if (process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF_PARENT_PROJECT) { router.get('/__cypress-studio/*', async (req, res) => { await networkProxy.handleHttpRequest(req, res) }) + + router.get('/__cypress-cy-prompt/*', async (req, res) => { + await networkProxy.handleHttpRequest(req, res) + }) } else { // express matches routes in order. since this callback executes after the // router has already been defined, we need to create a new router to use - // for the studio routes + // for the studio and cy-prompt routes const studioRouter = Router() router.use('/', studioRouter) getCtx().coreData.studioLifecycleManager?.registerStudioReadyListener((studio) => { studio.initializeRoutes(studioRouter) }) + + const cyPromptRouter = Router() + + router.use('/', cyPromptRouter) + getCtx().coreData.cyPromptLifecycleManager?.registerCyPromptReadyListener((cyPrompt) => { + cyPrompt.initializeRoutes(cyPromptRouter) + }) } router.get(`/${config.namespace}/tests`, (req, res, next) => { diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 36b77bf6f4e8..e99e18f5d2bd 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -23,7 +23,7 @@ import type { Automation } from './automation' // eslint-disable-next-line no-duplicate-imports import type { Socket } from '@packages/socket' -import type { RunState, CachedTestState, ProtocolManagerShape, AutomationCommands } from '@packages/types' +import type { RunState, CachedTestState, ProtocolManagerShape, AutomationCommands, CyPromptManagerShape } from '@packages/types' import memory from './browsers/memory' import { privilegedCommandsManager } from './privileged-commands/privileged-commands-manager' @@ -448,6 +448,12 @@ export class SocketBase { } }) + let cyPrompt: CyPromptManagerShape | undefined + + getCtx().coreData.cyPromptLifecycleManager?.registerCyPromptReadyListener((cp) => { + cyPrompt = cp + }) + socket.on('backend:request', (eventName: string, ...args) => { const userAgent = socket.request?.headers['user-agent'] || getCtx().coreData.app.browserUserAgent @@ -457,6 +463,10 @@ export class SocketBase { debug('backend:request %o', { eventName, args }) const backendRequest = () => { + if (eventName.startsWith('cy:prompt:')) { + return cyPrompt?.handleBackendRequest(eventName, ...args) + } + switch (eventName) { case 'preserve:run:state': runState = args[0] @@ -534,6 +544,12 @@ export class SocketBase { }) case 'close:extra:targets': return options.closeExtraTargets() + case 'wait:for:cy:prompt:ready': + return getCtx().coreData.cyPromptLifecycleManager?.getCyPrompt().then((cyPrompt) => { + return { + success: cyPrompt && cyPrompt.status === 'INITIALIZED', + } + }) default: throw new Error(`You requested a backend event we cannot handle: ${eventName}`) } diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index 0c5018bbb3d5..b60a5416f17b 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -30,7 +30,7 @@ export interface FullConfig extends Partial - & Pick // TODO: Figure out how to type this better. + & Pick // TODO: Figure out how to type this better. export interface SettingsOptions { testingType?: 'component' |'e2e' diff --git a/packages/types/src/cy-prompt/cy-prompt-server-types.ts b/packages/types/src/cy-prompt/cy-prompt-server-types.ts new file mode 100644 index 000000000000..9915b30b636b --- /dev/null +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -0,0 +1,43 @@ +/// + +import type { Router } from 'express' +import type { AxiosInstance } from 'axios' + +interface RetryOptions { + maxAttempts: number + retryDelay?: (attempt: number) => number + shouldRetry?: (err?: unknown) => boolean + onRetry?: (delay: number, err: unknown) => void +} + +export interface CyPromptCloudApi { + cloudUrl: string + cloudHeaders: Record + CloudRequest: AxiosInstance + isRetryableError: (err: unknown) => boolean + asyncRetry: AsyncRetry +} + +type AsyncRetry = ( + fn: (...args: TArgs) => Promise, + options: RetryOptions +) => (...args: TArgs) => Promise + +export interface CyPromptServerOptions { + cyPromptHash?: string + cyPromptPath: string + projectSlug?: string + cloudApi: CyPromptCloudApi +} + +export interface CyPromptServerShape { + initializeRoutes(router: Router): void + handleBackendRequest: (eventName: string, ...args: any[]) => Promise +} + +export interface CyPromptServerDefaultShape { + createCyPromptServer: ( + options: CyPromptServerOptions + ) => Promise + MOUNT_VERSION: number +} diff --git a/packages/types/src/cy-prompt/index.ts b/packages/types/src/cy-prompt/index.ts new file mode 100644 index 000000000000..fdb0db0a0294 --- /dev/null +++ b/packages/types/src/cy-prompt/index.ts @@ -0,0 +1,17 @@ +import type { CyPromptServerShape } from './cy-prompt-server-types' + +export * from './cy-prompt-server-types' + +export const CY_PROMPT_STATUSES = ['NOT_INITIALIZED', 'INITIALIZING', 'INITIALIZED', 'IN_ERROR'] as const + +export type CyPromptStatus = typeof CY_PROMPT_STATUSES[number] + +export interface CyPromptManagerShape extends CyPromptServerShape { + status: CyPromptStatus +} + +export interface CyPromptLifecycleManagerShape { + getCyPrompt: () => Promise + isCyPromptReady: () => boolean + registerCyPromptReadyListener: (listener: (cyPromptManager: CyPromptManagerShape) => void) => void +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 79cb6dc19776..a084fa82d96c 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -49,3 +49,5 @@ export * from './proxy' export * from './cloud' export * from './studio' + +export * from './cy-prompt' From cd1c7e359fa73192fd552c2d82a975a6a063623f Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sun, 18 May 2025 21:57:06 -0500 Subject: [PATCH 02/23] refactor and add tests --- .../config/__snapshots__/index.spec.ts.js | 3 + packages/config/test/project/utils.spec.ts | 1 + .../driver/cypress/e2e/commands/prompt.cy.ts | 9 + .../driver/src/cy/commands/prompt/index.ts | 5 + .../lib/{ => cloud}/StudioLifecycleManager.ts | 22 +- .../api/cy-prompt/get_cy_prompt_bundle.ts | 4 +- .../cy-prompt}/CyPromptLifecycleManager.ts | 80 ++---- .../CyPromptManager.ts} | 6 +- .../cy-prompt/ensure_cy_prompt_bundle.ts | 37 +++ packages/server/lib/project-base.ts | 7 +- .../cloud/cy-prompt/test-cy-prompt.ts | 23 ++ .../fixtures/cloud/studio/test-studio.ts | 2 +- .../StudioLifecycleManager_spec.ts | 18 +- .../cy-prompt/get_cy_prompt_bundle_spec.ts | 244 +++++++++++++++++ .../cy-prompt/post_cy_prompt_session_spec.ts | 79 ++++++ .../CyPromptLifecycleManager_spec.ts | 254 ++++++++++++++++++ .../cloud/cy-prompt/CyPromptManager_spec.ts | 102 +++++++ .../cy-prompt/ensure_cy_prompt_bundle_spec.ts | 100 +++++++ packages/server/test/unit/project_spec.js | 41 ++- packages/server/test/unit/routes_spec.ts | 54 +--- packages/server/test/unit/socket_spec.js | 71 +++++ packages/types/src/cy-prompt/index.ts | 1 - system-tests/__snapshots__/results_spec.ts.js | 1 + 23 files changed, 1027 insertions(+), 137 deletions(-) create mode 100644 packages/driver/cypress/e2e/commands/prompt.cy.ts rename packages/server/lib/{ => cloud}/StudioLifecycleManager.ts (90%) rename packages/server/lib/{ => cloud/cy-prompt}/CyPromptLifecycleManager.ts (67%) rename packages/server/lib/cloud/{cy-prompt.ts => cy-prompt/CyPromptManager.ts} (96%) create mode 100644 packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts create mode 100644 packages/server/test/support/fixtures/cloud/cy-prompt/test-cy-prompt.ts rename packages/server/test/unit/{ => cloud}/StudioLifecycleManager_spec.ts (95%) create mode 100644 packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts create mode 100644 packages/server/test/unit/cloud/api/cy-prompt/post_cy_prompt_session_spec.ts create mode 100644 packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts create mode 100644 packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts create mode 100644 packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts diff --git a/packages/config/__snapshots__/index.spec.ts.js b/packages/config/__snapshots__/index.spec.ts.js index bece4725456e..51a035c064be 100644 --- a/packages/config/__snapshots__/index.spec.ts.js +++ b/packages/config/__snapshots__/index.spec.ts.js @@ -46,6 +46,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys 1 'experimentalSourceRewriting': false, 'experimentalSingleTabRunMode': false, 'experimentalStudio': false, + 'experimentalCyPrompt': false, 'experimentalWebKitSupport': false, 'fileServerFolder': '', 'fixturesFolder': 'cypress/fixtures', @@ -137,6 +138,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys f 'experimentalSourceRewriting': false, 'experimentalSingleTabRunMode': false, 'experimentalStudio': false, + 'experimentalCyPrompt': false, 'experimentalWebKitSupport': false, 'fileServerFolder': '', 'fixturesFolder': 'cypress/fixtures', @@ -224,6 +226,7 @@ exports['config/src/index .getPublicConfigKeys returns list of public config key 'experimentalSourceRewriting', 'experimentalSingleTabRunMode', 'experimentalStudio', + 'experimentalCyPrompt', 'experimentalWebKitSupport', 'fileServerFolder', 'fixturesFolder', diff --git a/packages/config/test/project/utils.spec.ts b/packages/config/test/project/utils.spec.ts index 6090623a8358..2fd814ce8280 100644 --- a/packages/config/test/project/utils.spec.ts +++ b/packages/config/test/project/utils.spec.ts @@ -1078,6 +1078,7 @@ describe('config/src/project/utils', () => { experimentalRunAllSpecs: { value: false, from: 'default' }, experimentalSingleTabRunMode: { value: false, from: 'default' }, experimentalStudio: { value: false, from: 'default' }, + experimentalCyPrompt: { value: false, from: 'default' }, experimentalSourceRewriting: { value: false, from: 'default' }, experimentalWebKitSupport: { value: false, from: 'default' }, fileServerFolder: { value: '', from: 'default' }, diff --git a/packages/driver/cypress/e2e/commands/prompt.cy.ts b/packages/driver/cypress/e2e/commands/prompt.cy.ts new file mode 100644 index 000000000000..0b3b4d517e59 --- /dev/null +++ b/packages/driver/cypress/e2e/commands/prompt.cy.ts @@ -0,0 +1,9 @@ +describe('src/cy/commands/prompt', () => { + it('errors when cy.prompt() is not enabled', () => { + cy.visit('/fixtures/input_events.html') + + cy.prompt('Hello, world!').should('throw') + }) + + // TODO: add more tests when cy.prompt is deployed +}) diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 7169f1b77fba..a95358660194 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -37,6 +37,11 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise { Commands.addAll({ async prompt (message: string) { + if (!Cypress.config('experimentalCyPrompt')) { + // TODO: what do we want to do here? + throw new Error('cy.prompt() is not enabled. Please enable it by setting `experimentalCyPrompt: true` in your Cypress config.') + } + try { let cloud = initializedCyPrompt diff --git a/packages/server/lib/StudioLifecycleManager.ts b/packages/server/lib/cloud/StudioLifecycleManager.ts similarity index 90% rename from packages/server/lib/StudioLifecycleManager.ts rename to packages/server/lib/cloud/StudioLifecycleManager.ts index fbc1157ff55f..ff6115907fc7 100644 --- a/packages/server/lib/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/StudioLifecycleManager.ts @@ -1,21 +1,21 @@ -import type { StudioManager } from './cloud/studio' -import { ProtocolManager } from './cloud/protocol' -import { getAndInitializeStudioManager } from './cloud/api/studio/get_and_initialize_studio_manager' +import type { StudioManager } from './studio' +import { ProtocolManager } from './protocol' +import { getAndInitializeStudioManager } from './api/studio/get_and_initialize_studio_manager' import Debug from 'debug' import type { CloudDataSource } from '@packages/data-context/src/sources' -import type { Cfg } from './project-base' +import type { Cfg } from '../project-base' import _ from 'lodash' import type { DataContext } from '@packages/data-context' -import api from './cloud/api' -import { reportStudioError } from './cloud/api/studio/report_studio_error' -import { CloudRequest } from './cloud/api/cloud_request' -import { isRetryableError } from './cloud/network/is_retryable_error' -import { asyncRetry } from './util/async_retry' -import { postStudioSession } from './cloud/api/studio/post_studio_session' +import api from './api' +import { reportStudioError } from './api/studio/report_studio_error' +import { CloudRequest } from './api/cloud_request' +import { isRetryableError } from './network/is_retryable_error' +import { asyncRetry } from '../util/async_retry' +import { postStudioSession } from './api/studio/post_studio_session' import type { StudioStatus } from '@packages/types' const debug = Debug('cypress:server:studio-lifecycle-manager') -const routes = require('./cloud/routes') +const routes = require('./routes') export class StudioLifecycleManager { private studioManagerPromise?: Promise diff --git a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts index e78648242852..2a8d1b83e108 100644 --- a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts @@ -4,7 +4,7 @@ import fetch from 'cross-fetch' import os from 'os' import { agent } from '@packages/network' import { PUBLIC_KEY_VERSION } from '../../constants' -import fs from 'fs' +import { createWriteStream } from 'fs' import { verifySignatureFromFile } from '../../encryption' const pkg = require('@packages/root') @@ -36,7 +36,7 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: responseSignature = response.headers.get('x-cypress-signature') await new Promise((resolve, reject) => { - const writeStream = fs.createWriteStream(bundlePath) + const writeStream = createWriteStream(bundlePath) writeStream.on('error', reject) writeStream.on('finish', () => { diff --git a/packages/server/lib/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts similarity index 67% rename from packages/server/lib/CyPromptLifecycleManager.ts rename to packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 4367b07e7472..e7bce3cd41cb 100644 --- a/packages/server/lib/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -1,53 +1,37 @@ -import { CyPromptManager } from './cloud/cy-prompt' +import { CyPromptManager } from './CyPromptManager' import Debug from 'debug' import type { CloudDataSource } from '@packages/data-context/src/sources' -import type { Cfg } from './project-base' import type { DataContext } from '@packages/data-context' -import { CloudRequest } from './cloud/api/cloud_request' -import { isRetryableError } from './cloud/network/is_retryable_error' -import { asyncRetry } from './util/async_retry' -import { postCyPromptSession } from './cloud/api/cy-prompt/post_cy_prompt_session' -import type { CyPromptStatus } from '@packages/types' -import { getCyPromptBundle } from './cloud/api/cy-prompt/get_cy_prompt_bundle' +import { CloudRequest } from '../api/cloud_request' +import { isRetryableError } from '../network/is_retryable_error' +import { asyncRetry } from '../../util/async_retry' +import { postCyPromptSession } from '../api/cy-prompt/post_cy_prompt_session' import path from 'path' import os from 'os' -import tar from 'tar' -import { ensureDir, copy, readFile, remove } from 'fs-extra' +import { readFile } from 'fs-extra' +import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') -export const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt') - -const bundlePath = path.join(cyPromptPath, 'bundle.tar') -const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') - export class CyPromptLifecycleManager { private cyPromptManagerPromise?: Promise private cyPromptManager?: CyPromptManager private listeners: ((cyPromptManager: CyPromptManager) => void)[] = [] - private ctx?: DataContext - private lastStatus?: CyPromptStatus /** * Initialize the cy prompt manager. * Also registers this instance in the data context. * @param projectId The project ID * @param cloudDataSource The cloud data source - * @param cfg The project configuration - * @param debugData Debug data for the configuration * @param ctx Data context to register this instance with */ initializeCyPromptManager ({ projectId, cloudDataSource, - cfg, - debugData, ctx, }: { projectId: string cloudDataSource: CloudDataSource - cfg: Cfg - debugData: any ctx: DataContext }): void { // Register this instance in the data context @@ -55,13 +39,9 @@ export class CyPromptLifecycleManager { data.cyPromptLifecycleManager = this }) - this.ctx = ctx - const cyPromptManagerPromise = this.createCyPromptManager({ projectId, cloudDataSource, - cfg, - debugData, }).catch(async (error) => { debug('Error during cy prompt manager setup: %o', error) @@ -94,10 +74,6 @@ export class CyPromptLifecycleManager { this.cyPromptManagerPromise = cyPromptManagerPromise } - isCyPromptReady (): boolean { - return !!this.cyPromptManager - } - async getCyPrompt () { if (!this.cyPromptManagerPromise) { throw new Error('cy prompt manager has not been initialized') @@ -111,48 +87,28 @@ export class CyPromptLifecycleManager { private async createCyPromptManager ({ projectId, cloudDataSource, - cfg, - debugData, }: { projectId: string cloudDataSource: CloudDataSource - cfg: Cfg - debugData: any }): Promise { const cyPromptSession = await postCyPromptSession({ projectId, }) - // First remove cyPromptPath to ensure we have a clean slate - await remove(cyPromptPath) - await ensureDir(cyPromptPath) - - let cyPromptHash: string | undefined - - if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { - await getCyPromptBundle({ - cyPromptUrl: cyPromptSession.cyPromptUrl, - projectId, - bundlePath, - }) - - await tar.extract({ - file: bundlePath, - cwd: cyPromptPath, - }) + // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension + const cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] + const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) + const bundlePath = path.join(cyPromptPath, 'bundle.tar') + const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') - // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension - cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] - } else { - const driverPath = path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'driver') - const serverPath = path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server') - - await copy(driverPath, path.join(cyPromptPath, 'driver')) - await copy(serverPath, path.join(cyPromptPath, 'server')) - } + await ensureCyPromptBundle({ + cyPromptUrl: cyPromptSession.cyPromptUrl, + projectId, + cyPromptPath, + bundlePath, + }) const script = await readFile(serverFilePath, 'utf8') - const cyPromptManager = new CyPromptManager() const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production' diff --git a/packages/server/lib/cloud/cy-prompt.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts similarity index 96% rename from packages/server/lib/cloud/cy-prompt.ts rename to packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index 1a781ba06b36..ce247d5a156f 100644 --- a/packages/server/lib/cloud/cy-prompt.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -1,7 +1,7 @@ import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, CyPromptServerShape, CyPromptCloudApi } from '@packages/types' import type { Router } from 'express' import Debug from 'debug' -import { requireScript } from './require_script' +import { requireScript } from '../require_script' interface CyPromptServer { default: CyPromptServerDefaultShape } @@ -64,7 +64,9 @@ export class CyPromptManager implements CyPromptManagerShape { actualError = error } - this.status = 'IN_ERROR' + if (isEssential) { + this.status = 'IN_ERROR' + } // TODO: report error debug('Error invoking cy prompt server method %s: %o', method, actualError) diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts new file mode 100644 index 000000000000..14af0eac0e07 --- /dev/null +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -0,0 +1,37 @@ +import { copy, remove, ensureDir } from 'fs-extra' + +import tar from 'tar' +import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle' +import path from 'path' + +interface EnsureCyPromptBundleOptions { + cyPromptPath: string + cyPromptUrl: string + projectId: string + bundlePath: string +} + +export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId, bundlePath }: EnsureCyPromptBundleOptions) => { + // First remove cyPromptPath to ensure we have a clean slate + await remove(cyPromptPath) + await ensureDir(cyPromptPath) + + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + await getCyPromptBundle({ + cyPromptUrl, + projectId, + bundlePath, + }) + + await tar.extract({ + file: bundlePath, + cwd: cyPromptPath, + }) + } else { + const driverPath = path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'driver') + const serverPath = path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server') + + await copy(driverPath, path.join(cyPromptPath, 'driver')) + await copy(serverPath, path.join(cyPromptPath, 'server')) + } +} diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 36a04fd53612..9054d916fa71 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -24,12 +24,12 @@ 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 './StudioLifecycleManager' +import { StudioLifecycleManager } from './cloud/StudioLifecycleManager' import { reportStudioError } from './cloud/api/studio/report_studio_error' import { CloudRequest } from './cloud/api/cloud_request' import { isRetryableError } from './cloud/network/is_retryable_error' import { asyncRetry } from './util/async_retry' -import { CyPromptLifecycleManager } from './CyPromptLifecycleManager' +import { CyPromptLifecycleManager } from './cloud/cy-prompt/CyPromptLifecycleManager' export interface Cfg extends ReceivedCypressOptions { projectId?: string @@ -157,15 +157,12 @@ export class ProjectBase extends EE { process.chdir(this.projectRoot) this._server = new ServerBase(cfg) - if (cfg.projectId && cfg.experimentalCyPrompt) { const cyPromptLifecycleManager = new CyPromptLifecycleManager() cyPromptLifecycleManager.initializeCyPromptManager({ projectId: cfg.projectId, cloudDataSource: this.ctx.cloud, - cfg, - debugData: this.configDebugData, ctx: this.ctx, }) } diff --git a/packages/server/test/support/fixtures/cloud/cy-prompt/test-cy-prompt.ts b/packages/server/test/support/fixtures/cloud/cy-prompt/test-cy-prompt.ts new file mode 100644 index 000000000000..513a7e6107a6 --- /dev/null +++ b/packages/server/test/support/fixtures/cloud/cy-prompt/test-cy-prompt.ts @@ -0,0 +1,23 @@ +/// + +import type { CyPromptServerShape, CyPromptServerDefaultShape } from '@packages/types' +import type { Router } from 'express' + +class CyPromptServer implements CyPromptServerShape { + initializeRoutes (router: Router): void { + // This is a test implementation that does nothing + } + + handleBackendRequest (eventName: string, ...args: any[]): Promise { + return Promise.resolve() + } +} + +const cyPromptServerDefault: CyPromptServerDefaultShape = { + createCyPromptServer (): Promise { + return Promise.resolve(new CyPromptServer()) + }, + MOUNT_VERSION: 1, +} + +export default cyPromptServerDefault diff --git a/packages/server/test/support/fixtures/cloud/studio/test-studio.ts b/packages/server/test/support/fixtures/cloud/studio/test-studio.ts index 6b76dc89b55e..cc65ec353f2f 100644 --- a/packages/server/test/support/fixtures/cloud/studio/test-studio.ts +++ b/packages/server/test/support/fixtures/cloud/studio/test-studio.ts @@ -1,6 +1,6 @@ /// -import type { StudioServerShape, StudioServerDefaultShape } from '@packages/types' +import type { StudioServerShape, StudioServerDefaultShape, StudioEvent } from '@packages/types' import type { Router } from 'express' import type { Socket } from '@packages/socket' diff --git a/packages/server/test/unit/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/StudioLifecycleManager_spec.ts similarity index 95% rename from packages/server/test/unit/StudioLifecycleManager_spec.ts rename to packages/server/test/unit/cloud/StudioLifecycleManager_spec.ts index 3022ecba01d9..47b5ce285aae 100644 --- a/packages/server/test/unit/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/StudioLifecycleManager_spec.ts @@ -1,15 +1,15 @@ -import { sinon } from '../spec_helper' +import { sinon } from '../../spec_helper' import { expect } from 'chai' -import { StudioManager } from '../../lib/cloud/studio' -import { StudioLifecycleManager } from '../../lib/StudioLifecycleManager' +import { StudioManager } from '../../../lib/cloud/studio' +import { StudioLifecycleManager } from '../../../lib/cloud/StudioLifecycleManager' import type { DataContext } from '@packages/data-context' -import type { Cfg } from '../../lib/project-base' +import type { Cfg } from '../../../lib/project-base' import type { CloudDataSource } from '@packages/data-context/src/sources' -import * as getAndInitializeStudioManagerModule from '../../lib/cloud/api/studio/get_and_initialize_studio_manager' -import * as reportStudioErrorPath from '../../lib/cloud/api/studio/report_studio_error' -import ProtocolManager from '../../lib/cloud/protocol' -const api = require('../../lib/cloud/api').default -import * as postStudioSessionModule from '../../lib/cloud/api/studio/post_studio_session' +import * as getAndInitializeStudioManagerModule from '../../../lib/cloud/api/studio/get_and_initialize_studio_manager' +import * as reportStudioErrorPath from '../../../lib/cloud/api/studio/report_studio_error' +import ProtocolManager from '../../../lib/cloud/protocol' +const api = require('../../../lib/cloud/api').default +import * as postStudioSessionModule from '../../../lib/cloud/api/studio/post_studio_session' // Helper to wait for next tick in event loop const nextTick = () => new Promise((resolve) => process.nextTick(resolve)) diff --git a/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts new file mode 100644 index 000000000000..35fe19437b07 --- /dev/null +++ b/packages/server/test/unit/cloud/api/cy-prompt/get_cy_prompt_bundle_spec.ts @@ -0,0 +1,244 @@ +import { sinon, proxyquire } from '../../../../spec_helper' +import { Readable, Writable } from 'stream' +import { HttpError } from '../../../../../lib/cloud/network/http_error' + +describe('getCyPromptBundle', () => { + let writeResult: string + let readStream: Readable + let createWriteStreamStub: sinon.SinonStub + let crossFetchStub: sinon.SinonStub + let verifySignatureFromFileStub: sinon.SinonStub + let getCyPromptBundle: typeof import('../../../../../lib/cloud/api/cy-prompt/get_cy_prompt_bundle').getCyPromptBundle + + beforeEach(() => { + createWriteStreamStub = sinon.stub() + crossFetchStub = sinon.stub() + verifySignatureFromFileStub = sinon.stub() + readStream = Readable.from('console.log("cy-prompt script")') + + writeResult = '' + const writeStream = new Writable({ + write: (chunk, encoding, callback) => { + writeResult += chunk.toString() + callback() + }, + }) + + createWriteStreamStub.returns(writeStream) + + getCyPromptBundle = proxyquire('../lib/cloud/api/cy-prompt/get_cy_prompt_bundle', { + 'fs': { + createWriteStream: createWriteStreamStub, + }, + 'cross-fetch': crossFetchStub, + '../../encryption': { + verifySignatureFromFile: verifySignatureFromFileStub, + }, + 'os': { + platform: () => 'linux', + }, + '@packages/root': { + version: '1.2.3', + }, + }).getCyPromptBundle + }) + + it('downloads the cy-prompt bundle and extracts it', async () => { + crossFetchStub.resolves({ + ok: true, + statusText: 'OK', + body: readStream, + headers: { + get: (header) => { + if (header === 'x-cypress-signature') { + return '159' + } + }, + }, + }) + + verifySignatureFromFileStub.resolves(true) + + const projectId = '12345' + + await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' }) + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-cypress-project-slug': '12345', + 'x-cypress-cy-prompt-mount-version': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + + expect(writeResult).to.eq('console.log("cy-prompt script")') + + expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159') + }) + + it('downloads the cy-prompt bundle and extracts it after 1 fetch failure', async () => { + crossFetchStub.onFirstCall().rejects(new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub())) + crossFetchStub.onSecondCall().resolves({ + ok: true, + statusText: 'OK', + body: readStream, + headers: { + get: (header) => { + if (header === 'x-cypress-signature') { + return '159' + } + }, + }, + }) + + verifySignatureFromFileStub.resolves(true) + + const projectId = '12345' + + await getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' }) + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-cypress-project-slug': '12345', + 'x-cypress-cy-prompt-mount-version': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + + expect(writeResult).to.eq('console.log("cy-prompt script")') + + expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159') + }) + + it('throws an error and returns a cy-prompt manager in error state if the fetch fails more than twice', async () => { + const error = new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub()) + + crossFetchStub.rejects(error) + + const projectId = '12345' + + await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected + + expect(crossFetchStub).to.be.calledThrice + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-cypress-project-slug': '12345', + 'x-cypress-cy-prompt-mount-version': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + }) + + it('throws an error and returns a cy-prompt manager in error state if the response status is not ok', async () => { + crossFetchStub.resolves({ + ok: false, + statusText: 'Some failure', + }) + + const projectId = '12345' + + await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-cypress-project-slug': '12345', + 'x-cypress-cy-prompt-mount-version': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + }) + + it('throws an error and returns a cy-prompt manager in error state if the signature verification fails', async () => { + verifySignatureFromFileStub.resolves(false) + + crossFetchStub.resolves({ + ok: true, + statusText: 'OK', + body: readStream, + headers: { + get: (header) => { + if (header === 'x-cypress-signature') { + return '159' + } + }, + }, + }) + + verifySignatureFromFileStub.resolves(false) + + const projectId = '12345' + + await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected + + expect(writeResult).to.eq('console.log("cy-prompt script")') + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-cypress-project-slug': '12345', + 'x-cypress-cy-prompt-mount-version': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + + expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/cy-prompt/abc/bundle.tar', '159') + }) + + it('throws an error if there is no signature in the response headers', async () => { + crossFetchStub.resolves({ + ok: true, + statusText: 'OK', + body: readStream, + headers: { + get: () => null, + }, + }) + + const projectId = '12345' + + await expect(getCyPromptBundle({ cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/cy-prompt/abc/bundle.tar' })).to.be.rejected + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/cy-prompt/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-cypress-project-slug': '12345', + 'x-cypress-cy-prompt-mount-version': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + }) +}) diff --git a/packages/server/test/unit/cloud/api/cy-prompt/post_cy_prompt_session_spec.ts b/packages/server/test/unit/cloud/api/cy-prompt/post_cy_prompt_session_spec.ts new file mode 100644 index 000000000000..00cf9055a98d --- /dev/null +++ b/packages/server/test/unit/cloud/api/cy-prompt/post_cy_prompt_session_spec.ts @@ -0,0 +1,79 @@ +import { SystemError } from '../../../../../lib/cloud/network/system_error' +import { proxyquire } from '../../../../spec_helper' +import os from 'os' +import { agent } from '@packages/network' +import pkg from '@packages/root' + +describe('postCyPromptSession', () => { + let postCyPromptSession: typeof import('@packages/server/lib/cloud/api/cy-prompt/post_cy_prompt_session').postCyPromptSession + let crossFetchStub: sinon.SinonStub = sinon.stub() + + beforeEach(() => { + crossFetchStub.reset() + postCyPromptSession = (proxyquire('@packages/server/lib/cloud/api/cy-prompt/post_cy_prompt_session', { + 'cross-fetch': crossFetchStub, + }) as typeof import('@packages/server/lib/cloud/api/cy-prompt/post_cy_prompt_session')).postCyPromptSession + }) + + it('should post a cy-prompt session', async () => { + crossFetchStub.resolves({ + ok: true, + json: () => { + return Promise.resolve({ + cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', + }) + }, + }) + + const result = await postCyPromptSession({ + projectId: '12345', + }) + + expect(result).to.deep.equal({ + cyPromptUrl: 'http://localhost:1234/cy-prompt/bundle/abc.tgz', + }) + + expect(crossFetchStub).to.have.been.calledOnce + expect(crossFetchStub).to.have.been.calledWith( + 'http://localhost:1234/cy-prompt/session', + { + method: 'POST', + agent, + headers: { + 'Content-Type': 'application/json', + 'x-os-name': os.platform(), + 'x-cypress-version': pkg.version, + }, + body: JSON.stringify({ projectSlug: '12345', cyPromptMountVersion: 1 }), + }, + ) + }) + + it('should throw immediately if the response is not ok', async () => { + crossFetchStub.resolves({ + ok: false, + statusText: 'Some failure', + json: () => { + return Promise.resolve({ + error: 'Failed to create cy-prompt session', + }) + }, + }) + + await expect(postCyPromptSession({ + projectId: '12345', + })).to.be.rejectedWith('Failed to create cy-prompt session: Some failure') + + expect(crossFetchStub).to.have.been.calledOnce + }) + + it('should throw an error if we receive a retryable error more than twice', async () => { + crossFetchStub.rejects(new SystemError(new Error('Failed to create cy-prompt session'), 'http://localhost:1234/cy-prompt/session')) + + await expect(postCyPromptSession({ + projectId: '12345', + })).to.be.rejected + + expect(crossFetchStub).to.have.been.calledThrice + }) +}) diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts new file mode 100644 index 000000000000..c755a15677f0 --- /dev/null +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -0,0 +1,254 @@ +import { sinon, proxyquire } from '../../../spec_helper' +import { expect } from 'chai' +import { CyPromptManager } from '../../../../lib/cloud/cy-prompt/CyPromptManager' +import { CyPromptLifecycleManager } from '../../../../lib/cloud/cy-prompt/CyPromptLifecycleManager' +import type { DataContext } from '@packages/data-context' +import type { Cfg } from '../../../../lib/project-base' +import type { CloudDataSource } from '@packages/data-context/src/sources' +import path from 'path' +import os from 'os' +import { CloudRequest } from '../../../../lib/cloud/api/cloud_request' +import { isRetryableError } from '../../../../lib/cloud/network/is_retryable_error' +import { asyncRetry } from '../../../../lib/util/async_retry' + +describe('CyPromptLifecycleManager', () => { + let cyPromptLifecycleManager: CyPromptLifecycleManager + let mockCyPromptManager: CyPromptManager + let mockCtx: DataContext + let mockCloudDataSource: CloudDataSource + let mockCfg: Cfg + let CyPromptLifecycleManager: typeof import('../../../../lib/cloud/cy-prompt/CyPromptLifecycleManager').CyPromptLifecycleManager + let postCyPromptSessionStub: sinon.SinonStub + let cyPromptStatusChangeEmitterStub: sinon.SinonStub + let ensureCyPromptBundleStub: sinon.SinonStub + let cyPromptManagerSetupStub: sinon.SinonStub = sinon.stub() + let readFileStub: sinon.SinonStub = sinon.stub() + + beforeEach(() => { + postCyPromptSessionStub = sinon.stub() + cyPromptManagerSetupStub = sinon.stub() + ensureCyPromptBundleStub = sinon.stub() + cyPromptStatusChangeEmitterStub = sinon.stub() + mockCyPromptManager = { + status: 'INITIALIZED', + setup: cyPromptManagerSetupStub.resolves(), + } as unknown as CyPromptManager + + readFileStub = sinon.stub() + CyPromptLifecycleManager = proxyquire('../lib/cloud/cy-prompt/CyPromptLifecycleManager', { + './ensure_cy_prompt_bundle': { + ensureCyPromptBundle: ensureCyPromptBundleStub, + }, + '../api/cy-prompt/post_cy_prompt_session': { + postCyPromptSession: postCyPromptSessionStub, + }, + './CyPromptManager': { + CyPromptManager: class CyPromptManager { + constructor () { + return mockCyPromptManager + } + }, + }, + 'fs-extra': { + readFile: readFileStub.resolves('console.log("cy-prompt script")'), + }, + }).CyPromptLifecycleManager + + cyPromptLifecycleManager = new CyPromptLifecycleManager() + + cyPromptStatusChangeEmitterStub = sinon.stub() + + mockCtx = { + update: sinon.stub(), + coreData: {}, + cloud: { + getCloudUrl: sinon.stub().returns('https://cloud.cypress.io'), + additionalHeaders: sinon.stub().resolves({ 'Authorization': 'Bearer test-token' }), + }, + emitter: { + cyPromptStatusChange: cyPromptStatusChangeEmitterStub, + }, + } as unknown as DataContext + + mockCloudDataSource = { + getCloudUrl: sinon.stub().returns('https://cloud.cypress.io'), + additionalHeaders: sinon.stub().resolves({ 'Authorization': 'Bearer test-token' }), + } as CloudDataSource + + mockCfg = { + projectId: 'abc123', + testingType: 'e2e', + projectRoot: '/test/project', + port: 8888, + proxyUrl: 'http://localhost:8888', + devServerPublicPathRoute: '/__cypress/src', + namespace: '__cypress', + } as unknown as Cfg + + postCyPromptSessionStub.resolves({ + cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz', + }) + }) + + afterEach(() => { + sinon.restore() + }) + + describe('initializeCyPromptManager', () => { + it('initializes the cy-prompt manager and registers it in the data context', async () => { + cyPromptLifecycleManager.initializeCyPromptManager({ + projectId: 'test-project-id', + cloudDataSource: mockCloudDataSource, + cfg: mockCfg, + debugData: {}, + ctx: mockCtx, + }) + + const cyPromptReadyPromise = new Promise((resolve) => { + cyPromptLifecycleManager?.registerCyPromptReadyListener((cyPromptManager) => { + resolve(cyPromptManager) + }) + }) + + await cyPromptReadyPromise + + expect(mockCtx.update).to.be.calledOnce + expect(ensureCyPromptBundleStub).to.be.calledWith({ + cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'), + cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz', + projectId: 'test-project-id', + bundlePath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'bundle.tar'), + }) + + expect(cyPromptManagerSetupStub).to.be.calledWith({ + script: 'console.log("cy-prompt script")', + cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'), + cyPromptHash: 'abc', + projectSlug: 'test-project-id', + cloudApi: { + cloudUrl: 'https://cloud.cypress.io', + cloudHeaders: { 'Authorization': 'Bearer test-token' }, + CloudRequest, + isRetryableError, + asyncRetry, + }, + }) + + expect(postCyPromptSessionStub).to.be.calledWith({ + projectId: 'test-project-id', + }) + + expect(mockCloudDataSource.getCloudUrl).to.be.calledWith('test') + expect(mockCloudDataSource.additionalHeaders).to.be.called + expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') + }) + }) + + describe('getCyPrompt', () => { + it('throws an error when cy-prompt manager is not initialized', async () => { + try { + await cyPromptLifecycleManager.getCyPrompt() + expect.fail('Expected method to throw') + } catch (error) { + expect(error.message).to.equal('cy prompt manager has not been initialized') + } + }) + + it('returns the cy-prompt manager when initialized', async () => { + // @ts-expect-error - accessing private property + cyPromptLifecycleManager.cyPromptManagerPromise = Promise.resolve(mockCyPromptManager) + + const result = await cyPromptLifecycleManager.getCyPrompt() + + expect(result).to.equal(mockCyPromptManager) + }) + }) + + describe('registerCyPromptReadyListener', () => { + it('registers a listener that will be called when cy-prompt is ready', () => { + const listener = sinon.stub() + + cyPromptLifecycleManager.registerCyPromptReadyListener(listener) + + // @ts-expect-error - accessing private property + expect(cyPromptLifecycleManager.listeners).to.include(listener) + }) + + it('calls listener immediately if cy-prompt is already ready', async () => { + const listener = sinon.stub() + + // @ts-expect-error - accessing private property + cyPromptLifecycleManager.cyPromptManager = mockCyPromptManager + + // @ts-expect-error - accessing non-existent property + cyPromptLifecycleManager.cyPromptReady = true + + cyPromptLifecycleManager.registerCyPromptReadyListener(listener) + + expect(listener).to.be.calledWith(mockCyPromptManager) + }) + + it('does not call listener if cy-prompt manager is null', async () => { + const listener = sinon.stub() + + // @ts-expect-error - accessing private property + cyPromptLifecycleManager.cyPromptManager = null + + // @ts-expect-error - accessing non-existent property + cyPromptLifecycleManager.cyPromptReady = true + + cyPromptLifecycleManager.registerCyPromptReadyListener(listener) + + expect(listener).not.to.be.called + }) + + it('adds multiple listeners to the list', () => { + const listener1 = sinon.stub() + const listener2 = sinon.stub() + + cyPromptLifecycleManager.registerCyPromptReadyListener(listener1) + cyPromptLifecycleManager.registerCyPromptReadyListener(listener2) + + // @ts-expect-error - accessing private property + expect(cyPromptLifecycleManager.listeners).to.include(listener1) + // @ts-expect-error - accessing private property + expect(cyPromptLifecycleManager.listeners).to.include(listener2) + }) + + it('cleans up listeners after calling them when cy-prompt becomes ready', async () => { + const listener1 = sinon.stub() + const listener2 = sinon.stub() + + cyPromptLifecycleManager.registerCyPromptReadyListener(listener1) + cyPromptLifecycleManager.registerCyPromptReadyListener(listener2) + + // @ts-expect-error - accessing private property + expect(cyPromptLifecycleManager.listeners.length).to.equal(2) + + const listenersCalledPromise = Promise.all([ + new Promise((resolve) => { + listener1.callsFake(() => resolve()) + }), + new Promise((resolve) => { + listener2.callsFake(() => resolve()) + }), + ]) + + cyPromptLifecycleManager.initializeCyPromptManager({ + projectId: 'test-project-id', + cloudDataSource: mockCloudDataSource, + cfg: mockCfg, + debugData: {}, + ctx: mockCtx, + }) + + await listenersCalledPromise + + expect(listener1).to.be.calledWith(mockCyPromptManager) + expect(listener2).to.be.calledWith(mockCyPromptManager) + + // @ts-expect-error - accessing private property + expect(cyPromptLifecycleManager.listeners.length).to.equal(0) + }) + }) +}) diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts new file mode 100644 index 000000000000..f57a98542cb9 --- /dev/null +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts @@ -0,0 +1,102 @@ +import { proxyquire, sinon } from '../../../spec_helper' +import path from 'path' +import type { CyPromptServerShape } from '@packages/types' +import { expect } from 'chai' +import esbuild from 'esbuild' +import type { CyPromptManager as CyPromptManagerShape } from '@packages/server/lib/cloud/cy-prompt/CyPromptManager' +import os from 'os' + +const { outputFiles: [{ contents: stubCyPromptRaw }] } = esbuild.buildSync({ + entryPoints: [path.join(__dirname, '..', '..', '..', 'support', 'fixtures', 'cloud', 'cy-prompt', 'test-cy-prompt.ts')], + bundle: true, + format: 'cjs', + write: false, + platform: 'node', +}) +const stubCyPrompt = new TextDecoder('utf-8').decode(stubCyPromptRaw) + +describe('lib/cloud/cy-prompt', () => { + let cyPromptManager: CyPromptManagerShape + let cyPrompt: CyPromptServerShape + let CyPromptManager: typeof import('@packages/server/lib/cloud/cy-prompt/CyPromptManager').CyPromptManager + + beforeEach(async () => { + CyPromptManager = (proxyquire('../lib/cloud/cy-prompt/CyPromptManager', { + }) as typeof import('@packages/server/lib/cloud/cy-prompt/CyPromptManager')).CyPromptManager + + cyPromptManager = new CyPromptManager() + await cyPromptManager.setup({ + script: stubCyPrompt, + cyPromptPath: 'path', + cyPromptHash: 'abcdefg', + projectSlug: '1234', + cloudApi: {} as any, + }) + + cyPrompt = (cyPromptManager as any)._cyPromptServer + + sinon.stub(os, 'platform').returns('darwin') + sinon.stub(os, 'arch').returns('x64') + }) + + afterEach(() => { + sinon.restore() + }) + + describe('synchronous method invocation', () => { + it('reports an error when a synchronous method fails', () => { + const error = new Error('foo') + + sinon.stub(cyPrompt, 'initializeRoutes').throws(error) + + cyPromptManager.initializeRoutes({} as any) + + expect(cyPromptManager.status).to.eq('IN_ERROR') + }) + }) + + describe('asynchronous method invocation', () => { + it('reports an error when a asynchronous method fails', async () => { + const error = new Error('foo') + + sinon.stub(cyPrompt, 'handleBackendRequest').throws(error) + + await cyPromptManager.handleBackendRequest('cy:prompt:start', {} as any) + + expect(cyPromptManager.status).to.eq('IN_ERROR') + }) + }) + + describe('initializeRoutes', () => { + it('initializes routes', () => { + sinon.stub(cyPrompt, 'initializeRoutes') + const mockRouter = sinon.stub() + + cyPromptManager.initializeRoutes(mockRouter) + + expect(cyPrompt.initializeRoutes).to.be.calledWith(mockRouter) + }) + }) + + describe('handleBackendRequest', () => { + it('calls handleBackendRequest on the cy prompt server', () => { + sinon.stub(cyPrompt, 'handleBackendRequest') + + cyPromptManager.handleBackendRequest('cy:prompt:start', {} as any) + + expect(cyPrompt.handleBackendRequest).to.be.calledWith('cy:prompt:start', {} as any) + }) + + it('does not call handleBackendRequest when cy prompt server is not defined', () => { + // Set _cyPromptServer to undefined + (cyPromptManager as any)._cyPromptServer = undefined + + // Create a spy on invokeSync to verify it's not called + const invokeSyncSpy = sinon.spy(cyPromptManager, 'invokeSync') + + cyPromptManager.handleBackendRequest('cy:prompt:start', {} as any) + + expect(invokeSyncSpy).to.not.be.called + }) + }) +}) diff --git a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts new file mode 100644 index 000000000000..fe1eec6fb3cb --- /dev/null +++ b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts @@ -0,0 +1,100 @@ +import path from 'path' +import os from 'os' +import { proxyquire, sinon } from '../../../spec_helper' + +describe('ensureCyPromptBundle', () => { + let ensureCyPromptBundle: typeof import('../../../../lib/cloud/cy-prompt/ensure_cy_prompt_bundle').ensureCyPromptBundle + let tmpdir: string = '/tmp' + let rmStub: sinon.SinonStub = sinon.stub() + let ensureStub: sinon.SinonStub = sinon.stub() + let copyStub: sinon.SinonStub = sinon.stub() + let readFileStub: sinon.SinonStub = sinon.stub() + let extractStub: sinon.SinonStub = sinon.stub() + let getCyPromptBundleStub: sinon.SinonStub = sinon.stub() + + beforeEach(() => { + rmStub = sinon.stub() + ensureStub = sinon.stub() + copyStub = sinon.stub() + readFileStub = sinon.stub() + extractStub = sinon.stub() + getCyPromptBundleStub = sinon.stub() + + ensureCyPromptBundle = (proxyquire('../lib/cloud/cy-prompt/ensure_cy_prompt_bundle', { + os: { + tmpdir: () => tmpdir, + platform: () => 'linux', + }, + 'fs-extra': { + remove: rmStub.resolves(), + ensureDir: ensureStub.resolves(), + copy: copyStub.resolves(), + readFile: readFileStub.resolves('console.log("cy-prompt script")'), + }, + tar: { + extract: extractStub.resolves(), + }, + '../api/cy-prompt/get_cy_prompt_bundle': { + getCyPromptBundle: getCyPromptBundleStub.resolves(), + }, + })).ensureCyPromptBundle + }) + + describe('CYPRESS_LOCAL_CY_PROMPT_PATH not set', () => { + beforeEach(() => { + delete process.env.CYPRESS_LOCAL_CY_PROMPT_PATH + }) + + it('should ensure the cy prompt bundle', async () => { + const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', '123') + const bundlePath = path.join(cyPromptPath, 'bundle.tar') + + await ensureCyPromptBundle({ + cyPromptPath, + cyPromptUrl: 'https://cypress.io/cy-prompt', + projectId: '123', + bundlePath, + }) + + expect(rmStub).to.be.calledWith(cyPromptPath) + expect(ensureStub).to.be.calledWith(cyPromptPath) + expect(getCyPromptBundleStub).to.be.calledWith({ + cyPromptUrl: 'https://cypress.io/cy-prompt', + projectId: '123', + bundlePath, + }) + + expect(extractStub).to.be.calledWith({ + file: bundlePath, + cwd: cyPromptPath, + }) + }) + }) + + describe('CYPRESS_LOCAL_CY_PROMPT_PATH set', () => { + beforeEach(() => { + process.env.CYPRESS_LOCAL_CY_PROMPT_PATH = '/path/to/cy-prompt' + }) + + afterEach(() => { + delete process.env.CYPRESS_LOCAL_CY_PROMPT_PATH + }) + + it('should ensure the cy prompt bundle', async () => { + const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', '123') + const bundlePath = path.join(cyPromptPath, 'bundle.tar') + + await ensureCyPromptBundle({ + cyPromptPath, + cyPromptUrl: 'https://cypress.io/cy-prompt', + projectId: '123', + bundlePath, + }) + + expect(rmStub).to.be.calledWith(cyPromptPath) + expect(ensureStub).to.be.calledWith(cyPromptPath) + expect(copyStub).to.be.calledWith('/path/to/cy-prompt/driver', path.join(cyPromptPath, 'driver')) + expect(copyStub).to.be.calledWith('/path/to/cy-prompt/server', path.join(cyPromptPath, 'server')) + }) + }) +}) diff --git a/packages/server/test/unit/project_spec.js b/packages/server/test/unit/project_spec.js index 569e025b9182..e18c14a1cc5e 100644 --- a/packages/server/test/unit/project_spec.js +++ b/packages/server/test/unit/project_spec.js @@ -16,7 +16,8 @@ const system = require(`../../lib/util/system`) const { getCtx } = require(`../../lib/makeDataContext`) const studio = require('../../lib/cloud/api/studio/get_and_initialize_studio_manager') const browsers = require('../../lib/browsers') -const { StudioLifecycleManager } = require('../../lib/StudioLifecycleManager') +const { CyPromptLifecycleManager } = require('../../lib/cloud/cy-prompt/CyPromptLifecycleManager') +const { StudioLifecycleManager } = require('../../lib/cloud/StudioLifecycleManager') const { StudioManager } = require('../../lib/cloud/studio') let ctx @@ -49,6 +50,8 @@ describe('lib/project-base', () => { sinon.stub(studio, 'getAndInitializeStudioManager').resolves(this.testStudioManager) + CyPromptLifecycleManager.prototype.initializeCyPromptManager = sinon.stub() + await ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(this.todosPath) this.config = await ctx.project.getConfig() @@ -451,6 +454,42 @@ This option will not have an effect in Some-other-name. Tests that rely on web s }) }) + describe('CyPromptLifecycleManager', function () { + it('initializes cy prompt lifecycle manager', function () { + this.config.projectId = 'abc123' + this.config.experimentalCyPrompt = true + + return this.project.open() + .then(() => { + expect(CyPromptLifecycleManager.prototype.initializeCyPromptManager).to.be.calledWith({ + projectId: 'abc123', + cloudDataSource: ctx.cloud, + ctx, + }) + }) + }) + + it('does not initialize cy prompt lifecycle manager if experimentalCyPrompt is not enabled', function () { + this.config.projectId = 'abc123' + this.config.experimentalCyPrompt = false + + return this.project.open() + .then(() => { + expect(CyPromptLifecycleManager.prototype.initializeCyPromptManager).not.to.be.called + }) + }) + + it('does not initialize cy prompt lifecycle manager if projectId is not set', function () { + this.config.projectId = undefined + this.config.experimentalCyPrompt = true + + return this.project.open() + .then(() => { + expect(CyPromptLifecycleManager.prototype.initializeCyPromptManager).not.to.be.called + }) + }) + }) + describe('saved state', function () { beforeEach(function () { this._time = 1609459200000 diff --git a/packages/server/test/unit/routes_spec.ts b/packages/server/test/unit/routes_spec.ts index 9f4e52ae42a7..c8f37a55a3fb 100644 --- a/packages/server/test/unit/routes_spec.ts +++ b/packages/server/test/unit/routes_spec.ts @@ -263,56 +263,24 @@ describe('lib/routes', () => { expect(studioManager.initializeRoutes).to.be.calledWith(router) }) - it('initializes a dummy route for studio if studio is not present', () => { - delete getCtx().coreData.studioLifecycleManager - - const studioRouter = { - get: sinon.stub(), - post: sinon.stub(), - all: sinon.stub(), - use: sinon.stub(), - } - - const router = { - get: sinon.stub(), - post: sinon.stub(), - all: sinon.stub(), - use: sinon.stub().withArgs('/').returns(studioRouter), - } - - const Router = sinon.stub() - - Router.onFirstCall().returns(router) - Router.onSecondCall().returns(studioRouter) - - const { createCommonRoutes } = proxyquire('../../lib/routes', { - 'express': { Router }, - }) - - createCommonRoutes(routeOptions) - - expect(router.use).to.have.been.calledWith('/') - - expect(Router).to.have.been.calledTwice - - expect(getCtx().coreData.studioLifecycleManager).to.be.undefined - }) - - it('does not initialize routes on studio if status is in error', () => { - const studioManager = { - status: 'IN_ERROR', + it('initializes routes on cy prompt if present', () => { + const cyPromptManager = { initializeRoutes: sinon.stub(), } - const studioLifecycleManager = { - registerStudioReadyListener: sinon.stub().returns(() => {}), + const cyPromptLifecycleManager = { + registerCyPromptReadyListener: sinon.stub().callsFake((callback) => { + callback(cyPromptManager) + + return () => {} + }), } - getCtx().coreData.studioLifecycleManager = studioLifecycleManager as any + getCtx().coreData.cyPromptLifecycleManager = cyPromptLifecycleManager as any - setupCommonRoutes() + const { router } = setupCommonRoutes() - expect(studioManager.initializeRoutes).not.to.be.called + expect(cyPromptManager.initializeRoutes).to.be.calledWith(router) }) }) }) diff --git a/packages/server/test/unit/socket_spec.js b/packages/server/test/unit/socket_spec.js index 613cef3fbf04..e22b260b9774 100644 --- a/packages/server/test/unit/socket_spec.js +++ b/packages/server/test/unit/socket_spec.js @@ -87,8 +87,25 @@ describe('lib/socket', () => { }), } + // Create a mock cy prompt object with handleBackendRequest method + const mockCyPrompt = { + handleBackendRequest: sinon.stub().resolves({ foo: 'bar' }), + status: 'INITIALIZED', + } + ctx.coreData.studioLifecycleManager = studioLifecycleManager + const cyPromptLifecycleManager = { + getCyPrompt: sinon.stub().resolves(mockCyPrompt), + registerCyPromptReadyListener: sinon.stub().callsFake((callback) => { + callback(mockCyPrompt) + + return () => {} + }), + } + + ctx.coreData.cyPromptLifecycleManager = cyPromptLifecycleManager + this.server.startWebsockets(this.automation, this.cfg, this.options) this.socket = this.server._socket @@ -530,6 +547,60 @@ describe('lib/socket', () => { }) }) + context('on(backend:request, wait:for:cy:prompt:ready)', () => { + it('awaits cy prompt ready and returns true if cy prompt is ready', function (done) { + const mockCyPrompt = { + status: 'INITIALIZED', + } + + ctx.coreData.cyPromptLifecycleManager.getCyPrompt.resolves(mockCyPrompt) + + return this.client.emit('backend:request', 'wait:for:cy:prompt:ready', (resp) => { + expect(resp.response).to.deep.eq({ success: true }) + + return done() + }) + }) + + it('awaits cy prompt ready and returns false if cy prompt is not ready', function (done) { + const mockCyPrompt = { + status: 'NOT_INITIALIZED', + } + + ctx.coreData.cyPromptLifecycleManager.getCyPrompt.resolves(mockCyPrompt) + + return this.client.emit('backend:request', 'wait:for:cy:prompt:ready', (resp) => { + expect(resp.response).to.deep.eq({ success: false }) + + return done() + }) + }) + }) + + context('on(backend:request, cy:prompt)', () => { + it('calls handleBackendRequest with the correct arguments', function (done) { + // Verify that registerCyPromptReadyListener was called + expect(ctx.coreData.cyPromptLifecycleManager.registerCyPromptReadyListener).to.be.called + + // Check that the callback was called with the mock cy prompt object + const registerCyPromptReadyListenerCallback = ctx.coreData.cyPromptLifecycleManager.registerCyPromptReadyListener.firstCall.args[0] + + expect(registerCyPromptReadyListenerCallback).to.be.a('function') + + // Verify the mock cy prompt's handleBackendRequest was called by the callback + const mockCyPrompt = { handleBackendRequest: sinon.stub().resolves({ foo: 'bar' }) } + + registerCyPromptReadyListenerCallback(mockCyPrompt) + + return this.client.emit('backend:request', 'cy:prompt:init', 'foo', (resp) => { + expect(resp.response).to.deep.eq({ foo: 'bar' }) + expect(mockCyPrompt.handleBackendRequest).to.be.calledWith('cy:prompt:init', 'foo') + + return done() + }) + }) + }) + context('on(save:app:state)', () => { it('calls onSavedStateChanged with the state', function (done) { return this.client.emit('save:app:state', { reporterWidth: 500 }, () => { diff --git a/packages/types/src/cy-prompt/index.ts b/packages/types/src/cy-prompt/index.ts index fdb0db0a0294..f919c2f2f219 100644 --- a/packages/types/src/cy-prompt/index.ts +++ b/packages/types/src/cy-prompt/index.ts @@ -12,6 +12,5 @@ export interface CyPromptManagerShape extends CyPromptServerShape { export interface CyPromptLifecycleManagerShape { getCyPrompt: () => Promise - isCyPromptReady: () => boolean registerCyPromptReadyListener: (listener: (cyPromptManager: CyPromptManagerShape) => void) => void } diff --git a/system-tests/__snapshots__/results_spec.ts.js b/system-tests/__snapshots__/results_spec.ts.js index ba61829ae392..3089077aabd9 100644 --- a/system-tests/__snapshots__/results_spec.ts.js +++ b/system-tests/__snapshots__/results_spec.ts.js @@ -31,6 +31,7 @@ exports['module api and after:run results'] = ` "experimentalSourceRewriting": false, "experimentalSingleTabRunMode": false, "experimentalStudio": false, + "experimentalCyPrompt": false, "experimentalWebKitSupport": false, "fileServerFolder": "/path/to/fileServerFolder", "fixturesFolder": "/path/to/fixturesFolder", From b843dd5490361609c811aa672b6b848d0532a27b Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sun, 18 May 2025 22:04:59 -0500 Subject: [PATCH 03/23] refactor --- .../unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts index c755a15677f0..752f63e4d85e 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -16,7 +16,6 @@ describe('CyPromptLifecycleManager', () => { let mockCyPromptManager: CyPromptManager let mockCtx: DataContext let mockCloudDataSource: CloudDataSource - let mockCfg: Cfg let CyPromptLifecycleManager: typeof import('../../../../lib/cloud/cy-prompt/CyPromptLifecycleManager').CyPromptLifecycleManager let postCyPromptSessionStub: sinon.SinonStub let cyPromptStatusChangeEmitterStub: sinon.SinonStub @@ -99,8 +98,6 @@ describe('CyPromptLifecycleManager', () => { cyPromptLifecycleManager.initializeCyPromptManager({ projectId: 'test-project-id', cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, ctx: mockCtx, }) @@ -237,8 +234,6 @@ describe('CyPromptLifecycleManager', () => { cyPromptLifecycleManager.initializeCyPromptManager({ projectId: 'test-project-id', cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, ctx: mockCtx, }) From 4ecb321bd1e51732f377b6526bef701893431b8d Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 20 May 2025 14:17:28 -0500 Subject: [PATCH 04/23] rename experimental config --- cli/types/cypress.d.ts | 4 ++-- packages/config/__snapshots__/index.spec.ts.js | 6 +++--- packages/config/src/options.ts | 2 +- packages/config/test/project/utils.spec.ts | 4 ++-- packages/driver/cypress.config.ts | 1 + packages/driver/cypress/e2e/commands/prompt.cy.ts | 10 +++++----- packages/driver/src/cy/commands/prompt/index.ts | 4 ++-- .../src/cy/commands/prompt/prompt-driver-types.ts | 6 +++++- packages/frontend-shared/src/locales/en-US.json | 6 +++--- packages/server/lib/experiments.ts | 4 ++-- packages/server/lib/project-base.ts | 2 +- packages/server/test/unit/project_spec.js | 8 ++++---- packages/types/src/config.ts | 2 +- system-tests/__snapshots__/results_spec.ts.js | 2 +- 14 files changed, 33 insertions(+), 28 deletions(-) diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index c87f4f26bc21..563da5d31bdd 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -3159,10 +3159,10 @@ declare namespace Cypress { */ experimentalStudio: boolean /** - * Enables the Cy-Prompt feature. + * Enables the prompt command feature. * @default false */ - experimentalCyPrompt: boolean + experimentalPromptCommand: boolean /** * Adds support for testing in the WebKit browser engine used by Safari. See https://on.cypress.io/webkit-experiment for more information. * @default false diff --git a/packages/config/__snapshots__/index.spec.ts.js b/packages/config/__snapshots__/index.spec.ts.js index 51a035c064be..852c5c13027f 100644 --- a/packages/config/__snapshots__/index.spec.ts.js +++ b/packages/config/__snapshots__/index.spec.ts.js @@ -46,7 +46,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys 1 'experimentalSourceRewriting': false, 'experimentalSingleTabRunMode': false, 'experimentalStudio': false, - 'experimentalCyPrompt': false, + 'experimentalPromptCommand': false, 'experimentalWebKitSupport': false, 'fileServerFolder': '', 'fixturesFolder': 'cypress/fixtures', @@ -138,7 +138,7 @@ exports['config/src/index .getDefaultValues returns list of public config keys f 'experimentalSourceRewriting': false, 'experimentalSingleTabRunMode': false, 'experimentalStudio': false, - 'experimentalCyPrompt': false, + 'experimentalPromptCommand': false, 'experimentalWebKitSupport': false, 'fileServerFolder': '', 'fixturesFolder': 'cypress/fixtures', @@ -226,7 +226,7 @@ exports['config/src/index .getPublicConfigKeys returns list of public config key 'experimentalSourceRewriting', 'experimentalSingleTabRunMode', 'experimentalStudio', - 'experimentalCyPrompt', + 'experimentalPromptCommand', 'experimentalWebKitSupport', 'fileServerFolder', 'fixturesFolder', diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index f78a7b437528..6b91d4ec7540 100644 --- a/packages/config/src/options.ts +++ b/packages/config/src/options.ts @@ -260,7 +260,7 @@ const driverConfigOptions: Array = [ isExperimental: true, requireRestartOnChange: 'browser', }, { - name: 'experimentalCyPrompt', + name: 'experimentalPromptCommand', defaultValue: false, validation: validate.isBoolean, isExperimental: true, diff --git a/packages/config/test/project/utils.spec.ts b/packages/config/test/project/utils.spec.ts index 2fd814ce8280..85a23858bda3 100644 --- a/packages/config/test/project/utils.spec.ts +++ b/packages/config/test/project/utils.spec.ts @@ -1078,7 +1078,7 @@ describe('config/src/project/utils', () => { experimentalRunAllSpecs: { value: false, from: 'default' }, experimentalSingleTabRunMode: { value: false, from: 'default' }, experimentalStudio: { value: false, from: 'default' }, - experimentalCyPrompt: { value: false, from: 'default' }, + experimentalPromptCommand: { value: false, from: 'default' }, experimentalSourceRewriting: { value: false, from: 'default' }, experimentalWebKitSupport: { value: false, from: 'default' }, fileServerFolder: { value: '', from: 'default' }, @@ -1198,7 +1198,7 @@ describe('config/src/project/utils', () => { experimentalRunAllSpecs: { value: false, from: 'default' }, experimentalSingleTabRunMode: { value: false, from: 'default' }, experimentalStudio: { value: false, from: 'default' }, - experimentalCyPrompt: { value: false, from: 'default' }, + experimentalPromptCommand: { value: false, from: 'default' }, experimentalSourceRewriting: { value: false, from: 'default' }, experimentalWebKitSupport: { value: false, from: 'default' }, fileServerFolder: { value: '', from: 'default' }, diff --git a/packages/driver/cypress.config.ts b/packages/driver/cypress.config.ts index 13c8b45dcf83..d7df5cbee75b 100644 --- a/packages/driver/cypress.config.ts +++ b/packages/driver/cypress.config.ts @@ -7,6 +7,7 @@ export const baseConfig: Cypress.ConfigOptions = { experimentalStudio: true, experimentalMemoryManagement: true, experimentalWebKitSupport: true, + experimentalPromptCommand: true, hosts: { 'foobar.com': '127.0.0.1', '*.foobar.com': '127.0.0.1', diff --git a/packages/driver/cypress/e2e/commands/prompt.cy.ts b/packages/driver/cypress/e2e/commands/prompt.cy.ts index 0b3b4d517e59..9fb327349909 100644 --- a/packages/driver/cypress/e2e/commands/prompt.cy.ts +++ b/packages/driver/cypress/e2e/commands/prompt.cy.ts @@ -1,9 +1,9 @@ describe('src/cy/commands/prompt', () => { - it('errors when cy.prompt() is not enabled', () => { - cy.visit('/fixtures/input_events.html') + it('executes the prompt command', () => { + cy.visit('/fixtures/dom.html') - cy.prompt('Hello, world!').should('throw') + // TODO: add more tests when cy.prompt is built out, but for now this just + // verifies that the command executes without throwing an error + cy.prompt('Hello, world!') }) - - // TODO: add more tests when cy.prompt is deployed }) diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index a95358660194..f5de1be01ed4 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -37,9 +37,9 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise { Commands.addAll({ async prompt (message: string) { - if (!Cypress.config('experimentalCyPrompt')) { + if (!Cypress.config('experimentalPromptCommand')) { // TODO: what do we want to do here? - throw new Error('cy.prompt() is not enabled. Please enable it by setting `experimentalCyPrompt: true` in your Cypress config.') + throw new Error('cy.prompt() is not enabled. Please enable it by setting `experimentalPromptCommand: true` in your Cypress config.') } try { diff --git a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts index 589a765e3be9..3f742f4b9678 100644 --- a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts +++ b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts @@ -1,3 +1,7 @@ +export interface CypressInternal extends Cypress.Cypress { + backend: (eventName: string, ...args: any[]) => Promise +} + export interface CyPromptDriverDefaultShape { - cyPrompt: (Cypress: any, text: string) => Promise + cyPrompt: (Cypress: CypressInternal, text: string) => Promise } diff --git a/packages/frontend-shared/src/locales/en-US.json b/packages/frontend-shared/src/locales/en-US.json index 4fc3a1d3669d..3f009cb586a8 100644 --- a/packages/frontend-shared/src/locales/en-US.json +++ b/packages/frontend-shared/src/locales/en-US.json @@ -613,9 +613,9 @@ "name": "Studio", "description": "Generate and save commands directly to your test suite by interacting with your app as an end user would." }, - "experimentalCyPrompt": { - "name": "cy.prompt", - "description": "Enables support for the `cy.prompt()` command." + "experimentalPromptCommand": { + "name": "Prompt command", + "description": "Enables support for the prompt command." }, "experimentalWebKitSupport": { "name": "WebKit Support", diff --git a/packages/server/lib/experiments.ts b/packages/server/lib/experiments.ts index 921483d7773b..555bbca5c9db 100644 --- a/packages/server/lib/experiments.ts +++ b/packages/server/lib/experiments.ts @@ -60,7 +60,7 @@ const _summaries: StringValues = { experimentalRunAllSpecs: 'Enables the "Run All Specs" UI feature, allowing the execution of multiple specs sequentially', experimentalOriginDependencies: 'Enables support for `Cypress.require()` for including dependencies within the `cy.origin()` callback.', experimentalMemoryManagement: 'Enables support for improved memory management within Chromium-based browsers.', - experimentalCyPrompt: 'Enables support for the `cy.prompt()` command.', + experimentalPromptCommand: 'Enables support for the prompt command.', } /** @@ -83,7 +83,7 @@ const _names: StringValues = { experimentalRunAllSpecs: 'Run All Specs', experimentalOriginDependencies: 'Origin Dependencies', experimentalMemoryManagement: 'Memory Management', - experimentalCyPrompt: 'cy.prompt', + experimentalPromptCommand: 'Prompt Command', } /** diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 9054d916fa71..42d0986d6477 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -157,7 +157,7 @@ export class ProjectBase extends EE { process.chdir(this.projectRoot) this._server = new ServerBase(cfg) - if (cfg.projectId && cfg.experimentalCyPrompt) { + if (cfg.projectId && cfg.experimentalPromptCommand) { const cyPromptLifecycleManager = new CyPromptLifecycleManager() cyPromptLifecycleManager.initializeCyPromptManager({ diff --git a/packages/server/test/unit/project_spec.js b/packages/server/test/unit/project_spec.js index e18c14a1cc5e..448a121618aa 100644 --- a/packages/server/test/unit/project_spec.js +++ b/packages/server/test/unit/project_spec.js @@ -457,7 +457,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s describe('CyPromptLifecycleManager', function () { it('initializes cy prompt lifecycle manager', function () { this.config.projectId = 'abc123' - this.config.experimentalCyPrompt = true + this.config.experimentalPromptCommand = true return this.project.open() .then(() => { @@ -469,9 +469,9 @@ This option will not have an effect in Some-other-name. Tests that rely on web s }) }) - it('does not initialize cy prompt lifecycle manager if experimentalCyPrompt is not enabled', function () { + it('does not initialize cy prompt lifecycle manager if experimentalPromptCommand is not enabled', function () { this.config.projectId = 'abc123' - this.config.experimentalCyPrompt = false + this.config.experimentalPromptCommand = false return this.project.open() .then(() => { @@ -481,7 +481,7 @@ This option will not have an effect in Some-other-name. Tests that rely on web s it('does not initialize cy prompt lifecycle manager if projectId is not set', function () { this.config.projectId = undefined - this.config.experimentalCyPrompt = true + this.config.experimentalPromptCommand = true return this.project.open() .then(() => { diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index b60a5416f17b..84b11e45aed9 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -30,7 +30,7 @@ export interface FullConfig extends Partial - & Pick // TODO: Figure out how to type this better. + & Pick // TODO: Figure out how to type this better. export interface SettingsOptions { testingType?: 'component' |'e2e' diff --git a/system-tests/__snapshots__/results_spec.ts.js b/system-tests/__snapshots__/results_spec.ts.js index 3089077aabd9..dfbf7708d64b 100644 --- a/system-tests/__snapshots__/results_spec.ts.js +++ b/system-tests/__snapshots__/results_spec.ts.js @@ -31,7 +31,7 @@ exports['module api and after:run results'] = ` "experimentalSourceRewriting": false, "experimentalSingleTabRunMode": false, "experimentalStudio": false, - "experimentalCyPrompt": false, + "experimentalPromptCommand": false, "experimentalWebKitSupport": false, "fileServerFolder": "/path/to/fileServerFolder", "fixturesFolder": "/path/to/fixturesFolder", From aca2301003f8e1a0102bd6cbe2b2a8472618fb6e Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 20 May 2025 14:59:14 -0500 Subject: [PATCH 05/23] prompt --- cli/types/cypress.d.ts | 5 +++++ packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts | 2 +- packages/driver/src/cy/commands/prompt/index.ts | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 563da5d31bdd..6611f3ec08ef 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -1826,6 +1826,11 @@ declare namespace Cypress { */ prevUntil(element: E | JQuery, filter?: string, options?: Partial): Chainable> + /** + * TODO: add docs + */ + prompt(message: string, options?: Partial): Chainable + /** * Read a file and yield its contents. * diff --git a/packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts b/packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts index a58e07bda1e2..9d9765720482 100644 --- a/packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/commands/misc.cy.ts @@ -224,7 +224,7 @@ it('verifies number of cy commands', () => { 'writeFile', 'fixture', 'clearLocalStorage', 'url', 'hash', 'location', 'end', 'noop', 'log', 'wrap', 'reload', 'go', 'visit', 'focused', 'get', 'contains', 'shadow', 'within', 'request', 'session', 'screenshot', 'task', 'find', 'filter', 'not', 'children', 'eq', 'closest', 'first', 'last', 'next', 'nextAll', 'nextUntil', 'parent', 'parents', 'parentsUntil', 'prev', 'press', - 'prevAll', 'prevUntil', 'siblings', 'wait', 'title', 'window', 'document', 'viewport', 'server', 'route', 'intercept', 'origin', + 'prevAll', 'prevUntil', 'prompt', 'siblings', 'wait', 'title', 'window', 'document', 'viewport', 'server', 'route', 'intercept', 'origin', 'mount', 'as', 'root', 'getAllLocalStorage', 'clearAllLocalStorage', 'getAllSessionStorage', 'clearAllSessionStorage', 'getAllCookies', 'clearAllCookies', ] diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index f5de1be01ed4..1dcfe5980478 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -1,5 +1,5 @@ import { init, loadRemote } from '@module-federation/runtime' -import { CyPromptDriverDefaultShape } from './prompt-driver-types' +import type{ CyPromptDriverDefaultShape } from './prompt-driver-types' interface CyPromptDriver { default: CyPromptDriverDefaultShape } From bdbc924269cf769ce9b0595b7326d3215ed50980 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 20 May 2025 15:25:52 -0500 Subject: [PATCH 06/23] fix test --- .../cloud/cy-prompt/CyPromptLifecycleManager_spec.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts index 752f63e4d85e..bbe38fb9beed 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -3,7 +3,6 @@ import { expect } from 'chai' import { CyPromptManager } from '../../../../lib/cloud/cy-prompt/CyPromptManager' import { CyPromptLifecycleManager } from '../../../../lib/cloud/cy-prompt/CyPromptLifecycleManager' import type { DataContext } from '@packages/data-context' -import type { Cfg } from '../../../../lib/project-base' import type { CloudDataSource } from '@packages/data-context/src/sources' import path from 'path' import os from 'os' @@ -74,16 +73,6 @@ describe('CyPromptLifecycleManager', () => { additionalHeaders: sinon.stub().resolves({ 'Authorization': 'Bearer test-token' }), } as CloudDataSource - mockCfg = { - projectId: 'abc123', - testingType: 'e2e', - projectRoot: '/test/project', - port: 8888, - proxyUrl: 'http://localhost:8888', - devServerPublicPathRoute: '/__cypress/src', - namespace: '__cypress', - } as unknown as Cfg - postCyPromptSessionStub.resolves({ cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz', }) From d7598f4defe3e72219bb2d8c5e8551ef88f31a51 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 21 May 2025 12:27:07 -0500 Subject: [PATCH 07/23] chore: prototype moving the plugin to cloud delivered code --- .../driver/src/cy/commands/prompt/index.ts | 28 ++++++++++++------- packages/server/lib/socket-base.ts | 2 +- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 1dcfe5980478..ba5ccb760d40 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -36,24 +36,32 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise { Commands.addAll({ - async prompt (message: string) { + prompt (message: string) { if (!Cypress.config('experimentalPromptCommand')) { // TODO: what do we want to do here? throw new Error('cy.prompt() is not enabled. Please enable it by setting `experimentalPromptCommand: true` in your Cypress config.') } - try { - let cloud = initializedCyPrompt + const getCloud = async () => { + try { + let cloud = initializedCyPrompt - if (!cloud) { - cloud = await initializeCloudCyPrompt(Cypress) + console.log('cloud', cloud) + if (!cloud) { + cloud = await initializeCloudCyPrompt(Cypress) + } + console.log('cloud', cloud) + return cloud + } catch (error) { + // TODO: handle this better + console.error('Error in cy.prompt()', error) + throw new Error('CyPromptDriver not found') } - - return await cloud.cyPrompt(Cypress, message) - } catch (error) { - // TODO: handle this better - throw new Error('CyPromptDriver not found') } + + return cy.wrap(getCloud(), { log: false }).then((cloud) => { + return cloud.cyPrompt(Cypress, message) + }) }, }) } diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index e99e18f5d2bd..cd47f818a042 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -463,7 +463,7 @@ export class SocketBase { debug('backend:request %o', { eventName, args }) const backendRequest = () => { - if (eventName.startsWith('cy:prompt:')) { + if (eventName.startsWith('prompt:')) { return cyPrompt?.handleBackendRequest(eventName, ...args) } From 150f98fc79510b5c067ebf64527afe4c25ca269c Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Thu, 22 May 2025 08:55:12 -0500 Subject: [PATCH 08/23] move CDP local --- .../driver/src/cy/commands/prompt/index.ts | 4 ++-- .../server/lib/browsers/browser-cri-client.ts | 15 ++++++++++++++- packages/server/lib/browsers/chrome.ts | 16 +++++++++++++++- packages/server/lib/browsers/electron.ts | 18 ++++++++++++++++-- packages/server/lib/browsers/firefox.ts | 5 +++++ packages/server/lib/browsers/index.ts | 8 +++++++- packages/server/lib/browsers/types.ts | 6 +++++- packages/server/lib/browsers/webkit.ts | 5 +++++ .../cy-prompt/CyPromptLifecycleManager.ts | 1 + .../lib/cloud/cy-prompt/CyPromptManager.ts | 8 ++++++++ packages/server/lib/open_project.ts | 4 ++++ packages/server/lib/project-base.ts | 6 +++++- packages/server/lib/socket-base.ts | 2 ++ .../src/cy-prompt/cy-prompt-server-types.ts | 18 ++++++++++++++++++ 14 files changed, 107 insertions(+), 9 deletions(-) diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index ba5ccb760d40..53ccdf5d9fbb 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -46,14 +46,14 @@ export default (Commands, Cypress, cy) => { try { let cloud = initializedCyPrompt - console.log('cloud', cloud) if (!cloud) { cloud = await initializeCloudCyPrompt(Cypress) } - console.log('cloud', cloud) + return cloud } catch (error) { // TODO: handle this better + // eslint-disable-next-line no-console console.error('Error in cy.prompt()', error) throw new Error('CyPromptDriver not found') } diff --git a/packages/server/lib/browsers/browser-cri-client.ts b/packages/server/lib/browsers/browser-cri-client.ts index fe6aa66851a0..69683399d079 100644 --- a/packages/server/lib/browsers/browser-cri-client.ts +++ b/packages/server/lib/browsers/browser-cri-client.ts @@ -8,7 +8,7 @@ import * as errors from '../errors' import type { CypressError } from '@packages/errors' import { CriClient, DEFAULT_NETWORK_ENABLE_OPTIONS } from './cri-client' import { serviceWorkerClientEventHandler, serviceWorkerClientEventHandlerName } from '@packages/proxy/lib/http/util/service-worker-manager' -import type { ProtocolManagerShape } from '@packages/types' +import type { CyPromptManagerShape, ProtocolManagerShape } from '@packages/types' import type { ServiceWorkerEventHandler } from '@packages/proxy/lib/http/util/service-worker-manager' const debug = Debug('cypress:server:browsers:browser-cri-client') @@ -26,6 +26,7 @@ type BrowserCriClientOptions = { browserName: string onAsynchronousError: (err: CypressError) => void protocolManager?: ProtocolManagerShape + cyPromptManager?: CyPromptManagerShape fullyManageTabs?: boolean onServiceWorkerClientEvent: ServiceWorkerEventHandler } @@ -38,6 +39,7 @@ type BrowserCriClientCreateOptions = { onReconnect?: (client: CriClient) => void port: number protocolManager?: ProtocolManagerShape + cyPromptManager?: CyPromptManagerShape onServiceWorkerClientEvent: ServiceWorkerEventHandler } @@ -184,10 +186,12 @@ export class BrowserCriClient { private browserName: string private onAsynchronousError: (err: CypressError) => void private protocolManager?: ProtocolManagerShape + private cyPromptManager?: CyPromptManagerShape private fullyManageTabs?: boolean onServiceWorkerClientEvent: ServiceWorkerEventHandler currentlyAttachedTarget: CriClient | undefined currentlyAttachedProtocolTarget: CriClient | undefined + currentlyAttachedCyPromptTarget: CriClient | undefined // whenever we instantiate the instance we're already connected bc // we receive an underlying CRI connection // TODO: remove "connected" in favor of closing/closed or disconnected @@ -207,6 +211,7 @@ export class BrowserCriClient { this.browserName = options.browserName this.onAsynchronousError = options.onAsynchronousError this.protocolManager = options.protocolManager + this.cyPromptManager = options.cyPromptManager this.fullyManageTabs = options.fullyManageTabs this.onServiceWorkerClientEvent = options.onServiceWorkerClientEvent } @@ -223,6 +228,7 @@ export class BrowserCriClient { * @param options.onReconnect callback for when the browser cri client reconnects to the browser * @param options.port the port to which to connect * @param options.protocolManager the protocol manager to use with the browser cri client + * @param options.cyPromptManager the cy prompt manager to use with the browser cri client * @param options.onServiceWorkerClientEvent callback for when a service worker fetch event is received * @returns a wrapper around the chrome remote interface that is connected to the browser target */ @@ -235,6 +241,7 @@ export class BrowserCriClient { onReconnect, port, protocolManager, + cyPromptManager, onServiceWorkerClientEvent, } = options @@ -259,6 +266,7 @@ export class BrowserCriClient { browserName, onAsynchronousError, protocolManager, + cyPromptManager, fullyManageTabs, onServiceWorkerClientEvent, }) @@ -568,6 +576,11 @@ export class BrowserCriClient { await this.protocolManager?.connectToBrowser(this.currentlyAttachedProtocolTarget) } + if (!this.currentlyAttachedCyPromptTarget) { + this.currentlyAttachedCyPromptTarget = await this.currentlyAttachedTarget.clone() + await this.cyPromptManager?.connectToBrowser(this.currentlyAttachedCyPromptTarget) + } + return this.currentlyAttachedTarget }, this.browserName, this.port) } diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index a891e26d35e2..fab4a876e8b6 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -21,7 +21,7 @@ import type { CriClient } from './cri-client' import type { Automation } from '../automation' import memory from './memory' -import type { BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, RunModeVideoApi } from '@packages/types' +import type { BrowserLaunchOpts, BrowserNewTabOpts, CyPromptManagerShape, ProtocolManagerShape, RunModeVideoApi } from '@packages/types' import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' import { DEFAULT_CHROME_FLAGS } from '../util/chromium_flags' @@ -404,6 +404,20 @@ export = { await options.protocolManager?.connectToBrowser(browserCriClient.currentlyAttachedProtocolTarget) }, + async connectCyPromptToBrowser (options: { cyPromptManager?: CyPromptManagerShape }) { + const browserCriClient = this._getBrowserCriClient() + + if (!browserCriClient?.currentlyAttachedTarget) throw new Error('Missing pageCriClient in connectCyPromptToBrowser') + + // Clone the target here so that we separate the cy prompt client and the main client. + // This allows us to close the cy prompt client independently of the main client + if (!browserCriClient?.currentlyAttachedCyPromptTarget) { + browserCriClient.currentlyAttachedCyPromptTarget = await browserCriClient.currentlyAttachedTarget.clone() + } + + await options.cyPromptManager?.connectToBrowser(browserCriClient.currentlyAttachedCyPromptTarget) + }, + async closeProtocolConnection () { const browserCriClient = this._getBrowserCriClient() diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index 07762ae2b7c7..fe1ef2c462f0 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -12,7 +12,7 @@ import type { Browser, BrowserInstance, GracefulShutdownOptions } from './types' // tslint:disable-next-line no-implicit-dependencies - electron dep needs to be defined import type { BrowserWindow } from 'electron' import type { Automation } from '../automation' -import type { BrowserLaunchOpts, Preferences, ProtocolManagerShape, RunModeVideoApi } from '@packages/types' +import type { BrowserLaunchOpts, CyPromptManagerShape, Preferences, ProtocolManagerShape, RunModeVideoApi } from '@packages/types' import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' import memory from './memory' import { BrowserCriClient } from './browser-cri-client' @@ -276,7 +276,7 @@ export = { return this._launch(win, url, automation, electronOptions) }, - async _launch (win: BrowserWindow, url: string, automation: Automation, options: ElectronOpts, videoApi?: RunModeVideoApi, protocolManager?: ProtocolManagerShape, cdpSocketServer?: CDPSocketServer) { + async _launch (win: BrowserWindow, url: string, automation: Automation, options: ElectronOpts, videoApi?: RunModeVideoApi, protocolManager?: ProtocolManagerShape, cyPromptManager?: CyPromptManagerShape, cdpSocketServer?: CDPSocketServer) { if (options.show) { menu.set({ withInternalDevTools: true }) } @@ -500,6 +500,20 @@ export = { await options.protocolManager?.connectToBrowser(browserCriClient.currentlyAttachedProtocolTarget) }, + async connectCyPromptToBrowser (options: { cyPromptManager?: CyPromptManagerShape }) { + const browserCriClient = this._getBrowserCriClient() + + if (!browserCriClient?.currentlyAttachedTarget) throw new Error('Missing pageCriClient in connectCyPromptToBrowser') + + // Clone the target here so that we separate the cy prompt client and the main client. + // This allows us to close the cy prompt client independently of the main client + if (!browserCriClient.currentlyAttachedCyPromptTarget) { + browserCriClient.currentlyAttachedCyPromptTarget = await browserCriClient.currentlyAttachedTarget.clone() + } + + await options.cyPromptManager?.connectToBrowser(browserCriClient.currentlyAttachedCyPromptTarget) + }, + async closeProtocolConnection () { const browserCriClient = this._getBrowserCriClient() diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index 9cdc2c4e4c9e..c75f565a51ab 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -439,6 +439,11 @@ export function connectProtocolToBrowser (): Promise { throw new Error('Protocol is not yet supported in firefox.') } +export function connectCyPromptToBrowser (): Promise { + // TODO: we will maybe want to connect BIDI for cy prompt in firefox + throw new Error('CyPrompt is not yet supported in firefox.') +} + export function closeProtocolConnection (): Promise { throw new Error('Protocol is not yet supported in firefox.') } diff --git a/packages/server/lib/browsers/index.ts b/packages/server/lib/browsers/index.ts index b492d76b823a..5a5e01fbcf82 100644 --- a/packages/server/lib/browsers/index.ts +++ b/packages/server/lib/browsers/index.ts @@ -7,7 +7,7 @@ import check from 'check-more-types' import { exec } from 'child_process' import util from 'util' import os from 'os' -import { BROWSER_FAMILY, BrowserLaunchOpts, BrowserNewTabOpts, FoundBrowser, ProtocolManagerShape } from '@packages/types' +import { BROWSER_FAMILY, BrowserLaunchOpts, BrowserNewTabOpts, CyPromptManagerShape, FoundBrowser, ProtocolManagerShape } from '@packages/types' import type { Browser, BrowserInstance, BrowserLauncher } from './types' import type { Automation } from '../automation' import type { DataContext } from '@packages/data-context' @@ -147,6 +147,12 @@ export = { await browserLauncher.connectProtocolToBrowser(options) }, + async connectCyPromptToBrowser (options: { browser: Browser, foundBrowsers?: FoundBrowser[], cyPromptManager?: CyPromptManagerShape }) { + const browserLauncher = await getBrowserLauncher(options.browser, options.foundBrowsers || []) + + await browserLauncher.connectCyPromptToBrowser(options) + }, + async closeProtocolConnection (options: { browser: Browser, foundBrowsers?: FoundBrowser[] }) { const browserLauncher = await getBrowserLauncher(options.browser, options.foundBrowsers || []) diff --git a/packages/server/lib/browsers/types.ts b/packages/server/lib/browsers/types.ts index d0579439d622..59668bd41915 100644 --- a/packages/server/lib/browsers/types.ts +++ b/packages/server/lib/browsers/types.ts @@ -1,4 +1,4 @@ -import type { FoundBrowser, BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape } from '@packages/types' +import type { FoundBrowser, BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, CyPromptManagerShape } from '@packages/types' import type { EventEmitter } from 'events' import type { Automation } from '../automation' import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' @@ -45,6 +45,10 @@ export type BrowserLauncher = { * Used to connect the protocol to an existing browser. */ connectProtocolToBrowser: (options: { protocolManager?: ProtocolManagerShape }) => Promise + /** + * Used to connect the cy prompt to an existing browser. + */ + connectCyPromptToBrowser: (options: { cyPromptManager?: CyPromptManagerShape }) => Promise /** * Closes the protocol connection to the browser. */ diff --git a/packages/server/lib/browsers/webkit.ts b/packages/server/lib/browsers/webkit.ts index 794990cedccc..c4aa3965022b 100644 --- a/packages/server/lib/browsers/webkit.ts +++ b/packages/server/lib/browsers/webkit.ts @@ -40,6 +40,11 @@ export function connectProtocolToBrowser (): Promise { throw new Error('Protocol is not yet supported in WebKit.') } +export function connectCyPromptToBrowser (): Promise { + // TODO: evaluate if we can support cy prompt in WebKit + throw new Error('CyPrompt is not yet supported in WebKit.') +} + export function closeProtocolConnection (): Promise { throw new Error('Protocol is not yet supported in WebKit.') } diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index e7bce3cd41cb..ff7111226d4d 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -131,6 +131,7 @@ export class CyPromptLifecycleManager { debug('cy prompt is ready') this.cyPromptManager = cyPromptManager + this.callRegisteredListeners() return cyPromptManager diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index ce247d5a156f..53030cf37b57 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -1,4 +1,5 @@ import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, CyPromptServerShape, CyPromptCloudApi } from '@packages/types' +import type { CDPClient } from '@packages/types/src/cy-prompt/cy-prompt-server-types' import type { Router } from 'express' import Debug from 'debug' import { requireScript } from '../require_script' @@ -44,6 +45,12 @@ export class CyPromptManager implements CyPromptManagerShape { } } + connectToBrowser (cdpClient: CDPClient): void { + if (this._cyPromptServer) { + return this.invokeSync('connectToBrowser', { isEssential: true }, cdpClient) + } + } + /** * Abstracts invoking a synchronous method on the CyPromptServer instance, so we can handle * errors in a uniform way @@ -54,6 +61,7 @@ export class CyPromptManager implements CyPromptManagerShape { } try { + // @ts-expect-error - TS not associating the method & args properly, even though we know it's correct return this._cyPromptServer[method].apply(this._cyPromptServer, args) } catch (error: unknown) { let actualError: Error diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index 797eeac8f590..0f60e82c5189 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -235,6 +235,10 @@ export class OpenProject { await browsers.connectProtocolToBrowser(options) } + async connectCyPromptToBrowser (options) { + await browsers.connectCyPromptToBrowser(options) + } + changeUrlToSpec (spec: Cypress.Spec) { if (!this.projectBase) { debug('No projectBase, cannot change url') diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 42d0986d6477..bd9b39f4d6a8 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -17,7 +17,7 @@ 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 { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ProtocolManagerShape, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording, AutomationCommands, StudioMetricsTypes, CyPromptManagerShape } from '@packages/types' import { DataContext, getCtx } from '@packages/data-context' import { createHmac } from 'crypto' import { ServerBase } from './server-base' @@ -515,6 +515,10 @@ export class ProjectBase extends EE { } }, + onCyPromptReady: (cyPromptManager: CyPromptManagerShape) => { + browsers.connectCyPromptToBrowser({ browser: this.browser, foundBrowsers: this.options.browsers, cyPromptManager }) + }, + onCaptureVideoFrames: (data: any) => { // TODO: move this to browser automation middleware this.emit('capture:video:frames', data) diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index cd47f818a042..40ab9e549f78 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -153,6 +153,7 @@ export class SocketBase { onTestFileChange () {}, onCaptureVideoFrames () {}, onStudioInit () {}, + onCyPromptReady () {}, onStudioDestroy () {}, }) @@ -451,6 +452,7 @@ export class SocketBase { let cyPrompt: CyPromptManagerShape | undefined getCtx().coreData.cyPromptLifecycleManager?.registerCyPromptReadyListener((cp) => { + options.onCyPromptReady(cp) cyPrompt = cp }) diff --git a/packages/types/src/cy-prompt/cy-prompt-server-types.ts b/packages/types/src/cy-prompt/cy-prompt-server-types.ts index 9915b30b636b..1c37ec81a99e 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -1,8 +1,14 @@ /// +import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.d' import type { Router } from 'express' import type { AxiosInstance } from 'axios' +type Commands = ProtocolMapping.Commands +type Command = Commands[T] +type Events = ProtocolMapping.Events +type Event = Events[T] + interface RetryOptions { maxAttempts: number retryDelay?: (attempt: number) => number @@ -30,9 +36,21 @@ export interface CyPromptServerOptions { cloudApi: CyPromptCloudApi } +export interface CDPClient { + send>( + command: T, + params?: Command['paramsType'][0] + ): Promise['returnType']> + on>( + eventName: T, + cb: (event: Event[0]) => void | Promise + ): void +} + export interface CyPromptServerShape { initializeRoutes(router: Router): void handleBackendRequest: (eventName: string, ...args: any[]) => Promise + connectToBrowser: (cdpClient: CDPClient) => void } export interface CyPromptServerDefaultShape { From ac75435d98d75ade0480a7d5df00a1641a9a6188 Mon Sep 17 00:00:00 2001 From: Tim Griesser Date: Thu, 22 May 2025 12:42:35 -0400 Subject: [PATCH 09/23] add prompt:reset, change signature for cyPrompt --- packages/app/src/runner/event-manager.ts | 1 + .../driver/src/cy/commands/prompt/index.ts | 23 +++++++++++++++---- .../lib/cloud/cy-prompt/CyPromptManager.ts | 2 +- packages/server/lib/socket-base.ts | 4 ++++ 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index b5c3326924c6..32cbb3d87e8b 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -425,6 +425,7 @@ export class EventManager { async setup (config) { this.ws.emit('watch:test:file', config.spec) + Cypress.backend('prompt:reset', config.spec).catch(() => {}) if (config.isTextTerminal || config.experimentalInteractiveRunEvents) { await new Promise((resolve, reject) => { diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 53ccdf5d9fbb..93383f4719ee 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -1,5 +1,5 @@ import { init, loadRemote } from '@module-federation/runtime' -import type{ CyPromptDriverDefaultShape } from './prompt-driver-types' +import type { CyPromptDriverDefaultShape } from './prompt-driver-types' interface CyPromptDriver { default: CyPromptDriverDefaultShape } @@ -36,7 +36,9 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise { Commands.addAll({ - prompt (message: string) { + prompt (message: string | string[], options = {}) { + const promptCmd = cy.state('current') + if (!Cypress.config('experimentalPromptCommand')) { // TODO: what do we want to do here? throw new Error('cy.prompt() is not enabled. Please enable it by setting `experimentalPromptCommand: true` in your Cypress config.') @@ -55,12 +57,23 @@ export default (Commands, Cypress, cy) => { // TODO: handle this better // eslint-disable-next-line no-console console.error('Error in cy.prompt()', error) - throw new Error('CyPromptDriver not found') + + return new Error('CyPromptDriver not found') } } - return cy.wrap(getCloud(), { log: false }).then((cloud) => { - return cloud.cyPrompt(Cypress, message) + return cy.wrap(getCloud(), { log: false }).then((cloudOrError) => { + if (cloudOrError instanceof Error) { + throw cloudOrError + } + + return cloudOrError.cyPrompt({ + Cypress, + message, + options, + promptCmd, + cy, + }) }) }, }) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index 53030cf37b57..0d0da4a767f1 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -41,7 +41,7 @@ export class CyPromptManager implements CyPromptManagerShape { async handleBackendRequest (eventName: string, ...args: any[]): Promise { if (this._cyPromptServer) { - return this.invokeAsync('handleBackendRequest', { isEssential: true }, eventName, ...args) + return this.invokeAsync('handleBackendRequest', { isEssential: false }, eventName, ...args) } } diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 40ab9e549f78..9b8ecc75b513 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -466,6 +466,10 @@ export class SocketBase { const backendRequest = () => { if (eventName.startsWith('prompt:')) { + if (eventName === 'prompt:reset' && runState) { + return null + } + return cyPrompt?.handleBackendRequest(eventName, ...args) } From 1749ee6c5817f652f2e6866a89c19933974e9771 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Thu, 22 May 2025 16:41:58 -0500 Subject: [PATCH 10/23] various improvements --- cli/types/cypress.d.ts | 2 +- packages/app/src/runner/event-manager.ts | 2 +- .../driver/src/cy/commands/prompt/index.ts | 77 ++++++++++--------- .../cy/commands/prompt/prompt-driver-types.ts | 11 ++- 4 files changed, 52 insertions(+), 40 deletions(-) diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 6611f3ec08ef..0c824a560106 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -1829,7 +1829,7 @@ declare namespace Cypress { /** * TODO: add docs */ - prompt(message: string, options?: Partial): Chainable + prompt(message: string, options?: Partial): Chainable /** * Read a file and yield its contents. diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 32cbb3d87e8b..9dec3b58d66b 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -425,7 +425,6 @@ export class EventManager { async setup (config) { this.ws.emit('watch:test:file', config.spec) - Cypress.backend('prompt:reset', config.spec).catch(() => {}) if (config.isTextTerminal || config.experimentalInteractiveRunEvents) { await new Promise((resolve, reject) => { @@ -442,6 +441,7 @@ export class EventManager { } Cypress = this.Cypress = this.$CypressDriver.create(config) + Cypress.backend('prompt:reset', config.spec).catch(() => {}) this.localBus.emit('cypress:created', Cypress) // expose Cypress globally diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 93383f4719ee..6fc878a404c3 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -34,47 +34,50 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise { - Commands.addAll({ - prompt (message: string | string[], options = {}) { - const promptCmd = cy.state('current') +const getCloud = async (): Promise => { + try { + let cloud = initializedCyPrompt + + if (!cloud) { + cloud = await initializeCloudCyPrompt(Cypress) + } + + return cloud + } catch (error) { + // TODO: handle this better + // eslint-disable-next-line no-console + console.error('Error in cy.prompt()', error) - if (!Cypress.config('experimentalPromptCommand')) { - // TODO: what do we want to do here? - throw new Error('cy.prompt() is not enabled. Please enable it by setting `experimentalPromptCommand: true` in your Cypress config.') - } + return new Error('CyPromptDriver not found') + } +} - const getCloud = async () => { - try { - let cloud = initializedCyPrompt +const isError = (value: unknown): value is Error => { + return value instanceof Error +} - if (!cloud) { - cloud = await initializeCloudCyPrompt(Cypress) +export default (Commands, Cypress, cy) => { + if (Cypress.config('experimentalPromptCommand')) { + const cloud = getCloud() + + Commands.addAll({ + prompt (text: string | string[], options = {}) { + const promptCmd = cy.state('current') + + return cy.wrap(cloud, { log: false }).then((cloudOrError) => { + if (isError(cloudOrError)) { + throw cloudOrError } - return cloud - } catch (error) { - // TODO: handle this better - // eslint-disable-next-line no-console - console.error('Error in cy.prompt()', error) - - return new Error('CyPromptDriver not found') - } - } - - return cy.wrap(getCloud(), { log: false }).then((cloudOrError) => { - if (cloudOrError instanceof Error) { - throw cloudOrError - } - - return cloudOrError.cyPrompt({ - Cypress, - message, - options, - promptCmd, - cy, + return cloudOrError.cyPrompt({ + Cypress, + text, + options, + promptCmd, + cy, + }) }) - }) - }, - }) + }, + }) + } } diff --git a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts index 3f742f4b9678..06f0f4cf4b87 100644 --- a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts +++ b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts @@ -1,7 +1,16 @@ +/// + export interface CypressInternal extends Cypress.Cypress { backend: (eventName: string, ...args: any[]) => Promise } +export interface CyPromptOptions { + Cypress: CypressInternal + text: string | string[] + options?: object + promptCmd: unknown +} + export interface CyPromptDriverDefaultShape { - cyPrompt: (Cypress: CypressInternal, text: string) => Promise + cyPrompt: (opts: CyPromptOptions) => Cypress.Chainable } From 1c6c5a58233483288cdb5dbb1ffe583390df28bd Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Thu, 22 May 2025 21:36:14 -0500 Subject: [PATCH 11/23] fix types --- packages/types/src/cy-prompt/cy-prompt-server-types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/types/src/cy-prompt/cy-prompt-server-types.ts b/packages/types/src/cy-prompt/cy-prompt-server-types.ts index 1c37ec81a99e..4c69dba1b303 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -36,7 +36,7 @@ export interface CyPromptServerOptions { cloudApi: CyPromptCloudApi } -export interface CDPClient { +export interface CyPromptCDPClient { send>( command: T, params?: Command['paramsType'][0] @@ -50,7 +50,7 @@ export interface CDPClient { export interface CyPromptServerShape { initializeRoutes(router: Router): void handleBackendRequest: (eventName: string, ...args: any[]) => Promise - connectToBrowser: (cdpClient: CDPClient) => void + connectToBrowser: (cdpClient: CyPromptCDPClient) => void } export interface CyPromptServerDefaultShape { From 49c76ca8dde1449516aed478cde92a977f68288e Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Thu, 22 May 2025 21:48:56 -0500 Subject: [PATCH 12/23] pass Cypress to initializer --- packages/driver/src/cy/commands/prompt/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 6fc878a404c3..3f78434e955d 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -34,7 +34,7 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise => { +const getCloud = async (Cypress: Cypress.Cypress): Promise => { try { let cloud = initializedCyPrompt @@ -58,7 +58,7 @@ const isError = (value: unknown): value is Error => { export default (Commands, Cypress, cy) => { if (Cypress.config('experimentalPromptCommand')) { - const cloud = getCloud() + const cloud = getCloud(Cypress) Commands.addAll({ prompt (text: string | string[], options = {}) { From 269923d058a0fa8c90f55799ff10bb4dbcb45982 Mon Sep 17 00:00:00 2001 From: Tim Griesser Date: Fri, 23 May 2025 09:09:29 -0400 Subject: [PATCH 13/23] move prompt:reset after _addListeners --- packages/app/src/runner/event-manager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 9dec3b58d66b..cb7929519ffd 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -441,7 +441,6 @@ export class EventManager { } Cypress = this.Cypress = this.$CypressDriver.create(config) - Cypress.backend('prompt:reset', config.spec).catch(() => {}) this.localBus.emit('cypress:created', Cypress) // expose Cypress globally @@ -466,6 +465,8 @@ export class EventManager { } this._addListeners() + + Cypress.backend('prompt:reset', config.spec).catch(() => {}) } isBrowserFamily (family: string) { From 2fcea4e93b5affc7e4b6cde7ef6ddf2c063cf084 Mon Sep 17 00:00:00 2001 From: Tim Griesser Date: Fri, 23 May 2025 10:20:20 -0400 Subject: [PATCH 14/23] add getUser & config to prompt init --- .../cloud/cy-prompt/CyPromptLifecycleManager.ts | 10 ++++++++++ .../lib/cloud/cy-prompt/CyPromptManager.ts | 16 +++++++--------- .../unit/cloud/cy-prompt/CyPromptManager_spec.ts | 4 ++++ .../src/cy-prompt/cy-prompt-server-types.ts | 4 +++- 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index ff7111226d4d..915e025e5296 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -10,6 +10,7 @@ import path from 'path' import os from 'os' import { readFile } from 'fs-extra' import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle' +import type { AuthenticatedUserShape } from '@packages/data-context/src/data' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') @@ -34,6 +35,7 @@ export class CyPromptLifecycleManager { cloudDataSource: CloudDataSource ctx: DataContext }): void { + debug('initializing cy prompt') // Register this instance in the data context ctx.update((data) => { data.cyPromptLifecycleManager = this @@ -42,6 +44,8 @@ export class CyPromptLifecycleManager { const cyPromptManagerPromise = this.createCyPromptManager({ projectId, cloudDataSource, + getUser: () => ctx._apis.authApi.getUser(), + getConfig: () => ctx.project.getConfig(), }).catch(async (error) => { debug('Error during cy prompt manager setup: %o', error) @@ -87,9 +91,13 @@ export class CyPromptLifecycleManager { private async createCyPromptManager ({ projectId, cloudDataSource, + getUser, + getConfig, }: { projectId: string cloudDataSource: CloudDataSource + getUser: () => Promise + getConfig: () => Promise> }): Promise { const cyPromptSession = await postCyPromptSession({ projectId, @@ -127,6 +135,8 @@ export class CyPromptLifecycleManager { isRetryableError, asyncRetry, }, + getUser, + config: await getConfig(), }) debug('cy prompt is ready') diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index 0d0da4a767f1..b366d40d6bc7 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -1,17 +1,13 @@ -import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, CyPromptServerShape, CyPromptCloudApi } from '@packages/types' -import type { CDPClient } from '@packages/types/src/cy-prompt/cy-prompt-server-types' +import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, CyPromptServerShape } from '@packages/types' +import type { CyPromptCDPClient, CyPromptServerOptions } from '@packages/types/src/cy-prompt/cy-prompt-server-types' import type { Router } from 'express' import Debug from 'debug' import { requireScript } from '../require_script' interface CyPromptServer { default: CyPromptServerDefaultShape } -interface SetupOptions { +interface SetupOptions extends CyPromptServerOptions { script: string - cyPromptPath: string - cyPromptHash?: string - projectSlug?: string - cloudApi: CyPromptCloudApi } const debug = Debug('cypress:server:cy-prompt') @@ -20,7 +16,7 @@ export class CyPromptManager implements CyPromptManagerShape { status: CyPromptStatus = 'NOT_INITIALIZED' private _cyPromptServer: CyPromptServerShape | undefined - async setup ({ script, cyPromptPath, cyPromptHash, projectSlug, cloudApi }: SetupOptions): Promise { + async setup ({ script, cyPromptPath, cyPromptHash, projectSlug, cloudApi, getUser, config }: SetupOptions): Promise { const { createCyPromptServer } = requireScript(script).default this._cyPromptServer = await createCyPromptServer({ @@ -28,6 +24,8 @@ export class CyPromptManager implements CyPromptManagerShape { cyPromptPath, projectSlug, cloudApi, + getUser, + config, }) this.status = 'INITIALIZED' @@ -45,7 +43,7 @@ export class CyPromptManager implements CyPromptManagerShape { } } - connectToBrowser (cdpClient: CDPClient): void { + connectToBrowser (cdpClient: CyPromptCDPClient): void { if (this._cyPromptServer) { return this.invokeSync('connectToBrowser', { isEssential: true }, cdpClient) } diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts index f57a98542cb9..f542fcf99c39 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts @@ -31,6 +31,10 @@ describe('lib/cloud/cy-prompt', () => { cyPromptHash: 'abcdefg', projectSlug: '1234', cloudApi: {} as any, + async getUser () { + return {} + }, + config: {}, }) cyPrompt = (cyPromptManager as any)._cyPromptServer diff --git a/packages/types/src/cy-prompt/cy-prompt-server-types.ts b/packages/types/src/cy-prompt/cy-prompt-server-types.ts index 4c69dba1b303..24be665d29fd 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -1,5 +1,5 @@ /// - +import type { AuthenticatedUserShape } from '@packages/data-context/src/data/coreDataShape' import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.d' import type { Router } from 'express' import type { AxiosInstance } from 'axios' @@ -34,6 +34,8 @@ export interface CyPromptServerOptions { cyPromptPath: string projectSlug?: string cloudApi: CyPromptCloudApi + getUser(): Promise> + config: Partial } export interface CyPromptCDPClient { From 778adc970f28efc3972ac94a3673a23162242931 Mon Sep 17 00:00:00 2001 From: Tim Griesser Date: Fri, 23 May 2025 10:23:55 -0400 Subject: [PATCH 15/23] update type for getConfig --- packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts | 2 +- packages/types/src/cy-prompt/cy-prompt-server-types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 915e025e5296..49f6c18d0be2 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -97,7 +97,7 @@ export class CyPromptLifecycleManager { projectId: string cloudDataSource: CloudDataSource getUser: () => Promise - getConfig: () => Promise> + getConfig: () => Promise> }): Promise { const cyPromptSession = await postCyPromptSession({ projectId, diff --git a/packages/types/src/cy-prompt/cy-prompt-server-types.ts b/packages/types/src/cy-prompt/cy-prompt-server-types.ts index 24be665d29fd..18abcde89ec9 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -35,7 +35,7 @@ export interface CyPromptServerOptions { projectSlug?: string cloudApi: CyPromptCloudApi getUser(): Promise> - config: Partial + config: Partial } export interface CyPromptCDPClient { From 4728196d63938573d55e7a324b9fc06592d85daa Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 23 May 2025 10:01:30 -0500 Subject: [PATCH 16/23] reload cy prompt bundle on changes --- .../cy-prompt/CyPromptLifecycleManager.ts | 57 ++++++++++++++----- .../cy-prompt/ensure_cy_prompt_bundle.ts | 35 +++++------- packages/server/lib/routes.ts | 2 + 3 files changed, 59 insertions(+), 35 deletions(-) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 49f6c18d0be2..856da3b22322 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -11,6 +11,7 @@ import os from 'os' import { readFile } from 'fs-extra' import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle' import type { AuthenticatedUserShape } from '@packages/data-context/src/data' +import chokidar from 'chokidar' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') @@ -99,23 +100,30 @@ export class CyPromptLifecycleManager { getUser: () => Promise getConfig: () => Promise> }): Promise { - const cyPromptSession = await postCyPromptSession({ - projectId, - }) + let cyPromptPath: string + let cyPromptHash: string + + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + const cyPromptSession = await postCyPromptSession({ + projectId, + }) + + // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension + cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] + cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) + + await ensureCyPromptBundle({ + cyPromptPath, + cyPromptUrl: cyPromptSession.cyPromptUrl, + projectId, + }) + } else { + cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH + cyPromptHash = 'local' + } - // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension - const cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] - const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) - const bundlePath = path.join(cyPromptPath, 'bundle.tar') const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') - await ensureCyPromptBundle({ - cyPromptUrl: cyPromptSession.cyPromptUrl, - projectId, - cyPromptPath, - bundlePath, - }) - const script = await readFile(serverFilePath, 'utf8') const cyPromptManager = new CyPromptManager() @@ -142,6 +150,21 @@ export class CyPromptLifecycleManager { debug('cy prompt is ready') this.cyPromptManager = cyPromptManager + if (process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + chokidar.watch(serverFilePath, { + awaitWriteFinish: true, + }).on('change', () => { + this.createCyPromptManager({ + projectId, + cloudDataSource, + getUser, + getConfig, + }).catch((error) => { + debug('Error during reload of cy prompt manager: %o', error) + }) + }) + } + this.callRegisteredListeners() return cyPromptManager @@ -159,7 +182,11 @@ export class CyPromptLifecycleManager { listener(cyPromptManager) }) - this.listeners = [] + // Don't clear listeners if the cy prompt is local since we + // will be reloading the cy prompt manager on file changes + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + this.listeners = [] + } } /** diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index 14af0eac0e07..d1b9fc983b47 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -1,37 +1,32 @@ -import { copy, remove, ensureDir } from 'fs-extra' +import { remove, ensureDir } from 'fs-extra' import tar from 'tar' import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle' import path from 'path' interface EnsureCyPromptBundleOptions { - cyPromptPath: string cyPromptUrl: string projectId: string - bundlePath: string + cyPromptPath: string } -export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId, bundlePath }: EnsureCyPromptBundleOptions) => { +export const ensureCyPromptBundle = async ({ cyPromptUrl, projectId, cyPromptPath }: EnsureCyPromptBundleOptions) => { + const bundlePath = path.join(cyPromptPath, 'bundle.tar') + // First remove cyPromptPath to ensure we have a clean slate await remove(cyPromptPath) await ensureDir(cyPromptPath) - if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { - await getCyPromptBundle({ - cyPromptUrl, - projectId, - bundlePath, - }) + await getCyPromptBundle({ + cyPromptUrl, + projectId, + bundlePath, + }) - await tar.extract({ - file: bundlePath, - cwd: cyPromptPath, - }) - } else { - const driverPath = path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'driver') - const serverPath = path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server') + await tar.extract({ + file: bundlePath, + cwd: cyPromptPath, + }) - await copy(driverPath, path.join(cyPromptPath, 'driver')) - await copy(serverPath, path.join(cyPromptPath, 'server')) - } + return { cyPromptPath } } diff --git a/packages/server/lib/routes.ts b/packages/server/lib/routes.ts index 1c7f9fdfd487..61e6f6f8d7ec 100644 --- a/packages/server/lib/routes.ts +++ b/packages/server/lib/routes.ts @@ -128,6 +128,8 @@ export const createCommonRoutes = ({ router.use('/', cyPromptRouter) getCtx().coreData.cyPromptLifecycleManager?.registerCyPromptReadyListener((cyPrompt) => { + // Remove existing routes prior to registering new ones + cyPromptRouter.stack = [] cyPrompt.initializeRoutes(cyPromptRouter) }) } From 640f2fc4bd2f61d7dd0ab79036d4acb66533b9e8 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 23 May 2025 10:12:39 -0500 Subject: [PATCH 17/23] removing unneeded return value --- packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index d1b9fc983b47..b97c26fee300 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -27,6 +27,4 @@ export const ensureCyPromptBundle = async ({ cyPromptUrl, projectId, cyPromptPat file: bundlePath, cwd: cyPromptPath, }) - - return { cyPromptPath } } From bfd3228d1e59bed52bc0c60a38e613d7f5402144 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 23 May 2025 10:59:06 -0500 Subject: [PATCH 18/23] fix watching --- .../cy-prompt/CyPromptLifecycleManager.ts | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 856da3b22322..88974a024dc2 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -16,6 +16,7 @@ import chokidar from 'chokidar' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') export class CyPromptLifecycleManager { + private static watcher: chokidar.FSWatcher | null = null private cyPromptManagerPromise?: Promise private cyPromptManager?: CyPromptManager private listeners: ((cyPromptManager: CyPromptManager) => void)[] = [] @@ -42,11 +43,13 @@ export class CyPromptLifecycleManager { data.cyPromptLifecycleManager = this }) + const getUser = () => ctx._apis.authApi.getUser() + const getConfig = () => ctx.project.getConfig() const cyPromptManagerPromise = this.createCyPromptManager({ projectId, cloudDataSource, - getUser: () => ctx._apis.authApi.getUser(), - getConfig: () => ctx.project.getConfig(), + getUser, + getConfig, }).catch(async (error) => { debug('Error during cy prompt manager setup: %o', error) @@ -77,6 +80,29 @@ export class CyPromptLifecycleManager { }) this.cyPromptManagerPromise = cyPromptManagerPromise + + // If the cy prompt is local, we need to watch for changes to the cy prompt + // and reload the cy prompt manager on file changes + if (process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + // Close the watcher if it already exists + if (CyPromptLifecycleManager.watcher) { + CyPromptLifecycleManager.watcher.close() + } + + // Watch for changes to the cy prompt + CyPromptLifecycleManager.watcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server', 'index.js'), { + awaitWriteFinish: true, + }).on('change', () => { + this.createCyPromptManager({ + projectId, + cloudDataSource, + getUser, + getConfig, + }).catch((error) => { + debug('Error during reload of cy prompt manager: %o', error) + }) + }) + } } async getCyPrompt () { @@ -150,21 +176,6 @@ export class CyPromptLifecycleManager { debug('cy prompt is ready') this.cyPromptManager = cyPromptManager - if (process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { - chokidar.watch(serverFilePath, { - awaitWriteFinish: true, - }).on('change', () => { - this.createCyPromptManager({ - projectId, - cloudDataSource, - getUser, - getConfig, - }).catch((error) => { - debug('Error during reload of cy prompt manager: %o', error) - }) - }) - } - this.callRegisteredListeners() return cyPromptManager @@ -198,6 +209,12 @@ export class CyPromptLifecycleManager { if (this.cyPromptManager) { debug('cy prompt ready - calling listener immediately') listener(this.cyPromptManager) + + // If the cy prompt is local, we need to register the listener as well + // since the cy prompt manager will be reloaded on file changes + if (process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + this.listeners.push(listener) + } } else { debug('cy prompt not ready - registering cy prompt ready listener') this.listeners.push(listener) From 788e7a44d7d78cf343a5040eb8c10efb4386c33f Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 23 May 2025 15:12:50 -0500 Subject: [PATCH 19/23] create Cypress.promptBackend --- packages/app/src/runner/event-manager.ts | 14 +++++- .../driver/src/cross-origin/events/socket.ts | 1 + .../driver/src/cy/commands/prompt/index.ts | 2 +- .../cy/commands/prompt/prompt-driver-types.ts | 2 +- packages/driver/src/cypress.ts | 10 ++++- .../driver/types/internal-types-lite.d.ts | 1 + packages/server/lib/socket-base.ts | 44 +++++++++++++------ 7 files changed, 56 insertions(+), 18 deletions(-) diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index cb7929519ffd..c40fbf43b368 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -41,7 +41,7 @@ interface AddGlobalListenerOptions { } const driverToLocalAndReporterEvents = 'run:start run:end'.split(' ') -const driverToSocketEvents = 'backend:request automation:request mocha recorder:frame dev-server:on-spec-update'.split(' ') +const driverToSocketEvents = 'backend:request prompt:backend:request automation:request mocha recorder:frame dev-server:on-spec-update'.split(' ') const driverToLocalEvents = 'viewport:changed config stop url:changed page:loading visit:failed visit:blank cypress:in:cypress:runner:event'.split(' ') const socketRerunEvents = 'runner:restart watched:file:changed'.split(' ') const socketToDriverEvents = 'net:stubbing:event request:event script:error cross:origin:cookies dev-server:on-spec-updated'.split(' ') @@ -815,6 +815,18 @@ export class EventManager { Cypress.primaryOriginCommunicator.toSource(source, responseEvent, response) }) + Cypress.primaryOriginCommunicator.on('prompt:backend:request', async ({ args }, { source, responseEvent }) => { + let response + + try { + response = await Cypress.promptBackend(...args) + } catch (error) { + response = { error } + } + + Cypress.primaryOriginCommunicator.toSource(source, responseEvent, response) + }) + /** * Call an automation request for the requesting spec bridge since we cannot have websockets in the spec bridges. * Return it's response. diff --git a/packages/driver/src/cross-origin/events/socket.ts b/packages/driver/src/cross-origin/events/socket.ts index a159e1ea5554..754d88bc59b4 100644 --- a/packages/driver/src/cross-origin/events/socket.ts +++ b/packages/driver/src/cross-origin/events/socket.ts @@ -16,5 +16,6 @@ export const handleSocketEvents = (Cypress) => { } Cypress.on('backend:request', (...args) => onRequest('backend:request', args)) + Cypress.on('prompt:backend:request', (...args) => onRequest('prompt:backend:request', args)) Cypress.on('automation:request', (...args) => onRequest('automation:request', args)) } diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 3f78434e955d..21e2c3a20636 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -5,7 +5,7 @@ interface CyPromptDriver { default: CyPromptDriverDefaultShape } let initializedCyPrompt: CyPromptDriverDefaultShape | null = null const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise => { - const { success } = await Cypress.backend('wait:for:cy:prompt:ready') + const { success } = await Cypress.promptBackend('wait:for:cy:prompt:ready') if (!success) { throw new Error('CyPromptDriver not found') diff --git a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts index 06f0f4cf4b87..f6b29808f02d 100644 --- a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts +++ b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts @@ -1,7 +1,7 @@ /// export interface CypressInternal extends Cypress.Cypress { - backend: (eventName: string, ...args: any[]) => Promise + promptBackend: (eventName: string, ...args: any[]) => Promise } export interface CyPromptOptions { diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index c8657904fae7..b95e485c366b 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -765,6 +765,14 @@ class $Cypress { } backend (eventName, ...args) { + return this.backendBase('backend:request', eventName, ...args) + } + + promptBackend (eventName, ...args) { + return this.backendBase('prompt:backend:request', eventName, ...args) + } + + private backendBase (baseEventName, eventName, ...args) { return new Promise((resolve, reject) => { const fn = function (reply) { const e = reply.error @@ -787,7 +795,7 @@ class $Cypress { return resolve(reply.response) } - return this.emit('backend:request', eventName, ...args, fn) + return this.emit(baseEventName, eventName, ...args, fn) }) } diff --git a/packages/driver/types/internal-types-lite.d.ts b/packages/driver/types/internal-types-lite.d.ts index c41aef97cf6a..e139464ef7fb 100644 --- a/packages/driver/types/internal-types-lite.d.ts +++ b/packages/driver/types/internal-types-lite.d.ts @@ -12,6 +12,7 @@ declare namespace Cypress { toSpecBridge: (origin: string, event: string, data?: any, responseEvent?: string) => void userInvocationStack?: string } + promptBackend: (eventName: string, ...args: any[]) => Promise<{ success: boolean }> } interface Actions { diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 9b8ecc75b513..955ea8f3c890 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -456,6 +456,36 @@ export class SocketBase { cyPrompt = cp }) + socket.on('prompt:backend:request', (eventName: string, ...args) => { + const cb = args.pop() + + debug('prompt:backend:request %o', { eventName, args }) + + const promptBackendRequest = () => { + if (eventName === 'prompt:reset' && runState) { + return null + } + + switch (eventName) { + case 'wait:for:cy:prompt:ready': + return getCtx().coreData.cyPromptLifecycleManager?.getCyPrompt().then((cyPrompt) => { + return { + success: cyPrompt && cyPrompt.status === 'INITIALIZED', + } + }) + default: + return cyPrompt?.handleBackendRequest(eventName, ...args) + } + } + + return Bluebird.try(promptBackendRequest) + .then((resp) => { + return cb({ response: resp }) + }).catch((err) => { + return cb({ error: errors.cloneErr(err) }) + }) + }) + socket.on('backend:request', (eventName: string, ...args) => { const userAgent = socket.request?.headers['user-agent'] || getCtx().coreData.app.browserUserAgent @@ -465,14 +495,6 @@ export class SocketBase { debug('backend:request %o', { eventName, args }) const backendRequest = () => { - if (eventName.startsWith('prompt:')) { - if (eventName === 'prompt:reset' && runState) { - return null - } - - return cyPrompt?.handleBackendRequest(eventName, ...args) - } - switch (eventName) { case 'preserve:run:state': runState = args[0] @@ -550,12 +572,6 @@ export class SocketBase { }) case 'close:extra:targets': return options.closeExtraTargets() - case 'wait:for:cy:prompt:ready': - return getCtx().coreData.cyPromptLifecycleManager?.getCyPrompt().then((cyPrompt) => { - return { - success: cyPrompt && cyPrompt.status === 'INITIALIZED', - } - }) default: throw new Error(`You requested a backend event we cannot handle: ${eventName}`) } From befa4aa1b27f794e361046006bff622a510098da Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 23 May 2025 16:48:48 -0500 Subject: [PATCH 20/23] handle sharing types between cypress-services and cypress --- .../cy/commands/prompt/prompt-driver-types.ts | 5 ++ .../src/cy-prompt/cy-prompt-server-types.ts | 15 +++++- scripts/gulp/gulpfile.ts | 6 ++- scripts/gulp/tasks/gulpCloudDeliveredTypes.ts | 52 +++++++++++++++++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts index f6b29808f02d..4f0ca2b5a9df 100644 --- a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts +++ b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts @@ -1,3 +1,7 @@ +// Note: This file is owned by the cloud delivered +// cy prompt bundle. It is downloaded and copied here. +// It should not be modified directly here. + /// export interface CypressInternal extends Cypress.Cypress { @@ -9,6 +13,7 @@ export interface CyPromptOptions { text: string | string[] options?: object promptCmd: unknown + cy: Cypress.cy } export interface CyPromptDriverDefaultShape { diff --git a/packages/types/src/cy-prompt/cy-prompt-server-types.ts b/packages/types/src/cy-prompt/cy-prompt-server-types.ts index 18abcde89ec9..fca51ba3cb3a 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -1,5 +1,9 @@ +// Note: This file is owned by the cloud delivered +// cy prompt bundle. It is downloaded and copied here. +// It should not be modified directly here. + /// -import type { AuthenticatedUserShape } from '@packages/data-context/src/data/coreDataShape' + import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.d' import type { Router } from 'express' import type { AxiosInstance } from 'axios' @@ -16,6 +20,13 @@ interface RetryOptions { onRetry?: (delay: number, err: unknown) => void } +export interface CyPromptAuthenticatedUserShape { + id?: string //Cloud user id + name?: string + email?: string + authToken?: string +} + export interface CyPromptCloudApi { cloudUrl: string cloudHeaders: Record @@ -34,7 +45,7 @@ export interface CyPromptServerOptions { cyPromptPath: string projectSlug?: string cloudApi: CyPromptCloudApi - getUser(): Promise> + getUser(): Promise> config: Partial } diff --git a/scripts/gulp/gulpfile.ts b/scripts/gulp/gulpfile.ts index ca8aef403092..ed71b9a4018b 100644 --- a/scripts/gulp/gulpfile.ts +++ b/scripts/gulp/gulpfile.ts @@ -19,7 +19,7 @@ import { webpackReporter, webpackRunner } from './tasks/gulpWebpack' import { e2eTestScaffold, e2eTestScaffoldWatch } from './tasks/gulpE2ETestScaffold' import dedent from 'dedent' import { ensureCloudValidations, syncCloudValidations } from './tasks/gulpSyncValidations' -import { downloadStudioTypes } from './tasks/gulpCloudDeliveredTypes' +import { downloadStudioTypes, downloadPromptTypes } from './tasks/gulpCloudDeliveredTypes' if (process.env.CYPRESS_INTERNAL_VITE_DEV) { process.env.CYPRESS_INTERNAL_VITE_APP_PORT ??= '3333' @@ -61,6 +61,9 @@ gulp.task( webpackReporter, webpackRunner, gulp.series( + gulp.parallel( + downloadPromptTypes, + ), makePathMap, // Before dev, fetch the latest "remote" schema from Cypress Cloud syncRemoteGraphQL, @@ -257,6 +260,7 @@ gulp.task(openCypressApp) gulp.task(openCypressLaunchpad) gulp.task(downloadStudioTypes) +gulp.task(downloadPromptTypes) // If we want to run individually, for debugging/testing gulp.task('cyOpenLaunchpadOnly', cyOpenLaunchpad) diff --git a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts index f00c1b1a46ae..5e505943f347 100644 --- a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts +++ b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts @@ -4,6 +4,10 @@ import path from 'path' import fs from 'fs-extra' import { retrieveAndExtractStudioBundle, studioPath } from '@packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager' import { postStudioSession } from '@packages/server/lib/cloud/api/studio/post_studio_session' +import { ensureCyPromptBundle } from '@packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle' +import { postCyPromptSession } from '@packages/server/lib/cloud/api/cy-prompt/post_cy_prompt_session' +import os from 'os' +import chokidar from 'chokidar' export const downloadStudioTypes = async (): Promise => { const studioSession = await postStudioSession({ projectId: 'ypt4pf' }) @@ -20,3 +24,51 @@ export const downloadStudioTypes = async (): Promise => { path.join(__dirname, '..', '..', '..', 'packages', 'types', 'src', 'studio', 'studio-server-types.ts'), ) } + +export const downloadPromptTypes = async (): Promise => { + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + const cyPromptSession = await postCyPromptSession({ projectId: 'ypt4pf' }) + // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension + const cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] + const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) + + await ensureCyPromptBundle({ cyPromptUrl: cyPromptSession.cyPromptUrl, cyPromptPath, projectId: 'ypt4pf' }) + + await fs.copyFile( + path.join(cyPromptPath, 'driver', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'driver', 'src', 'cy', 'commands', 'prompt', 'prompt-driver-types.ts'), + ) + + await fs.copyFile( + path.join(cyPromptPath, 'server', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'types', 'src', 'cy-prompt', 'cy-prompt-server-types.ts'), + ) + } else { + const copyDriverTypes = async () => { + await fs.copyFile( + path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH!, 'driver', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'driver', 'src', 'cy', 'commands', 'prompt', 'prompt-driver-types.ts'), + ) + } + const copyServerTypes = async () => { + await fs.copyFile( + path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH!, 'server', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'types', 'src', 'cy-prompt', 'cy-prompt-server-types.ts'), + ) + } + + const driverWatcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'driver', 'types.ts'), { + awaitWriteFinish: true, + }) + + driverWatcher.on('ready', copyDriverTypes) + driverWatcher.on('change', copyDriverTypes) + + const serverWatcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server', 'types.ts'), { + awaitWriteFinish: true, + }) + + serverWatcher.on('ready', copyServerTypes) + serverWatcher.on('change', copyServerTypes) + } +} From 5ce495c874037fec7db364df19399929beae4451 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 23 May 2025 20:56:54 -0500 Subject: [PATCH 21/23] refactor ejection --- packages/app/src/runner/event-manager.ts | 29 ++++++++++++++++++++++ packages/driver/src/cypress/runner.ts | 16 ++++++++++++ packages/reporter/src/commands/command.tsx | 10 ++++++++ packages/reporter/src/lib/events.ts | 4 +++ 4 files changed, 59 insertions(+) diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index c40fbf43b368..37795c5052d7 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -336,6 +336,35 @@ export class EventManager { this._studioCopyToClipboard(cb) }) + this.reporterBus.on('prompt:eject', (testId, logId) => { + const log = Cypress.runner.getEjectionLogRegistry(testId, logId) + + const codeModalId = `__cy-prompt-code-modal__` + const toRender = Cypress._.template(` +
+
+
+

<%= title || 'Code Sample' %>

+ +
+
+
<%= code %>
+ +
+
+
+ `) + + window.document.body.insertAdjacentHTML( + 'beforeend', + toRender({ + code: log ?? '', + title: 'cy.prompt ejection', + language: 'JavaScript', + }), + ) + }) + this.localBus.on('studio:copy:to:clipboard', (cb) => { this._studioCopyToClipboard(cb) }) diff --git a/packages/driver/src/cypress/runner.ts b/packages/driver/src/cypress/runner.ts index d4c1b77ff9f9..a112b2b4ef41 100644 --- a/packages/driver/src/cypress/runner.ts +++ b/packages/driver/src/cypress/runner.ts @@ -1954,6 +1954,22 @@ export default { return }, + getEjectionLogRegistry (testId, logId) { + if (_skipCollectingLogs) return + + const test = getTestById(testId) + + if (!test) return + + const logAttrs = _.find(test.commands || [], (log) => log.id === logId) + + if (logAttrs) { + return logAttrs.renderProps.ejectionLogRegistry + } + + return + }, + getSnapshotPropsForLog (testId, logId) { if (_skipCollectingLogs) return diff --git a/packages/reporter/src/commands/command.tsx b/packages/reporter/src/commands/command.tsx index ab5dd6a5d2a4..89afbeb85119 100644 --- a/packages/reporter/src/commands/command.tsx +++ b/packages/reporter/src/commands/command.tsx @@ -532,6 +532,16 @@ const Command: React.FC = observer(({ model, aliasesWithDuplicates + {commandName === 'prompt' && ( +
{ + events.emit('prompt:eject', model.testId, model.id) + }} + > + Eject Code +
+ )} {model.hasChildren && model.isOpen && ( diff --git a/packages/reporter/src/lib/events.ts b/packages/reporter/src/lib/events.ts index 1d25f9aca4bc..81b2b1a1e68a 100644 --- a/packages/reporter/src/lib/events.ts +++ b/packages/reporter/src/lib/events.ts @@ -237,6 +237,10 @@ const events: Events = { localBus.on('studio:copy:to:clipboard', (cb) => { runner.emit('studio:copy:to:clipboard', cb) }) + + localBus.on('prompt:eject', (testId, logId) => { + runner.emit('prompt:eject', testId, logId) + }) }, emit (event, ...args) { From 451ce057bda4e372f956d3c32f2ac2ed6a992d47 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 23 May 2025 21:22:02 -0500 Subject: [PATCH 22/23] fix global mode --- .../cy-prompt/CyPromptLifecycleManager.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 88974a024dc2..9daab9a2dfe4 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -16,6 +16,7 @@ import chokidar from 'chokidar' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') export class CyPromptLifecycleManager { + private static hashLoadingMap: Map> = new Map() private static watcher: chokidar.FSWatcher | null = null private cyPromptManagerPromise?: Promise private cyPromptManager?: CyPromptManager @@ -138,11 +139,19 @@ export class CyPromptLifecycleManager { cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) - await ensureCyPromptBundle({ - cyPromptPath, - cyPromptUrl: cyPromptSession.cyPromptUrl, - projectId, - }) + let hashLoadingPromise = CyPromptLifecycleManager.hashLoadingMap.get(cyPromptHash) + + if (!hashLoadingPromise) { + hashLoadingPromise = ensureCyPromptBundle({ + cyPromptPath, + cyPromptUrl: cyPromptSession.cyPromptUrl, + projectId, + }) + + CyPromptLifecycleManager.hashLoadingMap.set(cyPromptHash, hashLoadingPromise) + } + + await hashLoadingPromise } else { cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH cyPromptHash = 'local' From 2e702f14374017517f937c7ba5dc4c4eda645f26 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sat, 24 May 2025 07:56:27 -0500 Subject: [PATCH 23/23] eject code --- packages/reporter/src/commands/commands.scss | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/reporter/src/commands/commands.scss b/packages/reporter/src/commands/commands.scss index 0ea33b2bddff..1c023ee147d1 100644 --- a/packages/reporter/src/commands/commands.scss +++ b/packages/reporter/src/commands/commands.scss @@ -105,6 +105,20 @@ margin-left: 0; } } + + .eject-prompt { + text-transform: none; + color: $gray-400; + display: flex; + align-items: center; + padding: 4px; + cursor: pointer; + + &:hover, + &:focus { + color: $gray-50; + } + } } .command-number-column {