eslint-plugin-sonarjs
Version:
371 lines (370 loc) • 14.7 kB
JavaScript
"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;
}