Skip to content

Commit b6c4ba1

Browse files
Blue Fbrian-mann
andauthored
fix: No unnecessary snapshotting (#19311)
Co-authored-by: Brian Mann <[email protected]>
1 parent 01f0261 commit b6c4ba1

File tree

4 files changed

+144
-11
lines changed

4 files changed

+144
-11
lines changed

packages/driver/cypress/integration/commands/assertions_spec.js

Lines changed: 107 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,39 @@
11
const { assertLogLength } = require('../../support/utils')
22
const { $, _ } = Cypress
33

4+
const captureCommands = () => {
5+
const commands = []
6+
7+
let current
8+
9+
cy.on('command:start', (command) => {
10+
current = command
11+
commands.push({
12+
name: command.attributes.name,
13+
snapshots: 0,
14+
retries: 0,
15+
})
16+
})
17+
18+
cy.on('command:retry', () => {
19+
commands[commands.length - 1].retries++
20+
})
21+
22+
cy.on('snapshot', () => {
23+
// Snapshots can occur outside the context of a command - for example, `expect(foo).to.exist` without any wrapping cy command.
24+
// So we keep track of the current command when one starts, and if we're not inside that, create an 'empty' command
25+
// for the snapshot to belong to
26+
if (!commands.length || current !== cy.state('current')) {
27+
current = null
28+
commands.push({ name: null, snapshots: 0, retries: 0 })
29+
}
30+
31+
commands[commands.length - 1].snapshots++
32+
})
33+
34+
return () => _.cloneDeep(commands)
35+
}
36+
437
describe('src/cy/commands/assertions', () => {
538
before(() => {
639
cy
@@ -10,10 +43,14 @@ describe('src/cy/commands/assertions', () => {
1043
})
1144
})
1245

46+
let testCommands
47+
1348
beforeEach(function () {
1449
const doc = cy.state('document')
1550

1651
$(doc.body).empty().html(this.body)
52+
53+
testCommands = captureCommands()
1754
})
1855

1956
context('#should', () => {
@@ -29,8 +66,14 @@ describe('src/cy/commands/assertions', () => {
2966
})
3067

3168
it('returns the subject for chainability', () => {
32-
cy.noop({ foo: 'bar' }).should('deep.eq', { foo: 'bar' }).then((obj) => {
33-
expect(obj).to.deep.eq({ foo: 'bar' })
69+
cy
70+
.noop({ foo: 'bar' }).should('deep.eq', { foo: 'bar' })
71+
.then((obj) => {
72+
expect(testCommands()).to.eql([
73+
{ name: 'noop', snapshots: 0, retries: 0 },
74+
{ name: 'should', snapshots: 1, retries: 0 },
75+
{ name: 'then', snapshots: 0, retries: 0 },
76+
])
3477
})
3578
})
3679

@@ -121,11 +164,19 @@ describe('src/cy/commands/assertions', () => {
121164
cy.wrap(obj).then(() => {
122165
setTimeout(() => {
123166
obj.foo = 'baz'
124-
}
125-
, 100)
167+
}, 100)
126168

127169
cy.wrap(obj)
128-
}).should('deep.eq', { foo: 'baz' })
170+
})
171+
.should('deep.eq', { foo: 'baz' })
172+
.then(() => {
173+
expect(testCommands()).to.containSubset([
174+
{ name: 'wrap', snapshots: 1, retries: 0 },
175+
{ name: 'then', snapshots: 0, retries: 0 },
176+
{ name: 'wrap', snapshots: 2, retries: (r) => r > 1 },
177+
{ name: 'then', snapshots: 0, retries: 0 },
178+
])
179+
})
129180
})
130181

131182
// https://github.com/cypress-io/cypress/issues/16006
@@ -151,6 +202,18 @@ describe('src/cy/commands/assertions', () => {
151202
cy.get('button:first').should(($button) => {
152203
expect($button).to.have.class('ready')
153204
})
205+
.then(() => {
206+
expect(testCommands()).to.eql([
207+
// cy.get() has 2 snapshots, 1 for itself, and 1
208+
// for the .should(...) assertion.
209+
210+
// TODO: Investigate whether or not the 2 commands are
211+
// snapshotted at the same time. If there's no tick between
212+
// them, we could reuse the snapshots
213+
{ name: 'get', snapshots: 2, retries: 2 },
214+
{ name: 'then', snapshots: 0, retries: 0 },
215+
])
216+
})
154217
})
155218

156219
it('works with regular objects', () => {
@@ -1475,7 +1538,7 @@ describe('src/cy/commands/assertions', () => {
14751538
done()
14761539
})
14771540

1478-
cy.get('div').should(($divs) => {
1541+
cy.get('div', { timeout: 100 }).should(($divs) => {
14791542
expect($divs, 'Filter should have 1 items').to.have.length(1)
14801543
})
14811544
})
@@ -2927,4 +2990,42 @@ describe('src/cy/commands/assertions', () => {
29272990
cy.get('.foo').should('not.exist')
29282991
})
29292992
})
2993+
2994+
context('implicit assertions', () => {
2995+
// https://github.com/cypress-io/cypress/issues/18549
2996+
// A targeted test for the above issue - in the absence of retries, only a single snapshot
2997+
// should be taken.
2998+
it('only snapshots once when failing to find DOM elements and not retrying', (done) => {
2999+
cy.on('fail', (err) => {
3000+
expect(testCommands()).to.eql([{
3001+
name: 'get',
3002+
snapshots: 1,
3003+
retries: 0,
3004+
}])
3005+
3006+
done()
3007+
})
3008+
3009+
cy.get('.badId', { timeout: 0 })
3010+
})
3011+
3012+
// https://github.com/cypress-io/cypress/issues/18549
3013+
// This issue was also causing two DOM snapshots to be taken every 50ms
3014+
// while waiting for an element to exist. The first test is sufficient to
3015+
// prevent regressions of the specific issue, but this one is intended to
3016+
// more generally assert that retries do not trigger multiple snapshots.
3017+
it('only snapshots once when retrying assertions', (done) => {
3018+
cy.on('fail', (err) => {
3019+
expect(testCommands()).to.containSubset([{
3020+
name: 'get',
3021+
snapshots: 1,
3022+
retries: (v) => v > 1,
3023+
}])
3024+
3025+
done()
3026+
})
3027+
3028+
cy.get('.badId', { timeout: 1000 })
3029+
})
3030+
})
29303031
})

packages/driver/src/cy/assertions.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,19 @@ export const create = (Cypress, cy) => {
151151
obj.$el = $dom.wrap(value)
152152
}
153153

154-
// if we are simply verifying the upcoming
155-
// assertions then do not immediately end or snapshot
156-
// else do
154+
// `verifying` represents whether we're deciding whether or not to resolve
155+
// a command (true) or of we're actually performing a user-facing assertion
156+
// (false).
157+
158+
// If we're verifying upcoming assertions (implicit or explicit),
159+
// then we don't need to take a DOM snapshot - one will be taken later when
160+
// retries time out or the command otherwise entirely fails or passes.
161+
// We save the error on _error because we may use it to construct the
162+
// timeout error which we eventually do display to the user.
163+
164+
// If we're actually performing an assertion which will be displayed to the
165+
// user though, then we want to take a DOM snapshot and display this error
166+
// (if any) in the log message on screen.
157167
if (verifying) {
158168
obj._error = error
159169
} else {
@@ -283,6 +293,7 @@ export const create = (Cypress, cy) => {
283293
}
284294

285295
const onPassFn = () => {
296+
cy.state('overrideAssert', undefined)
286297
if (_.isFunction(callbacks.onPass)) {
287298
return callbacks.onPass.call(this, cmds, options.assertions)
288299
}
@@ -305,6 +316,7 @@ export const create = (Cypress, cy) => {
305316
err = e2
306317
}
307318

319+
cy.state('overrideAssert', undefined)
308320
err.isDefaultAssertionErr = isDefaultAssertionErr
309321

310322
options.error = err
@@ -340,6 +352,24 @@ export const create = (Cypress, cy) => {
340352
// bail if we have no assertions and apply
341353
// the default assertions if applicable
342354
if (!cmds.length) {
355+
// In general in cypress, when assertions fail we want to take a DOM
356+
// snapshot to display to the user. In this case though, when we invoke
357+
// ensureExistence, we're not going to display the error (if there is
358+
// one) to the user - we're only deciding whether to resolve this current
359+
// command (assertions pass) or fail (and probably retry). A DOM snapshot
360+
// isn't necessary in either case - one will be taken later as part of the
361+
// command (if they pass) or when we time out retrying.
362+
363+
// Chai assertions have a signature of (passed, message, value, actual,
364+
// expected, error). Our assertFn, defined earlier in the file, adds
365+
// on a 7th arg, "verifying", which defaults to false. We here override
366+
// the assert function with our own, which just invokes the old one
367+
// with verifying = true. This override is cleaned up immediately
368+
// afterwards, in either onPassFn or onFailFn.
369+
cy.state('overrideAssert', function (...args) {
370+
return assertFn.apply(this, args.concat(true) as any)
371+
})
372+
343373
return Promise
344374
.try(ensureExistence)
345375
.then(onPassFn)
@@ -494,8 +524,6 @@ export const create = (Cypress, cy) => {
494524
return cy.state('overrideAssert', undefined)
495525
}
496526

497-
// store this in case our test ends early
498-
// and we reset between tests
499527
cy.state('overrideAssert', overrideAssert)
500528

501529
return Promise

packages/driver/src/cy/snapshots.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export const create = ($$, state) => {
132132
}
133133

134134
const createSnapshot = (name, $elToHighlight) => {
135+
Cypress.action('cy:snapshot', name)
135136
// create a unique selector for this el
136137
// but only IF the subject is truly an element. For example
137138
// we might be wrapping a primitive like "$([1, 2]).first()"

packages/driver/src/cypress.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,9 @@ class $Cypress {
515515
case 'cy:scrolled':
516516
return this.emit('scrolled', ...args)
517517

518+
case 'cy:snapshot':
519+
return this.emit('snapshot', ...args)
520+
518521
case 'app:uncaught:exception':
519522
return this.emitMap('uncaught:exception', ...args)
520523

0 commit comments

Comments
 (0)