From 85651af841fb64d61ae88bad2a908dfb41bcf167 Mon Sep 17 00:00:00 2001 From: cin Date: Mon, 6 Nov 2023 12:32:06 +0800 Subject: [PATCH] feat(linter): jest/prefer-todo rule (#1065) --- crates/oxc_linter/src/rules.rs | 2 + .../oxc_linter/src/rules/jest/prefer_todo.rs | 260 ++++++++++++++++++ .../oxc_linter/src/snapshots/prefer_todo.snap | 60 ++++ 3 files changed, 322 insertions(+) create mode 100644 crates/oxc_linter/src/rules/jest/prefer_todo.rs create mode 100644 crates/oxc_linter/src/snapshots/prefer_todo.snap diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index 7d38873e05936..bafc796d70476 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -119,6 +119,7 @@ mod jest { pub mod no_mocks_import; pub mod no_standalone_expect; pub mod no_test_prefixes; + pub mod prefer_todo; pub mod valid_describe_callback; pub mod valid_expect; pub mod valid_title; @@ -262,6 +263,7 @@ oxc_macros::declare_all_lint_rules! { jest::no_mocks_import, jest::no_standalone_expect, jest::no_test_prefixes, + jest::prefer_todo, jest::valid_describe_callback, jest::valid_expect, jest::valid_title, diff --git a/crates/oxc_linter/src/rules/jest/prefer_todo.rs b/crates/oxc_linter/src/rules/jest/prefer_todo.rs new file mode 100644 index 0000000000000..e8f6e57becabc --- /dev/null +++ b/crates/oxc_linter/src/rules/jest/prefer_todo.rs @@ -0,0 +1,260 @@ +use oxc_ast::{ + ast::{Argument, CallExpression, Expression, MemberExpression}, + AstKind, +}; +use oxc_diagnostics::{ + miette::{self, Diagnostic}, + thiserror::Error, +}; +use oxc_macros::declare_oxc_lint; +use oxc_span::Span; + +use crate::{ + context::LintContext, + fixer::Fix, + rule::Rule, + utils::{is_type_of_jest_fn_call, JestFnKind, JestGeneralFnKind}, + AstNode, +}; + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-jest(prefer-todo): Suggest using `test.todo`.")] +#[diagnostic(severity(warning))] +pub struct EmptyTest(#[label] pub Span); + +#[derive(Debug, Error, Diagnostic)] +#[error("eslint-plugin-jest(prefer-todo): Suggest using `test.todo`.")] +#[diagnostic(severity(warning))] +struct UmImplementedTestDiagnostic(#[label] pub Span); + +#[derive(Debug, Default, Clone)] +pub struct PreferTodo; + +declare_oxc_lint!( + /// ### What it does + /// When test cases are empty then it is better to mark them as `test.todo` as it + /// will be highlighted in the summary output. + /// + /// ### Why is this bad? + /// + /// This rule triggers a warning if empty test cases are used without 'test.todo'. + /// + /// ### Example + /// + /// ```javascript + /// test('i need to write this test'); // invalid + /// test('i need to write this test', () => {}); // invalid + /// test.skip('i need to write this test', () => {}); // invalid + /// + /// test.todo('i need to write this test'); + /// ``` + PreferTodo, + style, +); + +impl Rule for PreferTodo { + fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { + if let AstKind::CallExpression(call_expr) = node.kind() { + let counts = call_expr.arguments.len(); + + if counts < 1 + || should_filter_case(call_expr) + || !is_string_type(&call_expr.arguments[0]) + || !is_type_of_jest_fn_call( + call_expr, + node, + ctx, + &[JestFnKind::General(JestGeneralFnKind::Test)], + ) + { + return; + } + + if counts == 1 && !filter_todo_case(call_expr) { + let (content, span) = get_fix_content(call_expr); + ctx.diagnostic_with_fix(UmImplementedTestDiagnostic(span), || { + Fix::new(content, span) + }); + } + + if counts > 1 && is_empty_function(call_expr) { + ctx.diagnostic_with_fix(EmptyTest(call_expr.span), || { + let (content, span) = build_code(call_expr, ctx); + Fix::new(content, span) + }); + } + } + } +} + +fn filter_todo_case(expr: &CallExpression) -> bool { + if let Expression::MemberExpression(mem_expr) = &expr.callee { + if let Some(name) = mem_expr.static_property_name() { + return name == "todo"; + } + } + false +} + +fn should_filter_case(expr: &CallExpression) -> bool { + let result = match &expr.callee { + Expression::Identifier(ident) => ident.name.starts_with('x') || ident.name.starts_with('f'), + _ => false, + }; + result || filter_todo_case(expr) +} + +fn is_string_type(arg: &Argument) -> bool { + matches!( + arg, + Argument::Expression(Expression::StringLiteral(_) | Expression::TemplateLiteral(_)) + ) +} + +fn is_empty_function(expr: &CallExpression) -> bool { + match &expr.arguments[1] { + Argument::Expression(Expression::ArrowExpression(arrow)) => arrow.body.is_empty(), + Argument::Expression(Expression::FunctionExpression(func)) => { + let Some(func_body) = &func.body else { + return false; + }; + func_body.is_empty() + } + _ => false, + } +} + +fn get_fix_content<'a>(expr: &'a CallExpression<'a>) -> (&'a str, Span) { + match &expr.callee { + Expression::Identifier(ident) => { + (".todo", Span { start: ident.span.end, end: ident.span.end }) + } + Expression::MemberExpression(mem_expr) => { + if let Some((span, _)) = mem_expr.static_property_info() { + return ("todo", span); + } + ("", expr.span) + } + _ => ("", expr.span), + } +} + +fn build_code(expr: &CallExpression, ctx: &LintContext) -> (String, Span) { + let mut formatter = ctx.formatter(); + + if let Expression::Identifier(ident) = &expr.callee { + formatter.print_str(ident.name.as_bytes()); + formatter.print_str(b".todo("); + } else if let Expression::MemberExpression(mem_expr) = &expr.callee { + match &**mem_expr { + MemberExpression::ComputedMemberExpression(expr) => { + if let Expression::Identifier(ident) = &expr.object { + formatter.print_str(ident.name.as_bytes()); + formatter.print_str(b"["); + formatter.print_str(b"'todo'"); + formatter.print_str(b"]("); + } + } + MemberExpression::StaticMemberExpression(expr) => { + if let Expression::Identifier(ident) = &expr.object { + formatter.print_str(ident.name.as_bytes()); + formatter.print_str(b".todo("); + } + } + MemberExpression::PrivateFieldExpression(_) => {} + } + } + + if let Argument::Expression(Expression::StringLiteral(ident)) = &expr.arguments[0] { + // Todo: this punctuation should read from the config + formatter.print(b'\''); + formatter.print_str(ident.value.as_bytes()); + formatter.print(b'\''); + formatter.print(b')'); + } else if let Argument::Expression(Expression::TemplateLiteral(temp)) = &expr.arguments[0] { + formatter.print(b'`'); + for q in &temp.quasis { + formatter.print_str(q.value.raw.as_bytes()); + } + formatter.print(b'`'); + formatter.print(b')'); + } + + (formatter.into_code(), expr.span) +} + +#[test] +fn tests() { + use crate::tester::Tester; + + let pass = vec![ + ("test()", None), + ("test.concurrent()", None), + ("test.todo('i need to write this test correct');", None), + ("test(obj)", None), + ("test.concurrent(obj)", None), + ("fit('foo')", None), + ("fit.concurrent('foo')", None), + ("xit('foo')", None), + ("test('foo', 1)", None), + ("test('stub', () => expect(1).toBe(1));", None), + ("test.concurrent('stub', () => expect(1).toBe(1));", None), + ( + " + supportsDone && params.length < test.length + ? done => test(...params, done) + : () => test(...params); + ", + None, + ), + ]; + + let fail = vec![ + ("test('i need to write this test');", None), + ("test('i need to write this test',);", None), + ("test(`i need to write this test`);", None), + ("it('foo', function () {})", None), + ("it('foo', () => {})", None), + ("test.skip('i need to write this test', () => {});", None), + ("test.skip('i need to write this test', function() {});", None), + ("test[`skip`]('i need to write this test', function() {});", None), + ("test[`skip`]('i need to write this test', function() {});", None), + ]; + + let fix = vec![ + ( + "test.skip('i need to write this test');", + "test.todo('i need to write this test');", + None, + ), + ("test('i need to write this test',);", "test.todo('i need to write this test',);", None), + ("test(`i need to write this test`);", "test.todo(`i need to write this test`);", None), + ("it.skip('foo', function () {})", "it.todo('foo')", None), + ("it(`i need to write this test`, () => {})", "it.todo(`i need to write this test`)", None), + ( + "test.skip('i need to write this test', () => {});", + "test.todo('i need to write this test');", + None, + ), + ( + "test.skip('i need to write this test', function() {});", + "test.todo('i need to write this test');", + None, + ), + ( + "test['skip']('i need to write this test', function() {});", + "test['todo']('i need to write this test');", + None, + ), + ( + "test['skip']('i need to write this test', () => {});", + "test['todo']('i need to write this test');", + None, + ), + ]; + + Tester::new(PreferTodo::NAME, pass, fail) + .with_jest_plugin(true) + .expect_fix(fix) + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/prefer_todo.snap b/crates/oxc_linter/src/snapshots/prefer_todo.snap new file mode 100644 index 0000000000000..f08e1866bb70e --- /dev/null +++ b/crates/oxc_linter/src/snapshots/prefer_todo.snap @@ -0,0 +1,60 @@ +--- +source: crates/oxc_linter/src/tester.rs +assertion_line: 105 +expression: prefer_todo +--- + ⚠ eslint-plugin-jest(prefer-todo): Suggest using `test.todo`. + ╭─[prefer_todo.tsx:1:1] + 1 │ test('i need to write this test'); + · ▲ + ╰──── + + ⚠ eslint-plugin-jest(prefer-todo): Suggest using `test.todo`. + ╭─[prefer_todo.tsx:1:1] + 1 │ test('i need to write this test',); + · ▲ + ╰──── + + ⚠ eslint-plugin-jest(prefer-todo): Suggest using `test.todo`. + ╭─[prefer_todo.tsx:1:1] + 1 │ test(`i need to write this test`); + · ▲ + ╰──── + + ⚠ eslint-plugin-jest(prefer-todo): Suggest using `test.todo`. + ╭─[prefer_todo.tsx:1:1] + 1 │ it('foo', function () {}) + · ───────────────────────── + ╰──── + + ⚠ eslint-plugin-jest(prefer-todo): Suggest using `test.todo`. + ╭─[prefer_todo.tsx:1:1] + 1 │ it('foo', () => {}) + · ─────────────────── + ╰──── + + ⚠ eslint-plugin-jest(prefer-todo): Suggest using `test.todo`. + ╭─[prefer_todo.tsx:1:1] + 1 │ test.skip('i need to write this test', () => {}); + · ──────────────────────────────────────────────── + ╰──── + + ⚠ eslint-plugin-jest(prefer-todo): Suggest using `test.todo`. + ╭─[prefer_todo.tsx:1:1] + 1 │ test.skip('i need to write this test', function() {}); + · ───────────────────────────────────────────────────── + ╰──── + + ⚠ eslint-plugin-jest(prefer-todo): Suggest using `test.todo`. + ╭─[prefer_todo.tsx:1:1] + 1 │ test[`skip`]('i need to write this test', function() {}); + · ──────────────────────────────────────────────────────── + ╰──── + + ⚠ eslint-plugin-jest(prefer-todo): Suggest using `test.todo`. + ╭─[prefer_todo.tsx:1:1] + 1 │ test[`skip`]('i need to write this test', function() {}); + · ──────────────────────────────────────────────────────── + ╰──── + +