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