Skip to content

Commit

Permalink
feat: (multi-domain) handle uncaught errors in the spec bridge (#19976)
Browse files Browse the repository at this point in the history
  • Loading branch information
mschile authored Feb 2, 2022
1 parent fe03453 commit 4070270
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 28 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,26 @@ describe('multi-domain - uncaught errors', { experimentalSessionSupport: true, e
})

describe('async errors', () => {
it('fails the current test/command if async errors are thrown from the test code in switchToDomain while the callback window is still open', (done) => {
cy.on('fail', (err) => {
expect(err.name).to.eq('Error')
expect(err.message).to.include('setTimeout error')
expect(err.message).to.include('The following error originated from your test code, not from Cypress.')

done()
})

cy.switchToDomain('foobar.com', () => {
setTimeout(() => {
throw new Error('setTimeout error')
}, 50)

// add the cy.wait here to keep commands streaming in, forcing the
// switchToDomain callback window to be open long enough for the error to occur
cy.wait(250)
})
})

it('fails the current test/command if async errors are thrown from the switchToDomain context while the callback window is still open', () => {
const uncaughtExceptionSpy = cy.spy()
const r = cy.state('runnable')
Expand Down Expand Up @@ -122,6 +142,25 @@ describe('multi-domain - uncaught errors', { experimentalSessionSupport: true, e
expect(failureSpy).not.to.be.called
})
})

// FIXME: Remove skip once support is added for handling errors from switchToDomain after the callback windows closes
it.skip('fails the current test/command if async errors are thrown from the test code in switchToDomain while the callback window is now closed', (done) => {
cy.on('fail', (err) => {
expect(err.name).to.eq('Error')
expect(err.message).to.include('setTimeout error')
expect(err.message).to.include('The following error originated from your test code, not from Cypress.')

done()
})

cy.switchToDomain('foobar.com', () => {
setTimeout(() => {
throw new Error('setTimeout error')
}, 50)
})

cy.wait(250)
})
})

describe('unhandled rejections', () => {
Expand Down Expand Up @@ -155,6 +194,43 @@ describe('multi-domain - uncaught errors', { experimentalSessionSupport: true, e
cy.get('.error-two').invoke('text').should('equal', 'promise rejection')
})
})

it('fails the current test/command if a promise is rejected from the test code in switchToDomain while the callback window is still open', (done) => {
cy.on('fail', (err) => {
expect(err.name).to.eq('Error')
expect(err.message).to.include('rejected promise')
expect(err.message).to.include('The following error originated from your test code, not from Cypress. It was caused by an unhandled promise rejection.')
expect(err.message).to.not.include('https://on.cypress.io/uncaught-exception-from-application')

done()
})

cy.switchToDomain('foobar.com', () => {
Promise.reject(new Error('rejected promise'))

// add the cy.wait here to keep commands streaming in, forcing the
// switchToDomain callback window to be open long enough for the error to occur
cy.wait(250)
})
})

// FIXME: Remove skip once support is added for handling errors from switchToDomain after the callback windows closes
it.skip('fails the current test/command if a promise is rejected from the test code in switchToDomain while the callback window is now closed', (done) => {
cy.on('fail', (err) => {
expect(err.name).to.eq('Error')
expect(err.message).to.include('rejected promise')
expect(err.message).to.include('The following error originated from your test code, not from Cypress. It was caused by an unhandled promise rejection.')
expect(err.message).to.not.include('https://on.cypress.io/uncaught-exception-from-application')

done()
})

cy.switchToDomain('foobar.com', () => {
Promise.reject(new Error('rejected promise'))
})

cy.wait(250)
})
})

it('does not fail if thrown custom error with readonly name', (done) => {
Expand Down
20 changes: 19 additions & 1 deletion packages/driver/src/cy/multi-domain/commands_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export class CommandsManager {
}

listen () {
this.communicator.on('reject', this.reject)
this.communicator.on('command:enqueued', this.addCommand)
this.communicator.on('command:end', this.endCommand)
}
Expand Down Expand Up @@ -69,7 +70,7 @@ export class CommandsManager {
}

// If the command has failed, cast the error back to a proper Error object
let parsedError = correctStackForCrossDomainError(err, this.userInvocationStack)
const parsedError = correctStackForCrossDomainError(err, this.userInvocationStack)

if (logId) {
// Then, look up the logId associated with the failed command and stub out the onFail handler
Expand All @@ -85,7 +86,24 @@ export class CommandsManager {
this.cleanup()
}

reject = ({ err }) => {
// parse the error back to a proper Error object
const parsedError = correctStackForCrossDomainError(err, this.userInvocationStack)

delete parsedError.onFail

const r = cy.state('reject')

if (r) {
r(parsedError)
}

// finally, free up any memory and unbind any handlers now that the test has failed
this.cleanup()
}

async cleanup () {
this.communicator.off('reject', this.reject)
this.communicator.off('command:enqueued', this.addCommand)

// don't allow for new commands to be enqueued, but wait for commands
Expand Down
31 changes: 4 additions & 27 deletions packages/driver/src/multi-domain/cypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import $Cypress from '../cypress'
import { $Cy } from '../cypress/cy'
import $Commands from '../cypress/commands'
import $Log from '../cypress/log'
import $errUtils, { ErrorFromProjectRejectionEvent } from '../cypress/error_utils'
import { bindToListeners } from '../cy/listeners'
import { SpecBridgeDomainCommunicator } from './communicator'
import { handleDomainFn } from './domain_fn'
import { handleCommands } from './commands'
import { handleLogs } from './logs'
import { handleSocketEvents } from './socket'
import { handleSpecWindowEvents } from './spec_window_events'
import { handleErrorEvent } from './errors'

const specBridgeCommunicator = new SpecBridgeDomainCommunicator()

Expand Down Expand Up @@ -64,6 +65,7 @@ const setup = () => {
handleCommands(Cypress, cy, specBridgeCommunicator)
handleLogs(Cypress, specBridgeCommunicator)
handleSocketEvents(Cypress)
handleSpecWindowEvents(cy)

cy.onBeforeAppWindowLoad = onBeforeAppWindowLoad(Cypress, cy)

Expand All @@ -82,32 +84,7 @@ const onBeforeAppWindowLoad = (Cypress: Cypress.Cypress, cy: $Cy) => (autWindow:
cy.overrides.wrapNativeMethods(autWindow)
// TODO: DRY this up with the mostly-the-same code in src/cypress/cy.js
bindToListeners(autWindow, {
onError: (handlerType) => {
return (event) => {
const { originalErr, err, promise } = $errUtils.errorFromUncaughtEvent(handlerType, event) as ErrorFromProjectRejectionEvent
const handled = cy.onUncaughtException({
err,
promise,
handlerType,
frameType: 'app',
})

$errUtils.logError(cy.Cypress, handlerType, originalErr, handled)

if (!handled) {
// if unhandled, fail the current command to fail the current test in the primary domain
// a current command may not exist if an error occurs in the spec bridge after the test is over
const command = cy.state('current')
const log = command?.getLastLog()

if (log) log.error(err)
}

// return undefined so the browser does its default
// uncaught exception behavior (logging to console)
return undefined
}
},
onError: handleErrorEvent(cy, 'app'),
onHistoryNav () {},
onSubmit (e) {
return Cypress.action('app:form:submitted', e)
Expand Down
6 changes: 6 additions & 0 deletions packages/driver/src/multi-domain/domain_fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ export const handleDomainFn = (cy: $Cy, specBridgeCommunicator: SpecBridgeDomain

cy.state('onFail', (err) => {
const command = cy.state('current')

// If there isn't a current command, just reject to fail the test
if (!command) {
return specBridgeCommunicator.toPrimary('reject', { err })
}

const id = command.get('id')
const name = command.get('name')
const logId = command.getLastLog()?.get('id')
Expand Down
31 changes: 31 additions & 0 deletions packages/driver/src/multi-domain/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { $Cy } from '../cypress/cy'
import $errUtils, { ErrorFromProjectRejectionEvent } from '../cypress/error_utils'

export const handleErrorEvent = (cy: $Cy, frameType: 'spec' | 'app') => {
return (handlerType: string) => {
return (event) => {
const { originalErr, err, promise } = $errUtils.errorFromUncaughtEvent(handlerType, event) as ErrorFromProjectRejectionEvent
const handled = cy.onUncaughtException({
err,
promise,
handlerType,
frameType,
})

$errUtils.logError(Cypress, handlerType, originalErr, handled)

if (!handled) {
// if unhandled, fail the current command to fail the current test in the primary domain
// a current command may not exist if an error occurs in the spec bridge after the test is over
const command = cy.state('current')
const log = command?.getLastLog()

if (log) log.error(err)
}

// return undefined so the browser does its default
// uncaught exception behavior (logging to console)
return undefined
}
}
}
17 changes: 17 additions & 0 deletions packages/driver/src/multi-domain/spec_window_events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { $Cy } from '../cypress/cy'
import { handleErrorEvent } from './errors'

export const handleSpecWindowEvents = (cy: $Cy) => {
const handleWindowErrorEvent = handleErrorEvent(cy, 'spec')('error')
const handleWindowUnhandledRejectionEvent = handleErrorEvent(cy, 'spec')('unhandledrejection')

const handleUnload = () => {
window.removeEventListener('unload', handleUnload)
window.removeEventListener('error', handleWindowErrorEvent)
window.removeEventListener('unhandledrejection', handleWindowUnhandledRejectionEvent)
}

window.addEventListener('unload', handleUnload)
window.addEventListener('error', handleWindowErrorEvent)
window.addEventListener('unhandledrejection', handleWindowUnhandledRejectionEvent)
}

0 comments on commit 4070270

Please sign in to comment.