eslint-plugin-sonarjs
Version:
302 lines (301 loc) • 11.4 kB
JavaScript
;
/*
* 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/S8780/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 generate_meta_js_1 = require("../helpers/generate-meta.js");
const ast_js_1 = require("../helpers/ast.js");
const expect_call_chain_js_1 = require("../helpers/expect-call-chain.js");
const mocha_js_1 = require("../helpers/mocha.js");
const module_js_1 = require("../helpers/module.js");
const meta = __importStar(require("./generated-meta.js"));
const messages = {
awaitOrReturn: 'Await or return this async assertion.',
};
const JEST_MODULES = ['jest', '@jest/globals'];
const VITEST_MODULES = ['vitest'];
const JASMINE_MODULES = ['jasmine'];
const JASMINE_DEPENDENCIES = ['jasmine', 'jasmine-core', 'jasmine-node', 'karma-jasmine'];
const PLAYWRIGHT_MODULES = ['@playwright/test'];
const TEST_FUNCTION_NAMES = new Set([
'it',
'it.only',
'it.skip',
'test',
'test.only',
'test.skip',
'specify',
'specify.only',
'specify.skip',
'vitest.it',
'vitest.it.only',
'vitest.it.skip',
'vitest.test',
'vitest.test.only',
'vitest.test.skip',
'@playwright.test.test',
'@playwright.test.test.only',
'@playwright.test.test.skip',
]);
const PLAYWRIGHT_ASYNC_MATCHERS = new Set([
'toBeAttached',
'toBeChecked',
'toBeDisabled',
'toBeEditable',
'toBeEmpty',
'toBeEnabled',
'toBeFocused',
'toBeHidden',
'toBeInViewport',
'toBeOK',
'toBeVisible',
'toContainClass',
'toContainText',
'toHaveAccessibleDescription',
'toHaveAccessibleName',
'toHaveAttribute',
'toHaveClass',
'toHaveCount',
'toHaveCSS',
'toHaveId',
'toHaveJSProperty',
'toHaveRole',
'toHaveScreenshot',
'toHaveText',
'toHaveTitle',
'toHaveURL',
'toHaveValue',
'toHaveValues',
'toMatchAriaSnapshot',
]);
exports.rule = {
meta: (0, generate_meta_js_1.generateMeta)(meta, { messages }),
create(context) {
const frameworks = {
jest: (0, module_js_1.importsOrDependsOnModule)(context, JEST_MODULES, ['jest']),
vitest: (0, module_js_1.importsOrDependsOnModule)(context, VITEST_MODULES, VITEST_MODULES),
jasmine: (0, module_js_1.importsOrDependsOnModule)(context, JASMINE_MODULES, JASMINE_DEPENDENCIES),
playwright: (0, module_js_1.importsOrDependsOnModule)(context, PLAYWRIGHT_MODULES, PLAYWRIGHT_MODULES),
};
if (!Object.values(frameworks).some(Boolean)) {
return {};
}
return {
'CallExpression:exit'(node) {
const call = node;
const callback = extractSupportedTestCallback(context, call);
if (!callback) {
return;
}
if (callback.body.type !== 'BlockStatement') {
return;
}
if (usesDoneCallback(callback)) {
return;
}
forEachExpressionStatement(callback.body, statement => {
const assertionNode = getAsyncAssertionNode(context, statement.expression, frameworks);
if (assertionNode) {
context.report({
node: assertionNode,
messageId: 'awaitOrReturn',
});
}
});
},
};
},
};
function extractSupportedTestCallback(context, call) {
const mochaTestCase = (0, mocha_js_1.extractTestCase)(call);
if (mochaTestCase) {
return isFunctionArgument(mochaTestCase.callback) ? mochaTestCase.callback : null;
}
const name = (0, module_js_1.getFullyQualifiedName)(context, call.callee) ?? getDottedName(call.callee);
if (!name || !TEST_FUNCTION_NAMES.has(name)) {
return null;
}
for (const argument of call.arguments) {
if (isFunctionArgument(argument)) {
return argument;
}
}
return null;
}
function isFunctionArgument(node) {
return node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression';
}
/**
* Jest/Jasmine/Vitest pass a `done` callback whenever the test function declares a parameter
* (arity-based, regardless of its name). Mixing that with an awaited/returned assertion makes
* the runtime throw ("cannot both take a 'done' callback and return something"), so the fix this
* rule suggests doesn't apply to such tests. Playwright's fixtures parameter is always a
* destructuring pattern, never a plain identifier, so it isn't mistaken for a done callback.
*/
function usesDoneCallback(callback) {
const [firstParam] = callback.params;
return firstParam?.type === 'Identifier';
}
function forEachExpressionStatement(node, callback) {
switch (node.type) {
case 'ExpressionStatement':
callback(node);
return;
case 'BlockStatement':
node.body.forEach(child => forEachExpressionStatement(child, callback));
return;
case 'IfStatement':
forEachExpressionStatement(node.consequent, callback);
if (node.alternate) {
forEachExpressionStatement(node.alternate, callback);
}
return;
case 'ForStatement':
case 'ForInStatement':
case 'ForOfStatement':
case 'WhileStatement':
case 'DoWhileStatement':
case 'LabeledStatement':
case 'WithStatement':
forEachExpressionStatement(node.body, callback);
return;
case 'SwitchStatement':
node.cases.forEach(switchCase => switchCase.consequent.forEach(child => forEachExpressionStatement(child, callback)));
return;
case 'TryStatement':
forEachExpressionStatement(node.block, callback);
if (node.handler) {
forEachExpressionStatement(node.handler.body, callback);
}
if (node.finalizer) {
forEachExpressionStatement(node.finalizer, callback);
}
return;
}
}
function getAsyncAssertionNode(context, expression, frameworks) {
const unwrapped = (0, expect_call_chain_js_1.unwrapChainExpression)(expression);
if (unwrapped.type === 'AwaitExpression') {
return null;
}
if (unwrapped.type !== 'CallExpression') {
return null;
}
return (getJestOrVitestAsyncNode(context, unwrapped, frameworks) ??
getJasmineAsyncNode(context, unwrapped, frameworks) ??
getPlaywrightAsyncNode(context, unwrapped, frameworks));
}
function getJestOrVitestAsyncNode(context, call, frameworks) {
if (!frameworks.jest && !frameworks.vitest) {
return null;
}
const { segments } = (0, expect_call_chain_js_1.collectCallChain)(call);
const asyncSegment = segments.find(segment => segment.name === 'resolves' || segment.name === 'rejects');
if (!asyncSegment) {
return null;
}
const root = (0, expect_call_chain_js_1.getRootCall)(call);
if (!root || !isExpectCall(context, root, frameworks, false)) {
return null;
}
return asyncSegment.node;
}
function getJasmineAsyncNode(context, call, frameworks) {
if (!frameworks.jasmine) {
return null;
}
const root = (0, expect_call_chain_js_1.getRootCall)(call);
if (root && isNamedCall(context, root, 'expectAsync', ['jasmine.expectAsync'])) {
return root.callee;
}
return null;
}
function getPlaywrightAsyncNode(context, call, frameworks) {
if (!frameworks.playwright || call.callee.type !== 'MemberExpression' || call.callee.computed) {
return null;
}
const matcher = call.callee.property;
if (!(0, ast_js_1.isIdentifier)(matcher) || !PLAYWRIGHT_ASYNC_MATCHERS.has(matcher.name)) {
return null;
}
const root = (0, expect_call_chain_js_1.getRootCall)(call);
if (root && isExpectCall(context, root, frameworks, true)) {
return matcher;
}
return null;
}
function isExpectCall(context, call, frameworks, playwrightOnly) {
const fqn = (0, module_js_1.getFullyQualifiedName)(context, call.callee);
if (fqn) {
return playwrightOnly
? fqn === '@playwright.test.expect'
: fqn === 'expect' ||
fqn === '@jest.globals.expect' ||
fqn === 'vitest.expect' ||
(frameworks.jest && (0, ast_js_1.isIdentifier)(call.callee, 'expect')) ||
(frameworks.vitest && (0, ast_js_1.isIdentifier)(call.callee, 'expect'));
}
return !playwrightOnly && (0, ast_js_1.isIdentifier)(call.callee, 'expect');
}
function isNamedCall(context, call, globalName, importedNames) {
const fqn = (0, module_js_1.getFullyQualifiedName)(context, call.callee);
return importedNames.includes(fqn ?? '') || (0, ast_js_1.isIdentifier)(call.callee, globalName);
}
function getDottedName(node) {
if (node.type === 'Identifier') {
return node.name;
}
if (node.type !== 'MemberExpression' || node.computed || !(0, ast_js_1.isIdentifier)(node.property)) {
return null;
}
const objectName = getDottedName(node.object);
return objectName ? `${objectName}.${node.property.name}` : null;
}