From 2f776b80ce09e970afdeb4d5df3bdf9e261a372f Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 16 May 2025 09:16:04 -0500 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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 7740e6b72ab03ddaca4006c870e08b83ffb1354a Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 20 May 2025 16:42:49 -0500 Subject: [PATCH 07/11] Update cy-prompt-development.md --- guides/cy-prompt-development.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/guides/cy-prompt-development.md b/guides/cy-prompt-development.md index 8b137891791f..f48effe450ef 100644 --- a/guides/cy-prompt-development.md +++ b/guides/cy-prompt-development.md @@ -1 +1,31 @@ +# `cy.prompt` Development +In production, the code used to facilitate the prompt command will be retrieved from the Cloud. + +To run against locally developed `cy.prompt`: + +- Clone the `cypress-services` repo + - Run `yarn` + - Run `yarn watch` in `app/packages/cy-prompt` +- Set: + - `CYPRESS_INTERNAL_ENV=` (e.g. `staging` or `production` if you want to hit those deployments of `cypress-services` or `development` if you want to hit a locally running version of `cypress-services`) + - `CYPRESS_LOCAL_CY_PROMPT_PATH` to the path to the `cypress-services/app/packages/cy-prompt/dist/development` directory +- Clone the `cypress` repo + - Run `yarn` + - Run `yarn cypress:open` + - Log In to the Cloud via the App + - Open a project that has `experimentalPromptCommand: true` set in the `e2e` config of the `cypress.config.js|ts` file. + +To run against a deployed version of studio: + +- Set: + - `CYPRESS_INTERNAL_ENV=` (e.g. `staging` or `production` if you want to hit those deployments of `cypress-services` or `development` if you want to hit a locally running version of `cypress-services`) +``` + +## Testing + +### Unit/Component Testing + +The code that supports cloud `cy.prompt` and lives in the `cypress` monorepo is unit, integration, and e2e tested in a similar fashion to the rest of the code in the repo. See the [contributing guide](https://github.com/cypress-io/cypress/blob/ad353fcc0f7fdc51b8e624a2a1ef4e76ef9400a0/CONTRIBUTING.md?plain=1#L366) for more specifics. + +The code that supports cloud `cy.prompt` and lives in the `cypress-services` monorepo has unit tests that live alongside the code in that monorepo. From a620d24c49a43bb9cacc6c535291bd8f31a9ef05 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 20 May 2025 16:43:12 -0500 Subject: [PATCH 08/11] Update cy-prompt-development.md --- guides/cy-prompt-development.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/cy-prompt-development.md b/guides/cy-prompt-development.md index f48effe450ef..6172a87655c7 100644 --- a/guides/cy-prompt-development.md +++ b/guides/cy-prompt-development.md @@ -16,7 +16,7 @@ To run against locally developed `cy.prompt`: - Log In to the Cloud via the App - Open a project that has `experimentalPromptCommand: true` set in the `e2e` config of the `cypress.config.js|ts` file. -To run against a deployed version of studio: +To run against a deployed version of `cy.prompt`: - Set: - `CYPRESS_INTERNAL_ENV=` (e.g. `staging` or `production` if you want to hit those deployments of `cypress-services` or `development` if you want to hit a locally running version of `cypress-services`) From dcd49e89dfce64cb564d1374fbcf7bf75acab391 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Thu, 22 May 2025 21:44:05 -0500 Subject: [PATCH 09/11] PR comments --- .../driver/src/cy/commands/prompt/index.ts | 45 ++++++++++--------- .../src/cy-prompt/cy-prompt-server-types.ts | 4 ++ 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 1dcfe5980478..08498e10db07 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -5,12 +5,15 @@ interface CyPromptDriver { default: CyPromptDriverDefaultShape } let initializedCyPrompt: CyPromptDriverDefaultShape | null = null const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise => { + // Wait for the cy prompt bundle to be downloaded and ready const { success } = await Cypress.backend('wait:for:cy:prompt:ready') if (!success) { throw new Error('CyPromptDriver not found') } + // Once the cy prompt bundle is downloaded and ready, + // we can initialize it via the module federation runtime init({ remotes: [{ alias: 'cy-prompt', @@ -23,6 +26,8 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise('cy-prompt') if (!module?.default) { @@ -35,25 +40,25 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise { - Commands.addAll({ - async 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 - - if (!cloud) { - cloud = await initializeCloudCyPrompt(Cypress) - } + if (Cypress.config('experimentalPromptCommand')) { + Commands.addAll({ + async prompt (message: string) { + try { + let cloud = initializedCyPrompt - return await cloud.cyPrompt(Cypress, message) - } catch (error) { - // TODO: handle this better - throw new Error('CyPromptDriver not found') - } - }, - }) + // If the cy prompt driver is not initialized, + // we need to wait for it to be initialized + // before using it + 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/types/src/cy-prompt/cy-prompt-server-types.ts b/packages/types/src/cy-prompt/cy-prompt-server-types.ts index 9915b30b636b..8e6cdd83fb3b 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-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. + /// import type { Router } from 'express' From 41e08de04168bc5cf1880e6f611cb9ed4f736527 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 27 May 2025 17:19:17 -0500 Subject: [PATCH 10/11] Update packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 2a8d1b83e108..fbdc12171478 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 @@ -53,12 +53,12 @@ export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: }))() if (!responseSignature) { - throw new Error('Unable to get studio signature') + throw new Error('Unable to get cy-prompt signature') } const verified = await verifySignatureFromFile(bundlePath, responseSignature) if (!verified) { - throw new Error('Unable to verify studio signature') + throw new Error('Unable to verify cy-prompt signature') } } From 04ee86bce2b014e50153d7d4426bd5afa3b9d308 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 27 May 2025 17:21:48 -0500 Subject: [PATCH 11/11] PR comments --- guides/cy-prompt-development.md | 1 - packages/driver/src/cy/commands/prompt/index.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/guides/cy-prompt-development.md b/guides/cy-prompt-development.md index 6172a87655c7..0613ba08090e 100644 --- a/guides/cy-prompt-development.md +++ b/guides/cy-prompt-development.md @@ -20,7 +20,6 @@ To run against a deployed version of `cy.prompt`: - Set: - `CYPRESS_INTERNAL_ENV=` (e.g. `staging` or `production` if you want to hit those deployments of `cypress-services` or `development` if you want to hit a locally running version of `cypress-services`) -``` ## Testing diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 08498e10db07..211cfc4f1c23 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -56,7 +56,7 @@ export default (Commands, Cypress, cy) => { return await cloud.cyPrompt(Cypress, message) } catch (error) { // TODO: handle this better - throw new Error('CyPromptDriver not found') + throw new Error(`CyPromptDriver not found: ${error}`) } }, })