Skip to main content

browser_test/
driver_output.rs

1use std::collections::VecDeque;
2use std::env;
3use std::fmt;
4use std::num::NonZeroUsize;
5use std::sync::{Arc, Mutex};
6
7use chrome_for_testing_manager::{DriverOutputLine, DriverOutputListener, DriverOutputSource};
8use rootcause::Report;
9use rootcause::handlers::{
10    AttachmentFormattingPlacement, AttachmentFormattingStyle, AttachmentHandler, FormattingFunction,
11};
12use rootcause::report_attachment::ReportAttachment;
13
14use crate::BrowserTestError;
15use crate::env::env_flag_enabled;
16
17/// Default environment variable enabling browser driver output capture.
18pub(crate) const DEFAULT_BROWSER_DRIVER_OUTPUT_ENV: &str = "BROWSER_TEST_DRIVER_OUTPUT";
19
20/// Default environment variable controlling the captured browser driver output tail size.
21pub(crate) const DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES_ENV: &str =
22    "BROWSER_TEST_DRIVER_OUTPUT_TAIL_LINES";
23
24/// Default number of browser driver output lines retained when env capture is enabled.
25pub(crate) const DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES: usize = 200;
26
27/// Browser-driver output capture mode.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum DriverOutputConfig {
30    /// Do not capture browser-driver output.
31    Disabled,
32
33    /// Capture the last `usize` browser-driver output lines for failure diagnostics.
34    ///
35    /// `0` disables capture.
36    TailLines(usize),
37
38    /// Read capture settings from `BROWSER_TEST_DRIVER_OUTPUT` and
39    /// `BROWSER_TEST_DRIVER_OUTPUT_TAIL_LINES`.
40    FromEnv,
41}
42
43/// Deprecated name for [`DriverOutputConfig`].
44///
45/// Use [`DriverOutputConfig`] in new code.
46#[deprecated(since = "0.1.0", note = "use DriverOutputConfig instead")]
47pub type BrowserDriverOutputConfig = DriverOutputConfig;
48
49impl DriverOutputConfig {
50    /// Disable browser-driver output capture.
51    #[must_use]
52    pub const fn disabled() -> Self {
53        Self::Disabled
54    }
55
56    /// Capture the last `tail_lines` browser-driver output lines for failure diagnostics.
57    ///
58    /// `0` disables capture.
59    #[must_use]
60    pub const fn tail_lines(tail_lines: usize) -> Self {
61        Self::TailLines(tail_lines)
62    }
63
64    /// Capture the last `tail_lines` browser-driver output lines for failure diagnostics.
65    ///
66    /// `0` disables capture.
67    #[must_use]
68    pub const fn new(tail_lines: usize) -> Self {
69        Self::tail_lines(tail_lines)
70    }
71
72    /// Read capture settings from the browser-driver output environment variables.
73    #[must_use]
74    pub const fn from_env() -> Self {
75        Self::FromEnv
76    }
77}
78
79/// Shared capture handle for browser driver output.
80#[derive(Debug, Clone)]
81pub(crate) struct DriverOutputCapture {
82    inner: Arc<Mutex<BrowserDriverOutputState>>,
83}
84
85#[derive(Debug)]
86struct BrowserDriverOutputState {
87    tail_capacity: NonZeroUsize,
88    total_lines: usize,
89    tail_lines: VecDeque<DriverOutputLine>,
90}
91
92impl DriverOutputCapture {
93    /// Create a capture handle retaining the last `tail_lines` driver output lines.
94    ///
95    /// Use [`DriverOutputConfig`] when `0` should mean disabled.
96    #[must_use]
97    pub(crate) fn new(tail_lines: NonZeroUsize) -> Self {
98        Self {
99            inner: Arc::new(Mutex::new(BrowserDriverOutputState {
100                tail_capacity: tail_lines,
101                total_lines: 0,
102                tail_lines: VecDeque::with_capacity(tail_lines.get()),
103            })),
104        }
105    }
106
107    /// Return a snapshot of the currently captured output tail.
108    ///
109    /// # Panics
110    ///
111    /// Panics if the internal output capture mutex has been poisoned.
112    #[must_use]
113    pub(crate) fn snapshot(&self) -> DriverOutputSnapshot {
114        let state = self
115            .inner
116            .lock()
117            .expect("browser driver output capture mutex should not be poisoned");
118        DriverOutputSnapshot {
119            total_lines: state.total_lines,
120            tail_capacity: state.tail_capacity.get(),
121            tail_lines: state.tail_lines.iter().cloned().collect(),
122        }
123    }
124
125    pub(crate) fn listener(&self) -> DriverOutputListener {
126        let capture = self.clone();
127        DriverOutputListener::new(move |line| {
128            capture.push(line);
129        })
130    }
131
132    pub(crate) fn push(&self, line: DriverOutputLine) {
133        let mut state = self
134            .inner
135            .lock()
136            .expect("browser driver output capture mutex should not be poisoned");
137        state.total_lines += 1;
138
139        while state.tail_lines.len() >= state.tail_capacity.get() {
140            state.tail_lines.pop_front();
141        }
142        state.tail_lines.push_back(line);
143    }
144}
145
146/// Snapshot of browser driver output captured so far.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub(crate) struct DriverOutputSnapshot {
149    /// Total number of output lines seen by the capture handle.
150    pub(crate) total_lines: usize,
151
152    /// Maximum number of recent output lines retained.
153    pub(crate) tail_capacity: usize,
154
155    /// Recent output lines in callback sequence order.
156    pub(crate) tail_lines: Vec<DriverOutputLine>,
157}
158
159impl DriverOutputSnapshot {
160    /// Whether the retained output tail is empty.
161    #[must_use]
162    pub(crate) fn is_empty(&self) -> bool {
163        self.tail_lines.is_empty()
164    }
165}
166
167#[derive(Debug, Clone)]
168struct DriverOutputAttachment {
169    snapshot: DriverOutputSnapshot,
170}
171
172struct DriverOutputAttachmentHandler;
173
174impl AttachmentHandler<DriverOutputAttachment> for DriverOutputAttachmentHandler {
175    fn display(value: &DriverOutputAttachment, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
176        writeln!(formatter, "Recent browser driver output")?;
177        writeln!(
178            formatter,
179            "note: output comes from one shared browser-driver process; parallel tests may interleave lines."
180        )?;
181        if value.snapshot.total_lines > value.snapshot.tail_lines.len() {
182            writeln!(
183                formatter,
184                "showing the last {} of {} captured line(s).",
185                value.snapshot.tail_lines.len(),
186                value.snapshot.total_lines,
187            )?;
188        }
189
190        for line in &value.snapshot.tail_lines {
191            writeln!(
192                formatter,
193                "[{} {}] {}",
194                line.sequence,
195                source_label(line.source),
196                line.line,
197            )?;
198        }
199
200        Ok(())
201    }
202
203    fn debug(value: &DriverOutputAttachment, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
204        Self::display(value, formatter)
205    }
206
207    fn preferred_formatting_style(
208        _value: &DriverOutputAttachment,
209        _report_formatting: FormattingFunction,
210    ) -> AttachmentFormattingStyle {
211        AttachmentFormattingStyle {
212            placement: AttachmentFormattingPlacement::Appendix {
213                appendix_name: "Recent browser driver output",
214            },
215            function: FormattingFunction::Display,
216            priority: 5,
217        }
218    }
219}
220
221pub(crate) fn attach_browser_driver_output(
222    report: &mut Report<BrowserTestError>,
223    capture: Option<&DriverOutputCapture>,
224) {
225    let Some(snapshot) = capture.map(DriverOutputCapture::snapshot) else {
226        return;
227    };
228    if snapshot.is_empty() {
229        return;
230    }
231
232    report.attachments_mut().push(
233        ReportAttachment::new_custom::<DriverOutputAttachmentHandler>(DriverOutputAttachment {
234            snapshot,
235        })
236        .into_dynamic(),
237    );
238}
239
240pub(crate) fn attach_browser_driver_output_to_result(
241    result: Result<(), Report<BrowserTestError>>,
242    capture: Option<&DriverOutputCapture>,
243) -> Result<(), Report<BrowserTestError>> {
244    result.map_err(|mut err| {
245        attach_browser_driver_output(&mut err, capture);
246        err
247    })
248}
249
250pub(crate) fn browser_driver_output_config_from_env() -> DriverOutputConfig {
251    if !env_flag_enabled(DEFAULT_BROWSER_DRIVER_OUTPUT_ENV) {
252        return DriverOutputConfig::Disabled;
253    }
254
255    let tail_lines = env::var_os(DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES_ENV).map_or(
256        DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES,
257        |value| {
258            let value = value.to_string_lossy();
259            match value.trim().parse::<usize>() {
260                Ok(tail_lines) => tail_lines,
261                Err(err) => {
262                    tracing::warn!(
263                        env_var = DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES_ENV,
264                        value = %value,
265                        error = %err,
266                        fallback = DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES,
267                        "invalid browser driver output tail-line setting"
268                    );
269                    DEFAULT_BROWSER_DRIVER_OUTPUT_TAIL_LINES
270                }
271            }
272        },
273    );
274
275    if tail_lines == 0 {
276        DriverOutputConfig::Disabled
277    } else {
278        DriverOutputConfig::TailLines(tail_lines)
279    }
280}
281
282fn source_label(source: DriverOutputSource) -> &'static str {
283    match source {
284        DriverOutputSource::Stdout => "stdout",
285        DriverOutputSource::Stderr => "stderr",
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use assertr::prelude::*;
293
294    #[allow(deprecated)]
295    #[test]
296    fn deprecated_browser_driver_output_config_alias_matches_driver_output_config() {
297        let config: BrowserDriverOutputConfig = DriverOutputConfig::tail_lines(3);
298
299        assert_that!(config).is_equal_to(DriverOutputConfig::TailLines(3));
300    }
301
302    #[test]
303    fn capture_retains_bounded_tail_and_total_count() {
304        let capture = DriverOutputCapture::new(
305            NonZeroUsize::new(2).expect("literal tail capacity should be non-zero"),
306        );
307
308        capture.push(line(DriverOutputSource::Stdout, 0, "one"));
309        capture.push(line(DriverOutputSource::Stderr, 1, "two"));
310        capture.push(line(DriverOutputSource::Stdout, 2, "three"));
311
312        let snapshot = capture.snapshot();
313
314        assert_that!(snapshot.total_lines).is_equal_to(3);
315        assert_that!(snapshot.tail_capacity).is_equal_to(2);
316        assert_that!(snapshot.tail_lines.len()).is_equal_to(2);
317        assert_that!(&snapshot.tail_lines[0].line).is_equal_to("two");
318        assert_that!(&snapshot.tail_lines[1].line).is_equal_to("three");
319        assert_that!(snapshot.tail_lines[0].source).is_equal_to(DriverOutputSource::Stderr);
320    }
321
322    #[test]
323    fn browser_driver_output_is_rootcause_attachment_not_child_error() {
324        let capture = DriverOutputCapture::new(
325            NonZeroUsize::new(5).expect("literal tail capacity should be non-zero"),
326        );
327        capture.push(line(DriverOutputSource::Stdout, 0, "Starting ChromeDriver"));
328        let mut report = Report::new(BrowserTestError::RunTest {
329            test_name: "login".to_owned(),
330        });
331        let initial_attachment_count = report.attachments().len();
332
333        attach_browser_driver_output(&mut report, Some(&capture));
334
335        assert_that!(report.attachments().len()).is_equal_to(initial_attachment_count + 1);
336        assert_that!(report.children().len()).is_equal_to(0);
337        assert_that!(report.to_string()).contains("Recent browser driver output");
338        assert_that!(report.to_string()).contains("parallel tests may interleave lines");
339    }
340
341    fn line(source: DriverOutputSource, sequence: u64, line: &str) -> DriverOutputLine {
342        DriverOutputLine {
343            source,
344            sequence,
345            line: line.to_owned(),
346        }
347    }
348}