Skip to main content

aft/compress/
vitest.rs

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