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