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};
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}