UNPKG

eslint-plugin-sonarjs

Version:
371 lines (370 loc) • 14.7 kB
"use strict"; /* * SonarQube JavaScript Plugin * Copyright (C) SonarSource Sàrl * mailto:info AT sonarsource DOT com * * You can redistribute and/or modify this program under the terms of * the Sonar Source-Available License Version 1, as published by SonarSource Sàrl. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. * See the Sonar Source-Available License for more details. * * You should have received a copy of the Sonar Source-Available License * along with this program; if not, see https://sonarsource.com/license/ssal/ */ // https://sonarsource.github.io/rspec/#/rspec/S9162/javascript var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.rule = void 0; const ancestor_js_1 = require("../helpers/ancestor.js"); const ast_js_1 = require("../helpers/ast.js"); const assertions_chai_js_1 = require("../helpers/assertions-chai.js"); const generate_meta_js_1 = require("../helpers/generate-meta.js"); const test_file_pattern_js_1 = require("../helpers/test-file-pattern.js"); const meta = __importStar(require("./generated-meta.js")); const messages = { retryAssertions: 'Assertions in `.then()` run only once; use `.should()` to retry them and avoid flaky tests.', useShould: 'Replace `.then()` with retryable `.should()`.', }; const STRING_METHODS = new Set(['trim', 'toLowerCase', 'toUpperCase']); const NO_RETRY_SAFE_BINDINGS = new Map(); const CYPRESS_DOM_QUERY_COMMANDS = new Set([ 'children', 'closest', 'contains', 'eq', 'filter', 'find', 'first', 'focused', 'get', 'last', 'next', 'nextAll', 'nextUntil', 'not', 'parent', 'parents', 'parentsUntil', 'prev', 'prevAll', 'prevUntil', 'root', 'shadow', 'siblings', ]); exports.rule = { meta: (0, generate_meta_js_1.generateMeta)(meta, { messages, hasSuggestions: true }), create(context) { if (!(0, test_file_pattern_js_1.isTestRelatedFile)(context.filename, context.settings?.testFileExtensions)) { return {}; } return { CallExpression(node) { const call = node; if (!isCandidateThenCall(context, call)) { return; } const [callback] = call.arguments; if (!isAssertionOnlyCallback(context, callback)) { return; } context.report({ node: call.callee.property, messageId: 'retryAssertions', suggest: [ { messageId: 'useShould', fix: (fixer) => fixer.replaceText(call.callee.property, 'should'), }, ], }); }, }; }, }; function isCandidateThenCall(context, call) { return ((0, ast_js_1.isMethodCall)(call) && !isOptionalCall(call) && !call.callee.optional && !hasTypeArguments(call) && call.callee.property.name === 'then' && call.arguments.length === 1 && isRetryableCypressDomQueryChain(context, call.callee.object)); } function isRetryableCypressDomQueryChain(context, node) { let current = node; let sawQuery = false; while (true) { current = (0, ast_js_1.unwrapTypeScriptExpression)(current); if (current.type === 'ChainExpression') { current = current.expression; continue; } if (current.type !== 'CallExpression') { break; } if (!isRetryableDomQueryCall(current)) { return false; } sawQuery = true; current = current.callee.object; } return sawQuery && isUnshadowedGlobalCy(context, current); } function isRetryableDomQueryCall(call) { return ((0, ast_js_1.isMethodCall)(call) && !isOptionalCall(call) && CYPRESS_DOM_QUERY_COMMANDS.has(call.callee.property.name) && call.arguments.every((argument, index) => call.callee.property.name === 'get' && index === 0 ? isSelectorArgument(argument) : isRetrySafeQueryArgument(argument))); } function isUnshadowedGlobalCy(context, node) { const root = (0, ast_js_1.unwrapTypeScriptExpression)(node); if (root.type !== 'Identifier' || root.name !== 'cy') { return false; } const variable = (0, ast_js_1.getVariableFromName)(context, 'cy', root); return !variable || variable.defs.length === 0; } function isSelectorArgument(argument) { return (argument != null && argument.type !== 'SpreadElement' && (((0, ast_js_1.isStringLiteral)(argument) && !argument.value.startsWith('@')) || isTemplateSelector(argument))); } function isTemplateSelector(argument) { if (argument.type !== 'TemplateLiteral') { return false; } const prefix = argument.quasis[0]?.value.cooked; return prefix != null && prefix.length > 0 && !prefix.startsWith('@'); } function isRetrySafeQueryArgument(argument) { return (argument.type !== 'SpreadElement' && retrySafeKind(argument, NO_RETRY_SAFE_BINDINGS) !== null); } function isOptionalCall(call) { return call.optional === true; } function hasTypeArguments(call) { const typescriptCall = call; return typescriptCall.typeArguments != null || typescriptCall.typeParameters != null; } function findChainRoot(node) { const unwrapped = (0, ast_js_1.unwrapTypeScriptExpression)(node); if (unwrapped.type === 'MemberExpression') { return findChainRoot(unwrapped.object); } if (unwrapped.type === 'CallExpression') { return findChainRoot(unwrapped.callee); } if (unwrapped.type === 'ChainExpression') { return findChainRoot(unwrapped.expression); } return unwrapped; } function isAssertionOnlyCallback(context, callback) { if (!callback || (callback.type !== 'ArrowFunctionExpression' && callback.type !== 'FunctionExpression') || callback.async || callback.generator || callback.body.type !== 'BlockStatement' || callback.params.length !== 1 || callback.params[0].type !== 'Identifier' || callback.body.body.length === 0) { return false; } const bindings = new Map([[callback.params[0].name, 'yielded']]); const yieldedBindings = new Set([callback.params[0].name]); let assertionCount = 0; let hasYieldedAssertion = false; for (const statement of callback.body.body) { if (statement.type === 'VariableDeclaration') { if (!acceptRetrySafeDeclaration(statement, bindings, yieldedBindings, context.sourceCode.visitorKeys)) { return false; } continue; } if (statement.type !== 'ExpressionStatement') { return false; } const assertion = (0, assertions_chai_js_1.extractChaiAssertion)(context, statement.expression, true); if (!assertion || (assertion.style !== 'chai-bdd' && assertion.style !== 'chai-assert') || !isGlobalCypressAssertion(context, assertion.node) || !assertionArgumentsAreRetrySafe(assertion.node, bindings)) { return false; } hasYieldedAssertion ||= assertionUsesYieldedBinding(assertion, yieldedBindings, context.sourceCode.visitorKeys); assertionCount++; } return assertionCount > 0 && hasYieldedAssertion; } function assertionUsesYieldedBinding(assertion, yieldedBindings, visitorKeys) { return (usesYieldedBinding(assertion.actual, yieldedBindings, visitorKeys) || (assertion.expected != null && usesYieldedBinding(assertion.expected, yieldedBindings, visitorKeys))); } function assertionArgumentsAreRetrySafe(node, bindings) { const unwrapped = (0, ast_js_1.unwrapTypeScriptExpression)(node); if (unwrapped.type === 'CallExpression') { if (unwrapped.arguments.some((argument) => argument.type === 'SpreadElement' || retrySafeKind(argument, bindings) === null)) { return false; } return assertionArgumentsAreRetrySafe(unwrapped.callee, bindings); } if (unwrapped.type === 'MemberExpression') { return assertionArgumentsAreRetrySafe(unwrapped.object, bindings); } return true; } function isGlobalCypressAssertion(context, node) { const root = findChainRoot(node); if (root.type !== 'Identifier' || (root.name !== 'expect' && root.name !== 'assert')) { return false; } const variable = (0, ast_js_1.getVariableFromName)(context, root.name, root); return !variable || variable.defs.length === 0; } function acceptRetrySafeDeclaration(declaration, bindings, yieldedBindings, visitorKeys) { if (declaration.kind !== 'const') { return false; } for (const declarator of declaration.declarations) { if (declarator.id.type !== 'Identifier' || !declarator.init) { return false; } const kind = retrySafeKind(declarator.init, bindings); if (kind === null) { return false; } bindings.set(declarator.id.name, kind); if (usesYieldedBinding(declarator.init, yieldedBindings, visitorKeys)) { yieldedBindings.add(declarator.id.name); } } return true; } function usesYieldedBinding(expression, yieldedBindings, visitorKeys) { const node = (0, ast_js_1.unwrapTypeScriptExpression)(expression); if (node.type === 'Identifier') { return yieldedBindings.has(node.name); } if (node.type === 'MemberExpression') { return (usesYieldedBinding(node.object, yieldedBindings, visitorKeys) || (node.computed && usesYieldedBinding(node.property, yieldedBindings, visitorKeys))); } if (node.type === 'Property') { return (usesYieldedBinding(node.value, yieldedBindings, visitorKeys) || (node.computed && usesYieldedBinding(node.key, yieldedBindings, visitorKeys))); } return (0, ancestor_js_1.childrenOf)(node, visitorKeys).some(child => usesYieldedBinding(child, yieldedBindings, visitorKeys)); } function retrySafeKind(expression, bindings) { const node = (0, ast_js_1.unwrapTypeScriptExpression)(expression); switch (node.type) { case 'Literal': return typeof node.value === 'string' ? 'string' : 'value'; case 'Identifier': return bindings.get(node.name) ?? null; case 'TemplateLiteral': return allRetrySafe(node.expressions, bindings) ? 'string' : null; case 'UnaryExpression': return retrySafeUnaryKind(node, bindings); case 'BinaryExpression': case 'LogicalExpression': return allRetrySafe([node.left, node.right], bindings) ? 'value' : null; case 'ConditionalExpression': return retrySafeConditionalKind(node, bindings); case 'ArrayExpression': return isRetrySafeArray(node, bindings) ? 'record' : null; case 'ObjectExpression': return isRetrySafeObject(node, bindings) ? 'record' : null; case 'MemberExpression': return retrySafeMemberKind(node, bindings); case 'CallExpression': return retrySafeCallKind(node, bindings); default: return null; } } function retrySafeUnaryKind(unary, bindings) { return unary.operator !== 'delete' && retrySafeKind(unary.argument, bindings) !== null ? 'value' : null; } function retrySafeConditionalKind(conditional, bindings) { return allRetrySafe([conditional.test, conditional.consequent, conditional.alternate], bindings) ? 'value' : null; } function retrySafeMemberKind(member, bindings) { return !member.computed && retrySafeKind(member.object, bindings) === 'record' ? 'value' : null; } function allRetrySafe(expressions, bindings) { return expressions.every((expression) => retrySafeKind(expression, bindings) !== null); } function isRetrySafeArray(array, bindings) { return array.elements.every((element) => element === null || (element.type !== 'SpreadElement' && retrySafeKind(element, bindings) !== null)); } function isRetrySafeObject(object, bindings) { return object.properties.every((property) => property.type === 'Property' && property.kind === 'init' && !property.computed && !property.method && !isPrototypeProperty(property) && retrySafeKind(property.value, bindings) !== null); } function isPrototypeProperty(property) { return ((property.key.type === 'Identifier' && property.key.name === '__proto__') || (property.key.type === 'Literal' && property.key.value === '__proto__')); } function retrySafeCallKind(call, bindings) { if (!(0, ast_js_1.isMethodCall)(call) || call.arguments.length !== 0) { return null; } const receiverKind = retrySafeKind(call.callee.object, bindings); if (call.callee.property.name === 'text' && receiverKind === 'yielded') { return 'string'; } if (STRING_METHODS.has(call.callee.property.name) && receiverKind === 'string') { return 'string'; } return null; }