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 1 commit
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
22 changes: 15 additions & 7 deletions packages/app/src/runner/event-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ interface AddGlobalListenerOptions {
}

const driverToLocalAndReporterEvents = 'run:start run:end'.split(' ')
const driverToSocketEvents = 'backend:request automation:request mocha recorder:frame dev-server:on-spec-update'.split(' ')
const driverToSocketEvents = 'backend:request prompt:backend:request automation:request mocha recorder:frame dev-server:on-spec-update'.split(' ')
const driverToLocalEvents = 'viewport:changed config stop url:changed page:loading visit:failed visit:blank cypress:in:cypress:runner:event'.split(' ')
const socketRerunEvents = 'runner:restart watched:file:changed'.split(' ')
const socketToDriverEvents = 'net:stubbing:event request:event script:error cross:origin:cookies dev-server:on-spec-updated'.split(' ')
Expand Down Expand Up @@ -797,11 +797,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 }) => {
const baseBackendRequestHandler = async ({ args }, { source, responseEvent }) => {
let response

try {
Expand All @@ -811,7 +807,19 @@ export class EventManager {
}

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

/**
* 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', baseBackendRequestHandler)

/**
* 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('prompt:backend:request', baseBackendRequestHandler)

/**
* Call an automation request for the requesting spec bridge since we cannot have websockets in the spec bridges.
Expand Down
37 changes: 37 additions & 0 deletions packages/driver/cypress/e2e/cypress/cypress.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,43 @@ describe('driver/src/cypress/index', () => {
})
})

context('#promptBackend', () => {
it('sets __stackCleaned__ on errors', function () {
cy.stub(CypressInstance, 'emit')
.withArgs('prompt:backend:request')
.yieldsAsync({
error: {
name: 'Error',
message: 'msg',
stack: 'stack',
},
})

return CypressInstance.promptBackend('foo')
.catch((err) => {
expect(err.backend).to.be.true

expect(err.stack).not.to.include('From previous event')
})
})

// https://github.com/cypress-io/cypress/issues/4346
it('can complete if a circular reference is sent', () => {
const foo = {
bar: {},
}

foo.bar.baz = foo

return Cypress.promptBackend('foo', foo)
.then(() => {
throw new Error('should not reach')
}).catch((e) => {
expect(e.message).to.eq('You requested a backend event we cannot handle: foo')
})
})
})

context('.isCy', () => {
it('returns true on cy, cy chainable', () => {
expect(Cypress.isCy(cy)).to.be.true
Expand Down
1 change: 1 addition & 0 deletions packages/driver/src/cross-origin/events/socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ export const handleSocketEvents = (Cypress) => {
}

Cypress.on('backend:request', (...args) => onRequest('backend:request', args))
Cypress.on('prompt:backend:request', (...args) => onRequest('prompt:backend:request', args))
Cypress.on('automation:request', (...args) => onRequest('automation:request', args))
}
12 changes: 10 additions & 2 deletions packages/driver/src/cypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,7 +764,7 @@ class $Cypress {
}
}

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

return this.emit('backend:request', eventName, ...args, fn)
return this.emit(emitEventName, eventName, ...args, fn)
})
}

backend (eventName, ...args) {
return this.baseBackendRequestHandler('backend:request', eventName, ...args)
}

promptBackend (eventName, ...args) {
return this.baseBackendRequestHandler('prompt:backend:request', eventName, ...args)
Copy link
Member

Choose a reason for hiding this comment

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

I'm curious we'd want to introduce so many places in the app where this could diverge vs bundling this logic up in a cloud call that handles what to do as well as telling the app if it should continue with non-cloud logic

Cloud {
   backend (emitEventName: string, eventName, ...args)) {
      if (!cloudService || !cloudService.isSupportedEvent(emitEventName)) {
         return [true, null]
      }

       return [false, cloudService.handleEvent()]
   }
 
}

private baseBackendRequestHandler (emitEventName: string, eventName, ...args) {
    const [isAppBackendRequest, result] = await this.cloud.backend(.....) <-- initialized on start
    if (!isAppBackendRequest) {
       return result
    }

    return new Promise((resolve, reject) => {

    }
)


backend (eventName, ...args) {
    return this.baseBackendRequestHandler('backend:request', eventName, ...args)
  }

Copy link
Contributor

Choose a reason for hiding this comment

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

@emilyrohrbough makes a good point. Do we know if this is mostly for the experiment to iterate faster with plans to move it later to something a bit more stable

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is a good point and I've refactored it a bit to minimize what is necessary on the app providing hooks to be more flexible in the cloud

}

automation (eventName, ...args) {
// wrap action in promise
return new Promise((resolve, reject) => {
Expand Down
1 change: 1 addition & 0 deletions packages/driver/types/internal-types-lite.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ declare namespace Cypress {
(action: 'net:stubbing:event', frame: any)
(action: 'request:event', data: any)
(action: 'backend:request', fn: (...any) => void)
(action: 'prompt:backend:request', fn: (...any) => void)
(action: 'automation:request', fn: (...any) => void)
(action: 'viewport:changed', fn?: (viewport: { viewportWidth: string, viewportHeight: string }, callback: () => void) => void)
(action: 'before:screenshot', fn: (config: {}, fn: () => void) => void)
Expand Down
38 changes: 28 additions & 10 deletions packages/server/lib/socket-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,34 @@ export class SocketBase {
cyPrompt = cp
})

socket.on('prompt:backend:request', (eventName: string, ...args) => {
// cb is always the last argument
const cb = args.pop()

debug('prompt:backend:request %o', { eventName, args })

const promptBackendRequest = () => {
switch (eventName) {
case 'wait:for:cy:prompt:ready':
return getCtx().coreData.cyPromptLifecycleManager?.getCyPrompt().then((cyPrompt) => {
return {
success: cyPrompt && cyPrompt.status === 'INITIALIZED',
}
})
default: {
return cyPrompt?.handleBackendRequest(eventName, ...args)
}
}
}

return Bluebird.try(promptBackendRequest)
.then((resp) => {
return cb({ response: resp })
}).catch((err) => {
return cb({ error: errors.cloneErr(err) })
})
})

socket.on('backend:request', (eventName: string, ...args) => {
const userAgent = socket.request?.headers['user-agent'] || getCtx().coreData.app.browserUserAgent

Expand All @@ -463,10 +491,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]
Expand Down Expand Up @@ -544,12 +568,6 @@ export class SocketBase {
})
case 'close:extra:targets':
return options.closeExtraTargets()
case 'wait:for:cy:prompt:ready':
return getCtx().coreData.cyPromptLifecycleManager?.getCyPrompt().then((cyPrompt) => {
return {
success: cyPrompt && cyPrompt.status === 'INITIALIZED',
}
})
default:
throw new Error(`You requested a backend event we cannot handle: ${eventName}`)
}
Expand Down
12 changes: 6 additions & 6 deletions packages/server/test/unit/socket_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -547,15 +547,15 @@ describe('lib/socket', () => {
})
})

context('on(backend:request, wait:for:cy:prompt:ready)', () => {
context('on(prompt:backend:request, wait:for:cy:prompt:ready)', () => {
it('awaits cy prompt ready and returns true if cy prompt is ready', function (done) {
const mockCyPrompt = {
status: 'INITIALIZED',
}

ctx.coreData.cyPromptLifecycleManager.getCyPrompt.resolves(mockCyPrompt)

return this.client.emit('backend:request', 'wait:for:cy:prompt:ready', (resp) => {
return this.client.emit('prompt:backend:request', 'wait:for:cy:prompt:ready', (resp) => {
expect(resp.response).to.deep.eq({ success: true })

return done()
Expand All @@ -569,15 +569,15 @@ describe('lib/socket', () => {

ctx.coreData.cyPromptLifecycleManager.getCyPrompt.resolves(mockCyPrompt)

return this.client.emit('backend:request', 'wait:for:cy:prompt:ready', (resp) => {
return this.client.emit('prompt:backend:request', 'wait:for:cy:prompt:ready', (resp) => {
expect(resp.response).to.deep.eq({ success: false })

return done()
})
})
})

context('on(backend:request, cy:prompt)', () => {
context('on(prompt:backend:request, default)', () => {
it('calls handleBackendRequest with the correct arguments', function (done) {
// Verify that registerCyPromptReadyListener was called
expect(ctx.coreData.cyPromptLifecycleManager.registerCyPromptReadyListener).to.be.called
Expand All @@ -592,9 +592,9 @@ describe('lib/socket', () => {

registerCyPromptReadyListenerCallback(mockCyPrompt)

return this.client.emit('backend:request', 'cy:prompt:init', 'foo', (resp) => {
return this.client.emit('prompt:backend:request', 'prompt:init', 'foo', (resp) => {
expect(resp.response).to.deep.eq({ foo: 'bar' })
expect(mockCyPrompt.handleBackendRequest).to.be.calledWith('cy:prompt:init', 'foo')
expect(mockCyPrompt.handleBackendRequest).to.be.calledWith('prompt:init', 'foo')

return done()
})
Expand Down