Skip to main content

aft/compress/
playwright.rs

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}