Skip to main content

chrome_for_testing_manager/
chromedriver.rs

1use crate::ChromeForTestingManagerError;
2use crate::mgr::{ChromeForTestingManager, LoadedChromePackage, VersionRequest};
3use crate::output::{DriverOutputInspectors, DriverOutputListener};
4use crate::port::{Port, PortRequest};
5use chrome_for_testing::Channel;
6use rootcause::prelude::ResultExt;
7#[cfg(feature = "thirtyfour")]
8use rootcause::{IntoReportCollection, markers::SendSync};
9use rootcause::{Report, report};
10use std::fmt::{Debug, Formatter};
11use std::process::ExitStatus;
12use std::time::Duration;
13use tokio::runtime::RuntimeFlavor;
14use tokio_process_tools::TerminateOnDrop;
15use tokio_process_tools::broadcast::BroadcastOutputStream;
16use typed_builder::TypedBuilder;
17
18/// Configuration used when running a `ChromeDriver` process.
19#[derive(Debug, Clone, TypedBuilder)]
20pub struct ChromedriverRunConfig {
21    /// The requested `ChromeDriver` version.
22    #[builder(default = VersionRequest::LatestIn(Channel::Stable))]
23    pub version: VersionRequest,
24
25    /// The requested `ChromeDriver` port.
26    #[builder(default = PortRequest::Any)]
27    pub port: PortRequest,
28
29    /// Optional callback for browser-driver process output lines.
30    #[builder(default, setter(strip_option(fallback = output_listener_opt)))]
31    pub output_listener: Option<DriverOutputListener>,
32}
33
34impl Default for ChromedriverRunConfig {
35    fn default() -> Self {
36        Self::builder().build()
37    }
38}
39
40/// A wrapper struct for a spawned chromedriver process.
41/// Keep this alive until your test is complete.
42///
43/// Automatically terminates the spawned chromedriver process when dropped.
44///
45/// You can always manually call `terminate`, but the on-drop automation makes it much safer in
46/// quickly panicking contexts, such as tests.
47pub struct Chromedriver {
48    /// The manager instance used to resolve a version, download it and starting the chromedriver.
49    mgr: ChromeForTestingManager,
50
51    /// Chrome and chromedriver binaries used for testing.
52    loaded: LoadedChromePackage,
53
54    /// The running chromedriver process. Terminated when dropped.
55    ///
56    /// Always stores a process handle. The value is only taken out on termination,
57    /// notifying our `Drop` impl that the process was gracefully terminated when seeing `None`.
58    process: Option<TerminateOnDrop<BroadcastOutputStream>>,
59
60    /// Long-lived browser-driver output inspectors.
61    output_inspectors: Option<DriverOutputInspectors>,
62
63    /// The port the chromedriver process listens on.
64    port: Port,
65}
66
67impl Debug for Chromedriver {
68    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("Chromedriver")
70            .field("mgr", &self.mgr)
71            .field("loaded", &self.loaded)
72            .field("process", &self.process)
73            .field("output_inspectors", &self.output_inspectors)
74            .field("port", &self.port)
75            .finish()
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use assertr::prelude::*;
83
84    #[test]
85    fn run_config_defaults_to_latest_stable_on_any_port() {
86        let config = ChromedriverRunConfig::builder().build();
87
88        assert_that!(config.version).is_equal_to(VersionRequest::LatestIn(Channel::Stable));
89        assert_that!(config.port).is_equal_to(PortRequest::Any);
90        assert_that!(config.output_listener).is_none();
91    }
92
93    #[test]
94    fn run_config_accepts_bare_output_listener() {
95        let listener = DriverOutputListener::new(|_line| {});
96
97        let config = ChromedriverRunConfig::builder()
98            .output_listener(listener)
99            .build();
100
101        assert_that!(config.output_listener).is_some();
102    }
103
104    #[test]
105    fn run_config_accepts_optional_output_listener() {
106        let listener = DriverOutputListener::new(|_line| {});
107
108        let config = ChromedriverRunConfig::builder()
109            .output_listener_opt(Some(listener))
110            .build();
111
112        assert_that!(config.output_listener).is_some();
113
114        let config = ChromedriverRunConfig::builder()
115            .output_listener_opt(None)
116            .build();
117
118        assert_that!(config.output_listener).is_none();
119    }
120}
121
122impl Chromedriver {
123    /// Resolve, download, and launch a chromedriver process.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if the runtime is not multithreaded, version resolution fails,
128    /// the download fails, or the chromedriver process cannot be spawned.
129    pub async fn run(
130        config: ChromedriverRunConfig,
131    ) -> Result<Chromedriver, Report<ChromeForTestingManagerError>> {
132        // Assert that async-drop will work.
133        // This is the only way of constructing a `Chromedriver` instance,
134        // so it's safe to do this here.
135        match tokio::runtime::Handle::current().runtime_flavor() {
136            RuntimeFlavor::MultiThread => { /* we want this */ }
137            unsupported_flavor => {
138                return Err(report!(ChromeForTestingManagerError::UnsupportedRuntime {
139                    runtime_flavor: unsupported_flavor,
140                }));
141            }
142        }
143
144        let mgr = ChromeForTestingManager::new()?;
145        let selected = mgr.resolve_version(config.version).await?;
146        let loaded = mgr.download(selected).await?;
147        let (process_handle, actual_port, output_inspectors) = mgr
148            .launch_chromedriver(&loaded, config.port, config.output_listener)
149            .await?;
150        Ok(Chromedriver {
151            process: Some(
152                process_handle.terminate_on_drop(Duration::from_secs(3), Duration::from_secs(3)),
153            ),
154            output_inspectors: Some(output_inspectors),
155            port: actual_port,
156            loaded,
157            mgr,
158        })
159    }
160
161    /// Gracefully terminate the chromedriver process with default timeouts (3s each).
162    ///
163    /// # Errors
164    ///
165    /// Returns an error if the process cannot be terminated within the timeout.
166    pub async fn terminate(self) -> Result<ExitStatus, Report<ChromeForTestingManagerError>> {
167        self.terminate_with_timeouts(Duration::from_secs(3), Duration::from_secs(3))
168            .await
169    }
170
171    /// Gracefully terminate the chromedriver process with custom timeouts.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if the process cannot be terminated within the given timeouts.
176    #[expect(clippy::missing_panics_doc)] // Process handle is always present; only taken here.
177    pub async fn terminate_with_timeouts(
178        mut self,
179        interrupt_timeout: Duration,
180        terminate_timeout: Duration,
181    ) -> Result<ExitStatus, Report<ChromeForTestingManagerError>> {
182        let _output_inspectors = self.output_inspectors.take();
183        self.process
184            .take()
185            .expect("present")
186            .terminate(interrupt_timeout, terminate_timeout)
187            .await
188            .context(ChromeForTestingManagerError::TerminateChromedriver { port: self.port })
189    }
190
191    /// Execute an async closure with a `WebDriver` session.
192    /// The session will be automatically cleaned up after the closure completes.
193    ///
194    /// # Errors
195    ///
196    /// Returns an error if session creation fails or the user closure returns an error.
197    #[cfg(feature = "thirtyfour")]
198    pub async fn with_session<T, E, F>(
199        &self,
200        f: F,
201    ) -> Result<T, Report<ChromeForTestingManagerError>>
202    where
203        F: for<'a> AsyncFnOnce(&'a crate::session::Session) -> Result<T, E>,
204        E: IntoReportCollection<SendSync>,
205    {
206        self.with_custom_session(|_caps| Ok(()), f).await
207    }
208
209    /// Execute an async closure with a custom-configured `WebDriver` session.
210    /// The session will be automatically cleaned up after the closure completes.
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if capability setup, session creation, or the user closure fails.
215    #[cfg(feature = "thirtyfour")]
216    pub async fn with_custom_session<T, E, F>(
217        &self,
218        setup: impl Fn(
219            &mut thirtyfour::ChromeCapabilities,
220        ) -> Result<(), thirtyfour::prelude::WebDriverError>,
221        f: F,
222    ) -> Result<T, Report<ChromeForTestingManagerError>>
223    where
224        F: for<'a> AsyncFnOnce(&'a crate::session::Session) -> Result<T, E>,
225        E: IntoReportCollection<SendSync>,
226    {
227        use crate::session::Session;
228        use futures::FutureExt;
229
230        let mut caps = self.mgr.prepare_caps(&self.loaded)?;
231        setup(&mut caps).context(ChromeForTestingManagerError::ConfigureSessionCapabilities)?;
232        let driver = thirtyfour::WebDriver::new(format!("http://localhost:{}", self.port), caps)
233            .await
234            .context(ChromeForTestingManagerError::StartWebDriverSession { port: self.port })?;
235
236        let session = Session { driver };
237
238        // Execute the user function.
239        let maybe_panicked = core::panic::AssertUnwindSafe(f(&session))
240            .catch_unwind()
241            .await;
242
243        let user_result = match maybe_panicked {
244            Ok(result) => result.context(ChromeForTestingManagerError::RunSessionCallback),
245            Err(payload) => {
246                if let Err(quit_err) = session.quit().await {
247                    tracing::error!(
248                        "Failed to quit WebDriver session after user callback panic: {quit_err:?}"
249                    );
250                }
251                std::panic::resume_unwind(payload);
252            }
253        };
254
255        // No matter what happened, clean up the session.
256        let quit_result = session.quit().await;
257
258        match (user_result, quit_result) {
259            (Ok(value), Ok(())) => Ok(value),
260            (Ok(_), Err(quit_err)) => Err(quit_err),
261            (Err(user_err), Ok(())) => Err(user_err),
262            (Err(mut user_err), Err(quit_err)) => {
263                tracing::error!(
264                    "Failed to quit WebDriver session after user failure: {quit_err:?}"
265                );
266                user_err
267                    .children_mut()
268                    .push(quit_err.into_dynamic().into_cloneable());
269                Err(user_err)
270            }
271        }
272    }
273}