feat(linter): eslint-plugin-jest/no-large-snapshot (#3436)

part of https://github.com/oxc-project/oxc/issues/492

Rule Detail:
[link](https://github.com/jest-community/eslint-plugin-jest/blob/main/src/rules/no-large-snapshots.ts)

---------

Co-authored-by: wenzhe <mysteryven@gmail.com>
This commit is contained in:
cinchen 2024-06-02 22:58:28 +08:00 committed by GitHub
parent ac66de651b
commit daf559f06f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 661 additions and 0 deletions

View file

@ -162,6 +162,7 @@ mod jest {
pub mod no_identical_title;
pub mod no_interpolation_in_snapshots;
pub mod no_jasmine_globals;
pub mod no_large_snapshots;
pub mod no_mocks_import;
pub mod no_restricted_jest_methods;
pub mod no_restricted_matchers;
@ -536,6 +537,7 @@ oxc_macros::declare_all_lint_rules! {
jest::no_identical_title,
jest::no_interpolation_in_snapshots,
jest::no_jasmine_globals,
jest::no_large_snapshots,
jest::no_mocks_import,
jest::no_restricted_jest_methods,
jest::no_restricted_matchers,

View file

@ -0,0 +1,484 @@
use oxc_ast::{
ast::{Expression, ExpressionStatement, MemberExpression},
AstKind,
};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use regex::Regex;
use rustc_hash::{FxHashMap, FxHasher};
use std::{collections::HashMap, hash::BuildHasherDefault, ops::Deref, path::Path};
use crate::{
context::LintContext,
rule::Rule,
utils::{collect_possible_jest_call_node, parse_expect_jest_fn_call, PossibleJestNode},
};
fn no_snapshot(x0: usize, span0: Span) -> OxcDiagnostic {
OxcDiagnostic::warn("eslint-plugin-jest(no-large-snapshots): Disallow large snapshots.")
.with_help(format!("`{x0:?}`s should begin with lowercase"))
.with_labels([span0.into()])
}
fn too_long_snapshots(x0: usize, x1: usize, span0: Span) -> OxcDiagnostic {
OxcDiagnostic::warn("eslint-plugin-jest(no-large-snapshots): Disallow large snapshots.")
.with_help(format!(
"Expected Jest snapshot to be smaller than {x0:?} lines but was {x1:?} lines long"
))
.with_labels([span0.into()])
}
#[derive(Debug, Default, Clone)]
pub struct NoLargeSnapshots(Box<NoLargeSnapshotsConfig>);
#[derive(Debug, Default, Clone)]
pub struct NoLargeSnapshotsConfig {
pub max_size: usize,
pub inline_max_size: usize,
pub allowed_snapshots: FxHashMap<String, Vec<String>>,
}
impl Deref for NoLargeSnapshots {
type Target = NoLargeSnapshotsConfig;
fn deref(&self) -> &Self::Target {
&self.0
}
}
declare_oxc_lint!(
/// ### What it does
///
/// When using Jest's snapshot capability one should be mindful of the size of
/// created snapshots. As a general best practice snapshots should be limited in
/// size in order to be more manageable and reviewable. A stored snapshot is only as
/// good as its review and as such keeping it short, sweet, and readable is
/// important to allow for thorough reviews.
///
/// ### Example
///
/// ```javascript
///
/// // invalid
/// exports[`a large snapshot 1`] = `
/// line 1
/// line 2
/// line 3
/// line 4
/// line 5
/// line 6
/// line 7
/// line 8
/// line 9
/// line 10
/// line 11
/// line 12
/// line 13
/// line 14
/// line 15
/// line 16
/// line 17
/// line 18
/// line 19
/// line 20
/// line 21
/// line 22
/// line 23
/// line 24
/// line 25
/// line 26
/// line 27
/// line 28
/// line 29
/// line 30
/// line 31
/// line 32
/// line 33
/// line 34
/// line 35
/// line 36
/// line 37
/// line 38
/// line 39
/// line 40
/// line 41
/// line 42
/// line 43
/// line 44
/// line 45
/// line 46
/// line 47
/// line 48
/// line 49
/// line 50
/// line 51
/// `;
///
/// // valid
/// exports[`a more manageable and readable snapshot 1`] = `
/// line 1
/// line 2
/// line 3
/// line 4
/// `;
/// ```
///
NoLargeSnapshots,
style,
);
impl Rule for NoLargeSnapshots {
fn from_configuration(value: serde_json::Value) -> Self {
let config = value.get(0);
let max_size = config
.and_then(|c| c.get("maxSize"))
.and_then(serde_json::Value::as_number)
.and_then(serde_json::Number::as_u64)
.map_or(50, |v| usize::try_from(v).unwrap_or(50));
let inline_max_size = config
.and_then(|c| c.get("inlineMaxSize"))
.and_then(serde_json::Value::as_number)
.and_then(serde_json::Number::as_u64)
.map_or(max_size, |v| usize::try_from(v).unwrap_or(max_size));
let allowed_snapshots = config
.and_then(|c| c.get("allowedSnapshots"))
.and_then(serde_json::Value::as_object)
.and_then(Self::compile_allowed_snapshots)
.unwrap_or_default();
Self(Box::new(NoLargeSnapshotsConfig { max_size, inline_max_size, allowed_snapshots }))
}
fn run_once(&self, ctx: &LintContext) {
let is_snap = ctx.file_path().to_str().map_or(false, |p| {
Path::new(p).extension().map_or(false, |ext| ext.eq_ignore_ascii_case("snap"))
});
if is_snap {
for node in ctx.nodes().iter().collect::<Vec<_>>() {
if let AstKind::ExpressionStatement(expr_stmt) = node.kind() {
self.report_in_expr_stmt(expr_stmt, ctx);
}
}
} else {
for possible_jest_node in &collect_possible_jest_call_node(ctx) {
self.run(possible_jest_node, ctx);
}
}
}
}
impl NoLargeSnapshots {
fn run<'a>(&self, 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;
};
if jest_fn_call.args.len() > 0
&& jest_fn_call.members.iter().any(|member| {
member.is_name_equal("toMatchInlineSnapshot")
|| member.is_name_equal("toThrowErrorMatchingInlineSnapshot")
})
{
let Some(first_arg) = jest_fn_call.args.first() else {
return;
};
let Some(first_arg_expr) = first_arg.as_expression() else {
return;
};
let span = first_arg_expr.span();
self.report_in_span(span, ctx);
}
}
fn report_in_expr_stmt(&self, expr_stmt: &ExpressionStatement, ctx: &LintContext) {
let line_count = Self::get_line_count(expr_stmt.span, ctx);
let allowed = match &expr_stmt.expression {
Expression::AssignmentExpression(assignment_expr) => {
let Some(member_expr) = assignment_expr.left.as_member_expression() else {
return;
};
self.check_allowed_in_snapshots(member_expr, ctx)
}
Expression::BinaryExpression(binary_expr) => {
let Some(member_expr) = binary_expr.left.as_member_expression() else {
return;
};
self.check_allowed_in_snapshots(member_expr, ctx)
}
Expression::LogicalExpression(logical_expr) => {
let Some(member_expr) = logical_expr.left.as_member_expression() else {
return;
};
self.check_allowed_in_snapshots(member_expr, ctx)
}
_ => false,
};
if !allowed && line_count > self.max_size {
if line_count == 0 {
ctx.diagnostic(no_snapshot(line_count, expr_stmt.span));
} else {
ctx.diagnostic(too_long_snapshots(self.max_size, line_count, expr_stmt.span));
}
}
}
fn report_in_span(&self, span: Span, ctx: &LintContext) {
let line_count = Self::get_line_count(span, ctx);
if line_count > self.inline_max_size {
if self.inline_max_size == 0 {
ctx.diagnostic(no_snapshot(line_count, span));
} else {
ctx.diagnostic(too_long_snapshots(self.inline_max_size, line_count, span));
}
}
}
fn check_allowed_in_snapshots(
&self,
member_expr: &MemberExpression,
ctx: &LintContext,
) -> bool {
let Some(snapshot_name) = member_expr.static_property_name() else {
return false;
};
let Some(file_name) = ctx.file_path().to_str() else {
return false;
};
let Some(allowed_snapshots_in_file) = self.allowed_snapshots.get(file_name) else {
return false;
};
allowed_snapshots_in_file.iter().any(|allowed_snapshot| {
match Regex::new(allowed_snapshot) {
Ok(regex) => regex.is_match(snapshot_name),
Err(_) => snapshot_name == allowed_snapshot,
}
})
}
fn get_line_count(span: Span, ctx: &LintContext) -> usize {
let start = span.start as usize;
let end = span.end as usize;
ctx.source_text()[start..=end].lines().count() - 1
}
#[allow(clippy::unnecessary_wraps)]
pub fn compile_allowed_snapshots(
matchers: &serde_json::Map<String, serde_json::Value>,
) -> Option<HashMap<String, Vec<String>, BuildHasherDefault<FxHasher>>> {
Some(
matchers
.iter()
.map(|(key, value)| {
let serde_json::Value::Array(configs) = value else {
return (String::from(key), vec![]);
};
let configs = configs
.iter()
.filter_map(|c| c.as_str().map(std::string::ToString::to_string))
.collect();
(String::from(key), configs)
})
.collect(),
)
}
}
#[test]
fn test() {
use crate::tester::Tester;
// use std::path::PathBuf;
fn generate_snapshot_lines(lines: usize) -> String {
let repeated_lines = "line\n".repeat(lines);
format!("`\n{repeated_lines}`")
}
// fn generate_exports_snapshot_string(lines: usize, title: Option<&str>) -> String {
// let title = title.unwrap_or("a big component 1");
// format!("exports[`{}`] = {};", title, generate_snapshot_lines(lines))
// }
fn generate_expect_inline_snaps_code(line: usize, matcher: &str) -> String {
format!("expect(something).{}({});", matcher, generate_snapshot_lines(line))
}
fn generate_match_inline_snapshot(line: usize) -> String {
generate_expect_inline_snaps_code(line, "toMatchInlineSnapshot")
}
fn generate_throw_error_matching_inline_snapshot(line: usize) -> String {
generate_expect_inline_snaps_code(line, "toThrowErrorMatchingInlineSnapshot")
}
// Note: Currently oxlint didn't check `.snap` file
//
// #[cfg(target_os = "windows")]
// let snap_path = "c:\\mock-component.jsx.snap";
// #[cfg(target_os = "windows")]
// let another_snap_path = "c:\\another-mock-component.jsx.snap";
// #[cfg(not(target_os = "windows"))]
// let snap_path = "/mock-component.jsx.snap";
// #[cfg(not(target_os = "windows"))]
// let another_snap_path = "/another-mock-component.jsx.snap";
let tow_match_inline_cases = generate_match_inline_snapshot(2);
let two_throw_error_match_cases = generate_throw_error_matching_inline_snapshot(2);
let twenty_match_inline_cases = generate_match_inline_snapshot(20);
let sixty_match_inline_cases = generate_match_inline_snapshot(60);
let sixty_cases = format!(
"
expect(
functionUnderTest(
arg1,
arg2,
arg3
)
).toMatchInlineSnapshot({})
",
generate_snapshot_lines(60)
);
// let twenty_exports_snapshot = generate_exports_snapshot_string(20, None);
// let fifty_eight_exports_snapshot = generate_exports_snapshot_string(58, None);
let pass = vec![
("expect(something)", None, None, None),
("expect(something).toBe(1)", None, None, None),
("expect(something).toMatchInlineSnapshot", None, None, None),
("expect(something).toMatchInlineSnapshot()", None, None, None),
(tow_match_inline_cases.as_str(), None, None, None),
(two_throw_error_match_cases.as_str(), None, None, None),
(
twenty_match_inline_cases.as_str(),
Some(serde_json::json!([{ "maxSize": 19, "inlineMaxSize": 21 }])),
None,
None,
),
(
sixty_match_inline_cases.as_str(),
Some(serde_json::json!([{ "maxSize": 61 }])),
None,
None,
),
(sixty_cases.as_str(), Some(serde_json::json!([{ "maxSize": 61 }])), None, None),
// '/mock-component.jsx.snap'
// (twenty_exports_snapshot.as_str(), None, None, Some(PathBuf::from(snap_path))),
// '/mock-component.jsx.snap'
// (
// fifty_eight_exports_snapshot.as_str(),
// Some(serde_json::json!([{
// "allowedSnapshots": {
// snap_path.to_string(): ["a big component 1"]
// }
// }])),
// None,
// Some(PathBuf::from(snap_path)),
// ),
// '/mock-component.jsx.snap'
// (
// twenty_exports_snapshot.as_str(),
// Some(serde_json::json!([{ "maxSize": 21, "inlineMaxSize": 19 }])),
// None,
// Some(PathBuf::from(snap_path)),
// ),
];
let fifty_match_inline_cases = generate_match_inline_snapshot(50);
let fifty_throw_error_match_cases = generate_throw_error_matching_inline_snapshot(50);
// let fifty_two_exports_snapshot = generate_exports_snapshot_string(58, None);
// let one_hundred_exports_snapshot = generate_exports_snapshot_string(100, None);
// let one_exports_snapshot = generate_exports_snapshot_string(1, None);
// let fifty_eight_exports_snapshot = generate_exports_snapshot_string(58, None);
// let vec_to_str = [
// generate_exports_snapshot_string(58, Some("a big component w/ text")),
// generate_exports_snapshot_string(58, Some("a big component 2")),
// ]
// .join("\n\n");
let fail = vec![
(fifty_match_inline_cases.as_str(), None, None, None),
(fifty_throw_error_match_cases.as_str(), None, None, None),
(
fifty_throw_error_match_cases.as_str(),
Some(serde_json::json!([{ "maxSize": 51, "inlineMaxSize": 50 }])),
None,
None,
),
// '/mock-component.jsx.snap'
// (fifty_two_exports_snapshot.as_str(), None, None, Some(PathBuf::from(snap_path))),
// '/mock-component.jsx.snap'
// (
// one_hundred_exports_snapshot.as_str(),
// Some(serde_json::json!([{ "maxSize": 70 }])),
// None,
// Some(PathBuf::from(snap_path)),
// ),
// '/mock-component.jsx.snap'
// (
// one_hundred_exports_snapshot.as_str(),
// Some(serde_json::json!([{ "maxSize": 70, "inlineMaxSize": 101 }])),
// None,
// Some(PathBuf::from(snap_path)),
// ),
// '/mock-component.jsx.snap'
// (
// one_exports_snapshot.as_str(),
// Some(serde_json::json!([{ "maxSize": 0 }])),
// None,
// Some(PathBuf::from(snap_path)),
// ),
// '/mock-component.jsx.snap'
// (
// fifty_eight_exports_snapshot.as_str(),
// Some(serde_json::json!([{
// "allowedSnapshots": {
// another_snap_path.to_string(): [r"a big component \d+"]
// }
// }])),
// None,
// Some(PathBuf::from(snap_path)),
// ),
// '/mock-component.jsx.snap'
// (
// vec_to_str.as_str(),
// Some(serde_json::json!([{
// "allowedSnapshots": {
// snap_path.to_string(): [r"a big component \d+"],
// },
// }])),
// None,
// Some(PathBuf::from(snap_path)),
// ),
// '/mock-component.jsx.snap'
// (
// vec_to_str.as_str(),
// Some(serde_json::json!([{
// "allowedSnapshots": {
// snap_path.to_string(): ["a big component 2"],
// },
// }])),
// None,
// Some(PathBuf::from(snap_path)),
// ),
];
Tester::new(NoLargeSnapshots::NAME, pass, fail).with_jest_plugin(true).test_and_snapshot();
}

View file

@ -0,0 +1,175 @@
---
source: crates/oxc_linter/src/tester.rs
assertion_line: 161
expression: no_large_snapshots
---
⚠ eslint-plugin-jest(no-large-snapshots): Disallow large snapshots.
╭─[no_large_snapshots.tsx:1:41]
1 │ ╭─▶ expect(something).toMatchInlineSnapshot(`
2 │ │ line
3 │ │ line
4 │ │ line
5 │ │ line
6 │ │ line
7 │ │ line
8 │ │ line
9 │ │ line
10 │ │ line
11 │ │ line
12 │ │ line
13 │ │ line
14 │ │ line
15 │ │ line
16 │ │ line
17 │ │ line
18 │ │ line
19 │ │ line
20 │ │ line
21 │ │ line
22 │ │ line
23 │ │ line
24 │ │ line
25 │ │ line
26 │ │ line
27 │ │ line
28 │ │ line
29 │ │ line
30 │ │ line
31 │ │ line
32 │ │ line
33 │ │ line
34 │ │ line
35 │ │ line
36 │ │ line
37 │ │ line
38 │ │ line
39 │ │ line
40 │ │ line
41 │ │ line
42 │ │ line
43 │ │ line
44 │ │ line
45 │ │ line
46 │ │ line
47 │ │ line
48 │ │ line
49 │ │ line
50 │ │ line
51 │ │ line
52 │ ╰─▶ `);
╰────
help: Expected Jest snapshot to be smaller than 50 lines but was 51 lines long
⚠ eslint-plugin-jest(no-large-snapshots): Disallow large snapshots.
╭─[no_large_snapshots.tsx:1:54]
1 │ ╭─▶ expect(something).toThrowErrorMatchingInlineSnapshot(`
2 │ │ line
3 │ │ line
4 │ │ line
5 │ │ line
6 │ │ line
7 │ │ line
8 │ │ line
9 │ │ line
10 │ │ line
11 │ │ line
12 │ │ line
13 │ │ line
14 │ │ line
15 │ │ line
16 │ │ line
17 │ │ line
18 │ │ line
19 │ │ line
20 │ │ line
21 │ │ line
22 │ │ line
23 │ │ line
24 │ │ line
25 │ │ line
26 │ │ line
27 │ │ line
28 │ │ line
29 │ │ line
30 │ │ line
31 │ │ line
32 │ │ line
33 │ │ line
34 │ │ line
35 │ │ line
36 │ │ line
37 │ │ line
38 │ │ line
39 │ │ line
40 │ │ line
41 │ │ line
42 │ │ line
43 │ │ line
44 │ │ line
45 │ │ line
46 │ │ line
47 │ │ line
48 │ │ line
49 │ │ line
50 │ │ line
51 │ │ line
52 │ ╰─▶ `);
╰────
help: Expected Jest snapshot to be smaller than 50 lines but was 51 lines long
⚠ eslint-plugin-jest(no-large-snapshots): Disallow large snapshots.
╭─[no_large_snapshots.tsx:1:54]
1 │ ╭─▶ expect(something).toThrowErrorMatchingInlineSnapshot(`
2 │ │ line
3 │ │ line
4 │ │ line
5 │ │ line
6 │ │ line
7 │ │ line
8 │ │ line
9 │ │ line
10 │ │ line
11 │ │ line
12 │ │ line
13 │ │ line
14 │ │ line
15 │ │ line
16 │ │ line
17 │ │ line
18 │ │ line
19 │ │ line
20 │ │ line
21 │ │ line
22 │ │ line
23 │ │ line
24 │ │ line
25 │ │ line
26 │ │ line
27 │ │ line
28 │ │ line
29 │ │ line
30 │ │ line
31 │ │ line
32 │ │ line
33 │ │ line
34 │ │ line
35 │ │ line
36 │ │ line
37 │ │ line
38 │ │ line
39 │ │ line
40 │ │ line
41 │ │ line
42 │ │ line
43 │ │ line
44 │ │ line
45 │ │ line
46 │ │ line
47 │ │ line
48 │ │ line
49 │ │ line
50 │ │ line
51 │ │ line
52 │ ╰─▶ `);
╰────
help: Expected Jest snapshot to be smaller than 50 lines but was 51 lines long