mirror of
https://github.com/cypress-io/cypress.git
synced 2026-05-05 06:20:44 -05:00
chore: Refactor assertion logging (#23354)
* chore: Refactor assertion logging * Remove duplicate snapshot on command failure; Prevent circular json error when inspecting commands * Fix for 'next' attribute in logs * Add comments about logging and assertions * Fix duplicate logs on not.exist assertions * Move command-merging logic into asserting.ts * Fix TS error * Fix race condition with cross-origin tests * Review updates
This commit is contained in:
@@ -329,11 +329,15 @@ describe('src/cy/commands/assertions', () => {
|
||||
it('resolves eventually not exist', () => {
|
||||
const button = cy.$$('button:first')
|
||||
|
||||
cy.on('command:retry', _.after(2, _.once(() => {
|
||||
cy.on('command:retry', _.after(3, _.once(() => {
|
||||
button.remove()
|
||||
})))
|
||||
|
||||
cy.get('button:first').click().should('not.exist')
|
||||
|
||||
cy.then(function () {
|
||||
assertLogLength(this.logs, 3)
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves all 3 assertions', (done) => {
|
||||
@@ -715,7 +719,6 @@ describe('src/cy/commands/assertions', () => {
|
||||
it('does not log ensureElExistence errors', function (done) {
|
||||
cy.on('fail', (err) => {
|
||||
assertLogLength(this.logs, 1)
|
||||
|
||||
done()
|
||||
})
|
||||
|
||||
@@ -790,19 +793,18 @@ describe('src/cy/commands/assertions', () => {
|
||||
cy.noop({}).should('have.property', 'foo')
|
||||
})
|
||||
|
||||
it('ends and snapshots immediately and sets child', (done) => {
|
||||
it('snapshots immediately and sets child', (done) => {
|
||||
cy.on('log:added', (attrs, log) => {
|
||||
if (attrs.name === 'assert') {
|
||||
cy.removeAllListeners('log:added')
|
||||
|
||||
expect(log.get('ended')).to.be.true
|
||||
expect(log.get('state')).to.eq('passed')
|
||||
expect(log.get('snapshots').length).to.eq(1)
|
||||
expect(log.get('snapshots')[0]).to.be.an('object')
|
||||
expect(log.get('type')).to.eq('child')
|
||||
|
||||
done()
|
||||
if (attrs.name !== 'assert') {
|
||||
return
|
||||
}
|
||||
|
||||
cy.removeAllListeners('log:added')
|
||||
expect(log.get('snapshots').length).to.eq(1)
|
||||
expect(log.get('snapshots')[0]).to.be.an('object')
|
||||
expect(log.get('type')).to.eq('child')
|
||||
|
||||
done()
|
||||
})
|
||||
|
||||
cy.get('body').then(() => {
|
||||
|
||||
@@ -222,9 +222,11 @@ export const create = (Cypress: ICypress, cy: $Cy) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const finishAssertions = (assertions) => {
|
||||
return _.each(assertions, (log) => {
|
||||
log.snapshot()
|
||||
const finishAssertions = () => {
|
||||
cy.state('current').get('logs').forEach((log) => {
|
||||
if (log.get('next') || !log.get('snapshots')) {
|
||||
log.snapshot()
|
||||
}
|
||||
|
||||
const e = log.get('_error')
|
||||
|
||||
@@ -238,7 +240,6 @@ export const create = (Cypress: ICypress, cy: $Cy) => {
|
||||
|
||||
type VerifyUpcomingAssertionsCallbacks = {
|
||||
ensureExistenceFor?: 'subject' | 'dom' | boolean
|
||||
onPass?: Function
|
||||
onFail?: (err?, isDefaultAssertionErr?: boolean, cmds?: any[]) => void
|
||||
onRetry?: () => any
|
||||
}
|
||||
@@ -256,10 +257,6 @@ export const create = (Cypress: ICypress, cy: $Cy) => {
|
||||
// case where there are no upcoming assertion commands
|
||||
const isDefaultAssertionErr = cmds.length === 0
|
||||
|
||||
if (options.assertions == null) {
|
||||
options.assertions = []
|
||||
}
|
||||
|
||||
_.defaults(callbacks, {
|
||||
ensureExistenceFor: 'dom',
|
||||
})
|
||||
@@ -296,9 +293,6 @@ export const create = (Cypress: ICypress, cy: $Cy) => {
|
||||
|
||||
const onPassFn = () => {
|
||||
cy.state('overrideAssert', undefined)
|
||||
if (_.isFunction(callbacks.onPass)) {
|
||||
return callbacks.onPass.call(this, cmds, options.assertions)
|
||||
}
|
||||
|
||||
return subject
|
||||
}
|
||||
@@ -342,8 +336,9 @@ export const create = (Cypress: ICypress, cy: $Cy) => {
|
||||
onFail.call(this, err, isDefaultAssertionErr, cmds)
|
||||
}
|
||||
} catch (e3) {
|
||||
finishAssertions(options.assertions)
|
||||
throw e3
|
||||
} finally {
|
||||
finishAssertions()
|
||||
}
|
||||
|
||||
if (_.isFunction(onRetry)) {
|
||||
@@ -380,108 +375,7 @@ export const create = (Cypress: ICypress, cy: $Cy) => {
|
||||
.catch(onFailFn)
|
||||
}
|
||||
|
||||
let i = 0
|
||||
|
||||
const cmdHasFunctionArg = (cmd) => {
|
||||
return _.isFunction(cmd.get('args')[0])
|
||||
}
|
||||
|
||||
const overrideAssert = function (...args) {
|
||||
let cmd = cmds[i]
|
||||
const setCommandLog = (log) => {
|
||||
// our next log may not be an assertion
|
||||
// due to page events so make sure we wait
|
||||
// until we see page events
|
||||
if (log.get('name') !== 'assert') {
|
||||
return
|
||||
}
|
||||
|
||||
// when we do immediately unbind this function
|
||||
cy.state('onBeforeLog', null)
|
||||
|
||||
const insertNewLog = (log) => {
|
||||
cmd.log(log)
|
||||
|
||||
return options.assertions.push(log)
|
||||
}
|
||||
|
||||
// its possible a single 'should' will assert multiple
|
||||
// things such as the case with have.property. we can
|
||||
// detect when that is happening because cmd will be null.
|
||||
// if thats the case then just set cmd to be the previous
|
||||
// cmd and do not increase 'i'
|
||||
// this will prevent 2 logs from ever showing up but still
|
||||
// provide errors when the 1st assertion fails.
|
||||
if (!cmd) {
|
||||
cmd = cmds[i - 1]
|
||||
} else {
|
||||
i += 1
|
||||
}
|
||||
|
||||
// if our command has a function argument
|
||||
// then we know it may contain multiple
|
||||
// assertions
|
||||
if (cmdHasFunctionArg(cmd)) {
|
||||
let index = cmd.get('assertionIndex')
|
||||
let assertions = cmd.get('assertions')
|
||||
|
||||
// https://github.com/cypress-io/cypress/issues/4981
|
||||
// `assertions` is undefined because assertions added by
|
||||
// `should` command are not handled yet.
|
||||
// So, don't increase i and go back to the last command.
|
||||
if (!assertions) {
|
||||
i -= 1
|
||||
cmd = cmds[i - 1]
|
||||
index = cmd.get('assertionIndex')
|
||||
assertions = cmd.get('assertions')
|
||||
}
|
||||
|
||||
// always increase the assertionIndex
|
||||
// so our next assertion matches up
|
||||
// to the correct index
|
||||
const incrementIndex = () => {
|
||||
return cmd.set('assertionIndex', index += 1)
|
||||
}
|
||||
|
||||
// if we dont have an assertion at this
|
||||
// index then insert a new log
|
||||
const assertion = assertions[index]
|
||||
|
||||
if (!assertion) {
|
||||
assertions.push(log)
|
||||
incrementIndex()
|
||||
|
||||
return insertNewLog(log)
|
||||
}
|
||||
|
||||
// else just merge this log
|
||||
// into the previous assertion log
|
||||
incrementIndex()
|
||||
assertion.merge(log)
|
||||
|
||||
// dont output a new log
|
||||
return false
|
||||
}
|
||||
|
||||
// if we already have a log
|
||||
// then just update its attrs from
|
||||
// the new log
|
||||
const l = cmd.getLastLog()
|
||||
|
||||
if (l) {
|
||||
l.merge(log)
|
||||
|
||||
// and make sure we return false
|
||||
// which prevents a new log from
|
||||
// being added
|
||||
return false
|
||||
}
|
||||
|
||||
return insertNewLog(log)
|
||||
}
|
||||
|
||||
cy.state('onBeforeLog', setCommandLog)
|
||||
|
||||
// send verify=true as the last arg
|
||||
return assertFn.apply(this, args.concat(true) as any)
|
||||
}
|
||||
@@ -537,7 +431,7 @@ export const create = (Cypress: ICypress, cy: $Cy) => {
|
||||
|
||||
setSubjectAndSkip()
|
||||
|
||||
finishAssertions(options.assertions)
|
||||
finishAssertions()
|
||||
|
||||
return onPassFn()
|
||||
})
|
||||
@@ -547,7 +441,7 @@ export const create = (Cypress: ICypress, cy: $Cy) => {
|
||||
// when we're told not to retry
|
||||
if (err.retry === false) {
|
||||
// finish the assertions
|
||||
finishAssertions(options.assertions)
|
||||
finishAssertions()
|
||||
|
||||
// and then push our command into this err
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,29 @@ import $errUtils from '../../cypress/error_utils'
|
||||
const reExistence = /exist/
|
||||
const reHaveLength = /length/
|
||||
|
||||
const onBeforeLog = (log, command, commandLogId) => {
|
||||
log.set('commandLogId', commandLogId)
|
||||
|
||||
const previousLogInstance = command.get('logs').find(_.matchesProperty('attributes.commandLogId', commandLogId))
|
||||
|
||||
if (previousLogInstance) {
|
||||
// log.merge unsets any keys that aren't set on the new log instance. We
|
||||
// copy over 'snapshots' beforehand so that existing snapshots aren't lost
|
||||
// in the merge operation.
|
||||
log.set('snapshots', previousLogInstance.get('snapshots'))
|
||||
previousLogInstance.merge(log)
|
||||
|
||||
if (previousLogInstance.get('end')) {
|
||||
previousLogInstance.end()
|
||||
}
|
||||
|
||||
// Returning false prevents this new log from being added to the command log
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export default function (Commands, Cypress, cy, state) {
|
||||
const shouldFnWithCallback = function (subject, fn) {
|
||||
state('current')?.set('followedByShouldCallback', true)
|
||||
@@ -24,8 +47,44 @@ export default function (Commands, Cypress, cy, state) {
|
||||
}
|
||||
|
||||
const shouldFn = function (subject, chainers, ...args) {
|
||||
const command = cy.state('current')
|
||||
|
||||
// Most commands are responsible for creating and managing their own log messages directly.
|
||||
// .should(), however, is an exception - it is invoked by earlier commands, as part of
|
||||
// `verifyUpcomingAssertions`. This callback can also be invoked any number of times, but we only want
|
||||
// to display a few log messages (one for each assertion).
|
||||
|
||||
// Therefore, we each time Cypress.log() is called, we need a way to identify if this log call
|
||||
// a duplicate of a previous one that's just being retried. This is the purpose of `commandLogId` - it should
|
||||
// remain the same across multiple invocations of verifyUpcomingAssertions().
|
||||
|
||||
// It is composed of two parts: assertionIndex and logIndex. Assertion index is "which .should() command are we
|
||||
// inside". Consider the following case:
|
||||
// `cy.noop(3).should('be.lessThan', 4).should('be.greaterThan', 2)`
|
||||
// cy.state('current') is always the 'noop' command, which rolls up the two upcoming assertions, lessThan and
|
||||
// greaterThan. `assertionIndex` lets us tell them apart even though they have the same logIndex of 0 (since it
|
||||
// resets each time .should() is called).
|
||||
|
||||
// As another case, consider:
|
||||
// cy.noop(3).should((n) => { expect(n).to.be.lessThan(4); expect(n).to.be.greaterThan(2); })
|
||||
// Here, assertionIndex is 0 for both - one .should() block generates two log messages. In this case, logIndex is
|
||||
// used to tell them apart, since it increments each time Cypress.log() is called within a single retry of a single
|
||||
// .should().
|
||||
const assertionIndex = cy.state('upcomingAssertions') ? cy.state('upcomingAssertions').indexOf(command.get('currentAssertionCommand')) : 0
|
||||
let logIndex = 0
|
||||
|
||||
if (_.isFunction(chainers)) {
|
||||
return shouldFnWithCallback.apply(this, [subject, chainers])
|
||||
cy.state('onBeforeLog', (log) => {
|
||||
logIndex++
|
||||
|
||||
return onBeforeLog(log, command, `${assertionIndex}-${logIndex}`)
|
||||
})
|
||||
|
||||
try {
|
||||
return shouldFnWithCallback.apply(this, [subject, chainers])
|
||||
} finally {
|
||||
cy.state('onBeforeLog', undefined)
|
||||
}
|
||||
}
|
||||
|
||||
let exp = cy.expect(subject).to
|
||||
@@ -35,6 +94,7 @@ export default function (Commands, Cypress, cy, state) {
|
||||
// since we are throwing our own error
|
||||
// without going through the assertion we need
|
||||
// to ensure our .should command gets logged
|
||||
logIndex++
|
||||
const log = Cypress.log({
|
||||
name: 'should',
|
||||
type: 'child',
|
||||
@@ -58,31 +118,40 @@ export default function (Commands, Cypress, cy, state) {
|
||||
const isCheckingLengthOrExistence = isCheckingExistence || reHaveLength.test(chainers)
|
||||
|
||||
const applyChainer = function (memo, value) {
|
||||
if (value === lastChainer && !isCheckingExistence) {
|
||||
logIndex++
|
||||
cy.state('onBeforeLog', (log) => {
|
||||
return onBeforeLog(log, command, `${assertionIndex}-${logIndex}`)
|
||||
})
|
||||
|
||||
try {
|
||||
if (value === lastChainer && !isCheckingExistence) {
|
||||
// https://github.com/cypress-io/cypress/issues/16006
|
||||
// Referring some commands like 'visible' triggers assert function in chai_jquery.js
|
||||
// It creates duplicated messages and confuses users.
|
||||
const cmd = memo[value]
|
||||
const cmd = memo[value]
|
||||
|
||||
if (_.isFunction(cmd)) {
|
||||
try {
|
||||
return cmd.apply(memo, args)
|
||||
} catch (err: any) {
|
||||
// if we made it all the way to the actual
|
||||
// assertion but its set to retry false then
|
||||
// we need to log out this .should since there
|
||||
// was a problem with the actual assertion syntax
|
||||
if (err.retry === false) {
|
||||
return throwAndLogErr(err)
|
||||
if (_.isFunction(cmd)) {
|
||||
try {
|
||||
return cmd.apply(memo, args)
|
||||
} catch (err: any) {
|
||||
// if we made it all the way to the actual
|
||||
// assertion but its set to retry false then
|
||||
// we need to log out this .should since there
|
||||
// was a problem with the actual assertion syntax
|
||||
if (err.retry === false) {
|
||||
return throwAndLogErr(err)
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
|
||||
throw err
|
||||
} else {
|
||||
return cmd
|
||||
}
|
||||
} else {
|
||||
return cmd
|
||||
return memo[value]
|
||||
}
|
||||
} else {
|
||||
return memo[value]
|
||||
} finally {
|
||||
cy.state('onBeforeLog', undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -300,8 +300,8 @@ export default (Commands, Cypress, cy, state) => {
|
||||
if (responsesOrErr.hasSpecBridgeError) {
|
||||
delete responsesOrErr.hasSpecBridgeError
|
||||
if (options.log) {
|
||||
// skip this 'wait' log since it was already added through the primary
|
||||
Cypress.state('onBeforeLog', (log) => {
|
||||
// skip this 'wait' log since it was already added through the primary
|
||||
if (log.get('name') === 'wait') {
|
||||
// unbind this function so we don't impact any other logs
|
||||
cy.state('onBeforeLog', null)
|
||||
@@ -309,7 +309,7 @@ export default (Commands, Cypress, cy, state) => {
|
||||
return false
|
||||
}
|
||||
|
||||
return
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ const VALID_POSITIONS = 'topLeft top topRight left center right bottomLeft botto
|
||||
// they may need to work with both element arrays, or specific items
|
||||
// such as a single element, a single document, or single window
|
||||
|
||||
let returnFalse = () => false
|
||||
|
||||
export const create = (state: StateFunc, expect: $Cy['expect']) => {
|
||||
// TODO: we should probably normalize all subjects
|
||||
// into an array and loop through each and verify
|
||||
@@ -246,27 +244,14 @@ export const create = (state: StateFunc, expect: $Cy['expect']) => {
|
||||
}
|
||||
|
||||
const ensureExistence = (subject) => {
|
||||
returnFalse = () => {
|
||||
cleanup()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
return state('onBeforeLog', null)
|
||||
}
|
||||
|
||||
// prevent any additional logs this is an implicit assertion
|
||||
state('onBeforeLog', returnFalse)
|
||||
// prevent any additional logs since this is an implicit assertion
|
||||
state('onBeforeLog', () => false)
|
||||
|
||||
// verify the $el exists and use our default error messages
|
||||
// TODO: always unbind if our expectation failed
|
||||
try {
|
||||
expect(subject).to.exist
|
||||
} catch (err) {
|
||||
cleanup()
|
||||
|
||||
throw err
|
||||
} finally {
|
||||
state('onBeforeLog', null)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,18 +55,7 @@ export const create = (Cypress: ICypress, state: StateFunc, timeout: $Cy['timeou
|
||||
// if our total exceeds the timeout OR the total + the interval
|
||||
// exceed the runnables timeout, then bail
|
||||
if ((total + interval) >= options._runnableTimeout) {
|
||||
// snapshot the DOM since we are bailing
|
||||
// so the user can see the state we're in
|
||||
// when we fail
|
||||
if (log) {
|
||||
log.snapshot()
|
||||
}
|
||||
|
||||
const assertions = options.assertions
|
||||
|
||||
if (assertions) {
|
||||
finishAssertions(assertions)
|
||||
}
|
||||
finishAssertions()
|
||||
|
||||
let onFail
|
||||
|
||||
|
||||
@@ -1185,7 +1185,7 @@ export class $Cy extends EventEmitter2 implements ITimeouts, IStability, IAssert
|
||||
|
||||
// if this is a number, then we know we're about to insert this
|
||||
// into our commands and need to reset next + increment the index
|
||||
if (_.isNumber(nestedIndex)) {
|
||||
if (_.isNumber(nestedIndex) && nestedIndex < this.queue.length) {
|
||||
this.state('nestedIndex', (nestedIndex += 1))
|
||||
}
|
||||
|
||||
|
||||
@@ -355,15 +355,7 @@ export class Log {
|
||||
this.set('snapshots', snapshots)
|
||||
|
||||
if (options.next && shouldRebindSnapshotFn) {
|
||||
const originalLogSnapshotFn = this.snapshot
|
||||
|
||||
this.snapshot = function () {
|
||||
// restore the original snapshot function
|
||||
this.snapshot = originalLogSnapshotFn
|
||||
|
||||
// call orig fn with next as name
|
||||
return originalLogSnapshotFn.call(this, options.next)
|
||||
}
|
||||
this.set('next', options.next)
|
||||
}
|
||||
|
||||
return this
|
||||
@@ -376,10 +368,10 @@ export class Log {
|
||||
return this
|
||||
}
|
||||
|
||||
_.defaults(options, {
|
||||
at: null,
|
||||
next: null,
|
||||
})
|
||||
if (this.get('next')) {
|
||||
name = this.get('next')
|
||||
this.set('next', null)
|
||||
}
|
||||
|
||||
if (this.config('experimentalSessionAndOrigin') && !Cypress.isCrossOriginSpecBridge) {
|
||||
const activeSpecBridgeOriginPolicyIfApplicable = this.state('currentActiveOriginPolicy') || undefined
|
||||
@@ -620,24 +612,6 @@ class LogManager {
|
||||
|
||||
log.set(options)
|
||||
|
||||
// if snapshot was passed
|
||||
// in, go ahead and snapshot
|
||||
if (log.get('snapshot')) {
|
||||
log.snapshot()
|
||||
}
|
||||
|
||||
// if end was passed in
|
||||
// go ahead and end
|
||||
if (log.get('end')) {
|
||||
log.end()
|
||||
}
|
||||
|
||||
if (log.get('error')) {
|
||||
log.error(log.get('error'))
|
||||
}
|
||||
|
||||
log.wrapConsoleProps()
|
||||
|
||||
const onBeforeLog = state('onBeforeLog')
|
||||
|
||||
// dont trigger log if this function
|
||||
@@ -648,13 +622,24 @@ class LogManager {
|
||||
}
|
||||
}
|
||||
|
||||
// set the log on the command
|
||||
const current = state('current')
|
||||
const command = state('current')
|
||||
|
||||
if (current) {
|
||||
current.log(log)
|
||||
if (command) {
|
||||
command.log(log)
|
||||
}
|
||||
|
||||
// if snapshot was passed
|
||||
// in, go ahead and snapshot
|
||||
if (log.get('snapshot')) {
|
||||
log.snapshot()
|
||||
}
|
||||
|
||||
if (log.get('error')) {
|
||||
log.error(log.get('error'))
|
||||
}
|
||||
|
||||
log.wrapConsoleProps()
|
||||
|
||||
this.addToLogs(log)
|
||||
|
||||
if (options.sessionInfo) {
|
||||
@@ -667,9 +652,8 @@ class LogManager {
|
||||
|
||||
this.triggerLog(log)
|
||||
|
||||
// if not current state then the log is being run
|
||||
// with no command reference, so just end the log
|
||||
if (!current) {
|
||||
// if the log isn't associated with a command, then we know it won't be retrying and we should just end it.
|
||||
if (!command || log.get('end')) {
|
||||
log.end()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user