Skip to main content

chrome_for_testing_manager/
chromedriver.rs

1use crate::ChromeForTestingManagerError;
2use crate::mgr::{ChromeForTestingManager, LoadedChromePackage};
3use crate::output::{DriverOutputInspectors, DriverOutputListener};
4use crate::port::{Port, PortRequest};
5use crate::version::VersionRequest;
6use chrome_for_testing::Channel;
7use rootcause::prelude::ResultExt;
8#[cfg(feature = "thirtyfour")]
9use rootcause::{IntoReportCollection, markers::SendSync};
10use rootcause::{Report, report};
11use std::fmt::{Debug, Formatter};
12use std::path::PathBuf;
13use std::process::ExitStatus;
14use std::time::Duration;
15use tokio::runtime::RuntimeFlavor;
16use tokio_process_tools::{
17    BroadcastOutputStream, GracefulShutdown, ReliableWithBackpressure, ReplayEnabled,
18    TerminateOnDrop,
19};
20use typed_builder::TypedBuilder;
21
22/// Default per-platform graceful-shutdown budget used when terminating the spawned `chromedriver`
23/// process: 3 s `SIGTERM` on Unix (then `SIGKILL`) and 3 s `CTRL_BREAK_EVENT` on Windows (then
24/// `TerminateProcess`).
25#[must_use]
26pub(crate) fn default_graceful_shutdown() -> GracefulShutdown {
27    let timeout = Duration::from_secs(3);
28    GracefulShutdown::builder()
29        .unix_sigterm(timeout)
30        .windows_ctrl_break(timeout)
31        .build()
32}
33
34/// Configuration used when running a `ChromeDriver` process.
35///
36/// Construct via [`Self::builder`] or [`Self::default`]. Defaults: latest stable Chrome,
37/// OS-assigned port, no output listener, default cache directory, 3s graceful termination budget
38/// on all systems.
39///
40/// ```no_run
41/// # use chrome_for_testing_manager::{Channel, ChromedriverRunConfig, DriverOutputListener, GracefulShutdown};
42/// # use std::time::Duration;
43/// let config = ChromedriverRunConfig::builder()
44///     .version(Channel::Stable)            // Accepts Channel, Version, or VersionRequest.
45///     .port(8080u16)                       // Accepts u16, Port, or PortRequest.
46///     .output_listener(DriverOutputListener::new(|line| println!("{line:?}")))
47///     .graceful_shutdown(
48///         GracefulShutdown::builder()
49///             .unix_sigterm(Duration::from_secs(3))
50///             .windows_ctrl_break(Duration::from_secs(3))
51///             .build(),
52///     )
53///     .build();
54/// ```
55#[derive(Debug, Clone, TypedBuilder)]
56pub struct ChromedriverRunConfig {
57    /// The requested `ChromeDriver` version.
58    ///
59    /// Accepts anything implementing `Into<VersionRequest>`, including [`Channel`] and
60    /// [`crate::Version`].
61    #[builder(default = VersionRequest::LatestIn(Channel::Stable), setter(into))]
62    pub version: VersionRequest,
63
64    /// The requested `ChromeDriver` port.
65    ///
66    /// Accepts anything implementing `Into<PortRequest>`, including a bare `u16` and [`Port`].
67    #[builder(default = PortRequest::Any, setter(into))]
68    pub port: PortRequest,
69
70    /// Optional callback for browser-driver process output lines.
71    #[builder(default, setter(strip_option(fallback = output_listener_opt)))]
72    pub output_listener: Option<DriverOutputListener>,
73
74    /// Optional override for the cache directory holding downloaded chrome / chromedriver
75    /// artifacts. Defaults to the platform's per-user cache directory.
76    #[builder(default, setter(strip_option(fallback = cache_dir_opt)))]
77    pub cache_dir: Option<PathBuf>,
78
79    /// Per-platform graceful-shutdown budget applied when the [`Chromedriver`] handle is dropped
80    /// or [`Chromedriver::terminate`] is called.
81    #[builder(default = default_graceful_shutdown())]
82    pub graceful_shutdown: GracefulShutdown,
83}
84
85impl Default for ChromedriverRunConfig {
86    fn default() -> Self {
87        Self::builder().build()
88    }
89}
90
91/// A handle to a spawned chromedriver process plus its resolved Chrome / `ChromeDriver` binaries.
92///
93/// Terminates automatically when dropped, using the budget configured via
94/// [`ChromedriverRunConfig::graceful_shutdown`]. The on-drop automation keeps tests safe in the
95/// face of panics. Call [`Self::terminate`] to drive the same shutdown explicitly and surface any
96/// error.
97///
98/// Drive `WebDriver` sessions through [`Self::with_session`] / [`Self::with_custom_session`].
99/// Sessions are independent, so multiple of them can run concurrently against the same chromedriver
100/// via `tokio::join!` or `tokio::spawn` on a multi-thread runtime.
101pub struct Chromedriver {
102    /// The manager instance used to resolve a version, download it and starting the chromedriver.
103    mgr: ChromeForTestingManager,
104
105    /// Chrome and chromedriver binaries used for testing.
106    loaded: LoadedChromePackage,
107
108    /// The running chromedriver process. Terminated when dropped.
109    ///
110    /// Always stores a process handle. The value is only taken out on termination,
111    /// notifying our `Drop` impl that the process was gracefully terminated when seeing `None`.
112    process:
113        Option<TerminateOnDrop<BroadcastOutputStream<ReliableWithBackpressure, ReplayEnabled>>>,
114
115    /// Long-lived browser-driver output inspectors.
116    output_inspectors: Option<DriverOutputInspectors>,
117
118    /// The port the chromedriver process listens on.
119    port: Port,
120
121    /// Graceful-shutdown budget to use when terminating, including on drop.
122    graceful_shutdown: GracefulShutdown,
123}
124
125impl Debug for Chromedriver {
126    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
127        f.debug_struct("Chromedriver")
128            .field("mgr", &self.mgr)
129            .field("loaded", &self.loaded)
130            .field("process", &self.process)
131            .field("output_inspectors", &self.output_inspectors)
132            .field("port", &self.port)
133            .field("graceful_shutdown", &self.graceful_shutdown)
134            .finish()
135    }
136}
137
138impl Chromedriver {
139    /// Convenience: resolve, download, and launch chromedriver using
140    /// [`ChromedriverRunConfig::default`]. Equivalent to
141    /// `Chromedriver::run(ChromedriverRunConfig::default()).await`.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if the runtime is not multithreaded, version resolution fails,
146    /// the download fails, or the chromedriver process cannot be spawned.
147    pub async fn run_default() -> Result<Chromedriver, Report<ChromeForTestingManagerError>> {
148        Self::run(ChromedriverRunConfig::default()).await
149    }
150
151    /// Resolve, download, and launch a chromedriver process.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if the runtime is not multithreaded, version resolution fails,
156    /// the download fails, or the chromedriver process cannot be spawned.
157    pub async fn run(
158        config: ChromedriverRunConfig,
159    ) -> Result<Chromedriver, Report<ChromeForTestingManagerError>> {
160        // Assert that async-drop will work.
161        // This is the only way of constructing a `Chromedriver` instance,
162        // so it's safe to do this here.
163        match tokio::runtime::Handle::current().runtime_flavor() {
164            RuntimeFlavor::MultiThread => { /* we want this */ }
165            unsupported_flavor => {
166                return Err(report!(ChromeForTestingManagerError::UnsupportedRuntime {
167                    runtime_flavor: unsupported_flavor,
168                }));
169            }
170        }
171
172        let mgr = match config.cache_dir {
173            Some(cache_dir) => ChromeForTestingManager::new_with_cache_dir(cache_dir)?,
174            None => ChromeForTestingManager::new()?,
175        };
176        let selected = mgr.resolve_version(config.version).await?;
177        let loaded = mgr.download(selected).await?;
178        let graceful_shutdown = config.graceful_shutdown;
179        let (process_handle, actual_port, output_inspectors) = mgr
180            .launch_chromedriver(
181                &loaded,
182                config.port,
183                config.output_listener,
184                graceful_shutdown.clone(),
185            )
186            .await?;
187        Ok(Chromedriver {
188            process: Some(process_handle.terminate_on_drop(graceful_shutdown.clone())),
189            output_inspectors: Some(output_inspectors),
190            port: actual_port,
191            loaded,
192            mgr,
193            graceful_shutdown,
194        })
195    }
196
197    /// The port the chromedriver process is listening on.
198    ///
199    /// When constructed with [`PortRequest::Any`] this reflects the OS-assigned port.
200    #[must_use]
201    pub fn port(&self) -> Port {
202        self.port
203    }
204
205    /// Gracefully terminate the chromedriver process with the configured [`GracefulShutdown`],
206    /// configurable via the `graceful_shutdown` field of [`ChromedriverRunConfig`].
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if the process cannot be terminated within the configured graceful-shutdown
211    /// budget.
212    #[expect(clippy::missing_panics_doc)] // Process handle is always present; only taken here.
213    pub async fn terminate(mut self) -> Result<ExitStatus, Report<ChromeForTestingManagerError>> {
214        let _output_inspectors = self.output_inspectors.take();
215        self.process
216            .take()
217            .expect("present")
218            .terminate(self.graceful_shutdown)
219            .await
220            .context(ChromeForTestingManagerError::TerminateChromedriver { port: self.port })
221    }
222
223    /// Execute an async closure with a `WebDriver` session.
224    /// The session will be automatically cleaned up after the closure completes.
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if session creation fails or the user closure returns an error.
229    #[cfg(feature = "thirtyfour")]
230    pub async fn with_session<T, E, F>(
231        &self,
232        f: F,
233    ) -> Result<T, Report<ChromeForTestingManagerError>>
234    where
235        F: for<'a> AsyncFnOnce(&'a crate::session::Session) -> Result<T, E>,
236        E: IntoReportCollection<SendSync>,
237    {
238        self.with_custom_session(|_caps| Ok(()), f).await
239    }
240
241    /// Execute an async closure with a custom-configured `WebDriver` session.
242    /// The session will be automatically cleaned up after the closure completes.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if capability setup, session creation, or the user closure fails.
247    #[cfg(feature = "thirtyfour")]
248    pub async fn with_custom_session<T, E, F>(
249        &self,
250        setup: impl FnOnce(
251            &mut thirtyfour::ChromeCapabilities,
252        ) -> Result<(), thirtyfour::prelude::WebDriverError>,
253        f: F,
254    ) -> Result<T, Report<ChromeForTestingManagerError>>
255    where
256        F: for<'a> AsyncFnOnce(&'a crate::session::Session) -> Result<T, E>,
257        E: IntoReportCollection<SendSync>,
258    {
259        use crate::session::Session;
260        use futures::FutureExt;
261
262        let mut caps = self.mgr.prepare_caps(&self.loaded)?;
263        setup(&mut caps).context(ChromeForTestingManagerError::ConfigureSessionCapabilities)?;
264        let driver = thirtyfour::WebDriver::new(format!("http://localhost:{}", self.port), caps)
265            .await
266            .context(ChromeForTestingManagerError::StartWebDriverSession { port: self.port })?;
267
268        let session = Session { driver };
269
270        // Execute the user function.
271        let maybe_panicked = core::panic::AssertUnwindSafe(f(&session))
272            .catch_unwind()
273            .await;
274
275        let user_result = match maybe_panicked {
276            Ok(result) => result.context(ChromeForTestingManagerError::RunSessionCallback),
277            Err(payload) => {
278                if let Err(quit_err) = session.quit().await {
279                    tracing::error!(
280                        "Failed to quit WebDriver session after user callback panic: {quit_err:?}"
281                    );
282                }
283                std::panic::resume_unwind(payload);
284            }
285        };
286
287        // No matter what happened, clean up the session.
288        let quit_result = session.quit().await;
289
290        match (user_result, quit_result) {
291            (Ok(value), Ok(())) => Ok(value),
292            (Ok(_), Err(quit_err)) => Err(quit_err),
293            (Err(user_err), Ok(())) => Err(user_err),
294            (Err(mut user_err), Err(quit_err)) => {
295                tracing::error!(
296                    "Failed to quit WebDriver session after user failure: {quit_err:?}"
297                );
298                user_err
299                    .children_mut()
300                    .push(quit_err.into_dynamic().into_cloneable());
301                Err(user_err)
302            }
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use assertr::prelude::*;
311
312    #[test]
313    fn run_config_defaults_to_latest_stable_on_any_port() {
314        let config = ChromedriverRunConfig::builder().build();
315
316        assert_that!(config.version).is_equal_to(VersionRequest::LatestIn(Channel::Stable));
317        assert_that!(config.port).is_equal_to(PortRequest::Any);
318        assert_that!(config.output_listener).is_none();
319    }
320
321    #[test]
322    fn run_config_accepts_bare_output_listener() {
323        let listener = DriverOutputListener::new(|_line| {});
324
325        let config = ChromedriverRunConfig::builder()
326            .output_listener(listener)
327            .build();
328
329        assert_that!(config.output_listener).is_some();
330    }
331
332    #[test]
333    fn run_config_accepts_optional_output_listener() {
334        let listener = DriverOutputListener::new(|_line| {});
335
336        let config = ChromedriverRunConfig::builder()
337            .output_listener_opt(Some(listener))
338            .build();
339
340        assert_that!(config.output_listener).is_some();
341
342        let config = ChromedriverRunConfig::builder()
343            .output_listener_opt(None)
344            .build();
345
346        assert_that!(config.output_listener).is_none();
347    }
348
349    #[test]
350    fn builder_port_accepts_u16_via_setter_into() {
351        let config = ChromedriverRunConfig::builder().port(8080u16).build();
352        assert_that!(config.port).is_equal_to(PortRequest::Specific(Port(8080)));
353    }
354
355    #[test]
356    fn builder_version_accepts_channel_via_setter_into() {
357        let config = ChromedriverRunConfig::builder()
358            .version(Channel::Beta)
359            .build();
360        assert_that!(config.version).is_equal_to(VersionRequest::LatestIn(Channel::Beta));
361    }
362
363    #[test]
364    fn builder_accepts_cache_dir_and_graceful_shutdown() {
365        let shutdown = GracefulShutdown::builder()
366            .unix_sigterm(Duration::from_secs(1))
367            .windows_ctrl_break(Duration::from_secs(2))
368            .build();
369        let config = ChromedriverRunConfig::builder()
370            .cache_dir(PathBuf::from("/tmp/cft-cache"))
371            .graceful_shutdown(shutdown.clone())
372            .build();
373
374        assert_that!(config.cache_dir).is_equal_to(Some(PathBuf::from("/tmp/cft-cache")));
375        assert_that!(config.graceful_shutdown).is_equal_to(shutdown);
376    }
377
378    #[test]
379    fn default_graceful_shutdown_uses_three_second_sigterm_and_ctrl_break() {
380        let expected = GracefulShutdown::builder()
381            .unix_sigterm(Duration::from_secs(3))
382            .windows_ctrl_break(Duration::from_secs(3))
383            .build();
384        assert_that!(default_graceful_shutdown()).is_equal_to(expected);
385    }
386}