This commit is contained in:
2024-11-28 23:08:17 +01:00
parent 8895fde030
commit 0dda8e760c
16116 changed files with 2866428 additions and 71 deletions

View File

@@ -0,0 +1,14 @@
'use strict'
module.exports = {
plugins: ['cypress'],
env: {
'cypress/globals': true,
},
rules: {
'cypress/no-assigning-return-values': 'error',
'cypress/no-unnecessary-waiting': 'error',
'cypress/no-async-tests': 'error',
'cypress/unsafe-to-chain-command': 'error',
},
}

62
node_modules/eslint-plugin-cypress/lib/flat.js generated vendored Normal file
View File

@@ -0,0 +1,62 @@
const globals = require('globals')
const { name, version } = require('../package.json')
const plugin = {
meta: { name, version },
configs: {},
rules: {
'no-assigning-return-values': require('./rules/no-assigning-return-values'),
'unsafe-to-chain-command': require('./rules/unsafe-to-chain-command'),
'no-unnecessary-waiting': require('./rules/no-unnecessary-waiting'),
'no-async-before': require('./rules/no-async-before'),
'no-async-tests': require('./rules/no-async-tests'),
'assertion-before-screenshot': require('./rules/assertion-before-screenshot'),
'require-data-selectors': require('./rules/require-data-selectors'),
'no-force': require('./rules/no-force'),
'no-pause': require('./rules/no-pause'),
'no-debug': require('./rules/no-debug'),
},
}
const commonGlobals =
Object.assign({
cy: false,
Cypress: false,
expect: false,
assert: false,
chai: false,
}, globals.browser, globals.mocha)
Object.assign(plugin.configs, {
globals: {
name: 'cypress/globals',
plugins: {
cypress: plugin
},
languageOptions: {
globals:
commonGlobals,
}
}
})
Object.assign(plugin.configs, {
recommended: {
name: 'cypress/recommended',
plugins: {
cypress: plugin
},
rules: {
'cypress/no-assigning-return-values': 'error',
'cypress/no-unnecessary-waiting': 'error',
'cypress/no-async-tests': 'error',
'cypress/unsafe-to-chain-command': 'error',
},
languageOptions: {
globals:
commonGlobals,
}
}
})
module.exports = plugin

View File

@@ -0,0 +1,112 @@
'use strict'
const assertionCommands = [
// assertions
'should',
'and',
'contains',
// retries until it gets something
'get',
// not an assertion, but unlikely to require waiting for render
'scrollIntoView',
'scrollTo',
]
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'require screenshots to be preceded by an assertion',
category: 'Possible Errors',
recommended: false,
url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/assertion-before-screenshot.md',
},
schema: [],
messages: {
unexpected: 'Make an assertion on the page state before taking a screenshot',
},
},
create (context) {
return {
CallExpression (node) {
if (isCallingCyScreenshot(node) && !isPreviousAnAssertion(node)) {
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
}
node = node.callee.object
}
return false
}
function getPreviousInChain (node) {
return node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'CallExpression' &&
node.callee.object.callee.type === 'MemberExpression' &&
node.callee.object.callee.property.type === 'Identifier' &&
node.callee.object.callee.property.name
}
function getCallExpressionCypressCommand (node) {
return isRootCypress(node) &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name
}
function isCallingCyScreenshot (node) {
return getCallExpressionCypressCommand(node) === 'screenshot'
}
function getPreviousCypressCommand (node) {
const previousInChain = getPreviousInChain(node)
if (previousInChain) {
return previousInChain
}
while (node.parent && !node.parent.body) {
node = node.parent
}
if (!node.parent || !node.parent.body) return null
const body = node.parent.body.type === 'BlockStatement' ? node.parent.body.body : node.parent.body
const index = body.indexOf(node)
// in the case of a function declaration it won't be found
if (index < 0) return null
if (index === 0) return getPreviousCypressCommand(node.parent)
const previousStatement = body[index - 1]
if (previousStatement.type !== 'ExpressionStatement' ||
previousStatement.expression.type !== 'CallExpression') {
return null
}
return getCallExpressionCypressCommand(previousStatement.expression)
}
function isPreviousAnAssertion (node) {
const previousCypressCommand = getPreviousCypressCommand(node)
return assertionCommands.indexOf(previousCypressCommand) >= 0
}

View File

@@ -0,0 +1,68 @@
'use strict'
// safely get nested object property
function get (obj, propertyString = '') {
const properties = propertyString.split('.')
for (let i = 0; i < properties.length; i++) {
const value = (obj || {})[properties[i]]
if (value == null) return value
obj = value
}
return obj
}
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow assigning return values of `cy` calls',
category: 'Possible Errors',
recommended: true,
url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/no-assigning-return-values.md',
},
schema: [],
messages: {
unexpected: 'Do not assign the return value of a Cypress command',
},
},
create (context) {
return {
VariableDeclaration (node) {
if (node.declarations.some(isCypressCommandDeclaration)) {
context.report({ node, messageId: 'unexpected' })
}
},
}
},
}
const allowedCommands = {
now: true,
spy: true,
state: true,
stub: true,
}
function isCypressCommandDeclaration (declarator) {
let object = get(declarator, 'init.callee.object')
if (!object) return
while (object.callee) {
object = object.callee.object
if (!object) return
}
const commandName = get(declarator, 'init.callee.property.name')
const parent = get(object, 'parent.property.name') || get(declarator, 'id.name')
if (commandName && (allowedCommands[commandName] || allowedCommands[parent])) return
return object.name === 'cy'
}

View File

@@ -0,0 +1,53 @@
'use strict'
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow using `async`/`await` in Cypress `before` methods',
category: 'Possible Errors',
recommended: true,
url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/no-async-before.md',
},
schema: [],
messages: {
unexpected: 'Avoid using async functions with Cypress before / beforeEach functions',
},
},
create (context) {
function isBeforeBlock (callExpressionNode) {
const { type, name } = callExpressionNode.callee
return type === 'Identifier'
&& name === 'before' || name === 'beforeEach'
}
function isBeforeAsync (node) {
return node.arguments
&& node.arguments.length >= 2
&& node.arguments[1].async === true
}
const sourceCode = context.sourceCode ?? context.getSourceCode()
return {
Identifier (node) {
if (node.name === 'cy' || node.name === 'Cypress') {
const ancestors = sourceCode.getAncestors
? sourceCode.getAncestors(node)
: context.getAncestors()
const asyncTestBlocks = ancestors
.filter((n) => n.type === 'CallExpression')
.filter(isBeforeBlock)
.filter(isBeforeAsync)
if (asyncTestBlocks.length >= 1) {
asyncTestBlocks.forEach((node) => {
context.report({ node, messageId: 'unexpected' })
})
}
}
},
}
},
}

View File

@@ -0,0 +1,53 @@
'use strict'
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow using `async`/`await` in Cypress test cases',
category: 'Possible Errors',
recommended: true,
url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/no-async-tests.md',
},
schema: [],
messages: {
unexpected: 'Avoid using async functions with Cypress tests',
},
},
create (context) {
function isTestBlock (callExpressionNode) {
const { type, name } = callExpressionNode.callee
return type === 'Identifier'
&& name === 'it' || name === 'test'
}
function isTestAsync (node) {
return node.arguments
&& node.arguments.length >= 2
&& node.arguments[1].async === true
}
const sourceCode = context.sourceCode ?? context.getSourceCode()
return {
Identifier (node) {
if (node.name === 'cy' || node.name === 'Cypress') {
const ancestors = sourceCode.getAncestors
? sourceCode.getAncestors(node)
: context.getAncestors()
const asyncTestBlocks = ancestors
.filter((n) => n.type === 'CallExpression')
.filter(isTestBlock)
.filter(isTestAsync)
if (asyncTestBlocks.length >= 1) {
asyncTestBlocks.forEach((node) => {
context.report({ node, messageId: 'unexpected' })
})
}
}
},
}
},
}

View File

@@ -0,0 +1,61 @@
'use strict'
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow using `cy.debug()` calls',
category: 'Possible Errors',
recommended: false,
url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/no-debug.md',
},
fixable: null, // or "code" or "whitespace"
schema: [],
messages: {
unexpected: 'Do not use cy.debug command',
},
},
create (context) {
// variables should be defined here
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
function isCallingDebug (node) {
return node.callee &&
node.callee.property &&
node.callee.property.type === 'Identifier' &&
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
//----------------------------------------------------------------------
return {
CallExpression (node) {
if (isCypressCall(node) && isCallingDebug(node)) {
context.report({ node, messageId: 'unexpected' })
}
},
}
},
}

View File

@@ -0,0 +1,80 @@
'use strict'
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow using `force: true` with action commands',
category: 'Possible Errors',
recommended: false,
url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/no-force.md',
},
fixable: null, // or "code" or "whitespace"
schema: [],
messages: {
unexpected: 'Do not use force on click and type calls',
},
},
create (context) {
// variables should be defined here
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
function isCallingClickOrType (node) {
const allowedMethods = ['click', 'dblclick', 'type', 'trigger', 'check', 'rightclick', 'focus', 'select']
return node.property && node.property.type === 'Identifier' &&
allowedMethods.includes(node.property.name)
}
function isCypressCall (node) {
return node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'cy'
}
function hasOptionForce (node) {
return node.arguments && node.arguments.length &&
node.arguments.some((arg) => {
return arg.type === 'ObjectExpression' && arg.properties.some((propNode) => propNode.key && propNode.key.name === 'force')
})
}
function deepCheck (node, checkFunc) {
let currentNode = node
while (currentNode.parent) {
if (checkFunc(currentNode.parent)) {
return true
}
currentNode = currentNode.parent
}
return false
}
//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------
return {
CallExpression (node) {
if (isCypressCall(node) && deepCheck(node, isCallingClickOrType) && deepCheck(node, hasOptionForce)) {
context.report({ node, messageId: 'unexpected' })
}
},
}
},
}

View File

@@ -0,0 +1,61 @@
'use strict'
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'disallow using `cy.pause()` calls',
category: 'Possible Errors',
recommended: false,
url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/no-pause.md',
},
fixable: null, // or "code" or "whitespace"
schema: [],
messages: {
unexpected: 'Do not use cy.pause command',
},
},
create (context) {
// variables should be defined here
//----------------------------------------------------------------------
// Helpers
//----------------------------------------------------------------------
function isCallingPause (node) {
return node.callee &&
node.callee.property &&
node.callee.property.type === 'Identifier' &&
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
//----------------------------------------------------------------------
return {
CallExpression (node) {
if (isCypressCall(node) && isCallingPause(node)) {
context.report({ node, messageId: 'unexpected' })
}
},
}
},
}

View File

@@ -0,0 +1,91 @@
'use strict'
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow waiting for arbitrary time periods',
category: 'Possible Errors',
recommended: true,
url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/no-unnecessary-waiting.md',
},
schema: [],
messages: {
unexpected: 'Do not wait for arbitrary time periods',
},
},
create (context) {
const sourceCode = context.sourceCode ?? context.getSourceCode()
return {
CallExpression (node) {
if (isCallingCyWait(node)) {
const scope = sourceCode.getScope
? sourceCode.getScope(node)
: context.getScope()
if (isIdentifierNumberConstArgument(node, scope) || isNumberArgument(node)) {
context.report({ node, messageId: 'unexpected' })
}
}
},
}
},
}
function nodeIsCalledByCy (node) {
if (node.type === 'Identifier' && node.name === 'cy') return true
if (typeof node.callee === 'undefined' || typeof node.callee.object === 'undefined') {
return false
}
return nodeIsCalledByCy(node.callee.object)
}
function isCallingCyWait (node) {
return node.callee.type === 'MemberExpression' &&
nodeIsCalledByCy(node) &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'wait'
}
function isNumberArgument (node) {
return node.arguments.length > 0 &&
node.arguments[0].type === 'Literal' &&
typeof (node.arguments[0].value) === 'number'
}
function isIdentifierNumberConstArgument (node, scope) {
if (node.arguments.length === 0) return false
if (node.arguments[0].type !== 'Identifier') return false
const identifier = node.arguments[0]
const resolvedIdentifier = scope.references.find((ref) => ref.identifier === identifier).resolved
const definition = resolvedIdentifier.defs[0]
const isVariable = definition.type === 'Variable'
// const amount = 1000 or const amount = '@alias'
// cy.wait(amount)
if (isVariable) {
if (!definition.node.init) return false
return typeof definition.node.init.value === 'number'
}
// import { WAIT } from './constants'
// cy.wait(WAIT)
// we don't know if WAIT is a number or alias '@someRequest', so don't fail
if (definition.type === 'ImportBinding') return false
const param = definition.node.params[definition.index]
// function wait (amount) { cy.wait(amount) }
// we can't know the type of value, so don't fail
if (!param || param.type !== 'AssignmentPattern') return false
// function wait (amount = 1) { cy.wait(amount) } or
// function wait (amount = '@alias') { cy.wait(amount) }
return typeof param.right.value === 'number'
}

View File

@@ -0,0 +1,49 @@
'use strict'
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'require `data-*` attribute selectors',
category: 'Possible Errors',
recommended: false,
url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/require-data-selectors.md',
},
schema: [],
messages: {
unexpected: 'use data-* attribute selectors instead of classes or tag names',
},
},
create (context) {
return {
CallExpression (node) {
if (isCallingCyGet(node) && !isDataArgument(node)) {
context.report({ node, messageId: 'unexpected' })
}
},
}
},
}
function isCallingCyGet (node) {
return node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'cy' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'get'
}
function isDataArgument (node) {
return node.arguments.length > 0 &&
(
(node.arguments[0].type === 'Literal' && isAliasOrDataSelector(String(node.arguments[0].value))) ||
(node.arguments[0].type === 'TemplateLiteral' && isAliasOrDataSelector(String(node.arguments[0].quasis[0].value.cooked)))
)
}
function isAliasOrDataSelector (selector) {
return ['[data-', '@'].some(function (validValue) {
return selector.startsWith(validValue)
})
}

View File

@@ -0,0 +1,143 @@
'use strict'
const { basename } = require('path')
const NAME = basename(__dirname)
const DESCRIPTION = 'disallow actions within chains'
/**
* 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: {
type: 'problem',
docs: {
description: DESCRIPTION,
category: 'Possible Errors',
recommended: true,
url: 'https://github.com/cypress-io/eslint-plugin-cypress/blob/master/docs/rules/unsafe-to-chain-command.md',
},
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.',
},
},
create (context) {
const { methods } = getDefaultOptions(context)
return {
CallExpression (node) {
if (
isRootCypress(node) &&
isActionUnsafeToChain(node, methods) &&
node.parent.type === 'MemberExpression'
) {
context.report({
node,
messageId: 'unexpected',
})
}
},
}
},
}
/**
* @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
*/
const isActionUnsafeToChain = (node, additionalMethods = []) => {
const unsafeActionsRegex = new RegExp([
...unsafeToChainActions.map((action) => `^${action}$`),
...additionalMethods.map((method) => method instanceof RegExp ? method.source : method),
].join('|'))
return (
node.callee &&
node.callee.property &&
node.callee.property.type === 'Identifier' &&
unsafeActionsRegex.test(node.callee.property.name)
)
}