From ded59bc35b3bd343a718d44d7ecfa31fdab0d3e0 Mon Sep 17 00:00:00 2001 From: cinchen Date: Tue, 28 May 2024 21:23:16 +0800 Subject: [PATCH] feat(linter): eslint-plugin-jest/require-top-level-describe (#3439) part of https://github.com/oxc-project/oxc/issues/492 Rule Detail: [link](https://github.com/jest-community/eslint-plugin-jest/blob/main/src/rules/require-top-level-describe.ts) --------- Co-authored-by: wenzhe --- crates/oxc_linter/src/rules.rs | 2 + .../rules/jest/require_top_level_describe.rs | 393 ++++++++++++++++++ .../snapshots/require_top_level_describe.snap | 132 ++++++ 3 files changed, 527 insertions(+) create mode 100644 crates/oxc_linter/src/rules/jest/require_top_level_describe.rs create mode 100644 crates/oxc_linter/src/snapshots/require_top_level_describe.snap diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index afd54bd39..f6e4c5b56 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -183,6 +183,7 @@ mod jest { pub mod prefer_todo; pub mod require_hook; pub mod require_to_throw_message; + pub mod require_top_level_describe; pub mod valid_describe_callback; pub mod valid_expect; pub mod valid_title; @@ -552,6 +553,7 @@ oxc_macros::declare_all_lint_rules! { jest::prefer_todo, jest::require_hook, jest::require_to_throw_message, + jest::require_top_level_describe, jest::valid_describe_callback, jest::valid_expect, jest::valid_title, diff --git a/crates/oxc_linter/src/rules/jest/require_top_level_describe.rs b/crates/oxc_linter/src/rules/jest/require_top_level_describe.rs new file mode 100644 index 000000000..4779f9852 --- /dev/null +++ b/crates/oxc_linter/src/rules/jest/require_top_level_describe.rs @@ -0,0 +1,393 @@ +use std::collections::HashMap; + +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, parse_jest_fn_call, JestFnKind, JestGeneralFnKind, + ParsedGeneralJestFnCall, ParsedJestFnCallNew, PossibleJestNode, + }, +}; + +fn too_many_describes(max: usize, repeat: &str, span0: Span) -> OxcDiagnostic { + OxcDiagnostic::warn( + "eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block", + ) + .with_help(format!("There should not be more than {max:?} describe{repeat} at the top level.")) + .with_labels([span0.into()]) +} + +fn unexpected_test_case(span0: Span) -> OxcDiagnostic { + OxcDiagnostic::warn( + "eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block", + ) + .with_help("All test cases must be wrapped in a describe block.") + .with_labels([span0.into()]) +} + +fn unexpected_hook(span0: Span) -> OxcDiagnostic { + OxcDiagnostic::warn( + "eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block", + ) + .with_help("All hooks must be wrapped in a describe block.") + .with_labels([span0.into()]) +} + +#[derive(Debug, Clone)] +pub struct RequireTopLevelDescribe { + pub max_number_of_top_level_describes: usize, +} + +impl Default for RequireTopLevelDescribe { + fn default() -> Self { + Self { max_number_of_top_level_describes: usize::MAX } + } +} + +declare_oxc_lint!( + /// ### What it does + /// + /// This rule triggers a warning if a test case (`test` and `it`) or a hook + /// (`beforeAll`, `beforeEach`, `afterEach`, `afterAll`) is not located in a + /// top-level `describe` block. + /// + /// ### Example + /// + /// ```javascript + /// // invalid + /// Above a describe block + /// test('my test', () => {}); + /// describe('test suite', () => { + /// it('test', () => {}); + /// }); + /// // Below a describe block + /// describe('test suite', () => {}); + /// test('my test', () => {}); + /// // Same for hooks + /// beforeAll('my beforeAll', () => {}); + /// describe('test suite', () => {}); + /// afterEach('my afterEach', () => {}); + /// + /// //valid + /// // Above a describe block + /// // In a describe block + /// describe('test suite', () => { + /// test('my test', () => {}); + /// }); + /// + /// // In a nested describe block + /// describe('test suite', () => { + /// test('my test', () => {}); + /// describe('another test suite', () => { + /// test('my other test', () => {}); + /// }); + /// }); + /// ``` + /// + /// ### Options + /// + /// You can also enforce a limit on the number of describes allowed at the top-level + /// using the `maxNumberOfTopLevelDescribes` option: + /// + /// ```json + /// { + /// "jest/require-top-level-describe": [ + /// "error", + /// { + /// "maxNumberOfTopLevelDescribes": 2 + /// } + /// ] + /// } + /// ``` + /// + RequireTopLevelDescribe, + style, +); + +impl Rule for RequireTopLevelDescribe { + fn from_configuration(value: serde_json::Value) -> Self { + let max_number_of_top_level_describes = value + .get(0) + .and_then(|config| config.get("maxNumberOfTopLevelDescribes")) + .and_then(serde_json::Value::as_number) + .and_then(serde_json::Number::as_u64) + .map_or(usize::MAX, |v| usize::try_from(v).unwrap_or(usize::MAX)); + + Self { max_number_of_top_level_describes } + } + + fn run_once(&self, ctx: &LintContext) { + let mut describe_contexts: HashMap = HashMap::new(); + 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 describe_contexts, ctx); + } + } +} + +impl RequireTopLevelDescribe { + fn run<'a>( + &self, + possible_jest_node: &PossibleJestNode<'a, '_>, + describe_contexts: &mut HashMap, + ctx: &LintContext<'a>, + ) { + let node = possible_jest_node.node; + let scopes = ctx.scopes(); + let is_top = scopes.get_flags(node.scope_id()).is_top(); + + let AstKind::CallExpression(call_expr) = node.kind() else { + return; + }; + + let Some(ParsedJestFnCallNew::GeneralJestFnCall(ParsedGeneralJestFnCall { kind, .. })) = + parse_jest_fn_call(call_expr, possible_jest_node, ctx) + else { + return; + }; + + match kind { + JestFnKind::General(JestGeneralFnKind::Test) => { + if is_top { + ctx.diagnostic(unexpected_test_case(call_expr.span)); + } + } + JestFnKind::General(JestGeneralFnKind::Hook) => { + if is_top { + ctx.diagnostic(unexpected_hook(call_expr.span)); + } + } + JestFnKind::General(JestGeneralFnKind::Describe) => { + if !is_top { + return; + } + + let Some((_, count)) = describe_contexts.get_key_value(&node.scope_id()) else { + describe_contexts.insert(node.scope_id(), 1); + return; + }; + + if count >= &self.max_number_of_top_level_describes { + ctx.diagnostic(too_many_describes( + self.max_number_of_top_level_describes, + if *count == 1 { "" } else { "s" }, + call_expr.span, + )); + } else { + describe_contexts.insert(node.scope_id(), count + 1); + } + } + _ => (), + } + } +} + +#[test] +fn test() { + use crate::tester::Tester; + + let pass = vec![ + ("it.each()", None), + ("describe(\"test suite\", () => { test(\"my test\") });", None), + ("describe(\"test suite\", () => { it(\"my test\") });", None), + ( + " + describe(\"test suite\", () => { + beforeEach(\"a\", () => {}); + describe(\"b\", () => {}); + test(\"c\", () => {}) + }); + ", + None, + ), + ("describe(\"test suite\", () => { beforeAll(\"my beforeAll\") });", None), + ("describe(\"test suite\", () => { afterEach(\"my afterEach\") });", None), + ("describe(\"test suite\", () => { afterAll(\"my afterAll\") });", None), + ( + " + describe(\"test suite\", () => { + it(\"my test\", () => {}) + describe(\"another test suite\", () => {}); + test(\"my other test\", () => {}) + }); + ", + None, + ), + ("foo()", None), + ("describe.each([1, true])(\"trues\", value => { it(\"an it\", () => expect(value).toBe(true) ); });", None), + ( + " + describe('%s', () => { + it('is fine', () => { + // + }); + }); + + describe.each('world')('%s', () => { + it.each([1, 2, 3])('%n', () => { + // + }); + }); + ", + None, + ), + ( + " + describe.each('hello')('%s', () => { + it('is fine', () => { + // + }); + }); + + describe.each('world')('%s', () => { + it.each([1, 2, 3])('%n', () => { + // + }); + }); + ", + None, + ), + ( + " + import { jest } from '@jest/globals'; + + jest.doMock('my-module'); + ", + None, + ), + ("jest.doMock(\"my-module\")", None), + ("describe(\"test suite\", () => { test(\"my test\") });", None), + ("foo()", None), + ("describe.each([1, true])(\"trues\", value => { it(\"an it\", () => expect(value).toBe(true) ); });", None), + ( + " + describe('one', () => {}); + describe('two', () => {}); + describe('three', () => {}); + ", + None, + ), + ( + " + describe('one', () => { + describe('two', () => {}); + describe('three', () => {}); + }); + ", + Some(serde_json::json!({ "maxNumberOfTopLevelDescribes": 1 })), + ), + ]; + + let fail = vec![ + ("beforeEach(\"my test\", () => {})", None), + ( + " + test(\"my test\", () => {}) + describe(\"test suite\", () => {}); + ", + None, + ), + ( + " + test(\"my test\", () => {}) + describe(\"test suite\", () => { + it(\"test\", () => {}) + }); + ", + None, + ), + ( + " + describe(\"test suite\", () => {}); + afterAll(\"my test\", () => {}) + ", + None, + ), + ( + " + import { describe, afterAll as onceEverythingIsDone } from '@jest/globals'; + + describe(\"test suite\", () => {}); + onceEverythingIsDone(\"my test\", () => {}) + ", + None, + ), + ("it.skip('test', () => {});", None), + ("it.each([1, 2, 3])('%n', () => {});", None), + ("it.skip.each([1, 2, 3])('%n', () => {});", None), + ("it.skip.each``('%n', () => {});", None), + ("it.each``('%n', () => {});", None), + ( + " + describe(\"one\", () => {}); + describe(\"two\", () => {}); + describe(\"three\", () => {}); + ", + Some(serde_json::json!([{ "maxNumberOfTopLevelDescribes": 2 }])), + ), + ( + " + describe('one', () => { + describe('one (nested)', () => {}); + describe('two (nested)', () => {}); + }); + describe('two', () => { + describe('one (nested)', () => {}); + describe('two (nested)', () => {}); + describe('three (nested)', () => {}); + }); + describe('three', () => { + describe('one (nested)', () => {}); + describe('two (nested)', () => {}); + describe('three (nested)', () => {}); + }); + ", + Some(serde_json::json!([{ "maxNumberOfTopLevelDescribes": 2 }])), + ), + ( + " + import { + describe as describe1, + describe as describe2, + describe as describe3, + } from '@jest/globals'; + + describe1('one', () => { + describe('one (nested)', () => {}); + describe('two (nested)', () => {}); + }); + describe2('two', () => { + describe('one (nested)', () => {}); + describe('two (nested)', () => {}); + describe('three (nested)', () => {}); + }); + describe3('three', () => { + describe('one (nested)', () => {}); + describe('two (nested)', () => {}); + describe('three (nested)', () => {}); + }); + ", + Some(serde_json::json!([{ "maxNumberOfTopLevelDescribes": 2 }])), + ), + ( + " + describe('one', () => {}); + describe('two', () => {}); + describe('three', () => {}); + ", + Some(serde_json::json!([{ "maxNumberOfTopLevelDescribes": 1 }])), + ), + ]; + + Tester::new(RequireTopLevelDescribe::NAME, pass, fail) + .with_jest_plugin(true) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/require_top_level_describe.snap b/crates/oxc_linter/src/snapshots/require_top_level_describe.snap new file mode 100644 index 000000000..d9ff60a07 --- /dev/null +++ b/crates/oxc_linter/src/snapshots/require_top_level_describe.snap @@ -0,0 +1,132 @@ +--- +source: crates/oxc_linter/src/tester.rs +expression: require_top_level_describe +--- + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:1:1] + 1 │ beforeEach("my test", () => {}) + · ─────────────────────────────── + ╰──── + help: All hooks must be wrapped in a describe block. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:2:17] + 1 │ + 2 │ test("my test", () => {}) + · ───────────────────────── + 3 │ describe("test suite", () => {}); + ╰──── + help: All test cases must be wrapped in a describe block. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:2:17] + 1 │ + 2 │ test("my test", () => {}) + · ───────────────────────── + 3 │ describe("test suite", () => { + ╰──── + help: All test cases must be wrapped in a describe block. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:3:17] + 2 │ describe("test suite", () => {}); + 3 │ afterAll("my test", () => {}) + · ───────────────────────────── + 4 │ + ╰──── + help: All hooks must be wrapped in a describe block. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:5:17] + 4 │ describe("test suite", () => {}); + 5 │ onceEverythingIsDone("my test", () => {}) + · ───────────────────────────────────────── + 6 │ + ╰──── + help: All hooks must be wrapped in a describe block. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:1:1] + 1 │ it.skip('test', () => {}); + · ───────────────────────── + ╰──── + help: All test cases must be wrapped in a describe block. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:1:1] + 1 │ it.each([1, 2, 3])('%n', () => {}); + · ────────────────────────────────── + ╰──── + help: All test cases must be wrapped in a describe block. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:1:1] + 1 │ it.skip.each([1, 2, 3])('%n', () => {}); + · ─────────────────────────────────────── + ╰──── + help: All test cases must be wrapped in a describe block. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:1:1] + 1 │ it.skip.each``('%n', () => {}); + · ────────────────────────────── + ╰──── + help: All test cases must be wrapped in a describe block. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:1:1] + 1 │ it.each``('%n', () => {}); + · ───────────────────────── + ╰──── + help: All test cases must be wrapped in a describe block. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:4:17] + 3 │ describe("two", () => {}); + 4 │ describe("three", () => {}); + · ─────────────────────────── + 5 │ + ╰──── + help: There should not be more than 2 describes at the top level. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:11:17] + 10 │ }); + 11 │ ╭─▶ describe('three', () => { + 12 │ │ describe('one (nested)', () => {}); + 13 │ │ describe('two (nested)', () => {}); + 14 │ │ describe('three (nested)', () => {}); + 15 │ ╰─▶ }); + 16 │ + ╰──── + help: There should not be more than 2 describes at the top level. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:17:17] + 16 │ }); + 17 │ ╭─▶ describe3('three', () => { + 18 │ │ describe('one (nested)', () => {}); + 19 │ │ describe('two (nested)', () => {}); + 20 │ │ describe('three (nested)', () => {}); + 21 │ ╰─▶ }); + 22 │ + ╰──── + help: There should not be more than 2 describes at the top level. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:3:17] + 2 │ describe('one', () => {}); + 3 │ describe('two', () => {}); + · ───────────────────────── + 4 │ describe('three', () => {}); + ╰──── + help: There should not be more than 1 describe at the top level. + + ⚠ eslint-plugin-jest(require-top-level-describe): Require test cases and hooks to be inside a `describe` block + ╭─[require_top_level_describe.tsx:4:17] + 3 │ describe('two', () => {}); + 4 │ describe('three', () => {}); + · ─────────────────────────── + 5 │ + ╰──── + help: There should not be more than 1 describe at the top level.