Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,46 @@ export default defineConfig([
])
```

### Typed Linting

You can activate typed linting for increased accuracy, if you want. This is purely optional and all rules continue to work without it. It uses a TypeScript parser to parse your entire project, instead of only the file being linted, which gives the plugin more information to work with. Be aware of the [performance penalty](https://typescript-eslint.io/getting-started/typed-linting/#performance) that this brings with it.

First, install the `typescript-eslint` npm package:

```sh
npm install typescript-eslint --save-dev
Copy link
Contributor

@cacieprins cacieprins Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@typescript-eslint/parser is necessary as well, correct?

Can you add these as optional peer dependencies, with the appropriate semvers?

```

If you already have a TypeScript codebase, you can skip the next step. If not, you need a `tsconfig.json`. The following minimal `tsconfig.json` does not compile or check your code and includes all .js files.

```json
{
"include": ["**/*.js"],
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"noEmit": true
},
}
```

The last step is to extend your eslint config to use the `typescript-eslint` parser.

```js
import { defineConfig } from 'eslint/config'
import typescriptParser from '@typescript-eslint/parser'
export default defineConfig([
{
languageOptions: {
parser: typescriptParser,
parserOptions: {
projectService: true,
},
},
}
])
```

## Disable rules

You can disable specific rules per file, for a portion of a file, or for a single line. See the [ESLint rules](https://eslint.org/docs/latest/use/configure/rules#disable-rules) documentation. For example ...
Expand Down
17 changes: 11 additions & 6 deletions circle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ workflows:
- 'globals'
- 'one-rule'
- 'recommended'
- 'typed'
requires:
- build-test-project
- release:
Expand All @@ -46,7 +47,7 @@ jobs:
- checkout
- run:
name: Install dependencies
command: npm ci
command: CYPRESS_INSTALL_BINARY=0 npm ci
- run:
name: Show ESLint version
command: npx eslint --version
Expand All @@ -63,7 +64,7 @@ jobs:
- checkout
- run:
name: Install dependencies
command: npm ci
command: CYPRESS_INSTALL_BINARY=0 npm ci
- run:
name: Install ESLint 9
command: npm install eslint@9
Expand All @@ -80,7 +81,7 @@ jobs:
- checkout
- run:
name: Install dependencies
command: npm ci
command: CYPRESS_INSTALL_BINARY=0 npm ci
- run:
name: Install ESLint 10
command: npm install eslint@10
Expand All @@ -97,7 +98,7 @@ jobs:
- checkout
- run:
name: Install dependencies
command: npm ci
command: CYPRESS_INSTALL_BINARY=0 npm ci
- run:
name: Build tarball
command: npm pack
Expand All @@ -123,6 +124,10 @@ jobs:
description: Configuration file
default: 'recommended'
type: string
typescript-eslint-version:
description: Version of typescript-eslint to use
default: 'latest'
type: string
executor: docker-executor
working_directory: ./test-project
steps:
Expand All @@ -137,7 +142,7 @@ jobs:
- run:
name: Install dependencies
command: |
npm install eslint@<< parameters.eslint-version>> ./eslint-plugin-cypress-$PLUGIN_VERSION.tgz -D
npm install eslint@<< parameters.eslint-version >> typescript-eslint@<< parameters.typescript-eslint-version >> ./eslint-plugin-cypress-$PLUGIN_VERSION.tgz -D
- run:
name: Display ESLint version
command: |
Expand All @@ -154,7 +159,7 @@ jobs:
- checkout
- run:
name: Install dependencies
command: npm ci
command: CYPRESS_INSTALL_BINARY=0 npm ci
- run:
name: Run semantic release
command: npm run semantic-release
12 changes: 12 additions & 0 deletions docs/rules/unsafe-to-chain-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@

<!-- end auto-generated rule options list -->

## Typed Linting

If [Typed Linting](../../README.md#typed-linting) is enabled, this rule also catches unsafe chaining even when the Cypress chain was started from a helper function.

```js
function getTodo() {
return cy.get('.todo')
}

getTodo().type('todo A{enter}').type('todo B{enter}')
```

## Further Reading

See [retry-ability guide](https://docs.cypress.io/app/core-concepts/retry-ability#Actions-should-be-at-the-end-of-chains-not-the-middle).
25 changes: 2 additions & 23 deletions lib/rules/unsafe-to-chain-command.js
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -89,7 +90,7 @@ module.exports = {
return {
CallExpression(node) {
if (
isRootCypress(node)
isRootCypress(node, context)
&& isActionUnsafeToChain(node, methods)
&& node.parent.type === 'MemberExpression'
) {
Expand All @@ -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
Expand Down
43 changes: 43 additions & 0 deletions lib/rules/utils/is-root-cypress.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Checks if the root of a call expression originated from a 'cy' identifier.
* If type information are available, additionally checks the return type of helper functions.
*
* @param {import('estree').Node} node
* @param {import('eslint').Rule.RuleContext} context
* @returns {boolean}
*/
const isRootCypress = (node, context) => {
if (node.type !== 'CallExpression') {
return false
}

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

return isRootCypress(node.callee.object, context)
}

if (calleeType === 'Identifier') {
const services = context?.sourceCode?.parserServices
if (services?.program && services.esTreeNodeToTSNodeMap) {
const checker = services.program.getTypeChecker()
const tsNode = services.esTreeNodeToTSNodeMap.get(node)
const signature = checker.getResolvedSignature(tsNode)

if (signature) {
const returnType = checker.getReturnTypeOfSignature(signature)
const symbol = returnType.getSymbol()
if (symbol) {
return checker.getFullyQualifiedName(symbol) === 'Cypress.Chainable'
}
}
}
}

return false
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted utility duplicates existing implementations in other rules

Medium Severity

The new isRootCypress utility in lib/rules/utils/is-root-cypress.js duplicates local isRootCypress implementations that still exist in lib/rules/no-chained-get.js (line 24) and lib/rules/assertion-before-screenshot.js (line 42). The whole purpose of extracting this to a utility module is reuse, but neither of those rules was updated to import from the new utility. This triples the maintenance burden and risks inconsistent bug fixes across the three copies.

Fix in Cursor Fix in Web

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs fixed as well, please!


module.exports = { isRootCypress }
Loading