Skip to main content

aft/compress/
vitest.rs

1use serde_json::Value;
2
3use crate::compress::generic::{dedup_consecutive, middle_truncate, strip_ansi, GenericCompressor};
4use crate::compress::Compressor;
5
6const MAX_FAILURES: usize = 5;
7const MAX_FAILURE_MESSAGE_LINES: usize = 5;
8const MAX_LINES: usize = 250;
9
10pub struct VitestCompressor;
11
12#[derive(Debug)]
13struct Failure {
14    name: String,
15    messages: Vec<String>,
16}
17
18impl Compressor for VitestCompressor {
19    fn matches(&self, command: &str) -> bool {
20        command_tokens(command).any(|token| matches!(token.as_str(), "vitest" | "jest"))
21    }
22
23    fn compress(&self, command: &str, output: &str) -> String {
24        compress_test_runner(command, output)
25    }
26}
27
28fn compress_test_runner(command: &str, output: &str) -> String {
29    let trimmed = output.trim_start();
30    if trimmed.starts_with('{') {
31        if let Some(compressed) = compress_json(command, trimmed) {
32            return finish(&compressed);
33        }
34        return GenericCompressor::compress_output(output);
35    }
36
37    finish(&compress_text(output))
38}
39
40fn command_tokens(command: &str) -> impl Iterator<Item = String> + '_ {
41    command
42        .split_whitespace()
43        .map(|token| token.trim_matches(|ch| matches!(ch, '\'' | '"')))
44        .filter(|token| !matches!(*token, "npx" | "pnpm" | "yarn" | "bun" | "bunx"))
45        .map(|token| {
46            token
47                .rsplit(['/', '\\'])
48                .next()
49                .unwrap_or(token)
50                .trim_end_matches(".cmd")
51                .to_string()
52        })
53}
54
55fn compress_json(command: &str, input: &str) -> Option<String> {
56    let value: Value = serde_json::from_str(input).ok()?;
57    let total = number_field(&value, "numTotalTests").unwrap_or(0);
58    let passed = number_field(&value, "numPassedTests").unwrap_or(0);
59    let failed = number_field(&value, "numFailedTests").unwrap_or(0);
60    let failures = json_failures(&value);
61    let runner = runner_name(command);
62
63    let mut lines = vec![format!(
64        "{runner}: {passed} pass, {failed} fail (out of {total})"
65    )];
66    if failures.is_empty() {
67        return Some(lines.join("\n"));
68    }
69
70    lines.push(String::new());
71    for failure in failures.iter().take(MAX_FAILURES) {
72        lines.push(format!("FAIL {}", failure.name));
73        for message in failure.messages.iter().take(MAX_FAILURE_MESSAGE_LINES) {
74            lines.push(format!("  {message}"));
75        }
76    }
77    if failures.len() > MAX_FAILURES {
78        lines.push(format!("+{} more failures", failures.len() - MAX_FAILURES));
79    }
80
81    Some(lines.join("\n"))
82}
83
84fn json_failures(value: &Value) -> Vec<Failure> {
85    let mut failures = Vec::new();
86    for suite in value
87        .get("testResults")
88        .and_then(Value::as_array)
89        .into_iter()
90        .flatten()
91    {
92        let suite_name = string_field(suite, "name").unwrap_or("<unknown>");
93        let mut suite_had_assertion = false;
94        for assertion in suite
95            .get("assertionResults")
96            .and_then(Value::as_array)
97            .into_iter()
98            .flatten()
99        {
100            suite_had_assertion = true;
101            if string_field(assertion, "status") != Some("failed") {
102                continue;
103            }
104            let full_name = string_field(assertion, "fullName")
105                .or_else(|| string_field(assertion, "title"))
106                .unwrap_or("failed test")
107                .trim();
108            failures.push(Failure {
109                name: format_failure_name(suite_name, full_name),
110                messages: failure_messages(assertion),
111            });
112        }
113        if !suite_had_assertion && string_field(suite, "status") == Some("failed") {
114            failures.push(Failure {
115                name: suite_name.to_string(),
116                messages: suite
117                    .get("message")
118                    .and_then(Value::as_str)
119                    .map(first_message_lines)
120                    .unwrap_or_default(),
121            });
122        }
123    }
124    failures
125}
126
127fn format_failure_name(suite_name: &str, full_name: &str) -> String {
128    let suite_name = trim_workspace_path(suite_name);
129    if full_name.is_empty() {
130        suite_name.to_string()
131    } else {
132        format!("{suite_name} > {full_name}")
133    }
134}
135
136fn trim_workspace_path(path: &str) -> &str {
137    path.rsplit_once('/').map_or(path, |(_, file)| file)
138}
139
140fn failure_messages(assertion: &Value) -> Vec<String> {
141    let messages: Vec<String> = assertion
142        .get("failureMessages")
143        .and_then(Value::as_array)
144        .into_iter()
145        .flatten()
146        .filter_map(Value::as_str)
147        .flat_map(first_message_lines)
148        .collect();
149    if messages.is_empty() {
150        assertion
151            .get("failureMessage")
152            .and_then(Value::as_str)
153            .map(first_message_lines)
154            .unwrap_or_default()
155    } else {
156        messages
157    }
158}
159
160fn first_message_lines(message: &str) -> Vec<String> {
161    message
162        .lines()
163        .take(MAX_FAILURE_MESSAGE_LINES)
164        .map(str::trim_end)
165        .filter(|line| !line.trim().is_empty())
166        .map(ToString::to_string)
167        .collect()
168}
169
170fn compress_text(output: &str) -> String {
171    let lines: Vec<&str> = output.lines().collect();
172    let mut result = Vec::new();
173    let mut failures_seen = 0usize;
174    let mut omitted = 0usize;
175    let mut index = 0usize;
176
177    while index < lines.len() {
178        let line = lines[index];
179        let trimmed = line.trim_start();
180
181        if is_fail_line(trimmed) {
182            failures_seen += 1;
183            let keep = failures_seen <= MAX_FAILURES;
184            if !keep {
185                omitted += 1;
186            }
187            while index < lines.len() {
188                let current = lines[index];
189                let current_trimmed = current.trim_start();
190                if index != 0
191                    && index != lines.len() - 1
192                    && (is_fail_line(current_trimmed)
193                        || is_pass_line(current_trimmed)
194                        || is_summary_line(current_trimmed))
195                    && current_trimmed != trimmed
196                {
197                    break;
198                }
199                if keep && !is_ignored_noise(current_trimmed) {
200                    result.push(current.to_string());
201                }
202                index += 1;
203            }
204            continue;
205        }
206
207        if is_pass_line(trimmed) || is_summary_line(trimmed) {
208            result.push(line.to_string());
209        }
210        index += 1;
211    }
212
213    if omitted > 0 {
214        result.push(format!("+{omitted} more failures"));
215    }
216
217    if result.is_empty() {
218        return GenericCompressor::compress_output(output);
219    }
220    result.join("\n")
221}
222
223fn is_fail_line(trimmed: &str) -> bool {
224    trimmed.starts_with("FAIL ") || trimmed.starts_with("FAIL\t") || trimmed.starts_with("FAIL  ")
225}
226
227fn is_pass_line(trimmed: &str) -> bool {
228    trimmed.starts_with("PASS ")
229        || trimmed.starts_with("PASS\t")
230        || trimmed.starts_with("✓ ")
231        || trimmed.starts_with("✔ ")
232}
233
234fn is_summary_line(trimmed: &str) -> bool {
235    trimmed.starts_with("Tests:")
236        || trimmed.starts_with("Test Suites:")
237        || trimmed.starts_with("Snapshots:")
238        || trimmed.starts_with("Time:")
239        || trimmed.starts_with("Ran all test suites")
240        || trimmed.starts_with("Test Files")
241        || trimmed.starts_with("Start at")
242        || trimmed.starts_with("Duration")
243}
244
245fn is_ignored_noise(trimmed: &str) -> bool {
246    trimmed.starts_with("RERUN")
247        || trimmed.starts_with("Test Files")
248        || trimmed.chars().all(|ch| ch == '.' || ch.is_whitespace())
249}
250
251fn runner_name(command: &str) -> &'static str {
252    if command_tokens(command).any(|token| token == "jest") {
253        "jest"
254    } else {
255        "vitest"
256    }
257}
258
259fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
260    value.get(key).and_then(Value::as_str)
261}
262
263fn number_field(value: &Value, key: &str) -> Option<usize> {
264    value
265        .get(key)
266        .and_then(Value::as_u64)
267        .and_then(|number| usize::try_from(number).ok())
268}
269
270fn finish(input: &str) -> String {
271    let stripped = strip_ansi(input);
272    let deduped = dedup_consecutive(&stripped);
273    cap_lines(
274        &middle_truncate(&deduped, 32 * 1024, 16 * 1024, 16 * 1024),
275        MAX_LINES,
276    )
277}
278
279fn cap_lines(input: &str, max_lines: usize) -> String {
280    let lines: Vec<&str> = input.lines().collect();
281    if lines.len() <= max_lines {
282        return input.trim_end().to_string();
283    }
284    let mut kept = lines
285        .iter()
286        .take(max_lines)
287        .copied()
288        .collect::<Vec<_>>()
289        .join("\n");
290    kept.push_str(&format!(
291        "\n... truncated {} lines",
292        lines.len() - max_lines
293    ));
294    kept
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn matches_only_vitest_or_jest_tokens() {
303        let compressor = VitestCompressor;
304        assert!(compressor.matches("npx vitest run"));
305        assert!(compressor.matches("./node_modules/.bin/jest --json"));
306        assert!(!compressor.matches("pnpm test"));
307    }
308
309    #[test]
310    fn compresses_passing_text_summary() {
311        let output = r#"....
312
313PASS src/foo.test.ts
314PASS src/bar.test.ts
315Tests:       4 passed, 4 total
316Time:        1.23 s
317"#;
318
319        let compressed = compress_test_runner("jest", output);
320
321        assert!(compressed.contains("PASS src/foo.test.ts"));
322        assert!(compressed.contains("Tests:       4 passed, 4 total"));
323        assert!(!compressed.contains("...."));
324    }
325
326    #[test]
327    fn compresses_failure_text_blocks_and_summaries() {
328        let output = r#"RERUN  src/foo.test.ts x1
329FAIL src/foo.test.ts
330  ● math > adds
331
332    Expected: 1
333    Received: 2
334
335PASS src/bar.test.ts
336Test Files  1 failed | 1 passed (2)
337Tests       1 failed | 1 passed (2)
338Duration    1.26s
339"#;
340
341        let compressed = compress_test_runner("vitest", output);
342
343        assert!(compressed.contains("FAIL src/foo.test.ts"));
344        assert!(compressed.contains("Expected: 1"));
345        assert!(compressed.contains("PASS src/bar.test.ts"));
346        assert!(!compressed.contains("RERUN"));
347    }
348
349    #[test]
350    fn compresses_vitest_json_reporter_output() {
351        let output = r#"{"numTotalTests":14,"numPassedTests":12,"numFailedTests":2,"testResults":[{"name":"/repo/src/foo.test.ts","status":"failed","assertionResults":[{"fullName":"math adds","status":"failed","failureMessages":["Expected: 1\nReceived: 2\n    at src/foo.test.ts:4:10"]},{"fullName":"math subtracts","status":"failed","failureMessages":["AssertionError: expected 3 to be 2"]}]}]}"#;
352
353        let compressed = compress_test_runner("vitest --reporter=json", output);
354
355        assert!(compressed.starts_with("vitest: 12 pass, 2 fail (out of 14)"));
356        assert!(compressed.contains("FAIL foo.test.ts > math adds"));
357        assert!(compressed.contains("  Expected: 1"));
358    }
359
360    #[test]
361    fn compresses_jest_json_reporter_output() {
362        let output = r#"{"numTotalTests":1,"numPassedTests":0,"numFailedTests":1,"testResults":[{"name":"/repo/src/app.test.ts","assertionResults":[{"title":"renders","fullName":"app renders","status":"failed","failureMessages":["Error: boom"]}]}]}"#;
363
364        let compressed = compress_test_runner("npx jest --json", output);
365
366        assert!(compressed.starts_with("jest: 0 pass, 1 fail (out of 1)"));
367        assert!(compressed.contains("FAIL app.test.ts > app renders"));
368    }
369
370    #[test]
371    fn caps_json_failures_and_malformed_json_falls_back() {
372        let mut results = Vec::new();
373        for index in 0..6 {
374            results.push(format!(
375                r#"{{"fullName":"test {index}","status":"failed","failureMessages":["failure {index}"]}}"#
376            ));
377        }
378        let output = format!(
379            r#"{{"numTotalTests":6,"numPassedTests":0,"numFailedTests":6,"testResults":[{{"name":"/repo/src/foo.test.ts","assertionResults":[{}]}}]}}"#,
380            results.join(",")
381        );
382
383        let compressed = compress_test_runner("vitest --json", &output);
384
385        assert!(compressed.contains("+1 more failures"));
386        assert_eq!(
387            compress_test_runner("vitest --json", "{not-json"),
388            "{not-json"
389        );
390    }
391}