feat(linter): add jest/no-identical-title rule (#957)

https://github.com/jest-community/eslint-plugin-jest/blob/main/docs/rules/no-identical-title.md
This commit is contained in:
Wenzhe Wang 2023-10-07 04:11:53 -05:00 committed by GitHub
parent 21066a9ad7
commit 7a62d4bdbc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 608 additions and 0 deletions

View file

@ -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,

View file

@ -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::<Vec<(Span, JestFnKind, AstNodeId)>>();
// 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();
}

View file

@ -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."