Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add option to unsafe-to-chain-command rule to allow custom Cypress command linting #137

Merged
merged 3 commits into from Aug 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
133 changes: 114 additions & 19 deletions lib/rules/unsafe-to-chain-command.js
@@ -1,47 +1,142 @@
'use strict'

const { basename } = require('path')

const NAME = basename(__dirname)
const DESCRIPTION = 'Actions should be in the end of chains, not in the middle'

/**
* Commands listed in the documentation with text: 'It is unsafe to chain further commands that rely on the subject after xxx.'
* 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}
* for more information.
*
* @type {string[]}
*/
const unsafeToChainActions = [
'blur',
'clear',
'click',
'check',
'dblclick',
'each',
'focus',
'rightclick',
'screenshot',
'scrollIntoView',
'scrollTo',
'select',
'selectFile',
'spread',
'submit',
'type',
'trigger',
'uncheck',
'within',
]

/**
* @type {import('eslint').Rule.RuleMetaData['schema']}
*/
const schema = {
title: NAME,
description: DESCRIPTION,
type: 'object',
properties: {
methods: {
type: 'array',
description:
'An additional list of methods to check for unsafe chaining.',
default: [],
},
},
}

/**
* @param {import('eslint').Rule.RuleContext} context
* @returns {Record<string, any>}
*/
const getDefaultOptions = (context) => {
return Object.entries(schema.properties).reduce((acc, [key, value]) => {
if (!(value.default in value)) return acc

return {
...acc,
[key]: value.default,
}
}, context.options[0] || {})
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
docs: {
description: 'Actions should be in the end of chains, not in the middle',
description: DESCRIPTION,
category: 'Possible Errors',
recommended: true,
url: 'https://docs.cypress.io/guides/core-concepts/retry-ability#Actions-should-be-at-the-end-of-chains-not-the-middle',
},
schema: [],
schema: [schema],
messages: {
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.',
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.',
},
},
create (context) {
const { methods } = getDefaultOptions(context)

return {
CallExpression (node) {
if (isRootCypress(node) && isActionUnsafeToChain(node) && node.parent.type === 'MemberExpression') {
context.report({ node, messageId: 'unexpected' })
if (
isRootCypress(node) &&
isActionUnsafeToChain(node, methods) &&
node.parent.type === 'MemberExpression'
) {
context.report({
node,
messageId: 'unexpected',
})
}
},
}
},
}

function isRootCypress (node) {
while (node.type === 'CallExpression') {
if (node.callee.type !== 'MemberExpression') return false

if (node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'cy') {
return true
}
/**
* @param {import('estree').Node} node
* @returns {boolean}
*/
const isRootCypress = (node) => {
if (
node.type !== 'CallExpression' ||
node.callee.type !== 'MemberExpression'
) {
return false
}

node = node.callee.object
if (
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'cy'
) {
return true
}

return false
return isRootCypress(node.callee.object)
}

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

return node.callee && node.callee.property && node.callee.property.type === 'Identifier' && unsafeToChainActions.includes(node.callee.property.name)
return (
node.callee &&
node.callee.property &&
node.callee.property.type === 'Identifier' &&
unsafeActionsRegex.test(node.callee.property.name)
)
}
29 changes: 26 additions & 3 deletions tests/lib/rules/unsafe-to-chain-command.js
Expand Up @@ -10,11 +10,34 @@ const parserOptions = { ecmaVersion: 6 }

ruleTester.run('action-ends-chain', rule, {
valid: [
{ 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 },
{
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,
},
],

invalid: [
{ code: 'cy.get("new-todo").type("todo A{enter}").should("have.class", "active");', parserOptions, errors },
{ code: 'cy.get("new-todo").type("todo A{enter}").type("todo B{enter}");', parserOptions, errors },
{
code: 'cy.get("new-todo").type("todo A{enter}").should("have.class", "active");',
parserOptions,
errors,
},
{
code: 'cy.get("new-todo").type("todo A{enter}").type("todo B{enter}");',
parserOptions,
errors,
},
{
code: 'cy.get("new-todo").customType("todo A{enter}").customClick();',
parserOptions,
errors,
options: [{ methods: ['customType', 'customClick'] }],
},
{
code: 'cy.get("new-todo").customPress("Enter").customScroll();',
parserOptions,
errors,
options: [{ methods: [/customPress/, /customScroll/] }],
},
],
})