diff --git a/crates/oxc_linter/src/generated/rule_runner_impls.rs b/crates/oxc_linter/src/generated/rule_runner_impls.rs index 2697612c08a48..263901c9b2f3f 100644 --- a/crates/oxc_linter/src/generated/rule_runner_impls.rs +++ b/crates/oxc_linter/src/generated/rule_runner_impls.rs @@ -4223,6 +4223,14 @@ impl RuleRunner for crate::rules::vitest::no_importing_vitest_globals::NoImporti const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::Run; } +impl RuleRunner + for crate::rules::vitest::prefer_called_exactly_once_with::PreferCalledExactlyOnceWith +{ + const NODE_TYPES: Option<&AstTypesBitset> = + Some(&AstTypesBitset::from_types(&[AstType::BlockStatement, AstType::Program])); + const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::Run; +} + impl RuleRunner for crate::rules::vitest::prefer_called_once::PreferCalledOnce { const NODE_TYPES: Option<&AstTypesBitset> = None; const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnJestNode; diff --git a/crates/oxc_linter/src/generated/rules_enum.rs b/crates/oxc_linter/src/generated/rules_enum.rs index 5568235edcc1f..c4b6c7942cca5 100644 --- a/crates/oxc_linter/src/generated/rules_enum.rs +++ b/crates/oxc_linter/src/generated/rules_enum.rs @@ -673,6 +673,7 @@ pub use crate::rules::vitest::hoisted_apis_on_top::HoistedApisOnTop as VitestHoi pub use crate::rules::vitest::no_conditional_tests::NoConditionalTests as VitestNoConditionalTests; pub use crate::rules::vitest::no_import_node_test::NoImportNodeTest as VitestNoImportNodeTest; pub use crate::rules::vitest::no_importing_vitest_globals::NoImportingVitestGlobals as VitestNoImportingVitestGlobals; +pub use crate::rules::vitest::prefer_called_exactly_once_with::PreferCalledExactlyOnceWith as VitestPreferCalledExactlyOnceWith; pub use crate::rules::vitest::prefer_called_once::PreferCalledOnce as VitestPreferCalledOnce; pub use crate::rules::vitest::prefer_called_times::PreferCalledTimes as VitestPreferCalledTimes; pub use crate::rules::vitest::prefer_describe_function_title::PreferDescribeFunctionTitle as VitestPreferDescribeFunctionTitle; @@ -1371,6 +1372,7 @@ pub enum RuleEnum { VitestNoConditionalTests(VitestNoConditionalTests), VitestNoImportNodeTest(VitestNoImportNodeTest), VitestNoImportingVitestGlobals(VitestNoImportingVitestGlobals), + VitestPreferCalledExactlyOnceWith(VitestPreferCalledExactlyOnceWith), VitestPreferCalledOnce(VitestPreferCalledOnce), VitestPreferCalledTimes(VitestPreferCalledTimes), VitestPreferDescribeFunctionTitle(VitestPreferDescribeFunctionTitle), @@ -2148,7 +2150,9 @@ const VITEST_HOISTED_APIS_ON_TOP_ID: usize = VITEST_CONSISTENT_VITEST_VI_ID + 1u const VITEST_NO_CONDITIONAL_TESTS_ID: usize = VITEST_HOISTED_APIS_ON_TOP_ID + 1usize; const VITEST_NO_IMPORT_NODE_TEST_ID: usize = VITEST_NO_CONDITIONAL_TESTS_ID + 1usize; const VITEST_NO_IMPORTING_VITEST_GLOBALS_ID: usize = VITEST_NO_IMPORT_NODE_TEST_ID + 1usize; -const VITEST_PREFER_CALLED_ONCE_ID: usize = VITEST_NO_IMPORTING_VITEST_GLOBALS_ID + 1usize; +const VITEST_PREFER_CALLED_EXACTLY_ONCE_WITH_ID: usize = + VITEST_NO_IMPORTING_VITEST_GLOBALS_ID + 1usize; +const VITEST_PREFER_CALLED_ONCE_ID: usize = VITEST_PREFER_CALLED_EXACTLY_ONCE_WITH_ID + 1usize; const VITEST_PREFER_CALLED_TIMES_ID: usize = VITEST_PREFER_CALLED_ONCE_ID + 1usize; const VITEST_PREFER_DESCRIBE_FUNCTION_TITLE_ID: usize = VITEST_PREFER_CALLED_TIMES_ID + 1usize; const VITEST_PREFER_EXPECT_TYPE_OF_ID: usize = VITEST_PREFER_DESCRIBE_FUNCTION_TITLE_ID + 1usize; @@ -2949,6 +2953,7 @@ impl RuleEnum { Self::VitestNoConditionalTests(_) => VITEST_NO_CONDITIONAL_TESTS_ID, Self::VitestNoImportNodeTest(_) => VITEST_NO_IMPORT_NODE_TEST_ID, Self::VitestNoImportingVitestGlobals(_) => VITEST_NO_IMPORTING_VITEST_GLOBALS_ID, + Self::VitestPreferCalledExactlyOnceWith(_) => VITEST_PREFER_CALLED_EXACTLY_ONCE_WITH_ID, Self::VitestPreferCalledOnce(_) => VITEST_PREFER_CALLED_ONCE_ID, Self::VitestPreferCalledTimes(_) => VITEST_PREFER_CALLED_TIMES_ID, Self::VitestPreferDescribeFunctionTitle(_) => VITEST_PREFER_DESCRIBE_FUNCTION_TITLE_ID, @@ -3739,6 +3744,7 @@ impl RuleEnum { Self::VitestNoConditionalTests(_) => VitestNoConditionalTests::NAME, Self::VitestNoImportNodeTest(_) => VitestNoImportNodeTest::NAME, Self::VitestNoImportingVitestGlobals(_) => VitestNoImportingVitestGlobals::NAME, + Self::VitestPreferCalledExactlyOnceWith(_) => VitestPreferCalledExactlyOnceWith::NAME, Self::VitestPreferCalledOnce(_) => VitestPreferCalledOnce::NAME, Self::VitestPreferCalledTimes(_) => VitestPreferCalledTimes::NAME, Self::VitestPreferDescribeFunctionTitle(_) => VitestPreferDescribeFunctionTitle::NAME, @@ -4571,6 +4577,9 @@ impl RuleEnum { Self::VitestNoConditionalTests(_) => VitestNoConditionalTests::CATEGORY, Self::VitestNoImportNodeTest(_) => VitestNoImportNodeTest::CATEGORY, Self::VitestNoImportingVitestGlobals(_) => VitestNoImportingVitestGlobals::CATEGORY, + Self::VitestPreferCalledExactlyOnceWith(_) => { + VitestPreferCalledExactlyOnceWith::CATEGORY + } Self::VitestPreferCalledOnce(_) => VitestPreferCalledOnce::CATEGORY, Self::VitestPreferCalledTimes(_) => VitestPreferCalledTimes::CATEGORY, Self::VitestPreferDescribeFunctionTitle(_) => { @@ -5366,6 +5375,7 @@ impl RuleEnum { Self::VitestNoConditionalTests(_) => VitestNoConditionalTests::FIX, Self::VitestNoImportNodeTest(_) => VitestNoImportNodeTest::FIX, Self::VitestNoImportingVitestGlobals(_) => VitestNoImportingVitestGlobals::FIX, + Self::VitestPreferCalledExactlyOnceWith(_) => VitestPreferCalledExactlyOnceWith::FIX, Self::VitestPreferCalledOnce(_) => VitestPreferCalledOnce::FIX, Self::VitestPreferCalledTimes(_) => VitestPreferCalledTimes::FIX, Self::VitestPreferDescribeFunctionTitle(_) => VitestPreferDescribeFunctionTitle::FIX, @@ -6351,6 +6361,9 @@ impl RuleEnum { Self::VitestNoImportingVitestGlobals(_) => { VitestNoImportingVitestGlobals::documentation() } + Self::VitestPreferCalledExactlyOnceWith(_) => { + VitestPreferCalledExactlyOnceWith::documentation() + } Self::VitestPreferCalledOnce(_) => VitestPreferCalledOnce::documentation(), Self::VitestPreferCalledTimes(_) => VitestPreferCalledTimes::documentation(), Self::VitestPreferDescribeFunctionTitle(_) => { @@ -8273,6 +8286,10 @@ impl RuleEnum { VitestNoImportingVitestGlobals::config_schema(generator) .or_else(|| VitestNoImportingVitestGlobals::schema(generator)) } + Self::VitestPreferCalledExactlyOnceWith(_) => { + VitestPreferCalledExactlyOnceWith::config_schema(generator) + .or_else(|| VitestPreferCalledExactlyOnceWith::schema(generator)) + } Self::VitestPreferCalledOnce(_) => VitestPreferCalledOnce::config_schema(generator) .or_else(|| VitestPreferCalledOnce::schema(generator)), Self::VitestPreferCalledTimes(_) => VitestPreferCalledTimes::config_schema(generator) @@ -9024,6 +9041,7 @@ impl RuleEnum { Self::VitestNoConditionalTests(_) => "vitest", Self::VitestNoImportNodeTest(_) => "vitest", Self::VitestNoImportingVitestGlobals(_) => "vitest", + Self::VitestPreferCalledExactlyOnceWith(_) => "vitest", Self::VitestPreferCalledOnce(_) => "vitest", Self::VitestPreferCalledTimes(_) => "vitest", Self::VitestPreferDescribeFunctionTitle(_) => "vitest", @@ -11181,6 +11199,11 @@ impl RuleEnum { Self::VitestNoImportingVitestGlobals(_) => Ok(Self::VitestNoImportingVitestGlobals( VitestNoImportingVitestGlobals::from_configuration(value)?, )), + Self::VitestPreferCalledExactlyOnceWith(_) => { + Ok(Self::VitestPreferCalledExactlyOnceWith( + VitestPreferCalledExactlyOnceWith::from_configuration(value)?, + )) + } Self::VitestPreferCalledOnce(_) => { Ok(Self::VitestPreferCalledOnce(VitestPreferCalledOnce::from_configuration(value)?)) } @@ -11947,6 +11970,7 @@ impl RuleEnum { Self::VitestNoConditionalTests(rule) => rule.to_configuration(), Self::VitestNoImportNodeTest(rule) => rule.to_configuration(), Self::VitestNoImportingVitestGlobals(rule) => rule.to_configuration(), + Self::VitestPreferCalledExactlyOnceWith(rule) => rule.to_configuration(), Self::VitestPreferCalledOnce(rule) => rule.to_configuration(), Self::VitestPreferCalledTimes(rule) => rule.to_configuration(), Self::VitestPreferDescribeFunctionTitle(rule) => rule.to_configuration(), @@ -12645,6 +12669,7 @@ impl RuleEnum { Self::VitestNoConditionalTests(rule) => rule.run(node, ctx), Self::VitestNoImportNodeTest(rule) => rule.run(node, ctx), Self::VitestNoImportingVitestGlobals(rule) => rule.run(node, ctx), + Self::VitestPreferCalledExactlyOnceWith(rule) => rule.run(node, ctx), Self::VitestPreferCalledOnce(rule) => rule.run(node, ctx), Self::VitestPreferCalledTimes(rule) => rule.run(node, ctx), Self::VitestPreferDescribeFunctionTitle(rule) => rule.run(node, ctx), @@ -13341,6 +13366,7 @@ impl RuleEnum { Self::VitestNoConditionalTests(rule) => rule.run_once(ctx), Self::VitestNoImportNodeTest(rule) => rule.run_once(ctx), Self::VitestNoImportingVitestGlobals(rule) => rule.run_once(ctx), + Self::VitestPreferCalledExactlyOnceWith(rule) => rule.run_once(ctx), Self::VitestPreferCalledOnce(rule) => rule.run_once(ctx), Self::VitestPreferCalledTimes(rule) => rule.run_once(ctx), Self::VitestPreferDescribeFunctionTitle(rule) => rule.run_once(ctx), @@ -14133,6 +14159,7 @@ impl RuleEnum { Self::VitestNoConditionalTests(rule) => rule.run_on_jest_node(jest_node, ctx), Self::VitestNoImportNodeTest(rule) => rule.run_on_jest_node(jest_node, ctx), Self::VitestNoImportingVitestGlobals(rule) => rule.run_on_jest_node(jest_node, ctx), + Self::VitestPreferCalledExactlyOnceWith(rule) => rule.run_on_jest_node(jest_node, ctx), Self::VitestPreferCalledOnce(rule) => rule.run_on_jest_node(jest_node, ctx), Self::VitestPreferCalledTimes(rule) => rule.run_on_jest_node(jest_node, ctx), Self::VitestPreferDescribeFunctionTitle(rule) => rule.run_on_jest_node(jest_node, ctx), @@ -14831,6 +14858,7 @@ impl RuleEnum { Self::VitestNoConditionalTests(rule) => rule.should_run(ctx), Self::VitestNoImportNodeTest(rule) => rule.should_run(ctx), Self::VitestNoImportingVitestGlobals(rule) => rule.should_run(ctx), + Self::VitestPreferCalledExactlyOnceWith(rule) => rule.should_run(ctx), Self::VitestPreferCalledOnce(rule) => rule.should_run(ctx), Self::VitestPreferCalledTimes(rule) => rule.should_run(ctx), Self::VitestPreferDescribeFunctionTitle(rule) => rule.should_run(ctx), @@ -15813,6 +15841,9 @@ impl RuleEnum { Self::VitestNoImportingVitestGlobals(_) => { VitestNoImportingVitestGlobals::IS_TSGOLINT_RULE } + Self::VitestPreferCalledExactlyOnceWith(_) => { + VitestPreferCalledExactlyOnceWith::IS_TSGOLINT_RULE + } Self::VitestPreferCalledOnce(_) => VitestPreferCalledOnce::IS_TSGOLINT_RULE, Self::VitestPreferCalledTimes(_) => VitestPreferCalledTimes::IS_TSGOLINT_RULE, Self::VitestPreferDescribeFunctionTitle(_) => { @@ -16676,6 +16707,9 @@ impl RuleEnum { Self::VitestNoConditionalTests(_) => VitestNoConditionalTests::HAS_CONFIG, Self::VitestNoImportNodeTest(_) => VitestNoImportNodeTest::HAS_CONFIG, Self::VitestNoImportingVitestGlobals(_) => VitestNoImportingVitestGlobals::HAS_CONFIG, + Self::VitestPreferCalledExactlyOnceWith(_) => { + VitestPreferCalledExactlyOnceWith::HAS_CONFIG + } Self::VitestPreferCalledOnce(_) => VitestPreferCalledOnce::HAS_CONFIG, Self::VitestPreferCalledTimes(_) => VitestPreferCalledTimes::HAS_CONFIG, Self::VitestPreferDescribeFunctionTitle(_) => { @@ -17378,6 +17412,7 @@ impl RuleEnum { Self::VitestNoConditionalTests(rule) => rule.types_info(), Self::VitestNoImportNodeTest(rule) => rule.types_info(), Self::VitestNoImportingVitestGlobals(rule) => rule.types_info(), + Self::VitestPreferCalledExactlyOnceWith(rule) => rule.types_info(), Self::VitestPreferCalledOnce(rule) => rule.types_info(), Self::VitestPreferCalledTimes(rule) => rule.types_info(), Self::VitestPreferDescribeFunctionTitle(rule) => rule.types_info(), @@ -18074,6 +18109,7 @@ impl RuleEnum { Self::VitestNoConditionalTests(rule) => rule.run_info(), Self::VitestNoImportNodeTest(rule) => rule.run_info(), Self::VitestNoImportingVitestGlobals(rule) => rule.run_info(), + Self::VitestPreferCalledExactlyOnceWith(rule) => rule.run_info(), Self::VitestPreferCalledOnce(rule) => rule.run_info(), Self::VitestPreferCalledTimes(rule) => rule.run_info(), Self::VitestPreferDescribeFunctionTitle(rule) => rule.run_info(), @@ -18884,6 +18920,7 @@ pub static RULES: std::sync::LazyLock> = std::sync::LazyLock::new( RuleEnum::VitestNoConditionalTests(VitestNoConditionalTests::default()), RuleEnum::VitestNoImportNodeTest(VitestNoImportNodeTest::default()), RuleEnum::VitestNoImportingVitestGlobals(VitestNoImportingVitestGlobals::default()), + RuleEnum::VitestPreferCalledExactlyOnceWith(VitestPreferCalledExactlyOnceWith::default()), RuleEnum::VitestPreferCalledOnce(VitestPreferCalledOnce::default()), RuleEnum::VitestPreferCalledTimes(VitestPreferCalledTimes::default()), RuleEnum::VitestPreferDescribeFunctionTitle(VitestPreferDescribeFunctionTitle::default()), diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 59621d65d50ec..704fd2238f48a 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -702,6 +702,7 @@ pub(crate) mod vitest { pub mod no_conditional_tests; pub mod no_import_node_test; pub mod no_importing_vitest_globals; + pub mod prefer_called_exactly_once_with; pub mod prefer_called_once; pub mod prefer_called_times; pub mod prefer_describe_function_title; diff --git a/crates/oxc_linter/src/rules/vitest/prefer_called_exactly_once_with.rs b/crates/oxc_linter/src/rules/vitest/prefer_called_exactly_once_with.rs new file mode 100644 index 0000000000000..8755ea9be1c9a --- /dev/null +++ b/crates/oxc_linter/src/rules/vitest/prefer_called_exactly_once_with.rs @@ -0,0 +1,754 @@ +use itertools::Itertools; +use oxc_allocator::Vec as OxcVec; +use oxc_ast::{ + AstKind, + ast::{CallExpression, Expression, FunctionBody, MemberExpression, Statement}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::{CompactStr, GetSpan, Span}; +use std::collections::BTreeMap; + +use crate::{ + AstNode, + context::LintContext, + rule::Rule, + utils::{ + KnownMemberExpressionProperty, ParsedExpectFnCall, ParsedJestFnCallNew, PossibleJestNode, + parse_jest_fn_call, + }, +}; + +fn prefer_called_exactly_once_with_diagnostic( + substitute_span: Span, + remove_span: Span, +) -> OxcDiagnostic { + OxcDiagnostic::warn("Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target.") + .with_labels([ + substitute_span.label("Replace with `toHaveBeenCalledExactlyOnceWith`"), + remove_span.label("Remove this expect"), + ]) +} + +#[derive(Debug, Default, Clone)] +pub struct PreferCalledExactlyOnceWith; + +#[derive(Debug, Eq, PartialEq)] +enum ExpectPairStates { + WaitingOnce, + WaitingWith, + Paired, +} + +#[derive(Debug, Eq, PartialEq, Hash)] +enum MatcherKind { + ToHaveBeenCalledOnce, + ToHaveBeenCalledWith, + Unknown, +} + +impl MatcherKind { + pub fn from(name: &str) -> Self { + match name { + "toHaveBeenCalledOnce" => Self::ToHaveBeenCalledOnce, + "toHaveBeenCalledWith" => Self::ToHaveBeenCalledWith, + _ => Self::Unknown, + } + } + + /// Returns true if this matcher can be combined with its counterpart + /// to form `toHaveBeenCalledExactlyOnceWith`. + fn is_combinable(&self) -> bool { + matches!(self, Self::ToHaveBeenCalledOnce | Self::ToHaveBeenCalledWith) + } +} + +#[derive(Debug)] +struct TrackingExpectPair { + span_to_substitute: Span, + span_to_remove: Span, + identifier: CompactStr, + args_to_be_expected: CompactStr, + type_parameters: Option, + current_state: ExpectPairStates, +} + +impl TrackingExpectPair { + fn new_from_called_once(matcher_span: Span, identifier: CompactStr) -> Self { + Self { + span_to_substitute: matcher_span, + span_to_remove: Span::empty(0), + identifier, + args_to_be_expected: CompactStr::new(""), + type_parameters: None, + current_state: ExpectPairStates::WaitingWith, + } + } + + fn new_from_called_with( + matcher_span: Span, + identifier: CompactStr, + arguments: CompactStr, + type_parameters: Option, + ) -> Self { + Self { + span_to_substitute: matcher_span, + span_to_remove: Span::empty(0), + identifier, + args_to_be_expected: arguments, + type_parameters, + current_state: ExpectPairStates::WaitingOnce, + } + } + + fn update_tracking_with_called_once_information(&mut self, matcher_span: Span) { + self.span_to_remove = matcher_span; + self.current_state = ExpectPairStates::Paired; + } + + fn update_tracking_with_called_with_information( + &mut self, + matcher_span: Span, + identifier: CompactStr, + arguments: CompactStr, + type_parameters: Option, + ) { + self.span_to_remove = matcher_span; + self.identifier = identifier; + self.args_to_be_expected = arguments; + self.type_parameters = type_parameters; + self.current_state = ExpectPairStates::Paired; + } + + fn is_paired(&self) -> bool { + self.current_state == ExpectPairStates::Paired + } + + fn get_new_expect(&self) -> CompactStr { + let type_params = self + .type_parameters + .as_ref() + .map_or(CompactStr::new(""), |formatted| CompactStr::new(formatted.as_ref())); + + let expect = format!( + "expect({}).toHaveBeenCalledExactlyOnceWith{}({})", + self.identifier, type_params, self.args_to_be_expected + ); + CompactStr::new(expect.as_ref()) + } + + /// Returns true if this tracking pair can be completed by pairing with the given matcher. + /// This is used to detect when we have both `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` + /// on the same target, which can be combined into `toHaveBeenCalledExactlyOnceWith`. + fn can_pair_with(&self, matcher: &MatcherKind) -> bool { + if self.is_paired() { + return false; + } + + matches!( + (&self.current_state, matcher), + (ExpectPairStates::WaitingOnce, MatcherKind::ToHaveBeenCalledOnce) + | (ExpectPairStates::WaitingWith, MatcherKind::ToHaveBeenCalledWith), + ) + } +} + +declare_oxc_lint!( + /// ### What it does + /// + /// It checks when a target is expected with `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` instead of + /// `toHaveBeenCalledExactlyOnceWith`. + /// + /// ### Why is this bad? + /// + /// The user must deduct from both expects that the spy function is called once and with a specific arguments. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```js + /// test('foo', () => { + /// const mock = vi.fn() + /// mock('foo') + /// expect(mock).toHaveBeenCalledOnce() + /// expect(mock).toHaveBeenCalledWith('foo') + /// }) + /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```js + /// test('foo', () => { + /// const mock = vi.fn() + /// mock('foo') + /// expect(mock).toHaveBeenCalledExactlyOnceWith('foo') + /// }) + /// ``` + PreferCalledExactlyOnceWith, + vitest, + style, + dangerous_fix, +); + +impl Rule for PreferCalledExactlyOnceWith { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + match node.kind() { + AstKind::Program(program) => { + Self::check_block_body(&program.body, node, ctx); + } + AstKind::BlockStatement(block_statement) => { + Self::check_block_body(&block_statement.body, node, ctx); + } + _ => {} + } + } +} + +/// Mock reset methods that clear the mock call history. +const MOCK_RESET_METHODS: [&str; 3] = ["mockClear", "mockReset", "mockRestore"]; + +impl PreferCalledExactlyOnceWith { + fn check_block_body<'a>( + statements: &'a OxcVec<'a, Statement<'_>>, + node: &AstNode<'a>, + ctx: &LintContext<'a>, + ) { + let mut variables_expected: BTreeMap = BTreeMap::default(); + + for statement in statements { + let Statement::ExpressionStatement(statement_expression) = statement else { + continue; + }; + + let Expression::CallExpression(call_expr) = &statement_expression.expression else { + continue; + }; + + let Some(parsed_call_expression_statement) = + parse_call_expression_statement(call_expr, node, ctx) + else { + continue; + }; + + match parsed_call_expression_statement { + TestCallExpression::MockReset => { + let Some(Expression::Identifier(identify)) = + call_expr.callee.as_member_expression().map(MemberExpression::object) + else { + continue; + }; + + variables_expected.remove(&CompactStr::new(identify.name.as_ref())); + } + TestCallExpression::TestBlock(statements) => { + Self::check_block_body(statements, node, ctx); + } + TestCallExpression::ExpectFnCall(expect_call) => { + let Some((variable_expected_name, matcher)) = + get_identifier_and_matcher_to_be_expected(&expect_call, ctx) + else { + continue; + }; + + let duplicate_entry = variables_expected + .get(&variable_expected_name) + .is_some_and(|expects| !expects.can_pair_with(&matcher)); + + if duplicate_entry { + variables_expected.remove(&variable_expected_name); + continue; + } + + match matcher { + MatcherKind::ToHaveBeenCalledOnce => { + if let Some(expect) = + variables_expected.get_mut(&variable_expected_name) + { + let statement_span = GetSpan::span(statement); + + expect.update_tracking_with_called_once_information( + get_source_code_line_span(statement_span, ctx), + ); + } else { + variables_expected.insert( + variable_expected_name.clone(), + TrackingExpectPair::new_from_called_once( + call_expr.span, + variable_expected_name.clone(), + ), + ); + } + } + + MatcherKind::ToHaveBeenCalledWith => { + let to_be_arguments = expect_call.matcher_arguments.map_or( + CompactStr::new(""), + |arguments| { + let arguments_to_be_expected = arguments + .iter() + .map(|arg| ctx.source_range(GetSpan::span(arg))) + .join(", "); + CompactStr::new(arguments_to_be_expected.as_ref()) + }, + ); + + let type_notation = + call_expr.type_arguments.as_ref().map(|type_notation| { + CompactStr::new(ctx.source_range(type_notation.span)) + }); + + if let Some(expect) = + variables_expected.get_mut(&variable_expected_name) + { + let statement_span = GetSpan::span(statement); + + expect.update_tracking_with_called_with_information( + get_source_code_line_span(statement_span, ctx), + variable_expected_name, + to_be_arguments, + type_notation, + ); + } else { + variables_expected.insert( + variable_expected_name.clone(), + TrackingExpectPair::new_from_called_with( + call_expr.span, + variable_expected_name.clone(), + to_be_arguments, + type_notation, + ), + ); + } + } + MatcherKind::Unknown => {} + } + } + } + } + + for expects in variables_expected.values() { + if !expects.is_paired() { + continue; + } + + ctx.diagnostic_with_dangerous_fix( + prefer_called_exactly_once_with_diagnostic( + expects.span_to_substitute, + expects.span_to_remove, + ), + |fixer| { + let fixer = fixer.for_multifix(); + let substitute = expects.get_new_expect(); + fixer + .new_fix_with_capacity(2) + .extend(fixer.replace(expects.span_to_substitute, substitute)) + .extend(fixer.delete_range(expects.span_to_remove)) + .with_message("Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect") + }, + ); + } + } +} + +enum TestCallExpression<'a> { + TestBlock(&'a oxc_allocator::Vec<'a, Statement<'a>>), + MockReset, + ExpectFnCall(ParsedExpectFnCall<'a>), +} + +fn parse_call_expression_statement<'a>( + call_expr: &'a CallExpression<'a>, + node: &AstNode<'a>, + ctx: &LintContext<'a>, +) -> Option> { + if is_mock_reset_call_expression(call_expr) { + return Some(TestCallExpression::MockReset); + } + + match parse_jest_fn_call(call_expr, &PossibleJestNode { node, original: None }, ctx) { + Some(ParsedJestFnCallNew::GeneralJest(_)) => { + let callback = get_test_callback(call_expr)?; + + let body = get_callback_body(callback)?; + + Some(TestCallExpression::TestBlock(&body.statements)) + } + Some(ParsedJestFnCallNew::Expect(expect_vitest_call)) => { + Some(TestCallExpression::ExpectFnCall(expect_vitest_call)) + } + _ => None, + } +} + +fn get_identifier_and_matcher_to_be_expected<'a>( + expect_call: &ParsedExpectFnCall<'a>, + ctx: &LintContext<'a>, +) -> Option<(CompactStr, MatcherKind)> { + if expect_call.members.iter().any(is_not_modifier_member) { + return None; + } + + let matcher_index = expect_call.matcher_index?; + + let matcher = expect_call + .members + .get(matcher_index) + .and_then(KnownMemberExpressionProperty::name) + .map(|matcher_name| MatcherKind::from(matcher_name.as_ref()))?; + + if !matcher.is_combinable() { + return None; + } + + let arguments = expect_call.expect_arguments?; + + let identifier_name = + arguments.iter().map(|argument| ctx.source_range(GetSpan::span(argument))).join(", "); + + Some((CompactStr::new(identifier_name.as_ref()), matcher)) +} + +fn is_not_modifier_member(member: &KnownMemberExpressionProperty<'_>) -> bool { + member.is_name_equal("not") +} + +fn is_mock_reset_call_expression(call_expr: &CallExpression<'_>) -> bool { + call_expr.callee_name().is_some_and(|callee| MOCK_RESET_METHODS.contains(&callee)) +} + +/** + * Eslint fix is based on deleting the complete line of code. Span currently ignores the + * whitespaces, so the test were failing due the trailing whitespaces not being removed. + * Currently the method is asumming after the end of the statement, the next span position is the following line. + * Even doing it safely the end check, this fix will remain dangerous as it removes code. + */ +fn get_source_code_line_span(statement_span: Span, ctx: &LintContext<'_>) -> Span { + let mut column_0_span_index = statement_span.start; + + // Guard against underflow when statement is at the beginning of the file + while column_0_span_index > 0 + && !ctx + .source_range(Span::new(column_0_span_index - 1, statement_span.end + 1)) + .starts_with('\n') + { + column_0_span_index -= 1; + } + + Span::new(column_0_span_index, statement_span.end + 1) +} + +fn get_test_callback<'a>(call_expr: &'a CallExpression<'a>) -> Option<&'a Expression<'a>> { + call_expr.arguments.iter().rev().filter_map(|arg| arg.as_expression()).find(|expr| { + matches!(expr, Expression::FunctionExpression(_) | Expression::ArrowFunctionExpression(_)) + }) +} + +fn get_callback_body<'a>(callback: &'a Expression<'a>) -> Option<&'a FunctionBody<'a>> { + match callback { + Expression::FunctionExpression(func) => func.body.as_ref().map(AsRef::as_ref), + Expression::ArrowFunctionExpression(func) => Some(&func.body), + _ => None, + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + "expect(fn).toHaveBeenCalledExactlyOnceWith();", + "expect(x).toHaveBeenCalledExactlyOnceWith(args);", + "expect(x).toHaveBeenCalledOnce();", + "expect(x).toHaveBeenCalledWith('hoge');", + " + expect(x).toHaveBeenCalledOnce(); + expect(y).toHaveBeenCalledWith('hoge'); + ", + " + expect(x).toHaveBeenCalledWith('hoge'); + expect(x).toHaveBeenCalledWith('foo'); + ", + " + expect(x).toHaveBeenCalledOnce(); + expect(x).not.toHaveBeenCalledWith('hoge'); + ", + " + expect(x).not.toHaveBeenCalledOnce(); + expect(x).toHaveBeenCalledWith('hoge'); + ", + " + expect(x).not.toHaveBeenCalledOnce(); + expect(x).not.toHaveBeenCalledWith('hoge'); + ", + " + expect(x).toHaveBeenCalledOnce(); + x.mockRestore(); + expect(x).toHaveBeenCalledWith('hoge'); + ", + " + expect(x).toHaveBeenCalledOnce(); + x.mockReset(); + expect(x).toHaveBeenCalledWith('hoge'); + ", + " + expect(x).toHaveBeenCalledOnce(); + x.mockClear(); + expect(x).toHaveBeenCalledWith('hoge'); + ", + " + expect(x).toHaveBeenCalledOnce(); + y.mockClear(); + expect(y).toHaveBeenCalledWith('hoge'); + ", + "expect(fn).toHaveBeenCalledExactlyOnceWith<[{ id: number }]>()", + "expect(fn).toHaveBeenCalledExactlyOnceWith<[{ id: number }]>({id: 1})", + ]; + + let fail = vec![ + " + expect(x).toHaveBeenCalledOnce(); + expect(x).toHaveBeenCalledWith('hoge'); + ", + " + expect(x).toHaveBeenCalledWith('hoge'); + expect(x).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledWith('hoge', 123); + expect(x).toHaveBeenCalledOnce(); + ", + " + test('example',() => { + expect(x).toHaveBeenCalledWith('hoge', 123); + expect(x).toHaveBeenCalledOnce(); + }); + ", + " + expect(x).toHaveBeenCalledWith('hoge', 123); + expect(x).toHaveBeenCalledOnce(); + expect(y).toHaveBeenCalledWith('foo', 456); + expect(y).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledWith('hoge', 123); + const hoge = 'foo'; + expect(x).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledOnce(); + y.mockClear(); + expect(x).toHaveBeenCalledWith('hoge'); + ", + " + expect(x).toHaveBeenCalledOnce(); + expect(x).toHaveBeenCalledWith<[string]>('hoge'); + ", + " + expect(x).toHaveBeenCalledWith<[string]>('hoge'); + expect(x).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledOnce<[number]>(); + expect(x).toHaveBeenCalledWith<[string]>('hoge'); + ", + " + expect(x).toHaveBeenCalledOnce(); + expect(x).toHaveBeenCalledWith< + [ + { + id: number + } + ] + >('hoge'); + ", + " + expect(x).toHaveBeenCalledWith<[string, number]>('hoge', 123); + expect(x).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledWith<[string, number]>('hoge', 123); + expect(x).toHaveBeenCalledOnce(); + expect(y).toHaveBeenCalledWith('foo', 456); + expect(y).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledOnce(); + y.mockClear(); + expect(x).toHaveBeenCalledWith<[string]>('hoge'); + ", + ]; + + let fix = vec![ + ( + " + expect(x).toHaveBeenCalledOnce(); + expect(x).toHaveBeenCalledWith('hoge'); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith('hoge'); + ", + None, + ), + ( + " + expect(x).toHaveBeenCalledWith('hoge'); + expect(x).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith('hoge'); + ", + None, + ), + ( + " + expect(x).toHaveBeenCalledWith('hoge', 123); + expect(x).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith('hoge', 123); + ", + None, + ), + ( + " + test('example',() => { + expect(x).toHaveBeenCalledWith('hoge', 123); + expect(x).toHaveBeenCalledOnce(); + }); + ", + " + test('example',() => { + expect(x).toHaveBeenCalledExactlyOnceWith('hoge', 123); + }); + ", + None, + ), + ( + " + expect(x).toHaveBeenCalledWith('hoge', 123); + expect(x).toHaveBeenCalledOnce(); + expect(y).toHaveBeenCalledWith('foo', 456); + expect(y).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith('hoge', 123); + expect(y).toHaveBeenCalledExactlyOnceWith('foo', 456); + ", + None, + ), + ( + " + expect(x).toHaveBeenCalledWith('hoge', 123); + const hoge = 'foo'; + expect(x).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith('hoge', 123); + const hoge = 'foo'; + ", + None, + ), + ( + " + expect(x).toHaveBeenCalledOnce(); + y.mockClear(); + expect(x).toHaveBeenCalledWith('hoge'); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith('hoge'); + y.mockClear(); + ", + None, + ), + ( + " + expect(x).toHaveBeenCalledOnce(); + expect(x).toHaveBeenCalledWith<[string]>('hoge'); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith<[string]>('hoge'); + ", + None, + ), + ( + " + expect(x).toHaveBeenCalledWith<[string]>('hoge'); + expect(x).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith<[string]>('hoge'); + ", + None, + ), + ( + " + expect(x).toHaveBeenCalledOnce<[number]>(); + expect(x).toHaveBeenCalledWith<[string]>('hoge'); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith<[string]>('hoge'); + ", + None, + ), + ( + " + expect(x).toHaveBeenCalledOnce(); + expect(x).toHaveBeenCalledWith< + [ + { + id: number + } + ] + >('hoge'); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith< + [ + { + id: number + } + ] + >('hoge'); + ", + None, + ), + ( + " + expect(x).toHaveBeenCalledWith<[string, number]>('hoge', 123); + expect(x).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith<[string, number]>('hoge', 123); + ", + None, + ), + ( + " + expect(x).toHaveBeenCalledWith<[string, number]>('hoge', 123); + expect(x).toHaveBeenCalledOnce(); + expect(y).toHaveBeenCalledWith('foo', 456); + expect(y).toHaveBeenCalledOnce(); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith<[string, number]>('hoge', 123); + expect(y).toHaveBeenCalledExactlyOnceWith('foo', 456); + ", + None, + ), + ( + " + expect(x).toHaveBeenCalledOnce(); + y.mockClear(); + expect(x).toHaveBeenCalledWith<[string]>('hoge'); + ", + " + expect(x).toHaveBeenCalledExactlyOnceWith<[string]>('hoge'); + y.mockClear(); + ", + None, + ), + ]; + Tester::new(PreferCalledExactlyOnceWith::NAME, PreferCalledExactlyOnceWith::PLUGIN, pass, fail) + .with_vitest_plugin(true) + .expect_fix(fix) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/vitest_prefer_called_exactly_once_with.snap b/crates/oxc_linter/src/snapshots/vitest_prefer_called_exactly_once_with.snap new file mode 100644 index 0000000000000..f9f11ca2d432d --- /dev/null +++ b/crates/oxc_linter/src/snapshots/vitest_prefer_called_exactly_once_with.snap @@ -0,0 +1,221 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledOnce(); + · ────────────────┬─────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ expect(x).toHaveBeenCalledWith('hoge'); + · ─────────────────────────────┬──────────────────────────── + · ╰── Remove this expect + 4 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledWith('hoge'); + · ───────────────────┬────────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ expect(x).toHaveBeenCalledOnce(); + · ──────────────────────────┬───────────────────────── + · ╰── Remove this expect + 4 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledWith('hoge', 123); + · ─────────────────────┬───────────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ expect(x).toHaveBeenCalledOnce(); + · ──────────────────────────┬───────────────────────── + · ╰── Remove this expect + 4 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:3:12] + 2 │ test('example',() => { + 3 │ expect(x).toHaveBeenCalledWith('hoge', 123); + · ─────────────────────┬───────────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 4 │ expect(x).toHaveBeenCalledOnce(); + · ───────────────────────────┬────────────────────────── + · ╰── Remove this expect + 5 │ }); + 6 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledWith('hoge', 123); + · ─────────────────────┬───────────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ expect(x).toHaveBeenCalledOnce(); + · ──────────────────────────┬───────────────────────── + · ╰── Remove this expect + 4 │ expect(y).toHaveBeenCalledWith('foo', 456); + 5 │ expect(y).toHaveBeenCalledOnce(); + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:4:10] + 3 │ expect(x).toHaveBeenCalledOnce(); + 4 │ expect(y).toHaveBeenCalledWith('foo', 456); + · ─────────────────────┬──────────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 5 │ expect(y).toHaveBeenCalledOnce(); + · ──────────────────────────┬───────────────────────── + · ╰── Remove this expect + 6 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledWith('hoge', 123); + · ─────────────────────┬───────────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ const hoge = 'foo'; + 4 │ expect(x).toHaveBeenCalledOnce(); + · ──────────────────────────┬───────────────────────── + · ╰── Remove this expect + 5 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledOnce(); + · ────────────────┬─────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ y.mockClear(); + 4 │ expect(x).toHaveBeenCalledWith('hoge'); + · ─────────────────────────────┬──────────────────────────── + · ╰── Remove this expect + 5 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledOnce(); + · ────────────────┬─────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ expect(x).toHaveBeenCalledWith<[string]>('hoge'); + · ──────────────────────────────────┬───────────────────────────────── + · ╰── Remove this expect + 4 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledWith<[string]>('hoge'); + · ────────────────────────┬─────────────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ expect(x).toHaveBeenCalledOnce(); + · ──────────────────────────┬───────────────────────── + · ╰── Remove this expect + 4 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledOnce<[number]>(); + · ─────────────────────┬──────────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ expect(x).toHaveBeenCalledWith<[string]>('hoge'); + · ──────────────────────────────────┬───────────────────────────────── + · ╰── Remove this expect + 4 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledOnce(); + · ────────────────┬─────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ ╭─▶ expect(x).toHaveBeenCalledWith< + 4 │ │ [ + 5 │ │ { + 6 │ │ id: number + 7 │ │ } + 8 │ │ ] + 9 │ ├─▶ >('hoge'); + · ╰──── Remove this expect + 10 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledWith<[string, number]>('hoge', 123); + · ──────────────────────────────┬────────────────────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ expect(x).toHaveBeenCalledOnce(); + · ──────────────────────────┬───────────────────────── + · ╰── Remove this expect + 4 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledWith<[string, number]>('hoge', 123); + · ──────────────────────────────┬────────────────────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ expect(x).toHaveBeenCalledOnce(); + · ──────────────────────────┬───────────────────────── + · ╰── Remove this expect + 4 │ expect(y).toHaveBeenCalledWith('foo', 456); + 5 │ expect(y).toHaveBeenCalledOnce(); + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:4:10] + 3 │ expect(x).toHaveBeenCalledOnce(); + 4 │ expect(y).toHaveBeenCalledWith('foo', 456); + · ─────────────────────┬──────────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 5 │ expect(y).toHaveBeenCalledOnce(); + · ──────────────────────────┬───────────────────────── + · ╰── Remove this expect + 6 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect + + ⚠ eslint-plugin-vitest(prefer-called-exactly-once-with): Prefer `toHaveBeenCalledExactlyOnceWith` over `toHaveBeenCalledOnce` and `toHaveBeenCalledWith` on the same target. + ╭─[prefer_called_exactly_once_with.tsx:2:10] + 1 │ + 2 │ expect(x).toHaveBeenCalledOnce(); + · ────────────────┬─────────────── + · ╰── Replace with `toHaveBeenCalledExactlyOnceWith` + 3 │ y.mockClear(); + 4 │ expect(x).toHaveBeenCalledWith<[string]>('hoge'); + · ──────────────────────────────────┬───────────────────────────────── + · ╰── Remove this expect + 5 │ + ╰──── + help: Replace with `toHaveBeenCalledExactlyOnceWith` and remove redundant expect