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