Skip to content

Commit a02e4ce

Browse files
committed
Add new rule - assertion before screenshot
1 parent 8c561bb commit a02e4ce

File tree

6 files changed

+171
-6
lines changed

6 files changed

+171
-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: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
module.exports = {
9+
meta: {
10+
docs: {
11+
description: 'Assert on the page state before taking a screenshot, so the screenshot is consistent',
12+
category: 'Possible Errors',
13+
recommended: true,
14+
},
15+
schema: [],
16+
messages: {
17+
unexpected: 'Make an assertion on the page state before taking a screenshot',
18+
},
19+
},
20+
create (context) {
21+
return {
22+
CallExpression (node) {
23+
if (isCallingCyScreenshot(node) && !isPreviousAnAssertion(node)) {
24+
context.report({ node, messageId: 'unexpected' })
25+
}
26+
},
27+
}
28+
},
29+
}
30+
31+
function isRootCypress(node) {
32+
while(node.type === 'CallExpression') {
33+
if (node.callee.type === 'MemberExpression' &&
34+
node.callee.object.type === 'Identifier' &&
35+
node.callee.object.name === 'cy') {
36+
return true
37+
}
38+
node = node.callee.object
39+
}
40+
return false
41+
}
42+
43+
function getPreviousInChain(node) {
44+
return node.type === 'CallExpression' &&
45+
node.callee.type === 'MemberExpression' &&
46+
node.callee.object.type === 'CallExpression' &&
47+
node.callee.object.callee.type === 'MemberExpression' &&
48+
node.callee.object.callee.property.type === 'Identifier' &&
49+
node.callee.object.callee.property.name
50+
}
51+
52+
function getCallExpressionCypressCommand(node) {
53+
return isRootCypress(node) &&
54+
node.callee.property.type === 'Identifier' &&
55+
node.callee.property.name
56+
}
57+
58+
function isCallingCyScreenshot (node) {
59+
return getCallExpressionCypressCommand(node) === 'screenshot'
60+
}
61+
62+
function getPreviousCypressCommand(node) {
63+
const previousInChain = getPreviousInChain(node)
64+
65+
if (previousInChain) {
66+
return previousInChain
67+
}
68+
69+
while(node.parent && !node.parent.body) {
70+
node = node.parent
71+
}
72+
73+
if (!node.parent || !node.parent.body) return null
74+
75+
const body = node.parent.body.type === 'BlockStatement' ? node.parent.body.body : node.parent.body
76+
77+
const index = body.indexOf(node)
78+
79+
// in the case of a function declaration it won't be found
80+
if (index < 0) return null
81+
82+
if (index === 0) return getPreviousCypressCommand(node.parent);
83+
84+
const previousStatement = body[index - 1]
85+
86+
if (previousStatement.type !== 'ExpressionStatement' ||
87+
previousStatement.expression.type !== 'CallExpression')
88+
return null
89+
90+
return getCallExpressionCypressCommand(previousStatement.expression)
91+
}
92+
93+
function isPreviousAnAssertion (node) {
94+
const previousCypressCommand = getPreviousCypressCommand(node)
95+
return previousCypressCommand === 'get' ||
96+
previousCypressCommand === 'should'
97+
}

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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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").screenshot();', parserOptions },
15+
{ code: 'cy.get(".some-element").should("be.visible"); cy.screenshot();', parserOptions },
16+
{ code: 'cy.get(".some-element").screenshot().click()', parserOptions, errors },
17+
{ code: 'cy.get(".some-element"); if(true) cy.screenshot();', parserOptions },
18+
{ code: 'if(true) { cy.get(".some-element"); cy.screenshot(); }', parserOptions },
19+
{ code: 'cy.get(".some-element"); if(true) { cy.screenshot(); }', parserOptions },
20+
{ code: 'const a = () => { cy.get(".some-element"); cy.screenshot(); }', parserOptions, errors },
21+
],
22+
23+
invalid: [
24+
{ code: 'cy.screenshot()', parserOptions, errors },
25+
{ code: 'cy.visit("somepage"); cy.screenshot();', parserOptions, errors },
26+
{ code: 'cy.custom(); cy.screenshot()', parserOptions, errors },
27+
{ code: 'cy.get(".some-element").click(); cy.screenshot()', parserOptions, errors },
28+
{ code: 'cy.get(".some-element").click().screenshot()', parserOptions, errors },
29+
{ code: 'if(true) { cy.get(".some-element").click(); cy.screenshot(); }', parserOptions, errors },
30+
{ code: 'cy.get(".some-element").click(); if(true) { cy.screenshot(); }', parserOptions, errors },
31+
{ code: 'cy.get(".some-element"); function a() { cy.screenshot(); }', parserOptions, errors },
32+
{ code: 'cy.get(".some-element"); const a = () => { cy.screenshot(); }', parserOptions, errors },
33+
],
34+
})

0 commit comments

Comments
 (0)