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(lint): rule noMisplacedAssertion #1935

Merged
merged 6 commits into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
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
1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ define_categories! {
"lint/nursery/noExcessiveNestedTestSuites": "https://biomejs.dev/linter/rules/no-excessive-nested-test-suites",
"lint/nursery/noExportsInTest": "https://biomejs.dev/linter/rules/no-exports-in-test",
"lint/nursery/noFocusedTests": "https://biomejs.dev/linter/rules/no-focused-tests",
"lint/nursery/noMisplacedAssertion": "https://biomejs.dev/linter/rules/no-misplaced-assertion",
"lint/nursery/noNamespaceImport": "https://biomejs.dev/linter/rules/no-namespace-import",
"lint/nursery/noNodejsModules": "https://biomejs.dev/linter/rules/no-nodejs-modules",
"lint/nursery/noReExportAll": "https://biomejs.dev/linter/rules/no-re-export-all",
Expand Down
9 changes: 7 additions & 2 deletions crates/biome_js_analyze/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,12 @@ mod tests {
String::from_utf8(buffer).unwrap()
}

const SOURCE: &str = r#"<>{provider}</>
const SOURCE: &str = r#"import assert from "node:assert";
import { describe } from "node:test";

describe(() => {
assert.equal("something", "something")
})
"#;

let parsed = parse(SOURCE, JsFileSource::tsx(), JsParserOptions::default());
Expand All @@ -265,7 +270,7 @@ mod tests {
dependencies_index: Some(1),
stable_result: StableHookResult::None,
};
let rule_filter = RuleFilter::Rule("complexity", "noUselessFragments");
let rule_filter = RuleFilter::Rule("nursery", "noMisplacedAssertion");

options.configuration.rules.push_rule(
RuleKey::new("nursery", "useHookAtTopLevel"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ declare_rule! {
/// </>
/// ```
///
/// ### Options
/// ## Options
///
/// ```json
/// {
Expand Down
2 changes: 2 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod no_evolving_any;
pub mod no_excessive_nested_test_suites;
pub mod no_exports_in_test;
pub mod no_focused_tests;
pub mod no_misplaced_assertion;
pub mod no_namespace_import;
pub mod no_nodejs_modules;
pub mod no_re_export_all;
Expand All @@ -37,6 +38,7 @@ declare_group! {
self :: no_excessive_nested_test_suites :: NoExcessiveNestedTestSuites ,
self :: no_exports_in_test :: NoExportsInTest ,
self :: no_focused_tests :: NoFocusedTests ,
self :: no_misplaced_assertion :: NoMisplacedAssertion ,
self :: no_namespace_import :: NoNamespaceImport ,
self :: no_nodejs_modules :: NoNodejsModules ,
self :: no_re_export_all :: NoReExportAll ,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ impl Rule for NoExcessiveNestedTestSuites {
"Excessive `describe()` nesting detected."
},
)
.note(markup! {
.note(markup! {
"Excessive nesting of "<Emphasis>"describe()"</Emphasis>" calls can hinder test readability."
})
.note(markup! {
.note(markup! {
"Consider refactoring and "<Emphasis>"reduce the level of nested describe"</Emphasis>" to improve code clarity."
}),
)
Expand Down Expand Up @@ -116,14 +116,10 @@ impl Visitor for NestedTestVisitor {
WalkEvent::Enter(node) => {
if let Some(node) = JsCallExpression::cast_ref(node) {
if let Ok(callee) = node.callee() {
if callee.contains_a_test_pattern() == Ok(true) {
if let Some(function_name) = callee.get_callee_object_name() {
if function_name.text_trimmed() == "describe" {
self.curr_count += 1;
if self.curr_count == self.max_count + 1 {
ctx.match_query(NestedTest(node.clone()));
}
}
if callee.contains_describe_call() {
self.curr_count += 1;
if self.curr_count == self.max_count + 1 {
ctx.match_query(NestedTest(node.clone()));
}
}
}
Expand All @@ -132,12 +128,8 @@ impl Visitor for NestedTestVisitor {
WalkEvent::Leave(node) => {
if let Some(node) = JsCallExpression::cast_ref(node) {
if let Ok(callee) = node.callee() {
if callee.contains_a_test_pattern() == Ok(true) {
if let Some(function_name) = callee.get_callee_object_name() {
if function_name.text_trimmed() == "describe" {
self.curr_count -= 1;
}
}
if callee.contains_describe_call() {
self.curr_count -= 1;
}
}
}
Expand Down
183 changes: 183 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery/no_misplaced_assertion.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
use crate::services::semantic::Semantic;
use biome_analyze::{
context::RuleContext, declare_rule, Rule, RuleDiagnostic, RuleSource, RuleSourceKind,
};
use biome_console::markup;
use biome_deserialize::TextRange;
use biome_js_syntax::{AnyJsExpression, JsCallExpression, JsIdentifierBinding, JsImport};
use biome_rowan::AstNode;

declare_rule! {
/// Checks that the assertion function, for example `expect`, is placed inside an `it()` function call.
///
/// Placing (and using) the `expect` assertion function can result in unexpected behaviors when executing your testing suite.
///
/// The rule will check for the following assertion calls:
/// - `expect`
/// - `assert`
/// - `assertEquals`
///
/// If the assertion function is imported, the rule will check if they are imported from:
/// - `"chai"`
/// - `"node:assert"`
/// - `"node:assert/strict"`
/// - `"bun:test"`
/// - `"vitest"`
/// - Deno assertion module URL
///
/// Check the [options](#options) if you need to change the defaults.
///
/// ## Examples
///
/// ### Invalid
///
/// ```js,expect_diagnostic
/// describe("describe", () => {
/// expect()
/// })
/// ```
///
/// ```js,expect_diagnostic
/// import assert from "node:assert";
/// describe("describe", () => {
/// assert.equal()
/// })
/// ```
///
/// ```js,expect_diagnostic
/// import {test, expect} from "bun:test";
/// expect(1, 2)
/// ```
///
/// ```js,expect_diagnostic
/// import {assertEquals} from "https://deno.land/std@0.220.0/assert/mod.ts";
///
/// assertEquals(url.href, "https://deno.land/foo.js");
/// Deno.test("url test", () => {
/// const url = new URL("./foo.js", "https://deno.land/");
/// });
/// ```
///
/// ### Valid
///
/// ```js
/// import assert from "node:assert";
/// describe("describe", () => {
/// it("it", () => {
/// assert.equal()
/// })
/// })
/// ```
///
/// ```js
/// describe("describe", () => {
/// it("it", () => {
/// expect()
/// })
/// })
/// ```
///
pub NoMisplacedAssertion {
version: "next",
name: "noMisplacedAssertion",
recommended: false,
source: RuleSource::EslintJest("no-standalone-expect"),
source_kind: RuleSourceKind::Inspired,
}
}

const ASSERTION_FUNCTION_NAMES: [&str; 3] = ["assert", "assertEquals", "expect"];
const SPECIFIERS: [&str; 6] = [
"chai",
"node:assert",
"node:assert/strict",
"bun:test",
"vitest",
"/assert/mod.ts",
];

impl Rule for NoMisplacedAssertion {
type Query = Semantic<AnyJsExpression>;
type State = TextRange;
type Signals = Option<Self::State>;
type Options = ();

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();
let model = ctx.model();

if let Some(call_text) = node.to_assertion_call() {
let ancestor_is_test_call = {
node.syntax()
.ancestors()
.filter_map(JsCallExpression::cast)
.find_map(|call_expression| {
let callee = call_expression.callee().ok()?;
callee.contains_it_call().then_some(true)
})
.unwrap_or_default()
};

let ancestor_is_describe_call = {
node.syntax()
.ancestors()
.filter_map(JsCallExpression::cast)
.find_map(|call_expression| {
let callee = call_expression.callee().ok()?;
callee.contains_describe_call().then_some(true)
})
};
let assertion_call = node.get_callee_object_identifier()?;

if let Some(ancestor_is_describe_call) = ancestor_is_describe_call {
if ancestor_is_describe_call && ancestor_is_test_call {
return None;
}
} else if ancestor_is_test_call {
return None;
}

let binding = model.binding(&assertion_call);
if let Some(binding) = binding {
let ident = JsIdentifierBinding::cast_ref(binding.syntax())?;
let import = ident.syntax().ancestors().find_map(JsImport::cast)?;
let source_text = import.source_text().ok()?;
if (ASSERTION_FUNCTION_NAMES.contains(&call_text.text()))
&& (SPECIFIERS.iter().any(|specifier| {
// Deno is a particular case
if *specifier == "/assert/mod.ts" {
source_text.text().ends_with("/assert/mod.ts")
&& source_text.text().starts_with("https://deno.land/std")
} else {
*specifier == source_text.text()
}
}))
{
return Some(assertion_call.range());
}
} else if ASSERTION_FUNCTION_NAMES.contains(&call_text.text()) {
return Some(assertion_call.range());
}
}

None
}

fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
Some(
RuleDiagnostic::new(
rule_category!(),
state,
markup! {
"The assertion isn't inside a "<Emphasis>"it()"</Emphasis>", "<Emphasis>"test()"</Emphasis>" or "<Emphasis>"Deno.test()"</Emphasis>" function call."
},
)
.note(markup! {
"This will result in unexpected behaviours from your test suite."
})
.note(markup! {
"Move the assertion inside a "<Emphasis>"it()"</Emphasis>", "<Emphasis>"test()"</Emphasis>" or "<Emphasis>"Deno.test()"</Emphasis>" function call."
}),
)
}
}
2 changes: 2 additions & 0 deletions crates/biome_js_analyze/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ pub type NoInvalidUseBeforeDeclaration = < lint :: correctness :: no_invalid_use
pub type NoLabelVar = <lint::suspicious::no_label_var::NoLabelVar as biome_analyze::Rule>::Options;
pub type NoMisleadingCharacterClass = < lint :: suspicious :: no_misleading_character_class :: NoMisleadingCharacterClass as biome_analyze :: Rule > :: Options ;
pub type NoMisleadingInstantiator = < lint :: suspicious :: no_misleading_instantiator :: NoMisleadingInstantiator as biome_analyze :: Rule > :: Options ;
pub type NoMisplacedAssertion =
<lint::nursery::no_misplaced_assertion::NoMisplacedAssertion as biome_analyze::Rule>::Options;
pub type NoMisrefactoredShorthandAssign = < lint :: suspicious :: no_misrefactored_shorthand_assign :: NoMisrefactoredShorthandAssign as biome_analyze :: Rule > :: Options ;
pub type NoMultipleSpacesInRegularExpressionLiterals = < lint :: complexity :: no_multiple_spaces_in_regular_expression_literals :: NoMultipleSpacesInRegularExpressionLiterals as biome_analyze :: Rule > :: Options ;
pub type NoNamespace = <lint::style::no_namespace::NoNamespace as biome_analyze::Rule>::Options;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
describe(() => {
expect("something").toBeTrue()
})

expect("")
assertEquals(1, 1)
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: invalid.js
---
# Input
```jsx
describe(() => {
expect("something").toBeTrue()
})

expect("")
assertEquals(1, 1)
```

# Diagnostics
```
invalid.js:2:5 lint/nursery/noMisplacedAssertion ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

! The assertion isn't inside a it(), test() or Deno.test() function call.

1 │ describe(() => {
> 2 │ expect("something").toBeTrue()
│ ^^^^^^
3 │ })
4 │

i This will result in unexpected behaviours from your test suite.

i Move the assertion inside a it(), test() or Deno.test() function call.


```

```
invalid.js:5:1 lint/nursery/noMisplacedAssertion ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

! The assertion isn't inside a it(), test() or Deno.test() function call.

3 │ })
4 │
> 5 │ expect("")
│ ^^^^^^
6 │ assertEquals(1, 1)

i This will result in unexpected behaviours from your test suite.

i Move the assertion inside a it(), test() or Deno.test() function call.


```

```
invalid.js:6:1 lint/nursery/noMisplacedAssertion ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

! The assertion isn't inside a it(), test() or Deno.test() function call.

5 │ expect("")
> 6 │ assertEquals(1, 1)
│ ^^^^^^^^^^^^

i This will result in unexpected behaviours from your test suite.

i Move the assertion inside a it(), test() or Deno.test() function call.


```
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {test, expect} from "bun:test";

expect("something").toBeTrue()
Loading
Loading