Skip to content

Commit c0b7e3b

Browse files
authored
feat: added no-and rule to ensure .and() follows certain commands (#310)
1 parent f5ccf77 commit c0b7e3b

File tree

11 files changed

+238
-77
lines changed

11 files changed

+238
-77
lines changed

docs/rules/no-and.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# cypress/no-and
2+
3+
📝 Enforce `.should()` over `.and()` for starting assertion chains.
4+
5+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
6+
7+
<!-- end auto-generated rule header -->
8+
9+
Cypress's [.and()](https://on.cypress.io/and) is an alias for [.should()](https://on.cypress.io/should). Using `.and()` reads naturally when it follows `.should()` or `.contains()` — it continues an assertion chain like "should be empty **and** be hidden." In other positions, `.should()` is clearer.
10+
11+
## Rule Details
12+
13+
This rule allows `.and()` only when it immediately follows `.should()`, `.and()`, or `.contains()`. In all other positions, it flags `.and()` and auto-fixes it to `.should()`.
14+
15+
Examples of **incorrect** code for this rule:
16+
17+
```js
18+
cy.get('elem').and('have.text', 'blah')
19+
cy.get('foo').find('.bar').and('have.class', 'active')
20+
cy.get('foo').click().and('be.disabled')
21+
```
22+
23+
Examples of **correct** code for this rule:
24+
25+
```js
26+
cy.get('elem').should('have.text', 'blah')
27+
cy.get('.err').should('be.empty').and('be.hidden')
28+
cy.contains('Login').and('be.visible')
29+
cy.get('foo').should('be.visible').and('have.text', 'bar').and('have.class', 'active')
30+
```
31+
32+
## When Not To Use It
33+
34+
If you prefer using `.and()` interchangeably with `.should()` in all positions, turn this rule off.

lib/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const plugin = {
66
configs: {},
77
rules: {
88
'assertion-before-screenshot': require('./rules/assertion-before-screenshot'),
9+
'no-and': require('./rules/no-and'),
910
'no-assigning-return-values': require('./rules/no-assigning-return-values'),
1011
'no-async-before': require('./rules/no-async-before'),
1112
'no-async-tests': require('./rules/no-async-tests'),

lib/rules/assertion-before-screenshot.js

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict'
22

3+
const { isRootCypress } = require('../utils/is-root-cypress')
4+
35
const assertionCommands = [
46
// assertions
57
'should',
@@ -39,21 +41,6 @@ module.exports = {
3941
},
4042
}
4143

42-
function isRootCypress(node) {
43-
while (node.type === 'CallExpression') {
44-
if (node.callee.type !== 'MemberExpression') return false
45-
46-
if (node.callee.object.type === 'Identifier'
47-
&& node.callee.object.name === 'cy') {
48-
return true
49-
}
50-
51-
node = node.callee.object
52-
}
53-
54-
return false
55-
}
56-
5744
function getPreviousInChain(node) {
5845
return node.type === 'CallExpression'
5946
&& node.callee.type === 'MemberExpression'

lib/rules/no-and.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use strict'
2+
3+
const { isRootCypress } = require('../utils/is-root-cypress')
4+
5+
/** @type {import('eslint').Rule.RuleModule} */
6+
module.exports = {
7+
meta: {
8+
type: 'suggestion',
9+
docs: {
10+
description: 'enforce .should() over .and() for starting assertion chains',
11+
recommended: false,
12+
url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/no-and.md',
13+
},
14+
fixable: 'code',
15+
schema: [],
16+
messages: {
17+
unexpected:
18+
'Use .should() here; .and() is only allowed after .should(), .and(), or .contains().',
19+
},
20+
},
21+
22+
create(context) {
23+
const allowAndAfter = new Set(['should', 'and', 'contains'])
24+
25+
function isAllowedAfter(node) {
26+
const object = node.callee.object
27+
if (
28+
object
29+
&& object.type === 'CallExpression'
30+
&& object.callee.type === 'MemberExpression'
31+
) {
32+
return allowAndAfter.has(object.callee.property.name)
33+
}
34+
return false
35+
}
36+
37+
return {
38+
CallExpression(node) {
39+
if (
40+
node.callee.type === 'MemberExpression'
41+
&& node.callee.property.name === 'and'
42+
&& isRootCypress(node)
43+
&& !isAllowedAfter(node)
44+
) {
45+
context.report({
46+
node,
47+
messageId: 'unexpected',
48+
fix(fixer) {
49+
return fixer.replaceText(node.callee.property, 'should')
50+
},
51+
})
52+
}
53+
},
54+
}
55+
},
56+
}

lib/rules/no-chained-get.js

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict'
22

3+
const { isRootCypress } = require('../utils/is-root-cypress')
4+
35
// ------------------------------------------------------------------------------
46
// Rule Definition
57
// ------------------------------------------------------------------------------
@@ -21,24 +23,6 @@ module.exports = {
2123
},
2224

2325
create(context) {
24-
const isRootCypress = (node) => {
25-
if (
26-
node.type !== 'CallExpression'
27-
|| node.callee.type !== 'MemberExpression'
28-
) {
29-
return false
30-
}
31-
32-
if (
33-
node.callee.object.type === 'Identifier'
34-
&& node.callee.object.name === 'cy'
35-
) {
36-
return true
37-
}
38-
39-
return isRootCypress(node.callee.object)
40-
}
41-
4226
const hasChainedGet = (node) => {
4327
// Check if this node is a get() call
4428
const isGetCall

lib/rules/no-debug.js

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict'
22

3+
const { isRootCypress } = require('../utils/is-root-cypress')
4+
35
// ------------------------------------------------------------------------------
46
// Rule Definition
57
// ------------------------------------------------------------------------------
@@ -33,24 +35,14 @@ module.exports = {
3335
&& node.callee.property.name === 'debug'
3436
}
3537

36-
function isCypressCall(node) {
37-
if (!node.callee || node.callee.type !== 'MemberExpression') {
38-
return false
39-
}
40-
if (node.callee.object.type === 'Identifier' && node.callee.object.name === 'cy') {
41-
return true
42-
}
43-
return isCypressCall(node.callee.object)
44-
}
45-
4638
// ----------------------------------------------------------------------
4739
// Public
4840
// ----------------------------------------------------------------------
4941

5042
return {
5143

5244
CallExpression(node) {
53-
if (isCypressCall(node) && isCallingDebug(node)) {
45+
if (isRootCypress(node) && isCallingDebug(node)) {
5446
context.report({ node, messageId: 'unexpected' })
5547
}
5648
},

lib/rules/no-force.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ module.exports = {
3333
&& allowedMethods.includes(node.property.name)
3434
}
3535

36+
// Only the segment whose callee.object is `cy` (not full chain root):
37+
// deepCheck walks parents from that node to find `.click()` / `{ force }`.
3638
function isCypressCall(node) {
3739
return node.callee.type === 'MemberExpression'
3840
&& node.callee.object.type === 'Identifier'

lib/rules/no-pause.js

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use strict'
22

3+
const { isRootCypress } = require('../utils/is-root-cypress')
4+
35
// ------------------------------------------------------------------------------
46
// Rule Definition
57
// ------------------------------------------------------------------------------
@@ -33,24 +35,14 @@ module.exports = {
3335
&& node.callee.property.name === 'pause'
3436
}
3537

36-
function isCypressCall(node) {
37-
if (!node.callee || node.callee.type !== 'MemberExpression') {
38-
return false
39-
}
40-
if (node.callee.object.type === 'Identifier' && node.callee.object.name === 'cy') {
41-
return true
42-
}
43-
return isCypressCall(node.callee.object)
44-
}
45-
4638
// ----------------------------------------------------------------------
4739
// Public
4840
// ----------------------------------------------------------------------
4941

5042
return {
5143

5244
CallExpression(node) {
53-
if (isCypressCall(node) && isCallingPause(node)) {
45+
if (isRootCypress(node) && isCallingPause(node)) {
5446
context.report({ node, messageId: 'unexpected' })
5547
}
5648
},

lib/rules/unsafe-to-chain-command.js

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22

33
const { basename } = require('path')
4+
const { isRootCypress } = require('../utils/is-root-cypress')
45

56
const NAME = basename(__dirname)
67
const DESCRIPTION = 'disallow actions within chains'
@@ -103,28 +104,6 @@ module.exports = {
103104
},
104105
}
105106

106-
/**
107-
* @param {import('estree').Node} node
108-
* @returns {boolean}
109-
*/
110-
const isRootCypress = (node) => {
111-
if (
112-
node.type !== 'CallExpression'
113-
|| node.callee.type !== 'MemberExpression'
114-
) {
115-
return false
116-
}
117-
118-
if (
119-
node.callee.object.type === 'Identifier'
120-
&& node.callee.object.name === 'cy'
121-
) {
122-
return true
123-
}
124-
125-
return isRootCypress(node.callee.object)
126-
}
127-
128107
/**
129108
* @param {import('estree').Node} node
130109
* @param {(string | RegExp)[]} additionalMethods

lib/utils/is-root-cypress.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use strict'
2+
3+
/**
4+
* Whether `node` is a CallExpression that belongs to a Cypress command chain
5+
* rooted at `cy` (walks `callee.object` through nested calls).
6+
*
7+
* @param {import('estree').Node} node
8+
* @returns {boolean}
9+
*/
10+
function isRootCypress(node) {
11+
if (
12+
node.type !== 'CallExpression'
13+
|| node.callee.type !== 'MemberExpression'
14+
) {
15+
return false
16+
}
17+
18+
if (
19+
node.callee.object.type === 'Identifier'
20+
&& node.callee.object.name === 'cy'
21+
) {
22+
return true
23+
}
24+
25+
return isRootCypress(node.callee.object)
26+
}
27+
28+
module.exports = { isRootCypress }

0 commit comments

Comments
 (0)