From dc7a054afa82df0713cc282f0f3669d3f1cd6dc8 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 20 May 2025 16:37:22 -0500 Subject: [PATCH 01/14] Create cy-prompt-development.md --- guides/cy-prompt-development.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 guides/cy-prompt-development.md diff --git a/guides/cy-prompt-development.md b/guides/cy-prompt-development.md new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/guides/cy-prompt-development.md @@ -0,0 +1 @@ + From aa543ae5e77963a09f0d1405e8fdc86559972b12 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 27 May 2025 20:58:10 -0500 Subject: [PATCH 02/14] chore: cy prompt infrastructure (#31748) * feat: cy prompt infrastructure * refactor and add tests * refactor * rename experimental config * prompt * fix test * Update cy-prompt-development.md * Update cy-prompt-development.md * PR comments * Update packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * PR comments --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cli/types/cypress.d.ts | 10 + guides/cy-prompt-development.md | 29 +++ .../config/__snapshots__/index.spec.ts.js | 3 + packages/config/src/options.ts | 6 + packages/config/test/project/utils.spec.ts | 2 + .../data-context/src/data/coreDataShape.ts | 3 +- packages/driver/cypress.config.ts | 1 + .../driver/cypress/e2e/commands/prompt.cy.ts | 9 + .../e2e/e2e/origin/commands/misc.cy.ts | 2 +- packages/driver/package.json | 1 + packages/driver/src/cy/commands/index.ts | 3 + .../driver/src/cy/commands/prompt/index.ts | 64 +++++ .../cy/commands/prompt/prompt-driver-types.ts | 7 + .../driver/types/internal-types-lite.d.ts | 2 +- .../frontend-shared/src/locales/en-US.json | 4 + .../lib/{ => cloud}/StudioLifecycleManager.ts | 22 +- .../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 +- .../cy-prompt/CyPromptLifecycleManager.ts | 168 ++++++++++++ .../lib/cloud/cy-prompt/CyPromptManager.ts | 115 +++++++++ .../cy-prompt/ensure_cy_prompt_bundle.ts | 37 +++ packages/server/lib/cloud/routes.ts | 2 + packages/server/lib/experiments.ts | 2 + packages/server/lib/project-base.ts | 12 +- packages/server/lib/routes.ts | 15 +- packages/server/lib/socket-base.ts | 18 +- .../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 | 238 +++++++++++++++++ .../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/config.ts | 2 +- .../src/cy-prompt/cy-prompt-server-types.ts | 47 ++++ packages/types/src/cy-prompt/index.ts | 16 ++ packages/types/src/index.ts | 2 + system-tests/__snapshots__/results_spec.ts.js | 1 + 43 files changed, 1614 insertions(+), 75 deletions(-) create mode 100644 packages/driver/cypress/e2e/commands/prompt.cy.ts create mode 100644 packages/driver/src/cy/commands/prompt/index.ts create mode 100644 packages/driver/src/cy/commands/prompt/prompt-driver-types.ts rename packages/server/lib/{ => cloud}/StudioLifecycleManager.ts (90%) 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/CyPromptLifecycleManager.ts create mode 100644 packages/server/lib/cloud/cy-prompt/CyPromptManager.ts 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 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..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. * @@ -3158,6 +3163,11 @@ declare namespace Cypress { * @default false */ experimentalStudio: boolean + /** + * Enables the prompt command feature. + * @default false + */ + 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/guides/cy-prompt-development.md b/guides/cy-prompt-development.md index 8b137891791f..0613ba08090e 100644 --- a/guides/cy-prompt-development.md +++ b/guides/cy-prompt-development.md @@ -1 +1,30 @@ +# `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 `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 + +### 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. diff --git a/packages/config/__snapshots__/index.spec.ts.js b/packages/config/__snapshots__/index.spec.ts.js index bece4725456e..852c5c13027f 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, + 'experimentalPromptCommand': 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, + 'experimentalPromptCommand': 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', + 'experimentalPromptCommand', 'experimentalWebKitSupport', 'fileServerFolder', 'fixturesFolder', diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index e86be8c01354..6b91d4ec7540 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: 'experimentalPromptCommand', + 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..85a23858bda3 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' }, + experimentalPromptCommand: { value: false, from: 'default' }, experimentalSourceRewriting: { value: false, from: 'default' }, experimentalWebKitSupport: { value: false, from: 'default' }, fileServerFolder: { value: '', from: 'default' }, @@ -1197,6 +1198,7 @@ describe('config/src/project/utils', () => { experimentalRunAllSpecs: { value: false, from: 'default' }, experimentalSingleTabRunMode: { value: false, from: 'default' }, experimentalStudio: { 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/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/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 new file mode 100644 index 000000000000..9fb327349909 --- /dev/null +++ b/packages/driver/cypress/e2e/commands/prompt.cy.ts @@ -0,0 +1,9 @@ +describe('src/cy/commands/prompt', () => { + it('executes the prompt command', () => { + cy.visit('/fixtures/dom.html') + + // 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!') + }) +}) 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/package.json b/packages/driver/package.json index 319ee8badb2b..bf89f12c970a 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..211cfc4f1c23 --- /dev/null +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -0,0 +1,64 @@ +import { init, loadRemote } from '@module-federation/runtime' +import type{ CyPromptDriverDefaultShape } from './prompt-driver-types' + +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', + type: 'module', + name: 'cy-prompt', + entryGlobalName: 'cy-prompt', + entry: '/__cypress-cy-prompt/cy-prompt.js', + shareScope: 'default', + }], + name: 'driver', + }) + + // This cy-prompt.js file and any subsequent files are + // served from the cy prompt bundle. + 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) => { + if (Cypress.config('experimentalPromptCommand')) { + Commands.addAll({ + async prompt (message: string) { + try { + let cloud = initializedCyPrompt + + // 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: ${error}`) + } + }, + }) + } +} 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..3f742f4b9678 --- /dev/null +++ b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts @@ -0,0 +1,7 @@ +export interface CypressInternal extends Cypress.Cypress { + backend: (eventName: string, ...args: any[]) => Promise +} + +export interface CyPromptDriverDefaultShape { + cyPrompt: (Cypress: CypressInternal, 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..3f009cb586a8 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." }, + "experimentalPromptCommand": { + "name": "Prompt command", + "description": "Enables support for the 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/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 new file mode 100644 index 000000000000..fbdc12171478 --- /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 { createWriteStream } 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 = 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 cy-prompt signature') + } + + const verified = await verifySignatureFromFile(bundlePath, responseSignature) + + if (!verified) { + throw new Error('Unable to verify cy-prompt 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/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts new file mode 100644 index 000000000000..e7bce3cd41cb --- /dev/null +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -0,0 +1,168 @@ +import { CyPromptManager } from './CyPromptManager' +import Debug from 'debug' +import type { CloudDataSource } from '@packages/data-context/src/sources' +import type { DataContext } from '@packages/data-context' +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 { readFile } from 'fs-extra' +import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle' + +const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') + +export class CyPromptLifecycleManager { + private cyPromptManagerPromise?: Promise + private cyPromptManager?: CyPromptManager + private listeners: ((cyPromptManager: CyPromptManager) => void)[] = [] + + /** + * 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 ctx Data context to register this instance with + */ + initializeCyPromptManager ({ + projectId, + cloudDataSource, + ctx, + }: { + projectId: string + cloudDataSource: CloudDataSource + ctx: DataContext + }): void { + // Register this instance in the data context + ctx.update((data) => { + data.cyPromptLifecycleManager = this + }) + + const cyPromptManagerPromise = this.createCyPromptManager({ + projectId, + cloudDataSource, + }).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 + } + + 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, + }: { + projectId: string + cloudDataSource: CloudDataSource + }): Promise { + const cyPromptSession = await postCyPromptSession({ + projectId, + }) + + // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension + const cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] + const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) + const bundlePath = path.join(cyPromptPath, 'bundle.tar') + const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') + + await ensureCyPromptBundle({ + cyPromptUrl: cyPromptSession.cyPromptUrl, + projectId, + cyPromptPath, + bundlePath, + }) + + const script = await readFile(serverFilePath, 'utf8') + const cyPromptManager = new CyPromptManager() + + 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/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts new file mode 100644 index 000000000000..ce247d5a156f --- /dev/null +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -0,0 +1,115 @@ +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 + } + + if (isEssential) { + 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/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/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..555bbca5c9db 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.', + experimentalPromptCommand: 'Enables support for the prompt command.', } /** @@ -82,6 +83,7 @@ const _names: StringValues = { experimentalRunAllSpecs: 'Run All Specs', experimentalOriginDependencies: 'Origin Dependencies', experimentalMemoryManagement: 'Memory Management', + experimentalPromptCommand: 'Prompt Command', } /** diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 163342fb997a..42d0986d6477 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -24,11 +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 './cloud/cy-prompt/CyPromptLifecycleManager' export interface Cfg extends ReceivedCypressOptions { projectId?: string @@ -156,6 +157,15 @@ export class ProjectBase extends EE { process.chdir(this.projectRoot) this._server = new ServerBase(cfg) + if (cfg.projectId && cfg.experimentalPromptCommand) { + const cyPromptLifecycleManager = new CyPromptLifecycleManager() + + cyPromptLifecycleManager.initializeCyPromptManager({ + projectId: cfg.projectId, + cloudDataSource: this.ctx.cloud, + 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/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..bbe38fb9beed --- /dev/null +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -0,0 +1,238 @@ +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 { 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 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 + + 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, + 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, + 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..448a121618aa 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.experimentalPromptCommand = 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 experimentalPromptCommand is not enabled', function () { + this.config.projectId = 'abc123' + this.config.experimentalPromptCommand = 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.experimentalPromptCommand = 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/config.ts b/packages/types/src/config.ts index 0c5018bbb3d5..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/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..8e6cdd83fb3b --- /dev/null +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -0,0 +1,47 @@ +// 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' +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..f919c2f2f219 --- /dev/null +++ b/packages/types/src/cy-prompt/index.ts @@ -0,0 +1,16 @@ +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 + 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' diff --git a/system-tests/__snapshots__/results_spec.ts.js b/system-tests/__snapshots__/results_spec.ts.js index ba61829ae392..dfbf7708d64b 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, + "experimentalPromptCommand": false, "experimentalWebKitSupport": false, "fileServerFolder": "/path/to/fileServerFolder", "fixturesFolder": "/path/to/fixturesFolder", From 18b0d9d80a2b5d3ecccd69e85f47e126b0c11f2b Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sun, 1 Jun 2025 21:37:25 -0500 Subject: [PATCH 03/14] fix test --- packages/server/test/unit/project_spec.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/server/test/unit/project_spec.js b/packages/server/test/unit/project_spec.js index 9e4d48dacc05..244460e45e03 100644 --- a/packages/server/test/unit/project_spec.js +++ b/packages/server/test/unit/project_spec.js @@ -454,6 +454,8 @@ This option will not have an effect in Some-other-name. Tests that rely on web s this.config.projectId = 'abc123' this.config.experimentalPromptCommand = true + sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager') + return this.project.open() .then(() => { expect(CyPromptLifecycleManager.prototype.initializeCyPromptManager).to.be.calledWith({ @@ -468,6 +470,8 @@ This option will not have an effect in Some-other-name. Tests that rely on web s this.config.projectId = 'abc123' this.config.experimentalPromptCommand = false + sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager') + return this.project.open() .then(() => { expect(CyPromptLifecycleManager.prototype.initializeCyPromptManager).not.to.be.called @@ -478,6 +482,8 @@ This option will not have an effect in Some-other-name. Tests that rely on web s this.config.projectId = undefined this.config.experimentalPromptCommand = true + sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager') + return this.project.open() .then(() => { expect(CyPromptLifecycleManager.prototype.initializeCyPromptManager).not.to.be.called From 3bc98e0b915cc6bfdf0ab8ce7dddd7d33c74a448 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sun, 1 Jun 2025 22:07:09 -0500 Subject: [PATCH 04/14] Delete packages/server/lib/cloud/StudioLifecycleManager.ts --- .../lib/cloud/StudioLifecycleManager.ts | 220 ------------------ 1 file changed, 220 deletions(-) delete mode 100644 packages/server/lib/cloud/StudioLifecycleManager.ts diff --git a/packages/server/lib/cloud/StudioLifecycleManager.ts b/packages/server/lib/cloud/StudioLifecycleManager.ts deleted file mode 100644 index ff6115907fc7..000000000000 --- a/packages/server/lib/cloud/StudioLifecycleManager.ts +++ /dev/null @@ -1,220 +0,0 @@ -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 _ from 'lodash' -import type { DataContext } from '@packages/data-context' -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('./routes') - -export class StudioLifecycleManager { - private studioManagerPromise?: Promise - private studioManager?: StudioManager - private listeners: ((studioManager: StudioManager) => void)[] = [] - private ctx?: DataContext - private lastStatus?: StudioStatus - - public get cloudStudioRequested () { - return !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH) - } - - /** - * Initialize the studio manager and possibly set up protocol. - * 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 - */ - initializeStudioManager ({ - 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.studioLifecycleManager = this - }) - - this.ctx = ctx - - this.updateStatus('INITIALIZING') - - const studioManagerPromise = this.createStudioManager({ - projectId, - cloudDataSource, - cfg, - debugData, - }).catch(async (error) => { - debug('Error during studio 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() - - reportStudioError({ - cloudApi: { - cloudUrl, - cloudHeaders, - CloudRequest, - isRetryableError, - asyncRetry, - }, - studioHash: projectId, - projectSlug: cfg.projectId, - error, - studioMethod: 'initializeStudioManager', - studioMethodArgs: [], - }) - - this.updateStatus('IN_ERROR') - - // Clean up any registered listeners - this.listeners = [] - - return null - }) - - this.studioManagerPromise = studioManagerPromise - } - - isStudioReady (): boolean { - return !!this.studioManager - } - - async getStudio () { - if (!this.studioManagerPromise) { - throw new Error('Studio manager has not been initialized') - } - - const studioManager = await this.studioManagerPromise - - if (studioManager) { - this.updateStatus(studioManager.status) - } - - return studioManager - } - - private async createStudioManager ({ - projectId, - cloudDataSource, - cfg, - debugData, - }: { - projectId?: string - cloudDataSource: CloudDataSource - cfg: Cfg - debugData: any - }): Promise { - const studioSession = await postStudioSession({ - projectId, - }) - - const studioManager = await getAndInitializeStudioManager({ - studioUrl: studioSession.studioUrl, - projectId, - cloudDataSource, - shouldEnableStudio: this.cloudStudioRequested, - lifecycleManager: this, - }) - - if (studioManager.status === 'ENABLED') { - debug('Cloud studio is enabled - setting up protocol') - const protocolManager = new ProtocolManager() - const script = await api.getCaptureProtocolScript(studioSession.protocolUrl) - - await protocolManager.prepareProtocol(script, { - runId: 'studio', - projectId: cfg.projectId, - testingType: cfg.testingType, - cloudApi: { - url: routes.apiUrl, - retryWithBackoff: api.retryWithBackoff, - requestPromise: api.rp, - }, - projectConfig: _.pick(cfg, ['devServerPublicPathRoute', 'port', 'proxyUrl', 'namespace']), - mountVersion: api.runnerCapabilities.protocolMountVersion, - debugData, - mode: 'studio', - }) - - studioManager.protocolManager = protocolManager - } else { - debug('Cloud studio is not enabled - skipping protocol setup') - } - - debug('Studio is ready') - this.studioManager = studioManager - this.callRegisteredListeners() - this.updateStatus(studioManager.status) - - return studioManager - } - - private callRegisteredListeners () { - if (!this.studioManager) { - throw new Error('Studio manager has not been initialized') - } - - const studioManager = this.studioManager - - debug('Calling all studio ready listeners') - this.listeners.forEach((listener) => { - listener(studioManager) - }) - - this.listeners = [] - } - - /** - * Register a listener that will be called when the studio is ready - * @param listener Function to call when studio is ready - */ - registerStudioReadyListener (listener: (studioManager: StudioManager) => void): void { - // if there is already a studio manager, call the listener immediately - if (this.studioManager) { - debug('Studio ready - calling listener immediately') - listener(this.studioManager) - } else { - debug('Studio not ready - registering studio ready listener') - this.listeners.push(listener) - } - } - - public updateStatus (status: StudioStatus) { - if (status === this.lastStatus) { - debug('Studio status unchanged: %s', status) - - return - } - - debug('Studio status changed: %s → %s', this.lastStatus, status) - this.lastStatus = status - - if (this.ctx) { - this.ctx?.emitter.studioStatusChange() - } else { - debug('No ctx available, cannot emit studioStatusChange') - } - } -} From 93430dc12fe92c0907cc67a56752611d7cbe5576 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sun, 1 Jun 2025 22:07:31 -0500 Subject: [PATCH 05/14] Delete packages/server/test/unit/cloud/StudioLifecycleManager_spec.ts --- .../unit/cloud/StudioLifecycleManager_spec.ts | 465 ------------------ 1 file changed, 465 deletions(-) delete mode 100644 packages/server/test/unit/cloud/StudioLifecycleManager_spec.ts diff --git a/packages/server/test/unit/cloud/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/StudioLifecycleManager_spec.ts deleted file mode 100644 index 47b5ce285aae..000000000000 --- a/packages/server/test/unit/cloud/StudioLifecycleManager_spec.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { sinon } from '../../spec_helper' -import { expect } from 'chai' -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 { 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' - -// Helper to wait for next tick in event loop -const nextTick = () => new Promise((resolve) => process.nextTick(resolve)) - -describe('StudioLifecycleManager', () => { - let studioLifecycleManager: StudioLifecycleManager - let mockStudioManager: StudioManager - let mockCtx: DataContext - let mockCloudDataSource: CloudDataSource - let mockCfg: Cfg - let postStudioSessionStub: sinon.SinonStub - let getAndInitializeStudioManagerStub: sinon.SinonStub - let getCaptureProtocolScriptStub: sinon.SinonStub - let prepareProtocolStub: sinon.SinonStub - let reportStudioErrorStub: sinon.SinonStub - let studioStatusChangeEmitterStub: sinon.SinonStub - - beforeEach(() => { - studioLifecycleManager = new StudioLifecycleManager() - mockStudioManager = { - addSocketListeners: sinon.stub(), - canAccessStudioAI: sinon.stub().resolves(true), - status: 'INITIALIZED', - } as unknown as StudioManager - - studioStatusChangeEmitterStub = 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: { - studioStatusChange: studioStatusChangeEmitterStub, - }, - } as unknown as DataContext - - mockCloudDataSource = {} 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 - - postStudioSessionStub = sinon.stub(postStudioSessionModule, 'postStudioSession') - postStudioSessionStub.resolves({ - studioUrl: 'https://cloud.cypress.io/studio/bundle/abc.tgz', - protocolUrl: 'https://cloud.cypress.io/capture-protocol/script/def.js', - }) - - getAndInitializeStudioManagerStub = sinon.stub(getAndInitializeStudioManagerModule, 'getAndInitializeStudioManager') - getAndInitializeStudioManagerStub.resolves(mockStudioManager) - - getCaptureProtocolScriptStub = sinon.stub(api, 'getCaptureProtocolScript').resolves('console.log("hello")') - prepareProtocolStub = sinon.stub(ProtocolManager.prototype, 'prepareProtocol').resolves() - - reportStudioErrorStub = sinon.stub(reportStudioErrorPath, 'reportStudioError') - }) - - afterEach(() => { - sinon.restore() - }) - - describe('cloudStudioRequested', () => { - it('is true when CYPRESS_ENABLE_CLOUD_STUDIO is set', async () => { - process.env.CYPRESS_ENABLE_CLOUD_STUDIO = '1' - delete process.env.CYPRESS_LOCAL_STUDIO_PATH - - expect(studioLifecycleManager.cloudStudioRequested).to.be.true - }) - - it('is true when CYPRESS_LOCAL_STUDIO_PATH is set', async () => { - delete process.env.CYPRESS_ENABLE_CLOUD_STUDIO - process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio' - - expect(studioLifecycleManager.cloudStudioRequested).to.be.true - }) - - it('is false when neither env variable is set', async () => { - delete process.env.CYPRESS_ENABLE_CLOUD_STUDIO - delete process.env.CYPRESS_LOCAL_STUDIO_PATH - - expect(studioLifecycleManager.cloudStudioRequested).to.be.false - }) - - it('is true when both env variables are set', async () => { - process.env.CYPRESS_ENABLE_CLOUD_STUDIO = '1' - process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio' - - expect(studioLifecycleManager.cloudStudioRequested).to.be.true - }) - }) - - describe('initializeStudioManager', () => { - it('initializes the studio manager and registers it in the data context', async () => { - studioLifecycleManager.initializeStudioManager({ - projectId: 'test-project-id', - cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, - ctx: mockCtx, - }) - - const studioReadyPromise = new Promise((resolve) => { - studioLifecycleManager?.registerStudioReadyListener((studioManager) => { - resolve(studioManager) - }) - }) - - await studioReadyPromise - - expect(mockCtx.update).to.be.calledOnce - expect(studioLifecycleManager.isStudioReady()).to.be.true - }) - - it('sets up protocol if studio is enabled', async () => { - mockStudioManager.status = 'ENABLED' - - studioLifecycleManager.initializeStudioManager({ - projectId: 'abc123', - cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, - ctx: mockCtx, - }) - - const studioReadyPromise = new Promise((resolve) => { - studioLifecycleManager?.registerStudioReadyListener((studioManager) => { - resolve(studioManager) - }) - }) - - await studioReadyPromise - - expect(postStudioSessionStub).to.be.calledWith({ - projectId: 'abc123', - }) - - expect(getCaptureProtocolScriptStub).to.be.calledWith('https://cloud.cypress.io/capture-protocol/script/def.js') - expect(prepareProtocolStub).to.be.calledWith('console.log("hello")', { - runId: 'studio', - projectId: 'abc123', - testingType: 'e2e', - cloudApi: { - url: 'http://localhost:1234/', - retryWithBackoff: api.retryWithBackoff, - requestPromise: api.rp, - }, - projectConfig: { - devServerPublicPathRoute: '/__cypress/src', - namespace: '__cypress', - port: 8888, - proxyUrl: 'http://localhost:8888', - }, - mountVersion: 2, - debugData: {}, - mode: 'studio', - }) - }) - - it('handles errors during initialization and reports them', async () => { - const error = new Error('Test error') - const listener1 = sinon.stub() - const listener2 = sinon.stub() - - // Register listeners that should be cleaned up - studioLifecycleManager.registerStudioReadyListener(listener1) - studioLifecycleManager.registerStudioReadyListener(listener2) - - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners.length).to.equal(2) - - getAndInitializeStudioManagerStub.rejects(error) - - const reportErrorPromise = new Promise((resolve) => { - reportStudioErrorStub.callsFake(() => { - resolve() - - return undefined - }) - }) - - // Should not throw - studioLifecycleManager.initializeStudioManager({ - projectId: 'test-project-id', - cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, - ctx: mockCtx, - }) - - await reportErrorPromise - - expect(mockCtx.update).to.be.calledOnce - - // @ts-expect-error - accessing private property - const studioPromise = studioLifecycleManager.studioManagerPromise - - expect(studioPromise).to.not.be.null - - expect(reportStudioErrorStub).to.be.calledOnce - expect(reportStudioErrorStub).to.be.calledWithMatch({ - cloudApi: sinon.match.object, - studioHash: 'test-project-id', - projectSlug: 'abc123', - error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Test error')), - studioMethod: 'initializeStudioManager', - studioMethodArgs: [], - }) - - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners.length).to.equal(0) - - expect(listener1).not.to.be.called - expect(listener2).not.to.be.called - - if (studioPromise) { - const result = await studioPromise - - expect(result).to.be.null - } - }) - }) - - describe('isStudioReady', () => { - it('returns false when studio manager has not been initialized', () => { - expect(studioLifecycleManager.isStudioReady()).to.be.false - }) - - it('returns true when studio has been initialized', async () => { - // @ts-expect-error - accessing private property - studioLifecycleManager.studioManager = mockStudioManager - - expect(studioLifecycleManager.isStudioReady()).to.be.true - }) - }) - - describe('getStudio', () => { - it('throws an error when studio manager is not initialized', async () => { - try { - await studioLifecycleManager.getStudio() - expect.fail('Expected method to throw') - } catch (error) { - expect(error.message).to.equal('Studio manager has not been initialized') - } - }) - - it('returns the studio manager when initialized', async () => { - // @ts-expect-error - accessing private property - studioLifecycleManager.studioManagerPromise = Promise.resolve(mockStudioManager) - - const result = await studioLifecycleManager.getStudio() - - expect(result).to.equal(mockStudioManager) - }) - }) - - describe('registerStudioReadyListener', () => { - it('registers a listener that will be called when studio is ready', () => { - const listener = sinon.stub() - - studioLifecycleManager.registerStudioReadyListener(listener) - - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners).to.include(listener) - }) - - it('calls listener immediately if studio is already ready', async () => { - const listener = sinon.stub() - - // @ts-expect-error - accessing private property - studioLifecycleManager.studioManager = mockStudioManager - - // @ts-expect-error - accessing non-existent property - studioLifecycleManager.studioReady = true - - await Promise.resolve() - - studioLifecycleManager.registerStudioReadyListener(listener) - - await Promise.resolve() - await Promise.resolve() - await nextTick() - - expect(listener).to.be.calledWith(mockStudioManager) - }) - - it('does not call listener if studio manager is null', async () => { - const listener = sinon.stub() - - // @ts-expect-error - accessing private property - studioLifecycleManager.studioManager = null - - // @ts-expect-error - accessing non-existent property - studioLifecycleManager.studioReady = true - - studioLifecycleManager.registerStudioReadyListener(listener) - - // Give enough time for any promises to resolve - await Promise.resolve() - await Promise.resolve() - await nextTick() - - expect(listener).not.to.be.called - }) - - it('adds multiple listeners to the list', () => { - const listener1 = sinon.stub() - const listener2 = sinon.stub() - - studioLifecycleManager.registerStudioReadyListener(listener1) - studioLifecycleManager.registerStudioReadyListener(listener2) - - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners).to.include(listener1) - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners).to.include(listener2) - }) - - it('cleans up listeners after calling them when studio becomes ready', async () => { - const listener1 = sinon.stub() - const listener2 = sinon.stub() - - studioLifecycleManager.registerStudioReadyListener(listener1) - studioLifecycleManager.registerStudioReadyListener(listener2) - - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners.length).to.equal(2) - - const listenersCalledPromise = Promise.all([ - new Promise((resolve) => { - listener1.callsFake(() => resolve()) - }), - new Promise((resolve) => { - listener2.callsFake(() => resolve()) - }), - ]) - - studioLifecycleManager.initializeStudioManager({ - projectId: 'test-project-id', - cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, - ctx: mockCtx, - }) - - await listenersCalledPromise - - await nextTick() - - expect(listener1).to.be.calledWith(mockStudioManager) - expect(listener2).to.be.calledWith(mockStudioManager) - - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners.length).to.equal(0) - }) - }) - - describe('status tracking', () => { - it('updates status and emits events when status changes', async () => { - // Setup the context to test status updates - // @ts-expect-error - accessing private property - studioLifecycleManager.ctx = mockCtx - - studioLifecycleManager.updateStatus('INITIALIZING') - - // Wait for nextTick to process - await nextTick() - - expect(studioStatusChangeEmitterStub).to.be.calledOnce - - // Same status should not trigger another event - studioStatusChangeEmitterStub.reset() - studioLifecycleManager.updateStatus('INITIALIZING') - - await nextTick() - expect(studioStatusChangeEmitterStub).not.to.be.called - - // Different status should trigger another event - studioStatusChangeEmitterStub.reset() - studioLifecycleManager.updateStatus('ENABLED') - - await nextTick() - expect(studioStatusChangeEmitterStub).to.be.calledOnce - }) - - it('updates status when getStudio is called', async () => { - // @ts-expect-error - accessing private property - studioLifecycleManager.ctx = mockCtx - // @ts-expect-error - accessing private property - studioLifecycleManager.studioManagerPromise = Promise.resolve(mockStudioManager) - - const updateStatusSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus') - - const result = await studioLifecycleManager.getStudio() - - expect(result).to.equal(mockStudioManager) - expect(updateStatusSpy).to.be.calledWith('INITIALIZED') - }) - - it('handles status updates properly during initialization', async () => { - const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus') - - studioLifecycleManager.initializeStudioManager({ - projectId: 'test-project-id', - cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, - ctx: mockCtx, - }) - - // Should set INITIALIZING status immediately - expect(statusChangesSpy).to.be.calledWith('INITIALIZING') - - const studioReadyPromise = new Promise((resolve) => { - studioLifecycleManager?.registerStudioReadyListener(() => { - resolve(true) - }) - }) - - await studioReadyPromise - - expect(statusChangesSpy).to.be.calledWith('INITIALIZED') - }) - - it('updates status to IN_ERROR when initialization fails', async () => { - getAndInitializeStudioManagerStub.rejects(new Error('Test error')) - - const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus') - - studioLifecycleManager.initializeStudioManager({ - projectId: 'test-project-id', - cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, - ctx: mockCtx, - }) - - expect(statusChangesSpy).to.be.calledWith('INITIALIZING') - - await new Promise((resolve) => setTimeout(resolve, 10)) - - expect(statusChangesSpy).to.be.calledWith('IN_ERROR') - }) - }) -}) From ebba6e4197a48d59ae2ae277087354ae02329f54 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 3 Jun 2025 10:03:33 -0500 Subject: [PATCH 06/14] chore: add cdp connection to cy prompt (#31806) * chore: add cdp connection to cy prompt * minor fix * fix type build * try to fix build * Update packages/server/lib/browsers/browser-cri-client.ts Co-authored-by: Bill Glesias * Update packages/server/lib/browsers/browser-cri-client.ts Co-authored-by: Bill Glesias * do not support prompt in firefox or webkit * rework timing of lifecycle * refactor * fix tests * troubleshooting * troubleshooting * fix tests * additional troubleshooting * additional troubleshooting * additional troubleshooting * attempt to fix build * add back * debugging * debugging * debugging * debugging * clean up * fix unit tests * rework --------- Co-authored-by: Bill Glesias --- .../driver/cypress/e2e/commands/prompt.cy.ts | 7 ++ .../driver/src/cy/commands/prompt/index.ts | 6 ++ .../server/lib/browsers/browser-cri-client.ts | 50 +++++++++--- packages/server/lib/browsers/chrome.ts | 14 +++- packages/server/lib/browsers/electron.ts | 15 +++- packages/server/lib/browsers/firefox.ts | 4 + packages/server/lib/browsers/index.ts | 8 +- packages/server/lib/browsers/types.ts | 6 +- packages/server/lib/browsers/webkit.ts | 4 + .../api/cy-prompt/get_cy_prompt_bundle.ts | 2 +- .../cy-prompt/CyPromptLifecycleManager.ts | 4 +- .../lib/cloud/cy-prompt/CyPromptManager.ts | 11 ++- .../cy-prompt/ensure_cy_prompt_bundle.ts | 2 +- packages/server/lib/open_project.ts | 4 + packages/server/lib/project-base.ts | 8 +- packages/server/lib/socket-base.ts | 5 +- .../cloud/cy-prompt/test-cy-prompt.ts | 6 +- .../unit/browsers/browser-cri-client_spec.ts | 57 ++++++++++++-- .../test/unit/browsers/browsers_spec.js | 13 ++++ .../server/test/unit/browsers/chrome_spec.js | 76 +++++++++++++++++++ .../test/unit/browsers/electron_spec.js | 47 +++++++++++- .../server/test/unit/browsers/firefox_spec.ts | 6 ++ .../server/test/unit/browsers/webkit_spec.ts | 8 ++ .../CyPromptLifecycleManager_spec.ts | 2 +- .../cloud/cy-prompt/CyPromptManager_spec.ts | 30 +++++++- .../server/test/unit/open_project_spec.js | 9 +++ packages/server/test/unit/project_spec.js | 46 ++++++++--- packages/server/test/unit/socket_spec.js | 19 +++-- .../src/cy-prompt/cy-prompt-server-types.ts | 21 +++++ .../cache/darwin/snapshot-meta.json | 11 +++ 30 files changed, 446 insertions(+), 55 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/prompt.cy.ts b/packages/driver/cypress/e2e/commands/prompt.cy.ts index 9fb327349909..78ba0620c4bc 100644 --- a/packages/driver/cypress/e2e/commands/prompt.cy.ts +++ b/packages/driver/cypress/e2e/commands/prompt.cy.ts @@ -1,5 +1,12 @@ describe('src/cy/commands/prompt', () => { it('executes the prompt command', () => { + // TODO: (cy.prompt) We will look into supporting other browsers + // as this is rolled out. We will add error messages for other browsers + // and add tests if necessary + if (Cypress.isBrowser('webkit') || Cypress.isBrowser('firefox')) { + return + } + cy.visit('/fixtures/dom.html') // TODO: add more tests when cy.prompt is built out, but for now this just diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 211cfc4f1c23..96d00215a12e 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -43,6 +43,12 @@ export default (Commands, Cypress, cy) => { if (Cypress.config('experimentalPromptCommand')) { Commands.addAll({ async prompt (message: string) { + if (Cypress.browser.family !== 'chromium' && Cypress.browser.name !== 'electron') { + // TODO: (cy.prompt) We will look into supporting other browsers (and testing them) + // as this is rolled out + throw new Error('`cy.prompt()` is not supported in this browser.') + } + try { let cloud = initializedCyPrompt diff --git a/packages/server/lib/browsers/browser-cri-client.ts b/packages/server/lib/browsers/browser-cri-client.ts index fe6aa66851a0..db53b515950e 100644 --- a/packages/server/lib/browsers/browser-cri-client.ts +++ b/packages/server/lib/browsers/browser-cri-client.ts @@ -8,7 +8,7 @@ import * as errors from '../errors' import type { CypressError } from '@packages/errors' import { CriClient, DEFAULT_NETWORK_ENABLE_OPTIONS } from './cri-client' import { serviceWorkerClientEventHandler, serviceWorkerClientEventHandlerName } from '@packages/proxy/lib/http/util/service-worker-manager' -import type { ProtocolManagerShape } from '@packages/types' +import type { CyPromptManagerShape, ProtocolManagerShape } from '@packages/types' import type { ServiceWorkerEventHandler } from '@packages/proxy/lib/http/util/service-worker-manager' const debug = Debug('cypress:server:browsers:browser-cri-client') @@ -38,6 +38,7 @@ type BrowserCriClientCreateOptions = { onReconnect?: (client: CriClient) => void port: number protocolManager?: ProtocolManagerShape + cyPromptManager?: CyPromptManagerShape onServiceWorkerClientEvent: ServiceWorkerEventHandler } @@ -184,10 +185,12 @@ export class BrowserCriClient { private browserName: string private onAsynchronousError: (err: CypressError) => void private protocolManager?: ProtocolManagerShape + private cyPromptManager?: CyPromptManagerShape private fullyManageTabs?: boolean onServiceWorkerClientEvent: ServiceWorkerEventHandler currentlyAttachedTarget: CriClient | undefined currentlyAttachedProtocolTarget: CriClient | undefined + currentlyAttachedCyPromptTarget: CriClient | undefined // whenever we instantiate the instance we're already connected bc // we receive an underlying CRI connection // TODO: remove "connected" in favor of closing/closed or disconnected @@ -466,8 +469,9 @@ export class BrowserCriClient { // otherwise it means the the browser itself was closed // always close the connection to the page target because it was destroyed - browserCriClient.currentlyAttachedTarget.close().catch(() => { }), - browserCriClient.currentlyAttachedProtocolTarget?.close().catch(() => {}) + browserCriClient.currentlyAttachedTarget.close().catch(() => { }) + browserCriClient.currentlyAttachedProtocolTarget?.close().catch(() => { }) + browserCriClient.currentlyAttachedCyPromptTarget?.close().catch(() => { }) new Bluebird((resolve) => { // this event could fire either expectedly or unexpectedly @@ -565,9 +569,10 @@ export class BrowserCriClient { // which we do when we exit out of studio in open mode. if (!this.currentlyAttachedProtocolTarget) { this.currentlyAttachedProtocolTarget = await this.currentlyAttachedTarget.clone() - await this.protocolManager?.connectToBrowser(this.currentlyAttachedProtocolTarget) } + await this.protocolManager?.connectToBrowser(this.currentlyAttachedProtocolTarget) + return this.currentlyAttachedTarget }, this.browserName, this.port) } @@ -606,8 +611,11 @@ export class BrowserCriClient { debug('target closed', this.currentlyAttachedTarget.targetId) - await this.currentlyAttachedTarget.close().catch(() => {}) - await this.currentlyAttachedProtocolTarget?.close().catch(() => {}) + await Promise.all([ + this.currentlyAttachedTarget.close().catch(() => {}), + this.currentlyAttachedProtocolTarget?.close().catch(() => {}), + this.currentlyAttachedCyPromptTarget?.close().catch(() => {}), + ]) debug('target client closed', this.currentlyAttachedTarget.targetId) } @@ -620,6 +628,10 @@ export class BrowserCriClient { this.browserClient.off(subscription.eventName, subscription.cb as any) }) + this.currentlyAttachedCyPromptTarget?.queue.subscriptions.forEach((subscription) => { + this.browserClient.off(subscription.eventName, subscription.cb as any) + }) + if (target) { this.currentlyAttachedTarget = await CriClient.create({ target: target.targetId, @@ -631,13 +643,24 @@ export class BrowserCriClient { browserClient: this.browserClient, }) - // Clone the target here so that we separate the protocol client and the main client. - // This allows us to close the protocol client independently of the main client - // which we do when we exit out of studio in open mode. - this.currentlyAttachedProtocolTarget = await this.currentlyAttachedTarget.clone() + const currentTarget = this.currentlyAttachedTarget + + const createProtocolTarget = async () => { + this.currentlyAttachedProtocolTarget = await currentTarget.clone() + } + + const createCyPromptTarget = async () => { + this.currentlyAttachedCyPromptTarget = await currentTarget.clone() + } + + await Promise.all([ + createProtocolTarget(), + createCyPromptTarget(), + ]) } else { this.currentlyAttachedTarget = undefined this.currentlyAttachedProtocolTarget = undefined + this.currentlyAttachedCyPromptTarget = undefined } this.resettingBrowserTargets = false @@ -696,8 +719,11 @@ export class BrowserCriClient { this.connected = false if (this.currentlyAttachedTarget) { - await this.currentlyAttachedTarget.close() - await this.currentlyAttachedProtocolTarget?.close() + await Promise.all([ + this.currentlyAttachedTarget.close(), + this.currentlyAttachedProtocolTarget?.close(), + this.currentlyAttachedCyPromptTarget?.close(), + ]) } await this.browserClient.close() diff --git a/packages/server/lib/browsers/chrome.ts b/packages/server/lib/browsers/chrome.ts index c2d969d3b73b..ea00e5401049 100644 --- a/packages/server/lib/browsers/chrome.ts +++ b/packages/server/lib/browsers/chrome.ts @@ -21,7 +21,7 @@ import type { CriClient } from './cri-client' import type { Automation } from '../automation' import memory from './memory' -import type { BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, RunModeVideoApi } from '@packages/types' +import type { BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, CyPromptManagerShape, RunModeVideoApi } from '@packages/types' import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' import { DEFAULT_CHROME_FLAGS } from '../util/chromium_flags' @@ -412,6 +412,18 @@ export = { await options.protocolManager?.connectToBrowser(browserCriClient.currentlyAttachedProtocolTarget) }, + async connectCyPromptToBrowser (options: { cyPromptManager?: CyPromptManagerShape }) { + const browserCriClient = this._getBrowserCriClient() + + if (!browserCriClient?.currentlyAttachedTarget) throw new Error('Missing pageCriClient in connectCyPromptToBrowser') + + if (!browserCriClient.currentlyAttachedCyPromptTarget) { + browserCriClient.currentlyAttachedCyPromptTarget = await browserCriClient.currentlyAttachedTarget.clone() + } + + await options.cyPromptManager?.connectToBrowser(browserCriClient.currentlyAttachedCyPromptTarget) + }, + async closeProtocolConnection () { const browserCriClient = this._getBrowserCriClient() diff --git a/packages/server/lib/browsers/electron.ts b/packages/server/lib/browsers/electron.ts index 07762ae2b7c7..fb75f9d7593e 100644 --- a/packages/server/lib/browsers/electron.ts +++ b/packages/server/lib/browsers/electron.ts @@ -12,7 +12,7 @@ import type { Browser, BrowserInstance, GracefulShutdownOptions } from './types' // tslint:disable-next-line no-implicit-dependencies - electron dep needs to be defined import type { BrowserWindow } from 'electron' import type { Automation } from '../automation' -import type { BrowserLaunchOpts, Preferences, ProtocolManagerShape, RunModeVideoApi } from '@packages/types' +import type { BrowserLaunchOpts, Preferences, ProtocolManagerShape, CyPromptManagerShape, RunModeVideoApi } from '@packages/types' import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' import memory from './memory' import { BrowserCriClient } from './browser-cri-client' @@ -500,6 +500,19 @@ export = { await options.protocolManager?.connectToBrowser(browserCriClient.currentlyAttachedProtocolTarget) }, + async connectCyPromptToBrowser (options: { cyPromptManager?: CyPromptManagerShape }) { + const browserCriClient = this._getBrowserCriClient() + + if (!browserCriClient?.currentlyAttachedTarget) throw new Error('Missing pageCriClient in connectCyPromptToBrowser') + + // Clone the target here so that we separate the cy prompt client and the main client. + if (!browserCriClient.currentlyAttachedCyPromptTarget) { + browserCriClient.currentlyAttachedCyPromptTarget = await browserCriClient.currentlyAttachedTarget.clone() + } + + await options.cyPromptManager?.connectToBrowser(browserCriClient.currentlyAttachedCyPromptTarget) + }, + async closeProtocolConnection () { const browserCriClient = this._getBrowserCriClient() diff --git a/packages/server/lib/browsers/firefox.ts b/packages/server/lib/browsers/firefox.ts index 9cdc2c4e4c9e..34850fc0e3de 100644 --- a/packages/server/lib/browsers/firefox.ts +++ b/packages/server/lib/browsers/firefox.ts @@ -439,6 +439,10 @@ export function connectProtocolToBrowser (): Promise { throw new Error('Protocol is not yet supported in firefox.') } +export function connectCyPromptToBrowser (): Promise { + throw new Error('CyPrompt is not yet supported in firefox.') +} + export function closeProtocolConnection (): Promise { throw new Error('Protocol is not yet supported in firefox.') } diff --git a/packages/server/lib/browsers/index.ts b/packages/server/lib/browsers/index.ts index b492d76b823a..ed09ac4c0799 100644 --- a/packages/server/lib/browsers/index.ts +++ b/packages/server/lib/browsers/index.ts @@ -7,7 +7,7 @@ import check from 'check-more-types' import { exec } from 'child_process' import util from 'util' import os from 'os' -import { BROWSER_FAMILY, BrowserLaunchOpts, BrowserNewTabOpts, FoundBrowser, ProtocolManagerShape } from '@packages/types' +import { BROWSER_FAMILY, BrowserLaunchOpts, BrowserNewTabOpts, FoundBrowser, ProtocolManagerShape, CyPromptManagerShape } from '@packages/types' import type { Browser, BrowserInstance, BrowserLauncher } from './types' import type { Automation } from '../automation' import type { DataContext } from '@packages/data-context' @@ -147,6 +147,12 @@ export = { await browserLauncher.connectProtocolToBrowser(options) }, + async connectCyPromptToBrowser (options: { browser: Browser, foundBrowsers?: FoundBrowser[], cyPromptManager?: CyPromptManagerShape }) { + const browserLauncher = await getBrowserLauncher(options.browser, options.foundBrowsers || []) + + await browserLauncher.connectCyPromptToBrowser(options) + }, + async closeProtocolConnection (options: { browser: Browser, foundBrowsers?: FoundBrowser[] }) { const browserLauncher = await getBrowserLauncher(options.browser, options.foundBrowsers || []) diff --git a/packages/server/lib/browsers/types.ts b/packages/server/lib/browsers/types.ts index d0579439d622..59668bd41915 100644 --- a/packages/server/lib/browsers/types.ts +++ b/packages/server/lib/browsers/types.ts @@ -1,4 +1,4 @@ -import type { FoundBrowser, BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape } from '@packages/types' +import type { FoundBrowser, BrowserLaunchOpts, BrowserNewTabOpts, ProtocolManagerShape, CyPromptManagerShape } from '@packages/types' import type { EventEmitter } from 'events' import type { Automation } from '../automation' import type { CDPSocketServer } from '@packages/socket/lib/cdp-socket' @@ -45,6 +45,10 @@ export type BrowserLauncher = { * Used to connect the protocol to an existing browser. */ connectProtocolToBrowser: (options: { protocolManager?: ProtocolManagerShape }) => Promise + /** + * Used to connect the cy prompt to an existing browser. + */ + connectCyPromptToBrowser: (options: { cyPromptManager?: CyPromptManagerShape }) => Promise /** * Closes the protocol connection to the browser. */ diff --git a/packages/server/lib/browsers/webkit.ts b/packages/server/lib/browsers/webkit.ts index 794990cedccc..22ccf58a3302 100644 --- a/packages/server/lib/browsers/webkit.ts +++ b/packages/server/lib/browsers/webkit.ts @@ -40,6 +40,10 @@ export function connectProtocolToBrowser (): Promise { throw new Error('Protocol is not yet supported in WebKit.') } +export function connectCyPromptToBrowser (): Promise { + throw new Error('CyPrompt is not yet supported in WebKit.') +} + export function closeProtocolConnection (): Promise { throw new Error('Protocol is not yet supported in WebKit.') } diff --git a/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts b/packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts index fbdc12171478..0eb3f3879afa 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 @@ -10,7 +10,7 @@ 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 }) => { +export const getCyPromptBundle = async ({ cyPromptUrl, projectId, bundlePath }: { cyPromptUrl: string, projectId?: string, bundlePath: string }) => { let responseSignature: string | null = null await (asyncRetry(async () => { diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index e7bce3cd41cb..931d709c1ed1 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -30,7 +30,7 @@ export class CyPromptLifecycleManager { cloudDataSource, ctx, }: { - projectId: string + projectId?: string cloudDataSource: CloudDataSource ctx: DataContext }): void { @@ -88,7 +88,7 @@ export class CyPromptLifecycleManager { projectId, cloudDataSource, }: { - projectId: string + projectId?: string cloudDataSource: CloudDataSource }): Promise { const cyPromptSession = await postCyPromptSession({ diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index ce247d5a156f..c3d08058b4d6 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -1,4 +1,4 @@ -import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, CyPromptServerShape, CyPromptCloudApi } from '@packages/types' +import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, CyPromptServerShape, CyPromptCloudApi, CyPromptCDPClient } from '@packages/types' import type { Router } from 'express' import Debug from 'debug' import { requireScript } from '../require_script' @@ -40,7 +40,13 @@ export class CyPromptManager implements CyPromptManagerShape { async handleBackendRequest (eventName: string, ...args: any[]): Promise { if (this._cyPromptServer) { - return this.invokeAsync('handleBackendRequest', { isEssential: true }, eventName, ...args) + return this.invokeAsync('handleBackendRequest', { isEssential: false }, eventName, ...args) + } + } + + connectToBrowser (target: CyPromptCDPClient): void { + if (this._cyPromptServer) { + return this.invokeSync('connectToBrowser', { isEssential: true }, target) } } @@ -54,6 +60,7 @@ export class CyPromptManager implements CyPromptManagerShape { } try { + // @ts-expect-error - TS not associating the method & args properly, even though we know it's correct return this._cyPromptServer[method].apply(this._cyPromptServer, args) } catch (error: unknown) { let actualError: Error diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index 14af0eac0e07..5248c479e558 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -7,7 +7,7 @@ import path from 'path' interface EnsureCyPromptBundleOptions { cyPromptPath: string cyPromptUrl: string - projectId: string + projectId?: string bundlePath: string } diff --git a/packages/server/lib/open_project.ts b/packages/server/lib/open_project.ts index 797eeac8f590..0f60e82c5189 100644 --- a/packages/server/lib/open_project.ts +++ b/packages/server/lib/open_project.ts @@ -235,6 +235,10 @@ export class OpenProject { await browsers.connectProtocolToBrowser(options) } + async connectCyPromptToBrowser (options) { + await browsers.connectCyPromptToBrowser(options) + } + changeUrlToSpec (spec: Cypress.Spec) { if (!this.projectBase) { debug('No projectBase, cannot change url') diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 1264799e2592..222201d23c59 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -17,7 +17,7 @@ import { SocketCt } from './socket-ct' import { SocketE2E } from './socket-e2e' import { ensureProp } from './util/class-helpers' import system from './util/system' -import { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ProtocolManagerShape, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording, AutomationCommands, StudioMetricsTypes } from '@packages/types' +import { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ProtocolManagerShape, CyPromptManagerShape, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording, AutomationCommands, StudioMetricsTypes } from '@packages/types' import { DataContext, getCtx } from '@packages/data-context' import { createHmac } from 'crypto' import { ServerBase } from './server-base' @@ -158,7 +158,7 @@ export class ProjectBase extends EE { process.chdir(this.projectRoot) this._server = new ServerBase(cfg) - if (cfg.projectId && cfg.experimentalPromptCommand) { + if (cfg.experimentalPromptCommand) { const cyPromptLifecycleManager = new CyPromptLifecycleManager() cyPromptLifecycleManager.initializeCyPromptManager({ @@ -514,6 +514,10 @@ export class ProjectBase extends EE { } }, + onCyPromptReady: async (cyPromptManager: CyPromptManagerShape) => { + await browsers.connectCyPromptToBrowser({ browser: this.browser, foundBrowsers: this.options.browsers, cyPromptManager }) + }, + onCaptureVideoFrames: (data: any) => { // TODO: move this to browser automation middleware this.emit('capture:video:frames', data) diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index e99e18f5d2bd..aa254b2e4285 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -154,6 +154,7 @@ export class SocketBase { onCaptureVideoFrames () {}, onStudioInit () {}, onStudioDestroy () {}, + onCyPromptReady () {}, }) let automationClient @@ -545,7 +546,9 @@ export class SocketBase { case 'close:extra:targets': return options.closeExtraTargets() case 'wait:for:cy:prompt:ready': - return getCtx().coreData.cyPromptLifecycleManager?.getCyPrompt().then((cyPrompt) => { + return getCtx().coreData.cyPromptLifecycleManager?.getCyPrompt().then(async (cyPrompt) => { + await options.onCyPromptReady(cyPrompt) + return { success: cyPrompt && cyPrompt.status === 'INITIALIZED', } 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 index 513a7e6107a6..cde602ab31ff 100644 --- 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 @@ -1,6 +1,6 @@ /// -import type { CyPromptServerShape, CyPromptServerDefaultShape } from '@packages/types' +import type { CyPromptServerShape, CyPromptServerDefaultShape, CyPromptCDPClient } from '@packages/types' import type { Router } from 'express' class CyPromptServer implements CyPromptServerShape { @@ -11,6 +11,10 @@ class CyPromptServer implements CyPromptServerShape { handleBackendRequest (eventName: string, ...args: any[]): Promise { return Promise.resolve() } + + connectToBrowser (criClient: CyPromptCDPClient): void { + // This is a test implementation that does nothing + } } const cyPromptServerDefault: CyPromptServerDefaultShape = { diff --git a/packages/server/test/unit/browsers/browser-cri-client_spec.ts b/packages/server/test/unit/browsers/browser-cri-client_spec.ts index 39e18c66ad1a..5647f1179a13 100644 --- a/packages/server/test/unit/browsers/browser-cri-client_spec.ts +++ b/packages/server/test/unit/browsers/browser-cri-client_spec.ts @@ -4,7 +4,7 @@ import { expect, proxyquire, sinon } from '../../spec_helper' import * as protocol from '../../../lib/browsers/protocol' import { stripAnsi } from '@packages/errors' import net from 'net' -import { ProtocolManagerShape } from '@packages/types' +import { ProtocolManagerShape, CyPromptManagerShape } from '@packages/types' import type { Protocol } from 'devtools-protocol' import { serviceWorkerClientEventHandlerName } from '@packages/proxy/lib/http/util/service-worker-manager' @@ -14,6 +14,7 @@ const THROWS_PORT = 65535 type GetClientParams = { protocolManager?: ProtocolManagerShape + cyPromptManager?: CyPromptManagerShape fullyManageTabs?: boolean } @@ -370,6 +371,9 @@ describe('lib/browsers/browser-cri-client', function () { currentlyAttachedProtocolTarget: { close: sinon.stub().resolves(), }, + currentlyAttachedCyPromptTarget: { + close: sinon.stub().resolves(), + }, resettingBrowserTargets: false, }, event: { @@ -386,6 +390,7 @@ describe('lib/browsers/browser-cri-client', function () { expect(options.browserCriClient.getExtraTargetClient).not.to.be.called expect(options.browserCriClient.currentlyAttachedTarget.close).not.to.be.called expect(options.browserCriClient.currentlyAttachedProtocolTarget.close).not.to.be.called + expect(options.browserCriClient.currentlyAttachedCyPromptTarget.close).not.to.be.called }) it('closes the extra target client', () => { @@ -451,7 +456,7 @@ describe('lib/browsers/browser-cri-client', function () { it('creates a page client when the passed in url is found', async function () { const mockProtocolClient = {} const mockPageClient = { - clone: sinon.stub().returns(mockProtocolClient), + clone: sinon.stub().onFirstCall().returns(mockProtocolClient), } send.withArgs('Target.getTargets').resolves({ targetInfos: [{ targetId: '1', url: 'http://foo.com' }, { targetId: '2', url: 'http://bar.com' }] }) @@ -468,7 +473,7 @@ describe('lib/browsers/browser-cri-client', function () { it('creates a page client when the passed in url is found and notifies the protocol manager and fully managed tabs', async function () { const mockProtocolClient = {} const mockPageClient = { - clone: sinon.stub().returns(mockProtocolClient), + clone: sinon.stub().onFirstCall().returns(mockProtocolClient), } const protocolManager: any = { connectToBrowser: sinon.stub().resolves(), @@ -491,7 +496,7 @@ describe('lib/browsers/browser-cri-client', function () { it('creates a page client when the passed in url is found and notifies the protocol manager and fully managed tabs and attaching to target throws', async function () { const mockProtocolClient = {} const mockPageClient = { - clone: sinon.stub().returns(mockProtocolClient), + clone: sinon.stub().onFirstCall().returns(mockProtocolClient), } const protocolManager: any = { connectToBrowser: sinon.stub().resolves(), @@ -582,13 +587,28 @@ describe('lib/browsers/browser-cri-client', function () { }, } + const mockCurrentlyAttachedCyPromptTarget = { + targetId: '100', + close: sinon.stub().resolves(sinon.stub().resolves()), + queue: { + subscriptions: [{ + eventName: 'Network.requestWillBeSent', + cb: sinon.stub(), + }], + }, + } + const mockUpdatedCurrentlyAttachedProtocolTarget = { targetId: '101', } + const mockUpdatedCurrentlyAttachedCyPromptTarget = { + targetId: '101', + } + const mockUpdatedCurrentlyAttachedTarget = { targetId: '101', - clone: sinon.stub().returns(mockUpdatedCurrentlyAttachedProtocolTarget), + clone: sinon.stub().onFirstCall().returns(mockUpdatedCurrentlyAttachedProtocolTarget).onSecondCall().returns(mockUpdatedCurrentlyAttachedCyPromptTarget), } send.withArgs('Target.createTarget', { url: 'about:blank' }).resolves(mockUpdatedCurrentlyAttachedTarget) @@ -600,6 +620,7 @@ describe('lib/browsers/browser-cri-client', function () { browserClient.currentlyAttachedTarget = mockCurrentlyAttachedTarget browserClient.currentlyAttachedProtocolTarget = mockCurrentlyAttachedProtocolTarget + browserClient.currentlyAttachedCyPromptTarget = mockCurrentlyAttachedCyPromptTarget browserClient.browserClient.off = sinon.stub() await browserClient.resetBrowserTargets(true) @@ -607,8 +628,10 @@ describe('lib/browsers/browser-cri-client', function () { expect(mockCurrentlyAttachedTarget.close).to.be.called expect(browserClient.currentlyAttachedTarget).to.eql(mockUpdatedCurrentlyAttachedTarget) expect(browserClient.currentlyAttachedProtocolTarget).to.eql(mockUpdatedCurrentlyAttachedProtocolTarget) + expect(browserClient.currentlyAttachedCyPromptTarget).to.eql(mockUpdatedCurrentlyAttachedCyPromptTarget) expect(browserClient.browserClient.off).to.be.calledWith('Network.requestWillBeSent', mockCurrentlyAttachedTarget.queue.subscriptions[0].cb) expect(browserClient.browserClient.off).to.be.calledWith('Network.requestWillBeSent', mockCurrentlyAttachedProtocolTarget.queue.subscriptions[0].cb) + expect(browserClient.browserClient.off).to.be.calledWith('Network.requestWillBeSent', mockCurrentlyAttachedCyPromptTarget.queue.subscriptions[0].cb) }) it('closes the currently attached target without keeping a tab open', async function () { @@ -628,19 +651,30 @@ describe('lib/browsers/browser-cri-client', function () { }, } + const mockCurrentlyAttachedCyPromptTarget = { + targetId: '100', + close: sinon.stub().resolves(sinon.stub().resolves()), + queue: { + subscriptions: [], + }, + } + send.withArgs('Target.closeTarget', { targetId: '100' }).resolves() const browserClient = await getClient() as any browserClient.currentlyAttachedTarget = mockCurrentlyAttachedTarget browserClient.currentlyAttachedProtocolTarget = mockCurrentlyAttachedProtocolTarget + browserClient.currentlyAttachedCyPromptTarget = mockCurrentlyAttachedCyPromptTarget await browserClient.resetBrowserTargets(false) expect(mockCurrentlyAttachedTarget.close).to.be.called expect(mockCurrentlyAttachedProtocolTarget.close).to.be.called + expect(mockCurrentlyAttachedCyPromptTarget.close).to.be.called expect(browserClient.currentlyAttachedTarget).to.be.undefined expect(browserClient.currentlyAttachedProtocolTarget).to.be.undefined + expect(browserClient.currentlyAttachedCyPromptTarget).to.be.undefined }) it('throws when there is no currently attached target', async function () { @@ -688,14 +722,25 @@ describe('lib/browsers/browser-cri-client', function () { close: sinon.stub().resolves(), } + const mockCurrentlyAttachedProtocolTarget = { + close: sinon.stub().resolves(), + } + + const mockCurrentlyAttachedCyPromptTarget = { + close: sinon.stub().resolves(), + } + const browserClient = await getClient() as any browserClient.currentlyAttachedTarget = mockCurrentlyAttachedTarget + browserClient.currentlyAttachedProtocolTarget = mockCurrentlyAttachedProtocolTarget + browserClient.currentlyAttachedCyPromptTarget = mockCurrentlyAttachedCyPromptTarget await browserClient.close() expect(mockCurrentlyAttachedTarget.close).to.be.called - expect(close).to.be.called + expect(mockCurrentlyAttachedProtocolTarget.close).to.be.called + expect(mockCurrentlyAttachedCyPromptTarget.close).to.be.called }) it('just the browser client with no currently attached target', async function () { diff --git a/packages/server/test/unit/browsers/browsers_spec.js b/packages/server/test/unit/browsers/browsers_spec.js index a7ec2835070b..811e7d266434 100644 --- a/packages/server/test/unit/browsers/browsers_spec.js +++ b/packages/server/test/unit/browsers/browsers_spec.js @@ -118,6 +118,19 @@ describe('lib/browsers/index', () => { }) }) + context('.connectCyPromptToBrowser', () => { + it('connects browser to cy prompt', async () => { + sinon.stub(chrome, 'connectCyPromptToBrowser').resolves() + await browsers.connectCyPromptToBrowser({ + browser: { + family: 'chromium', + }, + }) + + expect(chrome.connectCyPromptToBrowser).to.be.called + }) + }) + context('.closeProtocolConnection', () => { it('calls close on instance', async () => { sinon.stub(chrome, 'closeProtocolConnection').resolves() diff --git a/packages/server/test/unit/browsers/chrome_spec.js b/packages/server/test/unit/browsers/chrome_spec.js index 62436c7a4670..fa4bfb73a569 100644 --- a/packages/server/test/unit/browsers/chrome_spec.js +++ b/packages/server/test/unit/browsers/chrome_spec.js @@ -665,6 +665,82 @@ describe('lib/browsers/chrome', () => { }) }) + context('#connectCyPromptToBrowser', () => { + it('connects to the browser cri client', async function () { + const cyPromptManager = { + connectToBrowser: sinon.stub().resolves(), + } + + const mockCurrentlyAttachedCyPromptTarget = {} + + const pageCriClient = { + clone: sinon.stub().returns(mockCurrentlyAttachedCyPromptTarget), + } + + const browserCriClient = { + currentlyAttachedTarget: pageCriClient, + currentlyAttachedCyPromptTarget: mockCurrentlyAttachedCyPromptTarget, + } + + sinon.stub(chrome, '_getBrowserCriClient').returns(browserCriClient) + + await chrome.connectCyPromptToBrowser({ cyPromptManager }) + + expect(pageCriClient.clone).not.to.be.called + expect(cyPromptManager.connectToBrowser).to.be.calledWith(mockCurrentlyAttachedCyPromptTarget) + }) + + it('connects to the browser cri client when the cy prompt target has not been created', async function () { + const cyPromptManager = { + connectToBrowser: sinon.stub().resolves(), + } + + const mockCurrentlyAttachedCyPromptTarget = {} + + const pageCriClient = { + clone: sinon.stub().resolves(mockCurrentlyAttachedCyPromptTarget), + } + + const browserCriClient = { + currentlyAttachedTarget: pageCriClient, + } + + sinon.stub(chrome, '_getBrowserCriClient').returns(browserCriClient) + + await chrome.connectCyPromptToBrowser({ cyPromptManager }) + + expect(pageCriClient.clone).to.be.called + expect(cyPromptManager.connectToBrowser).to.be.calledWith(mockCurrentlyAttachedCyPromptTarget) + expect(browserCriClient.currentlyAttachedCyPromptTarget).to.eq(mockCurrentlyAttachedCyPromptTarget) + }) + + it('throws error if there is no browser cri client', function () { + const cyPromptManager = { + connectToBrowser: sinon.stub().resolves(), + } + + sinon.stub(chrome, '_getBrowserCriClient').returns(null) + + expect(chrome.connectCyPromptToBrowser({ cyPromptManager })).to.be.rejectedWith('Missing pageCriClient in connectCyPromptToBrowser') + expect(cyPromptManager.connectToBrowser).not.to.be.called + }) + + it('throws error if there is no page cri client', function () { + const cyPromptManager = { + connectToBrowser: sinon.stub().resolves(), + } + + const browserCriClient = { + currentlyAttachedTarget: null, + } + + sinon.stub(chrome, '_getBrowserCriClient').returns(browserCriClient) + + expect(chrome.connectCyPromptToBrowser({ cyPromptManager })).to.be.rejectedWith('Missing pageCriClient in connectCyPromptToBrowser') + expect(cyPromptManager.connectToBrowser).not.to.be.called + }) + }) + context('#closeProtocolConnection', () => { it('closes the protocol connection', async function () { const mockCurrentlyAttachedProtocolTarget = { diff --git a/packages/server/test/unit/browsers/electron_spec.js b/packages/server/test/unit/browsers/electron_spec.js index 338db60b46d8..5c679f250d82 100644 --- a/packages/server/test/unit/browsers/electron_spec.js +++ b/packages/server/test/unit/browsers/electron_spec.js @@ -24,6 +24,10 @@ describe('lib/browsers/electron', () => { connectToBrowser: sinon.stub().resolves(), } + this.cyPromptManager = { + connectToBrowser: sinon.stub().resolves(), + } + this.url = 'https://foo.com' this.state = {} this.options = { @@ -280,6 +284,47 @@ describe('lib/browsers/electron', () => { }) }) + context('.connectCyPromptToBrowser', () => { + it('connects to the browser cri client', async function () { + const mockCurrentlyAttachedCyPromptTarget = {} + + this.browserCriClient.currentlyAttachedCyPromptTarget = mockCurrentlyAttachedCyPromptTarget + sinon.stub(electron, '_getBrowserCriClient').returns(this.browserCriClient) + + await electron.connectCyPromptToBrowser({ cyPromptManager: this.cyPromptManager }) + expect(this.pageCriClient.clone).not.to.be.called + expect(this.cyPromptManager.connectToBrowser).to.be.calledWith(mockCurrentlyAttachedCyPromptTarget) + }) + + it('connects to the browser cri client when the cy prompt target has not been created', async function () { + const mockCurrentlyAttachedCyPromptTarget = {} + + this.pageCriClient.clone.resolves(mockCurrentlyAttachedCyPromptTarget) + sinon.stub(electron, '_getBrowserCriClient').returns(this.browserCriClient) + + await electron.connectCyPromptToBrowser({ cyPromptManager: this.cyPromptManager }) + expect(this.pageCriClient.clone).to.be.called + expect(this.cyPromptManager.connectToBrowser).to.be.calledWith(mockCurrentlyAttachedCyPromptTarget) + expect(this.browserCriClient.currentlyAttachedCyPromptTarget).to.eq(mockCurrentlyAttachedCyPromptTarget) + }) + + it('throws error if there is no browser cri client', function () { + sinon.stub(electron, '_getBrowserCriClient').returns(null) + + expect(electron.connectCyPromptToBrowser({ cyPromptManager: this.cyPromptManager })).to.be.rejectedWith('Missing pageCriClient in connectCyPromptToBrowser') + expect(this.cyPromptManager.connectToBrowser).not.to.be.called + }) + + it('throws error if there is no page cri client', async function () { + this.browserCriClient.currentlyAttachedTarget = null + + sinon.stub(electron, '_getBrowserCriClient').returns(this.browserCriClient) + + expect(electron.connectCyPromptToBrowser({ cyPromptManager: this.cyPromptManager })).to.be.rejectedWith('Missing pageCriClient in connectCyPromptToBrowser') + expect(this.cyPromptManager.connectToBrowser).not.to.be.called + }) + }) + context('#closeProtocolConnection', () => { it('closes the protocol connection', async function () { const mockCurrentlyAttachedProtocolTarget = { @@ -515,7 +560,7 @@ describe('lib/browsers/electron', () => { it('registers onRequest automation middleware and calls show when requesting to be focused', function () { sinon.spy(this.automation, 'use') - return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }, undefined, undefined, { attachCDPClient: sinon.stub() }) + return electron._launch(this.win, this.url, this.automation, this.options, undefined, undefined, { attachCDPClient: sinon.stub() }) .then(() => { expect(this.automation.use).to.be.called expect(this.automation.use.lastCall.args[0].onRequest).to.be.a('function') diff --git a/packages/server/test/unit/browsers/firefox_spec.ts b/packages/server/test/unit/browsers/firefox_spec.ts index a7d1b76c65cc..f89a46112129 100644 --- a/packages/server/test/unit/browsers/firefox_spec.ts +++ b/packages/server/test/unit/browsers/firefox_spec.ts @@ -684,6 +684,12 @@ describe('lib/browsers/firefox', () => { }) }) + context('#connectCyPromptToBrowser', () => { + it('throws error', () => { + expect(firefox.connectCyPromptToBrowser).to.throw('CyPrompt is not yet supported in firefox.') + }) + }) + context('#closeProtocolConnection', () => { it('throws error', () => { expect(firefox.closeProtocolConnection).to.throw('Protocol is not yet supported in firefox.') diff --git a/packages/server/test/unit/browsers/webkit_spec.ts b/packages/server/test/unit/browsers/webkit_spec.ts index bff4d0f7d490..97a9f9e7994c 100644 --- a/packages/server/test/unit/browsers/webkit_spec.ts +++ b/packages/server/test/unit/browsers/webkit_spec.ts @@ -78,6 +78,14 @@ describe('lib/browsers/webkit', () => { }) }) + context('#connectCyPromptToBrowser', () => { + it('throws error', () => { + const webkit = getWebkit() + + expect(webkit.connectCyPromptToBrowser).to.throw('CyPrompt is not yet supported in WebKit.') + }) + }) + context('#closeProtocolConnection', () => { it('throws error', async () => { const webkit = getWebkit() 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 bbe38fb9beed..7f6d4df6e7e9 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -91,7 +91,7 @@ describe('CyPromptLifecycleManager', () => { }) const cyPromptReadyPromise = new Promise((resolve) => { - cyPromptLifecycleManager?.registerCyPromptReadyListener((cyPromptManager) => { + cyPromptLifecycleManager?.registerCyPromptReadyListener(async (cyPromptManager) => { resolve(cyPromptManager) }) }) diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts index f57a98542cb9..bbf19ce91af1 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts @@ -52,6 +52,8 @@ describe('lib/cloud/cy-prompt', () => { cyPromptManager.initializeRoutes({} as any) expect(cyPromptManager.status).to.eq('IN_ERROR') + + // TODO: (cy.prompt) test that the error is reported }) }) @@ -63,7 +65,7 @@ describe('lib/cloud/cy-prompt', () => { await cyPromptManager.handleBackendRequest('cy:prompt:start', {} as any) - expect(cyPromptManager.status).to.eq('IN_ERROR') + // TODO: (cy.prompt) test that the error is reported }) }) @@ -99,4 +101,30 @@ describe('lib/cloud/cy-prompt', () => { expect(invokeSyncSpy).to.not.be.called }) }) + + describe('connectToBrowser', () => { + it('connects to the browser', () => { + const mockCriClient = { + send: sinon.stub().resolves(), + on: sinon.stub().resolves(), + } + + sinon.stub(cyPrompt, 'connectToBrowser') + + cyPromptManager.connectToBrowser(mockCriClient) + + expect(cyPrompt.connectToBrowser).to.be.calledWith(mockCriClient) + }) + + it('does not call connectToBrowser when cy prompt server is not defined', () => { + // Set _cyPromptServer to undefined + (cyPromptManager as any)._cyPromptServer = undefined + + const invokeSyncSpy = sinon.spy(cyPromptManager, 'invokeSync') + + cyPromptManager.connectToBrowser({} as any) + + expect(invokeSyncSpy).to.not.be.called + }) + }) }) diff --git a/packages/server/test/unit/open_project_spec.js b/packages/server/test/unit/open_project_spec.js index ad0ed0f067fb..6f7e489d569c 100644 --- a/packages/server/test/unit/open_project_spec.js +++ b/packages/server/test/unit/open_project_spec.js @@ -266,4 +266,13 @@ describe('lib/open_project', () => { expect(browsers.connectProtocolToBrowser).to.be.calledWith(options) }) }) + + context('#connectCyPromptToBrowser', () => { + it('connects cy prompt to browser', async () => { + sinon.stub(browsers, 'connectCyPromptToBrowser').resolves() + const options = sinon.stub() + + await openProject.connectCyPromptToBrowser(options) + }) + }) }) diff --git a/packages/server/test/unit/project_spec.js b/packages/server/test/unit/project_spec.js index 244460e45e03..8f3477b3365d 100644 --- a/packages/server/test/unit/project_spec.js +++ b/packages/server/test/unit/project_spec.js @@ -477,18 +477,6 @@ This option will not have an effect in Some-other-name. Tests that rely on web s 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.experimentalPromptCommand = true - - sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager') - - return this.project.open() - .then(() => { - expect(CyPromptLifecycleManager.prototype.initializeCyPromptManager).not.to.be.called - }) - }) }) describe('saved state', function () { @@ -1039,6 +1027,40 @@ This option will not have an effect in Some-other-name. Tests that rely on web s expect(protocolManager.close).to.have.been.calledOnce expect(this.project['_protocolManager']).to.be.undefined }) + + it('passes onCyPromptReady callback', async function () { + const mockCyPromptManager = { + foo: 'bar', + } + + // Create a browser object + this.project.browser = { + name: 'chrome', + family: 'chromium', + } + + this.project.options = { browsers: [this.project.browser] } + + sinon.stub(browsers, 'connectCyPromptToBrowser') + + // Modify the startWebsockets stub to track the callbacks + const callbackPromise = new Promise((resolve) => { + this.project.server.startWebsockets.callsFake(async (automation, config, callbacks) => { + await callbacks.onCyPromptReady(mockCyPromptManager) + resolve() + }) + }) + + this.project.startWebsockets({}, {}) + + await callbackPromise + + expect(browsers.connectCyPromptToBrowser).to.have.been.calledWith({ + browser: this.project.browser, + foundBrowsers: this.project.options.browsers, + cyPromptManager: mockCyPromptManager, + }) + }) }) context('#getProjectId', () => { diff --git a/packages/server/test/unit/socket_spec.js b/packages/server/test/unit/socket_spec.js index e22b260b9774..ec2d3878202b 100644 --- a/packages/server/test/unit/socket_spec.js +++ b/packages/server/test/unit/socket_spec.js @@ -66,6 +66,7 @@ describe('lib/socket', () => { onSavedStateChanged: sinon.spy(), onStudioInit: sinon.stub(), onStudioDestroy: sinon.stub(), + onCyPromptReady: sinon.stub(), } this.automation = new Automation({ @@ -558,6 +559,8 @@ describe('lib/socket', () => { return this.client.emit('backend:request', 'wait:for:cy:prompt:ready', (resp) => { expect(resp.response).to.deep.eq({ success: true }) + expect(this.options.onCyPromptReady).to.be.calledWith(mockCyPrompt) + return done() }) }) @@ -578,25 +581,25 @@ describe('lib/socket', () => { }) context('on(backend:request, cy:prompt)', () => { - it('calls handleBackendRequest with the correct arguments', function (done) { + it('calls handleBackendRequest with the correct arguments', async function () { // 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) + await 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') + await new Promise((resolve) => { + 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() + resolve() + }) }) }) }) 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 8e6cdd83fb3b..be0df63c42ad 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -4,9 +4,18 @@ /// +import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.d' import type { Router } from 'express' import type { AxiosInstance } from 'axios' +export type CyPromptCommands = ProtocolMapping.Commands + +export type CyPromptCommand = CyPromptCommands[T] + +export type CyPromptEvents = ProtocolMapping.Events + +export type CyPromptEvent = CyPromptEvents[T] + interface RetryOptions { maxAttempts: number retryDelay?: (attempt: number) => number @@ -34,9 +43,21 @@ export interface CyPromptServerOptions { cloudApi: CyPromptCloudApi } +export interface CyPromptCDPClient { + send>( + command: T, + params?: CyPromptCommand['paramsType'][0] + ): Promise['returnType']> + on>( + eventName: T, + cb: (event: CyPromptEvent[0]) => void | Promise + ): void +} + export interface CyPromptServerShape { initializeRoutes(router: Router): void handleBackendRequest: (eventName: string, ...args: any[]) => Promise + connectToBrowser: (cdpClient: CyPromptCDPClient) => void } export interface CyPromptServerDefaultShape { diff --git a/tooling/v8-snapshot/cache/darwin/snapshot-meta.json b/tooling/v8-snapshot/cache/darwin/snapshot-meta.json index 39dd8c01fafb..49e2fbfc1cb3 100644 --- a/tooling/v8-snapshot/cache/darwin/snapshot-meta.json +++ b/tooling/v8-snapshot/cache/darwin/snapshot-meta.json @@ -1052,6 +1052,8 @@ "./node_modules/@cypress/commit-info/src/index.js", "./node_modules/@cypress/commit-info/src/utils.js", "./node_modules/@cypress/get-windows-proxy/node_modules/debug/src/common.js", + "./node_modules/@cypress/get-windows-proxy/node_modules/registry-js/dist/lib/index.js", + "./node_modules/@cypress/get-windows-proxy/node_modules/registry-js/dist/lib/registry.js", "./node_modules/@cypress/get-windows-proxy/src/index.js", "./node_modules/@cypress/parse-domain/build/tries/icann.complete.json", "./node_modules/@cypress/parse-domain/build/tries/private.complete.json", @@ -3183,6 +3185,8 @@ "./node_modules/recast/parsers/babel.js", "./node_modules/recast/parsers/esprima.js", "./node_modules/recast/parsers/typescript.js", + "./node_modules/registry-js/dist/lib/index.js", + "./node_modules/registry-js/dist/lib/registry.js", "./node_modules/request-promise-core/configure/request2.js", "./node_modules/request-promise-core/errors.js", "./node_modules/request-promise-core/lib/errors.js", @@ -3854,6 +3858,8 @@ "./packages/server/lib/browsers/webkit.ts", "./packages/server/lib/cloud/api/axios_middleware/logging.ts", "./packages/server/lib/cloud/api/axios_middleware/transform_error.ts", + "./packages/server/lib/cloud/api/cy-prompt/get_cy_prompt_bundle.ts", + "./packages/server/lib/cloud/api/cy-prompt/post_cy_prompt_session.ts", "./packages/server/lib/cloud/api/scrub_url.ts", "./packages/server/lib/cloud/api/studio/get_studio_bundle.ts", "./packages/server/lib/cloud/api/studio/post_studio_session.ts", @@ -3863,6 +3869,9 @@ "./packages/server/lib/cloud/artifacts/print_protocol_upload_error.ts", "./packages/server/lib/cloud/artifacts/upload_artifacts.ts", "./packages/server/lib/cloud/constants.ts", + "./packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts", + "./packages/server/lib/cloud/cy-prompt/CyPromptManager.ts", + "./packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts", "./packages/server/lib/cloud/encryption.ts", "./packages/server/lib/cloud/environment.ts", "./packages/server/lib/cloud/get_cloud_metadata.ts", @@ -4167,6 +4176,8 @@ "./packages/types/src/cloud.ts", "./packages/types/src/config.ts", "./packages/types/src/constants.ts", + "./packages/types/src/cy-prompt/cy-prompt-server-types.ts", + "./packages/types/src/cy-prompt/index.ts", "./packages/types/src/driver.ts", "./packages/types/src/editors.ts", "./packages/types/src/git.ts", From 22737d2417a9a30fa1ad05066b0d997a84182c18 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 3 Jun 2025 16:15:55 -0500 Subject: [PATCH 07/14] chore: create infrastructure to support backend function in cy.prompt (#31803) * chore: add promptBackend as an additional Cypress-attached function * Update packages/app/src/runner/event-manager.ts * update types * fix types * fix spacing * refactor * additional refactor * fix type build * fix build * refactor * reword messages * fix * debugging * undo debugging * PR comment * fix tests * fix tests * fix tests * fix test --- cli/types/cypress.d.ts | 10 --- packages/app/src/runner/event-manager.ts | 16 +---- packages/driver/cypress.config.ts | 1 + .../driver/cypress/e2e/commands/prompt.cy.ts | 10 ++- .../cypress/e2e/e2e/origin/patches.cy.ts | 62 +++++++++---------- packages/driver/package.json | 1 + .../driver/src/cross-origin/communicator.ts | 2 +- packages/driver/src/cross-origin/cypress.ts | 9 ++- .../driver/src/cross-origin/events/socket.ts | 36 ++++++----- .../driver/src/cy/commands/prompt/index.ts | 58 +++++++++++------ .../cy/commands/prompt/prompt-driver-types.ts | 54 +++++++++++++++- packages/driver/src/cypress.ts | 35 ++++++++++- .../driver/types/internal-types-lite.d.ts | 2 + packages/driver/types/internal-types.d.ts | 1 + .../lib/cloud/cy-prompt/CyPromptManager.ts | 6 +- packages/server/lib/project-base.ts | 1 + packages/server/lib/socket-base.ts | 16 ++--- .../cloud/cy-prompt/test-cy-prompt.ts | 5 +- .../cloud/cy-prompt/CyPromptManager_spec.ts | 35 ++--------- packages/server/test/unit/socket_spec.js | 42 +++++-------- packages/types/src/config.ts | 2 +- .../src/cy-prompt/cy-prompt-server-types.ts | 7 +-- 22 files changed, 237 insertions(+), 174 deletions(-) diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 6611f3ec08ef..65f51f96722e 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -1826,11 +1826,6 @@ 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. * @@ -3163,11 +3158,6 @@ declare namespace Cypress { * @default false */ experimentalStudio: boolean - /** - * Enables the prompt command feature. - * @default false - */ - 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/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 109f9f4aae19..3cecac6f6f1d 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -798,21 +798,7 @@ export class EventManager { }, ) - /** - * Call a backend request for the requesting spec bridge since we cannot have websockets in the spec bridges. - * Return it's response. - */ - Cypress.primaryOriginCommunicator.on('backend:request', async ({ args }, { source, responseEvent }) => { - let response - - try { - response = await Cypress.backend(...args) - } catch (error) { - response = { error } - } - - Cypress.primaryOriginCommunicator.toSource(source, responseEvent, response) - }) + Cypress.handlePrimaryOriginSocketEvent(Cypress, 'backend:request') /** * Call an automation request for the requesting spec bridge since we cannot have websockets in the spec bridges. diff --git a/packages/driver/cypress.config.ts b/packages/driver/cypress.config.ts index d7df5cbee75b..f67b3467effa 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, + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag experimentalPromptCommand: true, hosts: { '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 78ba0620c4bc..7f5e1cc0af1e 100644 --- a/packages/driver/cypress/e2e/commands/prompt.cy.ts +++ b/packages/driver/cypress/e2e/commands/prompt.cy.ts @@ -7,10 +7,18 @@ describe('src/cy/commands/prompt', () => { return } - cy.visit('/fixtures/dom.html') + cy.visit('http://www.foobar.com:3500/fixtures/dom.html') // TODO: add more tests when cy.prompt is built out, but for now this just // verifies that the command executes without throwing an error + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag cy.prompt('Hello, world!') + + cy.visit('http://www.barbaz.com:3500/fixtures/dom.html') + + cy.origin('http://www.barbaz.com:3500', () => { + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag + cy.prompt('Hello, world!') + }) }) }) diff --git a/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts b/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts index 2bdc0fbdb6b3..f1d62636dc91 100644 --- a/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts @@ -63,7 +63,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout describe('from the AUT', () => { beforeEach(() => { cy.intercept('/test-request').as('testRequest') - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests"]').click() @@ -87,7 +87,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout cy.wait('@testRequest') cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request', resourceType: 'fetch', credentialStatus: assertCredentialStatus, @@ -107,7 +107,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout cy.wait('@testRequest') cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request', resourceType: 'fetch', credentialStatus: assertCredentialStatus, @@ -127,7 +127,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout cy.wait('@testRequest') cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request', resourceType: 'fetch', credentialStatus: assertCredentialStatus, @@ -152,7 +152,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout ) cy.then(() => { - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).not.to.have.been.calledWithMatch('backend:request', 'request:sent:with:credentials') }) }) @@ -171,7 +171,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout ) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://app.foobar.com:3500/test-request', resourceType: 'fetch', credentialStatus: 'include', @@ -184,7 +184,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout describe('from the spec bridge', () => { beforeEach(() => { cy.intercept('/test-request').as('testRequest') - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests"]').click() @@ -216,7 +216,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request-credentials', resourceType: 'fetch', credentialStatus: assertCredentialStatus, @@ -246,7 +246,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request-credentials', resourceType: 'fetch', credentialStatus: assertCredentialStatus, @@ -274,7 +274,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request-credentials', resourceType: 'fetch', credentialStatus: assertCredentialStatus, @@ -297,7 +297,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).not.to.have.been.calledWithMatch('backend:request', 'request:sent:with:credentials') }) }) }) @@ -326,7 +326,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://app.foobar.com:3500/test-request', resourceType: 'fetch', credentialStatus: 'include', @@ -339,13 +339,13 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout // manually remove the spec bridge iframe to ensure Cypress.state('window') is not already set window.top?.document.getElementById('Spec\ Bridge:\ foobar.com')?.remove() - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests-onload"]').click() cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://localhost:3500/foo.bar.baz.json', resourceType: 'fetch', credentialStatus: 'same-origin', @@ -354,7 +354,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) it('does not patch fetch in the spec window or the AUT if the AUT is on the primary', () => { - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('fixtures/xhr-fetch-requests.html') cy.window().then((win) => { @@ -371,14 +371,14 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout cy.then(async () => { await fetch('/test-request') - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).not.to.have.been.calledWithMatch('request:sent:with:credentials') }) // expect AUT to NOT be patched in primary cy.window().then(async (win) => { await win.fetch('/test-request') - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).not.to.have.been.calledWithMatch('request:sent:with:credentials') }) }) }) @@ -387,7 +387,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout describe('from the AUT', () => { beforeEach(() => { cy.intercept('/test-request').as('testRequest') - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests"]').click() @@ -409,7 +409,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout cy.wait('@testRequest') cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request', resourceType: 'xhr', credentialStatus: withCredentials, @@ -431,7 +431,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).to.have.been.calledWithMatch('backend:request', 'request:sent:with:credentials') }) }) }) @@ -449,7 +449,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://app.foobar.com:3500/test-request', resourceType: 'xhr', credentialStatus: true, @@ -461,9 +461,9 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout describe('from the spec bridge', () => { beforeEach(() => { cy.intercept('/test-request').as('testRequest') - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.origin('http://www.foobar.com:3500', () => { - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() }) cy.visit('/fixtures/primary-origin.html') @@ -501,7 +501,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request-credentials', resourceType: 'xhr', credentialStatus: withCredentials, @@ -523,7 +523,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).to.have.been.calledWithMatch('backend:request', 'request:sent:with:credentials') }) }) @@ -553,7 +553,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://app.foobar.com:3500/test-request', resourceType: 'xhr', credentialStatus: true, @@ -567,13 +567,13 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout // manually remove the spec bridge iframe to ensure Cypress.state('window') is not already set window.top?.document.getElementById('Spec\ Bridge:\ foobar.com')?.remove() - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests-onload"]').click() cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://localhost:3500/foo.bar.baz.json', resourceType: 'xhr', credentialStatus: false, @@ -582,7 +582,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) it('does not patch xmlHttpRequest in the spec window or the AUT if the AUT is on the primary', () => { - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('fixtures/xhr-fetch-requests.html') cy.window().then((win) => { @@ -614,7 +614,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout xhr.send() }) - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).not.to.have.been.calledWithMatch('backend:request', 'request:sent:with:credentials') }) // expect AUT to NOT be patched in primary @@ -636,7 +636,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout xhr.send() }) - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).not.to.have.been.calledWithMatch('backend:request', 'request:sent:with:credentials') }) }) }) diff --git a/packages/driver/package.json b/packages/driver/package.json index 74e6d84bb986..1f764d7701cd 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -53,6 +53,7 @@ "chai-subset": "1.6.0", "clone": "2.1.2", "common-tags": "1.8.0", + "component-emitter": "1.3.0", "compression": "1.8.0", "cookie-parser": "1.4.5", "core-js-pure": "3.21.0", diff --git a/packages/driver/src/cross-origin/communicator.ts b/packages/driver/src/cross-origin/communicator.ts index 550a6955f79c..32564b8c2234 100644 --- a/packages/driver/src/cross-origin/communicator.ts +++ b/packages/driver/src/cross-origin/communicator.ts @@ -336,7 +336,7 @@ export class SpecBridgeCommunicator extends EventEmitter { }: { event: string data?: Cypress.ObjectLike - options: {syncGlobals: boolean} + options?: {syncGlobals: boolean} timeout: number }) { return new Promise((resolve, reject) => { diff --git a/packages/driver/src/cross-origin/cypress.ts b/packages/driver/src/cross-origin/cypress.ts index c6af33aad03c..64a0aca56c9f 100644 --- a/packages/driver/src/cross-origin/cypress.ts +++ b/packages/driver/src/cross-origin/cypress.ts @@ -14,7 +14,7 @@ import { bindToListeners } from '../cy/listeners' import { handleOriginFn } from './origin_fn' import { FINAL_SNAPSHOT_NAME } from '../cy/snapshots' import { handleLogs } from './events/logs' -import { handleSocketEvents } from './events/socket' +import { handleCrossOriginSocketEvent, handleDefaultCrossOriginSocketEvents } from './events/socket' import { handleSpecWindowEvents } from './events/spec_window' import { handleErrorEvent } from './events/errors' import { handleScreenshots } from './events/screenshots' @@ -120,6 +120,12 @@ const setup = ({ cypressConfig, env, isProtocolEnabled }: { cypressConfig: Cypre const { state, config } = Cypress + // These need to happen before the commands are created so that commands can + // use things like Cypress.backend during their creation. + handleDefaultCrossOriginSocketEvents(Cypress) + + Cypress.handleCrossOriginSocketEvent = handleCrossOriginSocketEvent + // @ts-ignore Cypress.Commands = $Commands.create(Cypress, cy, state, config) // @ts-ignore @@ -141,7 +147,6 @@ const setup = ({ cypressConfig, env, isProtocolEnabled }: { cypressConfig: Cypre handleOriginFn(Cypress, cy) handleLogs(Cypress) - handleSocketEvents(Cypress) handleSpecWindowEvents(cy) handleMiscEvents(Cypress, cy) handleScreenshots(Cypress) diff --git a/packages/driver/src/cross-origin/events/socket.ts b/packages/driver/src/cross-origin/events/socket.ts index a159e1ea5554..3cc996a71e54 100644 --- a/packages/driver/src/cross-origin/events/socket.ts +++ b/packages/driver/src/cross-origin/events/socket.ts @@ -1,20 +1,24 @@ -export const handleSocketEvents = (Cypress) => { - const onRequest = async (event, args) => { - // The last argument is the callback, pop that off before messaging primary and call it with the response. - const callback = args.pop() - const response = await Cypress.specBridgeCommunicator.toPrimaryPromise({ - event, - data: { args }, - timeout: Cypress.config().defaultCommandTimeout, - }) +const onRequest = async (event, args) => { + // The last argument is the callback, pop that off before messaging primary and call it with the response. + const callback = args.pop() + const response = await Cypress.specBridgeCommunicator.toPrimaryPromise<{ error?: string, response?: any }>({ + event, + data: { args }, + timeout: Cypress.config().defaultCommandTimeout, + }) - if (response && response.error) { - return callback({ error: response.error }) - } - - callback({ response }) + if (response && response.error) { + return callback({ error: response.error }) } - Cypress.on('backend:request', (...args) => onRequest('backend:request', args)) - Cypress.on('automation:request', (...args) => onRequest('automation:request', args)) + callback({ response }) +} + +export const handleCrossOriginSocketEvent = (Cypress, eventName: string) => { + Cypress.on(eventName, (...args) => onRequest(eventName, args)) +} + +export const handleDefaultCrossOriginSocketEvents = (Cypress) => { + handleCrossOriginSocketEvent(Cypress, 'backend:request') + handleCrossOriginSocketEvent(Cypress, 'automation:request') } diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 96d00215a12e..65c52ec93acb 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -1,15 +1,24 @@ import { init, loadRemote } from '@module-federation/runtime' -import type{ CyPromptDriverDefaultShape } from './prompt-driver-types' +import type { CypressInternal, CyPromptDriverDefaultShape } from './prompt-driver-types' +import type Emitter from 'component-emitter' interface CyPromptDriver { default: CyPromptDriverDefaultShape } -let initializedCyPrompt: CyPromptDriverDefaultShape | null = null -const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise => { +declare global { + interface Window { + getEventManager?: () => { + ws: Emitter + } + } +} + +let initializedModule: CyPromptDriverDefaultShape | null = null +const initializeModule = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']): 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') + throw new Error('error waiting for cy prompt bundle to be downloaded and ready') } // Once the cy prompt bundle is downloaded and ready, @@ -31,35 +40,48 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise('cy-prompt') if (!module?.default) { - throw new Error('CyPromptDriver not found') + throw new Error('error loading cy prompt driver') } - initializedCyPrompt = module.default + initializedModule = module.default + + return initializedModule +} + +const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']) => { + let cloudModule = initializedModule - return module.default + if (!cloudModule) { + cloudModule = await initializeModule(Cypress, cy) + } + + return cloudModule.createCyPrompt({ + Cypress: Cypress as CypressInternal, + cy, + eventManager: window.getEventManager ? window.getEventManager() : undefined, + }) } export default (Commands, Cypress, cy) => { if (Cypress.config('experimentalPromptCommand')) { + let initializeCloudCyPromptPromise: Promise> | undefined + + if (Cypress.browser.family === 'chromium' || Cypress.browser.name === 'electron') { + initializeCloudCyPromptPromise = initializeCloudCyPrompt(Cypress, cy) + } + Commands.addAll({ - async prompt (message: string) { - if (Cypress.browser.family !== 'chromium' && Cypress.browser.name !== 'electron') { + async prompt (message: string, options: object = {}) { + if (!initializeCloudCyPromptPromise) { // TODO: (cy.prompt) We will look into supporting other browsers (and testing them) // as this is rolled out throw new Error('`cy.prompt()` is not supported in this browser.') } try { - let cloud = initializedCyPrompt - - // 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) - } + const cyPrompt = await initializeCloudCyPromptPromise - return await cloud.cyPrompt(Cypress, message) + return await cyPrompt(message, options) } catch (error) { // TODO: handle this better throw new Error(`CyPromptDriver not found: ${error}`) diff --git a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts index 3f742f4b9678..2b57f796ce6b 100644 --- a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts +++ b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts @@ -1,7 +1,55 @@ -export interface CypressInternal extends Cypress.Cypress { - backend: (eventName: string, ...args: any[]) => Promise +import type Emitter from 'component-emitter' + +interface InternalActions extends Cypress.Actions { + ( + eventName: 'prompt:backend:request', + listener: (...args: any[]) => void + ): Cypress.Cypress +} + +export interface CypressInternalBase extends Cypress.Cypress { + backendRequestHandler: ( + backendRequestNamespace: string, + eventName: string, + ...args: any[] + ) => Promise + on: InternalActions +} + +interface CrossOriginCypressInternal extends CypressInternalBase { + isCrossOriginSpecBridge: true + handleCrossOriginSocketEvent: ( + Cypress: CypressInternal, + eventName: string + ) => void +} + +interface SameOriginCypressInternal extends CypressInternalBase { + isCrossOriginSpecBridge: false + handlePrimaryOriginSocketEvent: ( + Cypress: CypressInternal, + eventName: string + ) => void +} + +export type CypressInternal = + | CrossOriginCypressInternal + | SameOriginCypressInternal + +export interface CyPromptEventManager { + ws: Emitter +} + +export interface CyPromptOptions { + Cypress: CypressInternal + cy: Cypress.cy + // Note that the eventManager is present in same origin AUTs, but not cross origin + // so we need to check for it's presence before using it + eventManager?: CyPromptEventManager } export interface CyPromptDriverDefaultShape { - cyPrompt: (Cypress: CypressInternal, text: string) => Promise + createCyPrompt: ( + options: CyPromptOptions + ) => (text: string, commandOptions?: object) => Promise } diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index c8657904fae7..dd7e47d887d9 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -88,6 +88,31 @@ interface AutomationError extends Error { // Are we running Cypress in Cypress? (Used for E2E Testing for Cypress in Cypress only) const isCypressInCypress = document.defaultView !== top +const handlePrimaryOriginSocketEvent = (Cypress, backendRequestNamespace: string) => { + Cypress.primaryOriginCommunicator.on( + backendRequestNamespace, + async ({ args: [eventName, ...args] }: { args: [string, any[]] }, { source, responseEvent }) => { + let response + + try { + response = await Cypress.backendRequestHandler( + backendRequestNamespace, + eventName, + ...args, + ) + } catch (error) { + response = { error } + } + + Cypress.primaryOriginCommunicator.toSource( + source, + responseEvent, + response, + ) + }, + ) +} + class $Cypress { cy: any chai: any @@ -161,6 +186,8 @@ class $Cypress { sinon = sinon lolex = fakeTimers + handlePrimaryOriginSocketEvent = handlePrimaryOriginSocketEvent + static $: any static utils: any @@ -764,7 +791,7 @@ class $Cypress { } } - backend (eventName, ...args) { + backendRequestHandler (backendRequestNamespace: string, eventName, ...args) { return new Promise((resolve, reject) => { const fn = function (reply) { const e = reply.error @@ -787,10 +814,14 @@ class $Cypress { return resolve(reply.response) } - return this.emit('backend:request', eventName, ...args, fn) + return this.emit(backendRequestNamespace, eventName, ...args, fn) }) } + backend (eventName, ...args) { + return this.backendRequestHandler('backend:request', eventName, ...args) + } + automation (eventName, ...args) { // wrap action in promise return new Promise((resolve, reject) => { diff --git a/packages/driver/types/internal-types-lite.d.ts b/packages/driver/types/internal-types-lite.d.ts index c41aef97cf6a..0c1a809653ba 100644 --- a/packages/driver/types/internal-types-lite.d.ts +++ b/packages/driver/types/internal-types-lite.d.ts @@ -11,7 +11,9 @@ declare namespace Cypress { primaryOriginCommunicator: import('eventemitter2').EventEmitter2 & { toSpecBridge: (origin: string, event: string, data?: any, responseEvent?: string) => void userInvocationStack?: string + toSource: (source: string, responseEvent: string, response: any) => void } + backendRequestHandler: (backendRequestNamespace: string, emitter: Emitter, eventName: string, ...args: any[]) => Promise } interface Actions { diff --git a/packages/driver/types/internal-types.d.ts b/packages/driver/types/internal-types.d.ts index bc632521e2b2..4794fca9cfbe 100644 --- a/packages/driver/types/internal-types.d.ts +++ b/packages/driver/types/internal-types.d.ts @@ -53,6 +53,7 @@ declare namespace Cypress { utils: CypressUtils events: Events specBridgeCommunicator: import('../src/cross-origin/communicator').SpecBridgeCommunicator + handleCrossOriginSocketEvent?: typeof handleCrossOriginSocketEvent mocha: $Mocha configure: (config: Cypress.ObjectLike) => void isCrossOriginSpecBridge: boolean diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index c3d08058b4d6..aa9bb17b0cf8 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -2,6 +2,7 @@ import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, import type { Router } from 'express' import Debug from 'debug' import { requireScript } from '../require_script' +import type { Socket } from 'socket.io' interface CyPromptServer { default: CyPromptServerDefaultShape } @@ -38,9 +39,9 @@ export class CyPromptManager implements CyPromptManagerShape { } } - async handleBackendRequest (eventName: string, ...args: any[]): Promise { + addSocketListeners (socket: Socket): void { if (this._cyPromptServer) { - return this.invokeAsync('handleBackendRequest', { isEssential: false }, eventName, ...args) + this.invokeSync('addSocketListeners', { isEssential: true }, socket) } } @@ -89,6 +90,7 @@ export class CyPromptManager implements CyPromptManagerShape { } try { + // @ts-expect-error - TS not associating the method & args properly, even though we know it's correct return await this._cyPromptServer[method].apply(this._cyPromptServer, args) } catch (error: unknown) { let actualError: Error diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 222201d23c59..29db121758a6 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -158,6 +158,7 @@ export class ProjectBase extends EE { process.chdir(this.projectRoot) this._server = new ServerBase(cfg) + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag if (cfg.experimentalPromptCommand) { const cyPromptLifecycleManager = new CyPromptLifecycleManager() diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index aa254b2e4285..810a6ab643b3 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, CyPromptManagerShape } from '@packages/types' +import type { RunState, CachedTestState, ProtocolManagerShape, AutomationCommands } from '@packages/types' import memory from './browsers/memory' import { privilegedCommandsManager } from './privileged-commands/privileged-commands-manager' @@ -412,6 +412,10 @@ export class SocketBase { studio.addSocketListeners(socket) }) + getCtx().coreData.cyPromptLifecycleManager?.registerCyPromptReadyListener((cyPrompt) => { + cyPrompt.addSocketListeners(socket) + }) + socket.on('studio:init', async (cb) => { try { const { canAccessStudioAI } = await options.onStudioInit() @@ -449,12 +453,6 @@ 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 @@ -464,10 +462,6 @@ 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] 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 index cde602ab31ff..0eea3553260d 100644 --- 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 @@ -2,14 +2,15 @@ import type { CyPromptServerShape, CyPromptServerDefaultShape, CyPromptCDPClient } from '@packages/types' import type { Router } from 'express' +import type { Socket } from 'socket.io' 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() + addSocketListeners (socket: Socket): void { + // This is a test implementation that does nothing } connectToBrowser (criClient: CyPromptCDPClient): void { diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts index bbf19ce91af1..a4009743292b 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts @@ -57,18 +57,6 @@ describe('lib/cloud/cy-prompt', () => { }) }) - 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) - - // TODO: (cy.prompt) test that the error is reported - }) - }) - describe('initializeRoutes', () => { it('initializes routes', () => { sinon.stub(cyPrompt, 'initializeRoutes') @@ -80,25 +68,14 @@ describe('lib/cloud/cy-prompt', () => { }) }) - describe('handleBackendRequest', () => { - it('calls handleBackendRequest on the cy prompt server', () => { - sinon.stub(cyPrompt, 'handleBackendRequest') + describe('addSocketListeners', () => { + it('adds socket listeners', () => { + sinon.stub(cyPrompt, 'addSocketListeners') + const mockSocket = sinon.stub() - cyPromptManager.handleBackendRequest('cy:prompt:start', {} as any) + cyPromptManager.addSocketListeners(mockSocket) - 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 + expect(cyPrompt.addSocketListeners).to.be.calledWith(mockSocket) }) }) diff --git a/packages/server/test/unit/socket_spec.js b/packages/server/test/unit/socket_spec.js index ec2d3878202b..4a1104cf7036 100644 --- a/packages/server/test/unit/socket_spec.js +++ b/packages/server/test/unit/socket_spec.js @@ -90,7 +90,7 @@ describe('lib/socket', () => { // Create a mock cy prompt object with handleBackendRequest method const mockCyPrompt = { - handleBackendRequest: sinon.stub().resolves({ foo: 'bar' }), + addSocketListeners: sinon.stub(), status: 'INITIALIZED', } @@ -580,30 +580,6 @@ describe('lib/socket', () => { }) }) - context('on(backend:request, cy:prompt)', () => { - it('calls handleBackendRequest with the correct arguments', async function () { - // 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] - - // Verify the mock cy prompt's handleBackendRequest was called by the callback - const mockCyPrompt = { handleBackendRequest: sinon.stub().resolves({ foo: 'bar' }) } - - await registerCyPromptReadyListenerCallback(mockCyPrompt) - - await new Promise((resolve) => { - 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') - - resolve() - }) - }) - }) - }) - context('on(save:app:state)', () => { it('calls onSavedStateChanged with the state', function (done) { return this.client.emit('save:app:state', { reporterWidth: 500 }, () => { @@ -687,6 +663,22 @@ describe('lib/socket', () => { }) }) + context('cy.prompt.addSocketListeners', () => { + it('calls addSocketListeners on cy prompt when socket connects', function () { + // Verify that registerCyPromptReadyListener was called + expect(ctx.coreData.cyPromptLifecycleManager.registerCyPromptReadyListener).to.be.called + + const registerCyPromptReadyListenerCallback = ctx.coreData.cyPromptLifecycleManager.registerCyPromptReadyListener.firstCall.args[0] + + expect(registerCyPromptReadyListenerCallback).to.be.a('function') + + const mockCyPrompt = { addSocketListeners: sinon.stub() } + + registerCyPromptReadyListenerCallback(mockCyPrompt) + expect(mockCyPrompt.addSocketListeners).to.be.called + }) + }) + context('#isRunnerSocketConnected', function () { it('returns false when runner is not connected', function () { expect(this.socket.isRunnerSocketConnected()).to.eq(false) diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index 84b11e45aed9..0c5018bbb3d5 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 index be0df63c42ad..b212f1910664 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -1,12 +1,9 @@ -// Note: This file is owned by the cloud delivered -// cy prompt bundle. It is downloaded and copied here. -// It should not be modified directly here. - /// import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.d' import type { Router } from 'express' import type { AxiosInstance } from 'axios' +import type { Socket } from 'socket.io' export type CyPromptCommands = ProtocolMapping.Commands @@ -56,7 +53,7 @@ export interface CyPromptCDPClient { export interface CyPromptServerShape { initializeRoutes(router: Router): void - handleBackendRequest: (eventName: string, ...args: any[]) => Promise + addSocketListeners(socket: Socket): void connectToBrowser: (cdpClient: CyPromptCDPClient) => void } From e18d31a5e87f110ef157153a43d5e5e486265366 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 3 Jun 2025 17:26:13 -0500 Subject: [PATCH 08/14] chore: add watcher for cy-prompt development (#31810) * chore: add watcher for cy-prompt development * test caching * fix types --- .../cy-prompt/CyPromptLifecycleManager.ts | 94 +++++++-- .../cy-prompt/ensure_cy_prompt_bundle.ts | 33 ++- .../CyPromptLifecycleManager_spec.ts | 195 +++++++++++++++++- .../cy-prompt/ensure_cy_prompt_bundle_spec.ts | 72 ++----- 4 files changed, 302 insertions(+), 92 deletions(-) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 931d709c1ed1..22ae9a6afab8 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -10,10 +10,14 @@ import path from 'path' import os from 'os' import { readFile } from 'fs-extra' import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle' +import chokidar from 'chokidar' +import { getCloudMetadata } from '../get_cloud_metadata' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') export class CyPromptLifecycleManager { + private static hashLoadingMap: Map> = new Map() + private static watcher: chokidar.FSWatcher | null = null private cyPromptManagerPromise?: Promise private cyPromptManager?: CyPromptManager private listeners: ((cyPromptManager: CyPromptManager) => void)[] = [] @@ -72,6 +76,11 @@ export class CyPromptLifecycleManager { }) this.cyPromptManagerPromise = cyPromptManagerPromise + + this.setupWatcher({ + projectId, + cloudDataSource, + }) } async getCyPrompt () { @@ -91,29 +100,42 @@ export class CyPromptLifecycleManager { projectId?: string cloudDataSource: CloudDataSource }): Promise { + let cyPromptHash: string + let cyPromptPath: string + const cyPromptSession = await postCyPromptSession({ projectId, }) - // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension - 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') + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension + cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] + cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) - await ensureCyPromptBundle({ - cyPromptUrl: cyPromptSession.cyPromptUrl, - projectId, - cyPromptPath, - bundlePath, - }) + let hashLoadingPromise = CyPromptLifecycleManager.hashLoadingMap.get(cyPromptHash) + + if (!hashLoadingPromise) { + hashLoadingPromise = ensureCyPromptBundle({ + cyPromptUrl: cyPromptSession.cyPromptUrl, + projectId, + cyPromptPath, + }) + + CyPromptLifecycleManager.hashLoadingMap.set(cyPromptHash, hashLoadingPromise) + } + + await hashLoadingPromise + } else { + cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH + cyPromptHash = 'local' + } + + const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') 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() + const { cloudUrl, cloudHeaders } = await getCloudMetadata(cloudDataSource) await cyPromptManager.setup({ script, @@ -148,7 +170,43 @@ export class CyPromptLifecycleManager { listener(cyPromptManager) }) - this.listeners = [] + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + this.listeners = [] + } + } + + private setupWatcher ({ + projectId, + cloudDataSource, + }: { + projectId?: string + cloudDataSource: CloudDataSource + }) { + // Don't setup a watcher if the cy prompt bundle is NOT local + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + return + } + + // Close the watcher if a previous watcher exists + if (CyPromptLifecycleManager.watcher) { + CyPromptLifecycleManager.watcher.removeAllListeners() + CyPromptLifecycleManager.watcher.close().catch(() => {}) + } + + // Watch for changes to the cy prompt bundle + CyPromptLifecycleManager.watcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server', 'index.js'), { + awaitWriteFinish: true, + }).on('change', async () => { + this.cyPromptManager = undefined + this.cyPromptManagerPromise = this.createCyPromptManager({ + projectId, + cloudDataSource, + }).catch((error) => { + debug('Error during reload of cy prompt manager: %o', error) + + return null + }) + }) } /** @@ -160,6 +218,12 @@ export class CyPromptLifecycleManager { if (this.cyPromptManager) { debug('cy prompt ready - calling listener immediately') listener(this.cyPromptManager) + + // If the cy prompt bundle is local, we need to register the listener + // so that we can reload the cy prompt when the bundle changes + if (process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + this.listeners.push(listener) + } } else { debug('cy prompt not ready - registering cy prompt ready listener') this.listeners.push(listener) diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index 5248c479e558..fadf2e62eff8 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -1,4 +1,4 @@ -import { copy, remove, ensureDir } from 'fs-extra' +import { remove, ensureDir } from 'fs-extra' import tar from 'tar' import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle' @@ -8,30 +8,23 @@ interface EnsureCyPromptBundleOptions { cyPromptPath: string cyPromptUrl: string projectId?: string - bundlePath: string } -export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId, bundlePath }: EnsureCyPromptBundleOptions) => { +export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId }: EnsureCyPromptBundleOptions) => { + const bundlePath = path.join(cyPromptPath, 'bundle.tar') + // First remove cyPromptPath to ensure we have a clean slate await remove(cyPromptPath) await ensureDir(cyPromptPath) - if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { - await getCyPromptBundle({ - cyPromptUrl, - projectId, - bundlePath, - }) - - await 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 getCyPromptBundle({ + cyPromptUrl, + projectId, + bundlePath, + }) - await copy(driverPath, path.join(cyPromptPath, 'driver')) - await copy(serverPath, path.join(cyPromptPath, 'server')) - } + await tar.extract({ + file: bundlePath, + cwd: cyPromptPath, + }) } 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 7f6d4df6e7e9..47fe38b6d30a 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -21,6 +21,9 @@ describe('CyPromptLifecycleManager', () => { let ensureCyPromptBundleStub: sinon.SinonStub let cyPromptManagerSetupStub: sinon.SinonStub = sinon.stub() let readFileStub: sinon.SinonStub = sinon.stub() + let watcherStub: sinon.SinonStub = sinon.stub() + let watcherOnStub: sinon.SinonStub = sinon.stub() + let watcherCloseStub: sinon.SinonStub = sinon.stub() beforeEach(() => { postCyPromptSessionStub = sinon.stub() @@ -50,6 +53,12 @@ describe('CyPromptLifecycleManager', () => { 'fs-extra': { readFile: readFileStub.resolves('console.log("cy-prompt script")'), }, + 'chokidar': { + watch: watcherStub.returns({ + on: watcherOnStub, + close: watcherCloseStub, + }), + }, }).CyPromptLifecycleManager cyPromptLifecycleManager = new CyPromptLifecycleManager() @@ -80,6 +89,8 @@ describe('CyPromptLifecycleManager', () => { afterEach(() => { sinon.restore() + + delete process.env.CYPRESS_LOCAL_CY_PROMPT_PATH }) describe('initializeCyPromptManager', () => { @@ -103,7 +114,6 @@ describe('CyPromptLifecycleManager', () => { 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({ @@ -128,6 +138,134 @@ describe('CyPromptLifecycleManager', () => { expect(mockCloudDataSource.additionalHeaders).to.be.called expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') }) + + it('only calls ensureCyPromptBundle once per cy prompt hash', async () => { + cyPromptLifecycleManager.initializeCyPromptManager({ + projectId: 'test-project-id', + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + }) + + const cyPromptReadyPromise1 = new Promise((resolve) => { + cyPromptLifecycleManager?.registerCyPromptReadyListener((cyPromptManager) => { + resolve(cyPromptManager) + }) + }) + + const cyPromptManager1 = await cyPromptReadyPromise1 + + cyPromptLifecycleManager.initializeCyPromptManager({ + projectId: 'test-project-id', + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + }) + + const cyPromptReadyPromise2 = new Promise((resolve) => { + cyPromptLifecycleManager?.registerCyPromptReadyListener((cyPromptManager) => { + resolve(cyPromptManager) + }) + }) + + const cyPromptManager2 = await cyPromptReadyPromise2 + + expect(cyPromptManager1).to.equal(cyPromptManager2) + + expect(ensureCyPromptBundleStub).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', + }) + + expect(cyPromptManagerSetupStub).to.be.calledOnce + 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') + }) + + it('initializes the cy-prompt manager in watch mode if CYPRESS_LOCAL_CY_PROMPT_PATH is set', async () => { + process.env.CYPRESS_LOCAL_CY_PROMPT_PATH = '/path/to/cy-prompt' + + cyPromptLifecycleManager.initializeCyPromptManager({ + projectId: 'test-project-id', + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + }) + + const cyPromptReadyPromise = new Promise((resolve) => { + cyPromptLifecycleManager?.registerCyPromptReadyListener((cyPromptManager) => { + resolve(cyPromptManager) + }) + }) + + await cyPromptReadyPromise + + expect(mockCtx.update).to.be.calledOnce + expect(ensureCyPromptBundleStub).to.not.be.called + + expect(cyPromptManagerSetupStub).to.be.calledWith({ + script: 'console.log("cy-prompt script")', + cyPromptPath: '/path/to/cy-prompt', + cyPromptHash: 'local', + 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(readFileStub).to.be.calledWith(path.join('/path', 'to', 'cy-prompt', 'server', 'index.js'), 'utf8') + + expect(CyPromptLifecycleManager['watcher']).to.be.present + expect(watcherStub).to.be.calledWith(path.join('/path', 'to', 'cy-prompt', 'server', 'index.js'), { + awaitWriteFinish: true, + }) + + expect(watcherOnStub).to.be.calledWith('change') + + const onCallback = watcherOnStub.args[0][1] + + let mockCyPromptManagerPromise: Promise + const updatedCyPromptManager = {} as unknown as CyPromptManager + + cyPromptLifecycleManager['createCyPromptManager'] = sinon.stub().callsFake(() => { + mockCyPromptManagerPromise = new Promise((resolve) => { + resolve(updatedCyPromptManager) + }) + + return mockCyPromptManagerPromise + }) + + onCallback() + + expect(mockCyPromptManagerPromise).to.be.present + expect(await mockCyPromptManagerPromise).to.equal(updatedCyPromptManager) + }) }) describe('getCyPrompt', () => { @@ -174,6 +312,25 @@ describe('CyPromptLifecycleManager', () => { expect(listener).to.be.calledWith(mockCyPromptManager) }) + it('calls listener immediately and adds to the list of listeners when CYPRESS_LOCAL_CY_PROMPT_PATH is set', async () => { + process.env.CYPRESS_LOCAL_CY_PROMPT_PATH = '/path/to/cy-prompt' + + 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) + + // @ts-expect-error - accessing private property + expect(cyPromptLifecycleManager.listeners).to.include(listener) + }) + it('does not call listener if cy-prompt manager is null', async () => { const listener = sinon.stub() @@ -234,5 +391,41 @@ describe('CyPromptLifecycleManager', () => { // @ts-expect-error - accessing private property expect(cyPromptLifecycleManager.listeners.length).to.equal(0) }) + + it('does not clean up listeners when CYPRESS_LOCAL_CY_PROMPT_PATH is set', async () => { + process.env.CYPRESS_LOCAL_CY_PROMPT_PATH = '/path/to/cy-prompt' + + 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, + 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(2) + }) }) }) 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 index fe1eec6fb3cb..b40638f52a13 100644 --- 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 @@ -7,16 +7,12 @@ describe('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() @@ -28,8 +24,6 @@ describe('ensureCyPromptBundle', () => { 'fs-extra': { remove: rmStub.resolves(), ensureDir: ensureStub.resolves(), - copy: copyStub.resolves(), - readFile: readFileStub.resolves('console.log("cy-prompt script")'), }, tar: { extract: extractStub.resolves(), @@ -40,61 +34,27 @@ describe('ensureCyPromptBundle', () => { })).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, - }) + 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') - 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, - }) + await ensureCyPromptBundle({ + cyPromptPath, + cyPromptUrl: 'https://cypress.io/cy-prompt', + projectId: '123', }) - }) - describe('CYPRESS_LOCAL_CY_PROMPT_PATH set', () => { - beforeEach(() => { - process.env.CYPRESS_LOCAL_CY_PROMPT_PATH = '/path/to/cy-prompt' + 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, }) - 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')) + expect(extractStub).to.be.calledWith({ + file: bundlePath, + cwd: cyPromptPath, }) }) }) From 832867d0a9a1bf4d698eafbd479ab155f66c6468 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 4 Jun 2025 18:12:12 -0500 Subject: [PATCH 09/14] chore: turn on beta deployments for cy-prompt --- .circleci/workflows.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.circleci/workflows.yml b/.circleci/workflows.yml index aad4c1485403..63224c7e9fd2 100644 --- a/.circleci/workflows.yml +++ b/.circleci/workflows.yml @@ -39,6 +39,7 @@ mainBuildFilters: &mainBuildFilters # use the following branch as well to ensure that v8 snapshot cache updates are fully tested - 'update-v8-snapshot-cache-on-develop' - 'update-chrome-stable-from-136.0.7103.113-beta-from-137.0.7151.40' + - 'feat/cy-prompt' # usually we don't build Mac app - it takes a long time # but sometimes we want to really confirm we are doing the right thing @@ -49,6 +50,7 @@ macWorkflowFilters: &darwin-workflow-filters - equal: [ develop, << pipeline.git.branch >> ] # use the following branch as well to ensure that v8 snapshot cache updates are fully tested - equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ] + - equal: [ 'feat/cy-prompt', << pipeline.git.branch >> ] - equal: [ 'update-chrome-stable-from-136.0.7103.113-beta-from-137.0.7151.40', @@ -64,6 +66,7 @@ linuxArm64WorkflowFilters: &linux-arm64-workflow-filters - equal: [ develop, << pipeline.git.branch >> ] # use the following branch as well to ensure that v8 snapshot cache updates are fully tested - equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ] + - equal: [ 'feat/cy-prompt', << pipeline.git.branch >> ] - equal: [ 'update-chrome-stable-from-136.0.7103.113-beta-from-137.0.7151.40', @@ -91,6 +94,7 @@ windowsWorkflowFilters: &windows-workflow-filters - equal: [ develop, << pipeline.git.branch >> ] # use the following branch as well to ensure that v8 snapshot cache updates are fully tested - equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ] + - equal: [ 'feat/cy-prompt', << pipeline.git.branch >> ] - equal: [ 'update-chrome-stable-from-136.0.7103.113-beta-from-137.0.7151.40', @@ -169,7 +173,7 @@ commands: name: Set environment variable to determine whether or not to persist artifacts command: | echo "Setting SHOULD_PERSIST_ARTIFACTS variable" - echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "update-chrome-stable-from-136.0.7103.113-beta-from-137.0.7151.40" ]]; then + echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "update-chrome-stable-from-136.0.7103.113-beta-from-137.0.7151.40" && "$CIRCLE_BRANCH" != "feat/cy-prompt" ]]; then export SHOULD_PERSIST_ARTIFACTS=true fi' >> "$BASH_ENV" # You must run `setup_should_persist_artifacts` command and be using bash before running this command From 58e32342503811d82e8a4234dc3a4d89089dec33 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 6 Jun 2025 08:58:03 -0500 Subject: [PATCH 10/14] internal: (cy.prompt) handle errors better in the command definition (#31835) * internal: (cy.prompt) handle errors better in the command definition * internal: (cy.prompt) add timeout and handle loading errors more cleanly * add process environment variable * clean up test * update JSDoc --- guides/cy-prompt-development.md | 11 ++- .../prompt/prompt-initialization-error.cy.ts | 20 ++++++ .../e2e/commands/{ => prompt}/prompt.cy.ts | 0 .../driver/src/cy/commands/prompt/index.ts | 71 ++++++++++++------- .../cy-prompt/ensure_cy_prompt_bundle.ts | 32 +++++++-- packages/server/lib/project-base.ts | 2 +- .../cy-prompt/ensure_cy_prompt_bundle_spec.ts | 19 +++++ packages/server/test/unit/project_spec.js | 32 +++++++-- 8 files changed, 149 insertions(+), 38 deletions(-) create mode 100644 packages/driver/cypress/e2e/commands/prompt/prompt-initialization-error.cy.ts rename packages/driver/cypress/e2e/commands/{ => prompt}/prompt.cy.ts (100%) diff --git a/guides/cy-prompt-development.md b/guides/cy-prompt-development.md index 0613ba08090e..390e21475061 100644 --- a/guides/cy-prompt-development.md +++ b/guides/cy-prompt-development.md @@ -1,6 +1,6 @@ # `cy.prompt` Development -In production, the code used to facilitate the prompt command will be retrieved from the Cloud. +In production, the code used to facilitate the prompt command will be retrieved from the Cloud. While `cy.prompt` is still in its early stages it is hidden behind an environment variable: `CYPRESS_ENABLE_CY_PROMPT` but can also be run against local cloud Studio code via the environment variable: `CYPRESS_LOCAL_CY_PROMPT_PATH`. To run against locally developed `cy.prompt`: @@ -10,6 +10,15 @@ To run against locally developed `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 + +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`) + - `CYPRESS_ENABLE_CY_PROMPT=true` + +Regardless of running against local or deployed `cy.prompt`: + - Clone the `cypress` repo - Run `yarn` - Run `yarn cypress:open` diff --git a/packages/driver/cypress/e2e/commands/prompt/prompt-initialization-error.cy.ts b/packages/driver/cypress/e2e/commands/prompt/prompt-initialization-error.cy.ts new file mode 100644 index 000000000000..8e9144bff29a --- /dev/null +++ b/packages/driver/cypress/e2e/commands/prompt/prompt-initialization-error.cy.ts @@ -0,0 +1,20 @@ +describe('src/cy/commands/prompt', () => { + it('errors if wait for ready does not return success', (done) => { + const backendStub = cy.stub(Cypress, 'backend').log(false) + + backendStub.callThrough() + backendStub.withArgs('wait:for:cy:prompt:ready').resolves({ success: false }) + + cy.on('fail', (err) => { + expect(err.message).to.include('error waiting for cy prompt bundle to be downloaded and ready') + + done() + }) + + cy.visit('http://www.foobar.com:3500/fixtures/dom.html') + + cy['commandFns']['prompt'].__reset() + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag + cy.prompt('Hello, world!') + }) +}) diff --git a/packages/driver/cypress/e2e/commands/prompt.cy.ts b/packages/driver/cypress/e2e/commands/prompt/prompt.cy.ts similarity index 100% rename from packages/driver/cypress/e2e/commands/prompt.cy.ts rename to packages/driver/cypress/e2e/commands/prompt/prompt.cy.ts diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 65c52ec93acb..b64245a4751b 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -13,7 +13,7 @@ declare global { } let initializedModule: CyPromptDriverDefaultShape | null = null -const initializeModule = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']): Promise => { +const initializeModule = 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') @@ -48,45 +48,64 @@ const initializeModule = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['c return initializedModule } -const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']) => { - let cloudModule = initializedModule +const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']): Promise | Error> => { + try { + let cloudModule = initializedModule - if (!cloudModule) { - cloudModule = await initializeModule(Cypress, cy) - } + if (!cloudModule) { + cloudModule = await initializeModule(Cypress) + } - return cloudModule.createCyPrompt({ - Cypress: Cypress as CypressInternal, - cy, - eventManager: window.getEventManager ? window.getEventManager() : undefined, - }) + return await cloudModule.createCyPrompt({ + Cypress: Cypress as CypressInternal, + cy, + eventManager: window.getEventManager ? window.getEventManager() : undefined, + }) + } catch (error) { + return error + } } export default (Commands, Cypress, cy) => { if (Cypress.config('experimentalPromptCommand')) { - let initializeCloudCyPromptPromise: Promise> | undefined + let initializeCloudCyPromptPromise: Promise | Error> | undefined if (Cypress.browser.family === 'chromium' || Cypress.browser.name === 'electron') { initializeCloudCyPromptPromise = initializeCloudCyPrompt(Cypress, cy) } - Commands.addAll({ - async prompt (message: string, options: object = {}) { - if (!initializeCloudCyPromptPromise) { - // TODO: (cy.prompt) We will look into supporting other browsers (and testing them) - // as this is rolled out - throw new Error('`cy.prompt()` is not supported in this browser.') - } + const prompt = async (message: string, options: object = {}) => { + if (!initializeCloudCyPromptPromise) { + // TODO: (cy.prompt) We will look into supporting other browsers (and testing them) + // as this is rolled out + throw new Error('`cy.prompt()` is not supported in this browser.') + } - try { - const cyPrompt = await initializeCloudCyPromptPromise + try { + const bundleResult = await initializeCloudCyPromptPromise - return await cyPrompt(message, options) - } catch (error) { - // TODO: handle this better - throw new Error(`CyPromptDriver not found: ${error}`) + if (bundleResult instanceof Error) { + throw bundleResult } - }, + + const cyPrompt = bundleResult + + return await cyPrompt(message, options) + } catch (error) { + // TODO: handle this better + throw new Error(`CyPromptDriver not found: ${error}`) + } + } + + // For testing purposes, we can reset the prompt command initialization + // by calling the __reset method. + prompt.__reset = () => { + initializedModule = null + initializeCloudCyPromptPromise = initializeCloudCyPrompt(Cypress, cy) + } + + Commands.addAll({ + prompt, }) } } diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index fadf2e62eff8..8691fbea863d 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -4,23 +4,45 @@ import tar from 'tar' import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle' import path from 'path' +const DOWNLOAD_TIMEOUT = 30000 + interface EnsureCyPromptBundleOptions { cyPromptPath: string cyPromptUrl: string projectId?: string + downloadTimeoutMs?: number } -export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId }: EnsureCyPromptBundleOptions) => { +/** + * Ensures that the cy prompt bundle is downloaded and extracted into the given path + * @param options - The options for the ensure cy prompt bundle operation + * @param options.cyPromptPath - The path to extract the cy prompt bundle to + * @param options.cyPromptUrl - The URL of the cy prompt bundle + * @param options.projectId - The project ID of the cy prompt bundle + * @param options.downloadTimeoutMs - The timeout for the cy prompt bundle download + */ +export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId, downloadTimeoutMs = DOWNLOAD_TIMEOUT }: EnsureCyPromptBundleOptions) => { const bundlePath = path.join(cyPromptPath, 'bundle.tar') // First remove cyPromptPath to ensure we have a clean slate await remove(cyPromptPath) await ensureDir(cyPromptPath) - await getCyPromptBundle({ - cyPromptUrl, - projectId, - bundlePath, + let timeoutId: NodeJS.Timeout + + await Promise.race([ + getCyPromptBundle({ + cyPromptUrl, + projectId, + bundlePath, + }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error('Cy prompt bundle download timed out')) + }, downloadTimeoutMs) + }), + ]).finally(() => { + clearTimeout(timeoutId) }) await tar.extract({ diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 29db121758a6..3f9a4872c540 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -159,7 +159,7 @@ export class ProjectBase extends EE { this._server = new ServerBase(cfg) // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag - if (cfg.experimentalPromptCommand) { + if (process.env.CYPRESS_ENABLE_CY_PROMPT || cfg.experimentalPromptCommand) { const cyPromptLifecycleManager = new CyPromptLifecycleManager() cyPromptLifecycleManager.initializeCyPromptManager({ 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 index b40638f52a13..cba1100c9dfa 100644 --- 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 @@ -57,4 +57,23 @@ describe('ensureCyPromptBundle', () => { cwd: cyPromptPath, }) }) + + it('should throw an error if the cy prompt bundle download times out', async () => { + getCyPromptBundleStub.callsFake(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(new Error('Cy prompt bundle download timed out')) + }, 3000) + }) + }) + + const ensureCyPromptBundlePromise = ensureCyPromptBundle({ + cyPromptPath: '/tmp/cypress/cy-prompt/123', + cyPromptUrl: 'https://cypress.io/cy-prompt', + projectId: '123', + downloadTimeoutMs: 500, + }) + + await expect(ensureCyPromptBundlePromise).to.be.rejectedWith('Cy prompt bundle download timed out') + }) }) diff --git a/packages/server/test/unit/project_spec.js b/packages/server/test/unit/project_spec.js index 8f3477b3365d..2f4a1abe3799 100644 --- a/packages/server/test/unit/project_spec.js +++ b/packages/server/test/unit/project_spec.js @@ -450,15 +450,37 @@ 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 () { + let initializeCyPromptManagerStub + + afterEach(function () { + delete process.env.CYPRESS_ENABLE_CY_PROMPT + initializeCyPromptManagerStub.restore() + }) + + it('initializes cy prompt lifecycle manager if experimentalPromptCommand is enabled', function () { this.config.projectId = 'abc123' this.config.experimentalPromptCommand = true - sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager') + initializeCyPromptManagerStub = sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager') + + return this.project.open() + .then(() => { + expect(initializeCyPromptManagerStub).to.be.calledWith({ + projectId: 'abc123', + cloudDataSource: ctx.cloud, + ctx, + }) + }) + }) + + it('initializes cy prompt lifecycle manager if process.env.CYPRESS_ENABLE_CY_PROMPT is enabled', function () { + process.env.CYPRESS_ENABLE_CY_PROMPT = 'true' + + initializeCyPromptManagerStub = sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager') return this.project.open() .then(() => { - expect(CyPromptLifecycleManager.prototype.initializeCyPromptManager).to.be.calledWith({ + expect(initializeCyPromptManagerStub).to.be.calledWith({ projectId: 'abc123', cloudDataSource: ctx.cloud, ctx, @@ -470,11 +492,11 @@ This option will not have an effect in Some-other-name. Tests that rely on web s this.config.projectId = 'abc123' this.config.experimentalPromptCommand = false - sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager') + initializeCyPromptManagerStub = sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager') return this.project.open() .then(() => { - expect(CyPromptLifecycleManager.prototype.initializeCyPromptManager).not.to.be.called + expect(initializeCyPromptManagerStub).not.to.be.called }) }) }) From 30e48d172b23ed67c127c593928fc74b26576536 Mon Sep 17 00:00:00 2001 From: Alejandro Estrada Date: Tue, 10 Jun 2025 13:47:17 -0500 Subject: [PATCH 11/14] chore: handle errors (#31854) * chore: handle errors * Fix ts, add test * Fix error title * Fix ts * Fix ts --- packages/driver/cypress.config.ts | 2 +- packages/driver/cypress/component/spec.cy.ts | 13 +++++++ .../prompt/prompt-initialization-error.cy.ts | 35 +++++++++++++++++-- .../cypress/e2e/commands/prompt/prompt.cy.ts | 18 ++++++++++ .../driver/src/cy/commands/prompt/index.ts | 35 +++++++++++++++++-- packages/driver/src/cypress/error_messages.ts | 35 +++++++++++++++++++ .../driver/types/internal-types-lite.d.ts | 2 +- .../cy-prompt/CyPromptLifecycleManager.ts | 16 ++++++--- packages/server/lib/socket-base.ts | 7 ++-- packages/server/test/unit/socket_spec.js | 29 +++++++++++++-- packages/types/src/cy-prompt/index.ts | 5 ++- 11 files changed, 178 insertions(+), 19 deletions(-) diff --git a/packages/driver/cypress.config.ts b/packages/driver/cypress.config.ts index f67b3467effa..09165fa6b26c 100644 --- a/packages/driver/cypress.config.ts +++ b/packages/driver/cypress.config.ts @@ -40,7 +40,7 @@ export const baseConfig: Cypress.ConfigOptions = { }, component: { experimentalSingleTabRunMode: true, - specPattern: 'cypress/component/**/*.cy.js', + specPattern: 'cypress/component/**/*.cy.{js,ts}', supportFile: false, devServer: (devServerOptions) => { return cypressWebpackDevServer({ diff --git a/packages/driver/cypress/component/spec.cy.ts b/packages/driver/cypress/component/spec.cy.ts index 2c34b76336cc..d3997f4ef9bc 100644 --- a/packages/driver/cypress/component/spec.cy.ts +++ b/packages/driver/cypress/component/spec.cy.ts @@ -54,4 +54,17 @@ describe('component testing', () => { expect(Cypress.log).to.be.calledWithMatch(sinon.match({ 'message': `Error: "Promise rejected with a string!"`, name: 'uncaught exception' })) }) }) + + it('fails when trying to use cy.prompt in component tests', (done) => { + cy.spy(Cypress, 'log').log(false) + + cy.on('fail', (err) => { + expect(err.message).to.include('`cy.prompt` is currently only supported in end-to-end tests.') + + done() + }) + + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag + cy.prompt('Hello, world!') + }) }) diff --git a/packages/driver/cypress/e2e/commands/prompt/prompt-initialization-error.cy.ts b/packages/driver/cypress/e2e/commands/prompt/prompt-initialization-error.cy.ts index 8e9144bff29a..31a4a0d22c14 100644 --- a/packages/driver/cypress/e2e/commands/prompt/prompt-initialization-error.cy.ts +++ b/packages/driver/cypress/e2e/commands/prompt/prompt-initialization-error.cy.ts @@ -1,12 +1,41 @@ describe('src/cy/commands/prompt', () => { - it('errors if wait for ready does not return success', (done) => { + it('errors if wait for ready does not return success and error is ENOSPC', (done) => { const backendStub = cy.stub(Cypress, 'backend').log(false) + const error = new Error(`no space left on device, open 'bundle.tar`) + + error.name = 'ENOSPC' + + backendStub.callThrough() + backendStub.withArgs('wait:for:cy:prompt:ready').resolves({ success: false, error }) + + cy.on('fail', (err) => { + expect(err.message).to.include('Failed to download cy.prompt Cloud code') + expect(err.message).to.include(`no space left on device, open 'bundle.tar`) + + done() + }) + + cy.visit('http://www.foobar.com:3500/fixtures/dom.html') + + cy['commandFns']['prompt'].__reset() + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag + cy.prompt('Hello, world!') + }) + + it('errors if wait for ready does not return success and error is ECONNREFUSED', (done) => { + const backendStub = cy.stub(Cypress, 'backend').log(false) + + const error = new Error(`'bundle.tar' timed out after 10000s`) + + error.name = 'ECONNREFUSED' + backendStub.callThrough() - backendStub.withArgs('wait:for:cy:prompt:ready').resolves({ success: false }) + backendStub.withArgs('wait:for:cy:prompt:ready').resolves({ success: false, error }) cy.on('fail', (err) => { - expect(err.message).to.include('error waiting for cy prompt bundle to be downloaded and ready') + expect(err.message).to.include('Timed out waiting for cy.prompt Cloud code:') + expect(err.message).to.include(`'bundle.tar' timed out after 10000s`) done() }) diff --git a/packages/driver/cypress/e2e/commands/prompt/prompt.cy.ts b/packages/driver/cypress/e2e/commands/prompt/prompt.cy.ts index 7f5e1cc0af1e..83ef624492ab 100644 --- a/packages/driver/cypress/e2e/commands/prompt/prompt.cy.ts +++ b/packages/driver/cypress/e2e/commands/prompt/prompt.cy.ts @@ -21,4 +21,22 @@ describe('src/cy/commands/prompt', () => { cy.prompt('Hello, world!') }) }) + + it('fails when trying to use cy.prompt in a browser that is not supported', (done) => { + if (Cypress.isBrowser({ family: 'chromium' })) { + done() + + return + } + + cy.on('fail', (err) => { + expect(err.message).to.include('`cy.prompt` is only supported in Chromium-based browsers.') + + done() + }) + + cy.visit('http://www.foobar.com:3500/fixtures/dom.html') + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag + cy.prompt('Hello, world!') + }) }) diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index b64245a4751b..3fe42b5213e3 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -1,6 +1,7 @@ import { init, loadRemote } from '@module-federation/runtime' import type { CypressInternal, CyPromptDriverDefaultShape } from './prompt-driver-types' import type Emitter from 'component-emitter' +import $errUtils from '../../../cypress/error_utils' interface CyPromptDriver { default: CyPromptDriverDefaultShape } @@ -15,9 +16,26 @@ declare global { let initializedModule: CyPromptDriverDefaultShape | null = null const initializeModule = 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') + const { success, error } = await Cypress.backend('wait:for:cy:prompt:ready') + + if (error) { + if (error.name === 'ENOSPC') { + $errUtils.throwErrByPath('prompt.promptDownloadError', { + args: { + error, + }, + }) + } else { + $errUtils.throwErrByPath('prompt.promptDownloadTimedOut', { + args: { + error, + }, + }) + } + } - if (!success) { + if (!success && !error) { + // TODO: Generic error message throw new Error('error waiting for cy prompt bundle to be downloaded and ready') } @@ -40,6 +58,7 @@ const initializeModule = async (Cypress: Cypress.Cypress): Promise('cy-prompt') if (!module?.default) { + // TODO: Generic error message throw new Error('error loading cy prompt driver') } @@ -75,10 +94,18 @@ export default (Commands, Cypress, cy) => { } const prompt = async (message: string, options: object = {}) => { + if (Cypress.testingType === 'component') { + $errUtils.throwErrByPath('prompt.promptTestingTypeError') + + return + } + if (!initializeCloudCyPromptPromise) { // TODO: (cy.prompt) We will look into supporting other browsers (and testing them) // as this is rolled out - throw new Error('`cy.prompt()` is not supported in this browser.') + $errUtils.throwErrByPath('prompt.promptSupportedBrowser') + + return } try { @@ -92,6 +119,8 @@ export default (Commands, Cypress, cy) => { return await cyPrompt(message, options) } catch (error) { + // TODO: Check error that the user is logged in / record key + // TODO: handle this better throw new Error(`CyPromptDriver not found: ${error}`) } diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index a82d7a419084..9287733b8cd7 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -1322,6 +1322,41 @@ export default { `, }, + prompt: { + promptDownloadError (obj) { + return { + message: stripIndent`\ + Failed to download cy.prompt Cloud code: + + - ${obj.error.code}: ${obj.error.message} + + Check your network connection and file settings to ensure download is not interrupted. + `, + docsUrl: 'https://on.cypress.io/prompt-download-error', + } + }, + promptDownloadTimedOut (obj) { + return { + message: stripIndent`\ + Timed out waiting for cy.prompt Cloud code: + + - ${obj.error.code}: ${obj.error.message} + + Check your network connection and system configuration. + `, + docsUrl: 'https://on.cypress.io/prompt-download-error', + } + }, + promptSupportedBrowser: stripIndent`\ + \`cy.prompt\` is only supported in Chromium-based browsers. + + Use Chrome, Electron, Chromium, or Chrome for Testing. + `, + promptTestingTypeError: stripIndent`\ + \`cy.prompt\` is currently only supported in end-to-end tests. + `, + }, + proxy: { js_rewriting_failed: stripIndent`\ An error occurred in the Cypress proxy layer while rewriting your source code. This is a bug in Cypress. Open an issue if you see this message. diff --git a/packages/driver/types/internal-types-lite.d.ts b/packages/driver/types/internal-types-lite.d.ts index 0c1a809653ba..fdc13e96cb77 100644 --- a/packages/driver/types/internal-types-lite.d.ts +++ b/packages/driver/types/internal-types-lite.d.ts @@ -42,7 +42,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 }> + (task: 'wait:for:cy:prompt:ready'): Promise<{ success: boolean, error?: Error }> } interface Devices { diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 22ae9a6afab8..6aad10dc4f27 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -18,7 +18,10 @@ const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') export class CyPromptLifecycleManager { private static hashLoadingMap: Map> = new Map() private static watcher: chokidar.FSWatcher | null = null - private cyPromptManagerPromise?: Promise + private cyPromptManagerPromise?: Promise<{ + cyPromptManager?: CyPromptManager + error?: Error + }> private cyPromptManager?: CyPromptManager private listeners: ((cyPromptManager: CyPromptManager) => void)[] = [] @@ -72,7 +75,7 @@ export class CyPromptLifecycleManager { // Clean up any registered listeners this.listeners = [] - return null + return { error } }) this.cyPromptManagerPromise = cyPromptManagerPromise @@ -99,7 +102,7 @@ export class CyPromptLifecycleManager { }: { projectId?: string cloudDataSource: CloudDataSource - }): Promise { + }): Promise<{ cyPromptManager?: CyPromptManager, error?: Error }> { let cyPromptHash: string let cyPromptPath: string @@ -155,7 +158,7 @@ export class CyPromptLifecycleManager { this.cyPromptManager = cyPromptManager this.callRegisteredListeners() - return cyPromptManager + return { cyPromptManager } } private callRegisteredListeners () { @@ -204,7 +207,10 @@ export class CyPromptLifecycleManager { }).catch((error) => { debug('Error during reload of cy prompt manager: %o', error) - return null + return { + cyPromptManager: undefined, + error: new Error('Error during reload of cy prompt manager'), + } }) }) } diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 810a6ab643b3..1f466c9b0618 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -541,10 +541,13 @@ export class SocketBase { return options.closeExtraTargets() case 'wait:for:cy:prompt:ready': return getCtx().coreData.cyPromptLifecycleManager?.getCyPrompt().then(async (cyPrompt) => { - await options.onCyPromptReady(cyPrompt) + if (cyPrompt.cyPromptManager) { + await options.onCyPromptReady(cyPrompt.cyPromptManager) + } return { - success: cyPrompt && cyPrompt.status === 'INITIALIZED', + success: cyPrompt.cyPromptManager && cyPrompt.cyPromptManager.status === 'INITIALIZED', + error: cyPrompt.error ? errors.cloneErr(cyPrompt.error) : undefined, } }) default: diff --git a/packages/server/test/unit/socket_spec.js b/packages/server/test/unit/socket_spec.js index 4a1104cf7036..8bf47743746e 100644 --- a/packages/server/test/unit/socket_spec.js +++ b/packages/server/test/unit/socket_spec.js @@ -551,7 +551,10 @@ 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', + cyPromptManager: { + status: 'INITIALIZED', + }, + error: undefined, } ctx.coreData.cyPromptLifecycleManager.getCyPrompt.resolves(mockCyPrompt) @@ -559,7 +562,7 @@ describe('lib/socket', () => { return this.client.emit('backend:request', 'wait:for:cy:prompt:ready', (resp) => { expect(resp.response).to.deep.eq({ success: true }) - expect(this.options.onCyPromptReady).to.be.calledWith(mockCyPrompt) + expect(this.options.onCyPromptReady).to.be.calledWith(mockCyPrompt.cyPromptManager) return done() }) @@ -567,7 +570,10 @@ describe('lib/socket', () => { it('awaits cy prompt ready and returns false if cy prompt is not ready', function (done) { const mockCyPrompt = { - status: 'NOT_INITIALIZED', + cyPromptManager: { + status: 'NOT_INITIALIZED', + }, + error: undefined, } ctx.coreData.cyPromptLifecycleManager.getCyPrompt.resolves(mockCyPrompt) @@ -578,6 +584,23 @@ describe('lib/socket', () => { return done() }) }) + + it('awaits cy prompt ready and returns error if cy prompt error is thrown', function (done) { + const mockCyPrompt = { + cyPromptManager: undefined, + error: new Error('not loaded'), + } + + ctx.coreData.cyPromptLifecycleManager.getCyPrompt.resolves(mockCyPrompt) + + return this.client.emit('backend:request', 'wait:for:cy:prompt:ready', (resp) => { + expect(resp.response).to.deep.eq({ + error: errors.cloneErr(mockCyPrompt.error), + }) + + return done() + }) + }) }) context('on(save:app:state)', () => { diff --git a/packages/types/src/cy-prompt/index.ts b/packages/types/src/cy-prompt/index.ts index f919c2f2f219..39254bdd9bb3 100644 --- a/packages/types/src/cy-prompt/index.ts +++ b/packages/types/src/cy-prompt/index.ts @@ -11,6 +11,9 @@ export interface CyPromptManagerShape extends CyPromptServerShape { } export interface CyPromptLifecycleManagerShape { - getCyPrompt: () => Promise + getCyPrompt: () => Promise<{ + cyPromptManager?: CyPromptManagerShape + error?: Error + }> registerCyPromptReadyListener: (listener: (cyPromptManager: CyPromptManagerShape) => void) => void } From 04e821276634ebda825af782303bae128c134fe9 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 13 Jun 2025 13:53:27 -0500 Subject: [PATCH 12/14] chores: (cy.prompt) refactor routing to support both app and driver (#31891) --- packages/driver/src/cy/commands/prompt/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 3fe42b5213e3..ecc41e69ffd9 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -47,7 +47,7 @@ const initializeModule = async (Cypress: Cypress.Cypress): Promise Date: Fri, 13 Jun 2025 17:14:16 -0500 Subject: [PATCH 13/14] chore: Share error utils with the cloud (#31887) * share error utils with cloud * additional rework * Fix command, add isOpenMode * Add / fix test * fix ts --------- Co-authored-by: Ryan Manuel --- .../cypress/e2e/cypress/error_utils.cy.ts | 21 +++++++++ .../driver/src/cy/commands/prompt/index.ts | 23 +++++----- .../cy/commands/prompt/prompt-driver-types.ts | 4 ++ packages/driver/src/cypress/error_utils.ts | 14 +++++- .../cy-prompt/CyPromptLifecycleManager.ts | 46 +++++++++++++++---- .../lib/cloud/cy-prompt/CyPromptManager.ts | 12 +++-- packages/server/lib/modes/run.ts | 2 + packages/server/lib/project-base.ts | 3 +- .../CyPromptLifecycleManager_spec.ts | 41 ++++++++++++----- packages/server/test/unit/project_spec.js | 11 ++++- .../src/cy-prompt/cy-prompt-server-types.ts | 15 +++++- 11 files changed, 149 insertions(+), 43 deletions(-) diff --git a/packages/driver/cypress/e2e/cypress/error_utils.cy.ts b/packages/driver/cypress/e2e/cypress/error_utils.cy.ts index 609ea324eb5b..7eb2ab0a0ce1 100644 --- a/packages/driver/cypress/e2e/cypress/error_utils.cy.ts +++ b/packages/driver/cypress/e2e/cypress/error_utils.cy.ts @@ -624,6 +624,27 @@ describe('driver/src/cypress/error_utils', () => { }) }) + context('.extendErrorMessages', () => { + it('extends error messages', () => { + $errUtils.extendErrorMessages({ + testErrors: { + test: 'test error message', + }, + }) + + const fn = () => { + $errUtils.throwErrByPath('testErrors.test') + } + + expect(fn).to.throw().and.satisfy((err) => { + expect(err.message).to.equal('test error message') + expect(err.name).to.eq('CypressError') + + return true + }) + }) + }) + context('.getUnsupportedPlugin', () => { it('returns unsupported plugin if the error msg is the expected one', () => { const unsupportedPlugin = $errUtils.getUnsupportedPlugin({ diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index ecc41e69ffd9..cdf0df62757c 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -79,13 +79,18 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress, cy: Cypress.Cyp Cypress: Cypress as CypressInternal, cy, eventManager: window.getEventManager ? window.getEventManager() : undefined, + errorUtils: { + extendErrorMessages: $errUtils.extendErrorMessages, + throwErrByPath: $errUtils.throwErrByPath, + }, }) } catch (error) { return error } } -export default (Commands, Cypress, cy) => { +export default (Commands: Cypress.Cypress['Commands'], Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']) => { + // @ts-expect-error - these types are not yet implemented until the prompt command is rolled out if (Cypress.config('experimentalPromptCommand')) { let initializeCloudCyPromptPromise: Promise | Error> | undefined @@ -93,7 +98,7 @@ export default (Commands, Cypress, cy) => { initializeCloudCyPromptPromise = initializeCloudCyPrompt(Cypress, cy) } - const prompt = async (message: string, options: object = {}) => { + const prompt = (message: string, options: object = {}) => { if (Cypress.testingType === 'component') { $errUtils.throwErrByPath('prompt.promptTestingTypeError') @@ -108,22 +113,16 @@ export default (Commands, Cypress, cy) => { return } - try { - const bundleResult = await initializeCloudCyPromptPromise - + // TODO: figure out how to handle timeout more generally + return cy.wrap(initializeCloudCyPromptPromise, { log: false, timeout: 45000 }).then((bundleResult: ReturnType | Error) => { if (bundleResult instanceof Error) { throw bundleResult } const cyPrompt = bundleResult - return await cyPrompt(message, options) - } catch (error) { - // TODO: Check error that the user is logged in / record key - - // TODO: handle this better - throw new Error(`CyPromptDriver not found: ${error}`) - } + return cyPrompt(message, options) + }) } // For testing purposes, we can reset the prompt command initialization 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 2b57f796ce6b..42316c817928 100644 --- a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts +++ b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts @@ -46,6 +46,10 @@ export interface CyPromptOptions { // Note that the eventManager is present in same origin AUTs, but not cross origin // so we need to check for it's presence before using it eventManager?: CyPromptEventManager + errorUtils: { + extendErrorMessages: (errorMessages: any) => void + throwErrByPath: (err: any, path: string) => void + } } export interface CyPromptDriverDefaultShape { diff --git a/packages/driver/src/cypress/error_utils.ts b/packages/driver/src/cypress/error_utils.ts index aef91fdc5014..f71959142525 100644 --- a/packages/driver/src/cypress/error_utils.ts +++ b/packages/driver/src/cypress/error_utils.ts @@ -22,6 +22,8 @@ const ERR_PREPARED_FOR_SERIALIZATION = Symbol('ERR_PREPARED_FOR_SERIALIZATION') const crossOriginScriptRe = /^script error/i +let allErrorMessages = $errorMessages + if (!Error.captureStackTrace) { Error.captureStackTrace = (err, fn) => { const stack = (new Error()).stack @@ -396,7 +398,7 @@ const docsUrlByParents = (msgPath) => { return // reached root } - const obj = _.get($errorMessages, msgPath) + const obj = _.get(allErrorMessages, msgPath) if (obj.hasOwnProperty('docsUrl')) { return obj.docsUrl @@ -406,7 +408,7 @@ const docsUrlByParents = (msgPath) => { } const errByPath = (msgPath, args?) => { - let msgValue = _.get($errorMessages, msgPath) + let msgValue = _.get(allErrorMessages, msgPath) if (!msgValue) { return internalErr({ message: `Error message path '${msgPath}' does not exist` }) @@ -655,6 +657,13 @@ const getUnsupportedPlugin = (runnable) => { return null } +const extendErrorMessages = (errorMessages: any) => { + allErrorMessages = { + ...allErrorMessages, + ...errorMessages, + } +} + export default { stackWithReplacedProps, appendErrMsg, @@ -679,4 +688,5 @@ export default { throwErrByPath, warnByPath, wrapErr, + extendErrorMessages, } diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 6aad10dc4f27..2809e815d7f0 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -12,6 +12,7 @@ import { readFile } from 'fs-extra' import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle' import chokidar from 'chokidar' import { getCloudMetadata } from '../get_cloud_metadata' +import type { CyPromptAuthenticatedUserShape } from '@packages/types' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') @@ -33,22 +34,34 @@ export class CyPromptLifecycleManager { * @param ctx Data context to register this instance with */ initializeCyPromptManager ({ - projectId, cloudDataSource, ctx, + record, + key, }: { - projectId?: string cloudDataSource: CloudDataSource ctx: DataContext + record?: boolean + key?: string }): void { // Register this instance in the data context ctx.update((data) => { data.cyPromptLifecycleManager = this }) + const getProjectOptions = async () => { + return { + user: await ctx.actions.auth.authApi.getUser(), + projectSlug: (await ctx.project.getConfig()).projectId || undefined, + record, + key, + isOpenMode: ctx.isOpenMode, + } + } + const cyPromptManagerPromise = this.createCyPromptManager({ - projectId, cloudDataSource, + getProjectOptions, }).catch(async (error) => { debug('Error during cy prompt manager setup: %o', error) @@ -81,8 +94,8 @@ export class CyPromptLifecycleManager { this.cyPromptManagerPromise = cyPromptManagerPromise this.setupWatcher({ - projectId, cloudDataSource, + getProjectOptions, }) } @@ -97,17 +110,25 @@ export class CyPromptLifecycleManager { } private async createCyPromptManager ({ - projectId, cloudDataSource, + getProjectOptions, }: { projectId?: string cloudDataSource: CloudDataSource + getProjectOptions: () => Promise<{ + user?: CyPromptAuthenticatedUserShape + projectSlug?: string + record?: boolean + key?: string + }> }): Promise<{ cyPromptManager?: CyPromptManager, error?: Error }> { let cyPromptHash: string let cyPromptPath: string + const currentProjectOptions = await getProjectOptions() + const projectId = currentProjectOptions.projectSlug const cyPromptSession = await postCyPromptSession({ - projectId, + projectId: currentProjectOptions.projectSlug, }) if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { @@ -138,20 +159,19 @@ export class CyPromptLifecycleManager { const script = await readFile(serverFilePath, 'utf8') const cyPromptManager = new CyPromptManager() - const { cloudUrl, cloudHeaders } = await getCloudMetadata(cloudDataSource) + const { cloudUrl } = await getCloudMetadata(cloudDataSource) await cyPromptManager.setup({ script, cyPromptPath, cyPromptHash, - projectSlug: projectId, cloudApi: { cloudUrl, - cloudHeaders, CloudRequest, isRetryableError, asyncRetry, }, + getProjectOptions, }) debug('cy prompt is ready') @@ -181,9 +201,16 @@ export class CyPromptLifecycleManager { private setupWatcher ({ projectId, cloudDataSource, + getProjectOptions, }: { projectId?: string cloudDataSource: CloudDataSource + getProjectOptions: () => Promise<{ + user?: CyPromptAuthenticatedUserShape + projectSlug?: string + record?: boolean + key?: string + }> }) { // Don't setup a watcher if the cy prompt bundle is NOT local if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { @@ -204,6 +231,7 @@ export class CyPromptLifecycleManager { this.cyPromptManagerPromise = this.createCyPromptManager({ projectId, cloudDataSource, + getProjectOptions, }).catch((error) => { debug('Error during reload of cy prompt manager: %o', error) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index aa9bb17b0cf8..3aedaf852c1d 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -1,4 +1,4 @@ -import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, CyPromptServerShape, CyPromptCloudApi, CyPromptCDPClient } from '@packages/types' +import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, CyPromptServerShape, CyPromptCloudApi, CyPromptCDPClient, CyPromptAuthenticatedUserShape } from '@packages/types' import type { Router } from 'express' import Debug from 'debug' import { requireScript } from '../require_script' @@ -12,6 +12,12 @@ interface SetupOptions { cyPromptHash?: string projectSlug?: string cloudApi: CyPromptCloudApi + getProjectOptions: () => Promise<{ + user?: CyPromptAuthenticatedUserShape + projectSlug?: string + record?: boolean + key?: string + }> } const debug = Debug('cypress:server:cy-prompt') @@ -20,14 +26,14 @@ export class CyPromptManager implements CyPromptManagerShape { status: CyPromptStatus = 'NOT_INITIALIZED' private _cyPromptServer: CyPromptServerShape | undefined - async setup ({ script, cyPromptPath, cyPromptHash, projectSlug, cloudApi }: SetupOptions): Promise { + async setup ({ script, cyPromptPath, cyPromptHash, getProjectOptions, cloudApi }: SetupOptions): Promise { const { createCyPromptServer } = requireScript(script).default this._cyPromptServer = await createCyPromptServer({ cyPromptHash, cyPromptPath, - projectSlug, cloudApi, + getProjectOptions, }) this.status = 'INITIALIZED' diff --git a/packages/server/lib/modes/run.ts b/packages/server/lib/modes/run.ts index 1e668ddb0fd2..390226216a9e 100644 --- a/packages/server/lib/modes/run.ts +++ b/packages/server/lib/modes/run.ts @@ -160,6 +160,8 @@ const openProjectCreate = (projectRoot, socketId, args) => { onWarning, spec: args.spec, onError: args.onError, + record: args.record, + key: args.key, } return openProject.create(projectRoot, args, options) diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 669da1bfdfd6..e36899becf1b 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -166,9 +166,10 @@ export class ProjectBase extends EE { const cyPromptLifecycleManager = new CyPromptLifecycleManager() cyPromptLifecycleManager.initializeCyPromptManager({ - projectId: cfg.projectId, cloudDataSource: this.ctx.cloud, ctx: this.ctx, + record: this.options.record, + key: this.options.key, }) } 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 47fe38b6d30a..091d9fd0081b 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -75,6 +75,20 @@ describe('CyPromptLifecycleManager', () => { emitter: { cyPromptStatusChange: cyPromptStatusChangeEmitterStub, }, + actions: { + auth: { + authApi: { + getUser: sinon.stub().resolves({ + authToken: 'test-token', + }), + }, + }, + }, + project: { + getConfig: sinon.stub().resolves({ + projectId: 'test-project-id', + }), + }, } as unknown as DataContext mockCloudDataSource = { @@ -96,9 +110,10 @@ describe('CyPromptLifecycleManager', () => { describe('initializeCyPromptManager', () => { it('initializes the cy-prompt manager and registers it in the data context', async () => { cyPromptLifecycleManager.initializeCyPromptManager({ - projectId: 'test-project-id', cloudDataSource: mockCloudDataSource, ctx: mockCtx, + record: false, + key: '123e4567-e89b-12d3-a456-426614174000', }) const cyPromptReadyPromise = new Promise((resolve) => { @@ -120,14 +135,13 @@ describe('CyPromptLifecycleManager', () => { 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, }, + getProjectOptions: sinon.match.func, }) expect(postCyPromptSessionStub).to.be.calledWith({ @@ -141,9 +155,10 @@ describe('CyPromptLifecycleManager', () => { it('only calls ensureCyPromptBundle once per cy prompt hash', async () => { cyPromptLifecycleManager.initializeCyPromptManager({ - projectId: 'test-project-id', cloudDataSource: mockCloudDataSource, ctx: mockCtx, + record: false, + key: '123e4567-e89b-12d3-a456-426614174000', }) const cyPromptReadyPromise1 = new Promise((resolve) => { @@ -155,9 +170,10 @@ describe('CyPromptLifecycleManager', () => { const cyPromptManager1 = await cyPromptReadyPromise1 cyPromptLifecycleManager.initializeCyPromptManager({ - projectId: 'test-project-id', cloudDataSource: mockCloudDataSource, ctx: mockCtx, + record: false, + key: '123e4567-e89b-12d3-a456-426614174000', }) const cyPromptReadyPromise2 = new Promise((resolve) => { @@ -182,14 +198,13 @@ describe('CyPromptLifecycleManager', () => { 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, }, + getProjectOptions: sinon.match.func, }) expect(postCyPromptSessionStub).to.be.calledWith({ @@ -205,9 +220,10 @@ describe('CyPromptLifecycleManager', () => { process.env.CYPRESS_LOCAL_CY_PROMPT_PATH = '/path/to/cy-prompt' cyPromptLifecycleManager.initializeCyPromptManager({ - projectId: 'test-project-id', cloudDataSource: mockCloudDataSource, ctx: mockCtx, + record: false, + key: '123e4567-e89b-12d3-a456-426614174000', }) const cyPromptReadyPromise = new Promise((resolve) => { @@ -225,14 +241,13 @@ describe('CyPromptLifecycleManager', () => { script: 'console.log("cy-prompt script")', cyPromptPath: '/path/to/cy-prompt', cyPromptHash: 'local', - projectSlug: 'test-project-id', cloudApi: { cloudUrl: 'https://cloud.cypress.io', - cloudHeaders: { 'Authorization': 'Bearer test-token' }, CloudRequest, isRetryableError, asyncRetry, }, + getProjectOptions: sinon.match.func, }) expect(postCyPromptSessionStub).to.be.calledWith({ @@ -378,9 +393,10 @@ describe('CyPromptLifecycleManager', () => { ]) cyPromptLifecycleManager.initializeCyPromptManager({ - projectId: 'test-project-id', cloudDataSource: mockCloudDataSource, ctx: mockCtx, + record: false, + key: '123e4567-e89b-12d3-a456-426614174000', }) await listenersCalledPromise @@ -414,9 +430,10 @@ describe('CyPromptLifecycleManager', () => { ]) cyPromptLifecycleManager.initializeCyPromptManager({ - projectId: 'test-project-id', cloudDataSource: mockCloudDataSource, ctx: mockCtx, + record: false, + key: '123e4567-e89b-12d3-a456-426614174000', }) await listenersCalledPromise diff --git a/packages/server/test/unit/project_spec.js b/packages/server/test/unit/project_spec.js index 1c953bc13529..3886aee2b72c 100644 --- a/packages/server/test/unit/project_spec.js +++ b/packages/server/test/unit/project_spec.js @@ -462,20 +462,26 @@ This option will not have an effect in Some-other-name. Tests that rely on web s it('initializes cy prompt lifecycle manager if experimentalPromptCommand is enabled', function () { this.config.projectId = 'abc123' this.config.experimentalPromptCommand = true + this.project.options.record = true + this.project.options.key = '123e4567-e89b-12d3-a456-426614174000' initializeCyPromptManagerStub = sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager') return this.project.open() .then(() => { expect(initializeCyPromptManagerStub).to.be.calledWith({ - projectId: 'abc123', cloudDataSource: ctx.cloud, ctx, + record: true, + key: '123e4567-e89b-12d3-a456-426614174000', }) }) }) it('initializes cy prompt lifecycle manager if process.env.CYPRESS_ENABLE_CY_PROMPT is enabled', function () { + this.project.options.record = false + this.project.options.key = undefined + process.env.CYPRESS_ENABLE_CY_PROMPT = 'true' initializeCyPromptManagerStub = sinon.stub(CyPromptLifecycleManager.prototype, 'initializeCyPromptManager') @@ -483,9 +489,10 @@ This option will not have an effect in Some-other-name. Tests that rely on web s return this.project.open() .then(() => { expect(initializeCyPromptManagerStub).to.be.calledWith({ - projectId: 'abc123', cloudDataSource: ctx.cloud, ctx, + record: false, + key: undefined, }) }) }) 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 b212f1910664..2d882ec5361d 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -22,7 +22,6 @@ interface RetryOptions { export interface CyPromptCloudApi { cloudUrl: string - cloudHeaders: Record CloudRequest: AxiosInstance isRetryableError: (err: unknown) => boolean asyncRetry: AsyncRetry @@ -33,10 +32,22 @@ type AsyncRetry = ( options: RetryOptions ) => (...args: TArgs) => Promise +export interface CyPromptAuthenticatedUserShape { + id?: string //Cloud user id + name?: string + email?: string + authToken?: string +} + export interface CyPromptServerOptions { cyPromptHash?: string + getProjectOptions: () => Promise<{ + user?: CyPromptAuthenticatedUserShape + projectSlug?: string + record?: boolean + key?: string + }> cyPromptPath: string - projectSlug?: string cloudApi: CyPromptCloudApi } From 2e4c8e4e87e482489b6d611d64aaa8d35947b4a0 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Fri, 20 Jun 2025 17:16:57 -0500 Subject: [PATCH 14/14] internal: (cy.prompt) add infrastructure to support a Get Code modal (#31904) * chore: (cy.prompt) add infrastructure to support a Get Code modal * fix tests * fix code paths * Update eject button styles * handle errors * update types * Update packages/server/lib/socket-base.ts * Fix cy test * update readme --------- Co-authored-by: estrada9166 --- guides/cy-prompt-development.md | 16 ++- .../app/src/prompt/PromptGetCodeModal.vue | 124 +++++++++++++++++ packages/app/src/prompt/prompt-app-types.ts | 28 ++++ .../app/src/runner/SpecRunnerOpenMode.vue | 9 +- packages/app/src/runner/event-manager.ts | 24 ++++ packages/app/src/store/prompt-store.ts | 32 +++++ packages/app/src/studio/studio-app-types.ts | 18 +-- .../prompt/prompt-initialization-error.cy.ts | 4 +- .../driver/src/cy/commands/prompt/index.ts | 14 +- .../cy/commands/prompt/prompt-driver-types.ts | 36 ++++- .../driver/types/internal-types-lite.d.ts | 2 +- .../icons/technology-angle-brackets_x16.svg | 10 ++ .../reporter/src/commands/command-model.ts | 6 + packages/reporter/src/commands/command.cy.tsx | 92 +++++++++++++ packages/reporter/src/commands/command.tsx | 44 +++--- packages/reporter/src/commands/commands.scss | 36 +++++ packages/reporter/src/lib/events.ts | 4 + .../lib/cloud/cy-prompt/CyPromptManager.ts | 6 + packages/server/lib/socket-base.ts | 35 ++++- .../cloud/cy-prompt/test-cy-prompt.ts | 4 + .../cloud/cy-prompt/CyPromptManager_spec.ts | 29 ++++ packages/server/test/unit/socket_spec.js | 45 +++++- .../src/cy-prompt/cy-prompt-server-types.ts | 24 +++- .../types/src/studio/studio-server-types.ts | 1 + scripts/gulp/gulpfile.ts | 3 +- scripts/gulp/tasks/gulpCloudDeliveredTypes.ts | 130 +++++++++++++++--- 26 files changed, 692 insertions(+), 84 deletions(-) create mode 100644 packages/app/src/prompt/PromptGetCodeModal.vue create mode 100644 packages/app/src/prompt/prompt-app-types.ts create mode 100644 packages/app/src/store/prompt-store.ts create mode 100644 packages/frontend-shared/src/assets/icons/technology-angle-brackets_x16.svg diff --git a/guides/cy-prompt-development.md b/guides/cy-prompt-development.md index 390e21475061..1eebbb52be62 100644 --- a/guides/cy-prompt-development.md +++ b/guides/cy-prompt-development.md @@ -1,6 +1,6 @@ # `cy.prompt` Development -In production, the code used to facilitate the prompt command will be retrieved from the Cloud. While `cy.prompt` is still in its early stages it is hidden behind an environment variable: `CYPRESS_ENABLE_CY_PROMPT` but can also be run against local cloud Studio code via the environment variable: `CYPRESS_LOCAL_CY_PROMPT_PATH`. +In production, the code used to facilitate the prompt command will be retrieved from the Cloud. While `cy.prompt` is still in its early stages it is hidden behind an environment variable: `CYPRESS_ENABLE_CY_PROMPT` but can also be run against local cloud prompt code via the environment variable: `CYPRESS_LOCAL_CY_PROMPT_PATH`. To run against locally developed `cy.prompt`: @@ -30,6 +30,20 @@ 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`) +## Types + +The prompt bundle provides the types for the `app`, `driver`, and `server` interfaces that are used within the Cypress code. To incorporate the types into the code base, run: + +```sh +yarn gulp downloadPromptTypes +``` + +or to reference a local `cypress_services` repo: + +```sh +CYPRESS_LOCAL_CY_PROMPT_PATH= yarn gulp downloadPromptTypes +``` + ## Testing ### Unit/Component Testing diff --git a/packages/app/src/prompt/PromptGetCodeModal.vue b/packages/app/src/prompt/PromptGetCodeModal.vue new file mode 100644 index 000000000000..7c53f561909c --- /dev/null +++ b/packages/app/src/prompt/PromptGetCodeModal.vue @@ -0,0 +1,124 @@ + + + diff --git a/packages/app/src/prompt/prompt-app-types.ts b/packages/app/src/prompt/prompt-app-types.ts new file mode 100644 index 000000000000..09014fc44d99 --- /dev/null +++ b/packages/app/src/prompt/prompt-app-types.ts @@ -0,0 +1,28 @@ +// Note: This file is owned by the cloud delivered +// cy prompt bundle. It is downloaded and copied here. +// It should not be modified directly here. + +export interface CypressInternal extends Cypress.Cypress { + backendRequestHandler: ( + backendRequestNamespace: string, + eventName: string, + ...args: any[] + ) => Promise +} + +export interface GetCodeModalContentsProps { + Cypress: CypressInternal + testId: string + logId: string + onClose: () => void +} + +export type GetCodeModalContentsShape = ( + props: GetCodeModalContentsProps +) => JSX.Element + +export interface CyPromptAppDefaultShape { + // Purposefully do not use React in this signature to avoid conflicts when this type gets + // transferred to the Cypress app + GetCodeModalContents: GetCodeModalContentsShape +} diff --git a/packages/app/src/runner/SpecRunnerOpenMode.vue b/packages/app/src/runner/SpecRunnerOpenMode.vue index 89f720f76651..a5dfc452157d 100644 --- a/packages/app/src/runner/SpecRunnerOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerOpenMode.vue @@ -1,4 +1,9 @@