Skip to content

chore: create infrastructure to support backend function in cy.prompt #31803

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Jun 3, 2025
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions cli/types/cypress.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1826,11 +1826,6 @@ declare namespace Cypress {
*/
prevUntil<E extends Node = HTMLElement>(element: E | JQuery<E>, filter?: string, options?: Partial<Loggable & Timeoutable>): Chainable<JQuery<E>>

/**
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also took this opportunity to remove these types. We won't officially add them until we are ready to release the experiment

* TODO: add docs
*/
prompt(message: string, options?: Partial<Loggable & Timeoutable>): Chainable<Subject>

/**
* Read a file and yield its contents.
*
Expand Down Expand Up @@ -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
Expand Down
16 changes: 1 addition & 15 deletions packages/app/src/runner/event-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/driver/cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 9 additions & 1 deletion packages/driver/cypress/e2e/commands/prompt.cy.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
describe('src/cy/commands/prompt', () => {
it('executes the prompt command', () => {
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!')
})
})
})
1 change: 1 addition & 0 deletions packages/driver/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/driver/src/cross-origin/communicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>((resolve, reject) => {
Expand Down
9 changes: 7 additions & 2 deletions packages/driver/src/cross-origin/cypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
36 changes: 20 additions & 16 deletions packages/driver/src/cross-origin/events/socket.ts
Original file line number Diff line number Diff line change
@@ -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')
}
52 changes: 35 additions & 17 deletions packages/driver/src/cy/commands/prompt/index.ts
Original file line number Diff line number Diff line change
@@ -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<CyPromptDriverDefaultShape> => {
declare global {
interface Window {
getEventManager?: () => {
ws: Emitter
}
}
}

let initializedModule: CyPromptDriverDefaultShape | null = null
const initializeModule = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']): Promise<CyPromptDriverDefaultShape> => {
// 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,
Expand All @@ -31,29 +40,38 @@ const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress): Promise<CyProm
const module = await loadRemote<CyPromptDriver>('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 module.default
return initializedModule
}

const initializeCloudCyPrompt = async (Cypress: Cypress.Cypress, cy: Cypress.Cypress['cy']) => {
let cloudModule = initializedModule

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')) {
const initializeCloudCyPromptPromise = initializeCloudCyPrompt(Cypress, cy)

Commands.addAll({
async prompt (message: string) {
async prompt (message: string, options: object = {}) {
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}`)
Expand Down
54 changes: 51 additions & 3 deletions packages/driver/src/cy/commands/prompt/prompt-driver-types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,55 @@
export interface CypressInternal extends Cypress.Cypress {
backend: (eventName: string, ...args: any[]) => Promise<any>
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<any>
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<void>
createCyPrompt: (
options: CyPromptOptions
) => (text: string, commandOptions?: object) => Promise<void>
}
34 changes: 32 additions & 2 deletions packages/driver/src/cypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,30 @@ 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, eventName: string) => {
Cypress.primaryOriginCommunicator.on(
eventName,
async ({ args }: { args: [string, any[]] }, { source, responseEvent }) => {
let response

try {
response = await Cypress.backendRequestHandler(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm missing something, but is this supposed to be Cypress.backend or is the backendRequestNamespace missing from this call?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this should be backendRequestNamespace not eventName. Will update

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The functionality is right, the name is confusing (wrong)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eventName,
...args,
)
} catch (error) {
response = { error }
}

Cypress.primaryOriginCommunicator.toSource(
source,
responseEvent,
response,
)
},
)
}

class $Cypress {
cy: any
chai: any
Expand Down Expand Up @@ -161,6 +185,8 @@ class $Cypress {
sinon = sinon
lolex = fakeTimers

handlePrimaryOriginSocketEvent = handlePrimaryOriginSocketEvent

static $: any
static utils: any

Expand Down Expand Up @@ -764,7 +790,7 @@ class $Cypress {
}
}

backend (eventName, ...args) {
backendRequestHandler (backendRequestNamespace: string, eventName, ...args) {
return new Promise((resolve, reject) => {
const fn = function (reply) {
const e = reply.error
Expand All @@ -787,10 +813,14 @@ class $Cypress {
return resolve(reply.response)
}

return this.emit('backend:request', eventName, ...args, fn)
return Cypress.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) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/driver/types/internal-types-lite.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>
}

interface Actions {
Expand Down
1 change: 1 addition & 0 deletions packages/driver/types/internal-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading