diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index db30b32abc919..aa40f6e7df8f8 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -148,6 +148,7 @@ mod typescript { mod jest { pub mod expect_expect; pub mod max_expects; + pub mod max_nested_describe; pub mod no_alias_methods; pub mod no_commented_out_tests; pub mod no_conditional_expect; @@ -525,6 +526,7 @@ oxc_macros::declare_all_lint_rules! { typescript::prefer_literal_enum_member, jest::expect_expect, jest::max_expects, + jest::max_nested_describe, jest::no_alias_methods, jest::no_commented_out_tests, jest::no_conditional_expect, diff --git a/crates/oxc_linter/src/rules/jest/max_nested_describe.rs b/crates/oxc_linter/src/rules/jest/max_nested_describe.rs new file mode 100644 index 0000000000000..a007704067e11 --- /dev/null +++ b/crates/oxc_linter/src/rules/jest/max_nested_describe.rs @@ -0,0 +1,397 @@ +use oxc_ast::AstKind; +use oxc_diagnostics::OxcDiagnostic; +use oxc_macros::declare_oxc_lint; +use oxc_semantic::ScopeId; +use oxc_span::Span; + +use crate::{ + context::LintContext, + rule::Rule, + utils::{ + collect_possible_jest_call_node, is_type_of_jest_fn_call, JestFnKind, JestGeneralFnKind, + PossibleJestNode, + }, +}; + +fn exceeded_max_depth(current: usize, max: usize, span0: Span) -> OxcDiagnostic { + OxcDiagnostic::warn( + "eslint-plugin-jest(max-nested-describe): Enforces a maximum depth to nested describe calls.", + ) + .with_help(format!("Too many nested describe calls ({current}) - maximum allowed is {max}")) + .with_labels([span0.into()]) +} + +#[derive(Debug, Clone)] +pub struct MaxNestedDescribe { + pub max: usize, +} + +impl Default for MaxNestedDescribe { + fn default() -> Self { + Self { max: 5 } + } +} + +declare_oxc_lint!( + /// ### What it does + /// + /// This rule enforces a maximum depth to nested `describe()` calls to improve code + /// clarity in your tests. + /// + /// The following patterns are considered warnings (with the default option of + /// `{ "max": 5 } `): + /// + /// ### Example + /// + /// ```javascript + /// + /// // invalid + /// describe('foo', () => { + /// describe('bar', () => { + /// describe('baz', () => { + /// describe('qux', () => { + /// describe('quxx', () => { + /// describe('too many', () => { + /// it('should get something', () => { + /// expect(getSomething()).toBe('Something'); + /// }); + /// }); + /// }); + /// }); + /// }); + /// }); + /// }); + /// + /// describe('foo', function () { + /// describe('bar', function () { + /// describe('baz', function () { + /// describe('qux', function () { + /// describe('quxx', function () { + /// describe('too many', function () { + /// it('should get something', () => { + /// expect(getSomething()).toBe('Something'); + /// }); + /// }); + /// }); + /// }); + /// }); + /// }); + /// }); + /// + /// // valid + /// describe('foo', () => { + /// describe('bar', () => { + /// it('should get something', () => { + /// expect(getSomething()).toBe('Something'); + /// }); + /// }); + /// describe('qux', () => { + /// it('should get something', () => { + /// expect(getSomething()).toBe('Something'); + /// }); + /// }); + /// }); + /// + /// describe('foo2', function () { + /// it('should get something', () => { + /// expect(getSomething()).toBe('Something'); + /// }); + /// }); + /// + /// describe('foo', function () { + /// describe('bar', function () { + /// describe('baz', function () { + /// describe('qux', function () { + /// describe('this is the limit', function () { + /// it('should get something', () => { + /// expect(getSomething()).toBe('Something'); + /// }); + /// }); + /// }); + /// }); + /// }); + /// }); + /// ``` + /// + MaxNestedDescribe, + style, +); + +impl Rule for MaxNestedDescribe { + fn from_configuration(value: serde_json::Value) -> Self { + let max = value + .get(0) + .and_then(|config| config.get("max")) + .and_then(serde_json::Value::as_number) + .and_then(serde_json::Number::as_u64) + .map_or(5, |v| usize::try_from(v).unwrap_or(5)); + + Self { max } + } + + fn run_once(&self, ctx: &LintContext) { + let mut describes_hooks_depth: Vec = vec![]; + let mut possibles_jest_nodes = collect_possible_jest_call_node(ctx); + possibles_jest_nodes.sort_by_key(|n| n.node.id()); + + for possible_jest_node in &possibles_jest_nodes { + self.run(possible_jest_node, &mut describes_hooks_depth, ctx); + } + } +} + +impl MaxNestedDescribe { + fn run<'a>( + &self, + possible_jest_node: &PossibleJestNode<'a, '_>, + describes_hooks_depth: &mut Vec, + ctx: &LintContext<'a>, + ) { + let node = possible_jest_node.node; + let scope_id = node.scope_id(); + let AstKind::CallExpression(call_expr) = node.kind() else { + return; + }; + let is_describe_call = is_type_of_jest_fn_call( + call_expr, + possible_jest_node, + ctx, + &[JestFnKind::General(JestGeneralFnKind::Describe)], + ); + + if is_describe_call && !describes_hooks_depth.contains(&scope_id) { + describes_hooks_depth.push(scope_id); + } + + if is_describe_call && describes_hooks_depth.len() > self.max { + ctx.diagnostic(exceeded_max_depth( + describes_hooks_depth.len(), + self.max, + call_expr.span, + )); + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + ( + " + describe('foo', function() { + describe('bar', function () { + describe('baz', function () { + describe('qux', function () { + describe('qux', function () { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }) + }) + }) + }) + }); + ", + None, + ), + ( + " + describe('foo', function() { + describe('bar', function () { + describe('baz', function () { + describe('qux', function () { + describe('qux', function () { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + + fdescribe('qux', () => { + it('something', async () => { + expect('something').toBe('something'); + }); + }); + }) + }) + }) + }); + ", + None, + ), + ( + " + describe('foo', () => { + describe('bar', () => { + it('hello', async () => { + expect('hello').toBe('hello'); + }); + }); + }); + + xdescribe('foo', function() { + describe('bar', function() { + it('something', async () => { + expect('something').toBe('something'); + }); + }); + }); + ", + None, + ), + ( + " + describe('foo', () => { + describe.only('bar', () => { + describe.skip('baz', () => { + it('something', async () => { + expect('something').toBe('something'); + }); + }); + }); + }); + ", + Some(serde_json::json!([{ "max": 3 }])), + ), + ( + " + it('something', async () => { + expect('something').toBe('something'); + }); + ", + Some(serde_json::json!([{ "max": 0 }])), + ), + ( + " + describe('foo', () => { + describe.each(['hello', 'world'])(\"%s\", (a) => {}); + }); + ", + None, + ), + ( + " + describe('foo', () => { + describe.each` + foo | bar + ${'1'} | ${'2'} + `('$foo $bar', ({ foo, bar }) => {}); + }); + ", + None, + ), + ]; + + let fail = vec![ + ( + " + describe('foo', function() { + describe('bar', function () { + describe('baz', function () { + describe('qux', function () { + describe('quxx', function () { + describe('over limit', function () { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + }); + }); + }); + ", + None, + ), + ( + " + describe('foo', () => { + describe('bar', () => { + describe('baz', () => { + describe('baz1', () => { + describe('baz2', () => { + describe('baz3', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + + describe('baz4', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + }); + + describe('qux', function () { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }) + }); + ", + None, + ), + ( + " + fdescribe('foo', () => { + describe.only('bar', () => { + describe.skip('baz', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + + describe('baz', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + }); + }); + + xdescribe('qux', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + ", + Some(serde_json::json!([{ "max": 2 }])), + ), + ( + " + describe('qux', () => { + it('should get something', () => { + expect(getSomething()).toBe('Something'); + }); + }); + ", + Some(serde_json::json!([{ "max": 0 }])), + ), + ( + " + describe('foo', () => { + describe.each(['hello', 'world'])(\"%s\", (a) => {}); + }); + ", + Some(serde_json::json!([{ "max": 1 }])), + ), + ( + " + describe('foo', () => { + describe.each` + foo | bar + ${'1'} | ${'2'} + `('$foo $bar', ({ foo, bar }) => {}); + }); + ", + Some(serde_json::json!([{ "max": 1 }])), + ), + ]; + + Tester::new(MaxNestedDescribe::NAME, pass, fail).with_jest_plugin(true).test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/max_nested_describe.snap b/crates/oxc_linter/src/snapshots/max_nested_describe.snap new file mode 100644 index 0000000000000..7ad0e897168af --- /dev/null +++ b/crates/oxc_linter/src/snapshots/max_nested_describe.snap @@ -0,0 +1,120 @@ +--- +source: crates/oxc_linter/src/tester.rs +assertion_line: 203 +expression: max_nested_describe +--- + ⚠ eslint-plugin-jest(max-nested-describe): Enforces a maximum depth to nested describe calls. + ╭─[max_nested_describe.tsx:7:37] + 6 │ describe('quxx', function () { + 7 │ ╭─▶ describe('over limit', function () { + 8 │ │ it('should get something', () => { + 9 │ │ expect(getSomething()).toBe('Something'); + 10 │ │ }); + 11 │ ╰─▶ }); + 12 │ }); + ╰──── + help: Too many nested describe calls (6) - maximum allowed is 5 + + ⚠ eslint-plugin-jest(max-nested-describe): Enforces a maximum depth to nested describe calls. + ╭─[max_nested_describe.tsx:7:37] + 6 │ describe('baz2', () => { + 7 │ ╭─▶ describe('baz3', () => { + 8 │ │ it('should get something', () => { + 9 │ │ expect(getSomething()).toBe('Something'); + 10 │ │ }); + 11 │ ╰─▶ }); + 12 │ + ╰──── + help: Too many nested describe calls (6) - maximum allowed is 5 + + ⚠ eslint-plugin-jest(max-nested-describe): Enforces a maximum depth to nested describe calls. + ╭─[max_nested_describe.tsx:13:37] + 12 │ + 13 │ ╭─▶ describe('baz4', () => { + 14 │ │ it('should get something', () => { + 15 │ │ expect(getSomething()).toBe('Something'); + 16 │ │ }); + 17 │ ╰─▶ }); + 18 │ }); + ╰──── + help: Too many nested describe calls (6) - maximum allowed is 5 + + ⚠ eslint-plugin-jest(max-nested-describe): Enforces a maximum depth to nested describe calls. + ╭─[max_nested_describe.tsx:22:25] + 21 │ + 22 │ ╭─▶ describe('qux', function () { + 23 │ │ it('should get something', () => { + 24 │ │ expect(getSomething()).toBe('Something'); + 25 │ │ }); + 26 │ ╰─▶ }); + 27 │ }) + ╰──── + help: Too many nested describe calls (6) - maximum allowed is 5 + + ⚠ eslint-plugin-jest(max-nested-describe): Enforces a maximum depth to nested describe calls. + ╭─[max_nested_describe.tsx:4:25] + 3 │ describe.only('bar', () => { + 4 │ ╭─▶ describe.skip('baz', () => { + 5 │ │ it('should get something', () => { + 6 │ │ expect(getSomething()).toBe('Something'); + 7 │ │ }); + 8 │ ╰─▶ }); + 9 │ + ╰──── + help: Too many nested describe calls (3) - maximum allowed is 2 + + ⚠ eslint-plugin-jest(max-nested-describe): Enforces a maximum depth to nested describe calls. + ╭─[max_nested_describe.tsx:10:25] + 9 │ + 10 │ ╭─▶ describe('baz', () => { + 11 │ │ it('should get something', () => { + 12 │ │ expect(getSomething()).toBe('Something'); + 13 │ │ }); + 14 │ ╰─▶ }); + 15 │ }); + ╰──── + help: Too many nested describe calls (3) - maximum allowed is 2 + + ⚠ eslint-plugin-jest(max-nested-describe): Enforces a maximum depth to nested describe calls. + ╭─[max_nested_describe.tsx:18:17] + 17 │ + 18 │ ╭─▶ xdescribe('qux', () => { + 19 │ │ it('should get something', () => { + 20 │ │ expect(getSomething()).toBe('Something'); + 21 │ │ }); + 22 │ ╰─▶ }); + 23 │ + ╰──── + help: Too many nested describe calls (3) - maximum allowed is 2 + + ⚠ eslint-plugin-jest(max-nested-describe): Enforces a maximum depth to nested describe calls. + ╭─[max_nested_describe.tsx:2:17] + 1 │ + 2 │ ╭─▶ describe('qux', () => { + 3 │ │ it('should get something', () => { + 4 │ │ expect(getSomething()).toBe('Something'); + 5 │ │ }); + 6 │ ╰─▶ }); + 7 │ + ╰──── + help: Too many nested describe calls (1) - maximum allowed is 0 + + ⚠ eslint-plugin-jest(max-nested-describe): Enforces a maximum depth to nested describe calls. + ╭─[max_nested_describe.tsx:3:21] + 2 │ describe('foo', () => { + 3 │ describe.each(['hello', 'world'])("%s", (a) => {}); + · ────────────────────────────────────────────────── + 4 │ }); + ╰──── + help: Too many nested describe calls (2) - maximum allowed is 1 + + ⚠ eslint-plugin-jest(max-nested-describe): Enforces a maximum depth to nested describe calls. + ╭─[max_nested_describe.tsx:3:21] + 2 │ describe('foo', () => { + 3 │ ╭─▶ describe.each` + 4 │ │ foo | bar + 5 │ │ ${'1'} | ${'2'} + 6 │ ╰─▶ `('$foo $bar', ({ foo, bar }) => {}); + 7 │ }); + ╰──── + help: Too many nested describe calls (2) - maximum allowed is 1