Skip to content

Commit e5f0504

Browse files
feat: Add 'assertion-before-screenshot' rule
2 parents 8c561bb + 0b1b4b6 commit e5f0504

File tree

6 files changed

+185
-6
lines changed

6 files changed

+185
-6
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ Rules with a check mark (✅) are enabled by default while using the `plugin:cyp
4949
|:---|:--------|:------------|
5050
|| [no-assigning-return-values](./docs/rules/no-assigning-return-values.md) | Prevent assigning return values of cy calls |
5151
|| [no-unnecessary-waiting](./docs/rules/no-unnecessary-waiting.md) | Prevent waiting for arbitrary time periods |
52+
| | [assertion-before-screenshot](./docs/rules/assertion-before-screenshot.md) | Ensure screenshots are preceded by an assertion |
5253

5354
## Chai and `no-unused-expressions`
5455

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
## Assertion Before Screenshot
2+
3+
If you take screenshots without assertions then you may get different screenshots depending on timing.
4+
5+
For example, if clicking a button makes some network calls and upon success, renders something, then the screenshot may sometimes have the new render and sometimes not.
6+
7+
This rule checks there is an assertion making sure your application state is correct before doing a screenshot. This makes sure the result of the screenshot will be consistent.
8+
9+
Invalid:
10+
11+
```
12+
cy.visit('myUrl');
13+
cy.screenshot();
14+
```
15+
16+
Valid:
17+
18+
```
19+
cy.visit('myUrl');
20+
cy.get('[data-test-id="my-element"]').should('be.visible');
21+
cy.screenshot();
22+
```

index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module.exports = {
44
rules: {
55
'no-assigning-return-values': require('./lib/rules/no-assigning-return-values'),
66
'no-unnecessary-waiting': require('./lib/rules/no-unnecessary-waiting'),
7+
'assertion-before-screenshot': require('./lib/rules/assertion-before-screenshot'),
78
},
89
configs: {
910
recommended: require('./lib/config/recommended'),
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* @fileoverview Assert on the page state before taking a screenshot, so the screenshot is consistent
3+
* @author Luke Page
4+
*/
5+
6+
'use strict'
7+
8+
const assertionCommands = [
9+
// assertions
10+
'should',
11+
'and',
12+
'contains',
13+
14+
// retries until it gets something
15+
'get',
16+
17+
// not an assertion, but unlikely to require waiting for render
18+
'scrollIntoView',
19+
'scrollTo',
20+
];
21+
22+
module.exports = {
23+
meta: {
24+
docs: {
25+
description: 'Assert on the page state before taking a screenshot, so the screenshot is consistent',
26+
category: 'Possible Errors',
27+
recommended: false,
28+
},
29+
schema: [],
30+
messages: {
31+
unexpected: 'Make an assertion on the page state before taking a screenshot',
32+
},
33+
},
34+
create (context) {
35+
return {
36+
CallExpression (node) {
37+
if (isCallingCyScreenshot(node) && !isPreviousAnAssertion(node)) {
38+
context.report({ node, messageId: 'unexpected' })
39+
}
40+
},
41+
}
42+
},
43+
}
44+
45+
function isRootCypress(node) {
46+
while(node.type === 'CallExpression') {
47+
if (node.callee.type !== 'MemberExpression') return false
48+
if (node.callee.object.type === 'Identifier' &&
49+
node.callee.object.name === 'cy') {
50+
return true
51+
}
52+
node = node.callee.object
53+
}
54+
return false
55+
}
56+
57+
function getPreviousInChain(node) {
58+
return node.type === 'CallExpression' &&
59+
node.callee.type === 'MemberExpression' &&
60+
node.callee.object.type === 'CallExpression' &&
61+
node.callee.object.callee.type === 'MemberExpression' &&
62+
node.callee.object.callee.property.type === 'Identifier' &&
63+
node.callee.object.callee.property.name
64+
}
65+
66+
function getCallExpressionCypressCommand(node) {
67+
return isRootCypress(node) &&
68+
node.callee.property.type === 'Identifier' &&
69+
node.callee.property.name
70+
}
71+
72+
function isCallingCyScreenshot (node) {
73+
return getCallExpressionCypressCommand(node) === 'screenshot'
74+
}
75+
76+
function getPreviousCypressCommand(node) {
77+
const previousInChain = getPreviousInChain(node)
78+
79+
if (previousInChain) {
80+
return previousInChain
81+
}
82+
83+
while(node.parent && !node.parent.body) {
84+
node = node.parent
85+
}
86+
87+
if (!node.parent || !node.parent.body) return null
88+
89+
const body = node.parent.body.type === 'BlockStatement' ? node.parent.body.body : node.parent.body
90+
91+
const index = body.indexOf(node)
92+
93+
// in the case of a function declaration it won't be found
94+
if (index < 0) return null
95+
96+
if (index === 0) return getPreviousCypressCommand(node.parent);
97+
98+
const previousStatement = body[index - 1]
99+
100+
if (previousStatement.type !== 'ExpressionStatement' ||
101+
previousStatement.expression.type !== 'CallExpression')
102+
return null
103+
104+
return getCallExpressionCypressCommand(previousStatement.expression)
105+
}
106+
107+
function isPreviousAnAssertion (node) {
108+
const previousCypressCommand = getPreviousCypressCommand(node)
109+
return assertionCommands.indexOf(previousCypressCommand) >= 0
110+
}

package-lock.json

Lines changed: 16 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use strict'
2+
3+
const rule = require('../../../lib/rules/assertion-before-screenshot')
4+
const RuleTester = require('eslint').RuleTester
5+
6+
const ruleTester = new RuleTester()
7+
8+
const errors = [{ messageId: 'unexpected' }]
9+
const parserOptions = { ecmaVersion: 6 }
10+
11+
ruleTester.run('assertion-before-screenshot', rule, {
12+
valid: [
13+
{ code: 'cy.get(".some-element"); cy.screenshot();', parserOptions },
14+
{ code: 'cy.get(".some-element").should("exist").screenshot();', parserOptions },
15+
{ code: 'cy.get(".some-element").should("exist").screenshot().click()', parserOptions, errors },
16+
{ code: 'cy.get(".some-element").should("exist"); if(true) cy.screenshot();', parserOptions },
17+
{ code: 'if(true) { cy.get(".some-element").should("exist"); cy.screenshot(); }', parserOptions },
18+
{ code: 'cy.get(".some-element").should("exist"); if(true) { cy.screenshot(); }', parserOptions },
19+
{ code: 'const a = () => { cy.get(".some-element").should("exist"); cy.screenshot(); }', parserOptions, errors },
20+
{ code: 'cy.get(".some-element").should("exist").and("be.visible"); cy.screenshot();', parserOptions },
21+
{ code: 'cy.get(".some-element").contains("Text"); cy.screenshot();', parserOptions },
22+
],
23+
24+
invalid: [
25+
{ code: 'cy.screenshot()', parserOptions, errors },
26+
{ code: 'cy.visit("somepage"); cy.screenshot();', parserOptions, errors },
27+
{ code: 'cy.custom(); cy.screenshot()', parserOptions, errors },
28+
{ code: 'cy.get(".some-element").click(); cy.screenshot()', parserOptions, errors },
29+
{ code: 'cy.get(".some-element").click().screenshot()', parserOptions, errors },
30+
{ code: 'if(true) { cy.get(".some-element").click(); cy.screenshot(); }', parserOptions, errors },
31+
{ code: 'cy.get(".some-element").click(); if(true) { cy.screenshot(); }', parserOptions, errors },
32+
{ code: 'cy.get(".some-element"); function a() { cy.screenshot(); }', parserOptions, errors },
33+
{ code: 'cy.get(".some-element"); const a = () => { cy.screenshot(); }', parserOptions, errors },
34+
],
35+
})

0 commit comments

Comments
 (0)