eslint-plugin-sonarjs
Version:
313 lines (312 loc) • 10.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.extractTestAssertion = extractTestAssertion;
const ast_js_1 = require("./ast.js");
const assertions_chai_js_1 = require("./assertions-chai.js");
const assertions_cypress_js_1 = require("./assertions-cypress.js");
const module_js_1 = require("./module.js");
const JEST_LIKE_MODULES = ['vitest', 'bun:test', '@jest/globals'];
// Jest-like test runners which expose global methods that can be used in assertions
const JEST_LIKE_GLOBAL_MODULES = ['jest'];
const JASMINE_MODULES = ['jasmine'];
const JASMINE_GLOBAL_MODULES = ['jasmine', 'jasmine-core', 'jasmine-node', 'karma-jasmine'];
const PLAYWRIGHT_MODULES = ['@playwright/test'];
const CHAI_LIKE_GLOBAL_MODULES = [
'chai',
'chai/register-assert',
'chai/register-expect',
'chai/register-should',
'cypress',
];
const NODE_ASSERT_MODULES = ['assert', 'node:assert', 'assert/strict', 'node:assert/strict'];
/**
* Maps common Jest matcher names to their corresponding assertion predicates.
* Also covers Jest-compatible assertion libraries like Vitest, Bun's test runner, etc.
* https://jestjs.io/docs/expect
*/
const JEST_PREDICATES_MAPPING = {
toBeTruthy: 'truthy',
toBeFalsy: 'falsy',
toBeDefined: 'defined',
toBeUndefined: 'undefined',
toBeNull: 'null',
};
const ASSERTION_LIBRARIES = [
{
imports: JEST_LIKE_MODULES,
dependencies: JEST_LIKE_GLOBAL_MODULES,
extract: (_context, node) => extractExpectAssertion(node, 'jest-like'),
},
{
imports: JASMINE_MODULES,
dependencies: JASMINE_GLOBAL_MODULES,
extract: (_context, node) => extractExpectAssertion(node, 'jasmine'),
},
{
imports: PLAYWRIGHT_MODULES,
dependencies: PLAYWRIGHT_MODULES,
extract: (_context, node) => extractExpectAssertion(node, 'playwright'),
},
{
imports: CHAI_LIKE_GLOBAL_MODULES,
dependencies: CHAI_LIKE_GLOBAL_MODULES,
extract: (context, node) => (0, assertions_chai_js_1.extractChaiAssertion)(context, node, true) ?? (0, assertions_cypress_js_1.extractCypressChainAssertion)(node),
},
];
function extractTestAssertion(context, node) {
// Explicit imports in the current file are more precise than project-wide dependency signals.
const importedAssertion = extractAssertionFromLibraries(context, node, library => (0, module_js_1.importsModule)(context, library.imports));
if (importedAssertion) {
return importedAssertion;
}
const dependencyAssertion = extractAssertionFromLibraries(context, node, library => (0, module_js_1.importsOrDependsOnModule)(context, library.imports, library.dependencies));
if (dependencyAssertion) {
return dependencyAssertion;
}
// covers Node.js assert
if (node.type === 'CallExpression' && (0, module_js_1.importsModule)(context, NODE_ASSERT_MODULES)) {
return extractNodeJSAssertion(context, node);
}
return null;
}
function extractAssertionFromLibraries(context, node, isAvailable) {
for (const library of ASSERTION_LIBRARIES) {
if (!isAvailable(library)) {
continue;
}
const assertion = library.extract(context, node);
if (assertion) {
return assertion;
}
}
return null;
}
function extractExpectAssertion(expectCall, style) {
if (expectCall.type !== 'CallExpression') {
return null;
}
if (!(0, ast_js_1.isMethodCall)(expectCall)) {
return null;
}
const matcher = expectCall.callee.property;
const chain = extractExpectChain(expectCall.callee.object);
if (!chain) {
return null;
}
const actual = getArgumentAtIndex(chain.expectCall, 0);
if (!actual) {
return null;
}
const predicate = JEST_PREDICATES_MAPPING[matcher.name];
if (predicate !== undefined && expectCall.arguments.length === 0) {
return {
style,
kind: 'predicate',
predicate,
actual,
negated: chain.negated,
node: expectCall,
reportNode: actual,
};
}
const comparison = getJestComparison(matcher.name);
if (comparison && expectCall.arguments.length === 1) {
const expected = getArgumentAtIndex(expectCall, 0);
if (!expected) {
return null;
}
return {
style,
kind: 'comparison',
comparison,
actual,
expected,
negated: chain.negated,
node: expectCall,
reportNode: matcher,
};
}
return null;
}
function getJestComparison(name) {
switch (name) {
case 'toBe':
return 'strict';
case 'toEqual':
case 'toStrictEqual':
return 'deep';
default:
return null;
}
}
function extractExpectChain(node) {
let current = node;
let negated = false;
while (current.type === 'MemberExpression' && !current.computed) {
// covers cases like `expect(value).not.toBeTruthy()` where the presence of `.not` negates the assertion
// also guards against invalid chains like `expect(value).toBeTruthy.not`
if (negated || !(0, ast_js_1.isIdentifier)(current.property, 'not')) {
return null;
}
negated = true;
current = current.object;
}
// guards against invalid expect chains
// like `expect(value).toBeTruthy` (missing call)
if (current.type !== 'CallExpression' ||
current.arguments.length !== 1 ||
!(0, ast_js_1.isIdentifier)(current.callee, 'expect')) {
return null;
}
return { expectCall: current, negated };
}
function extractNodeJSAssertion(context, node) {
const assertCall = getNodeJSAssertCall(context, node);
if (!assertCall) {
return null;
}
if (assertCall.method === 'assert' || assertCall.method === 'ok') {
const actual = getArgumentAtIndex(node, 0);
if (!actual) {
return null;
}
return {
style: 'node-assert',
kind: 'predicate',
predicate: 'truthy',
actual,
negated: false,
node,
reportNode: actual,
};
}
const actual = getArgumentAtIndex(node, 0);
const expected = getArgumentAtIndex(node, 1);
if (!actual || !expected) {
return null;
}
return {
style: 'node-assert',
kind: 'comparison',
comparison: getNodeJSComparison(assertCall.method),
actual,
expected,
negated: assertCall.negated,
node,
reportNode: assertCall.reportNode,
};
}
function getNodeJSComparison(method) {
switch (method) {
case 'deepStrictEqual':
case 'notDeepStrictEqual':
case 'deepEqual':
case 'notDeepEqual':
return 'deep';
case 'looseDeepEqual':
case 'looseNotDeepEqual':
return 'loose';
default:
return 'strict';
}
}
function getNodeJSAssertCall(context, node) {
// fully qualified assert method like `assert.strictEqual()`
const fqn = (0, module_js_1.getFullyQualifiedName)(context, node.callee);
const fqnMethod = getNodeJSAssertMethodFromFqn(fqn);
if (fqnMethod) {
return {
// `negated` is derived from the raw method name, before normalization renames
// e.g. `notDeepEqual` to `looseNotDeepEqual` for a non-strict import, which would
// otherwise no longer start with `not`.
method: normalizeNodeJSAssertMethod(fqnMethod, isStrictNodeJSAssertFqn(fqn)),
negated: fqnMethod.startsWith('not'),
reportNode: node.callee,
};
}
// `assert.xxx()` where `assert`'s origin isn't traceable via its fully qualified name, e.g. a
// test-framework-provided `assert` parameter (`QUnit.test('...', assert => { assert.ok(x); })`).
// Whether such an `assert` is the strict or non-strict variant can't be determined here, so
// `deepEqual`/`notDeepEqual` — whose comparison kind depends on that — are left unresolved
// rather than guessing non-strict; the unambiguous methods are still recognized.
if ((0, ast_js_1.isMethodCall)(node) && (0, ast_js_1.isIdentifier)(node.callee.object, 'assert')) {
const method = getNodeJSAssertMethodFromName(node.callee.property.name);
if (method === 'deepEqual' || method === 'notDeepEqual') {
return null;
}
if (method) {
return {
method: normalizeNodeJSAssertMethod(method, false),
negated: method.startsWith('not'),
reportNode: node.callee.property,
};
}
}
return null;
}
function normalizeNodeJSAssertMethod(method, isStrictAssert) {
if (method === 'deepEqual') {
return isStrictAssert ? method : 'looseDeepEqual';
}
if (method === 'notDeepEqual') {
return isStrictAssert ? method : 'looseNotDeepEqual';
}
return method;
}
function isStrictNodeJSAssertFqn(fqn) {
return fqn?.startsWith('assert.strict.') ?? false;
}
function getNodeJSAssertMethodFromFqn(fqn) {
switch (fqn) {
case 'assert':
case 'assert.strict':
return 'assert';
case 'assert.ok':
case 'assert.strict.ok':
return 'ok';
case 'assert.deepEqual':
case 'assert.strict.deepEqual':
return 'deepEqual';
case 'assert.notDeepEqual':
case 'assert.strict.notDeepEqual':
return 'notDeepEqual';
case 'assert.strictEqual':
case 'assert.strict.strictEqual':
return 'strictEqual';
case 'assert.notStrictEqual':
case 'assert.strict.notStrictEqual':
return 'notStrictEqual';
case 'assert.deepStrictEqual':
case 'assert.strict.deepStrictEqual':
return 'deepStrictEqual';
case 'assert.notDeepStrictEqual':
case 'assert.strict.notDeepStrictEqual':
return 'notDeepStrictEqual';
default:
return null;
}
}
/**
* covers cases like `const { strictEqual } = require('assert'); strictEqual(...)` or `import { strictEqual } from 'assert'; strictEqual(...)`
*/
function getNodeJSAssertMethodFromName(name) {
switch (name) {
case 'ok':
case 'deepEqual':
case 'notDeepEqual':
case 'strictEqual':
case 'notStrictEqual':
case 'deepStrictEqual':
case 'notDeepStrictEqual':
return name;
default:
return null;
}
}
function getArgumentAtIndex(node, index) {
const argument = node.arguments[index];
if (!argument || argument.type === 'SpreadElement') {
return null;
}
return argument;
}