Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(linter): add the no_identical_title rule for vitest #7889

Closed
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/oxc_linter/src/rules.rs
Original file line number Diff line number Diff line change
@@ -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,
60 changes: 2 additions & 58 deletions crates/oxc_linter/src/rules/jest/no_identical_title.rs
Original file line number Diff line number Diff line change
@@ -169,7 +169,7 @@ fn get_closest_block(node: &AstNode, ctx: &LintContext) -> Option<NodeId> {
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();
}
208 changes: 208 additions & 0 deletions crates/oxc_linter/src/rules/vitest/no_identical_title.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<(Span, JestFnKind, NodeId)>>();
// 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<NodeId> {
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();
}
Loading