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 @@ -3950,6 +3950,11 @@ impl RuleRunner for crate::rules::vitest::no_import_node_test::NoImportNodeTest
const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnce;
}

impl RuleRunner for crate::rules::vitest::prefer_called_times::PreferCalledTimes {
const NODE_TYPES: Option<&AstTypesBitset> = None;
const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnJestNode;
}

impl RuleRunner for crate::rules::vitest::prefer_to_be_falsy::PreferToBeFalsy {
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 @@ -660,6 +660,7 @@ pub(crate) mod promise {
pub(crate) mod vitest {
pub mod no_conditional_tests;
pub mod no_import_node_test;
pub mod prefer_called_times;
pub mod prefer_to_be_falsy;
pub mod prefer_to_be_object;
pub mod prefer_to_be_truthy;
Expand Down Expand Up @@ -1308,6 +1309,7 @@ oxc_macros::declare_all_lint_rules! {
unicorn::throw_new_error,
vitest::no_conditional_tests,
vitest::no_import_node_test,
vitest::prefer_called_times,
vitest::prefer_to_be_falsy,
vitest::prefer_to_be_object,
vitest::prefer_to_be_truthy,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,12 @@ impl PreferToHaveBeenCalledTimes {
return;
}

let matcher_argument = parsed_expect_call.args.first();
let matcher_argument = parsed_expect_call.matcher_arguments.and_then(|args| args.first());
if matcher_argument.is_none() {
return;
}

let expect_argument = parsed_expect_call.head.parent.and_then(|parent| {
if let Expression::CallExpression(parent) = parent {
let expect_argument = parent.arguments.first();
return expect_argument;
}
None
});
let expect_argument = parsed_expect_call.expect_arguments.and_then(|args| args.first());

let expect_argument_mem_expr =
expect_argument.and_then(|arg| arg.as_expression()).and_then(|arg| match arg {
Expand Down Expand Up @@ -148,20 +142,21 @@ impl PreferToHaveBeenCalledTimes {
return fixer.noop();
};

let method_text = Self::build_expect_argument(expect_argument_mem_expr, fixer);
let param_text = Self::build_expect_argument(expect_argument_mem_expr, fixer);

let modifier_text = parsed_expect_call.modifiers().iter().fold(
String::new(),
|mut acc, modifier| {
use std::fmt::Write;
write!(&mut acc, ".{}", fixer.source_range(modifier.span)).unwrap();
acc
},
);

let method_text = "toHaveBeenCalledTimes";

let code = format!(
"expect({}){}.toHaveBeenCalledTimes({})",
method_text,
parsed_expect_call.modifiers().iter().fold(
String::new(),
|mut acc, modifier| {
use std::fmt::Write;
write!(&mut acc, ".{}", fixer.source_range(modifier.span)).unwrap();
acc
}
),
matcher_arg_text
"expect({param_text}){modifier_text}.{method_text}({matcher_arg_text})"
);

fixer.replace(call_expr.span, code)
Expand Down
182 changes: 182 additions & 0 deletions crates/oxc_linter/src/rules/vitest/prefer_called_times.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};

use crate::{
context::LintContext,
fixer::RuleFixer,
rule::Rule,
utils::{ParsedExpectFnCall, PossibleJestNode, parse_expect_jest_fn_call},
};
use oxc_ast::{
AstKind,
ast::{Argument, CallExpression},
};

fn prefer_called_times_diagnostic(span: Span) -> OxcDiagnostic {
OxcDiagnostic::warn("Use `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` instead of `toBeCalledOnce()` or `toHaveBeenCalledOnce()`")
.with_help("Replace with `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` for clarity and consistency")
.with_label(span)
}

#[derive(Debug, Default, Clone)]
pub struct PreferCalledTimes;

// See <https://github.com/vitest-dev/eslint-plugin-vitest/blob/main/docs/rules/prefer-called-times.md> for rule details.
declare_oxc_lint!(
/// ### What it does
///
/// This rule aims to enforce the use of `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` over `toBeCalledOnce()` or `toHaveBeenCalledOnce()`.
///
/// ### Why is this bad?
///
/// This rule aims to enforce the use of `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` over `toBeCalledOnce()` or `toHaveBeenCalledOnce()`.
///
/// ### Examples
///
/// Examples of **incorrect** code for this rule:
/// ```js
/// test('foo', () => {
/// const mock = vi.fn()
/// mock('foo')
/// expect(mock).toBeCalledOnce()
/// expect(mock).toHaveBeenCalledOnce()
/// })
/// ```
///
/// Examples of **correct** code for this rule:
/// ```js
/// test('foo', () => {
/// const mock = vi.fn()
/// mock('foo')
/// expect(mock).toBeCalledTimes(1)
/// expect(mock).toHaveBeenCalledTimes(1)
/// })
/// ```
PreferCalledTimes,
vitest,
style,
fix,
);

impl Rule for PreferCalledTimes {
fn run_on_jest_node<'a, 'c>(
&self,
jest_node: &PossibleJestNode<'a, 'c>,
ctx: &'c LintContext<'a>,
) {
let node = jest_node.node;

let AstKind::CallExpression(call_expr) = node.kind() else {
return;
};

let Some(parsed_expect_call) = parse_expect_jest_fn_call(call_expr, jest_node, ctx) else {
return;
};

Self::check_and_fix(&parsed_expect_call, call_expr, ctx);
}
}

impl PreferCalledTimes {
fn check_and_fix<'a>(
parsed_expect_call: &ParsedExpectFnCall<'a>,
call_expr: &CallExpression<'a>,
ctx: &LintContext<'a>,
) {
let Some(matcher) = parsed_expect_call.matcher() else {
return;
};

let is_wanted_matcher = matcher.is_name_equal("toBeCalledOnce")
|| matcher.is_name_equal("toHaveBeenCalledOnce");
if !is_wanted_matcher {
return;
}

let expect_argument = parsed_expect_call.expect_arguments.and_then(|args| args.first());

ctx.diagnostic_with_fix(prefer_called_times_diagnostic(call_expr.span), |fixer| {
let param_text = Self::build_expect_argument(expect_argument, fixer);

let modifier_text =
parsed_expect_call.modifiers().iter().fold(String::new(), |mut acc, modifier| {
use std::fmt::Write;
write!(&mut acc, ".{}", fixer.source_range(modifier.span)).unwrap();
acc
});

let method_text = if matcher.is_name_equal("toBeCalledOnce") {
"toBeCalledTimes"
} else {
"toHaveBeenCalledTimes"
};

let code = format!("expect({param_text}){modifier_text}.{method_text}(1)");

fixer.replace(call_expr.span, code)
});
}

fn build_expect_argument<'a>(
expect_argument: Option<&Argument<'_>>,
fixer: RuleFixer<'_, 'a>,
) -> &'a str {
if let Some(arg) = expect_argument {
return fixer.source_range(arg.span());
}
""
}
}

#[test]
fn test() {
use crate::tester::Tester;

let pass = vec![
"expect(fn).toBeCalledTimes(1);",
"expect(fn).toHaveBeenCalledTimes(1);",
"expect(fn).toBeCalledTimes(2);",
"expect(fn).toHaveBeenCalledTimes(2);",
"expect(fn).toBeCalledTimes(expect.anything());",
"expect(fn).toHaveBeenCalledTimes(expect.anything());",
"expect(fn).not.toBeCalledTimes(2);",
"expect(fn).rejects.not.toBeCalledTimes(1);",
"expect(fn).not.toHaveBeenCalledTimes(1);",
"expect(fn).resolves.not.toHaveBeenCalledTimes(1);",
"expect(fn).toBeCalledTimes(0);",
"expect(fn).toHaveBeenCalledTimes(0);",
"expect(fn);",
];

let fail = vec![
"expect(fn).toBeCalledOnce();",
"expect(fn).toHaveBeenCalledOnce();",
"expect(fn).not.toBeCalledOnce();",
"expect(fn).not.toHaveBeenCalledOnce();",
"expect(fn).resolves.toBeCalledOnce();",
"expect(fn).resolves.toHaveBeenCalledOnce();",
];

let fix = vec![
("expect(fn).toBeCalledOnce();", "expect(fn).toBeCalledTimes(1);", None),
("expect(fn).toHaveBeenCalledOnce();", "expect(fn).toHaveBeenCalledTimes(1);", None),
("expect(fn).not.toBeCalledOnce();", "expect(fn).not.toBeCalledTimes(1);", None),
(
"expect(fn).not.toHaveBeenCalledOnce();",
"expect(fn).not.toHaveBeenCalledTimes(1);",
None,
),
("expect(fn).resolves.toBeCalledOnce();", "expect(fn).resolves.toBeCalledTimes(1);", None),
(
"expect(fn).resolves.toHaveBeenCalledOnce();",
"expect(fn).resolves.toHaveBeenCalledTimes(1);",
None,
),
];
Tester::new(PreferCalledTimes::NAME, PreferCalledTimes::PLUGIN, pass, fail)
.with_vitest_plugin(true)
.expect_fix(fix)
.test_and_snapshot();
}
45 changes: 45 additions & 0 deletions crates/oxc_linter/src/snapshots/vitest_prefer_called_times.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
source: crates/oxc_linter/src/tester.rs
assertion_line: 427
---
⚠ eslint-plugin-vitest(prefer-called-times): Use `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` instead of `toBeCalledOnce()` or `toHaveBeenCalledOnce()`
╭─[prefer_called_times.tsx:1:1]
1 │ expect(fn).toBeCalledOnce();
· ───────────────────────────
╰────
help: Replace with `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` for clarity and consistency

⚠ eslint-plugin-vitest(prefer-called-times): Use `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` instead of `toBeCalledOnce()` or `toHaveBeenCalledOnce()`
╭─[prefer_called_times.tsx:1:1]
1 │ expect(fn).toHaveBeenCalledOnce();
· ─────────────────────────────────
╰────
help: Replace with `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` for clarity and consistency

⚠ eslint-plugin-vitest(prefer-called-times): Use `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` instead of `toBeCalledOnce()` or `toHaveBeenCalledOnce()`
╭─[prefer_called_times.tsx:1:1]
1 │ expect(fn).not.toBeCalledOnce();
· ───────────────────────────────
╰────
help: Replace with `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` for clarity and consistency

⚠ eslint-plugin-vitest(prefer-called-times): Use `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` instead of `toBeCalledOnce()` or `toHaveBeenCalledOnce()`
╭─[prefer_called_times.tsx:1:1]
1 │ expect(fn).not.toHaveBeenCalledOnce();
· ─────────────────────────────────────
╰────
help: Replace with `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` for clarity and consistency

⚠ eslint-plugin-vitest(prefer-called-times): Use `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` instead of `toBeCalledOnce()` or `toHaveBeenCalledOnce()`
╭─[prefer_called_times.tsx:1:1]
1 │ expect(fn).resolves.toBeCalledOnce();
· ────────────────────────────────────
╰────
help: Replace with `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` for clarity and consistency

⚠ eslint-plugin-vitest(prefer-called-times): Use `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` instead of `toBeCalledOnce()` or `toHaveBeenCalledOnce()`
╭─[prefer_called_times.tsx:1:1]
1 │ expect(fn).resolves.toHaveBeenCalledOnce();
· ──────────────────────────────────────────
╰────
help: Replace with `toBeCalledTimes(1)` or `toHaveBeenCalledTimes(1)` for clarity and consistency
22 changes: 22 additions & 0 deletions crates/oxc_linter/src/utils/jest/parse_jest_fn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,15 @@ fn parse_jest_expect_fn_call<'a>(
}

let kind = if is_type_of { JestFnKind::ExpectTypeOf } else { JestFnKind::Expect };
let expect_arguments = head.parent.and_then(|parent| {
if let Expression::CallExpression(parent) = parent {
return Some(&parent.arguments);
}
None
});

let matcher_arguments =
matcher.and_then(|matcher| members.get(matcher)).map(|_| &call_expr.arguments);

let parsed_expect_fn = ParsedExpectFnCall {
kind,
Expand All @@ -164,6 +173,8 @@ fn parse_jest_expect_fn_call<'a>(
matcher_index: matcher,
modifier_indices: modifiers,
expect_error,
expect_arguments,
matcher_arguments,
};

Some(if is_type_of {
Expand Down Expand Up @@ -367,6 +378,9 @@ pub struct ParsedExpectFnCall<'a> {
pub name: Cow<'a, str>,
pub local: Cow<'a, str>,
pub head: KnownMemberExpressionProperty<'a>,
/// this args changed bases on condition
/// In `expect(fn).toBeCalledTimes(2)`, it will be `[2]`
/// In `expect(fn)`, it will be `fn`
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
Expand All @@ -375,6 +389,14 @@ pub struct ParsedExpectFnCall<'a> {
// it save the matcher index from members
pub matcher_index: Option<usize>,
pub expect_error: Option<ExpectError>,

/// the arguments passed to the expect function
/// In `expect(1).toBe(2)`, it will be `[1]`
pub expect_arguments: Option<&'a oxc_allocator::Vec<'a, Argument<'a>>>,
/// the arguments passed to the matcher function
/// In `expect(1).toBe(2)`, it will be `[2]
/// In `expect(1)`, it will be `None`
pub matcher_arguments: Option<&'a oxc_allocator::Vec<'a, Argument<'a>>>,
}

impl<'a> ParsedExpectFnCall<'a> {
Expand Down
Loading