Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/oxc_linter/src/generated/rule_runner_impls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions crates/oxc_linter/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
173 changes: 173 additions & 0 deletions crates/oxc_linter/src/rules/vitest/prefer_called_once.rs
Original file line number Diff line number Diff line change
@@ -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();
}
51 changes: 51 additions & 0 deletions crates/oxc_linter/src/snapshots/vitest_prefer_called_once.snap
Original file line number Diff line number Diff line change
@@ -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()`.
Loading