Skip to content

Commit b9f7a26

Browse files
Merge pull request #137 from passbyval/master
feat: add option to `unsafe-to-chain-command` rule to allow custom Cypress command linting
2 parents 4832d83 + a284f39 commit b9f7a26

File tree

2 files changed

+140
-22
lines changed

2 files changed

+140
-22
lines changed
Lines changed: 114 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,142 @@
11
'use strict'
22

3+
const { basename } = require('path')
4+
5+
const NAME = basename(__dirname)
6+
const DESCRIPTION = 'Actions should be in the end of chains, not in the middle'
7+
8+
/**
9+
* Commands listed in the documentation with text: 'It is unsafe to chain further commands that rely on the subject after xxx.'
10+
* See {@link https://docs.cypress.io/guides/core-concepts/retry-ability#Actions-should-be-at-the-end-of-chains-not-the-middle Actions should be at the end of chains, not the middle}
11+
* for more information.
12+
*
13+
* @type {string[]}
14+
*/
15+
const unsafeToChainActions = [
16+
'blur',
17+
'clear',
18+
'click',
19+
'check',
20+
'dblclick',
21+
'each',
22+
'focus',
23+
'rightclick',
24+
'screenshot',
25+
'scrollIntoView',
26+
'scrollTo',
27+
'select',
28+
'selectFile',
29+
'spread',
30+
'submit',
31+
'type',
32+
'trigger',
33+
'uncheck',
34+
'within',
35+
]
36+
37+
/**
38+
* @type {import('eslint').Rule.RuleMetaData['schema']}
39+
*/
40+
const schema = {
41+
title: NAME,
42+
description: DESCRIPTION,
43+
type: 'object',
44+
properties: {
45+
methods: {
46+
type: 'array',
47+
description:
48+
'An additional list of methods to check for unsafe chaining.',
49+
default: [],
50+
},
51+
},
52+
}
53+
54+
/**
55+
* @param {import('eslint').Rule.RuleContext} context
56+
* @returns {Record<string, any>}
57+
*/
58+
const getDefaultOptions = (context) => {
59+
return Object.entries(schema.properties).reduce((acc, [key, value]) => {
60+
if (!(value.default in value)) return acc
61+
62+
return {
63+
...acc,
64+
[key]: value.default,
65+
}
66+
}, context.options[0] || {})
67+
}
68+
69+
/** @type {import('eslint').Rule.RuleModule} */
370
module.exports = {
471
meta: {
572
docs: {
6-
description: 'Actions should be in the end of chains, not in the middle',
73+
description: DESCRIPTION,
774
category: 'Possible Errors',
875
recommended: true,
976
url: 'https://docs.cypress.io/guides/core-concepts/retry-ability#Actions-should-be-at-the-end-of-chains-not-the-middle',
1077
},
11-
schema: [],
78+
schema: [schema],
1279
messages: {
13-
unexpected: 'It is unsafe to chain further commands that rely on the subject after this command. It is best to split the chain, chaining again from `cy.` in a next command line.',
80+
unexpected:
81+
'It is unsafe to chain further commands that rely on the subject after this command. It is best to split the chain, chaining again from `cy.` in a next command line.',
1482
},
1583
},
1684
create (context) {
85+
const { methods } = getDefaultOptions(context)
86+
1787
return {
1888
CallExpression (node) {
19-
if (isRootCypress(node) && isActionUnsafeToChain(node) && node.parent.type === 'MemberExpression') {
20-
context.report({ node, messageId: 'unexpected' })
89+
if (
90+
isRootCypress(node) &&
91+
isActionUnsafeToChain(node, methods) &&
92+
node.parent.type === 'MemberExpression'
93+
) {
94+
context.report({
95+
node,
96+
messageId: 'unexpected',
97+
})
2198
}
2299
},
23100
}
24101
},
25102
}
26103

27-
function isRootCypress (node) {
28-
while (node.type === 'CallExpression') {
29-
if (node.callee.type !== 'MemberExpression') return false
30-
31-
if (node.callee.object.type === 'Identifier' &&
32-
node.callee.object.name === 'cy') {
33-
return true
34-
}
104+
/**
105+
* @param {import('estree').Node} node
106+
* @returns {boolean}
107+
*/
108+
const isRootCypress = (node) => {
109+
if (
110+
node.type !== 'CallExpression' ||
111+
node.callee.type !== 'MemberExpression'
112+
) {
113+
return false
114+
}
35115

36-
node = node.callee.object
116+
if (
117+
node.callee.object.type === 'Identifier' &&
118+
node.callee.object.name === 'cy'
119+
) {
120+
return true
37121
}
38122

39-
return false
123+
return isRootCypress(node.callee.object)
40124
}
41125

42-
function isActionUnsafeToChain (node) {
43-
// commands listed in the documentation with text: 'It is unsafe to chain further commands that rely on the subject after xxx'
44-
const unsafeToChainActions = ['blur', 'clear', 'click', 'check', 'dblclick', 'each', 'focus', 'rightclick', 'screenshot', 'scrollIntoView', 'scrollTo', 'select', 'selectFile', 'spread', 'submit', 'type', 'trigger', 'uncheck', 'within']
126+
/**
127+
* @param {import('estree').Node} node
128+
* @param {(string | RegExp)[]} additionalMethods
129+
*/
130+
const isActionUnsafeToChain = (node, additionalMethods = []) => {
131+
const unsafeActionsRegex = new RegExp([
132+
...unsafeToChainActions,
133+
...additionalMethods.map((method) => method instanceof RegExp ? method.source : method),
134+
].join('|'))
45135

46-
return node.callee && node.callee.property && node.callee.property.type === 'Identifier' && unsafeToChainActions.includes(node.callee.property.name)
136+
return (
137+
node.callee &&
138+
node.callee.property &&
139+
node.callee.property.type === 'Identifier' &&
140+
unsafeActionsRegex.test(node.callee.property.name)
141+
)
47142
}

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

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,34 @@ const parserOptions = { ecmaVersion: 6 }
1010

1111
ruleTester.run('action-ends-chain', rule, {
1212
valid: [
13-
{ code: 'cy.get("new-todo").type("todo A{enter}"); cy.get("new-todo").type("todo B{enter}"); cy.get("new-todo").should("have.class", "active");', parserOptions },
13+
{
14+
code: 'cy.get("new-todo").type("todo A{enter}"); cy.get("new-todo").type("todo B{enter}"); cy.get("new-todo").should("have.class", "active");',
15+
parserOptions,
16+
},
1417
],
1518

1619
invalid: [
17-
{ code: 'cy.get("new-todo").type("todo A{enter}").should("have.class", "active");', parserOptions, errors },
18-
{ code: 'cy.get("new-todo").type("todo A{enter}").type("todo B{enter}");', parserOptions, errors },
20+
{
21+
code: 'cy.get("new-todo").type("todo A{enter}").should("have.class", "active");',
22+
parserOptions,
23+
errors,
24+
},
25+
{
26+
code: 'cy.get("new-todo").type("todo A{enter}").type("todo B{enter}");',
27+
parserOptions,
28+
errors,
29+
},
30+
{
31+
code: 'cy.get("new-todo").customType("todo A{enter}").customClick();',
32+
parserOptions,
33+
errors,
34+
options: [{ methods: ['customType', 'customClick'] }],
35+
},
36+
{
37+
code: 'cy.get("new-todo").customPress("Enter").customScroll();',
38+
parserOptions,
39+
errors,
40+
options: [{ methods: [/customPress/, /customScroll/] }],
41+
},
1942
],
2043
})

0 commit comments

Comments
 (0)