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