diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 4339f79b5d7..84b0701d77b 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ /// /// /// @@ -687,22 +686,22 @@ declare namespace Cypress { Keyboard: { defaults(options: Partial): void Keys: { - DOWN: 'ArrowDown', - LEFT: 'ArrowLeft', - RIGHT: 'ArrowRight', - UP: 'ArrowUp', - END: 'End', - HOME: 'Home', - PAGEDOWN: 'PageDown', - PAGEUP: 'PageUp', - ENTER: 'Enter', - TAB: 'Tab', - BACKSPACE: 'Backspace', - SPACE: 'Space', - DELETE: 'Delete', - INSERT: 'Insert', - ESC: 'Escape', - }, + DOWN: 'ArrowDown' + LEFT: 'ArrowLeft' + RIGHT: 'ArrowRight' + UP: 'ArrowUp' + END: 'End' + HOME: 'Home' + PAGEDOWN: 'PageDown' + PAGEUP: 'PageUp' + ENTER: 'Enter' + TAB: 'Tab' + BACKSPACE: 'Backspace' + SPACE: 'Space' + DELETE: 'Delete' + INSERT: 'Insert' + ESC: 'Escape' + } } /** @@ -758,7 +757,7 @@ declare namespace Cypress { * Trigger action * @private */ - action: (action: string, ...args: any[]) => T + action: (action: string, ...args: any[]) => T /** * Load files @@ -1857,7 +1856,7 @@ declare namespace Cypress { * * @see https://on.cypress.io/prompt */ - prompt(steps: string[], options?: PromptOptions): Chainable + prompt(steps: string[], options?: PromptOptions): Chainable /** * Read a file and yield its contents. * @@ -2906,8 +2905,8 @@ declare namespace Cypress { } type RetryStrategyWithModeSpecs = RetryStrategy & { - openMode: boolean; // defaults to false - runMode: boolean; // defaults to true + openMode: boolean // defaults to false + runMode: boolean // defaults to true } type RetryStrategy = @@ -2915,18 +2914,18 @@ declare namespace Cypress { | RetryStrategyDetectFlakeButAlwaysFailType interface RetryStrategyDetectFlakeAndPassOnThresholdType { - experimentalStrategy: "detect-flake-and-pass-on-threshold" + experimentalStrategy: 'detect-flake-and-pass-on-threshold' experimentalOptions?: { - maxRetries: number; // defaults to 2 if experimentalOptions is not provided, must be a whole number > 0 - passesRequired: number; // defaults to 2 if experimentalOptions is not provided, must be a whole number > 0 and <= maxRetries + maxRetries: number // defaults to 2 if experimentalOptions is not provided, must be a whole number > 0 + passesRequired: number // defaults to 2 if experimentalOptions is not provided, must be a whole number > 0 and <= maxRetries } } interface RetryStrategyDetectFlakeButAlwaysFailType { - experimentalStrategy: "detect-flake-but-always-fail" + experimentalStrategy: 'detect-flake-but-always-fail' experimentalOptions?: { - maxRetries: number; // defaults to 2 if experimentalOptions is not provided, must be a whole number > 0 - stopIfAnyPassed: boolean; // defaults to false if experimentalOptions is not provided + maxRetries: number // defaults to 2 if experimentalOptions is not provided, must be a whole number > 0 + stopIfAnyPassed: boolean // defaults to false if experimentalOptions is not provided } } interface ResolvedConfigOptions { @@ -3148,7 +3147,7 @@ declare namespace Cypress { * @see https://on.cypress.io/experiments#Experimental-CSP-Allow-List * @default false */ - experimentalCspAllowList: boolean | experimentalCspAllowedDirectives[], + experimentalCspAllowList: boolean | experimentalCspAllowedDirectives[] /** * Allows listening to the `before:run`, `after:run`, `before:spec`, and `after:spec` events in the plugins file during interactive mode. * @default false @@ -3281,7 +3280,7 @@ declare namespace Cypress { */ experimentalOriginDependencies?: boolean /** - * Enables support for the prompt command. + * Enables support for `cy.prompt`, an AI-powered command that turns natural language steps into executable Cypress test code. * @default false */ experimentalPromptCommand?: boolean diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 7055f6c8c5f..4bddeb5e828 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -919,6 +919,7 @@ export class EventManager { crossOriginLogs = {} this.studioStore.setActive(false) this.promptStore.resetState() + await new Promise((resolve) => this.ws.emit('prompt:reset', resolve)) } resetReporter () { diff --git a/packages/app/src/runner/index.ts b/packages/app/src/runner/index.ts index a5f810b3e64..9ce7f903692 100644 --- a/packages/app/src/runner/index.ts +++ b/packages/app/src/runner/index.ts @@ -190,7 +190,7 @@ function teardownSpec (isRerun: boolean = false) { export async function teardown () { UnifiedReporterAPI.setInitializedReporter(false) _eventManager?.stop() - _eventManager?.teardown(getMobxRunnerStore()) + await _eventManager?.teardown(getMobxRunnerStore()) await _eventManager?.resetReporter() _eventManager = undefined } diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index f666159240a..b214a860141 100644 --- a/packages/config/src/options.ts +++ b/packages/config/src/options.ts @@ -231,25 +231,25 @@ const driverConfigOptions: Array = [ overrideLevel: 'any', requireRestartOnChange: 'browser', }, { - name: 'experimentalSourceRewriting', + name: 'experimentalPromptCommand', defaultValue: false, validation: validate.isBoolean, isExperimental: true, requireRestartOnChange: 'server', }, { - name: 'experimentalSingleTabRunMode', + name: 'experimentalSourceRewriting', defaultValue: false, validation: validate.isBoolean, isExperimental: true, requireRestartOnChange: 'server', }, { - name: 'experimentalStudio', + name: 'experimentalSingleTabRunMode', defaultValue: false, validation: validate.isBoolean, isExperimental: true, requireRestartOnChange: 'server', }, { - name: 'experimentalPromptCommand', + name: 'experimentalStudio', defaultValue: false, validation: validate.isBoolean, isExperimental: true, diff --git a/packages/config/test/__snapshots__/index.spec.ts.snap b/packages/config/test/__snapshots__/index.spec.ts.snap index 2e4a587627e..2c54f643686 100644 --- a/packages/config/test/__snapshots__/index.spec.ts.snap +++ b/packages/config/test/__snapshots__/index.spec.ts.snap @@ -223,7 +223,7 @@ exports[`config/src/index > .getPublicConfigKeys > returns list of public config "experimentalModifyObstructiveThirdPartyCode", "injectDocumentDomain", "experimentalOriginDependencies", - "experimentalPromptCommand": false, + "experimentalPromptCommand", "experimentalSourceRewriting", "experimentalSingleTabRunMode", "experimentalStudio", diff --git a/packages/config/test/project/utils.spec.ts b/packages/config/test/project/utils.spec.ts index a6f2ac7b4f2..4c952f53a99 100644 --- a/packages/config/test/project/utils.spec.ts +++ b/packages/config/test/project/utils.spec.ts @@ -1008,76 +1008,6 @@ describe('config/src/project/utils', () => { const getFilesByGlob = vi.fn().mockReturnValue(['path/to/file']) -<<<<<<< HEAD - return mergeDefaults(obj, options, {}, getFilesByGlob) - .then((cfg) => { - expect(cfg.resolved).to.deep.eq({ - animationDistanceThreshold: { value: 5, from: 'default' }, - arch: { value: os.arch(), from: 'default' }, - baseUrl: { value: null, from: 'default' }, - blockHosts: { value: null, from: 'default' }, - browsers: { value: [], from: 'default' }, - chromeWebSecurity: { value: true, from: 'default' }, - clientCertificates: { value: [], from: 'default' }, - defaultBrowser: { value: null, from: 'default' }, - defaultCommandTimeout: { value: 4000, from: 'default' }, - downloadsFolder: { value: 'cypress/downloads', from: 'default' }, - env: {}, - excludeSpecPattern: { value: '*.hot-update.js', from: 'default' }, - execTimeout: { value: 60000, from: 'default' }, - experimentalModifyObstructiveThirdPartyCode: { value: false, from: 'default' }, - experimentalCspAllowList: { value: false, from: 'default' }, - experimentalInteractiveRunEvents: { value: false, from: 'default' }, - experimentalMemoryManagement: { value: false, from: 'default' }, - experimentalOriginDependencies: { value: false, from: 'default' }, - 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' }, - fixturesFolder: { value: 'cypress/fixtures', from: 'default' }, - hosts: { value: null, from: 'default' }, - includeShadowDom: { value: false, from: 'default' }, - injectDocumentDomain: { value: false, from: 'default' }, - justInTimeCompile: { value: true, from: 'default' }, - isInteractive: { value: true, from: 'default' }, - keystrokeDelay: { value: 0, from: 'default' }, - modifyObstructiveCode: { value: true, from: 'default' }, - numTestsKeptInMemory: { value: 50, from: 'default' }, - pageLoadTimeout: { value: 60000, from: 'default' }, - platform: { value: os.platform(), from: 'default' }, - port: { value: 1234, from: 'cli' }, - projectId: { value: null, from: 'default' }, - redirectionLimit: { value: 20, from: 'default' }, - reporter: { value: 'json', from: 'cli' }, - resolvedNodePath: { value: null, from: 'default' }, - resolvedNodeVersion: { value: null, from: 'default' }, - reporterOptions: { value: null, from: 'default' }, - requestTimeout: { value: 5000, from: 'default' }, - responseTimeout: { value: 30000, from: 'default' }, - retries: { value: { runMode: 0, openMode: 0, experimentalStrategy: undefined, experimentalOptions: undefined }, from: 'default' }, - screenshotOnRunFailure: { value: true, from: 'default' }, - screenshotsFolder: { value: 'cypress/screenshots', from: 'default' }, - specPattern: { value: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', from: 'default' }, - slowTestThreshold: { value: 10000, from: 'default' }, - supportFile: { value: false, from: 'config' }, - supportFolder: { value: false, from: 'default' }, - taskTimeout: { value: 60000, from: 'default' }, - testIsolation: { value: true, from: 'default' }, - trashAssetsBeforeRuns: { value: true, from: 'default' }, - userAgent: { value: null, from: 'default' }, - video: { value: false, from: 'default' }, - videoCompression: { value: false, from: 'default' }, - videosFolder: { value: 'cypress/videos', from: 'default' }, - viewportHeight: { value: 660, from: 'default' }, - viewportWidth: { value: 1000, from: 'default' }, - waitForAnimations: { value: true, from: 'default' }, - scrollBehavior: { value: 'top', from: 'default' }, - watchForFileChanges: { value: true, from: 'default' }, - }) -======= const cfg = await mergeDefaults(obj, options, {}, getFilesByGlob) expect(cfg.resolved).toEqual({ @@ -1099,6 +1029,7 @@ describe('config/src/project/utils', () => { experimentalInteractiveRunEvents: { value: false, from: 'default' }, experimentalMemoryManagement: { value: false, from: 'default' }, experimentalOriginDependencies: { value: false, from: 'default' }, + experimentalPromptCommand: { value: false, from: 'default' }, experimentalRunAllSpecs: { value: false, from: 'default' }, experimentalSingleTabRunMode: { value: false, from: 'default' }, experimentalStudio: { value: false, from: 'default' }, @@ -1144,7 +1075,6 @@ describe('config/src/project/utils', () => { waitForAnimations: { value: true, from: 'default' }, scrollBehavior: { value: 'top', from: 'default' }, watchForFileChanges: { value: true, from: 'default' }, ->>>>>>> origin/develop }) }) @@ -1192,62 +1122,6 @@ describe('config/src/project/utils', () => { value: 'foo', from: 'config', }, -<<<<<<< HEAD - excludeSpecPattern: { value: '*.hot-update.js', from: 'default' }, - execTimeout: { value: 60000, from: 'default' }, - experimentalModifyObstructiveThirdPartyCode: { value: false, from: 'default' }, - experimentalCspAllowList: { value: false, from: 'default' }, - experimentalInteractiveRunEvents: { value: false, from: 'default' }, - experimentalMemoryManagement: { value: false, from: 'default' }, - experimentalOriginDependencies: { value: false, from: 'default' }, - 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' }, - fixturesFolder: { value: 'cypress/fixtures', from: 'default' }, - hosts: { value: null, from: 'default' }, - includeShadowDom: { value: false, from: 'default' }, - injectDocumentDomain: { value: false, from: 'default' }, - justInTimeCompile: { value: true, from: 'default' }, - isInteractive: { value: true, from: 'default' }, - keystrokeDelay: { value: 0, from: 'default' }, - modifyObstructiveCode: { value: true, from: 'default' }, - numTestsKeptInMemory: { value: 50, from: 'default' }, - pageLoadTimeout: { value: 60000, from: 'default' }, - platform: { value: os.platform(), from: 'default' }, - port: { value: 2020, from: 'config' }, - projectId: { value: 'projectId123', from: 'env' }, - redirectionLimit: { value: 20, from: 'default' }, - reporter: { value: 'spec', from: 'default' }, - resolvedNodePath: { value: null, from: 'default' }, - resolvedNodeVersion: { value: null, from: 'default' }, - reporterOptions: { value: null, from: 'default' }, - requestTimeout: { value: 5000, from: 'default' }, - responseTimeout: { value: 30000, from: 'default' }, - retries: { value: { runMode: 0, openMode: 0, experimentalStrategy: undefined, experimentalOptions: undefined }, from: 'default' }, - screenshotOnRunFailure: { value: true, from: 'default' }, - screenshotsFolder: { value: 'cypress/screenshots', from: 'default' }, - slowTestThreshold: { value: 10000, from: 'default' }, - specPattern: { value: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', from: 'default' }, - supportFile: { value: false, from: 'config' }, - supportFolder: { value: false, from: 'default' }, - taskTimeout: { value: 60000, from: 'default' }, - testIsolation: { value: true, from: 'default' }, - trashAssetsBeforeRuns: { value: true, from: 'default' }, - userAgent: { value: null, from: 'default' }, - video: { value: false, from: 'default' }, - videoCompression: { value: false, from: 'default' }, - videosFolder: { value: 'cypress/videos', from: 'default' }, - viewportHeight: { value: 660, from: 'default' }, - viewportWidth: { value: 1000, from: 'default' }, - waitForAnimations: { value: true, from: 'default' }, - scrollBehavior: { value: 'top', from: 'default' }, - watchForFileChanges: { value: true, from: 'default' }, - }) -======= bar: { value: 'bar', from: 'envFile', @@ -1272,6 +1146,7 @@ describe('config/src/project/utils', () => { experimentalInteractiveRunEvents: { value: false, from: 'default' }, experimentalMemoryManagement: { value: false, from: 'default' }, experimentalOriginDependencies: { value: false, from: 'default' }, + experimentalPromptCommand: { value: false, from: 'default' }, experimentalRunAllSpecs: { value: false, from: 'default' }, experimentalSingleTabRunMode: { value: false, from: 'default' }, experimentalStudio: { value: false, from: 'default' }, @@ -1317,7 +1192,6 @@ describe('config/src/project/utils', () => { waitForAnimations: { value: true, from: 'default' }, scrollBehavior: { value: 'top', from: 'default' }, watchForFileChanges: { value: true, from: 'default' }, ->>>>>>> origin/develop }) }) 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 b0b7ceb7828..8e751dde4dc 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,40 +1,30 @@ describe('src/cy/commands/prompt', () => { - 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:prompt:ready').resolves({ success: false, error }) - + it('errors if download timeout is reached', (done) => { 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`) - + expect(err.message).to.include('Timed out downloading `cy.prompt` Cloud code') done() }) cy.visit('http://www.foobar.com:3500/fixtures/dom.html') - cy['commandFns']['prompt'].__resetPrompt() - cy.prompt(['Hello, world!']) + cy['commandFns']['prompt'].__resetPrompt(10000) + // @ts-expect-error - _downloadTimeout is a private option + cy.prompt(['Click the "click me" button'], { _downloadTimeout: 10 }) }) - it('errors if wait for ready does not return success and error is ECONNREFUSED', (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(`'bundle.tar' timed out after 10000s`) + const error = new Error(`no space left on device, open /Users/ruby/dev/bundle.tar`) - error.name = 'ECONNREFUSED' + ;(error as any).code = 'ENOSPC' backendStub.callThrough() backendStub.withArgs('wait:for:prompt:ready').resolves({ success: false, error }) cy.on('fail', (err) => { - expect(err.message).to.include('Timed out waiting for cy.prompt Cloud code:') - expect(err.message).to.include(`'bundle.tar' timed out after 10000s`) + expect(err.message).to.include('Failed to download `cy.prompt` Cloud code') + expect(err.message).to.include(`no space left on device, open /Users/ruby/dev/bundle.tar`) done() }) diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 02d92d26d07..8ddd63a6e98 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -15,30 +15,43 @@ declare global { } } +interface BundleResultInError { + error: Error + timedOut: false +} + +interface BundleResultTimedOut { + error: undefined + timedOut: true +} + +interface BundleResultSuccess { + error: undefined + bundle: Awaited> + timedOut: false +} + +type BundleResult = BundleResultInError | BundleResultTimedOut | BundleResultSuccess + 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, error } = await Cypress.backend('wait:for:prompt:ready') if (error) { - if (error.name === 'ENOSPC') { - $errUtils.throwErrByPath('prompt.promptDownloadError', { - args: { - error, - }, - }) - } else { - $errUtils.throwErrByPath('prompt.promptDownloadTimedOut', { - args: { - error, - }, - }) - } + $errUtils.throwErrByPath('prompt.promptDownloadError', { + args: { + error, + }, + }) } if (!success && !error) { - // TODO: Generic error message - throw new Error('error waiting for cy prompt bundle to be downloaded and ready') + $errUtils.throwErrByPath('prompt.promptDownloadError', { + args: { + error: new Error('error waiting for cy prompt bundle to be downloaded and ready'), + }, + }) } // Once the cy prompt bundle is downloaded and ready, @@ -60,16 +73,19 @@ const initializeModule = async (Cypress: Cypress.Cypress): Promise('cy-prompt') if (!module?.default) { - // TODO: Generic error message - throw new Error('error loading cy prompt driver') + $errUtils.throwErrByPath('prompt.promptDownloadError', { + args: { + error: new Error('error loading cy prompt driver'), + }, + }) } - initializedModule = module.default + initializedModule = module!.default return initializedModule } -const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']): Promise | Error> => { +const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']): Promise => { try { let cloudModule = initializedModule @@ -84,25 +100,33 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress, cy: Cypress.Cyp }) } - return await cloudModule.createCyPrompt({ - Cypress: Cypress as CypressInternal, - cy, - eventManager: window.getEventManager ? window.getEventManager() : undefined, - errorUtils: { - extendErrorMessages: $errUtils.extendErrorMessages, - throwErrByPath: $errUtils.throwErrByPath, - }, - getSourceDetailsForFirstLine: $stackUtils.getSourceDetailsForFirstLine, - onMoreInfoNeeded: ({ testId, logId, onSave, onCancel }: CyPromptMoreInfoNeededOptions) => { - if (Cypress.isCrossOriginSpecBridge) { - Cypress.specBridgeCommunicator.toPrimary('prompt:more-info-needed', { testId, logId, onSave, onCancel }) - } else { - window.getEventManager!().localBus.emit('prompt:more-info-needed', { testId, logId, onSave, onCancel }) - } - }, - }) + return { + bundle: await cloudModule.createCyPrompt({ + Cypress: Cypress as CypressInternal, + cy, + eventManager: window.getEventManager ? window.getEventManager() : undefined, + errorUtils: { + extendErrorMessages: $errUtils.extendErrorMessages, + throwErrByPath: $errUtils.throwErrByPath, + }, + getSourceDetailsForFirstLine: $stackUtils.getSourceDetailsForFirstLine, + onMoreInfoNeeded: ({ testId, logId, onSave, onCancel }: CyPromptMoreInfoNeededOptions) => { + if (Cypress.isCrossOriginSpecBridge) { + Cypress.specBridgeCommunicator.toPrimary('prompt:more-info-needed', { testId, logId, onSave, onCancel }) + } else { + window.getEventManager!().localBus.emit('prompt:more-info-needed', { testId, logId, onSave, onCancel }) + } + }, + }), + error: undefined, + timedOut: false, + } } catch (error) { - return error + return { + error, + bundle: undefined, + timedOut: false, + } } } @@ -115,12 +139,41 @@ export default (Commands: Cypress.Cypress['Commands'], Cypress: Cypress.Cypress, prompt (steps: string[], commandOptions: object = {}) { const promptCmd = cy.state('current') - return cy.wrap(initializeCloudCyPromptPromise, { log: false, timeout: 45000 }).then((bundleResult: Awaited>) => { - if (bundleResult instanceof Error) { - throw bundleResult + const downloadTimeout = '_downloadTimeout' in commandOptions ? commandOptions._downloadTimeout as number : 45000 + + let timeoutId: NodeJS.Timeout + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + resolve({ + error: undefined, + timedOut: true, + }) + }, downloadTimeout) + }) + const raceBundleResult = Promise.race([ + initializeCloudCyPromptPromise, + timeoutPromise, + ]).finally(() => { + clearTimeout(timeoutId) + }) as Promise + + return cy.wrap(raceBundleResult, { log: false, timeout: 1e9 }).then((bundleResult: BundleResult) => { + if (bundleResult.timedOut) { + cy.state('current', promptCmd) + + return $errUtils.throwErrByPath('prompt.promptDownloadTimedOut', { + args: { + error: new Error('cy.prompt bundle download timed out'), + }, + }) + } + + if (bundleResult.error) { + cy.state('current', promptCmd) + throw bundleResult.error } - const cyPrompt = bundleResult + const cyPrompt = bundleResult.bundle return cyPrompt({ steps, @@ -131,9 +184,9 @@ export default (Commands: Cypress.Cypress['Commands'], Cypress: Cypress.Cypress, }, } - commands.prompt['__resetPrompt'] = () => { + commands.prompt['__resetPrompt'] = async (delay: number = 0) => { initializedModule = null - initializeCloudCyPromptPromise = initializeCloudCyPrompt(Cypress, cy) + initializeCloudCyPromptPromise = new Promise((resolve) => setTimeout(resolve, delay)).then(() => initializeCloudCyPrompt(Cypress, cy)) } Commands.addAll(commands) diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index 4e9fb4cd45c..307831b990e 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -1335,11 +1335,11 @@ export default { promptDownloadError (obj) { return { message: stripIndent`\ - Failed to download cy.prompt Cloud code: + Failed to download \`cy.prompt\` Cloud code: - - ${obj.error.code}: ${obj.error.message} + - ${obj.error.code ? `${obj.error.code}: ` : ''}${obj.error.message} - Check your network connection and file settings to ensure download is not interrupted. + Check your network connection and file settings to ensure download is not interrupted. `, docsUrl: 'https://on.cypress.io/prompt-download-error', } @@ -1347,11 +1347,9 @@ export default { promptDownloadTimedOut (obj) { return { message: stripIndent`\ - Timed out waiting for cy.prompt Cloud code: - - - ${obj.error.code ? `${obj.error.code}: ` : ''}${obj.error.message} + Timed out downloading \`cy.prompt\` Cloud code. - Check your network connection and system configuration. + Check your network connection and system configuration to ensure download is not interrupted. `, docsUrl: 'https://on.cypress.io/prompt-download-error', } diff --git a/packages/driver/src/cypress/stack_utils.ts b/packages/driver/src/cypress/stack_utils.ts index ae4875a4eaf..4ba70924a25 100644 --- a/packages/driver/src/cypress/stack_utils.ts +++ b/packages/driver/src/cypress/stack_utils.ts @@ -519,6 +519,11 @@ const normalizedUserInvocationStack = (userInvocationStack) => { || line.includes('Chainer.prototype[key]') || line.includes('cy.') || line.includes('$Chainer.') + // Note that these are hard coded for now, because they are happening in the prompt command + // for some reason. Long term, we should make this more dynamic if necessary. + || line.includes('$Cy.prompt') + || line.includes('$Chainer.prompt') + || line.includes('CyPrompt.prototype.prompt') }).join('\n') return normalizeStackIndentation(nonCypressStackLines) diff --git a/packages/frontend-shared/src/locales/en-US.json b/packages/frontend-shared/src/locales/en-US.json index b2eef2fb099..ee6cbc6897a 100644 --- a/packages/frontend-shared/src/locales/en-US.json +++ b/packages/frontend-shared/src/locales/en-US.json @@ -615,7 +615,7 @@ }, "experimentalPromptCommand": { "name": "Prompt command", - "description": "Enables support for the prompt command." + "description": "Enables support for `cy.prompt`, an AI-powered command that turns natural language steps into executable Cypress test code." }, "experimentalWebKitSupport": { "name": "WebKit Support", 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 717095c0de2..fe943026f43 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 @@ -5,13 +5,10 @@ import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle' import path from 'path' import { verifySignature } from '../encryption' -const DOWNLOAD_TIMEOUT = 30000 - interface EnsureCyPromptBundleOptions { cyPromptPath: string cyPromptUrl: string projectId?: string - downloadTimeoutMs?: number } /** @@ -20,31 +17,19 @@ interface EnsureCyPromptBundleOptions { * @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): Promise> => { +export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId }: EnsureCyPromptBundleOptions): Promise> => { const bundlePath = path.join(cyPromptPath, 'bundle.tar') // First remove cyPromptPath to ensure we have a clean slate await remove(cyPromptPath) await ensureDir(cyPromptPath) - let timeoutId: NodeJS.Timeout - - const responseManifestSignature: string = 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) - }) as string + const responseManifestSignature: string = await getCyPromptBundle({ + cyPromptUrl, + projectId, + bundlePath, + }) await tar.extract({ file: bundlePath, diff --git a/packages/server/lib/experiments.ts b/packages/server/lib/experiments.ts index 555bbca5c9d..e3c374ba8fd 100644 --- a/packages/server/lib/experiments.ts +++ b/packages/server/lib/experiments.ts @@ -60,7 +60,7 @@ const _summaries: StringValues = { experimentalRunAllSpecs: 'Enables the "Run All Specs" UI feature, allowing the execution of multiple specs sequentially', experimentalOriginDependencies: 'Enables support for `Cypress.require()` for including dependencies within the `cy.origin()` callback.', experimentalMemoryManagement: 'Enables support for improved memory management within Chromium-based browsers.', - experimentalPromptCommand: 'Enables support for the prompt command.', + experimentalPromptCommand: 'Enables support for `cy.prompt`, an AI-powered command that turns natural language steps into executable Cypress test code.', } /** 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 054db8fb021..8cc2f349dc9 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 @@ -101,23 +101,4 @@ describe('ensureCyPromptBundle', () => { await expect(ensureCyPromptBundlePromise).to.be.rejectedWith('Unable to find cy-prompt manifest') }) - - 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/system-tests/__snapshots__/results_spec.ts.js b/system-tests/__snapshots__/results_spec.ts.js index dfbf7708d64..bc18840b17c 100644 --- a/system-tests/__snapshots__/results_spec.ts.js +++ b/system-tests/__snapshots__/results_spec.ts.js @@ -28,10 +28,10 @@ exports['module api and after:run results'] = ` "experimentalModifyObstructiveThirdPartyCode": false, "injectDocumentDomain": false, "experimentalOriginDependencies": false, + "experimentalPromptCommand": false, "experimentalSourceRewriting": false, "experimentalSingleTabRunMode": false, "experimentalStudio": false, - "experimentalPromptCommand": false, "experimentalWebKitSupport": false, "fileServerFolder": "/path/to/fileServerFolder", "fixturesFolder": "/path/to/fixturesFolder",