1use serde_json::Value;
2
3use crate::compress::generic::{dedup_consecutive, middle_truncate, strip_ansi, GenericCompressor};
4use crate::compress::{CompressionResult, Compressor, OutputProbe};
5
6const MAX_LINES: usize = 400;
7const MAX_JSON_FAILURES: usize = 20;
8const MAX_JSON_ERROR_LINES: usize = 20;
9
10pub struct PlaywrightCompressor;
11
12#[derive(Debug)]
13struct PlaywrightFailure {
14 file: String,
15 line: Option<u64>,
16 title: String,
17 error: String,
18}
19
20impl Compressor for PlaywrightCompressor {
21 fn matches(&self, command: &str) -> bool {
22 command_tokens(command).any(|token| token == "playwright")
23 }
24
25 fn compress_with_exit_code(
26 &self,
27 _command: &str,
28 output: &str,
29 exit_code: Option<i32>,
30 ) -> CompressionResult {
31 let compressed = compress_playwright(output);
32 if matches!(exit_code, Some(code) if code != 0) && is_success_summary(&compressed) {
33 GenericCompressor::compress_output(output).into()
34 } else {
35 compressed.into()
36 }
37 }
38
39 fn matches_output(&self, output: &str) -> bool {
40 output
41 .lines()
42 .any(|line| is_playwright_running_signature(line.trim_start()))
43 || looks_like_playwright_json_output(output)
44 }
45
46 fn matches_output_probe(&self, probe: &OutputProbe<'_>) -> bool {
47 let output = probe.output();
48 output
49 .lines()
50 .any(|line| is_playwright_running_signature(line.trim_start()))
51 || (output.trim_start().starts_with('{')
52 && probe.json().is_some_and(looks_like_playwright_json_value))
53 }
54}
55
56fn is_success_summary(text: &str) -> bool {
57 let lower = text.to_ascii_lowercase();
58 lower.starts_with("playwright:")
59 || (lower.contains(" tests: ") && lower.contains(" passed") && lower.contains("0 failed"))
60}
61
62fn looks_like_playwright_json_output(output: &str) -> bool {
63 let trimmed = output.trim_start();
64 if !trimmed.starts_with('{') {
65 return false;
66 }
67 serde_json::from_str::<Value>(trimmed)
68 .ok()
69 .is_some_and(|value| looks_like_playwright_json_value(&value))
70}
71
72fn looks_like_playwright_json_value(value: &Value) -> bool {
73 value.get("stats").is_some() && value.get("suites").is_some()
74}
75
76fn is_playwright_running_signature(trimmed: &str) -> bool {
77 let Some(rest) = trimmed.strip_prefix("Running ") else {
78 return false;
79 };
80 let mut parts = rest.split_whitespace();
81 let Some(test_count) = parts.next() else {
82 return false;
83 };
84 if test_count.parse::<usize>().is_err() {
85 return false;
86 }
87 if !matches!(parts.next(), Some("test" | "tests")) {
88 return false;
89 }
90 if parts.next() != Some("using") {
91 return false;
92 }
93 let Some(worker_count) = parts.next() else {
94 return false;
95 };
96 if worker_count.parse::<usize>().is_err() {
97 return false;
98 }
99 matches!(parts.next(), Some("worker" | "workers"))
100}
101
102fn compress_playwright(output: &str) -> String {
103 let trimmed = output.trim_start();
104 if trimmed.starts_with('{') {
105 if let Some(compressed) = compress_json(trimmed) {
106 return finish(&compressed);
107 }
108 return GenericCompressor::compress_output(output);
109 }
110
111 finish(&compress_text(output))
112}
113
114fn command_tokens(command: &str) -> impl Iterator<Item = String> + '_ {
115 command
116 .split_whitespace()
117 .map(|token| token.trim_matches(|ch| matches!(ch, '\'' | '"')))
118 .map(|token| {
119 token
120 .rsplit(['/', '\\'])
121 .next()
122 .unwrap_or(token)
123 .trim_end_matches(".cmd")
124 .to_string()
125 })
126}
127
128fn compress_json(input: &str) -> Option<String> {
129 let value: Value = serde_json::from_str(input).ok()?;
130 let stats = value.get("stats");
131 let passed = stats
132 .and_then(|stats| number_field(stats, "expected"))
133 .unwrap_or(0);
134 let failed = stats
135 .and_then(|stats| number_field(stats, "unexpected"))
136 .unwrap_or(0);
137 let total = passed + failed;
138 let failures = json_failures(&value);
139
140 let mut lines = vec![format!("{total} tests: {passed} passed, {failed} failed")];
141 for failure in failures.iter().take(MAX_JSON_FAILURES) {
142 lines.push(String::new());
143 let location = match failure.line {
144 Some(line) => format!("{}:{line}", failure.file),
145 None => failure.file.clone(),
146 };
147 lines.push(format!("[{location}] {}", failure.title));
148 for line in first_error_lines(&failure.error, MAX_JSON_ERROR_LINES) {
149 lines.push(format!(" {line}"));
150 }
151 }
152 if failures.len() > MAX_JSON_FAILURES {
153 lines.push(format!(
154 "+{} more failures",
155 failures.len() - MAX_JSON_FAILURES
156 ));
157 }
158
159 Some(lines.join("\n"))
160}
161
162fn json_failures(value: &Value) -> Vec<PlaywrightFailure> {
163 let mut failures = Vec::new();
164 for suite in value
165 .get("suites")
166 .and_then(Value::as_array)
167 .into_iter()
168 .flatten()
169 {
170 collect_suite_failures(suite, None, &mut failures);
171 }
172 failures
173}
174
175fn collect_suite_failures(
176 suite: &Value,
177 inherited_file: Option<&str>,
178 failures: &mut Vec<PlaywrightFailure>,
179) {
180 let suite_file = string_field(suite, "file").or(inherited_file);
181
182 for spec in suite
183 .get("specs")
184 .and_then(Value::as_array)
185 .into_iter()
186 .flatten()
187 {
188 let title = string_field(spec, "title").unwrap_or("failed test");
189 let line = number_field(spec, "line").or_else(|| number_field(spec, "column"));
190 let mut spec_file = string_field(spec, "file").or(suite_file);
191 let mut spec_line = line;
192
193 for test in spec
194 .get("tests")
195 .and_then(Value::as_array)
196 .into_iter()
197 .flatten()
198 {
199 if !is_failed_test(test) {
200 continue;
201 }
202 spec_file = string_field(test, "file").or(spec_file);
203 spec_line = number_field(test, "line").or(spec_line);
204
205 for result in test
206 .get("results")
207 .and_then(Value::as_array)
208 .into_iter()
209 .flatten()
210 {
211 if !is_failed_result(result) {
212 continue;
213 }
214 let error = result_error(result).unwrap_or_else(|| "Test failed".to_string());
215 failures.push(PlaywrightFailure {
216 file: spec_file.unwrap_or("<unknown>").to_string(),
217 line: spec_line,
218 title: title.to_string(),
219 error,
220 });
221 }
222 }
223 }
224
225 for child in suite
226 .get("suites")
227 .and_then(Value::as_array)
228 .into_iter()
229 .flatten()
230 {
231 collect_suite_failures(child, suite_file, failures);
232 }
233}
234
235fn is_failed_test(test: &Value) -> bool {
236 matches!(
237 string_field(test, "status"),
238 Some("unexpected" | "failed" | "timedOut" | "interrupted")
239 ) || test
240 .get("results")
241 .and_then(Value::as_array)
242 .into_iter()
243 .flatten()
244 .any(is_failed_result)
245}
246
247fn is_failed_result(result: &Value) -> bool {
248 matches!(
249 string_field(result, "status"),
250 Some("failed" | "timedOut" | "interrupted")
251 ) || result.get("error").is_some()
252 || result
253 .get("errors")
254 .and_then(Value::as_array)
255 .is_some_and(|errors| !errors.is_empty())
256}
257
258fn result_error(result: &Value) -> Option<String> {
259 result
260 .get("error")
261 .and_then(error_message)
262 .or_else(|| {
263 result
264 .get("errors")
265 .and_then(Value::as_array)
266 .and_then(|errors| errors.iter().find_map(error_message))
267 })
268 .or_else(|| string_field(result, "errorMessage").map(ToString::to_string))
269}
270
271fn error_message(value: &Value) -> Option<String> {
272 value.as_str().map(ToString::to_string).or_else(|| {
273 string_field(value, "message")
274 .or_else(|| string_field(value, "value"))
275 .map(ToString::to_string)
276 })
277}
278
279fn compress_text(output: &str) -> String {
280 let lines: Vec<&str> = output.lines().collect();
281 let mut kept = Vec::new();
282 let mut passed = None;
283 let mut duration = None;
284 let mut has_failures = false;
285 let mut index = 0;
286
287 while index < lines.len() {
288 let line = lines[index];
289 let trimmed = line.trim_start();
290
291 if let Some((count, time)) = parse_running_line(trimmed) {
292 passed.get_or_insert(count);
293 duration = time;
294 index += 1;
295 continue;
296 }
297
298 if is_passing_test_line(trimmed) {
299 index += 1;
300 continue;
301 }
302
303 if is_failure_heading(trimmed) {
304 has_failures = true;
305 while index < lines.len() {
306 let current = lines[index];
307 let current_trimmed = current.trim_start();
308 if !kept.is_empty()
309 && (is_summary_line(current_trimmed) || is_passing_test_line(current_trimmed))
310 {
311 break;
312 }
313 kept.push(current.to_string());
314 index += 1;
315 }
316 continue;
317 }
318
319 if is_summary_line(trimmed) {
320 if trimmed.contains("failed") {
321 has_failures = true;
322 }
323 if let Some(count) = summary_count(trimmed, "passed") {
324 passed = Some(count);
325 duration = parse_parenthesized_duration(trimmed).or(duration);
326 }
327 kept.push(line.to_string());
328 }
329
330 index += 1;
331 }
332
333 if !has_failures {
334 if let Some(passed) = passed {
335 return match duration {
336 Some(duration) => format!("playwright: {passed} tests passed ({duration})"),
337 None => format!("playwright: {passed} tests passed"),
338 };
339 }
340 }
341
342 if kept.is_empty() {
343 return GenericCompressor::compress_output(output);
344 }
345 kept.join("\n")
346}
347
348fn is_passing_test_line(trimmed: &str) -> bool {
349 trimmed.starts_with('✓') || trimmed.starts_with("✔")
350}
351
352fn is_failure_heading(trimmed: &str) -> bool {
353 let Some((prefix, rest)) = trimmed.split_once(')') else {
354 return false;
355 };
356 !prefix.is_empty() && prefix.chars().all(|ch| ch.is_ascii_digit()) && rest.contains('›')
357}
358
359fn is_summary_line(trimmed: &str) -> bool {
360 (trimmed.chars().next().is_some_and(|ch| ch.is_ascii_digit())
361 && (trimmed.contains(" failed")
362 || trimmed.contains(" passed")
363 || trimmed.contains(" skipped")
364 || trimmed.contains(" flaky")))
365 || (trimmed.starts_with('[') && trimmed.contains('›'))
366}
367
368fn parse_running_line(trimmed: &str) -> Option<(usize, Option<String>)> {
369 if !is_playwright_running_signature(trimmed) {
370 return None;
371 }
372 let count = trimmed
373 .strip_prefix("Running ")?
374 .split_whitespace()
375 .next()?
376 .parse()
377 .ok()?;
378 Some((count, parse_parenthesized_duration(trimmed)))
379}
380
381fn parse_parenthesized_duration(line: &str) -> Option<String> {
382 let start = line.rfind('(')?;
383 let end = line[start + 1..].find(')')? + start + 1;
384 Some(line[start + 1..end].to_string())
385}
386
387fn summary_count(trimmed: &str, word: &str) -> Option<usize> {
388 let mut parts = trimmed.split_whitespace();
389 let count = parts.next()?.parse().ok()?;
390 if parts.next()? == word {
391 Some(count)
392 } else {
393 None
394 }
395}
396
397fn first_error_lines(message: &str, max: usize) -> Vec<String> {
398 message
399 .lines()
400 .take(max)
401 .map(str::trim_end)
402 .filter(|line| !line.trim().is_empty())
403 .map(ToString::to_string)
404 .collect()
405}
406
407fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
408 value.get(key).and_then(Value::as_str)
409}
410
411fn number_field(value: &Value, key: &str) -> Option<u64> {
412 value.get(key).and_then(Value::as_u64)
413}
414
415fn finish(input: &str) -> String {
416 let stripped = strip_ansi(input);
417 let deduped = dedup_consecutive(&stripped);
418 cap_lines(
419 &middle_truncate(&deduped, 64 * 1024, 32 * 1024, 32 * 1024),
420 MAX_LINES,
421 )
422}
423
424fn cap_lines(input: &str, max_lines: usize) -> String {
425 let lines: Vec<&str> = input.lines().collect();
426 if lines.len() <= max_lines {
427 return input.trim_end().to_string();
428 }
429 let mut kept = lines
430 .iter()
431 .take(max_lines)
432 .copied()
433 .collect::<Vec<_>>()
434 .join("\n");
435 kept.push_str(&format!(
436 "\n... truncated {} lines",
437 lines.len() - max_lines
438 ));
439 kept
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn matches_playwright_token_anywhere_and_rejects_substrings() {
448 let compressor = PlaywrightCompressor;
449 assert!(compressor.matches("playwright test"));
450 assert!(compressor.matches("npx playwright test"));
451 assert!(compressor.matches("pnpm exec playwright test"));
452 assert!(compressor.matches("bun run playwright"));
453 assert!(compressor.matches("./node_modules/.bin/playwright test"));
454 assert!(!compressor.matches("playwright-runner test"));
455 assert!(!compressor.matches("/tmp/not-playwright-output.log"));
456 assert!(!compressor.matches("npm test"));
457 }
458
459 #[test]
460 fn text_happy_path_drops_passing_tests_and_preserves_summary() {
461 let output = r#"Running 4 tests using 2 workers
462
463 ✓ 1 [chromium] › example.spec.ts:5:1 › has title (2.3s)
464 ✓ 2 [chromium] › example.spec.ts:9:1 › get started link (1.8s)
465 ✓ 3 [chromium] › nav.spec.ts:3:1 › navigates (1.2s)
466 ✓ 4 [chromium] › auth.spec.ts:7:1 › logs out (1.0s)
467
468 4 passed (6.3s)
469"#;
470
471 let compressed = compress_playwright(output);
472
473 assert_eq!(compressed, "playwright: 4 tests passed (6.3s)");
474 assert!(!compressed.contains("has title"));
475 }
476
477 #[test]
478 fn text_failure_path_preserves_detail_verbatim_and_summary() {
479 let failure = r#" 1) auth.spec.ts:12:1 › login flow ─────────────────────────
480
481 Error: expect(received).toBe(expected)
482
483 Expected: "Welcome"
484 Received: undefined
485
486 12 | await page.click('text=Sign in');
487 13 | const title = await page.locator('h1').textContent();
488 > 14 | expect(title).toBe('Welcome');
489 | ^
490
491 at /tests/auth.spec.ts:14:18"#;
492 let output = format!(
493 "Running 3 tests using 1 workers\n ✓ 1 [chromium] › a.spec.ts:1:1 › passes (1s)\n ✘ 2 [chromium] › auth.spec.ts:12:1 › login flow (5.1s)\n\n{failure}\n\n 1 failed\n [chromium] › auth.spec.ts:12:1 › login flow\n 2 passed (7.1s)\n"
494 );
495
496 let compressed = compress_playwright(&output);
497
498 assert!(compressed.contains(failure));
499 assert!(compressed.contains("1 failed"));
500 assert!(compressed.contains("2 passed (7.1s)"));
501 assert!(!compressed.contains("a.spec.ts:1:1 › passes"));
502 }
503
504 #[test]
505 fn large_text_input_compresses_below_half_and_keeps_summary() {
506 let mut output = String::from("Running 500 tests using 8 workers\n");
507 for index in 1..=500 {
508 output.push_str(&format!(
509 " ✓ {index} [chromium] › spec{index}.ts:1:1 › passes {index} (10ms)\n"
510 ));
511 }
512 output.push_str("\n 500 passed (5.0s)\n");
513
514 let compressed = compress_playwright(&output);
515
516 assert!(compressed.len() < output.len() / 2);
517 assert_eq!(compressed, "playwright: 500 tests passed (5.0s)");
518 }
519
520 #[test]
521 fn json_reporter_summarizes_and_extracts_failures() {
522 let output = r#"{"stats":{"expected":12,"unexpected":1,"duration":15300},"suites":[{"title":"auth","file":"auth.spec.ts","specs":[{"title":"login flow","ok":false,"line":12,"tests":[{"status":"unexpected","results":[{"status":"failed","error":{"message":"Error: expect(received).toBe(expected)\nExpected: \"Welcome\"\nReceived: undefined"}}]}]},{"title":"has title","ok":true,"tests":[{"status":"expected","results":[{"status":"passed"}]}]}]}]}"#;
523
524 let compressed = compress_playwright(output);
525
526 assert!(compressed.starts_with("13 tests: 12 passed, 1 failed"));
527 assert!(compressed.contains("[auth.spec.ts:12] login flow"));
528 assert!(compressed.contains(" Error: expect(received).toBe(expected)"));
529 assert!(
530 compressed.contains("Expected: \\\"Welcome\\\"")
531 || compressed.contains("Expected: \"Welcome\"")
532 );
533 assert!(!compressed.contains("has title"));
534 assert!(!compressed.trim_start().starts_with('{'));
535 }
536}