diff --git a/apps/oxlint/src/snapshots/fixtures__cli__overrides_with_plugin_-c .oxlintrc.json@oxlint.snap b/apps/oxlint/src/snapshots/fixtures__cli__overrides_with_plugin_-c .oxlintrc.json@oxlint.snap index 835cae1434de0..e3b367ef62641 100644 --- a/apps/oxlint/src/snapshots/fixtures__cli__overrides_with_plugin_-c .oxlintrc.json@oxlint.snap +++ b/apps/oxlint/src/snapshots/fixtures__cli__overrides_with_plugin_-c .oxlintrc.json@oxlint.snap @@ -31,6 +31,15 @@ working directory: fixtures/cli/overrides_with_plugin `---- help: Add assertion(s) in this Test + ! eslint-plugin-vitest(expect-expect): Test has no assertions + ,-[index.test.ts:4:3] + 3 | + 4 | it("", () => {}); + : ^^ + 5 | // ^ jest/no-valid-title error as explicitly set in the `.test.ts` override + `---- + help: Add assertion(s) in this Test + x eslint-plugin-jest(valid-title): Should not have an empty title ,-[index.test.ts:4:6] 3 | @@ -58,7 +67,7 @@ working directory: fixtures/cli/overrides_with_plugin `---- help: Consider removing this declaration. -Found 2 warnings and 4 errors. +Found 3 warnings and 4 errors. Finished in ms on 2 files with 93 rules using 1 threads. ---------- CLI result: LintFoundErrors diff --git a/crates/oxc_linter/data/vitest_compatible_jest_rules.json b/crates/oxc_linter/data/vitest_compatible_jest_rules.json index f448e4631c845..a4fa531273868 100644 --- a/crates/oxc_linter/data/vitest_compatible_jest_rules.json +++ b/crates/oxc_linter/data/vitest_compatible_jest_rules.json @@ -1,5 +1,4 @@ [ - "expect-expect", "max-expects", "max-nested-describe", "no-alias-methods", diff --git a/crates/oxc_linter/src/generated/rule_runner_impls.rs b/crates/oxc_linter/src/generated/rule_runner_impls.rs index eb3531753e997..49289b776152b 100644 --- a/crates/oxc_linter/src/generated/rule_runner_impls.rs +++ b/crates/oxc_linter/src/generated/rule_runner_impls.rs @@ -4234,6 +4234,11 @@ impl RuleRunner for crate::rules::vitest::consistent_vitest_vi::ConsistentVitest const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::Run; } +impl RuleRunner for crate::rules::vitest::expect_expect::ExpectExpect { + const NODE_TYPES: Option<&AstTypesBitset> = None; + const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnJestNode; +} + impl RuleRunner for crate::rules::vitest::hoisted_apis_on_top::HoistedApisOnTop { 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 2d4d5547465a7..04a4ab16467e9 100644 --- a/crates/oxc_linter/src/generated/rules_enum.rs +++ b/crates/oxc_linter/src/generated/rules_enum.rs @@ -677,6 +677,7 @@ pub use crate::rules::vitest::consistent_each_for::ConsistentEachFor as VitestCo pub use crate::rules::vitest::consistent_test_filename::ConsistentTestFilename as VitestConsistentTestFilename; pub use crate::rules::vitest::consistent_test_it::ConsistentTestIt as VitestConsistentTestIt; pub use crate::rules::vitest::consistent_vitest_vi::ConsistentVitestVi as VitestConsistentVitestVi; +pub use crate::rules::vitest::expect_expect::ExpectExpect as VitestExpectExpect; pub use crate::rules::vitest::hoisted_apis_on_top::HoistedApisOnTop as VitestHoistedApisOnTop; pub use crate::rules::vitest::no_conditional_tests::NoConditionalTests as VitestNoConditionalTests; pub use crate::rules::vitest::no_import_node_test::NoImportNodeTest as VitestNoImportNodeTest; @@ -1383,6 +1384,7 @@ pub enum RuleEnum { VitestConsistentTestFilename(VitestConsistentTestFilename), VitestConsistentTestIt(VitestConsistentTestIt), VitestConsistentVitestVi(VitestConsistentVitestVi), + VitestExpectExpect(VitestExpectExpect), VitestHoistedApisOnTop(VitestHoistedApisOnTop), VitestNoConditionalTests(VitestNoConditionalTests), VitestNoImportNodeTest(VitestNoImportNodeTest), @@ -2168,7 +2170,8 @@ const VITEST_CONSISTENT_EACH_FOR_ID: usize = PROMISE_VALID_PARAMS_ID + 1usize; const VITEST_CONSISTENT_TEST_FILENAME_ID: usize = VITEST_CONSISTENT_EACH_FOR_ID + 1usize; const VITEST_CONSISTENT_TEST_IT_ID: usize = VITEST_CONSISTENT_TEST_FILENAME_ID + 1usize; const VITEST_CONSISTENT_VITEST_VI_ID: usize = VITEST_CONSISTENT_TEST_IT_ID + 1usize; -const VITEST_HOISTED_APIS_ON_TOP_ID: usize = VITEST_CONSISTENT_VITEST_VI_ID + 1usize; +const VITEST_EXPECT_EXPECT_ID: usize = VITEST_CONSISTENT_VITEST_VI_ID + 1usize; +const VITEST_HOISTED_APIS_ON_TOP_ID: usize = VITEST_EXPECT_EXPECT_ID + 1usize; 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; @@ -2980,6 +2983,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(_) => VITEST_CONSISTENT_TEST_FILENAME_ID, Self::VitestConsistentTestIt(_) => VITEST_CONSISTENT_TEST_IT_ID, Self::VitestConsistentVitestVi(_) => VITEST_CONSISTENT_VITEST_VI_ID, + Self::VitestExpectExpect(_) => VITEST_EXPECT_EXPECT_ID, Self::VitestHoistedApisOnTop(_) => VITEST_HOISTED_APIS_ON_TOP_ID, Self::VitestNoConditionalTests(_) => VITEST_NO_CONDITIONAL_TESTS_ID, Self::VitestNoImportNodeTest(_) => VITEST_NO_IMPORT_NODE_TEST_ID, @@ -3781,6 +3785,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(_) => VitestConsistentTestFilename::NAME, Self::VitestConsistentTestIt(_) => VitestConsistentTestIt::NAME, Self::VitestConsistentVitestVi(_) => VitestConsistentVitestVi::NAME, + Self::VitestExpectExpect(_) => VitestExpectExpect::NAME, Self::VitestHoistedApisOnTop(_) => VitestHoistedApisOnTop::NAME, Self::VitestNoConditionalTests(_) => VitestNoConditionalTests::NAME, Self::VitestNoImportNodeTest(_) => VitestNoImportNodeTest::NAME, @@ -4624,6 +4629,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(_) => VitestConsistentTestFilename::CATEGORY, Self::VitestConsistentTestIt(_) => VitestConsistentTestIt::CATEGORY, Self::VitestConsistentVitestVi(_) => VitestConsistentVitestVi::CATEGORY, + Self::VitestExpectExpect(_) => VitestExpectExpect::CATEGORY, Self::VitestHoistedApisOnTop(_) => VitestHoistedApisOnTop::CATEGORY, Self::VitestNoConditionalTests(_) => VitestNoConditionalTests::CATEGORY, Self::VitestNoImportNodeTest(_) => VitestNoImportNodeTest::CATEGORY, @@ -5430,6 +5436,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(_) => VitestConsistentTestFilename::FIX, Self::VitestConsistentTestIt(_) => VitestConsistentTestIt::FIX, Self::VitestConsistentVitestVi(_) => VitestConsistentVitestVi::FIX, + Self::VitestExpectExpect(_) => VitestExpectExpect::FIX, Self::VitestHoistedApisOnTop(_) => VitestHoistedApisOnTop::FIX, Self::VitestNoConditionalTests(_) => VitestNoConditionalTests::FIX, Self::VitestNoImportNodeTest(_) => VitestNoImportNodeTest::FIX, @@ -6424,6 +6431,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(_) => VitestConsistentTestFilename::documentation(), Self::VitestConsistentTestIt(_) => VitestConsistentTestIt::documentation(), Self::VitestConsistentVitestVi(_) => VitestConsistentVitestVi::documentation(), + Self::VitestExpectExpect(_) => VitestExpectExpect::documentation(), Self::VitestHoistedApisOnTop(_) => VitestHoistedApisOnTop::documentation(), Self::VitestNoConditionalTests(_) => VitestNoConditionalTests::documentation(), Self::VitestNoImportNodeTest(_) => VitestNoImportNodeTest::documentation(), @@ -8360,6 +8368,8 @@ impl RuleEnum { .or_else(|| VitestConsistentTestIt::schema(generator)), Self::VitestConsistentVitestVi(_) => VitestConsistentVitestVi::config_schema(generator) .or_else(|| VitestConsistentVitestVi::schema(generator)), + Self::VitestExpectExpect(_) => VitestExpectExpect::config_schema(generator) + .or_else(|| VitestExpectExpect::schema(generator)), Self::VitestHoistedApisOnTop(_) => VitestHoistedApisOnTop::config_schema(generator) .or_else(|| VitestHoistedApisOnTop::schema(generator)), Self::VitestNoConditionalTests(_) => VitestNoConditionalTests::config_schema(generator) @@ -9128,6 +9138,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(_) => "vitest", Self::VitestConsistentTestIt(_) => "vitest", Self::VitestConsistentVitestVi(_) => "vitest", + Self::VitestExpectExpect(_) => "vitest", Self::VitestHoistedApisOnTop(_) => "vitest", Self::VitestNoConditionalTests(_) => "vitest", Self::VitestNoImportNodeTest(_) => "vitest", @@ -11302,6 +11313,9 @@ impl RuleEnum { Self::VitestConsistentVitestVi(_) => Ok(Self::VitestConsistentVitestVi( VitestConsistentVitestVi::from_configuration(value)?, )), + Self::VitestExpectExpect(_) => { + Ok(Self::VitestExpectExpect(VitestExpectExpect::from_configuration(value)?)) + } Self::VitestHoistedApisOnTop(_) => { Ok(Self::VitestHoistedApisOnTop(VitestHoistedApisOnTop::from_configuration(value)?)) } @@ -12089,6 +12103,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(rule) => rule.to_configuration(), Self::VitestConsistentTestIt(rule) => rule.to_configuration(), Self::VitestConsistentVitestVi(rule) => rule.to_configuration(), + Self::VitestExpectExpect(rule) => rule.to_configuration(), Self::VitestHoistedApisOnTop(rule) => rule.to_configuration(), Self::VitestNoConditionalTests(rule) => rule.to_configuration(), Self::VitestNoImportNodeTest(rule) => rule.to_configuration(), @@ -12796,6 +12811,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(rule) => rule.run(node, ctx), Self::VitestConsistentTestIt(rule) => rule.run(node, ctx), Self::VitestConsistentVitestVi(rule) => rule.run(node, ctx), + Self::VitestExpectExpect(rule) => rule.run(node, ctx), Self::VitestHoistedApisOnTop(rule) => rule.run(node, ctx), Self::VitestNoConditionalTests(rule) => rule.run(node, ctx), Self::VitestNoImportNodeTest(rule) => rule.run(node, ctx), @@ -13501,6 +13517,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(rule) => rule.run_once(ctx), Self::VitestConsistentTestIt(rule) => rule.run_once(ctx), Self::VitestConsistentVitestVi(rule) => rule.run_once(ctx), + Self::VitestExpectExpect(rule) => rule.run_once(ctx), Self::VitestHoistedApisOnTop(rule) => rule.run_once(ctx), Self::VitestNoConditionalTests(rule) => rule.run_once(ctx), Self::VitestNoImportNodeTest(rule) => rule.run_once(ctx), @@ -14304,6 +14321,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(rule) => rule.run_on_jest_node(jest_node, ctx), Self::VitestConsistentTestIt(rule) => rule.run_on_jest_node(jest_node, ctx), Self::VitestConsistentVitestVi(rule) => rule.run_on_jest_node(jest_node, ctx), + Self::VitestExpectExpect(rule) => rule.run_on_jest_node(jest_node, ctx), Self::VitestHoistedApisOnTop(rule) => rule.run_on_jest_node(jest_node, ctx), Self::VitestNoConditionalTests(rule) => rule.run_on_jest_node(jest_node, ctx), Self::VitestNoImportNodeTest(rule) => rule.run_on_jest_node(jest_node, ctx), @@ -15011,6 +15029,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(rule) => rule.should_run(ctx), Self::VitestConsistentTestIt(rule) => rule.should_run(ctx), Self::VitestConsistentVitestVi(rule) => rule.should_run(ctx), + Self::VitestExpectExpect(rule) => rule.should_run(ctx), Self::VitestHoistedApisOnTop(rule) => rule.should_run(ctx), Self::VitestNoConditionalTests(rule) => rule.should_run(ctx), Self::VitestNoImportNodeTest(rule) => rule.should_run(ctx), @@ -16002,6 +16021,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(_) => VitestConsistentTestFilename::IS_TSGOLINT_RULE, Self::VitestConsistentTestIt(_) => VitestConsistentTestIt::IS_TSGOLINT_RULE, Self::VitestConsistentVitestVi(_) => VitestConsistentVitestVi::IS_TSGOLINT_RULE, + Self::VitestExpectExpect(_) => VitestExpectExpect::IS_TSGOLINT_RULE, Self::VitestHoistedApisOnTop(_) => VitestHoistedApisOnTop::IS_TSGOLINT_RULE, Self::VitestNoConditionalTests(_) => VitestNoConditionalTests::IS_TSGOLINT_RULE, Self::VitestNoImportNodeTest(_) => VitestNoImportNodeTest::IS_TSGOLINT_RULE, @@ -16878,6 +16898,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(_) => VitestConsistentTestFilename::HAS_CONFIG, Self::VitestConsistentTestIt(_) => VitestConsistentTestIt::HAS_CONFIG, Self::VitestConsistentVitestVi(_) => VitestConsistentVitestVi::HAS_CONFIG, + Self::VitestExpectExpect(_) => VitestExpectExpect::HAS_CONFIG, Self::VitestHoistedApisOnTop(_) => VitestHoistedApisOnTop::HAS_CONFIG, Self::VitestNoConditionalTests(_) => VitestNoConditionalTests::HAS_CONFIG, Self::VitestNoImportNodeTest(_) => VitestNoImportNodeTest::HAS_CONFIG, @@ -17589,6 +17610,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(rule) => rule.types_info(), Self::VitestConsistentTestIt(rule) => rule.types_info(), Self::VitestConsistentVitestVi(rule) => rule.types_info(), + Self::VitestExpectExpect(rule) => rule.types_info(), Self::VitestHoistedApisOnTop(rule) => rule.types_info(), Self::VitestNoConditionalTests(rule) => rule.types_info(), Self::VitestNoImportNodeTest(rule) => rule.types_info(), @@ -18294,6 +18316,7 @@ impl RuleEnum { Self::VitestConsistentTestFilename(rule) => rule.run_info(), Self::VitestConsistentTestIt(rule) => rule.run_info(), Self::VitestConsistentVitestVi(rule) => rule.run_info(), + Self::VitestExpectExpect(rule) => rule.run_info(), Self::VitestHoistedApisOnTop(rule) => rule.run_info(), Self::VitestNoConditionalTests(rule) => rule.run_info(), Self::VitestNoImportNodeTest(rule) => rule.run_info(), @@ -19115,6 +19138,7 @@ pub static RULES: std::sync::LazyLock> = std::sync::LazyLock::new( RuleEnum::VitestConsistentTestFilename(VitestConsistentTestFilename::default()), RuleEnum::VitestConsistentTestIt(VitestConsistentTestIt::default()), RuleEnum::VitestConsistentVitestVi(VitestConsistentVitestVi::default()), + RuleEnum::VitestExpectExpect(VitestExpectExpect::default()), RuleEnum::VitestHoistedApisOnTop(VitestHoistedApisOnTop::default()), RuleEnum::VitestNoConditionalTests(VitestNoConditionalTests::default()), RuleEnum::VitestNoImportNodeTest(VitestNoImportNodeTest::default()), diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 04eead47bf005..d2e434218e606 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -705,6 +705,7 @@ pub(crate) mod vitest { pub mod consistent_test_filename; pub mod consistent_test_it; pub mod consistent_vitest_vi; + pub mod expect_expect; pub mod hoisted_apis_on_top; pub mod no_conditional_tests; pub mod no_import_node_test; @@ -753,6 +754,7 @@ pub(crate) mod vue { pub(crate) mod shared { pub mod consistent_test_it; + pub mod expect_expect; pub mod valid_title; } diff --git a/crates/oxc_linter/src/rules/jest/expect_expect.rs b/crates/oxc_linter/src/rules/jest/expect_expect.rs index 32382ccbbb581..ace5f9c71c59d 100644 --- a/crates/oxc_linter/src/rules/jest/expect_expect.rs +++ b/crates/oxc_linter/src/rules/jest/expect_expect.rs @@ -1,142 +1,26 @@ -use cow_utils::CowUtils; -use lazy_regex::Regex; -use rustc_hash::FxHashSet; - -use oxc_ast::{ - AstKind, - ast::{CallExpression, Expression, FormalParameter, Function, Statement}, -}; -use oxc_ast_visit::{Visit, walk}; -use oxc_diagnostics::OxcDiagnostic; use oxc_macros::declare_oxc_lint; -use oxc_span::{CompactStr, GetSpan, Span}; -use oxc_syntax::scope::ScopeFlags; -use schemars::JsonSchema; use crate::{ - ast_util::get_declaration_of_variable, context::LintContext, rule::Rule, - utils::{ - JestFnKind, JestGeneralFnKind, PossibleJestNode, get_node_name, is_type_of_jest_fn_call, - }, + rules::shared::expect_expect::{DOCUMENTATION, ExpectExpectConfig}, + utils::PossibleJestNode, }; -fn expect_expect_diagnostic(span: Span) -> OxcDiagnostic { - OxcDiagnostic::warn("Test has no assertions") - .with_help("Add assertion(s) in this Test") - .with_label(span) -} - #[derive(Debug, Default, Clone)] pub struct ExpectExpect(Box); -#[derive(Debug, Clone, JsonSchema)] -#[serde(rename_all = "camelCase", default)] -pub struct ExpectExpectConfig { - /// A list of function names that should be treated as assertion functions. - /// - /// NOTE: The default value is `["expect"]` for Jest and - /// `["expect", "expectTypeOf", "assert", "assertType"]` for Vitest. - #[serde(rename = "assertFunctionNames")] - assert_function_names_jest: Vec, - #[schemars(skip)] // Skipped because this field isn't exposed to the user. - assert_function_names_vitest: Vec, - /// An array of function names that should also be treated as test blocks. - additional_test_block_functions: Vec, -} - -impl std::ops::Deref for ExpectExpect { - type Target = ExpectExpectConfig; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl Default for ExpectExpectConfig { - fn default() -> Self { - Self { - assert_function_names_jest: vec!["expect".into()], - assert_function_names_vitest: vec![ - "expect".into(), - "expectTypeOf".into(), - "assert".into(), - "assertType".into(), - ], - additional_test_block_functions: vec![], - } - } -} - declare_oxc_lint!( - /// ### What it does - /// - /// This rule triggers when there is no call made to `expect` in a test, ensure that there is at least one `expect` call made in a test. - /// - /// ### Why is this bad? - /// - /// People may forget to add assertions. - /// - /// ### Examples - /// - /// Examples of **incorrect** code for this rule: - /// ```javascript - /// it('should be a test', () => { - /// console.log('no assertion'); - /// }); - /// test('should assert something', () => {}); - /// ``` - /// - /// This rule is compatible with [eslint-plugin-vitest](https://github.com/vitest-dev/eslint-plugin-vitest/blob/v1.1.9/docs/rules/expect-expect.md), - /// to use it, add the following configuration to your `.oxlintrc.json`: - /// - /// ```json - /// { - /// "rules": { - /// "vitest/expect-expect": "error" - /// } - /// } - /// ``` ExpectExpect, jest, correctness, config = ExpectExpectConfig, + docs = DOCUMENTATION ); impl Rule for ExpectExpect { fn from_configuration(value: serde_json::Value) -> Result { - let default_assert_function_names_jest = vec!["expect".into()]; - let default_assert_function_names_vitest = - vec!["expect".into(), "expectTypeOf".into(), "assert".into(), "assertType".into()]; - let config = value.get(0); - - let assert_function_names = config - .and_then(|config| config.get("assertFunctionNames")) - .and_then(serde_json::Value::as_array) - .map(|v| { - v.iter() - .filter_map(serde_json::Value::as_str) - .map(convert_pattern) - .collect::>() - }); - - let assert_function_names_jest = - assert_function_names.clone().unwrap_or(default_assert_function_names_jest); - let assert_function_names_vitest = - assert_function_names.unwrap_or(default_assert_function_names_vitest); - - let additional_test_block_functions = config - .and_then(|config| config.get("additionalTestBlockFunctions")) - .and_then(serde_json::Value::as_array) - .map(|v| v.iter().filter_map(serde_json::Value::as_str).map(CompactStr::from).collect()) - .unwrap_or_default(); - - Ok(Self(Box::new(ExpectExpectConfig { - assert_function_names_jest, - assert_function_names_vitest, - additional_test_block_functions, - }))) + ExpectExpectConfig::from_configuration(&value).map(|config| Self(Box::new(config))) } fn run_on_jest_node<'a, 'c>( @@ -144,209 +28,15 @@ impl Rule for ExpectExpect { jest_node: &PossibleJestNode<'a, 'c>, ctx: &'c LintContext<'a>, ) { - run(self, jest_node, ctx); - } -} - -fn run<'a>( - rule: &ExpectExpect, - possible_jest_node: &PossibleJestNode<'a, '_>, - ctx: &LintContext<'a>, -) { - let node = possible_jest_node.node; - if let AstKind::CallExpression(call_expr) = node.kind() { - let name = get_node_name(&call_expr.callee); - if is_type_of_jest_fn_call( - call_expr, - possible_jest_node, - ctx, - &[JestFnKind::General(JestGeneralFnKind::Test)], - ) || rule.additional_test_block_functions.contains(&name) - { - if let Some(member_expr) = call_expr.callee.as_member_expression() { - let Some(property_name) = member_expr.static_property_name() else { - return; - }; - if property_name == "todo" { - return; - } - if property_name == "skip" && ctx.frameworks().is_vitest() { - return; - } - } - - let assert_function_names = if ctx.frameworks().is_vitest() { - &rule.assert_function_names_vitest - } else { - &rule.assert_function_names_jest - }; - - let mut visitor = AssertionVisitor::new(ctx, assert_function_names); - - // Visit each argument of the test call - for argument in &call_expr.arguments { - if let Some(expr) = argument.as_expression() { - visitor.check_expression(expr); - if visitor.found_assertion { - return; - } - } - } - - if !visitor.found_assertion { - ctx.diagnostic(expect_expect_diagnostic(call_expr.callee.span())); - } - } + self.0.run_on_jest_node(jest_node, ctx); } } -struct AssertionVisitor<'a, 'b> { - ctx: &'b LintContext<'a>, - assert_function_names: &'b [CompactStr], - visited: FxHashSet, - found_assertion: bool, -} - -impl<'a, 'b> AssertionVisitor<'a, 'b> { - fn new(ctx: &'b LintContext<'a>, assert_function_names: &'b [CompactStr]) -> Self { - Self { ctx, assert_function_names, visited: FxHashSet::default(), found_assertion: false } - } - - fn check_expression(&mut self, expr: &Expression<'a>) { - // Avoid infinite loops by tracking visited expressions - if !self.visited.insert(expr.span()) { - return; - } - - match expr { - Expression::FunctionExpression(fn_expr) => { - if let Some(body) = &fn_expr.body { - self.visit_function_body(body); - } - } - Expression::ArrowFunctionExpression(arrow_expr) => { - self.visit_function_body(&arrow_expr.body); - } - Expression::CallExpression(call_expr) => { - self.visit_call_expression(call_expr); - } - Expression::Identifier(ident) => { - self.check_identifier(ident); - } - Expression::AwaitExpression(expr) => { - self.check_expression(&expr.argument); - } - Expression::ArrayExpression(array_expr) => { - for element in &array_expr.elements { - if let Some(element_expr) = element.as_expression() { - self.check_expression(element_expr); - if self.found_assertion { - return; - } - } - } - } - _ => {} - } - } - - fn check_identifier(&mut self, ident: &oxc_ast::ast::IdentifierReference<'a>) { - let Some(node) = get_declaration_of_variable(ident, self.ctx) else { - return; - }; - let AstKind::Function(function) = node.kind() else { - return; - }; - if let Some(body) = &function.body { - self.visit_function_body(body); - } - } -} - -impl<'a> Visit<'a> for AssertionVisitor<'a, '_> { - fn visit_call_expression(&mut self, call_expr: &CallExpression<'a>) { - let name = get_node_name(&call_expr.callee); - if matches_assert_function_name(&name, self.assert_function_names) { - self.found_assertion = true; - return; - } - - for argument in &call_expr.arguments { - if let Some(expr) = argument.as_expression() { - self.check_expression(expr); - if self.found_assertion { - return; - } - } - } - - walk::walk_call_expression(self, call_expr); - } - - fn visit_expression_statement(&mut self, stmt: &oxc_ast::ast::ExpressionStatement<'a>) { - self.check_expression(&stmt.expression); - if !self.found_assertion { - walk::walk_expression_statement(self, stmt); - } - } - - fn visit_block_statement(&mut self, block: &oxc_ast::ast::BlockStatement<'a>) { - for stmt in &block.body { - self.visit_statement(stmt); - if self.found_assertion { - return; - } - } - } - - fn visit_if_statement(&mut self, if_stmt: &oxc_ast::ast::IfStatement<'a>) { - if let Statement::BlockStatement(block_stmt) = &if_stmt.consequent { - self.visit_block_statement(block_stmt); - } - if self.found_assertion { - return; - } - if let Some(alternate) = &if_stmt.alternate { - self.visit_statement(alternate); - } - } - - fn visit_function(&mut self, _func: &Function<'a>, _flags: ScopeFlags) {} - - fn visit_formal_parameter(&mut self, _param: &FormalParameter<'a>) {} -} - -/// Checks if node names returned by getNodeName matches any of the given star patterns -fn matches_assert_function_name(name: &str, patterns: &[CompactStr]) -> bool { - patterns.iter().any(|pattern| Regex::new(pattern).unwrap().is_match(name)) -} - -fn convert_pattern(pattern: &str) -> CompactStr { - // Pre-process pattern, e.g. - // request.*.expect -> request.[a-z\\d]*.expect - // request.**.expect -> request.[a-z\\d\\.]*.expect - // request.**.expect* -> request.[a-z\\d\\.]*.expect[a-z\\d]* - let pattern = pattern - .split('.') - .map(|p| { - if p == "**" { - CompactStr::from("[a-z\\d\\.]*") - } else { - p.cow_replace('*', "[a-z\\d]*").into() - } - }) - .collect::>() - .join("\\."); - - // 'a.b.c' -> /^a\.b\.c(\.|$)/iu - format!("(?ui)^{pattern}(\\.|$)").into() -} - #[test] fn test() { use crate::tester::Tester; - let mut pass = vec![ + let pass = vec![ ("it.todo('will test something eventually')", None), ("test.todo('will test something eventually')", None), ("['x']();", None), @@ -508,7 +198,7 @@ fn test() { (r"it('msg', async () => { const r = foo(); return expect(r).rejects.toThrow(); });", None), ]; - let mut fail = vec![ + let fail = vec![ ("it(\"should fail\", () => {});", None), ("it(\"should fail\", myTest); function myTest() {}", None), ("test(\"should fail\", () => {});", None), @@ -600,290 +290,7 @@ fn test() { ), ]; - let pass_vitest = vec![ - ( - " - import { test } from 'vitest'; - test.skip(\"skipped test\", () => {}) - ", - None, - ), - ("it.todo(\"will test something eventually\")", None), - ("test.todo(\"will test something eventually\")", None), - ("['x']();", None), - ("it(\"should pass\", () => expect(true).toBeDefined())", None), - ("test(\"should pass\", () => expect(true).toBeDefined())", None), - ("it(\"should pass\", () => somePromise().then(() => expect(true).toBeDefined()))", None), - ("it(\"should pass\", myTest); function myTest() { expect(true).toBeDefined() }", None), - ( - " - test('should pass', () => { - expect(true).toBeDefined(); - foo(true).toBe(true); - }); - ", - Some(serde_json::json!([{ "assertFunctionNames": ["expect", "foo"] }])) - ), - ( - " - import { bench } from 'vitest' - - bench('normal sorting', () => { - const x = [1, 5, 4, 2, 3] - x.sort((a, b) => { - return a - b - }) - }, { time: 1000 }) - ", - None, - ), - ( - "it(\"should return undefined\", () => expectSaga(mySaga).returns());", - Some(serde_json::json!([{ "assertFunctionNames": ["expectSaga"] }])), - ), - ( - "test('verifies expect method call', () => expect$(123));", - Some(serde_json::json!([{ "assertFunctionNames": ["expect\\$"] }])), - ), - ( - "test('verifies expect method call', () => new Foo().expect(123));", - Some(serde_json::json!([{ "assertFunctionNames": ["Foo.expect"] }])), - ), - ( - " - test('verifies deep expect method call', () => { - tester.foo().expect(123); - }); - ", - Some(serde_json::json!([{ "assertFunctionNames": ["tester.foo.expect"] }])), - ), - ( - " - test('verifies chained expect method call', () => { - tester - .foo() - .bar() - .expect(456); - }); - ", - Some(serde_json::json!([{ "assertFunctionNames": ["tester.foo.bar.expect"] }])), - ), - ( - " - test(\"verifies the function call\", () => { - td.verify(someFunctionCall()) - }) - ", - Some(serde_json::json!([{ "assertFunctionNames": ["td.verify"] }])), - ), - ( - "it(\"should pass\", () => expect(true).toBeDefined())", - Some(serde_json::json!([{ - "assertFunctionNames": "undefined", - "additionalTestBlockFunctions": "undefined", - }])), - ), - ( - " - theoretically('the number {input} is correctly translated to string', theories, theory => { - const output = NumberToLongString(theory.input); - expect(output).toBe(theory.expected); - }) - ", - Some(serde_json::json!([{ "additionalTestBlockFunctions": ["theoretically"] }])), - ), - ( - "test('should pass *', () => expect404ToBeLoaded());", - Some(serde_json::json!([{ "assertFunctionNames": ["expect*"] }])), - ), - ( - "test('should pass *', () => expect.toHaveStatus404());", - Some(serde_json::json!([{ "assertFunctionNames": ["expect.**"] }])), - ), - ( - "test('should pass', () => tester.foo().expect(123));", - Some(serde_json::json!([{ "assertFunctionNames": ["tester.*.expect"] }])), - ), - ( - "test('should pass **', () => tester.foo().expect(123));", - Some(serde_json::json!([{ "assertFunctionNames": ["**"] }])), - ), - ( - "test('should pass *', () => tester.foo().expect(123));", - Some(serde_json::json!([{ "assertFunctionNames": ["*"] }])), - ), - ( - "test('should pass', () => tester.foo().expect(123));", - Some(serde_json::json!([{ "assertFunctionNames": ["tester.**"] }])), - ), - ( - "test('should pass', () => tester.foo().expect(123));", - Some(serde_json::json!([{ "assertFunctionNames": ["tester.*"] }])), - ), - ( - "test('should pass', () => tester.foo().bar().expectIt(456));", - Some(serde_json::json!([{ "assertFunctionNames": ["tester.**.expect*"] }])), - ), - ( - "test('should pass', () => request.get().foo().expect(456));", - Some(serde_json::json!([{ "assertFunctionNames": ["request.**.expect"] }])), - ), - ( - "test('should pass', () => request.get().foo().expect(456));", - Some(serde_json::json!([{ "assertFunctionNames": ["request.**.e*e*t"] }])), - ), - ( - " - import { test } from 'vitest'; - - test('should pass', () => { - expect(true).toBeDefined(); - foo(true).toBe(true); - }); - ", - Some(serde_json::json!([{ "assertFunctionNames": ["expect", "foo"] }])), - ), - ( - " - import { test as checkThat } from 'vitest'; - - checkThat('this passes', () => { - expect(true).toBeDefined(); - foo(true).toBe(true); - }); - ", - Some(serde_json::json!([{ "assertFunctionNames": ["expect", "foo"] }])), - ), - ( - " - const { test } = require('vitest'); - - test('verifies chained expect method call', () => { - tester - .foo() - .bar() - .expect(456); - }); - ", - Some(serde_json::json!([{ "assertFunctionNames": ["tester.foo.bar.expect"] }])), - ), - ( - " - it(\"should pass with 'typecheck' enabled\", () => { - expectTypeOf({ a: 1 }).toEqualTypeOf<{ a: number }>() - }); - ", - None - ), - ( - " - import { assert, it } from 'vitest'; - - it('test', () => { - assert.throws(() => { - throw Error('Invalid value'); - }); - }); - ", - Some(serde_json::json!([{ "assertFunctionNames": ["assert"] }])), - ), - ( - " - import { expectTypeOf } from 'vitest' - - expectTypeOf({ a: 1 }).toEqualTypeOf<{ a: number }>() - ", - Some(serde_json::json!([{ "assertFunctionNames": ["expectTypeOf"] }])), - ), - ( - " - import { assertType } from 'vitest' - - function concat(a: string, b: string): string - function concat(a: number, b: number): number - function concat(a: string | number, b: string | number): string | number - - assertType(concat('a', 'b')) - assertType(concat(1, 2)) - // @ts-expect-error wrong types - assertType(concat('a', 2)) - ", - Some(serde_json::json!([{ "assertFunctionNames": ["assertType"] }])), - ), - ]; - - let fail_vitest = vec![ - ("it(\"should fail\", () => {});", None), - ("it(\"should fail\", myTest); function myTest() {}", None), - ("test(\"should fail\", () => {});", None), - ( - "afterEach(() => {});", - Some(serde_json::json!([{ "additionalTestBlockFunctions": ["afterEach"] }])), - ), - // Todo: currently it's not support - // ( - // " - // theoretically('the number {input} is correctly translated to string', theories, theory => { - // const output = NumberToLongString(theory.input); - // }) - // ", - // Some(serde_json::json!([{ "additionalTestBlockFunctions": ["theoretically"] }])), - // ), - ("it(\"should fail\", () => { somePromise.then(() => {}); });", None), - ( - "test(\"should fail\", () => { foo(true).toBe(true); })", - Some(serde_json::json!([{ "assertFunctionNames": ["expect"] }])), - ), - ( - "it(\"should also fail\",() => expectSaga(mySaga).returns());", - Some(serde_json::json!([{ "assertFunctionNames": ["expect"] }])), - ), - ( - "test('should fail', () => request.get().foo().expect(456));", - Some(serde_json::json!([{ "assertFunctionNames": ["request.*.expect"] }])), - ), - ( - "test('should fail', () => request.get().foo().bar().expect(456));", - Some(serde_json::json!([{ "assertFunctionNames": ["request.foo**.expect"] }])), - ), - ( - "test('should fail', () => tester.request(123));", - Some(serde_json::json!([{ "assertFunctionNames": ["request.*"] }])), - ), - ( - "test('should fail', () => request(123));", - Some(serde_json::json!([{ "assertFunctionNames": ["request.*"] }])), - ), - ( - "test('should fail', () => request(123));", - Some(serde_json::json!([{ "assertFunctionNames": ["request.**"] }])), - ), - ( - " - import { test as checkThat } from 'vitest'; - - checkThat('this passes', () => { - // ... - }); - ", - Some(serde_json::json!([{ "assertFunctionNames": ["expect", "foo"] }])), - ), - // Todo: currently we couldn't support ignore the typecheck option. - // ( - // " - // it(\"should fail without 'typecheck' enabled\", () => { - // expectTypeOf({ a: 1 }).toEqualTypeOf<{ a: number }>() - // }); - // ", - // None, - // ), - ]; - - pass.extend(pass_vitest); - fail.extend(fail_vitest); - Tester::new(ExpectExpect::NAME, ExpectExpect::PLUGIN, pass, fail) .with_jest_plugin(true) - .with_vitest_plugin(true) .test_and_snapshot(); } diff --git a/crates/oxc_linter/src/rules/shared/expect_expect.rs b/crates/oxc_linter/src/rules/shared/expect_expect.rs new file mode 100644 index 0000000000000..772db5e7f2af5 --- /dev/null +++ b/crates/oxc_linter/src/rules/shared/expect_expect.rs @@ -0,0 +1,325 @@ +use cow_utils::CowUtils; +use lazy_regex::Regex; +use rustc_hash::FxHashSet; + +use oxc_ast::{ + AstKind, + ast::{CallExpression, Expression, FormalParameter, Function, Statement}, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_span::{CompactStr, GetSpan, Span}; +use oxc_syntax::scope::ScopeFlags; +use schemars::JsonSchema; + +use crate::{ + ast_util::get_declaration_of_variable, + context::LintContext, + utils::{ + JestFnKind, JestGeneralFnKind, PossibleJestNode, get_node_name, is_type_of_jest_fn_call, + }, +}; + +fn expect_expect_diagnostic(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Test has no assertions") + .with_help("Add assertion(s) in this Test") + .with_label(span) +} + +pub const DOCUMENTATION: &str = r#"### What it does + +This rule triggers when there is no call made to `expect` in a test, ensure that there is at least one `expect` call made in a test. + +### Why is this bad? + +People may forget to add assertions. + +### Examples + +Examples of **incorrect** code for this rule: +```javascript +it('should be a test', () => { + console.log('no assertion'); +}); +test('should assert something', () => {}); +``` + +This rule is compatible with [eslint-plugin-vitest](https://github.com/vitest-dev/eslint-plugin-vitest/blob/v1.1.9/docs/rules/expect-expect.md), +to use it, add the following configuration to your `.oxlintrc.json`: + +```json +{ + "rules": { + "vitest/expect-expect": "error" + } +} +``` +"#; +#[derive(Debug, Clone, JsonSchema)] +#[serde(rename_all = "camelCase", default)] +pub struct ExpectExpectConfig { + /// A list of function names that should be treated as assertion functions. + /// + /// NOTE: The default value is `["expect"]` for Jest and + /// `["expect", "expectTypeOf", "assert", "assertType"]` for Vitest. + #[serde(rename = "assertFunctionNames")] + assert_function_names_jest: Vec, + #[schemars(skip)] // Skipped because this field isn't exposed to the user. + assert_function_names_vitest: Vec, + /// An array of function names that should also be treated as test blocks. + additional_test_block_functions: Vec, +} + +impl Default for ExpectExpectConfig { + fn default() -> Self { + Self { + assert_function_names_jest: vec!["expect".into()], + assert_function_names_vitest: vec![ + "expect".into(), + "expectTypeOf".into(), + "assert".into(), + "assertType".into(), + ], + additional_test_block_functions: vec![], + } + } +} + +impl ExpectExpectConfig { + #[expect(clippy::unnecessary_wraps)] // TODO: fail on serde_json::Error + pub fn from_configuration(value: &serde_json::Value) -> Result { + let default_assert_function_names_jest = vec!["expect".into()]; + let default_assert_function_names_vitest = + vec!["expect".into(), "expectTypeOf".into(), "assert".into(), "assertType".into()]; + let config = value.get(0); + + let assert_function_names = config + .and_then(|config| config.get("assertFunctionNames")) + .and_then(serde_json::Value::as_array) + .map(|v| { + v.iter() + .filter_map(serde_json::Value::as_str) + .map(convert_pattern) + .collect::>() + }); + + let assert_function_names_jest = + assert_function_names.clone().unwrap_or(default_assert_function_names_jest); + let assert_function_names_vitest = + assert_function_names.unwrap_or(default_assert_function_names_vitest); + + let additional_test_block_functions = config + .and_then(|config| config.get("additionalTestBlockFunctions")) + .and_then(serde_json::Value::as_array) + .map(|v| v.iter().filter_map(serde_json::Value::as_str).map(CompactStr::from).collect()) + .unwrap_or_default(); + + Ok(Self { + assert_function_names_jest, + assert_function_names_vitest, + additional_test_block_functions, + }) + } + + pub fn run_on_jest_node<'a, 'c>( + &self, + jest_node: &PossibleJestNode<'a, 'c>, + ctx: &'c LintContext<'a>, + ) { + run(self, jest_node, ctx); + } +} + +fn run<'a>( + rule: &ExpectExpectConfig, + possible_jest_node: &PossibleJestNode<'a, '_>, + ctx: &LintContext<'a>, +) { + let node = possible_jest_node.node; + if let AstKind::CallExpression(call_expr) = node.kind() { + let name = get_node_name(&call_expr.callee); + if is_type_of_jest_fn_call( + call_expr, + possible_jest_node, + ctx, + &[JestFnKind::General(JestGeneralFnKind::Test)], + ) || rule.additional_test_block_functions.contains(&name) + { + if let Some(member_expr) = call_expr.callee.as_member_expression() { + let Some(property_name) = member_expr.static_property_name() else { + return; + }; + if property_name == "todo" { + return; + } + if property_name == "skip" && ctx.frameworks().is_vitest() { + return; + } + } + + let assert_function_names = if ctx.frameworks().is_vitest() { + &rule.assert_function_names_vitest + } else { + &rule.assert_function_names_jest + }; + + let mut visitor = AssertionVisitor::new(ctx, assert_function_names); + + // Visit each argument of the test call + for argument in &call_expr.arguments { + if let Some(expr) = argument.as_expression() { + visitor.check_expression(expr); + if visitor.found_assertion { + return; + } + } + } + + if !visitor.found_assertion { + ctx.diagnostic(expect_expect_diagnostic(call_expr.callee.span())); + } + } + } +} + +struct AssertionVisitor<'a, 'b> { + ctx: &'b LintContext<'a>, + assert_function_names: &'b [CompactStr], + visited: FxHashSet, + found_assertion: bool, +} + +impl<'a, 'b> AssertionVisitor<'a, 'b> { + fn new(ctx: &'b LintContext<'a>, assert_function_names: &'b [CompactStr]) -> Self { + Self { ctx, assert_function_names, visited: FxHashSet::default(), found_assertion: false } + } + + fn check_expression(&mut self, expr: &Expression<'a>) { + // Avoid infinite loops by tracking visited expressions + if !self.visited.insert(expr.span()) { + return; + } + + match expr { + Expression::FunctionExpression(fn_expr) => { + if let Some(body) = &fn_expr.body { + self.visit_function_body(body); + } + } + Expression::ArrowFunctionExpression(arrow_expr) => { + self.visit_function_body(&arrow_expr.body); + } + Expression::CallExpression(call_expr) => { + self.visit_call_expression(call_expr); + } + Expression::Identifier(ident) => { + self.check_identifier(ident); + } + Expression::AwaitExpression(expr) => { + self.check_expression(&expr.argument); + } + Expression::ArrayExpression(array_expr) => { + for element in &array_expr.elements { + if let Some(element_expr) = element.as_expression() { + self.check_expression(element_expr); + if self.found_assertion { + return; + } + } + } + } + _ => {} + } + } + + fn check_identifier(&mut self, ident: &oxc_ast::ast::IdentifierReference<'a>) { + let Some(node) = get_declaration_of_variable(ident, self.ctx) else { + return; + }; + let AstKind::Function(function) = node.kind() else { + return; + }; + if let Some(body) = &function.body { + self.visit_function_body(body); + } + } +} + +impl<'a> Visit<'a> for AssertionVisitor<'a, '_> { + fn visit_call_expression(&mut self, call_expr: &CallExpression<'a>) { + let name = get_node_name(&call_expr.callee); + if matches_assert_function_name(&name, self.assert_function_names) { + self.found_assertion = true; + return; + } + + for argument in &call_expr.arguments { + if let Some(expr) = argument.as_expression() { + self.check_expression(expr); + if self.found_assertion { + return; + } + } + } + + walk::walk_call_expression(self, call_expr); + } + + fn visit_expression_statement(&mut self, stmt: &oxc_ast::ast::ExpressionStatement<'a>) { + self.check_expression(&stmt.expression); + if !self.found_assertion { + walk::walk_expression_statement(self, stmt); + } + } + + fn visit_block_statement(&mut self, block: &oxc_ast::ast::BlockStatement<'a>) { + for stmt in &block.body { + self.visit_statement(stmt); + if self.found_assertion { + return; + } + } + } + + fn visit_if_statement(&mut self, if_stmt: &oxc_ast::ast::IfStatement<'a>) { + if let Statement::BlockStatement(block_stmt) = &if_stmt.consequent { + self.visit_block_statement(block_stmt); + } + if self.found_assertion { + return; + } + if let Some(alternate) = &if_stmt.alternate { + self.visit_statement(alternate); + } + } + + fn visit_function(&mut self, _func: &Function<'a>, _flags: ScopeFlags) {} + + fn visit_formal_parameter(&mut self, _param: &FormalParameter<'a>) {} +} + +/// Checks if node names returned by getNodeName matches any of the given star patterns +fn matches_assert_function_name(name: &str, patterns: &[CompactStr]) -> bool { + patterns.iter().any(|pattern| Regex::new(pattern).unwrap().is_match(name)) +} + +fn convert_pattern(pattern: &str) -> CompactStr { + // Pre-process pattern, e.g. + // request.*.expect -> request.[a-z\\d]*.expect + // request.**.expect -> request.[a-z\\d\\.]*.expect + // request.**.expect* -> request.[a-z\\d\\.]*.expect[a-z\\d]* + let pattern = pattern + .split('.') + .map(|p| { + if p == "**" { + CompactStr::from("[a-z\\d\\.]*") + } else { + p.cow_replace('*', "[a-z\\d]*").into() + } + }) + .collect::>() + .join("\\."); + + // 'a.b.c' -> /^a\.b\.c(\.|$)/iu + format!("(?ui)^{pattern}(\\.|$)").into() +} diff --git a/crates/oxc_linter/src/rules/vitest/expect_expect.rs b/crates/oxc_linter/src/rules/vitest/expect_expect.rs new file mode 100644 index 0000000000000..52fa36b6b4fa0 --- /dev/null +++ b/crates/oxc_linter/src/rules/vitest/expect_expect.rs @@ -0,0 +1,321 @@ +use oxc_macros::declare_oxc_lint; + +use crate::{ + context::LintContext, + rule::Rule, + rules::shared::expect_expect::{DOCUMENTATION, ExpectExpectConfig}, + utils::PossibleJestNode, +}; + +#[derive(Debug, Default, Clone)] +pub struct ExpectExpect(Box); + +declare_oxc_lint!( + ExpectExpect, + vitest, + correctness, + config = ExpectExpectConfig, + docs = DOCUMENTATION +); + +impl Rule for ExpectExpect { + fn from_configuration(value: serde_json::Value) -> Result { + ExpectExpectConfig::from_configuration(&value).map(|config| Self(Box::new(config))) + } + + fn run_on_jest_node<'a, 'c>( + &self, + jest_node: &PossibleJestNode<'a, 'c>, + ctx: &'c LintContext<'a>, + ) { + self.0.run_on_jest_node(jest_node, ctx); + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + ( + " + import { test } from 'vitest'; + test.skip(\"skipped test\", () => {}) + ", + None, + ), + ("it.todo(\"will test something eventually\")", None), + ("test.todo(\"will test something eventually\")", None), + ("['x']();", None), + ("it(\"should pass\", () => expect(true).toBeDefined())", None), + ("test(\"should pass\", () => expect(true).toBeDefined())", None), + ("it(\"should pass\", () => somePromise().then(() => expect(true).toBeDefined()))", None), + ("it(\"should pass\", myTest); function myTest() { expect(true).toBeDefined() }", None), + ( + " + test('should pass', () => { + expect(true).toBeDefined(); + foo(true).toBe(true); + }); + ", + Some(serde_json::json!([{ "assertFunctionNames": ["expect", "foo"] }])) + ), + ( + " + import { bench } from 'vitest' + + bench('normal sorting', () => { + const x = [1, 5, 4, 2, 3] + x.sort((a, b) => { + return a - b + }) + }, { time: 1000 }) + ", + None, + ), + ( + "it(\"should return undefined\", () => expectSaga(mySaga).returns());", + Some(serde_json::json!([{ "assertFunctionNames": ["expectSaga"] }])), + ), + ( + "test('verifies expect method call', () => expect$(123));", + Some(serde_json::json!([{ "assertFunctionNames": ["expect\\$"] }])), + ), + ( + "test('verifies expect method call', () => new Foo().expect(123));", + Some(serde_json::json!([{ "assertFunctionNames": ["Foo.expect"] }])), + ), + ( + " + test('verifies deep expect method call', () => { + tester.foo().expect(123); + }); + ", + Some(serde_json::json!([{ "assertFunctionNames": ["tester.foo.expect"] }])), + ), + ( + " + test('verifies chained expect method call', () => { + tester + .foo() + .bar() + .expect(456); + }); + ", + Some(serde_json::json!([{ "assertFunctionNames": ["tester.foo.bar.expect"] }])), + ), + ( + " + test(\"verifies the function call\", () => { + td.verify(someFunctionCall()) + }) + ", + Some(serde_json::json!([{ "assertFunctionNames": ["td.verify"] }])), + ), + ( + "it(\"should pass\", () => expect(true).toBeDefined())", + Some(serde_json::json!([{ + "assertFunctionNames": "undefined", + "additionalTestBlockFunctions": "undefined", + }])), + ), + ( + " + theoretically('the number {input} is correctly translated to string', theories, theory => { + const output = NumberToLongString(theory.input); + expect(output).toBe(theory.expected); + }) + ", + Some(serde_json::json!([{ "additionalTestBlockFunctions": ["theoretically"] }])), + ), + ( + "test('should pass *', () => expect404ToBeLoaded());", + Some(serde_json::json!([{ "assertFunctionNames": ["expect*"] }])), + ), + ( + "test('should pass *', () => expect.toHaveStatus404());", + Some(serde_json::json!([{ "assertFunctionNames": ["expect.**"] }])), + ), + ( + "test('should pass', () => tester.foo().expect(123));", + Some(serde_json::json!([{ "assertFunctionNames": ["tester.*.expect"] }])), + ), + ( + "test('should pass **', () => tester.foo().expect(123));", + Some(serde_json::json!([{ "assertFunctionNames": ["**"] }])), + ), + ( + "test('should pass *', () => tester.foo().expect(123));", + Some(serde_json::json!([{ "assertFunctionNames": ["*"] }])), + ), + ( + "test('should pass', () => tester.foo().expect(123));", + Some(serde_json::json!([{ "assertFunctionNames": ["tester.**"] }])), + ), + ( + "test('should pass', () => tester.foo().expect(123));", + Some(serde_json::json!([{ "assertFunctionNames": ["tester.*"] }])), + ), + ( + "test('should pass', () => tester.foo().bar().expectIt(456));", + Some(serde_json::json!([{ "assertFunctionNames": ["tester.**.expect*"] }])), + ), + ( + "test('should pass', () => request.get().foo().expect(456));", + Some(serde_json::json!([{ "assertFunctionNames": ["request.**.expect"] }])), + ), + ( + "test('should pass', () => request.get().foo().expect(456));", + Some(serde_json::json!([{ "assertFunctionNames": ["request.**.e*e*t"] }])), + ), + ( + " + import { test } from 'vitest'; + + test('should pass', () => { + expect(true).toBeDefined(); + foo(true).toBe(true); + }); + ", + Some(serde_json::json!([{ "assertFunctionNames": ["expect", "foo"] }])), + ), + ( + " + import { test as checkThat } from 'vitest'; + + checkThat('this passes', () => { + expect(true).toBeDefined(); + foo(true).toBe(true); + }); + ", + Some(serde_json::json!([{ "assertFunctionNames": ["expect", "foo"] }])), + ), + ( + " + const { test } = require('vitest'); + + test('verifies chained expect method call', () => { + tester + .foo() + .bar() + .expect(456); + }); + ", + Some(serde_json::json!([{ "assertFunctionNames": ["tester.foo.bar.expect"] }])), + ), + ( + " + it(\"should pass with 'typecheck' enabled\", () => { + expectTypeOf({ a: 1 }).toEqualTypeOf<{ a: number }>() + }); + ", + None + ), + ( + " + import { assert, it } from 'vitest'; + + it('test', () => { + assert.throws(() => { + throw Error('Invalid value'); + }); + }); + ", + Some(serde_json::json!([{ "assertFunctionNames": ["assert"] }])), + ), + ( + " + import { expectTypeOf } from 'vitest' + + expectTypeOf({ a: 1 }).toEqualTypeOf<{ a: number }>() + ", + Some(serde_json::json!([{ "assertFunctionNames": ["expectTypeOf"] }])), + ), + ( + " + import { assertType } from 'vitest' + + function concat(a: string, b: string): string + function concat(a: number, b: number): number + function concat(a: string | number, b: string | number): string | number + + assertType(concat('a', 'b')) + assertType(concat(1, 2)) + // @ts-expect-error wrong types + assertType(concat('a', 2)) + ", + Some(serde_json::json!([{ "assertFunctionNames": ["assertType"] }])), + ), + ]; + + let fail = vec![ + ("it(\"should fail\", () => {});", None), + ("it(\"should fail\", myTest); function myTest() {}", None), + ("test(\"should fail\", () => {});", None), + ( + "afterEach(() => {});", + Some(serde_json::json!([{ "additionalTestBlockFunctions": ["afterEach"] }])), + ), + // Todo: currently it's not support + // ( + // " + // theoretically('the number {input} is correctly translated to string', theories, theory => { + // const output = NumberToLongString(theory.input); + // }) + // ", + // Some(serde_json::json!([{ "additionalTestBlockFunctions": ["theoretically"] }])), + // ), + ("it(\"should fail\", () => { somePromise.then(() => {}); });", None), + ( + "test(\"should fail\", () => { foo(true).toBe(true); })", + Some(serde_json::json!([{ "assertFunctionNames": ["expect"] }])), + ), + ( + "it(\"should also fail\",() => expectSaga(mySaga).returns());", + Some(serde_json::json!([{ "assertFunctionNames": ["expect"] }])), + ), + ( + "test('should fail', () => request.get().foo().expect(456));", + Some(serde_json::json!([{ "assertFunctionNames": ["request.*.expect"] }])), + ), + ( + "test('should fail', () => request.get().foo().bar().expect(456));", + Some(serde_json::json!([{ "assertFunctionNames": ["request.foo**.expect"] }])), + ), + ( + "test('should fail', () => tester.request(123));", + Some(serde_json::json!([{ "assertFunctionNames": ["request.*"] }])), + ), + ( + "test('should fail', () => request(123));", + Some(serde_json::json!([{ "assertFunctionNames": ["request.*"] }])), + ), + ( + "test('should fail', () => request(123));", + Some(serde_json::json!([{ "assertFunctionNames": ["request.**"] }])), + ), + ( + " + import { test as checkThat } from 'vitest'; + + checkThat('this passes', () => { + // ... + }); + ", + Some(serde_json::json!([{ "assertFunctionNames": ["expect", "foo"] }])), + ), + // Todo: currently we couldn't support ignore the typecheck option. + // ( + // " + // it(\"should fail without 'typecheck' enabled\", () => { + // expectTypeOf({ a: 1 }).toEqualTypeOf<{ a: number }>() + // }); + // ", + // None, + // ), + ]; + + Tester::new(ExpectExpect::NAME, ExpectExpect::PLUGIN, pass, fail) + .with_vitest_plugin(true) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/jest_expect_expect.snap b/crates/oxc_linter/src/snapshots/jest_expect_expect.snap index 6545955341c05..4f81097cbbab7 100644 --- a/crates/oxc_linter/src/snapshots/jest_expect_expect.snap +++ b/crates/oxc_linter/src/snapshots/jest_expect_expect.snap @@ -128,96 +128,3 @@ source: crates/oxc_linter/src/tester.rs 3 │ t.test("emitter with newListener that removes handler", function(t) { ╰──── help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:1:1] - 1 │ it("should fail", () => {}); - · ── - ╰──── - help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:1:1] - 1 │ it("should fail", myTest); function myTest() {} - · ── - ╰──── - help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:1:1] - 1 │ test("should fail", () => {}); - · ──── - ╰──── - help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:1:1] - 1 │ afterEach(() => {}); - · ───────── - ╰──── - help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:1:1] - 1 │ it("should fail", () => { somePromise.then(() => {}); }); - · ── - ╰──── - help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:1:1] - 1 │ test("should fail", () => { foo(true).toBe(true); }) - · ──── - ╰──── - help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:1:1] - 1 │ it("should also fail",() => expectSaga(mySaga).returns()); - · ── - ╰──── - help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:1:1] - 1 │ test('should fail', () => request.get().foo().expect(456)); - · ──── - ╰──── - help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:1:1] - 1 │ test('should fail', () => request.get().foo().bar().expect(456)); - · ──── - ╰──── - help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:1:1] - 1 │ test('should fail', () => tester.request(123)); - · ──── - ╰──── - help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:1:1] - 1 │ test('should fail', () => request(123)); - · ──── - ╰──── - help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:1:1] - 1 │ test('should fail', () => request(123)); - · ──── - ╰──── - help: Add assertion(s) in this Test - - ⚠ eslint-plugin-jest(expect-expect): Test has no assertions - ╭─[expect_expect.tsx:4:17] - 3 │ - 4 │ checkThat('this passes', () => { - · ───────── - 5 │ // ... - ╰──── - help: Add assertion(s) in this Test diff --git a/crates/oxc_linter/src/snapshots/vitest_expect_expect.snap b/crates/oxc_linter/src/snapshots/vitest_expect_expect.snap new file mode 100644 index 0000000000000..1945a1a18aaf1 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/vitest_expect_expect.snap @@ -0,0 +1,95 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:1:1] + 1 │ it("should fail", () => {}); + · ── + ╰──── + help: Add assertion(s) in this Test + + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:1:1] + 1 │ it("should fail", myTest); function myTest() {} + · ── + ╰──── + help: Add assertion(s) in this Test + + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:1:1] + 1 │ test("should fail", () => {}); + · ──── + ╰──── + help: Add assertion(s) in this Test + + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:1:1] + 1 │ afterEach(() => {}); + · ───────── + ╰──── + help: Add assertion(s) in this Test + + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:1:1] + 1 │ it("should fail", () => { somePromise.then(() => {}); }); + · ── + ╰──── + help: Add assertion(s) in this Test + + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:1:1] + 1 │ test("should fail", () => { foo(true).toBe(true); }) + · ──── + ╰──── + help: Add assertion(s) in this Test + + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:1:1] + 1 │ it("should also fail",() => expectSaga(mySaga).returns()); + · ── + ╰──── + help: Add assertion(s) in this Test + + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:1:1] + 1 │ test('should fail', () => request.get().foo().expect(456)); + · ──── + ╰──── + help: Add assertion(s) in this Test + + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:1:1] + 1 │ test('should fail', () => request.get().foo().bar().expect(456)); + · ──── + ╰──── + help: Add assertion(s) in this Test + + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:1:1] + 1 │ test('should fail', () => tester.request(123)); + · ──── + ╰──── + help: Add assertion(s) in this Test + + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:1:1] + 1 │ test('should fail', () => request(123)); + · ──── + ╰──── + help: Add assertion(s) in this Test + + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:1:1] + 1 │ test('should fail', () => request(123)); + · ──── + ╰──── + help: Add assertion(s) in this Test + + ⚠ eslint-plugin-vitest(expect-expect): Test has no assertions + ╭─[expect_expect.tsx:4:17] + 3 │ + 4 │ checkThat('this passes', () => { + · ───────── + 5 │ // ... + ╰──── + help: Add assertion(s) in this Test diff --git a/crates/oxc_linter/src/utils/mod.rs b/crates/oxc_linter/src/utils/mod.rs index b581c483579a2..32bb05eb76ad3 100644 --- a/crates/oxc_linter/src/utils/mod.rs +++ b/crates/oxc_linter/src/utils/mod.rs @@ -37,8 +37,7 @@ pub use self::{ // the crates/oxc_linter/data/vitest_compatible_jest_rules.json // file is also updated. The JSON file is used by the oxlint-migrate // and eslint-plugin-oxlint repos to keep everything synced. -const VITEST_COMPATIBLE_JEST_RULES: [&str; 42] = [ - "expect-expect", +const VITEST_COMPATIBLE_JEST_RULES: [&str; 41] = [ "max-expects", "max-nested-describe", "no-alias-methods",