diff --git a/lib/rules/unsafe-to-chain-command.js b/lib/rules/unsafe-to-chain-command.js index 490e9585..ac2e13d3 100644 --- a/lib/rules/unsafe-to-chain-command.js +++ b/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} + */ +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) + ) } diff --git a/tests/lib/rules/unsafe-to-chain-command.js b/tests/lib/rules/unsafe-to-chain-command.js index 64326faa..2855b59a 100644 --- a/tests/lib/rules/unsafe-to-chain-command.js +++ b/tests/lib/rules/unsafe-to-chain-command.js @@ -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/] }], + }, ], })