From fa8fb33a9eb7c3d73ac9ddeafe795b056e51c900 Mon Sep 17 00:00:00 2001 From: Todd Kemp Date: Fri, 27 Mar 2026 17:02:25 -0400 Subject: [PATCH 1/9] feat: add no-add rule to disallow .and() in favor of .should() --- docs/rules/no-add.md | 35 ++++++++++++++ lib/index.js | 1 + lib/rules/no-add.js | 75 +++++++++++++++++++++++++++++ tests/lib/rules/no-add.js | 99 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 docs/rules/no-add.md create mode 100644 lib/rules/no-add.js create mode 100644 tests/lib/rules/no-add.js diff --git a/docs/rules/no-add.md b/docs/rules/no-add.md new file mode 100644 index 00000000..6bf1343d --- /dev/null +++ b/docs/rules/no-add.md @@ -0,0 +1,35 @@ +# cypress/no-add + +📝 Disallow the use of `.and()`. + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +Cypress's [.and()](https://on.cypress.io/and) is an alias for [.should()](https://on.cypress.io/should). Using `.should()` consistently makes assertions easier to read and avoids ambiguity. + +## Rule Details + +This rule disallows the use of `.and()` in Cypress chains and auto-fixes it to `.should()`. + +Examples of **incorrect** code for this rule: + +```js +cy.get('foo').and('be.visible') +cy.get('foo').should('be.visible').and('have.text', 'bar') +cy.contains('Submit').and('be.disabled') +cy.get('input').invoke('val').and('eq', 'hello') +``` + +Examples of **correct** code for this rule: + +```js +cy.get('foo').should('be.visible') +cy.get('foo').should('be.visible').should('have.text', 'bar') +cy.contains('Submit').should('be.disabled') +cy.get('input').invoke('val').should('eq', 'hello') +``` + +## When Not To Use It + +If you prefer using `.and()` for readability in chained assertions, turn this rule off. diff --git a/lib/index.js b/lib/index.js index 0894f73f..d255abaa 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,6 +6,7 @@ const plugin = { configs: {}, rules: { 'assertion-before-screenshot': require('./rules/assertion-before-screenshot'), + 'no-add': require('./rules/no-add'), 'no-assigning-return-values': require('./rules/no-assigning-return-values'), 'no-async-before': require('./rules/no-async-before'), 'no-async-tests': require('./rules/no-async-tests'), diff --git a/lib/rules/no-add.js b/lib/rules/no-add.js new file mode 100644 index 00000000..84461bd1 --- /dev/null +++ b/lib/rules/no-add.js @@ -0,0 +1,75 @@ +/** + * @fileoverview disallow the use of .and() + * @author Todd Kemp + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Rule Definition +// ------------------------------------------------------------------------------ + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: 'disallow the use of .and()', + recommended: false, + url: null, // URL to the documentation page for this rule + }, + fixable: 'code', + schema: [], // Add a schema if the rule has options + messages: { + unexpected: 'Do not use .and(); use .should() instead', + }, + }, + + create(context) { + // variables should be defined here + + // ---------------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------------- + + function rootIsCy(node) { + let current = node.callee.object + while (current) { + if (current.type === 'Identifier' && current.name === 'cy') { + return true + } + if (current.type === 'CallExpression') { + current = current.callee.object + } + else if (current.type === 'MemberExpression') { + current = current.object + } + else { + break + } + } + return false + } + + // ---------------------------------------------------------------------- + // Public + // ---------------------------------------------------------------------- + + return { + CallExpression(node) { + if ( + node.callee.type === 'MemberExpression' + && node.callee.property.name === 'and' + && rootIsCy(node) + ) { + context.report({ + node, + messageId: 'unexpected', + fix(fixer) { + return fixer.replaceText(node.callee.property, 'should') + }, + }) + } + }, + } + }, +} diff --git a/tests/lib/rules/no-add.js b/tests/lib/rules/no-add.js new file mode 100644 index 00000000..8f9b1d16 --- /dev/null +++ b/tests/lib/rules/no-add.js @@ -0,0 +1,99 @@ +/** + * @fileoverview disallow the use of .and() + * @author Todd Kemp + */ +'use strict' + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const rule = require('../../../lib/rules/no-add'), + RuleTester = require('eslint').RuleTester + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester() +const errors = [{ messageId: 'unexpected' }] + +ruleTester.run('no-add', rule, { + valid: [ + { code: 'cy.get(\'foo\').should(\'be.visible\')' }, + { code: 'cy.get(\'foo\').should(\'be.visible\').should(\'have.text\', \'bar\')' }, + { code: 'cy.get(\'foo\').find(\'.bar\').should(\'have.class\', \'active\')' }, + { code: 'someOtherLib.and(\'something\')' }, + { code: 'someOtherLib.get(\'foo\').and(\'be.visible\')' }, + { code: 'expect(foo).to.equal(true).and(\'have.text\', \'bar\')' }, + { code: 'someOtherLib.get(\'foo\').find(\'.bar\').filter(\'.baz\').first().and(\'be.visible\')' }, + ], + + invalid: [ + { + code: 'cy.and(\'be.visible\')', + output: 'cy.should(\'be.visible\')', + errors, + }, + { + code: 'cy.get(\'foo\').and(\'be.visible\')', + output: 'cy.get(\'foo\').should(\'be.visible\')', + errors, + }, + { + code: 'cy.get(\'foo\').should(\'be.visible\').and(\'have.text\', \'bar\')', + output: 'cy.get(\'foo\').should(\'be.visible\').should(\'have.text\', \'bar\')', + errors, + }, + { + code: 'cy.get(\'foo\').find(\'.bar\').and(\'have.class\', \'active\')', + output: 'cy.get(\'foo\').find(\'.bar\').should(\'have.class\', \'active\')', + errors, + }, + { + code: 'cy.get(\'foo\').find(\'.bar\').filter(\'.baz\').first().and(\'be.visible\')', + output: 'cy.get(\'foo\').find(\'.bar\').filter(\'.baz\').first().should(\'be.visible\')', + errors, + }, + { + code: 'cy.get(\'foo\').find(\'.bar\').filter(\'.baz\').first().parent().siblings().eq(0).and(\'be.visible\')', + output: 'cy.get(\'foo\').find(\'.bar\').filter(\'.baz\').first().parent().siblings().eq(0).should(\'be.visible\')', + errors, + }, + { + code: 'cy.get(\'.container\').within(() => { cy.get(\'.item\').and(\'be.visible\') })', + output: 'cy.get(\'.container\').within(() => { cy.get(\'.item\').should(\'be.visible\') })', + errors, + }, + { + code: 'cy.get(\'foo\').then(($el) => { cy.wrap($el).and(\'have.class\', \'active\') })', + output: 'cy.get(\'foo\').then(($el) => { cy.wrap($el).should(\'have.class\', \'active\') })', + errors, + }, + { + code: 'cy.get(\'foo\').then(($el) => {}).and(\'be.visible\')', + output: 'cy.get(\'foo\').then(($el) => {}).should(\'be.visible\')', + errors, + }, + { + code: 'cy.get(\'foo\').as(\'myEl\').and(\'be.visible\')', + output: 'cy.get(\'foo\').as(\'myEl\').should(\'be.visible\')', + errors, + }, + { + code: 'cy.get(\'foo\').and(\'be.visible\').and(\'have.text\', \'bar\')', + output: 'cy.get(\'foo\').should(\'be.visible\').should(\'have.text\', \'bar\')', + errors: [{ messageId: 'unexpected' }, { messageId: 'unexpected' }], + }, + { + code: 'cy.contains(\'Submit\').and(\'be.disabled\')', + output: 'cy.contains(\'Submit\').should(\'be.disabled\')', + errors, + }, + { + code: 'cy.get(\'input\').invoke(\'val\').and(\'eq\', \'hello\')', + output: 'cy.get(\'input\').invoke(\'val\').should(\'eq\', \'hello\')', + errors, + }, + ], +}) From b722323a9f58381a666a33a00f5b67bf4817a2e9 Mon Sep 17 00:00:00 2001 From: Todd Kemp Date: Mon, 30 Mar 2026 09:29:27 -0400 Subject: [PATCH 2/9] Fix naming typo (and vs. add). --- docs/rules/{no-add.md => no-and.md} | 2 +- lib/index.js | 2 +- lib/rules/{no-add.js => no-and.js} | 0 tests/lib/rules/{no-add.js => no-and.js} | 4 ++-- 4 files changed, 4 insertions(+), 4 deletions(-) rename docs/rules/{no-add.md => no-and.md} (98%) rename lib/rules/{no-add.js => no-and.js} (100%) rename tests/lib/rules/{no-add.js => no-and.js} (97%) diff --git a/docs/rules/no-add.md b/docs/rules/no-and.md similarity index 98% rename from docs/rules/no-add.md rename to docs/rules/no-and.md index 6bf1343d..947f2a94 100644 --- a/docs/rules/no-add.md +++ b/docs/rules/no-and.md @@ -1,4 +1,4 @@ -# cypress/no-add +# cypress/no-and 📝 Disallow the use of `.and()`. diff --git a/lib/index.js b/lib/index.js index d255abaa..a67e6d35 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,7 +6,7 @@ const plugin = { configs: {}, rules: { 'assertion-before-screenshot': require('./rules/assertion-before-screenshot'), - 'no-add': require('./rules/no-add'), + 'no-and': require('./rules/no-and'), 'no-assigning-return-values': require('./rules/no-assigning-return-values'), 'no-async-before': require('./rules/no-async-before'), 'no-async-tests': require('./rules/no-async-tests'), diff --git a/lib/rules/no-add.js b/lib/rules/no-and.js similarity index 100% rename from lib/rules/no-add.js rename to lib/rules/no-and.js diff --git a/tests/lib/rules/no-add.js b/tests/lib/rules/no-and.js similarity index 97% rename from tests/lib/rules/no-add.js rename to tests/lib/rules/no-and.js index 8f9b1d16..81b7e941 100644 --- a/tests/lib/rules/no-add.js +++ b/tests/lib/rules/no-and.js @@ -8,7 +8,7 @@ // Requirements // ------------------------------------------------------------------------------ -const rule = require('../../../lib/rules/no-add'), +const rule = require('../../../lib/rules/no-and'), RuleTester = require('eslint').RuleTester // ------------------------------------------------------------------------------ @@ -18,7 +18,7 @@ const rule = require('../../../lib/rules/no-add'), const ruleTester = new RuleTester() const errors = [{ messageId: 'unexpected' }] -ruleTester.run('no-add', rule, { +ruleTester.run('no-and', rule, { valid: [ { code: 'cy.get(\'foo\').should(\'be.visible\')' }, { code: 'cy.get(\'foo\').should(\'be.visible\').should(\'have.text\', \'bar\')' }, From 43e1684d6d4c60cc233a80336bc9799d9b9bac6d Mon Sep 17 00:00:00 2001 From: Todd Kemp Date: Tue, 7 Apr 2026 15:52:01 -0400 Subject: [PATCH 3/9] feat: update no-and rule to allow .and() after .should(), .and(), and .contains() --- lib/rules/no-and.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/rules/no-and.js b/lib/rules/no-and.js index 84461bd1..90b2a3ac 100644 --- a/lib/rules/no-and.js +++ b/lib/rules/no-and.js @@ -50,6 +50,20 @@ module.exports = { return false } + const allowAndAfter = new Set(['should', 'and', 'contains']) + + function isAllowedAfter(node) { + const object = node.callee.object + if ( + object + && object.type === 'CallExpression' + && object.callee.type === 'MemberExpression' + ) { + return allowAndAfter.has(object.callee.property.name) + } + return false + } + // ---------------------------------------------------------------------- // Public // ---------------------------------------------------------------------- @@ -60,6 +74,7 @@ module.exports = { node.callee.type === 'MemberExpression' && node.callee.property.name === 'and' && rootIsCy(node) + && !isAllowedAfter(node) ) { context.report({ node, From 683a1fc61bae35c6b07991d589493267a3c1e1d9 Mon Sep 17 00:00:00 2001 From: Todd Kemp Date: Tue, 7 Apr 2026 15:57:17 -0400 Subject: [PATCH 4/9] test: add comprehensive test coverage for no-and rule allowlist behavior --- tests/lib/rules/no-and.js | 58 ++++++++++++++++++++++++--------------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/tests/lib/rules/no-and.js b/tests/lib/rules/no-and.js index 81b7e941..266ee927 100644 --- a/tests/lib/rules/no-and.js +++ b/tests/lib/rules/no-and.js @@ -20,13 +20,22 @@ const errors = [{ messageId: 'unexpected' }] ruleTester.run('no-and', rule, { valid: [ + { code: 'cy.get(\'elem\').should(\'have.text\', \'blah\')' }, { code: 'cy.get(\'foo\').should(\'be.visible\')' }, { code: 'cy.get(\'foo\').should(\'be.visible\').should(\'have.text\', \'bar\')' }, { code: 'cy.get(\'foo\').find(\'.bar\').should(\'have.class\', \'active\')' }, { code: 'someOtherLib.and(\'something\')' }, { code: 'someOtherLib.get(\'foo\').and(\'be.visible\')' }, { code: 'expect(foo).to.equal(true).and(\'have.text\', \'bar\')' }, - { code: 'someOtherLib.get(\'foo\').find(\'.bar\').filter(\'.baz\').first().and(\'be.visible\')' }, + { code: 'cy.get(\'.err\').should(\'be.empty\').and(\'be.hidden\')' }, + { code: 'cy.get(\'foo\').should(\'be.visible\').and(\'have.text\', \'bar\')' }, + { code: 'cy.get(\'foo\').should(\'be.visible\').and(\'have.text\', \'bar\').and(\'have.class\', \'active\')' }, + { code: 'cy.contains(\'Login\').and(\'be.visible\')' }, + { code: 'cy.contains(\'Submit\').and(\'be.disabled\')' }, + { code: 'cy.contains(\'Login\').should(\'be.visible\').and(\'have.class\', \'active\')' }, + { code: 'cy.get(\'foo\').should(($el) => { expect($el).to.have.length(1) }).and(\'be.visible\')' }, + { code: 'cy.contains(\'.nav\', \'Home\').and(\'have.class\', \'active\')' }, + { code: 'cy.contains(\'Save\').should(\'not.be.disabled\').and(\'be.visible\')' }, ], invalid: [ @@ -36,13 +45,13 @@ ruleTester.run('no-and', rule, { errors, }, { - code: 'cy.get(\'foo\').and(\'be.visible\')', - output: 'cy.get(\'foo\').should(\'be.visible\')', + code: 'cy.get(\'elem\').and(\'have.text\', \'blah\')', + output: 'cy.get(\'elem\').should(\'have.text\', \'blah\')', errors, }, { - code: 'cy.get(\'foo\').should(\'be.visible\').and(\'have.text\', \'bar\')', - output: 'cy.get(\'foo\').should(\'be.visible\').should(\'have.text\', \'bar\')', + code: 'cy.get(\'foo\').and(\'be.visible\')', + output: 'cy.get(\'foo\').should(\'be.visible\')', errors, }, { @@ -51,13 +60,18 @@ ruleTester.run('no-and', rule, { errors, }, { - code: 'cy.get(\'foo\').find(\'.bar\').filter(\'.baz\').first().and(\'be.visible\')', - output: 'cy.get(\'foo\').find(\'.bar\').filter(\'.baz\').first().should(\'be.visible\')', + code: 'cy.get(\'foo\').click().and(\'be.disabled\')', + output: 'cy.get(\'foo\').click().should(\'be.disabled\')', + errors, + }, + { + code: 'cy.get(\'input\').invoke(\'val\').and(\'eq\', \'hello\')', + output: 'cy.get(\'input\').invoke(\'val\').should(\'eq\', \'hello\')', errors, }, { - code: 'cy.get(\'foo\').find(\'.bar\').filter(\'.baz\').first().parent().siblings().eq(0).and(\'be.visible\')', - output: 'cy.get(\'foo\').find(\'.bar\').filter(\'.baz\').first().parent().siblings().eq(0).should(\'be.visible\')', + code: 'cy.get(\'foo\').then(($el) => {}).and(\'be.visible\')', + output: 'cy.get(\'foo\').then(($el) => {}).should(\'be.visible\')', errors, }, { @@ -66,33 +80,33 @@ ruleTester.run('no-and', rule, { errors, }, { - code: 'cy.get(\'foo\').then(($el) => { cy.wrap($el).and(\'have.class\', \'active\') })', - output: 'cy.get(\'foo\').then(($el) => { cy.wrap($el).should(\'have.class\', \'active\') })', + code: 'cy.get(\'input\').type(\'bar\').and(\'have.value\', \'bar\')', + output: 'cy.get(\'input\').type(\'bar\').should(\'have.value\', \'bar\')', errors, }, { - code: 'cy.get(\'foo\').then(($el) => {}).and(\'be.visible\')', - output: 'cy.get(\'foo\').then(($el) => {}).should(\'be.visible\')', + code: 'cy.get(\'input\').check().and(\'be.checked\')', + output: 'cy.get(\'input\').check().should(\'be.checked\')', errors, }, { - code: 'cy.get(\'foo\').as(\'myEl\').and(\'be.visible\')', - output: 'cy.get(\'foo\').as(\'myEl\').should(\'be.visible\')', + code: 'cy.get(\'select\').select(\'option\').and(\'have.value\', \'option\')', + output: 'cy.get(\'select\').select(\'option\').should(\'have.value\', \'option\')', errors, }, { - code: 'cy.get(\'foo\').and(\'be.visible\').and(\'have.text\', \'bar\')', - output: 'cy.get(\'foo\').should(\'be.visible\').should(\'have.text\', \'bar\')', - errors: [{ messageId: 'unexpected' }, { messageId: 'unexpected' }], + code: 'cy.wrap(obj).and(\'deep.equal\', expected)', + output: 'cy.wrap(obj).should(\'deep.equal\', expected)', + errors, }, { - code: 'cy.contains(\'Submit\').and(\'be.disabled\')', - output: 'cy.contains(\'Submit\').should(\'be.disabled\')', + code: 'cy.get(\'foo\').its(\'length\').and(\'eq\', 3)', + output: 'cy.get(\'foo\').its(\'length\').should(\'eq\', 3)', errors, }, { - code: 'cy.get(\'input\').invoke(\'val\').and(\'eq\', \'hello\')', - output: 'cy.get(\'input\').invoke(\'val\').should(\'eq\', \'hello\')', + code: 'cy.get(\'foo\').and(\'be.visible\').and(\'have.text\', \'bar\')', + output: 'cy.get(\'foo\').should(\'be.visible\').and(\'have.text\', \'bar\')', errors, }, ], From b698ddf58902e44bf8e62b74703b75809be43784 Mon Sep 17 00:00:00 2001 From: Todd Kemp Date: Tue, 7 Apr 2026 16:02:10 -0400 Subject: [PATCH 5/9] docs: clarify no-and rule allows .and() after .should(), .and(), and .contains() --- docs/rules/no-and.md | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/docs/rules/no-and.md b/docs/rules/no-and.md index 947f2a94..8ba4274a 100644 --- a/docs/rules/no-and.md +++ b/docs/rules/no-and.md @@ -1,35 +1,34 @@ # cypress/no-and -📝 Disallow the use of `.and()`. +📝 Enforce `.should()` over `.and()` for starting assertion chains. 🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). -Cypress's [.and()](https://on.cypress.io/and) is an alias for [.should()](https://on.cypress.io/should). Using `.should()` consistently makes assertions easier to read and avoids ambiguity. +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. ## Rule Details -This rule disallows the use of `.and()` in Cypress chains and auto-fixes it to `.should()`. +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()`. Examples of **incorrect** code for this rule: ```js -cy.get('foo').and('be.visible') -cy.get('foo').should('be.visible').and('have.text', 'bar') -cy.contains('Submit').and('be.disabled') -cy.get('input').invoke('val').and('eq', 'hello') +cy.get('elem').and('have.text', 'blah') +cy.get('foo').find('.bar').and('have.class', 'active') +cy.get('foo').click().and('be.disabled') ``` Examples of **correct** code for this rule: ```js -cy.get('foo').should('be.visible') -cy.get('foo').should('be.visible').should('have.text', 'bar') -cy.contains('Submit').should('be.disabled') -cy.get('input').invoke('val').should('eq', 'hello') +cy.get('elem').should('have.text', 'blah') +cy.get('.err').should('be.empty').and('be.hidden') +cy.contains('Login').and('be.visible') +cy.get('foo').should('be.visible').and('have.text', 'bar').and('have.class', 'active') ``` ## When Not To Use It -If you prefer using `.and()` for readability in chained assertions, turn this rule off. +If you prefer using `.and()` interchangeably with `.should()` in all positions, turn this rule off. From afef6f0fd6d1041c6c801c49168bf90e50e797da Mon Sep 17 00:00:00 2001 From: Todd Kemp Date: Tue, 7 Apr 2026 16:08:46 -0400 Subject: [PATCH 6/9] refactor: rename no-and rule to no-starting-and for clarity --- docs/rules/{no-and.md => no-starting-and.md} | 2 +- lib/index.js | 2 +- lib/rules/{no-and.js => no-starting-and.js} | 4 ++-- tests/lib/rules/{no-and.js => no-starting-and.js} | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) rename docs/rules/{no-and.md => no-starting-and.md} (98%) rename lib/rules/{no-and.js => no-starting-and.js} (93%) rename tests/lib/rules/{no-and.js => no-starting-and.js} (95%) diff --git a/docs/rules/no-and.md b/docs/rules/no-starting-and.md similarity index 98% rename from docs/rules/no-and.md rename to docs/rules/no-starting-and.md index 8ba4274a..75ed9743 100644 --- a/docs/rules/no-and.md +++ b/docs/rules/no-starting-and.md @@ -1,4 +1,4 @@ -# cypress/no-and +# cypress/no-starting-and 📝 Enforce `.should()` over `.and()` for starting assertion chains. diff --git a/lib/index.js b/lib/index.js index a67e6d35..86501764 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,7 +6,7 @@ const plugin = { configs: {}, rules: { 'assertion-before-screenshot': require('./rules/assertion-before-screenshot'), - 'no-and': require('./rules/no-and'), + 'no-starting-and': require('./rules/no-starting-and'), 'no-assigning-return-values': require('./rules/no-assigning-return-values'), 'no-async-before': require('./rules/no-async-before'), 'no-async-tests': require('./rules/no-async-tests'), diff --git a/lib/rules/no-and.js b/lib/rules/no-starting-and.js similarity index 93% rename from lib/rules/no-and.js rename to lib/rules/no-starting-and.js index 90b2a3ac..237c2088 100644 --- a/lib/rules/no-and.js +++ b/lib/rules/no-starting-and.js @@ -1,5 +1,5 @@ /** - * @fileoverview disallow the use of .and() + * @fileoverview enforce .should() over .and() for starting assertion chains * @author Todd Kemp */ 'use strict' @@ -13,7 +13,7 @@ module.exports = { meta: { type: 'suggestion', docs: { - description: 'disallow the use of .and()', + description: 'enforce .should() over .and() for starting assertion chains', recommended: false, url: null, // URL to the documentation page for this rule }, diff --git a/tests/lib/rules/no-and.js b/tests/lib/rules/no-starting-and.js similarity index 95% rename from tests/lib/rules/no-and.js rename to tests/lib/rules/no-starting-and.js index 266ee927..21a0fc40 100644 --- a/tests/lib/rules/no-and.js +++ b/tests/lib/rules/no-starting-and.js @@ -1,5 +1,5 @@ /** - * @fileoverview disallow the use of .and() + * @fileoverview enforce .should() over .and() for starting assertion chains * @author Todd Kemp */ 'use strict' @@ -8,7 +8,7 @@ // Requirements // ------------------------------------------------------------------------------ -const rule = require('../../../lib/rules/no-and'), +const rule = require('../../../lib/rules/no-starting-and'), RuleTester = require('eslint').RuleTester // ------------------------------------------------------------------------------ @@ -18,7 +18,7 @@ const rule = require('../../../lib/rules/no-and'), const ruleTester = new RuleTester() const errors = [{ messageId: 'unexpected' }] -ruleTester.run('no-and', rule, { +ruleTester.run('no-starting-and', rule, { valid: [ { code: 'cy.get(\'elem\').should(\'have.text\', \'blah\')' }, { code: 'cy.get(\'foo\').should(\'be.visible\')' }, From a01b3055fd92dc75c7a54155684d19fce21a6201 Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Wed, 8 Apr 2026 15:31:44 -0600 Subject: [PATCH 7/9] cleanup and rename --- docs/rules/{no-starting-and.md => no-and.md} | 0 lib/rules/{no-starting-and.js => no-and.js} | 22 ++----------------- .../rules/{no-starting-and.js => no-and.js} | 14 +----------- 3 files changed, 3 insertions(+), 33 deletions(-) rename docs/rules/{no-starting-and.md => no-and.md} (100%) rename lib/rules/{no-starting-and.js => no-and.js} (67%) rename tests/lib/rules/{no-starting-and.js => no-and.js} (87%) diff --git a/docs/rules/no-starting-and.md b/docs/rules/no-and.md similarity index 100% rename from docs/rules/no-starting-and.md rename to docs/rules/no-and.md diff --git a/lib/rules/no-starting-and.js b/lib/rules/no-and.js similarity index 67% rename from lib/rules/no-starting-and.js rename to lib/rules/no-and.js index 237c2088..e7d78578 100644 --- a/lib/rules/no-starting-and.js +++ b/lib/rules/no-and.js @@ -1,13 +1,5 @@ -/** - * @fileoverview enforce .should() over .and() for starting assertion chains - * @author Todd Kemp - */ 'use strict' -// ------------------------------------------------------------------------------ -// Rule Definition -// ------------------------------------------------------------------------------ - /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { @@ -15,22 +7,16 @@ module.exports = { docs: { description: 'enforce .should() over .and() for starting assertion chains', recommended: false, - url: null, // URL to the documentation page for this rule + url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/no-starting-and.md', }, fixable: 'code', - schema: [], // Add a schema if the rule has options + schema: [], messages: { unexpected: 'Do not use .and(); use .should() instead', }, }, create(context) { - // variables should be defined here - - // ---------------------------------------------------------------------- - // Helpers - // ---------------------------------------------------------------------- - function rootIsCy(node) { let current = node.callee.object while (current) { @@ -64,10 +50,6 @@ module.exports = { return false } - // ---------------------------------------------------------------------- - // Public - // ---------------------------------------------------------------------- - return { CallExpression(node) { if ( diff --git a/tests/lib/rules/no-starting-and.js b/tests/lib/rules/no-and.js similarity index 87% rename from tests/lib/rules/no-starting-and.js rename to tests/lib/rules/no-and.js index 21a0fc40..4dcff651 100644 --- a/tests/lib/rules/no-starting-and.js +++ b/tests/lib/rules/no-and.js @@ -1,20 +1,8 @@ -/** - * @fileoverview enforce .should() over .and() for starting assertion chains - * @author Todd Kemp - */ 'use strict' -// ------------------------------------------------------------------------------ -// Requirements -// ------------------------------------------------------------------------------ - -const rule = require('../../../lib/rules/no-starting-and'), +const rule = require('../../../lib/rules/no-and'), RuleTester = require('eslint').RuleTester -// ------------------------------------------------------------------------------ -// Tests -// ------------------------------------------------------------------------------ - const ruleTester = new RuleTester() const errors = [{ messageId: 'unexpected' }] From eca7c7e891ec6402a896a22d41926b1bfe9e8ab2 Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Wed, 8 Apr 2026 15:39:20 -0600 Subject: [PATCH 8/9] fix rename --- docs/rules/no-and.md | 2 +- lib/index.js | 2 +- lib/rules/no-and.js | 5 +++-- tests/lib/rules/no-and.js | 9 +++++++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/rules/no-and.md b/docs/rules/no-and.md index 75ed9743..8ba4274a 100644 --- a/docs/rules/no-and.md +++ b/docs/rules/no-and.md @@ -1,4 +1,4 @@ -# cypress/no-starting-and +# cypress/no-and 📝 Enforce `.should()` over `.and()` for starting assertion chains. diff --git a/lib/index.js b/lib/index.js index 86501764..a67e6d35 100644 --- a/lib/index.js +++ b/lib/index.js @@ -6,7 +6,7 @@ const plugin = { configs: {}, rules: { 'assertion-before-screenshot': require('./rules/assertion-before-screenshot'), - 'no-starting-and': require('./rules/no-starting-and'), + 'no-and': require('./rules/no-and'), 'no-assigning-return-values': require('./rules/no-assigning-return-values'), 'no-async-before': require('./rules/no-async-before'), 'no-async-tests': require('./rules/no-async-tests'), diff --git a/lib/rules/no-and.js b/lib/rules/no-and.js index e7d78578..517efc8b 100644 --- a/lib/rules/no-and.js +++ b/lib/rules/no-and.js @@ -7,12 +7,13 @@ module.exports = { docs: { description: 'enforce .should() over .and() for starting assertion chains', recommended: false, - url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/no-starting-and.md', + url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/no-and.md', }, fixable: 'code', schema: [], messages: { - unexpected: 'Do not use .and(); use .should() instead', + unexpected: + 'Use .should() here; .and() is only allowed after .should(), .and(), or .contains().', }, }, diff --git a/tests/lib/rules/no-and.js b/tests/lib/rules/no-and.js index 4dcff651..4127c13b 100644 --- a/tests/lib/rules/no-and.js +++ b/tests/lib/rules/no-and.js @@ -4,9 +4,14 @@ const rule = require('../../../lib/rules/no-and'), RuleTester = require('eslint').RuleTester const ruleTester = new RuleTester() -const errors = [{ messageId: 'unexpected' }] +const errors = [ + { + message: + 'Use .should() here; .and() is only allowed after .should(), .and(), or .contains().', + }, +] -ruleTester.run('no-starting-and', rule, { +ruleTester.run('no-and', rule, { valid: [ { code: 'cy.get(\'elem\').should(\'have.text\', \'blah\')' }, { code: 'cy.get(\'foo\').should(\'be.visible\')' }, From ee566f76eacbaed99f10ec70b042b9b0b4bf58f7 Mon Sep 17 00:00:00 2001 From: Matthew Schile Date: Wed, 8 Apr 2026 20:28:46 -0600 Subject: [PATCH 9/9] refactor is-root-cypress --- lib/rules/assertion-before-screenshot.js | 17 ++------------ lib/rules/no-and.js | 23 +++---------------- lib/rules/no-chained-get.js | 20 ++--------------- lib/rules/no-debug.js | 14 +++--------- lib/rules/no-force.js | 2 ++ lib/rules/no-pause.js | 14 +++--------- lib/rules/unsafe-to-chain-command.js | 23 +------------------ lib/utils/is-root-cypress.js | 28 ++++++++++++++++++++++++ 8 files changed, 44 insertions(+), 97 deletions(-) create mode 100644 lib/utils/is-root-cypress.js diff --git a/lib/rules/assertion-before-screenshot.js b/lib/rules/assertion-before-screenshot.js index f446bc19..84a51249 100644 --- a/lib/rules/assertion-before-screenshot.js +++ b/lib/rules/assertion-before-screenshot.js @@ -1,5 +1,7 @@ 'use strict' +const { isRootCypress } = require('../utils/is-root-cypress') + const assertionCommands = [ // assertions 'should', @@ -39,21 +41,6 @@ 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 - } - - node = node.callee.object - } - - return false -} - function getPreviousInChain(node) { return node.type === 'CallExpression' && node.callee.type === 'MemberExpression' diff --git a/lib/rules/no-and.js b/lib/rules/no-and.js index 517efc8b..cc4b7b9f 100644 --- a/lib/rules/no-and.js +++ b/lib/rules/no-and.js @@ -1,5 +1,7 @@ 'use strict' +const { isRootCypress } = require('../utils/is-root-cypress') + /** @type {import('eslint').Rule.RuleModule} */ module.exports = { meta: { @@ -18,25 +20,6 @@ module.exports = { }, create(context) { - function rootIsCy(node) { - let current = node.callee.object - while (current) { - if (current.type === 'Identifier' && current.name === 'cy') { - return true - } - if (current.type === 'CallExpression') { - current = current.callee.object - } - else if (current.type === 'MemberExpression') { - current = current.object - } - else { - break - } - } - return false - } - const allowAndAfter = new Set(['should', 'and', 'contains']) function isAllowedAfter(node) { @@ -56,7 +39,7 @@ module.exports = { if ( node.callee.type === 'MemberExpression' && node.callee.property.name === 'and' - && rootIsCy(node) + && isRootCypress(node) && !isAllowedAfter(node) ) { context.report({ diff --git a/lib/rules/no-chained-get.js b/lib/rules/no-chained-get.js index 8ee4cba1..d7733da8 100644 --- a/lib/rules/no-chained-get.js +++ b/lib/rules/no-chained-get.js @@ -1,5 +1,7 @@ 'use strict' +const { isRootCypress } = require('../utils/is-root-cypress') + // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ @@ -21,24 +23,6 @@ module.exports = { }, create(context) { - const isRootCypress = (node) => { - if ( - node.type !== 'CallExpression' - || node.callee.type !== 'MemberExpression' - ) { - return false - } - - if ( - node.callee.object.type === 'Identifier' - && node.callee.object.name === 'cy' - ) { - return true - } - - return isRootCypress(node.callee.object) - } - const hasChainedGet = (node) => { // Check if this node is a get() call const isGetCall diff --git a/lib/rules/no-debug.js b/lib/rules/no-debug.js index ab862bf8..efc391cd 100644 --- a/lib/rules/no-debug.js +++ b/lib/rules/no-debug.js @@ -1,5 +1,7 @@ 'use strict' +const { isRootCypress } = require('../utils/is-root-cypress') + // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ @@ -33,16 +35,6 @@ module.exports = { && node.callee.property.name === 'debug' } - function isCypressCall(node) { - if (!node.callee || node.callee.type !== 'MemberExpression') { - return false - } - if (node.callee.object.type === 'Identifier' && node.callee.object.name === 'cy') { - return true - } - return isCypressCall(node.callee.object) - } - // ---------------------------------------------------------------------- // Public // ---------------------------------------------------------------------- @@ -50,7 +42,7 @@ module.exports = { return { CallExpression(node) { - if (isCypressCall(node) && isCallingDebug(node)) { + if (isRootCypress(node) && isCallingDebug(node)) { context.report({ node, messageId: 'unexpected' }) } }, diff --git a/lib/rules/no-force.js b/lib/rules/no-force.js index f170e279..904214fb 100644 --- a/lib/rules/no-force.js +++ b/lib/rules/no-force.js @@ -33,6 +33,8 @@ module.exports = { && allowedMethods.includes(node.property.name) } + // Only the segment whose callee.object is `cy` (not full chain root): + // deepCheck walks parents from that node to find `.click()` / `{ force }`. function isCypressCall(node) { return node.callee.type === 'MemberExpression' && node.callee.object.type === 'Identifier' diff --git a/lib/rules/no-pause.js b/lib/rules/no-pause.js index 374ae8ab..e59b917e 100644 --- a/lib/rules/no-pause.js +++ b/lib/rules/no-pause.js @@ -1,5 +1,7 @@ 'use strict' +const { isRootCypress } = require('../utils/is-root-cypress') + // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ @@ -33,16 +35,6 @@ module.exports = { && node.callee.property.name === 'pause' } - function isCypressCall(node) { - if (!node.callee || node.callee.type !== 'MemberExpression') { - return false - } - if (node.callee.object.type === 'Identifier' && node.callee.object.name === 'cy') { - return true - } - return isCypressCall(node.callee.object) - } - // ---------------------------------------------------------------------- // Public // ---------------------------------------------------------------------- @@ -50,7 +42,7 @@ module.exports = { return { CallExpression(node) { - if (isCypressCall(node) && isCallingPause(node)) { + if (isRootCypress(node) && isCallingPause(node)) { context.report({ node, messageId: 'unexpected' }) } }, diff --git a/lib/rules/unsafe-to-chain-command.js b/lib/rules/unsafe-to-chain-command.js index 2ca539d4..07fdd4ef 100644 --- a/lib/rules/unsafe-to-chain-command.js +++ b/lib/rules/unsafe-to-chain-command.js @@ -1,6 +1,7 @@ 'use strict' const { basename } = require('path') +const { isRootCypress } = require('../utils/is-root-cypress') const NAME = basename(__dirname) const DESCRIPTION = 'disallow actions within chains' @@ -103,28 +104,6 @@ module.exports = { }, } -/** - * @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' - && node.callee.object.name === 'cy' - ) { - return true - } - - return isRootCypress(node.callee.object) -} - /** * @param {import('estree').Node} node * @param {(string | RegExp)[]} additionalMethods diff --git a/lib/utils/is-root-cypress.js b/lib/utils/is-root-cypress.js new file mode 100644 index 00000000..3fbfdfb1 --- /dev/null +++ b/lib/utils/is-root-cypress.js @@ -0,0 +1,28 @@ +'use strict' + +/** + * Whether `node` is a CallExpression that belongs to a Cypress command chain + * rooted at `cy` (walks `callee.object` through nested calls). + * + * @param {import('estree').Node} node + * @returns {boolean} + */ +function isRootCypress(node) { + if ( + node.type !== 'CallExpression' + || node.callee.type !== 'MemberExpression' + ) { + return false + } + + if ( + node.callee.object.type === 'Identifier' + && node.callee.object.name === 'cy' + ) { + return true + } + + return isRootCypress(node.callee.object) +} + +module.exports = { isRootCypress }