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}