Skip to content
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

fix: log error on reject with string content #25059

Merged
merged 6 commits into from
Dec 19, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 12 additions & 0 deletions packages/app/cypress/e2e/runner/reporter.errors.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,18 @@ describe('errors ui', {
],
})

verify('spec unhandled rejection with string content', {
uncaught: true,
column: 20,
originalMessage: 'Unhandled promise rejection with string content from the spec',
message: [
'The following error originated from your test code',
'It was caused by an unhandled promise rejection',
],
geritol marked this conversation as resolved.
Show resolved Hide resolved
stackRegex: /.*/,
hasCodeFrame: false,
})

verify('spec unhandled rejection with done', {
uncaught: true,
column: 20,
Expand Down
27 changes: 26 additions & 1 deletion packages/driver/cypress/component/spec.cy.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { sinon } = Cypress

describe('component testing', () => {
/** @type {Cypress.Agent<sinon.SinonSpy>} */
let uncaughtExceptionStub
Expand All @@ -12,17 +14,40 @@ describe('component testing', () => {
})
})

beforeEach(() => {
uncaughtExceptionStub.resetHistory()
document.querySelector('[data-cy-root]').innerHTML = ''
})

it('fails and shows an error', () => {
cy.spy(Cypress, 'log').log(false)
const $el = document.createElement('button')

$el.innerText = `Don't click it!`
$el.addEventListener('click', () => {
throw Error('An error!')
throw new Error('An error!')
})

document.querySelector('[data-cy-root]').appendChild($el)
cy.get('button').click().then(() => {
expect(uncaughtExceptionStub).to.have.been.calledOnceWithExactly(null)
expect(Cypress.log).to.be.calledWithMatch(sinon.match({ 'message': `Error: An error!`, name: 'uncaught exception' }))
})
})

it('fails and shows when a promise rejects with a string', () => {
cy.spy(Cypress, 'log').log(false)
const $el = document.createElement('button')

$el.innerText = `Don't click it!`
$el.addEventListener('click', new Promise((_, reject) => {
reject('Promise rejected with a string!')
}))

document.querySelector('[data-cy-root]').appendChild($el)
cy.get('button').click().then(() => {
expect(uncaughtExceptionStub).to.have.been.calledOnceWithExactly(null)
expect(Cypress.log).to.be.calledWithMatch(sinon.match({ 'message': `Error: "Promise rejected with a string!"`, name: 'uncaught exception' }))
})
})
})
36 changes: 34 additions & 2 deletions packages/driver/cypress/e2e/cypress/error_utils.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import $stackUtils from '@packages/driver/src/cypress/stack_utils'
import $errUtils, { CypressError } from '@packages/driver/src/cypress/error_utils'
import $errorMessages from '@packages/driver/src/cypress/error_messages'

const { sinon } = Cypress

describe('driver/src/cypress/error_utils', () => {
context('.modifyErrMsg', () => {
let originalErr
Expand Down Expand Up @@ -90,7 +92,7 @@ describe('driver/src/cypress/error_utils', () => {
})

it('attaches onFail to the error when it is a function', () => {
const onFail = function () {}
const onFail = function () { }
const fn = () => $errUtils.throwErr(new Error('foo'), { onFail })

expect(fn).throw().and.satisfy((err) => {
Expand Down Expand Up @@ -561,7 +563,7 @@ describe('driver/src/cypress/error_utils', () => {

it('does not error if no last log', () => {
state.returns({
getLastLog: () => {},
getLastLog: () => { },
})

const result = $errUtils.createUncaughtException({
Expand Down Expand Up @@ -660,4 +662,34 @@ describe('driver/src/cypress/error_utils', () => {
expect(unsupportedPlugin).to.eq(null)
})
})

context('.logError', () => {
let cypressMock

beforeEach(() => {
cypressMock = {
log: cy.stub(),
}
})

it('calls Cypress.log with error name and message when error is instance of Error', () => {
$errUtils.logError(cypressMock, 'error', new Error('Some error'))
expect(cypressMock.log).to.have.been.calledWithMatch(sinon.match.has('message', `Error: Some error`))
})

it('calls Cypress.log with error name and message when error a string', () => {
$errUtils.logError(cypressMock, 'error', 'Some string error')
expect(cypressMock.log).to.have.been.calledWithMatch(sinon.match.has('message', `Error: \"Some string error\"`))
})

it('calls Cypress.log with default error name and provided message message when error is an object with a message', () => {
$errUtils.logError(cypressMock, 'error', { message: 'Some object error with message' })
expect(cypressMock.log).to.have.been.calledWithMatch(sinon.match.has('message', `Error: Some object error with message`))
})

it('calls Cypress.log with error name and message when error is an object', () => {
$errUtils.logError(cypressMock, 'error', { err: 'Error details' })
expect(cypressMock.log).to.have.been.calledWithMatch(sinon.match.has('message', `Error: {"err":"Error details"}`))
})
})
})
25 changes: 13 additions & 12 deletions packages/driver/src/cypress/cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ const setTopOnError = function (Cypress, cy: $Cy) {

// prevent Mocha from setting top.onerror
Object.defineProperty(top, 'onerror', {
set () {},
get () {},
set () { },
get () { },
configurable: false,
enumerable: true,
})
Expand All @@ -131,12 +131,12 @@ const ensureRunnable = (cy, cmd) => {
interface ICyFocused extends Omit<
IFocused,
'documentHasFocus' | 'interceptFocus' | 'interceptBlur'
> {}
> { }

interface ICySnapshots extends Omit<
ISnapshots,
'onCssModified' | 'onBeforeWindowLoad'
> {}
> { }

export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssertions, IRetries, IJQuery, ILocation, ITimer, IChai, IXhr, IAliases, ICySnapshots, ICyFocused {
id: string
Expand Down Expand Up @@ -505,16 +505,16 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert
// If the runner can communicate, we should setup all events, otherwise just setup the window and fire the load event.
if (isRunnerAbleToCommunicateWithAUT) {
if (this.Cypress.isBrowser('webkit')) {
// WebKit's unhandledrejection event will sometimes not fire within the AUT
// due to a documented bug: https://bugs.webkit.org/show_bug.cgi?id=187822
// To ensure that the event will always fire (and always report these
// unhandled rejections to the user), we patch the AUT's Error constructor
// to enqueue a no-op microtask when executed, which ensures that the unhandledrejection
// event handler will be executed if this Error is uncaught.
// WebKit's unhandledrejection event will sometimes not fire within the AUT
// due to a documented bug: https://bugs.webkit.org/show_bug.cgi?id=187822
// To ensure that the event will always fire (and always report these
// unhandled rejections to the user), we patch the AUT's Error constructor
// to enqueue a no-op microtask when executed, which ensures that the unhandledrejection
// event handler will be executed if this Error is uncaught.
const originalError = autWindow.Error

autWindow.Error = function __CyWebKitError (...args) {
autWindow.queueMicrotask(() => {})
autWindow.queueMicrotask(() => { })

return originalError.apply(this, args)
}
Expand Down Expand Up @@ -1059,6 +1059,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert
// eslint-disable-next-line @cypress/dev/arrow-body-multiline-braces
onError: (handlerType) => (event) => {
const { originalErr, err, promise } = $errUtils.errorFromUncaughtEvent(handlerType, event) as ErrorFromProjectRejectionEvent

const handled = cy.onUncaughtException({
err,
promise,
Expand All @@ -1080,7 +1081,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert
onSubmit (e) {
return cy.Cypress.action('app:form:submitted', e)
},
onLoad () {},
onLoad () { },
onBeforeUnload (e) {
cy.isStable(false, 'beforeunload')

Expand Down
29 changes: 27 additions & 2 deletions packages/driver/src/cypress/error_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ const appendErrMsg = (err, errMsg) => {
}

const makeErrFromObj = (obj) => {
if (_.isString(obj)) {
return new Error(obj)
}

const err2 = new Error(obj.message)

err2.name = obj.name
Expand Down Expand Up @@ -549,9 +553,11 @@ const errorFromUncaughtEvent = (handlerType: HandlerType, event) => {
errorFromProjectRejectionEvent(event)
}

const logError = (Cypress, handlerType: HandlerType, err, handled = false) => {
const logError = (Cypress, handlerType: HandlerType, err: unknown, handled = false) => {
const error = toLoggableError(err)

Cypress.log({
message: `${err.name}: ${err.message}`,
message: `${error.name || 'Error'}: ${error.message}`,
name: 'uncaught exception',
type: 'parent',
// specifying the error causes the log to be red/failed
Expand All @@ -572,6 +578,25 @@ const logError = (Cypress, handlerType: HandlerType, err, handled = false) => {
})
}

interface LoggableError { name?: string, message: string }

const isLoggableError = (error: unknown): error is LoggableError => {
return (
typeof error === 'object' &&
error !== null &&
'message' in error)
}

const toLoggableError = (maybeError: unknown): LoggableError => {
if (isLoggableError(maybeError)) return maybeError

try {
return { message: JSON.stringify(maybeError) }
} catch {
return { message: String(maybeError) }
}
}

const getUnsupportedPlugin = (runnable) => {
if (!(runnable.invocationDetails && runnable.invocationDetails.originalFile && runnable.err && runnable.err.message)) {
return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ describe('uncaught errors', { defaultCommandTimeout: 0 }, () => {
cy.wait(10000)
})

it('spec unhandled rejection with string content', () => {
Promise.reject('Unhandled promise rejection with string content from the spec')

cy.wait(10000)
})

// eslint-disable-next-line mocha/handle-done-callback
it('spec unhandled rejection with done', (done) => {
Promise.reject(new Error('Unhandled promise rejection from the spec'))
Expand Down