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