From 49c6eb32fa4becc276599697b733815264d71754 Mon Sep 17 00:00:00 2001 From: Valerie Rutsch Date: Sun, 6 Aug 2023 21:53:12 -0700 Subject: [PATCH 1/3] feat(pencil): add option for unsafe chaining rule to check for custom cypress methods --- lib/rules/unsafe-to-chain-command.js | 112 +++++++++++++++++---- tests/lib/rules/unsafe-to-chain-command.js | 29 +++++- 2 files changed, 120 insertions(+), 21 deletions(-) diff --git a/lib/rules/unsafe-to-chain-command.js b/lib/rules/unsafe-to-chain-command.js index 490e9585..fb959338 100644 --- a/lib/rules/unsafe-to-chain-command.js +++ b/lib/rules/unsafe-to-chain-command.js @@ -1,23 +1,94 @@ '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. + */ +const unsafeToChainActions = [ + 'blur', + 'clear', + 'click', + 'check', + 'dblclick', + 'each', + 'focus', + 'rightclick', + 'screenshot', + 'scrollIntoView', + 'scrollTo', + 'select', + 'selectFile', + 'spread', + 'submit', + 'type', + 'trigger', + 'uncheck', + 'within', +] + +const getDefaultOptions = (schema, context) => { + return { + ...Object.entries(schema.properties).reduce((acc, [key, value]) => { + if (value.default === undefined) return acc + + return { + ...acc, + [key]: value.default, + } + }, {}), + ...context.options[0], + } +} + +const schema = { + title: NAME, + description: DESCRIPTION, + type: 'object', + properties: { + methods: { + type: 'array', + description: + 'An additional list of methods to check for unsafe chaining.', + default: [], + }, + }, +} + +/** @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(schema, 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', + }) } }, } @@ -25,23 +96,28 @@ module.exports = { } 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 - } + if (node.type !== 'CallExpression' || node.callee.type !== 'MemberExpression') return - 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'] +function 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/] }], + }, ], }) From 071a41bcf8561453005cf3bfeb1c160acfcb516b Mon Sep 17 00:00:00 2001 From: Valerie Rutsch Date: Sun, 6 Aug 2023 22:13:09 -0700 Subject: [PATCH 2/3] refactor(pencil): add jsdoc types. use 'in' operator --- lib/rules/unsafe-to-chain-command.js | 55 +++++++++++++++++++--------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/lib/rules/unsafe-to-chain-command.js b/lib/rules/unsafe-to-chain-command.js index fb959338..6c644f3e 100644 --- a/lib/rules/unsafe-to-chain-command.js +++ b/lib/rules/unsafe-to-chain-command.js @@ -9,6 +9,8 @@ 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', @@ -32,20 +34,9 @@ const unsafeToChainActions = [ 'within', ] -const getDefaultOptions = (schema, context) => { - return { - ...Object.entries(schema.properties).reduce((acc, [key, value]) => { - if (value.default === undefined) return acc - - return { - ...acc, - [key]: value.default, - } - }, {}), - ...context.options[0], - } -} - +/** + * @type {import('eslint').Rule.RuleMetaData['schema']} + */ const schema = { title: NAME, description: DESCRIPTION, @@ -60,6 +51,21 @@ const schema = { }, } +/** + * @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: { @@ -76,7 +82,7 @@ module.exports = { }, }, create (context) { - const { methods } = getDefaultOptions(schema, context) + const { methods } = getDefaultOptions(context) return { CallExpression (node) { @@ -95,8 +101,17 @@ module.exports = { }, } -function isRootCypress (node) { - if (node.type !== 'CallExpression' || node.callee.type !== 'MemberExpression') return +/** + * @param {import('estree').Node} node + * @returns {boolean} + */ +const isRootCypress = (node) => { + if ( + node.type !== 'CallExpression' || + node.callee.type !== 'MemberExpression' + ) { + return false + } if ( node.callee.object.type === 'Identifier' && @@ -108,7 +123,11 @@ function isRootCypress (node) { return isRootCypress(node.callee.object) } -function isActionUnsafeToChain (node, additionalMethods) { +/** + * @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), From a284f392fbd81cda9e8183bb46c4134817a18544 Mon Sep 17 00:00:00 2001 From: Valerie Rutsch Date: Sun, 6 Aug 2023 22:38:46 -0700 Subject: [PATCH 3/3] refactor(pencil) add correct union type --- lib/rules/unsafe-to-chain-command.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rules/unsafe-to-chain-command.js b/lib/rules/unsafe-to-chain-command.js index 6c644f3e..ac2e13d3 100644 --- a/lib/rules/unsafe-to-chain-command.js +++ b/lib/rules/unsafe-to-chain-command.js @@ -125,7 +125,7 @@ const isRootCypress = (node) => { /** * @param {import('estree').Node} node - * @param {string[] | RegExp[]} additionalMethods + * @param {(string | RegExp)[]} additionalMethods */ const isActionUnsafeToChain = (node, additionalMethods = []) => { const unsafeActionsRegex = new RegExp([