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): eslint-plugin-jest require-to-throw-message #2384

Merged
merged 3 commits into from
Feb 11, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Expand Up @@ -143,6 +143,7 @@ mod jest {
pub mod prefer_called_with;
pub mod prefer_equality_matcher;
pub mod prefer_todo;
pub mod require_to_throw_message;
pub mod valid_describe_callback;
pub mod valid_expect;
pub mod valid_title;
Expand Down Expand Up @@ -431,6 +432,7 @@ oxc_macros::declare_all_lint_rules! {
jest::prefer_called_with,
jest::prefer_equality_matcher,
jest::prefer_todo,
jest::require_to_throw_message,
jest::valid_describe_callback,
jest::valid_expect,
jest::valid_title,
Expand Down
183 changes: 183 additions & 0 deletions crates/oxc_linter/src/rules/jest/require_to_throw_message.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
use oxc_ast::AstKind;
use oxc_diagnostics::{
miette::{self, Diagnostic},
thiserror::Error,
};
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;

use crate::{
context::LintContext,
rule::Rule,
utils::{collect_possible_jest_call_node, parse_expect_jest_fn_call, PossibleJestNode},
};

#[derive(Debug, Error, Diagnostic)]
#[error("eslint-plugin-jest(require-to-throw-message): Require a message for `toThrow()`.")]
#[diagnostic(severity(warning), help("Add an error message to {0:?}()"))]
struct RequireToThrowMessageDiagnostic(pub String, #[label] pub Span);

#[derive(Debug, Default, Clone)]
pub struct RequireToThrowMessage;

declare_oxc_lint!(
/// ### What it does
/// This rule triggers a warning if `toThrow()` or `toThrowError()` is used without an error message.
///
/// ### Example
/// ```javascript
/// // invalid
/// test('all the things', async () => {
/// expect(() => a()).toThrow();
/// expect(() => a()).toThrowError();
/// await expect(a()).rejects.toThrow();
/// await expect(a()).rejects.toThrowError();
/// });
///
/// // valid
/// test('all the things', async () => {
/// expect(() => a()).toThrow('a');
/// expect(() => a()).toThrowError('a');
/// await expect(a()).rejects.toThrow('a');
/// await expect(a()).rejects.toThrowError('a');
/// });
/// ```
///
RequireToThrowMessage,
correctness
);

impl Rule for RequireToThrowMessage {
fn run_once(&self, ctx: &LintContext) {
for possible_jest_node in &collect_possible_jest_call_node(ctx) {
Self::run(possible_jest_node, ctx);
}
}
}

impl RequireToThrowMessage {
pub fn run<'a>(possible_jest_node: &PossibleJestNode<'a, '_>, ctx: &LintContext<'a>) {
let node = possible_jest_node.node;
let AstKind::CallExpression(call_expr) = node.kind() else {
return;
};

let Some(jest_fn_call) = parse_expect_jest_fn_call(call_expr, possible_jest_node, ctx)
else {
return;
};

let Some(matcher) = jest_fn_call.matcher() else {
return;
};

let Some(matcher_name) = matcher.name() else {
return;
};

let has_not = jest_fn_call.modifiers().iter().any(|modifier| modifier.is_name_equal("not"));

if jest_fn_call.args.len() == 0
&& (matcher_name == "toThrow" || matcher_name == "toThrowError")
&& !has_not
{
ctx.diagnostic(RequireToThrowMessageDiagnostic(matcher_name.to_string(), matcher.span));
}
}
}

#[test]
fn test() {
use crate::tester::Tester;

let pass = vec![
// String
("expect(() => { throw new Error('a'); }).toThrow('a');", None),
("expect(() => { throw new Error('a'); }).toThrowError('a');", None),
(
"
test('string', async () => {
const throwErrorAsync = async () => { throw new Error('a') };
await expect(throwErrorAsync()).rejects.toThrow('a');
await expect(throwErrorAsync()).rejects.toThrowError('a');
})
",
None,
),
// Template literal
("const a = 'a'; expect(() => { throw new Error('a'); }).toThrow(`${a}`);", None),
("const a = 'a'; expect(() => { throw new Error('a'); }).toThrowError(`${a}`);", None),
(
"
test('Template literal', async () => {
const a = 'a';
const throwErrorAsync = async () => { throw new Error('a') };
await expect(throwErrorAsync()).rejects.toThrow(`${a}`);
await expect(throwErrorAsync()).rejects.toThrowError(`${a}`);
})
",
None,
),
// Regex
("expect(() => { throw new Error('a'); }).toThrow(/^a$/);", None),
("expect(() => { throw new Error('a'); }).toThrowError(/^a$/);", None),
(
"
test('Regex', async () => {
const throwErrorAsync = async () => { throw new Error('a') };
await expect(throwErrorAsync()).rejects.toThrow(/^a$/);
await expect(throwErrorAsync()).rejects.toThrowError(/^a$/);
})
",
None,
),
// Function
("expect(() => { throw new Error('a'); }).toThrow((() => { return 'a'; })());", None),
("expect(() => { throw new Error('a'); }).toThrowError((() => { return 'a'; })());", None),
(
"
test('Function', async () => {
const throwErrorAsync = async () => { throw new Error('a') };
const fn = () => { return 'a'; };
await expect(throwErrorAsync()).rejects.toThrow(fn());
await expect(throwErrorAsync()).rejects.toThrowError(fn());
})
",
None,
),
// Allow no message for `not`.
("expect(() => { throw new Error('a'); }).not.toThrow();", None),
("expect(() => { throw new Error('a'); }).not.toThrowError();", None),
(
"
test('Allow no message for `not`', async () => {
const throwErrorAsync = async () => { throw new Error('a') };
await expect(throwErrorAsync()).resolves.not.toThrow();
await expect(throwErrorAsync()).resolves.not.toThrowError();
})
",
None,
),
("expect(a);", None),
];

let fail = vec![
// Empty toThrow
("expect(() => { throw new Error('a'); }).toThrow();", None),
// Empty toThrowError
("expect(() => { throw new Error('a'); }).toThrowError();", None),
// Empty rejects.toThrow / rejects.toThrowError
(
"
test('empty rejects.toThrow', async () => {
const throwErrorAsync = async () => { throw new Error('a') };
await expect(throwErrorAsync()).rejects.toThrow();
await expect(throwErrorAsync()).rejects.toThrowError();
})
",
None,
),
];

Tester::new(RequireToThrowMessage::NAME, pass, fail).test_and_snapshot();
}
37 changes: 37 additions & 0 deletions crates/oxc_linter/src/snapshots/require_to_throw_message.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
source: crates/oxc_linter/src/tester.rs
expression: require_to_throw_message
---

⚠ eslint-plugin-jest(require-to-throw-message): Require a message for `toThrow()`.
╭─[require_to_throw_message.tsx:1:41]
1 │ expect(() => { throw new Error('a'); }).toThrow();
· ───────
╰────
help: Add an error message to "toThrow"()

⚠ eslint-plugin-jest(require-to-throw-message): Require a message for `toThrow()`.
mysteryven marked this conversation as resolved.
Show resolved Hide resolved
╭─[require_to_throw_message.tsx:1:41]
1 │ expect(() => { throw new Error('a'); }).toThrowError();
· ────────────
╰────
help: Add an error message to "toThrowError"()
mysteryven marked this conversation as resolved.
Show resolved Hide resolved

⚠ eslint-plugin-jest(require-to-throw-message): Require a message for `toThrow()`.
╭─[require_to_throw_message.tsx:4:61]
3 │ const throwErrorAsync = async () => { throw new Error('a') };
4 │ await expect(throwErrorAsync()).rejects.toThrow();
· ───────
5 │ await expect(throwErrorAsync()).rejects.toThrowError();
╰────
help: Add an error message to "toThrow"()

⚠ eslint-plugin-jest(require-to-throw-message): Require a message for `toThrow()`.
╭─[require_to_throw_message.tsx:5:61]
4 │ await expect(throwErrorAsync()).rejects.toThrow();
5 │ await expect(throwErrorAsync()).rejects.toThrowError();
· ────────────
6 │ })
╰────
help: Add an error message to "toThrowError"()

Loading