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