diff --git a/crates/oxc_linter/src/jest_ast_util.rs b/crates/oxc_linter/src/jest_ast_util.rs index cc408d52c..374aa8aca 100644 --- a/crates/oxc_linter/src/jest_ast_util.rs +++ b/crates/oxc_linter/src/jest_ast_util.rs @@ -133,7 +133,8 @@ pub fn parse_jest_fn_call<'a>( let name = resolved.original.unwrap_or(resolved.local).as_str(); let kind = JestFnKind::from(name); let mut members = Vec::new(); - let iter = chain.into_iter().skip(1); + let mut iter = chain.into_iter(); + let head = iter.next()?; let rest = iter; // every member node must have a member expression as their parent @@ -143,7 +144,7 @@ pub fn parse_jest_fn_call<'a>( } if matches!(kind, JestFnKind::Expect) { - return parse_jest_expect_fn_call(call_expr, members, name); + return parse_jest_expect_fn_call(call_expr, members, name, head); } // Check every link in the chain except the last is a member expression @@ -171,6 +172,7 @@ fn parse_jest_expect_fn_call<'a>( call_expr: &'a CallExpression<'a>, members: Vec>, name: &'a str, + head: KnownMemberExpressionProperty<'a>, ) -> Option> { // check if the `member` is being called, which means it is the matcher let has_matcher = match &call_expr.callee { @@ -184,6 +186,7 @@ fn parse_jest_expect_fn_call<'a>( return Some(ParsedJestFnCall::ExpectFnCall(ParsedExpectFnCall { kind: JestFnKind::Expect, + head, members, name: Cow::Borrowed(name), args: &call_expr.arguments, @@ -407,6 +410,7 @@ pub struct ParsedExpectFnCall<'a> { pub kind: JestFnKind, pub members: Vec>, pub name: Cow<'a, str>, + pub head: KnownMemberExpressionProperty<'a>, pub args: &'a oxc_allocator::Vec<'a, Argument<'a>>, // In `expect(1).not.resolved.toBe()`, "not", "resolved" will be modifier // it save a group of modifier index from members diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index ae885b5fe..0a3202ee0 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -112,6 +112,7 @@ mod jest { pub mod no_interpolation_in_snapshots; pub mod no_jasmine_globals; pub mod no_mocks_import; + pub mod no_standalone_expect; pub mod no_test_prefixes; pub mod valid_describe_callback; } @@ -214,6 +215,7 @@ oxc_macros::declare_all_lint_rules! { jest::no_jasmine_globals, jest::no_mocks_import, jest::no_export, + jest::no_standalone_expect, unicorn::no_instanceof_array, unicorn::no_unnecessary_await, unicorn::no_thenable, diff --git a/crates/oxc_linter/src/rules/jest/no_standalone_expect.rs b/crates/oxc_linter/src/rules/jest/no_standalone_expect.rs new file mode 100644 index 000000000..8445cfc67 --- /dev/null +++ b/crates/oxc_linter/src/rules/jest/no_standalone_expect.rs @@ -0,0 +1,321 @@ +use oxc_ast::{ast::Expression, AstKind}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{ + context::LintContext, + jest_ast_util::{ + get_node_name, parse_expect_jest_fn_call, parse_general_jest_fn_call, JestFnKind, + JestGeneralFnKind, ParsedExpectFnCall, + }, + rule::Rule, + AstNode, +}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint(jest/no-standalone-expect): Expect must be inside of a test block.")] +#[diagnostic(severity(warning), help("Did you forget to wrap `expect` in a `test` or `it` block?"))] +struct NoStandaloneExpectDiagnostic(#[label] pub Span); + +/// +#[derive(Debug, Default, Clone)] +pub struct NoStandaloneExpect { + additional_test_block_functions: Vec, +} + +declare_oxc_lint!( + /// ### What it does + /// + /// Prevents `expect` statements outside of a `test` or `it` block. An `expect` + /// within a helper function (but outside of a `test` or `it` block) will not + /// trigger this rule. + /// + /// Statements like `expect.hasAssertions()` will NOT trigger this rule since these + /// calls will execute if they are not in a test block. + /// + /// ### Example + /// ```javascript + /// describe('a test', () => { + /// expect(1).toBe(1); + /// }); + /// ``` + NoStandaloneExpect, + restriction +); + +impl Rule for NoStandaloneExpect { + fn from_configuration(value: serde_json::Value) -> Self { + let additional_test_block_functions = value + .get(0) + .and_then(|v| v.get("additionalTestBlockFunctions")) + .and_then(serde_json::Value::as_array) + .map(|v| { + v.iter().filter_map(serde_json::Value::as_str).map(ToString::to_string).collect() + }) + .unwrap_or_default(); + + Self { additional_test_block_functions } + } + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + let AstKind::CallExpression(call_expr) = node.kind() else { + return; + }; + let Some(jest_fn_call) = parse_expect_jest_fn_call(call_expr, node, ctx) else { + return; + }; + let ParsedExpectFnCall { head, members, .. } = jest_fn_call; + + // only report `expect.hasAssertions` & `expect.assertions` member calls + if members.len() == 1 + && members[0].is_name_unequal("assertions") + && members[0].is_name_unequal("hasAssertions") + { + if let Some(Expression::MemberExpression(_)) = head.parent { + return; + } + } + + if is_correct_place_to_call_expect(node, ctx, &self.additional_test_block_functions) + .is_none() + { + ctx.diagnostic(NoStandaloneExpectDiagnostic(head.span)); + } + } +} + +fn is_correct_place_to_call_expect<'a>( + node: &AstNode<'a>, + ctx: &LintContext<'a>, + additional_test_block_functions: &[String], +) -> Option<()> { + let mut parent = ctx.nodes().parent_node(node.id())?; + + // loop until find the closest function body + loop { + match parent.kind() { + AstKind::FunctionBody(_) => { + break; + } + _ => { + parent = ctx.nodes().parent_node(parent.id())?; + } + } + } + + let node = parent; + let parent = ctx.nodes().parent_node(node.id())?; + + match parent.kind() { + AstKind::Function(function) => { + // `function foo() { expect(1).toBe(1); }` + if function.is_function_declaration() { + return Some(()); + } + + if function.is_expression() { + let grandparent = ctx.nodes().parent_node(parent.id())?; + + // `test('foo', function () { expect(1).toBe(1) })` + // `const foo = function() {expect(1).toBe(1)}` + return if is_var_declarator_or_test_block( + grandparent, + ctx, + additional_test_block_functions, + ) { + Some(()) + } else { + None + }; + } + } + AstKind::ArrowExpression(_) => { + let grandparent = ctx.nodes().parent_node(parent.id())?; + // `test('foo', () => expect(1).toBe(1))` + // `const foo = () => expect(1).toBe(1)` + return if is_var_declarator_or_test_block( + grandparent, + ctx, + additional_test_block_functions, + ) { + Some(()) + } else { + None + }; + } + _ => {} + } + + None +} + +fn is_var_declarator_or_test_block<'a>( + node: &AstNode<'a>, + ctx: &LintContext<'a>, + additional_test_block_functions: &[String], +) -> bool { + match node.kind() { + AstKind::VariableDeclarator(_) => return true, + AstKind::CallExpression(call_expr) => { + if let Some(jest_fn_call) = parse_general_jest_fn_call(call_expr, node, ctx) { + if matches!(jest_fn_call.kind, JestFnKind::General(JestGeneralFnKind::Test)) { + return true; + } + } + + let node_name = get_node_name(&call_expr.callee); + if additional_test_block_functions.iter().any(|fn_name| &node_name == fn_name) { + return true; + } + } + AstKind::Argument(_) => { + if let Some(parent) = ctx.nodes().parent_node(node.id()) { + return is_var_declarator_or_test_block( + parent, + ctx, + additional_test_block_functions, + ); + } + } + _ => {} + } + + false +} + +#[allow(clippy::too_many_lines)] +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + ("expect.any(String)", None), + ("expect.extend({})", None), + ("describe('a test', () => { it('an it', () => {expect(1).toBe(1); }); });", None), + ("describe('a test', () => { it('an it', () => { const func = () => { expect(1).toBe(1); }; }); });", None), + ("describe('a test', () => { const func = () => { expect(1).toBe(1); }; });", None), + ("describe('a test', () => { function func() { expect(1).toBe(1); }; });", None), + ("describe('a test', () => { const func = function(){ expect(1).toBe(1); }; });", None), + ("it('an it', () => expect(1).toBe(1))", None), + ("const func = function(){ expect(1).toBe(1); };", None), + ("const func = () => expect(1).toBe(1);", None), + ("{}", None), + ("it.each([1, true])('trues', value => { expect(value).toBe(true); });", None), + ("it.each([1, true])('trues', value => { expect(value).toBe(true); }); it('an it', () => { expect(1).toBe(1) });", None), + ( + " + it.each` + num | value + ${1} | ${true} + `('trues', ({ value }) => { + expect(value).toBe(true); + }); + ", + None + ), + ("it.only('an only', value => { expect(value).toBe(true); });", None), + ("it.concurrent('an concurrent', value => { expect(value).toBe(true); });", None), + ("describe.each([1, true])('trues', value => { it('an it', () => expect(value).toBe(true) ); });", None), + (" + describe('scenario', () => { + const t = Math.random() ? it.only : it; + t('testing', () => expect(true)); + }); + ", Some(serde_json::json!([{ "additionalTestBlockFunctions": ['t'] }]))), + ( + r#" + each([ + [1, 1, 2], + [1, 2, 3], + [2, 1, 3], + ]).test('returns the result of adding %d to %d', (a, b, expected) => { + expect(a + b).toBe(expected); + }); + "#, Some(serde_json::json!([{ "additionalTestBlockFunctions": ["each.test"] }]))) + ]; + + let fail = vec![ + ("(() => {})('testing', () => expect(true).toBe(false))", None), + ("expect.hasAssertions()", None), + ("expect().hasAssertions()", None), + ( + " + describe('scenario', () => { + const t = Math.random() ? it.only : it; + t('testing', () => expect(true).toBe(false)); + }); + ", + None + ), + ( + " + describe('scenario', () => { + const t = Math.random() ? it.only : it; + t('testing', () => expect(true).toBe(false)); + }); + ", + None + ), + ( + " + each([ + [1, 1, 2], + [1, 2, 3], + [2, 1, 3], + ]).test('returns the result of adding %d to %d', (a, b, expected) => { + expect(a + b).toBe(expected); + }); + ", None), + ( + " + each([ + [1, 1, 2], + [1, 2, 3], + [2, 1, 3], + ]).test('returns the result of adding %d to %d', (a, b, expected) => { + expect(a + b).toBe(expected); + }); + ", + Some(serde_json::json!([{ "additionalTestBlockFunctions": ["each"] }])) + ), + ( + " + each([ + [1, 1, 2], + [1, 2, 3], + [2, 1, 3], + ]).test('returns the result of adding %d to %d', (a, b, expected) => { + expect(a + b).toBe(expected); + }); + ", + Some(serde_json::json!([{ "additionalTestBlockFunctions": ["test"] }])) + ), + ("describe('a test', () => { expect(1).toBe(1); });", None), + ("describe('a test', () => expect(1).toBe(1));", None), + ("describe('a test', () => { const func = () => { expect(1).toBe(1); }; expect(1).toBe(1); });", None), + ("describe('a test', () => { it(() => { expect(1).toBe(1); }); expect(1).toBe(1); });", None), + ("expect(1).toBe(1);", None), + ("{expect(1).toBe(1)}", None), + ("it.each([1, true])('trues', value => { expect(value).toBe(true); }); expect(1).toBe(1);", None), + ("describe.each([1, true])('trues', value => { expect(value).toBe(true); });", None), + ( + " + import { expect as pleaseExpect } from '@jest/globals'; + describe('a test', () => { pleaseExpect(1).toBe(1); }); + ", + None + ), + ( + " + import { expect as pleaseExpect } from '@jest/globals'; + beforeEach(() => pleaseExpect.hasAssertions()); + ", + None + ) + ]; + + Tester::new(NoStandaloneExpect::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/no_standalone_expect.snap b/crates/oxc_linter/src/snapshots/no_standalone_expect.snap new file mode 100644 index 000000000..b4071bfa7 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/no_standalone_expect.snap @@ -0,0 +1,145 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: no_standalone_expect +--- + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:1:1] + 1 │ (() => {})('testing', () => expect(true).toBe(false)) + · ────── + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:1:1] + 1 │ expect.hasAssertions() + · ────── + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:1:1] + 1 │ expect().hasAssertions() + · ────── + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:3:1] + 3 │ const t = Math.random() ? it.only : it; + 4 │ t('testing', () => expect(true).toBe(false)); + · ────── + 5 │ }); + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:3:1] + 3 │ const t = Math.random() ? it.only : it; + 4 │ t('testing', () => expect(true).toBe(false)); + · ────── + 5 │ }); + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:6:1] + 6 │ ]).test('returns the result of adding %d to %d', (a, b, expected) => { + 7 │ expect(a + b).toBe(expected); + · ────── + 8 │ }); + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:6:1] + 6 │ ]).test('returns the result of adding %d to %d', (a, b, expected) => { + 7 │ expect(a + b).toBe(expected); + · ────── + 8 │ }); + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:6:1] + 6 │ ]).test('returns the result of adding %d to %d', (a, b, expected) => { + 7 │ expect(a + b).toBe(expected); + · ────── + 8 │ }); + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:1:1] + 1 │ describe('a test', () => { expect(1).toBe(1); }); + · ────── + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:1:1] + 1 │ describe('a test', () => expect(1).toBe(1)); + · ────── + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:1:1] + 1 │ describe('a test', () => { const func = () => { expect(1).toBe(1); }; expect(1).toBe(1); }); + · ────── + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:1:1] + 1 │ describe('a test', () => { it(() => { expect(1).toBe(1); }); expect(1).toBe(1); }); + · ────── + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:1:1] + 1 │ expect(1).toBe(1); + · ────── + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:1:1] + 1 │ {expect(1).toBe(1)} + · ────── + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:1:1] + 1 │ it.each([1, true])('trues', value => { expect(value).toBe(true); }); expect(1).toBe(1); + · ────── + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:1:1] + 1 │ describe.each([1, true])('trues', value => { expect(value).toBe(true); }); + · ────── + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:2:1] + 2 │ import { expect as pleaseExpect } from '@jest/globals'; + 3 │ describe('a test', () => { pleaseExpect(1).toBe(1); }); + · ──────────── + 4 │ + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + + ⚠ eslint(jest/no-standalone-expect): Expect must be inside of a test block. + ╭─[no_standalone_expect.tsx:2:1] + 2 │ import { expect as pleaseExpect } from '@jest/globals'; + 3 │ beforeEach(() => pleaseExpect.hasAssertions()); + · ──────────── + 4 │ + ╰──── + help: Did you forget to wrap `expect` in a `test` or `it` block? + +