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",