From d4faccefd880bd844cc707c592857879fcdc108d Mon Sep 17 00:00:00 2001 From: Sysix Date: Fri, 23 Jan 2026 19:34:48 +0100 Subject: [PATCH] refactor(linter): move `valid-title` rule to `shared` and crate own rule for `vitest` --- .../cli/overrides_with_plugin/.oxlintrc.json | 2 +- ..._issue_10394_-c .oxlintrc.json@oxlint.snap | 10 +- ..._with_plugin_-c .oxlintrc.json@oxlint.snap | 19 +- .../src/generated/rule_runner_impls.rs | 5 + crates/oxc_linter/src/generated/rules_enum.rs | 26 +- crates/oxc_linter/src/rules.rs | 5 + .../oxc_linter/src/rules/jest/valid_title.rs | 463 +----------- .../src/rules/shared/valid_title.rs | 466 ++++++++++++ .../src/rules/vitest/valid_title.rs | 706 ++++++++++++++++++ .../src/snapshots/vitest_valid_title.snap | 583 +++++++++++++++ tasks/linter_codegen/src/rules.rs | 1 + 11 files changed, 1825 insertions(+), 461 deletions(-) create mode 100644 crates/oxc_linter/src/rules/shared/valid_title.rs create mode 100644 crates/oxc_linter/src/rules/vitest/valid_title.rs create mode 100644 crates/oxc_linter/src/snapshots/vitest_valid_title.snap diff --git a/apps/oxlint/fixtures/cli/overrides_with_plugin/.oxlintrc.json b/apps/oxlint/fixtures/cli/overrides_with_plugin/.oxlintrc.json index febf7a7f2bbe2..1fe14ed671c85 100644 --- a/apps/oxlint/fixtures/cli/overrides_with_plugin/.oxlintrc.json +++ b/apps/oxlint/fixtures/cli/overrides_with_plugin/.oxlintrc.json @@ -5,9 +5,9 @@ "plugins": ["jest", "vitest"], "rules": { "jest/valid-title": "deny", + "vitest/valid-title": "deny", "no-unused-vars": "off" } } ] } - \ No newline at end of file diff --git a/apps/oxlint/src/snapshots/fixtures__cli__issue_10394_-c .oxlintrc.json@oxlint.snap b/apps/oxlint/src/snapshots/fixtures__cli__issue_10394_-c .oxlintrc.json@oxlint.snap index 19902f5a75ca5..670c4a79c8ae2 100644 --- a/apps/oxlint/src/snapshots/fixtures__cli__issue_10394_-c .oxlintrc.json@oxlint.snap +++ b/apps/oxlint/src/snapshots/fixtures__cli__issue_10394_-c .oxlintrc.json@oxlint.snap @@ -14,7 +14,15 @@ working directory: fixtures/cli/issue_10394 `---- help: Write a meaningful title for your test -Found 1 warning and 0 errors. + ! eslint-plugin-vitest(valid-title): Should not have an empty title + ,-[foo.test.ts:1:10] + 1 | describe("", () => { + : ^^ + 2 | // + `---- + help: Write a meaningful title for your test + +Found 2 warnings and 0 errors. Finished in ms on 1 file with 93 rules using 1 threads. ---------- CLI result: LintSucceeded 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 402ddd740246e..835cae1434de0 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 @@ -14,6 +14,14 @@ working directory: fixtures/cli/overrides_with_plugin `---- help: Write a meaningful title for your test + x eslint-plugin-vitest(valid-title): Should not have an empty title + ,-[index.test.ts:1:10] + 1 | describe("", () => { + : ^^ + 2 | // ^ jest/no-valid-title error as explicitly set in the `.test.ts` override + `---- + help: Write a meaningful title for your test + ! eslint-plugin-jest(expect-expect): Test has no assertions ,-[index.test.ts:4:3] 3 | @@ -32,6 +40,15 @@ working directory: fixtures/cli/overrides_with_plugin `---- help: Write a meaningful title for your test + x eslint-plugin-vitest(valid-title): Should not have an empty title + ,-[index.test.ts:4:6] + 3 | + 4 | it("", () => {}); + : ^^ + 5 | // ^ jest/no-valid-title error as explicitly set in the `.test.ts` override + `---- + help: Write a meaningful title for your test + ! eslint(no-unused-vars): Variable 'foo' is declared but never used. Unused variables should start with a '_'. ,-[index.ts:1:7] 1 | const foo = 123; @@ -41,7 +58,7 @@ working directory: fixtures/cli/overrides_with_plugin `---- help: Consider removing this declaration. -Found 2 warnings and 2 errors. +Found 2 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/src/generated/rule_runner_impls.rs b/crates/oxc_linter/src/generated/rule_runner_impls.rs index 177cdf1aae391..4809ca2b483f9 100644 --- a/crates/oxc_linter/src/generated/rule_runner_impls.rs +++ b/crates/oxc_linter/src/generated/rule_runner_impls.rs @@ -4299,6 +4299,11 @@ impl RuleRunner for crate::rules::vitest::require_local_test_context_for_concurr const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnJestNode; } +impl RuleRunner for crate::rules::vitest::valid_title::ValidTitle { + const NODE_TYPES: Option<&AstTypesBitset> = None; + const RUN_FUNCTIONS: RuleRunFunctionsImplemented = RuleRunFunctionsImplemented::RunOnJestNode; +} + impl RuleRunner for crate::rules::vitest::warn_todo::WarnTodo { 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 3119531d64a27..c13dc07e17eff 100644 --- a/crates/oxc_linter/src/generated/rules_enum.rs +++ b/crates/oxc_linter/src/generated/rules_enum.rs @@ -689,6 +689,7 @@ pub use crate::rules::vitest::prefer_to_be_falsy::PreferToBeFalsy as VitestPrefe pub use crate::rules::vitest::prefer_to_be_object::PreferToBeObject as VitestPreferToBeObject; pub use crate::rules::vitest::prefer_to_be_truthy::PreferToBeTruthy as VitestPreferToBeTruthy; pub use crate::rules::vitest::require_local_test_context_for_concurrent_snapshots::RequireLocalTestContextForConcurrentSnapshots as VitestRequireLocalTestContextForConcurrentSnapshots; +pub use crate::rules::vitest::valid_title::ValidTitle as VitestValidTitle; pub use crate::rules::vitest::warn_todo::WarnTodo as VitestWarnTodo; pub use crate::rules::vue::define_emits_declaration::DefineEmitsDeclaration as VueDefineEmitsDeclaration; pub use crate::rules::vue::define_props_declaration::DefinePropsDeclaration as VueDefinePropsDeclaration; @@ -1395,6 +1396,7 @@ pub enum RuleEnum { VitestRequireLocalTestContextForConcurrentSnapshots( VitestRequireLocalTestContextForConcurrentSnapshots, ), + VitestValidTitle(VitestValidTitle), VitestWarnTodo(VitestWarnTodo), NodeGlobalRequire(NodeGlobalRequire), NodeHandleCallbackErr(NodeHandleCallbackErr), @@ -2177,8 +2179,9 @@ const VITEST_PREFER_TO_BE_OBJECT_ID: usize = VITEST_PREFER_TO_BE_FALSY_ID + 1usi const VITEST_PREFER_TO_BE_TRUTHY_ID: usize = VITEST_PREFER_TO_BE_OBJECT_ID + 1usize; const VITEST_REQUIRE_LOCAL_TEST_CONTEXT_FOR_CONCURRENT_SNAPSHOTS_ID: usize = VITEST_PREFER_TO_BE_TRUTHY_ID + 1usize; -const VITEST_WARN_TODO_ID: usize = +const VITEST_VALID_TITLE_ID: usize = VITEST_REQUIRE_LOCAL_TEST_CONTEXT_FOR_CONCURRENT_SNAPSHOTS_ID + 1usize; +const VITEST_WARN_TODO_ID: usize = VITEST_VALID_TITLE_ID + 1usize; const NODE_GLOBAL_REQUIRE_ID: usize = VITEST_WARN_TODO_ID + 1usize; const NODE_HANDLE_CALLBACK_ERR_ID: usize = NODE_GLOBAL_REQUIRE_ID + 1usize; const NODE_NO_EXPORTS_ASSIGN_ID: usize = NODE_HANDLE_CALLBACK_ERR_ID + 1usize; @@ -2988,6 +2991,7 @@ impl RuleEnum { Self::VitestRequireLocalTestContextForConcurrentSnapshots(_) => { VITEST_REQUIRE_LOCAL_TEST_CONTEXT_FOR_CONCURRENT_SNAPSHOTS_ID } + Self::VitestValidTitle(_) => VITEST_VALID_TITLE_ID, Self::VitestWarnTodo(_) => VITEST_WARN_TODO_ID, Self::NodeGlobalRequire(_) => NODE_GLOBAL_REQUIRE_ID, Self::NodeHandleCallbackErr(_) => NODE_HANDLE_CALLBACK_ERR_ID, @@ -3787,6 +3791,7 @@ impl RuleEnum { Self::VitestRequireLocalTestContextForConcurrentSnapshots(_) => { VitestRequireLocalTestContextForConcurrentSnapshots::NAME } + Self::VitestValidTitle(_) => VitestValidTitle::NAME, Self::VitestWarnTodo(_) => VitestWarnTodo::NAME, Self::NodeGlobalRequire(_) => NodeGlobalRequire::NAME, Self::NodeHandleCallbackErr(_) => NodeHandleCallbackErr::NAME, @@ -4630,6 +4635,7 @@ impl RuleEnum { Self::VitestRequireLocalTestContextForConcurrentSnapshots(_) => { VitestRequireLocalTestContextForConcurrentSnapshots::CATEGORY } + Self::VitestValidTitle(_) => VitestValidTitle::CATEGORY, Self::VitestWarnTodo(_) => VitestWarnTodo::CATEGORY, Self::NodeGlobalRequire(_) => NodeGlobalRequire::CATEGORY, Self::NodeHandleCallbackErr(_) => NodeHandleCallbackErr::CATEGORY, @@ -5432,6 +5438,7 @@ impl RuleEnum { Self::VitestRequireLocalTestContextForConcurrentSnapshots(_) => { VitestRequireLocalTestContextForConcurrentSnapshots::FIX } + Self::VitestValidTitle(_) => VitestValidTitle::FIX, Self::VitestWarnTodo(_) => VitestWarnTodo::FIX, Self::NodeGlobalRequire(_) => NodeGlobalRequire::FIX, Self::NodeHandleCallbackErr(_) => NodeHandleCallbackErr::FIX, @@ -6428,6 +6435,7 @@ impl RuleEnum { Self::VitestRequireLocalTestContextForConcurrentSnapshots(_) => { VitestRequireLocalTestContextForConcurrentSnapshots::documentation() } + Self::VitestValidTitle(_) => VitestValidTitle::documentation(), Self::VitestWarnTodo(_) => VitestWarnTodo::documentation(), Self::NodeGlobalRequire(_) => NodeGlobalRequire::documentation(), Self::NodeHandleCallbackErr(_) => NodeHandleCallbackErr::documentation(), @@ -8376,6 +8384,8 @@ impl RuleEnum { VitestRequireLocalTestContextForConcurrentSnapshots::schema(generator) }) } + Self::VitestValidTitle(_) => VitestValidTitle::config_schema(generator) + .or_else(|| VitestValidTitle::schema(generator)), Self::VitestWarnTodo(_) => VitestWarnTodo::config_schema(generator) .or_else(|| VitestWarnTodo::schema(generator)), Self::NodeGlobalRequire(_) => NodeGlobalRequire::config_schema(generator) @@ -9120,6 +9130,7 @@ impl RuleEnum { Self::VitestPreferToBeObject(_) => "vitest", Self::VitestPreferToBeTruthy(_) => "vitest", Self::VitestRequireLocalTestContextForConcurrentSnapshots(_) => "vitest", + Self::VitestValidTitle(_) => "vitest", Self::VitestWarnTodo(_) => "vitest", Self::NodeGlobalRequire(_) => "node", Self::NodeHandleCallbackErr(_) => "node", @@ -11320,6 +11331,9 @@ impl RuleEnum { VitestRequireLocalTestContextForConcurrentSnapshots::from_configuration(value)?, )) } + Self::VitestValidTitle(_) => { + Ok(Self::VitestValidTitle(VitestValidTitle::from_configuration(value)?)) + } Self::VitestWarnTodo(_) => { Ok(Self::VitestWarnTodo(VitestWarnTodo::from_configuration(value)?)) } @@ -12075,6 +12089,7 @@ impl RuleEnum { Self::VitestRequireLocalTestContextForConcurrentSnapshots(rule) => { rule.to_configuration() } + Self::VitestValidTitle(rule) => rule.to_configuration(), Self::VitestWarnTodo(rule) => rule.to_configuration(), Self::NodeGlobalRequire(rule) => rule.to_configuration(), Self::NodeHandleCallbackErr(rule) => rule.to_configuration(), @@ -12778,6 +12793,7 @@ impl RuleEnum { Self::VitestPreferToBeObject(rule) => rule.run(node, ctx), Self::VitestPreferToBeTruthy(rule) => rule.run(node, ctx), Self::VitestRequireLocalTestContextForConcurrentSnapshots(rule) => rule.run(node, ctx), + Self::VitestValidTitle(rule) => rule.run(node, ctx), Self::VitestWarnTodo(rule) => rule.run(node, ctx), Self::NodeGlobalRequire(rule) => rule.run(node, ctx), Self::NodeHandleCallbackErr(rule) => rule.run(node, ctx), @@ -13481,6 +13497,7 @@ impl RuleEnum { Self::VitestPreferToBeObject(rule) => rule.run_once(ctx), Self::VitestPreferToBeTruthy(rule) => rule.run_once(ctx), Self::VitestRequireLocalTestContextForConcurrentSnapshots(rule) => rule.run_once(ctx), + Self::VitestValidTitle(rule) => rule.run_once(ctx), Self::VitestWarnTodo(rule) => rule.run_once(ctx), Self::NodeGlobalRequire(rule) => rule.run_once(ctx), Self::NodeHandleCallbackErr(rule) => rule.run_once(ctx), @@ -14284,6 +14301,7 @@ impl RuleEnum { Self::VitestRequireLocalTestContextForConcurrentSnapshots(rule) => { rule.run_on_jest_node(jest_node, ctx) } + Self::VitestValidTitle(rule) => rule.run_on_jest_node(jest_node, ctx), Self::VitestWarnTodo(rule) => rule.run_on_jest_node(jest_node, ctx), Self::NodeGlobalRequire(rule) => rule.run_on_jest_node(jest_node, ctx), Self::NodeHandleCallbackErr(rule) => rule.run_on_jest_node(jest_node, ctx), @@ -14987,6 +15005,7 @@ impl RuleEnum { Self::VitestPreferToBeObject(rule) => rule.should_run(ctx), Self::VitestPreferToBeTruthy(rule) => rule.should_run(ctx), Self::VitestRequireLocalTestContextForConcurrentSnapshots(rule) => rule.should_run(ctx), + Self::VitestValidTitle(rule) => rule.should_run(ctx), Self::VitestWarnTodo(rule) => rule.should_run(ctx), Self::NodeGlobalRequire(rule) => rule.should_run(ctx), Self::NodeHandleCallbackErr(rule) => rule.should_run(ctx), @@ -15982,6 +16001,7 @@ impl RuleEnum { Self::VitestRequireLocalTestContextForConcurrentSnapshots(_) => { VitestRequireLocalTestContextForConcurrentSnapshots::IS_TSGOLINT_RULE } + Self::VitestValidTitle(_) => VitestValidTitle::IS_TSGOLINT_RULE, Self::VitestWarnTodo(_) => VitestWarnTodo::IS_TSGOLINT_RULE, Self::NodeGlobalRequire(_) => NodeGlobalRequire::IS_TSGOLINT_RULE, Self::NodeHandleCallbackErr(_) => NodeHandleCallbackErr::IS_TSGOLINT_RULE, @@ -16854,6 +16874,7 @@ impl RuleEnum { Self::VitestRequireLocalTestContextForConcurrentSnapshots(_) => { VitestRequireLocalTestContextForConcurrentSnapshots::HAS_CONFIG } + Self::VitestValidTitle(_) => VitestValidTitle::HAS_CONFIG, Self::VitestWarnTodo(_) => VitestWarnTodo::HAS_CONFIG, Self::NodeGlobalRequire(_) => NodeGlobalRequire::HAS_CONFIG, Self::NodeHandleCallbackErr(_) => NodeHandleCallbackErr::HAS_CONFIG, @@ -17559,6 +17580,7 @@ impl RuleEnum { Self::VitestPreferToBeObject(rule) => rule.types_info(), Self::VitestPreferToBeTruthy(rule) => rule.types_info(), Self::VitestRequireLocalTestContextForConcurrentSnapshots(rule) => rule.types_info(), + Self::VitestValidTitle(rule) => rule.types_info(), Self::VitestWarnTodo(rule) => rule.types_info(), Self::NodeGlobalRequire(rule) => rule.types_info(), Self::NodeHandleCallbackErr(rule) => rule.types_info(), @@ -18262,6 +18284,7 @@ impl RuleEnum { Self::VitestPreferToBeObject(rule) => rule.run_info(), Self::VitestPreferToBeTruthy(rule) => rule.run_info(), Self::VitestRequireLocalTestContextForConcurrentSnapshots(rule) => rule.run_info(), + Self::VitestValidTitle(rule) => rule.run_info(), Self::VitestWarnTodo(rule) => rule.run_info(), Self::NodeGlobalRequire(rule) => rule.run_info(), Self::NodeHandleCallbackErr(rule) => rule.run_info(), @@ -19083,6 +19106,7 @@ pub static RULES: std::sync::LazyLock> = std::sync::LazyLock::new( RuleEnum::VitestRequireLocalTestContextForConcurrentSnapshots( VitestRequireLocalTestContextForConcurrentSnapshots::default(), ), + RuleEnum::VitestValidTitle(VitestValidTitle::default()), RuleEnum::VitestWarnTodo(VitestWarnTodo::default()), RuleEnum::NodeGlobalRequire(NodeGlobalRequire::default()), RuleEnum::NodeHandleCallbackErr(NodeHandleCallbackErr::default()), diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 0299d8631cda9..8dad91d025bbc 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -717,6 +717,7 @@ pub(crate) mod vitest { pub mod prefer_to_be_object; pub mod prefer_to_be_truthy; pub mod require_local_test_context_for_concurrent_snapshots; + pub mod valid_title; pub mod warn_todo; } @@ -749,6 +750,10 @@ pub(crate) mod vue { pub mod valid_define_props; } +pub(crate) mod shared { + pub mod valid_title; +} + // Re-export RuleEnum, RULES, and all rule type aliases from generated code pub use crate::generated::rules_enum::*; diff --git a/crates/oxc_linter/src/rules/jest/valid_title.rs b/crates/oxc_linter/src/rules/jest/valid_title.rs index 71bb87c04071e..f14b753f1cd00 100644 --- a/crates/oxc_linter/src/rules/jest/valid_title.rs +++ b/crates/oxc_linter/src/rules/jest/valid_title.rs @@ -1,142 +1,16 @@ -use std::hash::Hash; - -use cow_utils::CowUtils; -use lazy_regex::Regex; -use oxc_ast::{ - AstKind, - ast::{Argument, BinaryExpression, Expression}, -}; -use oxc_diagnostics::OxcDiagnostic; use oxc_macros::declare_oxc_lint; -use oxc_span::{CompactStr, GetSpan, Span}; -use rustc_hash::FxHashMap; use serde_json::Value; use crate::{ context::LintContext, rule::Rule, - utils::{JestFnKind, JestGeneralFnKind, PossibleJestNode, parse_general_jest_fn_call}, + rules::{PossibleJestNode, shared::valid_title as SharedValidTitle}, }; -fn title_must_be_string_diagnostic(span: Span) -> OxcDiagnostic { - OxcDiagnostic::warn("Title must be a string") - .with_help("Replace your title with a string") - .with_label(span) -} - -fn empty_title_diagnostic(span: Span) -> OxcDiagnostic { - OxcDiagnostic::warn("Should not have an empty title") - .with_help("Write a meaningful title for your test") - .with_label(span) -} - -fn duplicate_prefix_diagnostic(span: Span) -> OxcDiagnostic { - OxcDiagnostic::warn("Should not have duplicate prefix") - .with_help("The function name already has the prefix, try to remove the duplicate prefix") - .with_label(span) -} - -fn accidental_space_diagnostic(span: Span) -> OxcDiagnostic { - OxcDiagnostic::warn("Should not have leading or trailing spaces") - .with_help("Remove the leading or trailing spaces") - .with_label(span) -} - -fn disallowed_word_diagnostic(word: &str, span: Span) -> OxcDiagnostic { - OxcDiagnostic::warn(format!("{word} is not allowed in test title")) - .with_help("It is included in the `disallowedWords` of your config file, try to remove it from your title") - .with_label(span) -} - -fn must_match_diagnostic(message: &str, span: Span) -> OxcDiagnostic { - OxcDiagnostic::warn(message.to_string()) - .with_help("Make sure the title matches the `mustMatch` of your config file") - .with_label(span) -} - -fn must_not_match_diagnostic(message: &str, span: Span) -> OxcDiagnostic { - OxcDiagnostic::warn(message.to_string()) - .with_help("Make sure the title does not match the `mustNotMatch` of your config file") - .with_label(span) -} - -#[derive(Debug, Default, Clone)] -pub struct ValidTitle(Box); - #[derive(Debug, Default, Clone)] -pub struct ValidTitleConfig { - /// Whether to ignore the type of the name passed to `test`. - ignore_type_of_test_name: bool, - /// Whether to ignore the type of the name passed to `describe`. - ignore_type_of_describe_name: bool, - /// Whether to allow arguments as titles. - allow_arguments: bool, - /// A list of disallowed words, which will not be allowed in titles. - disallowed_words: Vec, - /// Whether to ignore leading and trailing spaces in titles. - ignore_spaces: bool, - /// Patterns for titles that must not match. - must_not_match_patterns: FxHashMap, - /// Patterns for titles that must be matched for the title to be valid. - must_match_patterns: FxHashMap, -} - -impl std::ops::Deref for ValidTitle { - type Target = ValidTitleConfig; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} +pub struct ValidTitle(Box); declare_oxc_lint!( - /// ### What it does - /// - /// Checks that the titles of Jest and Vitest blocks are valid. - /// - /// Titles must be: - /// - not empty, - /// - strings, - /// - not prefixed with their block name, - /// - have no leading or trailing spaces. - /// - /// ### Why is this bad? - /// - /// Titles that are not valid can be misleading and make it harder to understand the purpose of the test. - /// - /// ### Examples - /// - /// Examples of **incorrect** code for this rule: - /// ```javascript - /// describe('', () => {}); - /// describe('foo', () => { - /// it('', () => {}); - /// }); - /// it('', () => {}); - /// test('', () => {}); - /// xdescribe('', () => {}); - /// xit('', () => {}); - /// xtest('', () => {}); - /// ``` - /// Examples of **correct** code for this rule: - /// ```javascript - /// describe('foo', () => {}); - /// it('bar', () => {}); - /// test('baz', () => {}); - /// ``` - /// - /// ### Options - /// ```typescript - /// interface Options { - /// ignoreSpaces?: boolean; - /// ignoreTypeOfTestName?: boolean; - /// ignoreTypeOfDescribeName?: boolean; - /// allowArguments?: boolean; - /// disallowedWords?: string[]; - /// mustNotMatch?: Partial> | string; - /// mustMatch?: Partial> | string; - /// } - /// ``` ValidTitle, jest, correctness, @@ -144,45 +18,13 @@ declare_oxc_lint!( // TODO: Replace this with an actual config struct. This is a dummy value to // indicate that this rule has configuration and avoid errors. config = Value, + docs = SharedValidTitle::DOCUMENTATION, ); impl Rule for ValidTitle { fn from_configuration(value: serde_json::Value) -> Result { - let config = value.get(0); - let get_as_bool = |name: &str| -> bool { - config - .and_then(|v| v.get(name)) - .and_then(serde_json::Value::as_bool) - .unwrap_or_default() - }; - - let ignore_type_of_test_name = get_as_bool("ignoreTypeOfTestName"); - let ignore_type_of_describe_name = get_as_bool("ignoreTypeOfDescribeName"); - let allow_arguments = get_as_bool("allowArguments"); - let ignore_spaces = get_as_bool("ignoreSpaces"); - let disallowed_words = config - .and_then(|v| v.get("disallowedWords")) - .and_then(|v| v.as_array()) - .map(|v| v.iter().filter_map(|v| v.as_str().map(CompactStr::from)).collect()) - .unwrap_or_default(); - let must_not_match_patterns = config - .and_then(|v| v.get("mustNotMatch")) - .and_then(compile_matcher_patterns) - .unwrap_or_default(); - let must_match_patterns = config - .and_then(|v| v.get("mustMatch")) - .and_then(compile_matcher_patterns) - .unwrap_or_default(); - - Ok(Self(Box::new(ValidTitleConfig { - ignore_type_of_test_name, - ignore_type_of_describe_name, - allow_arguments, - disallowed_words, - ignore_spaces, - must_not_match_patterns, - must_match_patterns, - }))) + SharedValidTitle::ValidTitleConfig::from_configuration(&value) + .map(|config| Self(Box::new(config))) } fn run_on_jest_node<'a, 'c>( @@ -190,300 +32,7 @@ impl Rule for ValidTitle { jest_node: &PossibleJestNode<'a, 'c>, ctx: &'c LintContext<'a>, ) { - self.run(jest_node, ctx); - } -} - -impl ValidTitle { - fn run<'a>(&self, possible_jest_fn_node: &PossibleJestNode<'a, '_>, ctx: &LintContext<'a>) { - let node = possible_jest_fn_node.node; - let AstKind::CallExpression(call_expr) = node.kind() else { - return; - }; - let Some(jest_fn_call) = parse_general_jest_fn_call(call_expr, possible_jest_fn_node, ctx) - else { - return; - }; - - if !matches!( - jest_fn_call.kind, - JestFnKind::General(JestGeneralFnKind::Describe | JestGeneralFnKind::Test) - ) { - return; - } - - // Check if extend keyword has been used (vitest feature) - if let Some(member) = jest_fn_call.members.first() - && member.is_name_equal("extend") - { - return; - } - - let Some(arg) = call_expr.arguments.first() else { - return; - }; - - // Handle typecheck settings - skip for describe when enabled (vitest feature) - if ctx.settings().vitest.typecheck - && matches!(jest_fn_call.kind, JestFnKind::General(JestGeneralFnKind::Describe)) - { - return; - } - - // Handle allowArguments option (vitest feature) - if self.allow_arguments && matches!(arg, Argument::Identifier(_)) { - return; - } - - let need_report_name = match jest_fn_call.kind { - JestFnKind::General(JestGeneralFnKind::Test) => !self.ignore_type_of_test_name, - JestFnKind::General(JestGeneralFnKind::Describe) => !self.ignore_type_of_describe_name, - _ => unreachable!(), - }; - - match arg { - Argument::StringLiteral(string_literal) => { - validate_title( - &string_literal.value, - string_literal.span, - self, - &jest_fn_call.name, - ctx, - ); - } - Argument::TemplateLiteral(template_literal) => { - if let Some(quasi) = template_literal.single_quasi() { - validate_title( - quasi.as_str(), - template_literal.span, - self, - &jest_fn_call.name, - ctx, - ); - } - } - Argument::BinaryExpression(binary_expr) => { - if does_binary_expression_contain_string_node(binary_expr) { - return; - } - if need_report_name { - ctx.diagnostic(title_must_be_string_diagnostic(arg.span())); - } - } - _ => { - if need_report_name { - ctx.diagnostic(title_must_be_string_diagnostic(arg.span())); - } - } - } - } -} - -type CompiledMatcherAndMessage = (Regex, Option); - -#[derive(Debug, Clone, Hash, PartialEq, Eq)] -enum MatchKind { - Describe, - It, - Test, -} - -#[derive(Copy, Clone)] -enum MatcherPattern<'a> { - String(&'a serde_json::Value), - Vec(&'a Vec), -} - -impl MatchKind { - fn from(name: &str) -> Option { - match name { - "describe" => Some(Self::Describe), - "it" => Some(Self::It), - "test" => Some(Self::Test), - _ => None, - } - } -} - -fn compile_matcher_patterns( - matcher_patterns: &serde_json::Value, -) -> Option> { - matcher_patterns - .as_array() - .map_or_else( - || { - // for `{ "describe": "/pattern/" }` - let obj = matcher_patterns.as_object()?; - let mut map: FxHashMap = FxHashMap::default(); - for (key, value) in obj { - let Some(v) = compile_matcher_pattern(MatcherPattern::String(value)) else { - continue; - }; - if let Some(kind) = MatchKind::from(key) { - map.insert(kind, v); - } - } - - Some(map) - }, - |value| { - // for `["/pattern/", "message"]` - let mut map: FxHashMap = FxHashMap::default(); - let v = &compile_matcher_pattern(MatcherPattern::Vec(value))?; - map.insert(MatchKind::Describe, v.clone()); - map.insert(MatchKind::Test, v.clone()); - map.insert(MatchKind::It, v.clone()); - Some(map) - }, - ) - .map_or_else( - || { - // for `"/pattern/"` - let string = matcher_patterns.as_str()?; - let mut map: FxHashMap = FxHashMap::default(); - let v = &compile_matcher_pattern(MatcherPattern::String( - &serde_json::Value::String(string.to_string()), - ))?; - map.insert(MatchKind::Describe, v.clone()); - map.insert(MatchKind::Test, v.clone()); - map.insert(MatchKind::It, v.clone()); - Some(map) - }, - Some, - ) -} - -fn compile_matcher_pattern(pattern: MatcherPattern) -> Option { - match pattern { - MatcherPattern::String(pattern) => { - let pattern_str = pattern.as_str()?; - - // Check for JS regex literal: /pattern/flags - if let Some(stripped) = pattern_str.strip_prefix('/') - && let Some(end) = stripped.rfind('/') - { - let (pat, _flags) = stripped.split_at(end); - // For now, ignore flags and just use the pattern - let regex = Regex::new(pat).ok()?; - return Some((regex, None)); - } - - // Fallback: treat as a normal Rust regex with Unicode support - let reg_str = format!("(?u){pattern_str}"); - let reg = Regex::new(®_str).ok()?; - Some((reg, None)) - } - MatcherPattern::Vec(pattern) => { - let pattern_str = pattern.first().and_then(|v| v.as_str())?; - - // Check for JS regex literal: /pattern/flags - let regex = if let Some(stripped) = pattern_str.strip_prefix('/') - && let Some(end) = stripped.rfind('/') - { - let (pat, _flags) = stripped.split_at(end); - Regex::new(pat).ok()? - } else { - let reg_str = format!("(?u){pattern_str}"); - Regex::new(®_str).ok()? - }; - - let message = pattern.get(1).and_then(serde_json::Value::as_str).map(CompactStr::from); - Some((regex, message)) - } - } -} - -fn validate_title( - title: &str, - span: Span, - valid_title: &ValidTitle, - name: &str, - ctx: &LintContext, -) { - if title.is_empty() { - ctx.diagnostic(empty_title_diagnostic(span)); - return; - } - - if !valid_title.disallowed_words.is_empty() { - let Ok(disallowed_words_reg) = Regex::new(&format!( - r"(?iu)\b(?:{})\b", - valid_title.disallowed_words.join("|").cow_replace('.', r"\.") - )) else { - return; - }; - - if let Some(matched) = disallowed_words_reg.find(title) { - ctx.diagnostic(disallowed_word_diagnostic(matched.as_str(), span)); - } - return; - } - - let trimmed_title = title.trim(); - if !valid_title.ignore_spaces && trimmed_title != title { - ctx.diagnostic_with_fix(accidental_space_diagnostic(span), |fixer| { - let inner_span = span.shrink(1); - let raw_text = fixer.source_range(inner_span); - let trimmed_raw = raw_text.trim().to_string(); - fixer.replace(inner_span, trimmed_raw) - }); - } - - let un_prefixed_name = name.trim_start_matches(['f', 'x']); - let Some(first_word) = title.split(' ').next() else { - return; - }; - - if first_word == un_prefixed_name { - ctx.diagnostic_with_fix(duplicate_prefix_diagnostic(span), |fixer| { - // Use raw source text to preserve escape sequences - let inner_span = span.shrink(1); - let raw_text = fixer.source_range(inner_span); - // Find the first space in raw text to avoid byte offset issues - // if the prefix word ever contains escapable characters - let space_pos = raw_text.find(' ').unwrap_or(raw_text.len()); - let replaced_raw = raw_text[space_pos..].trim().to_string(); - fixer.replace(inner_span, replaced_raw) - }); - return; - } - - let Some(jest_fn_name) = MatchKind::from(un_prefixed_name) else { - return; - }; - - if let Some((regex, message)) = valid_title.must_match_patterns.get(&jest_fn_name) - && !regex.is_match(title) - { - let raw_pattern = regex.as_str(); - let message = match message.as_ref() { - Some(message) => message.as_str(), - None => &format!("{un_prefixed_name} should match {raw_pattern}"), - }; - ctx.diagnostic(must_match_diagnostic(message, span)); - } - - if let Some((regex, message)) = valid_title.must_not_match_patterns.get(&jest_fn_name) - && regex.is_match(title) - { - let raw_pattern = regex.as_str(); - let message = match message.as_ref() { - Some(message) => message.as_str(), - None => &format!("{un_prefixed_name} should not match {raw_pattern}"), - }; - - ctx.diagnostic(must_not_match_diagnostic(message, span)); - } -} - -fn does_binary_expression_contain_string_node(expr: &BinaryExpression) -> bool { - if expr.left.is_string_literal() || expr.right.is_string_literal() { - return true; - } - - match &expr.left { - Expression::BinaryExpression(left) => does_binary_expression_contain_string_node(left), - _ => false, + SharedValidTitle::ValidTitleConfig::run_rule(&self.0, jest_node, ctx); } } diff --git a/crates/oxc_linter/src/rules/shared/valid_title.rs b/crates/oxc_linter/src/rules/shared/valid_title.rs new file mode 100644 index 0000000000000..f9f1bd200d6f6 --- /dev/null +++ b/crates/oxc_linter/src/rules/shared/valid_title.rs @@ -0,0 +1,466 @@ +use std::hash::Hash; + +use cow_utils::CowUtils; +use lazy_regex::Regex; +use oxc_ast::{ + AstKind, + ast::{Argument, BinaryExpression, Expression}, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_span::{CompactStr, GetSpan, Span}; +use rustc_hash::FxHashMap; + +use crate::{ + context::LintContext, + utils::{JestFnKind, JestGeneralFnKind, PossibleJestNode, parse_general_jest_fn_call}, +}; + +fn title_must_be_string_diagnostic(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Title must be a string") + .with_help("Replace your title with a string") + .with_label(span) +} + +fn empty_title_diagnostic(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Should not have an empty title") + .with_help("Write a meaningful title for your test") + .with_label(span) +} + +fn duplicate_prefix_diagnostic(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Should not have duplicate prefix") + .with_help("The function name already has the prefix, try to remove the duplicate prefix") + .with_label(span) +} + +fn accidental_space_diagnostic(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Should not have leading or trailing spaces") + .with_help("Remove the leading or trailing spaces") + .with_label(span) +} + +fn disallowed_word_diagnostic(word: &str, span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn(format!("{word} is not allowed in test title")) + .with_help("It is included in the `disallowedWords` of your config file, try to remove it from your title") + .with_label(span) +} + +pub fn must_match_diagnostic(message: &str, span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn(message.to_string()) + .with_help("Make sure the title matches the `mustMatch` of your config file") + .with_label(span) +} + +pub fn must_not_match_diagnostic(message: &str, span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn(message.to_string()) + .with_help("Make sure the title does not match the `mustNotMatch` of your config file") + .with_label(span) +} + +pub const DOCUMENTATION: &str = r" +### What it does + +Checks that the titles of Jest and Vitest blocks are valid. + +Titles must be: +- not empty, +- strings, +- not prefixed with their block name, +- have no leading or trailing spaces. + +### Why is this bad? + +Titles that are not valid can be misleading and make it harder to understand the purpose of the test. + +### Examples + +Examples of **incorrect** code for this rule: +```javascript +describe('', () => {}); +describe('foo', () => { + it('', () => {}); +}); +it('', () => {}); +test('', () => {}); +xdescribe('', () => {}); +xit('', () => {}); +xtest('', () => {}); +``` +Examples of **correct** code for this rule: +```javascript +describe('foo', () => {}); +it('bar', () => {}); +test('baz', () => {}); +``` + +### Options +```typescript +interface Options { + ignoreSpaces?: boolean; + ignoreTypeOfTestName?: boolean; + ignoreTypeOfDescribeName?: boolean; + allowArguments?: boolean; + disallowedWords?: string[]; + mustNotMatch?: Partial> | string; + mustMatch?: Partial> | string; +} +``` +"; + +#[derive(Debug, Default, Clone)] +pub struct ValidTitleConfig { + /// Whether to ignore the type of the name passed to `test`. + ignore_type_of_test_name: bool, + /// Whether to ignore the type of the name passed to `describe`. + ignore_type_of_describe_name: bool, + /// Whether to allow arguments as titles. + allow_arguments: bool, + /// A list of disallowed words, which will not be allowed in titles. + disallowed_words: Vec, + /// Whether to ignore leading and trailing spaces in titles. + ignore_spaces: bool, + /// Patterns for titles that must not match. + must_not_match_patterns: FxHashMap, + /// Patterns for titles that must be matched for the title to be valid. + must_match_patterns: FxHashMap, +} + +impl ValidTitleConfig { + #[expect(clippy::unnecessary_wraps)] // TODO: handle error + pub fn from_configuration( + value: &serde_json::Value, + ) -> Result { + let config = value.get(0); + let get_as_bool = |name: &str| -> bool { + config + .and_then(|v| v.get(name)) + .and_then(serde_json::Value::as_bool) + .unwrap_or_default() + }; + + let ignore_type_of_test_name = get_as_bool("ignoreTypeOfTestName"); + let ignore_type_of_describe_name = get_as_bool("ignoreTypeOfDescribeName"); + let allow_arguments = get_as_bool("allowArguments"); + let ignore_spaces = get_as_bool("ignoreSpaces"); + let disallowed_words = config + .and_then(|v| v.get("disallowedWords")) + .and_then(|v| v.as_array()) + .map(|v| v.iter().filter_map(|v| v.as_str().map(CompactStr::from)).collect()) + .unwrap_or_default(); + let must_not_match_patterns = config + .and_then(|v| v.get("mustNotMatch")) + .and_then(compile_matcher_patterns) + .unwrap_or_default(); + let must_match_patterns = config + .and_then(|v| v.get("mustMatch")) + .and_then(compile_matcher_patterns) + .unwrap_or_default(); + + Ok(ValidTitleConfig { + ignore_type_of_test_name, + ignore_type_of_describe_name, + allow_arguments, + disallowed_words, + ignore_spaces, + must_not_match_patterns, + must_match_patterns, + }) + } + + pub fn run_rule<'a>( + config: &ValidTitleConfig, + possible_jest_fn_node: &PossibleJestNode<'a, '_>, + ctx: &LintContext<'a>, + ) { + let node = possible_jest_fn_node.node; + let AstKind::CallExpression(call_expr) = node.kind() else { + return; + }; + let Some(jest_fn_call) = parse_general_jest_fn_call(call_expr, possible_jest_fn_node, ctx) + else { + return; + }; + + if !matches!( + jest_fn_call.kind, + JestFnKind::General(JestGeneralFnKind::Describe | JestGeneralFnKind::Test) + ) { + return; + } + + // Check if extend keyword has been used (vitest feature) + if let Some(member) = jest_fn_call.members.first() + && member.is_name_equal("extend") + { + return; + } + + let Some(arg) = call_expr.arguments.first() else { + return; + }; + + // Handle typecheck settings - skip for describe when enabled (vitest feature) + if ctx.settings().vitest.typecheck + && matches!(jest_fn_call.kind, JestFnKind::General(JestGeneralFnKind::Describe)) + { + return; + } + + // Handle allowArguments option (vitest feature) + if config.allow_arguments && matches!(arg, Argument::Identifier(_)) { + return; + } + + let need_report_name = match jest_fn_call.kind { + JestFnKind::General(JestGeneralFnKind::Test) => !config.ignore_type_of_test_name, + JestFnKind::General(JestGeneralFnKind::Describe) => { + !config.ignore_type_of_describe_name + } + _ => unreachable!(), + }; + + match arg { + Argument::StringLiteral(string_literal) => { + validate_title( + &string_literal.value, + string_literal.span, + config, + &jest_fn_call.name, + ctx, + ); + } + Argument::TemplateLiteral(template_literal) => { + if let Some(quasi) = template_literal.single_quasi() { + validate_title( + quasi.as_str(), + template_literal.span, + config, + &jest_fn_call.name, + ctx, + ); + } + } + Argument::BinaryExpression(binary_expr) => { + if does_binary_expression_contain_string_node(binary_expr) { + return; + } + if need_report_name { + ctx.diagnostic(title_must_be_string_diagnostic(arg.span())); + } + } + _ => { + if need_report_name { + ctx.diagnostic(title_must_be_string_diagnostic(arg.span())); + } + } + } + } +} + +type CompiledMatcherAndMessage = (Regex, Option); + +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +enum MatchKind { + Describe, + It, + Test, +} + +#[derive(Copy, Clone)] +enum MatcherPattern<'a> { + String(&'a serde_json::Value), + Vec(&'a Vec), +} + +impl MatchKind { + fn from(name: &str) -> Option { + match name { + "describe" => Some(Self::Describe), + "it" => Some(Self::It), + "test" => Some(Self::Test), + _ => None, + } + } +} + +fn compile_matcher_patterns( + matcher_patterns: &serde_json::Value, +) -> Option> { + matcher_patterns + .as_array() + .map_or_else( + || { + // for `{ "describe": "/pattern/" }` + let obj = matcher_patterns.as_object()?; + let mut map: FxHashMap = FxHashMap::default(); + for (key, value) in obj { + let Some(v) = compile_matcher_pattern(MatcherPattern::String(value)) else { + continue; + }; + if let Some(kind) = MatchKind::from(key) { + map.insert(kind, v); + } + } + + Some(map) + }, + |value| { + // for `["/pattern/", "message"]` + let mut map: FxHashMap = FxHashMap::default(); + let v = &compile_matcher_pattern(MatcherPattern::Vec(value))?; + map.insert(MatchKind::Describe, v.clone()); + map.insert(MatchKind::Test, v.clone()); + map.insert(MatchKind::It, v.clone()); + Some(map) + }, + ) + .map_or_else( + || { + // for `"/pattern/"` + let string = matcher_patterns.as_str()?; + let mut map: FxHashMap = FxHashMap::default(); + let v = &compile_matcher_pattern(MatcherPattern::String( + &serde_json::Value::String(string.to_string()), + ))?; + map.insert(MatchKind::Describe, v.clone()); + map.insert(MatchKind::Test, v.clone()); + map.insert(MatchKind::It, v.clone()); + Some(map) + }, + Some, + ) +} + +fn compile_matcher_pattern(pattern: MatcherPattern) -> Option { + match pattern { + MatcherPattern::String(pattern) => { + let pattern_str = pattern.as_str()?; + + // Check for JS regex literal: /pattern/flags + if let Some(stripped) = pattern_str.strip_prefix('/') + && let Some(end) = stripped.rfind('/') + { + let (pat, _flags) = stripped.split_at(end); + // For now, ignore flags and just use the pattern + let regex = Regex::new(pat).ok()?; + return Some((regex, None)); + } + + // Fallback: treat as a normal Rust regex with Unicode support + let reg_str = format!("(?u){pattern_str}"); + let reg = Regex::new(®_str).ok()?; + Some((reg, None)) + } + MatcherPattern::Vec(pattern) => { + let pattern_str = pattern.first().and_then(|v| v.as_str())?; + + // Check for JS regex literal: /pattern/flags + let regex = if let Some(stripped) = pattern_str.strip_prefix('/') + && let Some(end) = stripped.rfind('/') + { + let (pat, _flags) = stripped.split_at(end); + Regex::new(pat).ok()? + } else { + let reg_str = format!("(?u){pattern_str}"); + Regex::new(®_str).ok()? + }; + + let message = pattern.get(1).and_then(serde_json::Value::as_str).map(CompactStr::from); + Some((regex, message)) + } + } +} + +fn validate_title( + title: &str, + span: Span, + config: &ValidTitleConfig, + name: &str, + ctx: &LintContext, +) { + if title.is_empty() { + ctx.diagnostic(empty_title_diagnostic(span)); + return; + } + + if !config.disallowed_words.is_empty() { + let Ok(disallowed_words_reg) = Regex::new(&format!( + r"(?iu)\b(?:{})\b", + config.disallowed_words.join("|").cow_replace('.', r"\.") + )) else { + return; + }; + + if let Some(matched) = disallowed_words_reg.find(title) { + ctx.diagnostic(disallowed_word_diagnostic(matched.as_str(), span)); + } + return; + } + + let trimmed_title = title.trim(); + if !config.ignore_spaces && trimmed_title != title { + ctx.diagnostic_with_fix(accidental_space_diagnostic(span), |fixer| { + let inner_span = span.shrink(1); + let raw_text = fixer.source_range(inner_span); + let trimmed_raw = raw_text.trim().to_string(); + fixer.replace(inner_span, trimmed_raw) + }); + } + + let un_prefixed_name = name.trim_start_matches(['f', 'x']); + let Some(first_word) = title.split(' ').next() else { + return; + }; + + if first_word == un_prefixed_name { + ctx.diagnostic_with_fix(duplicate_prefix_diagnostic(span), |fixer| { + // Use raw source text to preserve escape sequences + let inner_span = span.shrink(1); + let raw_text = fixer.source_range(inner_span); + // Find the first space in raw text to avoid byte offset issues + // if the prefix word ever contains escapable characters + let space_pos = raw_text.find(' ').unwrap_or(raw_text.len()); + let replaced_raw = raw_text[space_pos..].trim().to_string(); + fixer.replace(inner_span, replaced_raw) + }); + return; + } + + let Some(jest_fn_name) = MatchKind::from(un_prefixed_name) else { + return; + }; + + if let Some((regex, message)) = config.must_match_patterns.get(&jest_fn_name) + && !regex.is_match(title) + { + let raw_pattern = regex.as_str(); + let message = match message.as_ref() { + Some(message) => message.as_str(), + None => &format!("{un_prefixed_name} should match {raw_pattern}"), + }; + ctx.diagnostic(must_match_diagnostic(message, span)); + } + + if let Some((regex, message)) = config.must_not_match_patterns.get(&jest_fn_name) + && regex.is_match(title) + { + let raw_pattern = regex.as_str(); + let message = match message.as_ref() { + Some(message) => message.as_str(), + None => &format!("{un_prefixed_name} should not match {raw_pattern}"), + }; + + ctx.diagnostic(must_not_match_diagnostic(message, span)); + } +} + +fn does_binary_expression_contain_string_node(expr: &BinaryExpression) -> bool { + if expr.left.is_string_literal() || expr.right.is_string_literal() { + return true; + } + + match &expr.left { + Expression::BinaryExpression(left) => does_binary_expression_contain_string_node(left), + _ => false, + } +} diff --git a/crates/oxc_linter/src/rules/vitest/valid_title.rs b/crates/oxc_linter/src/rules/vitest/valid_title.rs new file mode 100644 index 0000000000000..f080c3f8e654b --- /dev/null +++ b/crates/oxc_linter/src/rules/vitest/valid_title.rs @@ -0,0 +1,706 @@ +use oxc_macros::declare_oxc_lint; +use serde_json::Value; + +use crate::{ + context::LintContext, + rule::Rule, + rules::{PossibleJestNode, shared::valid_title as SharedValidTitle}, +}; + +#[derive(Debug, Default, Clone)] +pub struct ValidTitle(Box); + +declare_oxc_lint!( + ValidTitle, + vitest, + correctness, + conditional_fix, + // TODO: Replace this with an actual config struct. This is a dummy value to + // indicate that this rule has configuration and avoid errors. + config = Value, + docs = SharedValidTitle::DOCUMENTATION +); + +impl Rule for ValidTitle { + fn from_configuration(value: serde_json::Value) -> Result { + SharedValidTitle::ValidTitleConfig::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>, + ) { + SharedValidTitle::ValidTitleConfig::run_rule(&self.0, jest_node, ctx); + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + ("describe('the correct way to properly handle all the things', () => {});", None), + ("test('that all is as it should be', () => {});", None), + ( + "it('correctly sets the value', () => {});", + Some(serde_json::json!([ + { "ignoreTypeOfDescribeName": false, "disallowedWords": ["correct"] }, + ])), + ), + ("it('correctly sets the value', () => {});", Some(serde_json::json!([]))), + ("describe('the correct way to properly handle all the things', () => {});", None), + ("test('that all is as it should be', () => {});", None), + ( + "it('correctly sets the value', () => {});", + Some(serde_json::json!([{ "mustMatch": {} }])), + ), + ( + "it('correctly sets the value', () => {});", + Some(serde_json::json!([{ "mustMatch": " " }])), + ), + ( + "it('correctly sets the value', () => {});", + Some(serde_json::json!([{ "mustMatch": [" "] }])), + ), + ( + "it('correctly sets the value #unit', () => {});", + Some(serde_json::json!([{ "mustMatch": "#(?:unit|integration|e2e)" }])), + ), + ( + "it('correctly sets the value', () => {});", + Some(serde_json::json!([{ "mustMatch": "^[^#]+$|(?:#(?:unit|e2e))" }])), + ), + ( + "it('correctly sets the value', () => {});", + Some(serde_json::json!([{ "mustMatch": { "test": "#(?:unit|integration|e2e)" } }])), + ), + ( + " + describe('things to test', () => { + describe('unit tests #unit', () => { + it('is true', () => { + expect(true).toBe(true); + }); + }); + + describe('e2e tests #e2e', () => { + it('is another test #jest4life', () => {}); + }); + }); + ", + Some(serde_json::json!([{ "mustMatch": { "test": "^[^#]+$|(?:#(?:unit|e2e))" } }])), + ), + ("it('is a string', () => {});", None), + ("it('is' + ' a ' + ' string', () => {});", None), + ("it(1 + ' + ' + 1, () => {});", None), + ("test('is a string', () => {});", None), + ("xtest('is a string', () => {});", None), + ("xtest(`${myFunc} is a string`, () => {});", None), + ("describe('is a string', () => {});", None), + ("describe.skip('is a string', () => {});", None), + ("describe.skip(`${myFunc} is a string`, () => {});", None), + ("fdescribe('is a string', () => {});", None), + ( + "describe(String(/.+/), () => {});", + Some(serde_json::json!([{ "ignoreTypeOfDescribeName": true }])), + ), + ( + "describe(myFunction, () => {});", + Some(serde_json::json!([{ "ignoreTypeOfDescribeName": true }])), + ), + ( + "xdescribe(skipFunction, () => {});", + Some(serde_json::json!([{ "ignoreTypeOfDescribeName": true, "disallowedWords": [] }])), + ), + ("describe()", None), + ("someFn('', function () {})", None), + ("describe('foo', function () {})", None), + ("describe('foo', function () { it('bar', function () {}) })", None), + ("test('foo', function () {})", None), + ("test.concurrent('foo', function () {})", None), + ("test(`foo`, function () {})", None), + ("test.concurrent(`foo`, function () {})", None), + ("test(`${foo}`, function () {})", None), + ("test.concurrent(`${foo}`, function () {})", None), + ("it('foo', function () {})", None), + ("it.each([])()", None), + ("it.concurrent('foo', function () {})", None), + ("xdescribe('foo', function () {})", None), + ("xit('foo', function () {})", None), + ("xtest('foo', function () {})", None), + ("it()", None), + ("it.concurrent()", None), + ("describe()", None), + ("it.each()()", None), + ("describe('foo', function () {})", None), + ("fdescribe('foo', function () {})", None), + ("xdescribe('foo', function () {})", None), + ("it('foo', function () {})", None), + ("it.concurrent('foo', function () {})", None), + ("fit('foo', function () {})", None), + ("fit.concurrent('foo', function () {})", None), + ("xit('foo', function () {})", None), + ("test('foo', function () {})", None), + ("test.concurrent('foo', function () {})", None), + ("xtest('foo', function () {})", None), + ("xtest(`foo`, function () {})", None), + ("someFn('foo', function () {})", None), + ( + " + describe('foo', () => { + it('bar', () => {}) + }) + ", + None, + ), + ( + "it(`GIVEN... + `, () => {});", + Some(serde_json::json!([{ "ignoreSpaces": true }])), + ), + ("describe('foo', function () {})", None), + ("fdescribe('foo', function () {})", None), + ("xdescribe('foo', function () {})", None), + ("xdescribe(`foo`, function () {})", None), + ("test('foo', function () {})", None), + ("test('foo', function () {})", None), + ("xtest('foo', function () {})", None), + ("xtest(`foo`, function () {})", None), + ("test('foo test', function () {})", None), + ("xtest('foo test', function () {})", None), + ("it('foo', function () {})", None), + ("fit('foo', function () {})", None), + ("xit('foo', function () {})", None), + ("xit(`foo`, function () {})", None), + ("it('foos it correctly', function () {})", None), + ( + " + describe('foo', () => { + it('bar', () => {}) + }) + ", + None, + ), + ( + " + describe('foo', () => { + it('describes things correctly', () => {}) + }) + ", + None, + ), + ("it(abc, function () {})", Some(serde_json::json!([{ "ignoreTypeOfTestName": true }]))), + // Vitest-specific tests with allowArguments option + ("it(foo, () => {});", Some(serde_json::json!([{ "allowArguments": true }]))), + ("describe(bar, () => {});", Some(serde_json::json!([{ "allowArguments": true }]))), + ("test(baz, () => {});", Some(serde_json::json!([{ "allowArguments": true }]))), + // Vitest-specific tests with .extend() + ( + "export const myTest = test.extend({ + archive: [] + })", + None, + ), + ("const localTest = test.extend({})", None), + ( + "import { it } from 'vitest' + + const test = it.extend({ + fixture: [ + async ({}, use) => { + setup() + await use() + teardown() + }, + { auto: true } + ], + }) + + test('', () => {})", + None, + ), + ]; + + let fail = vec![ + ( + "test('the correct way to properly handle all things', () => {});", + Some(serde_json::json!([{ "disallowedWords": ["correct", "properly", "all"] }])), + ), + ( + "describe('the correct way to do things', function () {})", + Some(serde_json::json!([{ "disallowedWords": ["correct"] }])), + ), + ( + "it('has ALL the things', () => {})", + Some(serde_json::json!([{ "disallowedWords": ["all"] }])), + ), + ( + "xdescribe('every single one of them', function () {})", + Some(serde_json::json!([{ "disallowedWords": ["every"] }])), + ), + ( + "describe('Very Descriptive Title Goes Here', function () {})", + Some(serde_json::json!([{ "disallowedWords": ["descriptive"] }])), + ), + ( + "test(`that the value is set properly`, function () {})", + Some(serde_json::json!([{ "disallowedWords": ["properly"] }])), + ), + // TODO: The regex `(?:#(?!unit|e2e))\w+` in those test cases is not valid in Rust + // ( + // " + // describe('things to test', () => { + // describe('unit tests #unit', () => { + // it('is true', () => { + // expect(true).toBe(true); + // }); + // }); + + // describe('e2e tests #e4e', () => { + // it('is another test #e2e #jest4life', () => {}); + // }); + // }); + // ", + // Some(serde_json::json!([ + // { + // "mustNotMatch": r#"(?:#(?!unit|e2e))\w+"#, + // "mustMatch": "^[^#]+$|(?:#(?:unit|e2e))", + // }, + // ])), + // ), + // ( + // " + // import { describe, describe as context, it as thisTest } from '@jest/globals'; + + // describe('things to test', () => { + // context('unit tests #unit', () => { + // thisTest('is true', () => { + // expect(true).toBe(true); + // }); + // }); + + // context('e2e tests #e4e', () => { + // thisTest('is another test #e2e #jest4life', () => {}); + // }); + // }); + // ", + // Some( + // serde_json::json!([ { "mustNotMatch": r#"(?:#(?!unit|e2e))\w+"#, "mustMatch": "^[^#]+$|(?:#(?:unit|e2e))", }, ]), + // ), + // ), + // ( + // " + // describe('things to test', () => { + // describe('unit tests #unit', () => { + // it('is true', () => { + // expect(true).toBe(true); + // }); + // }); + + // describe('e2e tests #e4e', () => { + // it('is another test #e2e #jest4life', () => {}); + // }); + // }); + // ", + // Some(serde_json::json!([ + // { + // "mustNotMatch": [ + // r#"(?:#(?!unit|e2e))\w+"#, + // "Please include '#unit' or '#e2e' in titles", + // ], + // "mustMatch": [ + // "^[^#]+$|(?:#(?:unit|e2e))", + // "Please include '#unit' or '#e2e' in titles", + // ], + // }, + // ])), + // ), + // ( + // " + // describe('things to test', () => { + // describe('unit tests #unit', () => { + // it('is true', () => { + // expect(true).toBe(true); + // }); + // }); + + // describe('e2e tests #e4e', () => { + // it('is another test #e2e #jest4life', () => {}); + // }); + // }); + // ", + // Some(serde_json::json!([ + // { + // "mustNotMatch": { "describe": [r#"(?:#(?!unit|e2e))\w+"#] }, + // "mustMatch": { "describe": "^[^#]+$|(?:#(?:unit|e2e))" }, + // }, + // ])), + // ), + // ( + // " + // describe('things to test', () => { + // describe('unit tests #unit', () => { + // it('is true', () => { + // expect(true).toBe(true); + // }); + // }); + + // describe('e2e tests #e4e', () => { + // it('is another test #e2e #jest4life', () => {}); + // }); + // }); + // ", + // Some(serde_json::json!([ + // { + // "mustNotMatch": { + // "describe": [ + // r#"(?:#(?!unit|e2e))\w+"#, + // "Please include '#unit' or '#e2e' in describe titles", + // ], + // }, + // "mustMatch": { "describe": "^[^#]+$|(?:#(?:unit|e2e))" }, + // }, + // ])), + // ), + // ( + // " + // describe('things to test', () => { + // describe('unit tests #unit', () => { + // it('is true', () => { + // expect(true).toBe(true); + // }); + // }); + + // describe('e2e tests #e4e', () => { + // it('is another test #e2e #jest4life', () => {}); + // }); + // }); + // ", + // Some(serde_json::json!([ + // { + // "mustNotMatch": { "describe": r#"(?:#(?!unit|e2e))\w+"# }, + // "mustMatch": { "it": "^[^#]+$|(?:#(?:unit|e2e))" }, + // }, + // ])), + // ), + // ( + // " + // describe('things to test', () => { + // describe('unit tests #unit', () => { + // it('is true #jest4life', () => { + // expect(true).toBe(true); + // }); + // }); + + // describe('e2e tests #e4e', () => { + // it('is another test #e2e #jest4life', () => {}); + // }); + // }); + // ", + // Some(serde_json::json!([ + // { + // "mustNotMatch": { + // "describe": [ + // r#"(?:#(?!unit|e2e))\w+"#, + // "Please include '#unit' or '#e2e' in describe titles", + // ], + // }, + // "mustMatch": { + // "it": [ + // "^[^#]+$|(?:#(?:unit|e2e))", + // "Please include '#unit' or '#e2e' in it titles", + // ], + // }, + // }, + // ])), + // ), + ( + "test('the correct way to properly handle all things', () => {});", + Some(serde_json::json!([{ "mustMatch": "#(?:unit|integration|e2e)" }])), + ), + ( + "describe('the test', () => {});", + Some(serde_json::json!([ + { "mustMatch": { "describe": "#(?:unit|integration|e2e)" } }, + ])), + ), + ( + "xdescribe('the test', () => {});", + Some(serde_json::json!([ + { "mustMatch": { "describe": "#(?:unit|integration|e2e)" } }, + ])), + ), + ( + "describe.skip('the test', () => {});", + Some(serde_json::json!([ + { "mustMatch": { "describe": "#(?:unit|integration|e2e)" } }, + ])), + ), + ("it.each([])(1, () => {});", None), + ("it.skip.each([])(1, () => {});", None), + ("it.skip.each``(1, () => {});", None), + ("it(123, () => {});", None), + ("it.concurrent(123, () => {});", None), + ("it(1 + 2 + 3, () => {});", None), + ("it.concurrent(1 + 2 + 3, () => {});", None), + ( + "test.skip(123, () => {});", + Some(serde_json::json!([{ "ignoreTypeOfDescribeName": true }])), + ), + ("describe(String(/.+/), () => {});", None), + ( + "describe(myFunction, () => 1);", + Some(serde_json::json!([{ "ignoreTypeOfDescribeName": false }])), + ), + ("describe(myFunction, () => {});", None), + ("xdescribe(myFunction, () => {});", None), + ("describe(6, function () {})", None), + ("describe.skip(123, () => {});", None), + ("describe('', function () {})", None), + ( + " + describe('foo', () => { + it('', () => {}); + }); + ", + None, + ), + ("it('', function () {})", None), + ("it.concurrent('', function () {})", None), + ("test('', function () {})", None), + ("test.concurrent('', function () {})", None), + ("test(``, function () {})", None), + ("test.concurrent(``, function () {})", None), + ("xdescribe('', () => {})", None), + ("xit('', () => {})", None), + ("xtest('', () => {})", None), + ("describe(' foo', function () {})", None), + ("describe.each()(' foo', function () {})", None), + ("describe.only.each()(' foo', function () {})", None), + ("describe(' foo foe fum', function () {})", None), + ("describe('foo foe fum ', function () {})", None), + ("fdescribe(' foo', function () {})", None), + ("fdescribe(' foo', function () {})", None), + ("xdescribe(' foo', function () {})", None), + ("it(' foo', function () {})", None), + ("it.concurrent(' foo', function () {})", None), + ("fit(' foo', function () {})", None), + ("it.skip(' foo', function () {})", None), + ("fit('foo ', function () {})", None), + ("it.skip('foo ', function () {})", None), + ( + " + import { test as testThat } from '@jest/globals'; + + testThat('foo works ', () => {}); + ", + None, + ), + ("xit(' foo', function () {})", None), + ("test(' foo', function () {})", None), + ("test.concurrent(' foo', function () {})", None), + ("test(` foo`, function () {})", None), + ("test.concurrent(` foo`, function () {})", None), + ("test(` foo bar bang`, function () {})", None), + ("test.concurrent(` foo bar bang`, function () {})", None), + ("test(` foo bar bang `, function () {})", None), + ("test.concurrent(` foo bar bang `, function () {})", None), + ("xtest(' foo', function () {})", None), + ("xtest(' foo ', function () {})", None), + ( + " + describe(' foo', () => { + it('bar', () => {}) + }) + ", + None, + ), + ( + " + describe('foo', () => { + it(' bar', () => {}) + }) + ", + None, + ), + ("describe('describe foo', function () {})", None), + ("fdescribe('describe foo', function () {})", None), + ("xdescribe('describe foo', function () {})", None), + ("describe('describe foo', function () {})", None), + ("fdescribe(`describe foo`, function () {})", None), + ("test('test foo', function () {})", None), + ("xtest('test foo', function () {})", None), + ("test(`test foo`, function () {})", None), + ("test(`test foo test`, function () {})", None), + ("it('it foo', function () {})", None), + ("fit('it foo', function () {})", None), + ("xit('it foo', function () {})", None), + ("it('it foos it correctly', function () {})", None), + ( + " + describe('describe foo', () => { + it('bar', () => {}) + }) + ", + None, + ), + ( + " + describe('describe foo', () => { + it('describes things correctly', () => {}) + }) + ", + None, + ), + ( + " + describe('foo', () => { + it('it bar', () => {}) + }) + ", + None, + ), + ("it(abc, function () {})", None), + // Vitest-specific fail test with allowArguments: false + ("test(bar, () => {});", Some(serde_json::json!([{ "allowArguments": false }]))), + ]; + + let fix = vec![ + ("describe(' foo', function () {})", "describe('foo', function () {})"), + ("describe.each()(' foo', function () {})", "describe.each()('foo', function () {})"), + ( + "describe.only.each()(' foo', function () {})", + "describe.only.each()('foo', function () {})", + ), + ("describe(' foo foe fum', function () {})", "describe('foo foe fum', function () {})"), + ("describe('foo foe fum ', function () {})", "describe('foo foe fum', function () {})"), + ("fdescribe(' foo', function () {})", "fdescribe('foo', function () {})"), + ("fdescribe(' foo', function () {})", "fdescribe('foo', function () {})"), + ("xdescribe(' foo', function () {})", "xdescribe('foo', function () {})"), + ("it(' foo', function () {})", "it('foo', function () {})"), + ("it.concurrent(' foo', function () {})", "it.concurrent('foo', function () {})"), + ("fit(' foo', function () {})", "fit('foo', function () {})"), + ("it.skip(' foo', function () {})", "it.skip('foo', function () {})"), + ("fit('foo ', function () {})", "fit('foo', function () {})"), + ("it.skip('foo ', function () {})", "it.skip('foo', function () {})"), + ( + " + import { test as testThat } from '@jest/globals'; + + testThat('foo works ', () => {}); + ", + " + import { test as testThat } from '@jest/globals'; + + testThat('foo works', () => {}); + ", + ), + ("xit(' foo', function () {})", "xit('foo', function () {})"), + ("test(' foo', function () {})", "test('foo', function () {})"), + ("test.concurrent(' foo', function () {})", "test.concurrent('foo', function () {})"), + ("test(` foo`, function () {})", "test(`foo`, function () {})"), + ("test.concurrent(` foo`, function () {})", "test.concurrent(`foo`, function () {})"), + ("test(` foo bar bang`, function () {})", "test(`foo bar bang`, function () {})"), + ( + "test.concurrent(` foo bar bang`, function () {})", + "test.concurrent(`foo bar bang`, function () {})", + ), + ("test(` foo bar bang `, function () {})", "test(`foo bar bang`, function () {})"), + ( + "test.concurrent(` foo bar bang `, function () {})", + "test.concurrent(`foo bar bang`, function () {})", + ), + ("xtest(' foo', function () {})", "xtest('foo', function () {})"), + ("xtest(' foo ', function () {})", "xtest('foo', function () {})"), + ( + " + describe(' foo', () => { + it('bar', () => {}) + }) + ", + " + describe('foo', () => { + it('bar', () => {}) + }) + ", + ), + ( + " + describe('foo', () => { + it(' bar', () => {}) + }) + ", + " + describe('foo', () => { + it('bar', () => {}) + }) + ", + ), + ("describe('describe foo', function () {})", "describe('foo', function () {})"), + ("fdescribe('describe foo', function () {})", "fdescribe('foo', function () {})"), + ("xdescribe('describe foo', function () {})", "xdescribe('foo', function () {})"), + ("describe('describe foo', function () {})", "describe('foo', function () {})"), + ("fdescribe(`describe foo`, function () {})", "fdescribe(`foo`, function () {})"), + ("test('test foo', function () {})", "test('foo', function () {})"), + ("xtest('test foo', function () {})", "xtest('foo', function () {})"), + ("test(`test foo`, function () {})", "test(`foo`, function () {})"), + ("test(`test foo test`, function () {})", "test(`foo test`, function () {})"), + ("it('it foo', function () {})", "it('foo', function () {})"), + ("fit('it foo', function () {})", "fit('foo', function () {})"), + ("xit('it foo', function () {})", "xit('foo', function () {})"), + ("it('it foos it correctly', function () {})", "it('foos it correctly', function () {})"), + ( + " + describe('describe foo', () => { + it('bar', () => {}) + }) + ", + " + describe('foo', () => { + it('bar', () => {}) + }) + ", + ), + ( + " + describe('describe foo', () => { + it('describes things correctly', () => {}) + }) + ", + " + describe('foo', () => { + it('describes things correctly', () => {}) + }) + ", + ), + ( + " + describe('foo', () => { + it('it bar', () => {}) + }) + ", + " + describe('foo', () => { + it('bar', () => {}) + }) + ", + ), + // AccidentalSpace: preserve escape sequences when trimming spaces + ( + "test('issue #225513: Cmd-Click doesn\\'t work on JSDoc {@link URL|LinkText} format ', () => { assert(true); });", + "test('issue #225513: Cmd-Click doesn\\'t work on JSDoc {@link URL|LinkText} format', () => { assert(true); });", + ), + // DuplicatePrefix: preserve escape sequences when removing prefix + ( + "test('test that it doesn\\'t break', () => {});", + "test('that it doesn\\'t break', () => {});", + ), + ]; + + Tester::new(ValidTitle::NAME, ValidTitle::PLUGIN, pass, fail) + .with_vitest_plugin(true) + .expect_fix(fix) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/vitest_valid_title.snap b/crates/oxc_linter/src/snapshots/vitest_valid_title.snap new file mode 100644 index 0000000000000..44561d304a2fa --- /dev/null +++ b/crates/oxc_linter/src/snapshots/vitest_valid_title.snap @@ -0,0 +1,583 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ eslint-plugin-vitest(valid-title): correct is not allowed in test title + ╭─[valid_title.tsx:1:6] + 1 │ test('the correct way to properly handle all things', () => {}); + · ─────────────────────────────────────────────── + ╰──── + help: It is included in the `disallowedWords` of your config file, try to remove it from your title + + ⚠ eslint-plugin-vitest(valid-title): correct is not allowed in test title + ╭─[valid_title.tsx:1:10] + 1 │ describe('the correct way to do things', function () {}) + · ────────────────────────────── + ╰──── + help: It is included in the `disallowedWords` of your config file, try to remove it from your title + + ⚠ eslint-plugin-vitest(valid-title): ALL is not allowed in test title + ╭─[valid_title.tsx:1:4] + 1 │ it('has ALL the things', () => {}) + · ──────────────────── + ╰──── + help: It is included in the `disallowedWords` of your config file, try to remove it from your title + + ⚠ eslint-plugin-vitest(valid-title): every is not allowed in test title + ╭─[valid_title.tsx:1:11] + 1 │ xdescribe('every single one of them', function () {}) + · ────────────────────────── + ╰──── + help: It is included in the `disallowedWords` of your config file, try to remove it from your title + + ⚠ eslint-plugin-vitest(valid-title): Descriptive is not allowed in test title + ╭─[valid_title.tsx:1:10] + 1 │ describe('Very Descriptive Title Goes Here', function () {}) + · ────────────────────────────────── + ╰──── + help: It is included in the `disallowedWords` of your config file, try to remove it from your title + + ⚠ eslint-plugin-vitest(valid-title): properly is not allowed in test title + ╭─[valid_title.tsx:1:6] + 1 │ test(`that the value is set properly`, function () {}) + · ──────────────────────────────── + ╰──── + help: It is included in the `disallowedWords` of your config file, try to remove it from your title + + ⚠ eslint-plugin-vitest(valid-title): test should match (?u)#(?:unit|integration|e2e) + ╭─[valid_title.tsx:1:6] + 1 │ test('the correct way to properly handle all things', () => {}); + · ─────────────────────────────────────────────── + ╰──── + help: Make sure the title matches the `mustMatch` of your config file + + ⚠ eslint-plugin-vitest(valid-title): describe should match (?u)#(?:unit|integration|e2e) + ╭─[valid_title.tsx:1:10] + 1 │ describe('the test', () => {}); + · ────────── + ╰──── + help: Make sure the title matches the `mustMatch` of your config file + + ⚠ eslint-plugin-vitest(valid-title): describe should match (?u)#(?:unit|integration|e2e) + ╭─[valid_title.tsx:1:11] + 1 │ xdescribe('the test', () => {}); + · ────────── + ╰──── + help: Make sure the title matches the `mustMatch` of your config file + + ⚠ eslint-plugin-vitest(valid-title): describe should match (?u)#(?:unit|integration|e2e) + ╭─[valid_title.tsx:1:15] + 1 │ describe.skip('the test', () => {}); + · ────────── + ╰──── + help: Make sure the title matches the `mustMatch` of your config file + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:13] + 1 │ it.each([])(1, () => {}); + · ─ + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:18] + 1 │ it.skip.each([])(1, () => {}); + · ─ + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:16] + 1 │ it.skip.each``(1, () => {}); + · ─ + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:4] + 1 │ it(123, () => {}); + · ─── + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:15] + 1 │ it.concurrent(123, () => {}); + · ─── + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:4] + 1 │ it(1 + 2 + 3, () => {}); + · ───────── + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:15] + 1 │ it.concurrent(1 + 2 + 3, () => {}); + · ───────── + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:11] + 1 │ test.skip(123, () => {}); + · ─── + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:10] + 1 │ describe(String(/.+/), () => {}); + · ──────────── + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:10] + 1 │ describe(myFunction, () => 1); + · ────────── + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:10] + 1 │ describe(myFunction, () => {}); + · ────────── + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:11] + 1 │ xdescribe(myFunction, () => {}); + · ────────── + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:10] + 1 │ describe(6, function () {}) + · ─ + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:15] + 1 │ describe.skip(123, () => {}); + · ─── + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Should not have an empty title + ╭─[valid_title.tsx:1:10] + 1 │ describe('', function () {}) + · ── + ╰──── + help: Write a meaningful title for your test + + ⚠ eslint-plugin-vitest(valid-title): Should not have an empty title + ╭─[valid_title.tsx:3:24] + 2 │ describe('foo', () => { + 3 │ it('', () => {}); + · ── + 4 │ }); + ╰──── + help: Write a meaningful title for your test + + ⚠ eslint-plugin-vitest(valid-title): Should not have an empty title + ╭─[valid_title.tsx:1:4] + 1 │ it('', function () {}) + · ── + ╰──── + help: Write a meaningful title for your test + + ⚠ eslint-plugin-vitest(valid-title): Should not have an empty title + ╭─[valid_title.tsx:1:15] + 1 │ it.concurrent('', function () {}) + · ── + ╰──── + help: Write a meaningful title for your test + + ⚠ eslint-plugin-vitest(valid-title): Should not have an empty title + ╭─[valid_title.tsx:1:6] + 1 │ test('', function () {}) + · ── + ╰──── + help: Write a meaningful title for your test + + ⚠ eslint-plugin-vitest(valid-title): Should not have an empty title + ╭─[valid_title.tsx:1:17] + 1 │ test.concurrent('', function () {}) + · ── + ╰──── + help: Write a meaningful title for your test + + ⚠ eslint-plugin-vitest(valid-title): Should not have an empty title + ╭─[valid_title.tsx:1:6] + 1 │ test(``, function () {}) + · ── + ╰──── + help: Write a meaningful title for your test + + ⚠ eslint-plugin-vitest(valid-title): Should not have an empty title + ╭─[valid_title.tsx:1:17] + 1 │ test.concurrent(``, function () {}) + · ── + ╰──── + help: Write a meaningful title for your test + + ⚠ eslint-plugin-vitest(valid-title): Should not have an empty title + ╭─[valid_title.tsx:1:11] + 1 │ xdescribe('', () => {}) + · ── + ╰──── + help: Write a meaningful title for your test + + ⚠ eslint-plugin-vitest(valid-title): Should not have an empty title + ╭─[valid_title.tsx:1:5] + 1 │ xit('', () => {}) + · ── + ╰──── + help: Write a meaningful title for your test + + ⚠ eslint-plugin-vitest(valid-title): Should not have an empty title + ╭─[valid_title.tsx:1:7] + 1 │ xtest('', () => {}) + · ── + ╰──── + help: Write a meaningful title for your test + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:10] + 1 │ describe(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:17] + 1 │ describe.each()(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:22] + 1 │ describe.only.each()(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:10] + 1 │ describe(' foo foe fum', function () {}) + · ────────────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:10] + 1 │ describe('foo foe fum ', function () {}) + · ────────────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:11] + 1 │ fdescribe(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:11] + 1 │ fdescribe(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:11] + 1 │ xdescribe(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:4] + 1 │ it(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:15] + 1 │ it.concurrent(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:5] + 1 │ fit(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:9] + 1 │ it.skip(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:5] + 1 │ fit('foo ', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:9] + 1 │ it.skip('foo ', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:4:26] + 3 │ + 4 │ testThat('foo works ', () => {}); + · ──────────── + 5 │ + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:5] + 1 │ xit(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:6] + 1 │ test(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:17] + 1 │ test.concurrent(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:6] + 1 │ test(` foo`, function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:17] + 1 │ test.concurrent(` foo`, function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:6] + 1 │ test(` foo bar bang`, function () {}) + · ─────────────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:17] + 1 │ test.concurrent(` foo bar bang`, function () {}) + · ─────────────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:6] + 1 │ test(` foo bar bang `, function () {}) + · ───────────────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:17] + 1 │ test.concurrent(` foo bar bang `, function () {}) + · ───────────────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:7] + 1 │ xtest(' foo', function () {}) + · ────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:1:7] + 1 │ xtest(' foo ', function () {}) + · ──────── + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:2:26] + 1 │ + 2 │ describe(' foo', () => { + · ────── + 3 │ it('bar', () => {}) + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have leading or trailing spaces + ╭─[valid_title.tsx:3:24] + 2 │ describe('foo', () => { + 3 │ it(' bar', () => {}) + · ────── + 4 │ }) + ╰──── + help: Remove the leading or trailing spaces + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:10] + 1 │ describe('describe foo', function () {}) + · ────────────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:11] + 1 │ fdescribe('describe foo', function () {}) + · ────────────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:11] + 1 │ xdescribe('describe foo', function () {}) + · ────────────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:10] + 1 │ describe('describe foo', function () {}) + · ────────────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:11] + 1 │ fdescribe(`describe foo`, function () {}) + · ────────────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:6] + 1 │ test('test foo', function () {}) + · ────────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:7] + 1 │ xtest('test foo', function () {}) + · ────────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:6] + 1 │ test(`test foo`, function () {}) + · ────────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:6] + 1 │ test(`test foo test`, function () {}) + · ─────────────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:4] + 1 │ it('it foo', function () {}) + · ──────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:5] + 1 │ fit('it foo', function () {}) + · ──────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:5] + 1 │ xit('it foo', function () {}) + · ──────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:1:4] + 1 │ it('it foos it correctly', function () {}) + · ────────────────────── + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:2:26] + 1 │ + 2 │ describe('describe foo', () => { + · ────────────── + 3 │ it('bar', () => {}) + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:2:26] + 1 │ + 2 │ describe('describe foo', () => { + · ────────────── + 3 │ it('describes things correctly', () => {}) + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Should not have duplicate prefix + ╭─[valid_title.tsx:3:24] + 2 │ describe('foo', () => { + 3 │ it('it bar', () => {}) + · ──────── + 4 │ }) + ╰──── + help: The function name already has the prefix, try to remove the duplicate prefix + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:4] + 1 │ it(abc, function () {}) + · ─── + ╰──── + help: Replace your title with a string + + ⚠ eslint-plugin-vitest(valid-title): Title must be a string + ╭─[valid_title.tsx:1:6] + 1 │ test(bar, () => {}); + · ─── + ╰──── + help: Replace your title with a string diff --git a/tasks/linter_codegen/src/rules.rs b/tasks/linter_codegen/src/rules.rs index 2c68d9da03b1e..0cfdff69ada5d 100644 --- a/tasks/linter_codegen/src/rules.rs +++ b/tasks/linter_codegen/src/rules.rs @@ -43,6 +43,7 @@ pub fn get_all_rules(contents: &str) -> Vec> { // Inside a plugin module, detect rule module: `pub mod no_debugger;` if let Some(plugin) = current_plugin + && plugin != "shared" && line.starts_with("pub mod ") && line.ends_with(';') && let Some(rule_name) = line.strip_prefix("pub mod ").and_then(|s| s.strip_suffix(';'))