diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index a8aa90ac2..c4a8fdffb 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -111,6 +111,7 @@ mod jest { pub mod no_done_callback; pub mod no_export; pub mod no_focused_tests; + pub mod no_identical_title; pub mod no_interpolation_in_snapshots; pub mod no_jasmine_globals; pub mod no_mocks_import; @@ -222,6 +223,7 @@ oxc_macros::declare_all_lint_rules! { jest::no_mocks_import, jest::no_export, jest::no_standalone_expect, + jest::no_identical_title, unicorn::no_instanceof_array, unicorn::no_unnecessary_await, unicorn::no_thenable, diff --git a/crates/oxc_linter/src/rules/jest/no_identical_title.rs b/crates/oxc_linter/src/rules/jest/no_identical_title.rs new file mode 100644 index 000000000..78b04cc87 --- /dev/null +++ b/crates/oxc_linter/src/rules/jest/no_identical_title.rs @@ -0,0 +1,493 @@ +use std::collections::HashMap; + +use oxc_ast::{ + ast::{Argument, Expression}, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_semantic::{AstNodeId, ReferenceId}; +use oxc_span::{Atom, Span}; + +use crate::{ + context::LintContext, + jest_ast_util::{parse_general_jest_fn_call, JestFnKind, JestGeneralFnKind}, + rule::Rule, + AstNode, +}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint(jest/no-identical-title): {0:?}")] +#[diagnostic(severity(warning), help("{1:?}"))] +struct NoIdenticalTitleDiagnostic(&'static str, &'static str, #[label] pub 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, + restriction +); + +impl Rule for NoIdenticalTitle { + fn run_once(&self, ctx: &LintContext) { + // TODO: support detect import from "@jest/globals" + let references = ctx.scopes().root_unresolved_references().iter().filter(|(key, _)| { + DESCRIBE_NAMES.contains(&key.as_str()) || TEST_NAMES.contains(&key.as_str()) + }); + let mut title_to_span_mapping = HashMap::new(); + let mut span_to_parent_mapping = HashMap::new(); + + for (_, reference_ids) in references { + for &reference_id in reference_ids { + let Some((span, title, kind, parent_id)) = process_reference(reference_id, ctx) + else { + continue; + }; + + 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 Some(parent) = span_to_parent_mapping.get(span) else { return None }; + 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 { + let (error, help) = Message::details(kind); + ctx.diagnostic(NoIdenticalTitleDiagnostic(error, help, span)); + } + } + } + } +} + +const DESCRIBE_NAMES: [&str; 3] = ["describe", "fdescribe", "xdescribe"]; +const TEST_NAMES: [&str; 5] = ["it", "fit", "xit", "test", "xtest"]; + +fn process_reference<'a>( + reference_id: ReferenceId, + ctx: &LintContext<'a>, +) -> Option<(Span, &'a Atom, JestFnKind, AstNodeId)> { + let reference = ctx.symbols().get_reference(reference_id); + let node = ctx.nodes().parent_node(reference.node_id())?; + let node = get_closest_call_expr(node, ctx)?; + let closest_block = get_closest_block(node, ctx)?; + let AstKind::CallExpression(call_expr) = node.kind() else { + return None; + }; + let jest_fn_call = parse_general_jest_fn_call(call_expr, node, ctx)?; + match call_expr.arguments.get(0) { + Some(Argument::Expression(Expression::StringLiteral(string_lit))) => { + Some((call_expr.span, &string_lit.value, jest_fn_call.kind, closest_block.id())) + } + Some(Argument::Expression(Expression::TemplateLiteral(template_lit))) => { + match template_lit.quasi() { + Some(quasi) => Some((call_expr.span, quasi, jest_fn_call.kind, closest_block.id())), + None => None, + } + } + _ => None, + } +} + +fn get_closest_block<'a, 'b>( + node: &'b AstNode<'a>, + ctx: &'b LintContext<'a>, +) -> Option<&'b AstNode<'a>> { + match node.kind() { + AstKind::BlockStatement(_) | AstKind::FunctionBody(_) | AstKind::Program(_) => Some(node), + _ => { + let parent = ctx.nodes().parent_node(node.id())?; + get_closest_block(parent, ctx) + } + } +} + +fn get_closest_call_expr<'a, 'b>( + node: &'b AstNode<'a>, + ctx: &'b LintContext<'a>, +) -> Option<&'b AstNode<'a>> { + match node.kind() { + AstKind::CallExpression(_) => Some(node), + AstKind::MemberExpression(member_expr) => { + if member_expr.static_property_name() == Some("each") { + return None; + } + let parent = ctx.nodes().parent_node(node.id())?; + get_closest_call_expr(parent, ctx) + } + _ => None, + } +} + +struct Message; + +impl Message { + fn details(kind: JestFnKind) -> (&'static str, &'static str) { + match kind { + // (error, help) + JestFnKind::General(JestGeneralFnKind::Describe) => ( + "Describe block title is used multiple times in the same describe block.", + "Change the title of describe block.", + ), + JestFnKind::General(JestGeneralFnKind::Test) => ( + "Test title is used multiple times in the same describe block.", + "Change the title of test.", + ), + _ => unreachable!(), + } + } +} + +#[allow(clippy::too_many_lines)] +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + ("it(); it();", None), + ("describe(); describe();", None), + ("describe('foo', () => {}); it('foo', () => {});", None), + ( + " + describe('foo', () => { + it('works', () => {}); + }); + ", + None, + ), + ( + " + it('one', () => {}); + it('two', () => {}); + ", + None, + ), + ( + " + describe('foo', () => {}); + describe('foe', () => {}); + ", + None, + ), + ( + " + it(`one`, () => {}); + it(`two`, () => {}); + ", + None, + ), + ( + " + describe(`foo`, () => {}); + describe(`foe`, () => {}); + ", + None, + ), + ( + " + describe('foo', () => { + test('this', () => {}); + test('that', () => {}); + }); + ", + None, + ), + ( + " + test.concurrent('this', () => {}); + test.concurrent('that', () => {}); + ", + None, + ), + ( + " + test.concurrent('this', () => {}); + test.only.concurrent('that', () => {}); + ", + None, + ), + ( + " + test.only.concurrent('this', () => {}); + test.concurrent('that', () => {}); + ", + None, + ), + ( + " + test.only.concurrent('this', () => {}); + test.only.concurrent('that', () => {}); + ", + None, + ), + ( + " + test.only('this', () => {}); + test.only('that', () => {}); + ", + None, + ), + ( + " + describe('foo', () => { + it('works', () => {}); + + describe('foe', () => { + it('works', () => {}); + }); + }); + ", + None, + ), + ( + " + describe('foo', () => { + describe('foe', () => { + it('works', () => {}); + }); + + it('works', () => {}); + }); + ", + None, + ), + ("describe('foo', () => describe('foe', () => {}));", None), + ( + " + describe('foo', () => { + describe('foe', () => {}); + }); + + describe('foe', () => {}); + ", + None, + ), + ("test('number' + n, function() {});", None), + ("test('number' + n, function() {}); test('number' + n, function() {});", None), + // ("it(`${n}`, function() {});", None), + // ("it(`${n}`, function() {}); it(`${n}`, function() {});", None), + ( + " + describe('a class named ' + myClass.name, () => { + describe('#myMethod', () => {}); + }); + + describe('something else', () => {}); + ", + None, + ), + ( + " + describe('my class', () => { + describe('#myMethod', () => {}); + describe('a class named ' + myClass.name, () => {}); + }); + ", + None, + ), + ( + " + const test = { content: () => 'foo' }; + test.content(`something that is not from jest`, () => {}); + test.content(`something that is not from jest`, () => {}); + ", + None, + ), + ( + " + const describe = { content: () => 'foo' }; + describe.content(`something that is not from jest`, () => {}); + describe.content(`something that is not from jest`, () => {}); + ", + None, + ), + ( + " + describe.each` + description + ${'b'} + `('$description', () => {}); + + describe.each` + description + ${'a'} + `('$description', () => {}); + ", + None, + ), + ( + " + describe('top level', () => { + describe.each``('nested each', () => { + describe.each``('nested nested each', () => {}); + }); + + describe('nested', () => {}); + }); + ", + None, + ), + ( + " + describe.each``('my title', value => {}); + describe.each``('my title', value => {}); + describe.each([])('my title', value => {}); + describe.each([])('my title', value => {}); + ", + None, + ), + ( + " + describe.each([])('when the value is %s', value => {}); + describe.each([])('when the value is %s', value => {}); + ", + None, + ), + ]; + + let fail = vec![ + ( + " + describe('foo', () => { + it('works', () => {}); + it('works', () => {}); + }); + ", + None, + ), + ( + " + it('works', () => {}); + it('works', () => {}); + ", + None, + ), + ( + " + test.only('this', () => {}); + test('this', () => {}); + ", + None, + ), + ( + " + xtest('this', () => {}); + test('this', () => {}); + ", + None, + ), + ( + " + test.only('this', () => {}); + test.only('this', () => {}); + ", + None, + ), + ( + " + test.concurrent('this', () => {}); + test.concurrent('this', () => {}); + ", + None, + ), + ( + " + test.only('this', () => {}); + test.concurrent('this', () => {}); + ", + None, + ), + ( + " + describe('foo', () => {}); + describe('foo', () => {}); + ", + None, + ), + ( + " + describe('foo', () => {}); + xdescribe('foo', () => {}); + ", + None, + ), + ( + " + fdescribe('foo', () => {}); + describe('foo', () => {}); + ", + None, + ), + ( + " + describe('foo', () => { + describe('foe', () => {}); + }); + describe('foo', () => {}); + ", + None, + ), + ( + " + describe('foo', () => { + it(`catches backticks with the same title`, () => {}); + it(`catches backticks with the same title`, () => {}); + }); + ", + None, + ), + // ( + // " + // context('foo', () => { + // describe('foe', () => {}); + // }); + // describe('foo', () => {}); + // ", + // None, + // ), + ]; + + Tester::new(NoIdenticalTitle::NAME, pass, fail).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/no_identical_title.snap b/crates/oxc_linter/src/snapshots/no_identical_title.snap new file mode 100644 index 000000000..24e7561ac --- /dev/null +++ b/crates/oxc_linter/src/snapshots/no_identical_title.snap @@ -0,0 +1,113 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: no_identical_title +--- + ⚠ eslint(jest/no-identical-title): "Test title is used multiple times in the same describe block." + ╭─[no_identical_title.tsx:3:1] + 3 │ it('works', () => {}); + 4 │ it('works', () => {}); + · ───────────────────── + 5 │ }); + ╰──── + help: "Change the title of test." + + ⚠ eslint(jest/no-identical-title): "Test title is used multiple times in the same describe block." + ╭─[no_identical_title.tsx:2:1] + 2 │ it('works', () => {}); + 3 │ it('works', () => {}); + · ───────────────────── + 4 │ + ╰──── + help: "Change the title of test." + + ⚠ eslint(jest/no-identical-title): "Test title is used multiple times in the same describe block." + ╭─[no_identical_title.tsx:2:1] + 2 │ test.only('this', () => {}); + 3 │ test('this', () => {}); + · ────────────────────── + 4 │ + ╰──── + help: "Change the title of test." + + ⚠ eslint(jest/no-identical-title): "Test title is used multiple times in the same describe block." + ╭─[no_identical_title.tsx:2:1] + 2 │ xtest('this', () => {}); + 3 │ test('this', () => {}); + · ────────────────────── + 4 │ + ╰──── + help: "Change the title of test." + + ⚠ eslint(jest/no-identical-title): "Test title is used multiple times in the same describe block." + ╭─[no_identical_title.tsx:2:1] + 2 │ test.only('this', () => {}); + 3 │ test.only('this', () => {}); + · ─────────────────────────── + 4 │ + ╰──── + help: "Change the title of test." + + ⚠ eslint(jest/no-identical-title): "Test title is used multiple times in the same describe block." + ╭─[no_identical_title.tsx:2:1] + 2 │ test.concurrent('this', () => {}); + 3 │ test.concurrent('this', () => {}); + · ───────────────────────────────── + 4 │ + ╰──── + help: "Change the title of test." + + ⚠ eslint(jest/no-identical-title): "Test title is used multiple times in the same describe block." + ╭─[no_identical_title.tsx:2:1] + 2 │ test.only('this', () => {}); + 3 │ test.concurrent('this', () => {}); + · ───────────────────────────────── + 4 │ + ╰──── + help: "Change the title of test." + + ⚠ eslint(jest/no-identical-title): "Describe block title is used multiple times in the same describe block." + ╭─[no_identical_title.tsx:2:1] + 2 │ describe('foo', () => {}); + 3 │ describe('foo', () => {}); + · ───────────────────────── + 4 │ + ╰──── + help: "Change the title of describe block." + + ⚠ eslint(jest/no-identical-title): "Describe block title is used multiple times in the same describe block." + ╭─[no_identical_title.tsx:1:1] + 1 │ + 2 │ describe('foo', () => {}); + · ───────────────────────── + 3 │ xdescribe('foo', () => {}); + ╰──── + help: "Change the title of describe block." + + ⚠ eslint(jest/no-identical-title): "Describe block title is used multiple times in the same describe block." + ╭─[no_identical_title.tsx:2:1] + 2 │ fdescribe('foo', () => {}); + 3 │ describe('foo', () => {}); + · ───────────────────────── + 4 │ + ╰──── + help: "Change the title of describe block." + + ⚠ eslint(jest/no-identical-title): "Describe block title is used multiple times in the same describe block." + ╭─[no_identical_title.tsx:4:1] + 4 │ }); + 5 │ describe('foo', () => {}); + · ───────────────────────── + 6 │ + ╰──── + help: "Change the title of describe block." + + ⚠ eslint(jest/no-identical-title): "Test title is used multiple times in the same describe block." + ╭─[no_identical_title.tsx:3:1] + 3 │ it(`catches backticks with the same title`, () => {}); + 4 │ it(`catches backticks with the same title`, () => {}); + · ───────────────────────────────────────────────────── + 5 │ }); + ╰──── + help: "Change the title of test." + +