diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 8a1c0686db7b7..509bbac7b9baa 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -504,6 +504,7 @@ mod promise { mod vitest { pub mod no_conditional_tests; + pub mod no_identical_title; pub mod no_import_node_test; pub mod prefer_each; pub mod prefer_to_be_falsy; @@ -978,6 +979,7 @@ oxc_macros::declare_all_lint_rules! { unicorn::switch_case_braces, unicorn::text_encoding_identifier_case, unicorn::throw_new_error, + vitest::no_identical_title, vitest::no_conditional_tests, vitest::no_import_node_test, vitest::prefer_each, diff --git a/crates/oxc_linter/src/rules/jest/no_identical_title.rs b/crates/oxc_linter/src/rules/jest/no_identical_title.rs index 0f25ddf253cad..2e2c9bf127124 100644 --- a/crates/oxc_linter/src/rules/jest/no_identical_title.rs +++ b/crates/oxc_linter/src/rules/jest/no_identical_title.rs @@ -169,7 +169,7 @@ fn get_closest_block(node: &AstNode, ctx: &LintContext) -> Option { fn test() { use crate::tester::Tester; - let mut pass = vec![ + let pass = vec![ ("it(); it();", None), ("describe(); describe();", None), ("describe('foo', () => {}); it('foo', () => {});", None), @@ -371,7 +371,7 @@ fn test() { ), ]; - let mut fail = vec![ + let fail = vec![ ( " describe('foo', () => { @@ -473,63 +473,7 @@ fn test() { // ), ]; - let pass_vitest = vec![ - " - suite('parent', () => { - suite('child 1', () => { - test('grand child 1', () => {}) - }) - suite('child 2', () => { - test('grand child 1', () => {}) - }) - }) - ", - "it(); it();", - r#"test("two", () => {});"#, - " - fdescribe('a describe', () => { - test('a test', () => { - expect(true).toBe(true); - }); - }); - fdescribe('another describe', () => { - test('a test', () => { - expect(true).toBe(true); - }); - }); - ", - " - suite('parent', () => { - suite('child 1', () => { - test('grand child 1', () => {}) - }) - suite('child 2', () => { - test('grand child 1', () => {}) - }) - }) - ", - ]; - - let fail_vitest = vec![ - " - describe('foo', () => { - it('works', () => {}); - it('works', () => {}); - }); - ", - " - xdescribe('foo', () => { - it('works', () => {}); - it('works', () => {}); - }); - ", - ]; - - pass.extend(pass_vitest.into_iter().map(|x| (x, None))); - fail.extend(fail_vitest.into_iter().map(|x| (x, None))); - Tester::new(NoIdenticalTitle::NAME, NoIdenticalTitle::CATEGORY, pass, fail) .with_jest_plugin(true) - .with_vitest_plugin(true) .test_and_snapshot(); } diff --git a/crates/oxc_linter/src/rules/vitest/no_identical_title.rs b/crates/oxc_linter/src/rules/vitest/no_identical_title.rs new file mode 100644 index 0000000000000..dfe34463236d8 --- /dev/null +++ b/crates/oxc_linter/src/rules/vitest/no_identical_title.rs @@ -0,0 +1,208 @@ +use oxc_ast::{ + ast::{Argument, CallExpression}, + AstKind, +}; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_semantic::NodeId; +use oxc_span::Span; +use rustc_hash::FxHashMap; + +use crate::{ + context::LintContext, + rule::Rule, + utils::{ + collect_possible_jest_call_node, parse_general_jest_fn_call, JestFnKind, JestGeneralFnKind, + PossibleJestNode, + }, + AstNode, +}; + +fn describe_repeat(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Describe block title is used multiple times in the same describe block.") + .with_help("Change the title of describe block.") + .with_label(span) +} + +fn test_repeat(span: Span) -> OxcDiagnostic { + OxcDiagnostic::warn("Test title is used multiple times in the same describe block.") + .with_help("Change the title of test.") + .with_label(span) +} + +#[derive(Debug, Default, Clone)] +pub struct NoIdenticalTitle; + +declare_oxc_lint!( + /// ### What it does + /// + /// This rule looks at the title of every test and test suite. + /// It will report when two test suites or two test cases at the same level of a test suite have the same title. + /// + /// ### Why is this bad? + /// + /// Having identical titles for two different tests or test suites may create confusion. + /// For example, when a test with the same title as another test in the same test suite fails, it is harder to know which one failed and thus harder to fix. + /// + /// ### Example + /// ```javascript + /// describe('baz', () => { + /// //... + /// }); + /// + /// describe('baz', () => { + /// // Has the same title as a previous test suite + /// // ... + /// }); + /// ``` + NoIdenticalTitle, + style, +); + +impl Rule for NoIdenticalTitle { + fn run_once(&self, ctx: &LintContext) { + let possible_jest_nodes = collect_possible_jest_call_node(ctx); + let mut title_to_span_mapping = FxHashMap::default(); + let mut span_to_parent_mapping = FxHashMap::default(); + + possible_jest_nodes + .iter() + .filter_map(|possible_jest_node| { + let AstKind::CallExpression(call_expr) = possible_jest_node.node.kind() else { + return None; + }; + filter_and_process_jest_result(call_expr, possible_jest_node, ctx) + }) + .for_each(|(span, title, kind, parent_id)| { + span_to_parent_mapping.insert(span, parent_id); + title_to_span_mapping + .entry(title) + .and_modify(|e: &mut Vec<(JestFnKind, Span)>| e.push((kind, span))) + .or_insert_with(|| vec![(kind, span)]); + }); + + for kind_and_span in title_to_span_mapping.values() { + let mut kind_and_spans = kind_and_span + .iter() + .filter_map(|(kind, span)| { + let parent = span_to_parent_mapping.get(span)?; + Some((*span, *kind, *parent)) + }) + .collect::>(); + // After being sorted by parent_id, the span with the same parent will be placed nearby. + kind_and_spans.sort_by(|a, b| a.2.cmp(&b.2)); + + // Skip the first element, for `describe('foo'); describe('foo');`, we only need to check the second one. + for i in 1..kind_and_spans.len() { + let (span, kind, parent_id) = kind_and_spans[i]; + let (_, prev_kind, prev_parent) = kind_and_spans[i - 1]; + + if kind == prev_kind && parent_id == prev_parent { + match kind { + JestFnKind::General(JestGeneralFnKind::Describe) => { + ctx.diagnostic(describe_repeat(span)); + } + JestFnKind::General(JestGeneralFnKind::Test) => { + ctx.diagnostic(test_repeat(span)); + } + _ => {} + } + } + } + } + } +} + +fn filter_and_process_jest_result<'a>( + call_expr: &'a CallExpression<'a>, + possible_jest_node: &PossibleJestNode<'a, '_>, + ctx: &LintContext<'a>, +) -> Option<(Span, &'a str, JestFnKind, NodeId)> { + let result = parse_general_jest_fn_call(call_expr, possible_jest_node, ctx)?; + let kind = result.kind; + // we only need check `describe` or `test` block + if !matches!(kind, JestFnKind::General(JestGeneralFnKind::Describe | JestGeneralFnKind::Test)) { + return None; + } + + if result.members.iter().any(|m| m.is_name_equal("each")) { + return None; + } + + let parent_id = get_closest_block(possible_jest_node.node, ctx)?; + + match call_expr.arguments.first() { + Some(Argument::StringLiteral(string_lit)) => { + Some((string_lit.span, &string_lit.value, kind, parent_id)) + } + Some(Argument::TemplateLiteral(template_lit)) => { + template_lit.quasi().map(|quasi| (template_lit.span, quasi.as_str(), kind, parent_id)) + } + _ => None, + } +} + +fn get_closest_block(node: &AstNode, ctx: &LintContext) -> Option { + match node.kind() { + AstKind::BlockStatement(_) | AstKind::FunctionBody(_) | AstKind::Program(_) => { + Some(node.id()) + } + _ => { + let parent = ctx.nodes().parent_node(node.id())?; + get_closest_block(parent, ctx) + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + "suite('parent', () => { + suite('child 1', () => { + test('grand child 1', () => {}) + }) + suite('child 2', () => { + test('grand child 1', () => {}) + }) + })", + "it(); it();", + r#"test("two", () => {});"#, + "fdescribe('a describe', () => { + test('a test', () => { + expect(true).toBe(true); + }); + }); + fdescribe('another describe', () => { + test('a test', () => { + expect(true).toBe(true); + }); + });", + " + suite('parent', () => { + suite('child 1', () => { + test('grand child 1', () => {}) + }) + suite('child 2', () => { + test('grand child 1', () => {}) + }) + }) + ", + ]; + + let fail = vec![ + "describe('foo', () => { + it('works', () => {}); + it('works', () => {}); + });", + "xdescribe('foo', () => { + it('works', () => {}); + it('works', () => {}); + });", + ]; + + Tester::new(NoIdenticalTitle::NAME, NoIdenticalTitle::CATEGORY, pass, fail) + .with_vitest_plugin(true) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/jest_no_identical_title.snap b/crates/oxc_linter/src/snapshots/jest_no_identical_title.snap index 8b2021c8fa5b4..ea31915bc11eb 100644 --- a/crates/oxc_linter/src/snapshots/jest_no_identical_title.snap +++ b/crates/oxc_linter/src/snapshots/jest_no_identical_title.snap @@ -2,7 +2,7 @@ source: crates/oxc_linter/src/tester.rs snapshot_kind: text --- - ⚠ eslint-plugin-vitest(no-identical-title): Test title is used multiple times in the same describe block. + ⚠ eslint-plugin-jest(no-identical-title): Test title is used multiple times in the same describe block. ╭─[no_identical_title.tsx:4:20] 3 │ it('works', () => {}); 4 │ it('works', () => {}); @@ -11,7 +11,7 @@ snapshot_kind: text ╰──── help: Change the title of test. - ⚠ eslint-plugin-vitest(no-identical-title): Test title is used multiple times in the same describe block. + ⚠ eslint-plugin-jest(no-identical-title): Test title is used multiple times in the same describe block. ╭─[no_identical_title.tsx:3:18] 2 │ it('works', () => {}); 3 │ it('works', () => {}); @@ -20,7 +20,7 @@ snapshot_kind: text ╰──── help: Change the title of test. - ⚠ eslint-plugin-vitest(no-identical-title): Test title is used multiple times in the same describe block. + ⚠ eslint-plugin-jest(no-identical-title): Test title is used multiple times in the same describe block. ╭─[no_identical_title.tsx:3:20] 2 │ test.only('this', () => {}); 3 │ test('this', () => {}); @@ -29,7 +29,7 @@ snapshot_kind: text ╰──── help: Change the title of test. - ⚠ eslint-plugin-vitest(no-identical-title): Test title is used multiple times in the same describe block. + ⚠ eslint-plugin-jest(no-identical-title): Test title is used multiple times in the same describe block. ╭─[no_identical_title.tsx:2:21] 1 │ 2 │ xtest('this', () => {}); @@ -38,7 +38,7 @@ snapshot_kind: text ╰──── help: Change the title of test. - ⚠ eslint-plugin-vitest(no-identical-title): Test title is used multiple times in the same describe block. + ⚠ eslint-plugin-jest(no-identical-title): Test title is used multiple times in the same describe block. ╭─[no_identical_title.tsx:3:25] 2 │ test.only('this', () => {}); 3 │ test.only('this', () => {}); @@ -47,7 +47,7 @@ snapshot_kind: text ╰──── help: Change the title of test. - ⚠ eslint-plugin-vitest(no-identical-title): Test title is used multiple times in the same describe block. + ⚠ eslint-plugin-jest(no-identical-title): Test title is used multiple times in the same describe block. ╭─[no_identical_title.tsx:3:31] 2 │ test.concurrent('this', () => {}); 3 │ test.concurrent('this', () => {}); @@ -56,7 +56,7 @@ snapshot_kind: text ╰──── help: Change the title of test. - ⚠ eslint-plugin-vitest(no-identical-title): Test title is used multiple times in the same describe block. + ⚠ eslint-plugin-jest(no-identical-title): Test title is used multiple times in the same describe block. ╭─[no_identical_title.tsx:3:31] 2 │ test.only('this', () => {}); 3 │ test.concurrent('this', () => {}); @@ -65,7 +65,7 @@ snapshot_kind: text ╰──── help: Change the title of test. - ⚠ eslint-plugin-vitest(no-identical-title): Describe block title is used multiple times in the same describe block. + ⚠ eslint-plugin-jest(no-identical-title): Describe block title is used multiple times in the same describe block. ╭─[no_identical_title.tsx:3:24] 2 │ describe('foo', () => {}); 3 │ describe('foo', () => {}); @@ -74,7 +74,7 @@ snapshot_kind: text ╰──── help: Change the title of describe block. - ⚠ eslint-plugin-vitest(no-identical-title): Describe block title is used multiple times in the same describe block. + ⚠ eslint-plugin-jest(no-identical-title): Describe block title is used multiple times in the same describe block. ╭─[no_identical_title.tsx:2:24] 1 │ 2 │ describe('foo', () => {}); @@ -83,7 +83,7 @@ snapshot_kind: text ╰──── help: Change the title of describe block. - ⚠ eslint-plugin-vitest(no-identical-title): Describe block title is used multiple times in the same describe block. + ⚠ eslint-plugin-jest(no-identical-title): Describe block title is used multiple times in the same describe block. ╭─[no_identical_title.tsx:3:24] 2 │ fdescribe('foo', () => {}); 3 │ describe('foo', () => {}); @@ -92,7 +92,7 @@ snapshot_kind: text ╰──── help: Change the title of describe block. - ⚠ eslint-plugin-vitest(no-identical-title): Describe block title is used multiple times in the same describe block. + ⚠ eslint-plugin-jest(no-identical-title): Describe block title is used multiple times in the same describe block. ╭─[no_identical_title.tsx:5:24] 4 │ }); 5 │ describe('foo', () => {}); @@ -101,7 +101,7 @@ snapshot_kind: text ╰──── help: Change the title of describe block. - ⚠ eslint-plugin-vitest(no-identical-title): Test title is used multiple times in the same describe block. + ⚠ eslint-plugin-jest(no-identical-title): Test title is used multiple times in the same describe block. ╭─[no_identical_title.tsx:4:20] 3 │ it(`catches backticks with the same title`, () => {}); 4 │ it(`catches backticks with the same title`, () => {}); @@ -109,21 +109,3 @@ snapshot_kind: text 5 │ }); ╰──── help: Change the title of test. - - ⚠ eslint-plugin-vitest(no-identical-title): Test title is used multiple times in the same describe block. - ╭─[no_identical_title.tsx:4:20] - 3 │ it('works', () => {}); - 4 │ it('works', () => {}); - · ─────── - 5 │ }); - ╰──── - help: Change the title of test. - - ⚠ eslint-plugin-vitest(no-identical-title): Test title is used multiple times in the same describe block. - ╭─[no_identical_title.tsx:4:20] - 3 │ it('works', () => {}); - 4 │ it('works', () => {}); - · ─────── - 5 │ }); - ╰──── - help: Change the title of test.