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