diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 6611f3ec08ef..65f51f96722e 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -1826,11 +1826,6 @@ declare namespace Cypress { */ prevUntil(element: E | JQuery, filter?: string, options?: Partial): Chainable> - /** - * TODO: add docs - */ - prompt(message: string, options?: Partial): Chainable - /** * Read a file and yield its contents. * @@ -3163,11 +3158,6 @@ declare namespace Cypress { * @default false */ experimentalStudio: boolean - /** - * Enables the prompt command feature. - * @default false - */ - experimentalPromptCommand: boolean /** * Adds support for testing in the WebKit browser engine used by Safari. See https://on.cypress.io/webkit-experiment for more information. * @default false diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 109f9f4aae19..3cecac6f6f1d 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -798,21 +798,7 @@ export class EventManager { }, ) - /** - * Call a backend request for the requesting spec bridge since we cannot have websockets in the spec bridges. - * Return it's response. - */ - Cypress.primaryOriginCommunicator.on('backend:request', async ({ args }, { source, responseEvent }) => { - let response - - try { - response = await Cypress.backend(...args) - } catch (error) { - response = { error } - } - - Cypress.primaryOriginCommunicator.toSource(source, responseEvent, response) - }) + Cypress.handlePrimaryOriginSocketEvent(Cypress, 'backend:request') /** * Call an automation request for the requesting spec bridge since we cannot have websockets in the spec bridges. diff --git a/packages/driver/cypress.config.ts b/packages/driver/cypress.config.ts index d7df5cbee75b..f67b3467effa 100644 --- a/packages/driver/cypress.config.ts +++ b/packages/driver/cypress.config.ts @@ -7,6 +7,7 @@ export const baseConfig: Cypress.ConfigOptions = { experimentalStudio: true, experimentalMemoryManagement: true, experimentalWebKitSupport: true, + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag experimentalPromptCommand: true, hosts: { 'foobar.com': '127.0.0.1', diff --git a/packages/driver/cypress/e2e/commands/prompt.cy.ts b/packages/driver/cypress/e2e/commands/prompt.cy.ts index 78ba0620c4bc..7f5e1cc0af1e 100644 --- a/packages/driver/cypress/e2e/commands/prompt.cy.ts +++ b/packages/driver/cypress/e2e/commands/prompt.cy.ts @@ -7,10 +7,18 @@ describe('src/cy/commands/prompt', () => { return } - cy.visit('/fixtures/dom.html') + cy.visit('http://www.foobar.com:3500/fixtures/dom.html') // TODO: add more tests when cy.prompt is built out, but for now this just // verifies that the command executes without throwing an error + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag cy.prompt('Hello, world!') + + cy.visit('http://www.barbaz.com:3500/fixtures/dom.html') + + cy.origin('http://www.barbaz.com:3500', () => { + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag + cy.prompt('Hello, world!') + }) }) }) diff --git a/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts b/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts index 2bdc0fbdb6b3..f1d62636dc91 100644 --- a/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts +++ b/packages/driver/cypress/e2e/e2e/origin/patches.cy.ts @@ -63,7 +63,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout describe('from the AUT', () => { beforeEach(() => { cy.intercept('/test-request').as('testRequest') - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests"]').click() @@ -87,7 +87,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout cy.wait('@testRequest') cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request', resourceType: 'fetch', credentialStatus: assertCredentialStatus, @@ -107,7 +107,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout cy.wait('@testRequest') cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request', resourceType: 'fetch', credentialStatus: assertCredentialStatus, @@ -127,7 +127,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout cy.wait('@testRequest') cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request', resourceType: 'fetch', credentialStatus: assertCredentialStatus, @@ -152,7 +152,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout ) cy.then(() => { - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).not.to.have.been.calledWithMatch('backend:request', 'request:sent:with:credentials') }) }) @@ -171,7 +171,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout ) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://app.foobar.com:3500/test-request', resourceType: 'fetch', credentialStatus: 'include', @@ -184,7 +184,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout describe('from the spec bridge', () => { beforeEach(() => { cy.intercept('/test-request').as('testRequest') - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests"]').click() @@ -216,7 +216,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request-credentials', resourceType: 'fetch', credentialStatus: assertCredentialStatus, @@ -246,7 +246,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request-credentials', resourceType: 'fetch', credentialStatus: assertCredentialStatus, @@ -274,7 +274,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request-credentials', resourceType: 'fetch', credentialStatus: assertCredentialStatus, @@ -297,7 +297,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).not.to.have.been.calledWithMatch('backend:request', 'request:sent:with:credentials') }) }) }) @@ -326,7 +326,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://app.foobar.com:3500/test-request', resourceType: 'fetch', credentialStatus: 'include', @@ -339,13 +339,13 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout // manually remove the spec bridge iframe to ensure Cypress.state('window') is not already set window.top?.document.getElementById('Spec\ Bridge:\ foobar.com')?.remove() - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests-onload"]').click() cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://localhost:3500/foo.bar.baz.json', resourceType: 'fetch', credentialStatus: 'same-origin', @@ -354,7 +354,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) it('does not patch fetch in the spec window or the AUT if the AUT is on the primary', () => { - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('fixtures/xhr-fetch-requests.html') cy.window().then((win) => { @@ -371,14 +371,14 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout cy.then(async () => { await fetch('/test-request') - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).not.to.have.been.calledWithMatch('request:sent:with:credentials') }) // expect AUT to NOT be patched in primary cy.window().then(async (win) => { await win.fetch('/test-request') - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).not.to.have.been.calledWithMatch('request:sent:with:credentials') }) }) }) @@ -387,7 +387,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout describe('from the AUT', () => { beforeEach(() => { cy.intercept('/test-request').as('testRequest') - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests"]').click() @@ -409,7 +409,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout cy.wait('@testRequest') cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request', resourceType: 'xhr', credentialStatus: withCredentials, @@ -431,7 +431,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).to.have.been.calledWithMatch('backend:request', 'request:sent:with:credentials') }) }) }) @@ -449,7 +449,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://app.foobar.com:3500/test-request', resourceType: 'xhr', credentialStatus: true, @@ -461,9 +461,9 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout describe('from the spec bridge', () => { beforeEach(() => { cy.intercept('/test-request').as('testRequest') - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.origin('http://www.foobar.com:3500', () => { - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() }) cy.visit('/fixtures/primary-origin.html') @@ -501,7 +501,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://www.foobar.com:3500/test-request-credentials', resourceType: 'xhr', credentialStatus: withCredentials, @@ -523,7 +523,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).to.have.been.calledWithMatch('backend:request', 'request:sent:with:credentials') }) }) @@ -553,7 +553,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://app.foobar.com:3500/test-request', resourceType: 'xhr', credentialStatus: true, @@ -567,13 +567,13 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout // manually remove the spec bridge iframe to ensure Cypress.state('window') is not already set window.top?.document.getElementById('Spec\ Bridge:\ foobar.com')?.remove() - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('/fixtures/primary-origin.html') cy.get('a[data-cy="xhr-fetch-requests-onload"]').click() cy.then(() => { - expect(Cypress.backend).to.have.been.calledWith('request:sent:with:credentials', { + expect(Cypress.backendRequestHandler).to.have.been.calledWith('backend:request', 'request:sent:with:credentials', { url: 'http://localhost:3500/foo.bar.baz.json', resourceType: 'xhr', credentialStatus: false, @@ -582,7 +582,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout }) it('does not patch xmlHttpRequest in the spec window or the AUT if the AUT is on the primary', () => { - cy.stub(Cypress, 'backend').log(false).callThrough() + cy.stub(Cypress, 'backendRequestHandler').log(false).callThrough() cy.visit('fixtures/xhr-fetch-requests.html') cy.window().then((win) => { @@ -614,7 +614,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout xhr.send() }) - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).not.to.have.been.calledWithMatch('backend:request', 'request:sent:with:credentials') }) // expect AUT to NOT be patched in primary @@ -636,7 +636,7 @@ describe('src/cross-origin/patches', { browser: '!webkit', defaultCommandTimeout xhr.send() }) - expect(Cypress.backend).not.to.have.been.calledWithMatch('request:sent:with:credentials') + expect(Cypress.backendRequestHandler).not.to.have.been.calledWithMatch('backend:request', 'request:sent:with:credentials') }) }) }) diff --git a/packages/driver/package.json b/packages/driver/package.json index 74e6d84bb986..1f764d7701cd 100644 --- a/packages/driver/package.json +++ b/packages/driver/package.json @@ -53,6 +53,7 @@ "chai-subset": "1.6.0", "clone": "2.1.2", "common-tags": "1.8.0", + "component-emitter": "1.3.0", "compression": "1.8.0", "cookie-parser": "1.4.5", "core-js-pure": "3.21.0", diff --git a/packages/driver/src/cross-origin/communicator.ts b/packages/driver/src/cross-origin/communicator.ts index 550a6955f79c..32564b8c2234 100644 --- a/packages/driver/src/cross-origin/communicator.ts +++ b/packages/driver/src/cross-origin/communicator.ts @@ -336,7 +336,7 @@ export class SpecBridgeCommunicator extends EventEmitter { }: { event: string data?: Cypress.ObjectLike - options: {syncGlobals: boolean} + options?: {syncGlobals: boolean} timeout: number }) { return new Promise((resolve, reject) => { diff --git a/packages/driver/src/cross-origin/cypress.ts b/packages/driver/src/cross-origin/cypress.ts index c6af33aad03c..64a0aca56c9f 100644 --- a/packages/driver/src/cross-origin/cypress.ts +++ b/packages/driver/src/cross-origin/cypress.ts @@ -14,7 +14,7 @@ import { bindToListeners } from '../cy/listeners' import { handleOriginFn } from './origin_fn' import { FINAL_SNAPSHOT_NAME } from '../cy/snapshots' import { handleLogs } from './events/logs' -import { handleSocketEvents } from './events/socket' +import { handleCrossOriginSocketEvent, handleDefaultCrossOriginSocketEvents } from './events/socket' import { handleSpecWindowEvents } from './events/spec_window' import { handleErrorEvent } from './events/errors' import { handleScreenshots } from './events/screenshots' @@ -120,6 +120,12 @@ const setup = ({ cypressConfig, env, isProtocolEnabled }: { cypressConfig: Cypre const { state, config } = Cypress + // These need to happen before the commands are created so that commands can + // use things like Cypress.backend during their creation. + handleDefaultCrossOriginSocketEvents(Cypress) + + Cypress.handleCrossOriginSocketEvent = handleCrossOriginSocketEvent + // @ts-ignore Cypress.Commands = $Commands.create(Cypress, cy, state, config) // @ts-ignore @@ -141,7 +147,6 @@ const setup = ({ cypressConfig, env, isProtocolEnabled }: { cypressConfig: Cypre handleOriginFn(Cypress, cy) handleLogs(Cypress) - handleSocketEvents(Cypress) handleSpecWindowEvents(cy) handleMiscEvents(Cypress, cy) handleScreenshots(Cypress) diff --git a/packages/driver/src/cross-origin/events/socket.ts b/packages/driver/src/cross-origin/events/socket.ts index a159e1ea5554..3cc996a71e54 100644 --- a/packages/driver/src/cross-origin/events/socket.ts +++ b/packages/driver/src/cross-origin/events/socket.ts @@ -1,20 +1,24 @@ -export const handleSocketEvents = (Cypress) => { - const onRequest = async (event, args) => { - // The last argument is the callback, pop that off before messaging primary and call it with the response. - const callback = args.pop() - const response = await Cypress.specBridgeCommunicator.toPrimaryPromise({ - event, - data: { args }, - timeout: Cypress.config().defaultCommandTimeout, - }) +const onRequest = async (event, args) => { + // The last argument is the callback, pop that off before messaging primary and call it with the response. + const callback = args.pop() + const response = await Cypress.specBridgeCommunicator.toPrimaryPromise<{ error?: string, response?: any }>({ + event, + data: { args }, + timeout: Cypress.config().defaultCommandTimeout, + }) - if (response && response.error) { - return callback({ error: response.error }) - } - - callback({ response }) + if (response && response.error) { + return callback({ error: response.error }) } - Cypress.on('backend:request', (...args) => onRequest('backend:request', args)) - Cypress.on('automation:request', (...args) => onRequest('automation:request', args)) + callback({ response }) +} + +export const handleCrossOriginSocketEvent = (Cypress, eventName: string) => { + Cypress.on(eventName, (...args) => onRequest(eventName, args)) +} + +export const handleDefaultCrossOriginSocketEvents = (Cypress) => { + handleCrossOriginSocketEvent(Cypress, 'backend:request') + handleCrossOriginSocketEvent(Cypress, 'automation:request') } diff --git a/packages/driver/src/cy/commands/prompt/index.ts b/packages/driver/src/cy/commands/prompt/index.ts index 96d00215a12e..65c52ec93acb 100644 --- a/packages/driver/src/cy/commands/prompt/index.ts +++ b/packages/driver/src/cy/commands/prompt/index.ts @@ -1,15 +1,24 @@ import { init, loadRemote } from '@module-federation/runtime' -import type{ CyPromptDriverDefaultShape } from './prompt-driver-types' +import type { CypressInternal, CyPromptDriverDefaultShape } from './prompt-driver-types' +import type Emitter from 'component-emitter' interface CyPromptDriver { default: CyPromptDriverDefaultShape } -let initializedCyPrompt: CyPromptDriverDefaultShape | null = null -const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise => { +declare global { + interface Window { + getEventManager?: () => { + ws: Emitter + } + } +} + +let initializedModule: CyPromptDriverDefaultShape | null = null +const initializeModule = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']): Promise => { // Wait for the cy prompt bundle to be downloaded and ready const { success } = await Cypress.backend('wait:for:cy:prompt:ready') if (!success) { - throw new Error('CyPromptDriver not found') + throw new Error('error waiting for cy prompt bundle to be downloaded and ready') } // Once the cy prompt bundle is downloaded and ready, @@ -31,35 +40,48 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise('cy-prompt') if (!module?.default) { - throw new Error('CyPromptDriver not found') + throw new Error('error loading cy prompt driver') } - initializedCyPrompt = module.default + initializedModule = module.default + + return initializedModule +} + +const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']) => { + let cloudModule = initializedModule - return module.default + if (!cloudModule) { + cloudModule = await initializeModule(Cypress, cy) + } + + return cloudModule.createCyPrompt({ + Cypress: Cypress as CypressInternal, + cy, + eventManager: window.getEventManager ? window.getEventManager() : undefined, + }) } export default (Commands, Cypress, cy) => { if (Cypress.config('experimentalPromptCommand')) { + let initializeCloudCyPromptPromise: Promise> | undefined + + if (Cypress.browser.family === 'chromium' || Cypress.browser.name === 'electron') { + initializeCloudCyPromptPromise = initializeCloudCyPrompt(Cypress, cy) + } + Commands.addAll({ - async prompt (message: string) { - if (Cypress.browser.family !== 'chromium' && Cypress.browser.name !== 'electron') { + async prompt (message: string, options: object = {}) { + if (!initializeCloudCyPromptPromise) { // TODO: (cy.prompt) We will look into supporting other browsers (and testing them) // as this is rolled out throw new Error('`cy.prompt()` is not supported in this browser.') } try { - let cloud = initializedCyPrompt - - // If the cy prompt driver is not initialized, - // we need to wait for it to be initialized - // before using it - if (!cloud) { - cloud = await initializeCloudCyPrompt(Cypress) - } + const cyPrompt = await initializeCloudCyPromptPromise - return await cloud.cyPrompt(Cypress, message) + return await cyPrompt(message, options) } catch (error) { // TODO: handle this better throw new Error(`CyPromptDriver not found: ${error}`) diff --git a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts index 3f742f4b9678..2b57f796ce6b 100644 --- a/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts +++ b/packages/driver/src/cy/commands/prompt/prompt-driver-types.ts @@ -1,7 +1,55 @@ -export interface CypressInternal extends Cypress.Cypress { - backend: (eventName: string, ...args: any[]) => Promise +import type Emitter from 'component-emitter' + +interface InternalActions extends Cypress.Actions { + ( + eventName: 'prompt:backend:request', + listener: (...args: any[]) => void + ): Cypress.Cypress +} + +export interface CypressInternalBase extends Cypress.Cypress { + backendRequestHandler: ( + backendRequestNamespace: string, + eventName: string, + ...args: any[] + ) => Promise + on: InternalActions +} + +interface CrossOriginCypressInternal extends CypressInternalBase { + isCrossOriginSpecBridge: true + handleCrossOriginSocketEvent: ( + Cypress: CypressInternal, + eventName: string + ) => void +} + +interface SameOriginCypressInternal extends CypressInternalBase { + isCrossOriginSpecBridge: false + handlePrimaryOriginSocketEvent: ( + Cypress: CypressInternal, + eventName: string + ) => void +} + +export type CypressInternal = + | CrossOriginCypressInternal + | SameOriginCypressInternal + +export interface CyPromptEventManager { + ws: Emitter +} + +export interface CyPromptOptions { + Cypress: CypressInternal + cy: Cypress.cy + // Note that the eventManager is present in same origin AUTs, but not cross origin + // so we need to check for it's presence before using it + eventManager?: CyPromptEventManager } export interface CyPromptDriverDefaultShape { - cyPrompt: (Cypress: CypressInternal, text: string) => Promise + createCyPrompt: ( + options: CyPromptOptions + ) => (text: string, commandOptions?: object) => Promise } diff --git a/packages/driver/src/cypress.ts b/packages/driver/src/cypress.ts index c8657904fae7..dd7e47d887d9 100644 --- a/packages/driver/src/cypress.ts +++ b/packages/driver/src/cypress.ts @@ -88,6 +88,31 @@ interface AutomationError extends Error { // Are we running Cypress in Cypress? (Used for E2E Testing for Cypress in Cypress only) const isCypressInCypress = document.defaultView !== top +const handlePrimaryOriginSocketEvent = (Cypress, backendRequestNamespace: string) => { + Cypress.primaryOriginCommunicator.on( + backendRequestNamespace, + async ({ args: [eventName, ...args] }: { args: [string, any[]] }, { source, responseEvent }) => { + let response + + try { + response = await Cypress.backendRequestHandler( + backendRequestNamespace, + eventName, + ...args, + ) + } catch (error) { + response = { error } + } + + Cypress.primaryOriginCommunicator.toSource( + source, + responseEvent, + response, + ) + }, + ) +} + class $Cypress { cy: any chai: any @@ -161,6 +186,8 @@ class $Cypress { sinon = sinon lolex = fakeTimers + handlePrimaryOriginSocketEvent = handlePrimaryOriginSocketEvent + static $: any static utils: any @@ -764,7 +791,7 @@ class $Cypress { } } - backend (eventName, ...args) { + backendRequestHandler (backendRequestNamespace: string, eventName, ...args) { return new Promise((resolve, reject) => { const fn = function (reply) { const e = reply.error @@ -787,10 +814,14 @@ class $Cypress { return resolve(reply.response) } - return this.emit('backend:request', eventName, ...args, fn) + return this.emit(backendRequestNamespace, eventName, ...args, fn) }) } + backend (eventName, ...args) { + return this.backendRequestHandler('backend:request', eventName, ...args) + } + automation (eventName, ...args) { // wrap action in promise return new Promise((resolve, reject) => { diff --git a/packages/driver/types/internal-types-lite.d.ts b/packages/driver/types/internal-types-lite.d.ts index c41aef97cf6a..0c1a809653ba 100644 --- a/packages/driver/types/internal-types-lite.d.ts +++ b/packages/driver/types/internal-types-lite.d.ts @@ -11,7 +11,9 @@ declare namespace Cypress { primaryOriginCommunicator: import('eventemitter2').EventEmitter2 & { toSpecBridge: (origin: string, event: string, data?: any, responseEvent?: string) => void userInvocationStack?: string + toSource: (source: string, responseEvent: string, response: any) => void } + backendRequestHandler: (backendRequestNamespace: string, emitter: Emitter, eventName: string, ...args: any[]) => Promise } interface Actions { diff --git a/packages/driver/types/internal-types.d.ts b/packages/driver/types/internal-types.d.ts index bc632521e2b2..4794fca9cfbe 100644 --- a/packages/driver/types/internal-types.d.ts +++ b/packages/driver/types/internal-types.d.ts @@ -53,6 +53,7 @@ declare namespace Cypress { utils: CypressUtils events: Events specBridgeCommunicator: import('../src/cross-origin/communicator').SpecBridgeCommunicator + handleCrossOriginSocketEvent?: typeof handleCrossOriginSocketEvent mocha: $Mocha configure: (config: Cypress.ObjectLike) => void isCrossOriginSpecBridge: boolean diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts index c3d08058b4d6..aa9bb17b0cf8 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptManager.ts @@ -2,6 +2,7 @@ import type { CyPromptManagerShape, CyPromptStatus, CyPromptServerDefaultShape, import type { Router } from 'express' import Debug from 'debug' import { requireScript } from '../require_script' +import type { Socket } from 'socket.io' interface CyPromptServer { default: CyPromptServerDefaultShape } @@ -38,9 +39,9 @@ export class CyPromptManager implements CyPromptManagerShape { } } - async handleBackendRequest (eventName: string, ...args: any[]): Promise { + addSocketListeners (socket: Socket): void { if (this._cyPromptServer) { - return this.invokeAsync('handleBackendRequest', { isEssential: false }, eventName, ...args) + this.invokeSync('addSocketListeners', { isEssential: true }, socket) } } @@ -89,6 +90,7 @@ export class CyPromptManager implements CyPromptManagerShape { } try { + // @ts-expect-error - TS not associating the method & args properly, even though we know it's correct return await this._cyPromptServer[method].apply(this._cyPromptServer, args) } catch (error: unknown) { let actualError: Error diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 222201d23c59..29db121758a6 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -158,6 +158,7 @@ export class ProjectBase extends EE { process.chdir(this.projectRoot) this._server = new ServerBase(cfg) + // @ts-expect-error - this will not error when we actually release the experimentalPromptCommand flag if (cfg.experimentalPromptCommand) { const cyPromptLifecycleManager = new CyPromptLifecycleManager() diff --git a/packages/server/lib/socket-base.ts b/packages/server/lib/socket-base.ts index aa254b2e4285..810a6ab643b3 100644 --- a/packages/server/lib/socket-base.ts +++ b/packages/server/lib/socket-base.ts @@ -23,7 +23,7 @@ import type { Automation } from './automation' // eslint-disable-next-line no-duplicate-imports import type { Socket } from '@packages/socket' -import type { RunState, CachedTestState, ProtocolManagerShape, AutomationCommands, CyPromptManagerShape } from '@packages/types' +import type { RunState, CachedTestState, ProtocolManagerShape, AutomationCommands } from '@packages/types' import memory from './browsers/memory' import { privilegedCommandsManager } from './privileged-commands/privileged-commands-manager' @@ -412,6 +412,10 @@ export class SocketBase { studio.addSocketListeners(socket) }) + getCtx().coreData.cyPromptLifecycleManager?.registerCyPromptReadyListener((cyPrompt) => { + cyPrompt.addSocketListeners(socket) + }) + socket.on('studio:init', async (cb) => { try { const { canAccessStudioAI } = await options.onStudioInit() @@ -449,12 +453,6 @@ export class SocketBase { } }) - let cyPrompt: CyPromptManagerShape | undefined - - getCtx().coreData.cyPromptLifecycleManager?.registerCyPromptReadyListener((cp) => { - cyPrompt = cp - }) - socket.on('backend:request', (eventName: string, ...args) => { const userAgent = socket.request?.headers['user-agent'] || getCtx().coreData.app.browserUserAgent @@ -464,10 +462,6 @@ export class SocketBase { debug('backend:request %o', { eventName, args }) const backendRequest = () => { - if (eventName.startsWith('cy:prompt:')) { - return cyPrompt?.handleBackendRequest(eventName, ...args) - } - switch (eventName) { case 'preserve:run:state': runState = args[0] diff --git a/packages/server/test/support/fixtures/cloud/cy-prompt/test-cy-prompt.ts b/packages/server/test/support/fixtures/cloud/cy-prompt/test-cy-prompt.ts index cde602ab31ff..0eea3553260d 100644 --- a/packages/server/test/support/fixtures/cloud/cy-prompt/test-cy-prompt.ts +++ b/packages/server/test/support/fixtures/cloud/cy-prompt/test-cy-prompt.ts @@ -2,14 +2,15 @@ import type { CyPromptServerShape, CyPromptServerDefaultShape, CyPromptCDPClient } from '@packages/types' import type { Router } from 'express' +import type { Socket } from 'socket.io' class CyPromptServer implements CyPromptServerShape { initializeRoutes (router: Router): void { // This is a test implementation that does nothing } - handleBackendRequest (eventName: string, ...args: any[]): Promise { - return Promise.resolve() + addSocketListeners (socket: Socket): void { + // This is a test implementation that does nothing } connectToBrowser (criClient: CyPromptCDPClient): void { diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts index bbf19ce91af1..a4009743292b 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptManager_spec.ts @@ -57,18 +57,6 @@ describe('lib/cloud/cy-prompt', () => { }) }) - describe('asynchronous method invocation', () => { - it('reports an error when a asynchronous method fails', async () => { - const error = new Error('foo') - - sinon.stub(cyPrompt, 'handleBackendRequest').throws(error) - - await cyPromptManager.handleBackendRequest('cy:prompt:start', {} as any) - - // TODO: (cy.prompt) test that the error is reported - }) - }) - describe('initializeRoutes', () => { it('initializes routes', () => { sinon.stub(cyPrompt, 'initializeRoutes') @@ -80,25 +68,14 @@ describe('lib/cloud/cy-prompt', () => { }) }) - describe('handleBackendRequest', () => { - it('calls handleBackendRequest on the cy prompt server', () => { - sinon.stub(cyPrompt, 'handleBackendRequest') + describe('addSocketListeners', () => { + it('adds socket listeners', () => { + sinon.stub(cyPrompt, 'addSocketListeners') + const mockSocket = sinon.stub() - cyPromptManager.handleBackendRequest('cy:prompt:start', {} as any) + cyPromptManager.addSocketListeners(mockSocket) - expect(cyPrompt.handleBackendRequest).to.be.calledWith('cy:prompt:start', {} as any) - }) - - it('does not call handleBackendRequest when cy prompt server is not defined', () => { - // Set _cyPromptServer to undefined - (cyPromptManager as any)._cyPromptServer = undefined - - // Create a spy on invokeSync to verify it's not called - const invokeSyncSpy = sinon.spy(cyPromptManager, 'invokeSync') - - cyPromptManager.handleBackendRequest('cy:prompt:start', {} as any) - - expect(invokeSyncSpy).to.not.be.called + expect(cyPrompt.addSocketListeners).to.be.calledWith(mockSocket) }) }) diff --git a/packages/server/test/unit/socket_spec.js b/packages/server/test/unit/socket_spec.js index ec2d3878202b..4a1104cf7036 100644 --- a/packages/server/test/unit/socket_spec.js +++ b/packages/server/test/unit/socket_spec.js @@ -90,7 +90,7 @@ describe('lib/socket', () => { // Create a mock cy prompt object with handleBackendRequest method const mockCyPrompt = { - handleBackendRequest: sinon.stub().resolves({ foo: 'bar' }), + addSocketListeners: sinon.stub(), status: 'INITIALIZED', } @@ -580,30 +580,6 @@ describe('lib/socket', () => { }) }) - context('on(backend:request, cy:prompt)', () => { - it('calls handleBackendRequest with the correct arguments', async function () { - // Verify that registerCyPromptReadyListener was called - expect(ctx.coreData.cyPromptLifecycleManager.registerCyPromptReadyListener).to.be.called - - // Check that the callback was called with the mock cy prompt object - const registerCyPromptReadyListenerCallback = ctx.coreData.cyPromptLifecycleManager.registerCyPromptReadyListener.firstCall.args[0] - - // Verify the mock cy prompt's handleBackendRequest was called by the callback - const mockCyPrompt = { handleBackendRequest: sinon.stub().resolves({ foo: 'bar' }) } - - await registerCyPromptReadyListenerCallback(mockCyPrompt) - - await new Promise((resolve) => { - this.client.emit('backend:request', 'cy:prompt:init', 'foo', (resp) => { - expect(resp.response).to.deep.eq({ foo: 'bar' }) - expect(mockCyPrompt.handleBackendRequest).to.be.calledWith('cy:prompt:init', 'foo') - - resolve() - }) - }) - }) - }) - context('on(save:app:state)', () => { it('calls onSavedStateChanged with the state', function (done) { return this.client.emit('save:app:state', { reporterWidth: 500 }, () => { @@ -687,6 +663,22 @@ describe('lib/socket', () => { }) }) + context('cy.prompt.addSocketListeners', () => { + it('calls addSocketListeners on cy prompt when socket connects', function () { + // Verify that registerCyPromptReadyListener was called + expect(ctx.coreData.cyPromptLifecycleManager.registerCyPromptReadyListener).to.be.called + + const registerCyPromptReadyListenerCallback = ctx.coreData.cyPromptLifecycleManager.registerCyPromptReadyListener.firstCall.args[0] + + expect(registerCyPromptReadyListenerCallback).to.be.a('function') + + const mockCyPrompt = { addSocketListeners: sinon.stub() } + + registerCyPromptReadyListenerCallback(mockCyPrompt) + expect(mockCyPrompt.addSocketListeners).to.be.called + }) + }) + context('#isRunnerSocketConnected', function () { it('returns false when runner is not connected', function () { expect(this.socket.isRunnerSocketConnected()).to.eq(false) diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index 84b11e45aed9..0c5018bbb3d5 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -30,7 +30,7 @@ export interface FullConfig extends Partial - & Pick // TODO: Figure out how to type this better. + & Pick // TODO: Figure out how to type this better. export interface SettingsOptions { testingType?: 'component' |'e2e' diff --git a/packages/types/src/cy-prompt/cy-prompt-server-types.ts b/packages/types/src/cy-prompt/cy-prompt-server-types.ts index be0df63c42ad..b212f1910664 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -1,12 +1,9 @@ -// Note: This file is owned by the cloud delivered -// cy prompt bundle. It is downloaded and copied here. -// It should not be modified directly here. - /// import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping.d' import type { Router } from 'express' import type { AxiosInstance } from 'axios' +import type { Socket } from 'socket.io' export type CyPromptCommands = ProtocolMapping.Commands @@ -56,7 +53,7 @@ export interface CyPromptCDPClient { export interface CyPromptServerShape { initializeRoutes(router: Router): void - handleBackendRequest: (eventName: string, ...args: any[]) => Promise + addSocketListeners(socket: Socket): void connectToBrowser: (cdpClient: CyPromptCDPClient) => void }