diff --git a/crates/oxc_linter/src/generated/rule_runner_impls.rs b/crates/oxc_linter/src/generated/rule_runner_impls.rs index 10ee542029c21..efb2f60dba71f 100644 --- a/crates/oxc_linter/src/generated/rule_runner_impls.rs +++ b/crates/oxc_linter/src/generated/rule_runner_impls.rs @@ -4035,6 +4035,11 @@ impl RuleRunner const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnJestNode; } +impl RuleRunner for crate::rules::vitest::prefer_called_once::PreferCalledOnce { + const NODE_TYPES: Option<&AstTypesBitset> = None; + const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnJestNode; +} + impl RuleRunner for crate::rules::vitest::prefer_called_times::PreferCalledTimes { const NODE_TYPES: Option<&AstTypesBitset> = None; const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnJestNode; diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index cef1885e1e9e8..bd59984cd690a 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -672,6 +672,7 @@ pub(crate) mod vitest { pub mod no_conditional_tests; pub mod no_import_node_test; pub mod no_unneeded_async_expect_function; + pub mod prefer_called_once; pub mod prefer_called_times; pub mod prefer_describe_function_title; pub mod prefer_to_be_falsy; @@ -1340,6 +1341,7 @@ oxc_macros::declare_all_lint_rules! { vitest::no_conditional_tests, vitest::no_import_node_test, vitest::no_unneeded_async_expect_function, + vitest::prefer_called_once, vitest::prefer_called_times, vitest::prefer_describe_function_title, vitest::prefer_to_be_falsy, diff --git a/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs b/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs new file mode 100644 index 0000000000000..e5d0d4aff013c --- /dev/null +++ b/crates/oxc_linter/src/rules/vitest/prefer_called_once.rs @@ -0,0 +1,173 @@ +use oxc_ast::{AstKind, ast::Argument}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{ + context::LintContext, + rule::Rule, + utils::{PossibleJestNode, parse_expect_jest_fn_call}, +}; + +fn prefer_called_once_diagnostic(span: Span, new_matcher_name: &str) -> OxcDiagnostic { + OxcDiagnostic::warn( + "The use of `toBeCalledTimes(1)` and `toHaveBeenCalledTimes(1)` is discouraged.", + ) + .with_help(format!("Prefer `{new_matcher_name}()`.")) + .with_label(span) +} + +#[derive(Debug, Default, Clone)] +pub struct PreferCalledOnce; + +declare_oxc_lint!( + /// ### What it does + /// + /// Substitute `toBeCalledTimes(1)` and `toHaveBeenCalledTimes(1)` with + /// `toBeCalledOnce()` and `toHaveBeenCalledOnce()` respectively. + /// + /// ### Why is this bad? + /// + /// The *Times method required to read the arguments to know how many times + /// is expected a spy to be called. Most of the times you expecting a method is called + /// once. + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```js + /// test('foo', () => { + /// const mock = vi.fn() + /// mock('foo') + /// expect(mock).toBeCalledTimes(1) + /// expect(mock).toHaveBeenCalledTimes(1) + /// }) + /// ``` + /// + /// Examples of **correct** code for this rule: + /// ```js + /// test('foo', () => { + /// const mock = vi.fn() + /// mock('foo') + /// expect(mock).toBeCalledOnce() + /// expect(mock).toHaveBeenCalledOnce() + /// }) + /// ``` + PreferCalledOnce, + vitest, + style, + fix, +); + +impl Rule for PreferCalledOnce { + fn run_on_jest_node<'a, 'c>( + &self, + jest_node: &crate::utils::PossibleJestNode<'a, 'c>, + ctx: &'c LintContext<'a>, + ) { + Self::run(jest_node, ctx); + } +} + +impl PreferCalledOnce { + fn run<'a>(possible_jest_node: &PossibleJestNode<'a, '_>, ctx: &LintContext<'a>) { + let node = possible_jest_node.node; + + let AstKind::CallExpression(call_expr) = node.kind() else { + return; + }; + + let Some(parsed_expect) = parse_expect_jest_fn_call(call_expr, possible_jest_node, ctx) + else { + return; + }; + + if parsed_expect.matcher_arguments.is_some_and(|arguments| arguments.len() != 1) { + return; + } + + let Some(Argument::NumericLiteral(called_times_value)) = + parsed_expect.matcher_arguments.and_then(|arguments| arguments.first()) + else { + return; + }; + + let Some(matcher_to_be_fixed) = parsed_expect.members.iter().find(|member| { + member.is_name_equal("toBeCalledTimes") || member.is_name_equal("toHaveBeenCalledTimes") + }) else { + return; + }; + + if called_times_value.raw.is_some_and(|value| value.as_ref() == "1") { + let new_matcher_name = { + let span_matcher_without_suffix = + Span::new(matcher_to_be_fixed.span.start, matcher_to_be_fixed.span.end - 5); + + format!("{}Once", ctx.source_range(span_matcher_without_suffix)) + }; + + let matcher_and_args_span = + Span::new(matcher_to_be_fixed.span.start, call_expr.span.end); + + ctx.diagnostic_with_fix( + prefer_called_once_diagnostic(matcher_and_args_span, new_matcher_name.as_ref()), + |fixer| { + let multi_fix = fixer.for_multifix(); + let mut fixes = multi_fix.new_fix_with_capacity(2); + + fixes.push(fixer.replace(matcher_to_be_fixed.span, new_matcher_name)); + fixes.push(fixer.delete(&called_times_value.span)); + + fixes.with_message("Replace API with preferOnce instead of Times") + }, + ); + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + "expect(fn).toBeCalledOnce();", + "expect(fn).toHaveBeenCalledOnce();", + "expect(fn).toBeCalledTimes(2);", + "expect(fn).toHaveBeenCalledTimes(2);", + "expect(fn).toBeCalledTimes(expect.anything());", + "expect(fn).toHaveBeenCalledTimes(expect.anything());", + "expect(fn).not.toBeCalledOnce();", + "expect(fn).rejects.not.toBeCalledOnce();", + "expect(fn).not.toHaveBeenCalledOnce();", + "expect(fn).resolves.not.toHaveBeenCalledOnce();", + "expect(fn).toBeCalledTimes(0);", + "expect(fn).toHaveBeenCalledTimes(0);", + "expect(fn);", + ]; + + let fail = vec![ + "expect(fn).toBeCalledTimes(1);", + "expect(fn).toHaveBeenCalledTimes(1);", + "expect(fn).not.toBeCalledTimes(1);", + "expect(fn).not.toHaveBeenCalledTimes(1);", + "expect(fn).resolves.toBeCalledTimes(1);", + "expect(fn).resolves.toHaveBeenCalledTimes(1);", + "expect(fn).resolves.toHaveBeenCalledTimes(/*comment*/1);", + ]; + + let fix = vec![ + ("expect(fn).toBeCalledTimes(1);", "expect(fn).toBeCalledOnce();"), + ("expect(fn).toHaveBeenCalledTimes(1);", "expect(fn).toHaveBeenCalledOnce();"), + ("expect(fn).not.toBeCalledTimes(1);", "expect(fn).not.toBeCalledOnce();"), + ("expect(fn).not.toHaveBeenCalledTimes(1);", "expect(fn).not.toHaveBeenCalledOnce();"), + ("expect(fn).resolves.toBeCalledTimes(1);", "expect(fn).resolves.toBeCalledOnce();"), + ( + "expect(fn).resolves.toHaveBeenCalledTimes(1);", + "expect(fn).resolves.toHaveBeenCalledOnce();", + ), + ]; + Tester::new(PreferCalledOnce::NAME, PreferCalledOnce::PLUGIN, pass, fail) + .expect_fix(fix) + .with_vitest_plugin(true) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/vitest_prefer_called_once.snap b/crates/oxc_linter/src/snapshots/vitest_prefer_called_once.snap new file mode 100644 index 0000000000000..fb6015d5bc45d --- /dev/null +++ b/crates/oxc_linter/src/snapshots/vitest_prefer_called_once.snap @@ -0,0 +1,51 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint-plugin-vitest(prefer-called-once): The use of `toBeCalledTimes(1)` and `toHaveBeenCalledTimes(1)` is discouraged. + ╭─[prefer_called_once.tsx:1:12] + 1 │ expect(fn).toBeCalledTimes(1); + · ────────────────── + ╰──── + help: Prefer `toBeCalledOnce()`. + + ⚠ eslint-plugin-vitest(prefer-called-once): The use of `toBeCalledTimes(1)` and `toHaveBeenCalledTimes(1)` is discouraged. + ╭─[prefer_called_once.tsx:1:12] + 1 │ expect(fn).toHaveBeenCalledTimes(1); + · ──────────────────────── + ╰──── + help: Prefer `toHaveBeenCalledOnce()`. + + ⚠ eslint-plugin-vitest(prefer-called-once): The use of `toBeCalledTimes(1)` and `toHaveBeenCalledTimes(1)` is discouraged. + ╭─[prefer_called_once.tsx:1:16] + 1 │ expect(fn).not.toBeCalledTimes(1); + · ────────────────── + ╰──── + help: Prefer `toBeCalledOnce()`. + + ⚠ eslint-plugin-vitest(prefer-called-once): The use of `toBeCalledTimes(1)` and `toHaveBeenCalledTimes(1)` is discouraged. + ╭─[prefer_called_once.tsx:1:16] + 1 │ expect(fn).not.toHaveBeenCalledTimes(1); + · ──────────────────────── + ╰──── + help: Prefer `toHaveBeenCalledOnce()`. + + ⚠ eslint-plugin-vitest(prefer-called-once): The use of `toBeCalledTimes(1)` and `toHaveBeenCalledTimes(1)` is discouraged. + ╭─[prefer_called_once.tsx:1:21] + 1 │ expect(fn).resolves.toBeCalledTimes(1); + · ────────────────── + ╰──── + help: Prefer `toBeCalledOnce()`. + + ⚠ eslint-plugin-vitest(prefer-called-once): The use of `toBeCalledTimes(1)` and `toHaveBeenCalledTimes(1)` is discouraged. + ╭─[prefer_called_once.tsx:1:21] + 1 │ expect(fn).resolves.toHaveBeenCalledTimes(1); + · ──────────────────────── + ╰──── + help: Prefer `toHaveBeenCalledOnce()`. + + ⚠ eslint-plugin-vitest(prefer-called-once): The use of `toBeCalledTimes(1)` and `toHaveBeenCalledTimes(1)` is discouraged. + ╭─[prefer_called_once.tsx:1:21] + 1 │ expect(fn).resolves.toHaveBeenCalledTimes(/*comment*/1); + · ─────────────────────────────────── + ╰──── + help: Prefer `toHaveBeenCalledOnce()`.