diff --git a/packages/driver/cypress.config.ts b/packages/driver/cypress.config.ts index f67b3467effa..09165fa6b26c 100644 --- a/packages/driver/cypress.config.ts +++ b/packages/driver/cypress.config.ts @@ -40,7 +40,7 @@ export const baseConfig: Cypress.ConfigOptions = { }, component: { experimentalSingleTabRunMode: true, - specPattern: 'cypress/component/**/*.cy.js', + specPattern: 'cypress/component/**/*.cy.{js,ts}', supportFile: false, devServer: (devServerOptions) => { return cypressWebpackDevServer({ diff --git a/packages/driver/cypress/component/spec.cy.ts b/packages/driver/cypress/component/spec.cy.ts index 2c34b76336cc..d3997f4ef9bc 100644 --- a/packages/driver/cypress/component/spec.cy.ts +++ b/packages/driver/cypress/component/spec.cy.ts @@ -54,4 +54,17 @@ describe('component testing', () => { expect(Cypress.log).to.be.calledWithMatch(sinon.match({ 'message': `Error: "Promise rejected with a string!"`, name: 'uncaught exception' })) }) }) + + it('fails when trying to use cy.prompt in component tests', (done) => { + cy.spy(Cypress, 'log').log(false) + + cy.on('fail', (err) => { + expect(err.message).to.include('`cy.prompt` is currently only supported in end-to-end tests.') + + done() + }) + + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag + cy.prompt('Hello, world!') + }) }) diff --git a/packages/driver/cypress/e2e/commands/prompt/prompt-initialization-error.cy.ts b/packages/driver/cypress/e2e/commands/prompt/prompt-initialization-error.cy.ts index 8e9144bff29a..31a4a0d22c14 100644 --- a/packages/driver/cypress/e2e/commands/prompt/prompt-initialization-error.cy.ts +++ b/packages/driver/cypress/e2e/commands/prompt/prompt-initialization-error.cy.ts @@ -1,12 +1,41 @@ describe('src/cy/commands/prompt', () => { - it('errors if wait for ready does not return success', (done) => { + it('errors if wait for ready does not return success and error is ENOSPC', (done) => { const backendStub = cy.stub(Cypress, 'backend').log(false) + const error = new Error(`no space left on device, open 'bundle.tar`) + + error.name = 'ENOSPC' + + backendStub.callThrough() + backendStub.withArgs('wait:for:cy:prompt:ready').resolves({ success: false, error }) + + cy.on('fail', (err) => { + expect(err.message).to.include('Failed to download cy.prompt Cloud code') + expect(err.message).to.include(`no space left on device, open 'bundle.tar`) + + done() + }) + + cy.visit('http://www.foobar.com:3500/fixtures/dom.html') + + cy['commandFns']['prompt'].__reset() + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag + cy.prompt('Hello, world!') + }) + + it('errors if wait for ready does not return success and error is ECONNREFUSED', (done) => { + const backendStub = cy.stub(Cypress, 'backend').log(false) + + const error = new Error(`'bundle.tar' timed out after 10000s`) + + error.name = 'ECONNREFUSED' + backendStub.callThrough() - backendStub.withArgs('wait:for:cy:prompt:ready').resolves({ success: false }) + backendStub.withArgs('wait:for:cy:prompt:ready').resolves({ success: false, error }) cy.on('fail', (err) => { - expect(err.message).to.include('error waiting for cy prompt bundle to be downloaded and ready') + expect(err.message).to.include('Timed out waiting for cy.prompt Cloud code:') + expect(err.message).to.include(`'bundle.tar' timed out after 10000s`) done() }) diff --git a/packages/driver/cypress/e2e/commands/prompt/prompt.cy.ts b/packages/driver/cypress/e2e/commands/prompt/prompt.cy.ts index 7f5e1cc0af1e..83ef624492ab 100644 --- a/packages/driver/cypress/e2e/commands/prompt/prompt.cy.ts +++ b/packages/driver/cypress/e2e/commands/prompt/prompt.cy.ts @@ -21,4 +21,22 @@ describe('src/cy/commands/prompt', () => { cy.prompt('Hello, world!') }) }) + + it('fails when trying to use cy.prompt in a browser that is not supported', (done) => { + if (Cypress.isBrowser({ family: 'chromium' })) { + done() + + return + } + + cy.on('fail', (err) => { + expect(err.message).to.include('`cy.prompt` is only supported in Chromium-based browsers.') + + done() + }) + + cy.visit('http://www.foobar.com:3500/fixtures/dom.html') + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag + cy.prompt('Hello, world!') + }) }) diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index b64245a4751b..3fe42b5213e3 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -1,6 +1,7 @@ import { init, loadRemote } from '@module-federation/runtime' import type { CypressInternal, CyPromptDriverDefaultShape } from './prompt-driver-types' import type Emitter from 'component-emitter' +import $errUtils from '../../../cypress/error_utils' interface CyPromptDriver { default: CyPromptDriverDefaultShape } @@ -15,9 +16,26 @@ declare global { let initializedModule: CyPromptDriverDefaultShape | null = null const initializeModule = async (Cypress: Cypress.Cypress): Promise => { // Wait for the cy prompt bundle to be downloaded and ready - const { success } = await Cypress.backend('wait:for:cy:prompt:ready') + const { success, error } = await Cypress.backend('wait:for:cy:prompt:ready') + + if (error) { + if (error.name === 'ENOSPC') { + $errUtils.throwErrByPath('prompt.promptDownloadError', { + args: { + error, + }, + }) + } else { + $errUtils.throwErrByPath('prompt.promptDownloadTimedOut', { + args: { + error, + }, + }) + } + } - if (!success) { + if (!success && !error) { + // TODO: Generic error message throw new Error('error waiting for cy prompt bundle to be downloaded and ready') } @@ -40,6 +58,7 @@ const initializeModule = async (Cypress: Cypress.Cypress): Promise('cy-prompt') if (!module?.default) { + // TODO: Generic error message throw new Error('error loading cy prompt driver') } @@ -75,10 +94,18 @@ export default (Commands, Cypress, cy) => { } const prompt = async (message: string, options: object = {}) => { + if (Cypress.testingType === 'component') { + $errUtils.throwErrByPath('prompt.promptTestingTypeError') + + return + } + if (!initializeCloudCyPromptPromise) { // TODO: (cy.prompt) We will look into supporting other browsers (and testing them) // as this is rolled out - throw new Error('`cy.prompt()` is not supported in this browser.') + $errUtils.throwErrByPath('prompt.promptSupportedBrowser') + + return } try { @@ -92,6 +119,8 @@ export default (Commands, Cypress, cy) => { return await cyPrompt(message, options) } catch (error) { + // TODO: Check error that the user is logged in / record key + // TODO: handle this better throw new Error(`CyPromptDriver not found: ${error}`) } diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index a82d7a419084..9287733b8cd7 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -1322,6 +1322,41 @@ export default { `, }, + prompt: { + promptDownloadError (obj) { + return { + message: stripIndent`\ + Failed to download cy.prompt Cloud code: + + - ${obj.error.code}: ${obj.error.message} + + Check your network connection and file settings to ensure download is not interrupted. + `, + docsUrl: 'https://on.cypress.io/prompt-download-error', + } + }, + promptDownloadTimedOut (obj) { + return { + message: stripIndent`\ + Timed out waiting for cy.prompt Cloud code: + + - ${obj.error.code}: ${obj.error.message} + + Check your network connection and system configuration. + `, + docsUrl: 'https://on.cypress.io/prompt-download-error', + } + }, + promptSupportedBrowser: stripIndent`\ + \`cy.prompt\` is only supported in Chromium-based browsers. + + Use Chrome, Electron, Chromium, or Chrome for Testing. + `, + promptTestingTypeError: stripIndent`\ + \`cy.prompt\` is currently only supported in end-to-end tests. + `, + }, + proxy: { js_rewriting_failed: stripIndent`\ An error occurred in the Cypress proxy layer while rewriting your source code. This is a bug in Cypress. Open an issue if you see this message. diff --git a/packages/driver/types/internal-types-lite.d.ts b/packages/driver/types/internal-types-lite.d.ts index 0c1a809653ba..fdc13e96cb77 100644 --- a/packages/driver/types/internal-types-lite.d.ts +++ b/packages/driver/types/internal-types-lite.d.ts @@ -42,7 +42,7 @@ declare namespace Cypress { (task: 'protocol:test:before:after:run:async', attributes: any, options: any): Promise (task: 'protocol:url:changed', input: any): Promise (task: 'protocol:page:loading', input: any): Promise - (task: 'wait:for:cy:prompt:ready'): Promise<{ success: boolean }> + (task: 'wait:for:cy:prompt:ready'): Promise<{ success: boolean, error?: Error }> } interface Devices { diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 22ae9a6afab8..6aad10dc4f27 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -18,7 +18,10 @@ const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') export class CyPromptLifecycleManager { private static hashLoadingMap: Map> = new Map() private static watcher: chokidar.FSWatcher | null = null - private cyPromptManagerPromise?: Promise + private cyPromptManagerPromise?: Promise<{ + cyPromptManager?: CyPromptManager + error?: Error + }> private cyPromptManager?: CyPromptManager private listeners: ((cyPromptManager: CyPromptManager) => void)[] = [] @@ -72,7 +75,7 @@ export class CyPromptLifecycleManager { // Clean up any registered listeners this.listeners = [] - return null + return { error } }) this.cyPromptManagerPromise = cyPromptManagerPromise @@ -99,7 +102,7 @@ export class CyPromptLifecycleManager { }: { projectId?: string cloudDataSource: CloudDataSource - }): Promise { + }): Promise<{ cyPromptManager?: CyPromptManager, error?: Error }> { let cyPromptHash: string let cyPromptPath: string @@ -155,7 +158,7 @@ export class CyPromptLifecycleManager { this.cyPromptManager = cyPromptManager this.callRegisteredListeners() - return cyPromptManager + return { cyPromptManager } } private callRegisteredListeners () { @@ -204,7 +207,10 @@ export class CyPromptLifecycleManager { }).catch((error) => { debug('Error during reload of cy prompt manager: %o', error) - return null + return { + cyPromptManager: undefined, + error: new Error('Error during reload of cy prompt manager'), + } }) }) } diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index 810a6ab643b3..1f466c9b0618 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -541,10 +541,13 @@ export class SocketBase { return options.closeExtraTargets() case 'wait:for:cy:prompt:ready': return getCtx().coreData.cyPromptLifecycleManager?.getCyPrompt().then(async (cyPrompt) => { - await options.onCyPromptReady(cyPrompt) + if (cyPrompt.cyPromptManager) { + await options.onCyPromptReady(cyPrompt.cyPromptManager) + } return { - success: cyPrompt && cyPrompt.status === 'INITIALIZED', + success: cyPrompt.cyPromptManager && cyPrompt.cyPromptManager.status === 'INITIALIZED', + error: cyPrompt.error ? errors.cloneErr(cyPrompt.error) : undefined, } }) default: diff --git a/packages/server/test/unit/socket_spec.js b/packages/server/test/unit/socket_spec.js index 4a1104cf7036..8bf47743746e 100644 --- a/packages/server/test/unit/socket_spec.js +++ b/packages/server/test/unit/socket_spec.js @@ -551,7 +551,10 @@ describe('lib/socket', () => { context('on(backend:request, wait:for:cy:prompt:ready)', () => { it('awaits cy prompt ready and returns true if cy prompt is ready', function (done) { const mockCyPrompt = { - status: 'INITIALIZED', + cyPromptManager: { + status: 'INITIALIZED', + }, + error: undefined, } ctx.coreData.cyPromptLifecycleManager.getCyPrompt.resolves(mockCyPrompt) @@ -559,7 +562,7 @@ describe('lib/socket', () => { return this.client.emit('backend:request', 'wait:for:cy:prompt:ready', (resp) => { expect(resp.response).to.deep.eq({ success: true }) - expect(this.options.onCyPromptReady).to.be.calledWith(mockCyPrompt) + expect(this.options.onCyPromptReady).to.be.calledWith(mockCyPrompt.cyPromptManager) return done() }) @@ -567,7 +570,10 @@ describe('lib/socket', () => { it('awaits cy prompt ready and returns false if cy prompt is not ready', function (done) { const mockCyPrompt = { - status: 'NOT_INITIALIZED', + cyPromptManager: { + status: 'NOT_INITIALIZED', + }, + error: undefined, } ctx.coreData.cyPromptLifecycleManager.getCyPrompt.resolves(mockCyPrompt) @@ -578,6 +584,23 @@ describe('lib/socket', () => { return done() }) }) + + it('awaits cy prompt ready and returns error if cy prompt error is thrown', function (done) { + const mockCyPrompt = { + cyPromptManager: undefined, + error: new Error('not loaded'), + } + + ctx.coreData.cyPromptLifecycleManager.getCyPrompt.resolves(mockCyPrompt) + + return this.client.emit('backend:request', 'wait:for:cy:prompt:ready', (resp) => { + expect(resp.response).to.deep.eq({ + error: errors.cloneErr(mockCyPrompt.error), + }) + + return done() + }) + }) }) context('on(save:app:state)', () => { diff --git a/packages/types/src/cy-prompt/index.ts b/packages/types/src/cy-prompt/index.ts index f919c2f2f219..39254bdd9bb3 100644 --- a/packages/types/src/cy-prompt/index.ts +++ b/packages/types/src/cy-prompt/index.ts @@ -11,6 +11,9 @@ export interface CyPromptManagerShape extends CyPromptServerShape { } export interface CyPromptLifecycleManagerShape { - getCyPrompt: () => Promise + getCyPrompt: () => Promise<{ + cyPromptManager?: CyPromptManagerShape + error?: Error + }> registerCyPromptReadyListener: (listener: (cyPromptManager: CyPromptManagerShape) => void) => void }