eslint-plugin-sonarjs
Version:
209 lines (208 loc) • 9.71 kB
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 mocha_style_test_frameworks_js_1 = require("../helpers/mocha-style-test-frameworks.js");
const assertion_detection_js_1 = require("../helpers/assertion-detection.js");
const meta = __importStar(require("./generated-meta.js"));
const messages = {
moveAssertion: 'Move this assertion into a test case or a lifecycle hook.',
};
exports.rule = {
meta: (0, generate_meta_js_1.generateMeta)(meta, { messages }),
create(context) {
if (!(0, assertion_detection_js_1.hasSupportedAssertionLibrary)(context)) {
return {};
}
// Dedupe by enclosing statement: a single `expect(x).toBe(y)` produces two
// matching CallExpressions (the inner `expect()` and the outer chained
// call), and Cypress `.should().and()` matches per link. They all share one
// ExpressionStatement, so we report each statement at most once.
const reportedStatements = new Set();
// Test structure is a whole-file property, so top-level assertions are only
// resolved at Program:exit.
let hasTestStructure = false;
// Top-level assertion statements → whether any matched call is script-capable.
const topLevelStatements = new Map();
const report = (statement) => {
if (!reportedStatements.has(statement)) {
reportedStatements.add(statement);
context.report({ node: statement, messageId: 'moveAssertion' });
}
};
return {
CallExpression(node) {
if (isTestStructureConstruct(context, node)) {
hasTestStructure = true;
}
if (!(0, assertion_detection_js_1.isAssertion)(context, node)) {
return;
}
if ((0, assertion_detection_js_1.isTypeLevelAssertion)(context, node)) {
return;
}
const statement = findEnclosingExpressionStatement(context, node);
if (statement === undefined) {
return;
}
switch (classifyPlacement(context, node)) {
case 'inside-function':
return; // a test case, a lifecycle hook, or an unrelated helper
case 'suite-body':
report(statement); // runs at suite-collection time — always misplaced
return;
case 'top-level': {
const scriptCapable = (topLevelStatements.get(statement) ?? false) ||
(0, assertion_detection_js_1.isScriptCapableAssertion)(context, node);
topLevelStatements.set(statement, scriptCapable);
break;
}
}
},
'Program:exit'() {
// A top-level script-capable assertion in a file with no test structure is
// a standalone script (the assertion is the test); a runner-bound one is
// always misplaced. Flag everything else.
for (const [statement, scriptCapable] of topLevelStatements) {
if (hasTestStructure || !scriptCapable) {
report(statement);
}
}
},
};
},
};
/**
* Whether `call` proves the file is a test file: a Mocha-style suite or test case,
* a `@playwright/test`-bound `test(...)`, or a `test.describe(...)`. Binding-checked
* (reusing `isMochaTestConstruct`), so a locally-defined `describe`/`it`/`test` does
* not count. The name-only `test.describe` arm (for Playwright's extended-fixtures
* pattern, where `test` does not resolve to `@playwright/test`) is restricted to
* `describe` so a bare local `test()` is not mistaken for test structure.
*/
function isTestStructureConstruct(context, call) {
return ((0, mocha_style_test_frameworks_js_1.isMochaTestConstruct)(context, call, mocha_style_test_frameworks_js_1.SUITE_FUNCTION_NAMES, { allowParameterized: true }) ||
(0, mocha_style_test_frameworks_js_1.isMochaTestConstruct)(context, call, mocha_style_test_frameworks_js_1.TEST_FUNCTION_NAMES, { allowParameterized: true }) ||
(0, mocha_style_test_frameworks_js_1.getPlaywrightTestQualifiers)(context, call.callee) !== undefined ||
isPlaywrightDescribe(context, call));
}
/**
* Returns the ExpressionStatement `node` is the expression of, or `undefined` when
* the assertion is not a standalone statement (an argument, declaration, return, or
* arrow concise body) — we only flag statement-level assertions, the strongest
* signal it actually executes here. The walk climbs only the assertion's own
* call/member chain (including `await`/`yield`/optional-chaining/non-null wrappers,
* which matters for async-first libraries like supertest).
*/
function findEnclosingExpressionStatement(context, node) {
const ancestors = context.sourceCode.getAncestors(node);
let child = node;
for (let i = ancestors.length - 1; i >= 0; i--) {
const parent = ancestors[i];
switch (parent.type) {
case 'ExpressionStatement':
return parent;
case 'MemberExpression':
if (parent.object !== child) {
return undefined;
}
break;
case 'CallExpression':
if (parent.callee !== child) {
return undefined;
}
break;
case 'ChainExpression':
case 'AwaitExpression':
case 'YieldExpression':
break;
default:
// TSNonNullExpression (`x!.should()`) is a TS-only node absent from the
// estree type union; treat it as a transparent chain wrapper too.
if (parent.type !== 'TSNonNullExpression') {
return undefined;
}
}
child = parent;
}
return undefined;
}
/**
* Where an assertion sits relative to test cases:
* - `top-level`: no enclosing function — it runs at module load.
* - `suite-body`: its nearest enclosing function is a suite callback
* (`describe`/`context`/`suite` or Playwright's `test.describe`) — it runs at
* suite-collection time, never as part of a test.
* - `inside-function`: any other enclosing function — a test case, a lifecycle
* hook, or an unrelated helper. We cannot prove it runs outside a test, so we
* stay silent.
*/
function classifyPlacement(context, node) {
const ancestors = context.sourceCode.getAncestors(node);
const fnIndex = findEnclosingFunctionIndex(ancestors);
if (fnIndex === -1) {
return 'top-level';
}
const enclosingFunction = ancestors[fnIndex];
const parent = ancestors[fnIndex - 1];
const isSuiteBody = parent?.type === 'CallExpression' &&
parent.arguments.includes(enclosingFunction) &&
isSuiteCallback(context, parent);
return isSuiteBody ? 'suite-body' : 'inside-function';
}
/**
* Whether `call` is a suite declaration whose body runs at collection time:
* a Mocha-style `describe`/`context`/`suite` (also covering Jest/Vitest/Jasmine/
* Cypress), or Playwright's `test.describe(...)`.
*/
function isSuiteCallback(context, call) {
return ((0, mocha_style_test_frameworks_js_1.isMochaTestConstruct)(context, call, mocha_style_test_frameworks_js_1.SUITE_FUNCTION_NAMES, { allowParameterized: true }) ||
isPlaywrightDescribe(context, call));
}
function isPlaywrightDescribe(context, call) {
const qualifiers = (0, mocha_style_test_frameworks_js_1.getPlaywrightTestQualifiers)(context, call.callee) ??
(0, mocha_style_test_frameworks_js_1.getPlaywrightDescribeQualifiers)(call.callee);
return qualifiers?.[0] === 'describe';
}
function findEnclosingFunctionIndex(ancestors) {
for (let i = ancestors.length - 1; i >= 0; i--) {
if (ast_js_1.FUNCTION_NODES.includes(ancestors[i].type)) {
return i;
}
}
return -1;
}