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};
5#[cfg(feature = "thirtyfour")]
6use crate::session_builder::{DefaultCaps, DefaultConfig, SessionBuilder};
7use crate::version::VersionRequest;
8use chrome_for_testing::Channel;
9use rootcause::prelude::ResultExt;
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 browser sessions through [`Self::session`]. Sessions are independent, so multiple of them
99/// can run concurrently against the same chromedriver via `tokio::join!` or `tokio::spawn` on a
100/// multi-thread runtime.
101pub struct Chromedriver {
102    /// The manager instance used to resolve a version, download it and starting the chromedriver.
103    pub(crate) mgr: ChromeForTestingManager,
104
105    /// Chrome and chromedriver binaries used for testing.
106    pub(crate) 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    /// Start building a scoped `thirtyfour` [`crate::Session`] against this chromedriver.
224    ///
225    /// This is the primary entry point for running a browser test. The returned
226    /// [`SessionBuilder`] is a fluent, chainable builder with three steps:
227    ///
228    /// 1. (optional) [`SessionBuilder::with_caps`] mutates the
229    ///    [`thirtyfour::ChromeCapabilities`] used to create the session (e.g. unset headless, add
230    ///    Chrome args, register extensions).
231    /// 2. (optional) [`SessionBuilder::with_config`] receives the
232    ///    [`thirtyfour::WebDriverBuilder`] and may set client-side options such as the element
233    ///    poller, request timeout, user-agent, or keep-alive flag before the session is opened.
234    /// 3. (required, terminal) [`SessionBuilder::run`] opens the session, awaits the user
235    ///    closure, and tears the session down once the closure resolves or panics.
236    ///
237    /// Sessions are independent. Multiple sessions can run concurrently against the same
238    /// `Chromedriver` via `tokio::join!` or `tokio::spawn` on a multi-thread runtime.
239    ///
240    /// # Examples
241    ///
242    /// Smallest case (default headless capabilities, default client config):
243    ///
244    /// ```no_run
245    /// use chrome_for_testing_manager::Chromedriver;
246    /// use rootcause::Report;
247    ///
248    /// # async fn run() -> Result<(), Report> {
249    /// Chromedriver::run_default()
250    ///     .await?
251    ///     .session()
252    ///     .run(async |session| {
253    ///         session.goto("https://wikipedia.org").await?;
254    ///         Ok::<(), thirtyfour::error::WebDriverError>(())
255    ///     })
256    ///     .await?;
257    /// # Ok(()) }
258    /// ```
259    ///
260    /// With both setup steps:
261    ///
262    /// ```no_run
263    /// use chrome_for_testing_manager::Chromedriver;
264    /// use rootcause::Report;
265    /// use std::time::Duration;
266    /// use thirtyfour::ChromiumLikeCapabilities;
267    ///
268    /// # async fn run() -> Result<(), Report> {
269    /// Chromedriver::run_default()
270    ///     .await?
271    ///     .session()
272    ///     .with_caps(ChromiumLikeCapabilities::unset_headless)
273    ///     .with_config(|b| b.request_timeout(Duration::from_secs(30)))
274    ///     .run(async |session| {
275    ///         session.goto("https://wikipedia.org").await?;
276    ///         Ok::<(), thirtyfour::error::WebDriverError>(())
277    ///     })
278    ///     .await?;
279    /// # Ok(()) }
280    /// ```
281    #[cfg(feature = "thirtyfour")]
282    #[must_use]
283    pub fn session(&self) -> SessionBuilder<'_, DefaultCaps, DefaultConfig> {
284        SessionBuilder::new(self)
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use assertr::prelude::*;
292
293    #[test]
294    fn run_config_defaults_to_latest_stable_on_any_port() {
295        let config = ChromedriverRunConfig::builder().build();
296
297        assert_that!(config.version).is_equal_to(VersionRequest::LatestIn(Channel::Stable));
298        assert_that!(config.port).is_equal_to(PortRequest::Any);
299        assert_that!(config.output_listener).is_none();
300    }
301
302    #[test]
303    fn run_config_accepts_bare_output_listener() {
304        let listener = DriverOutputListener::new(|_line| {});
305
306        let config = ChromedriverRunConfig::builder()
307            .output_listener(listener)
308            .build();
309
310        assert_that!(config.output_listener).is_some();
311    }
312
313    #[test]
314    fn run_config_accepts_optional_output_listener() {
315        let listener = DriverOutputListener::new(|_line| {});
316
317        let config = ChromedriverRunConfig::builder()
318            .output_listener_opt(Some(listener))
319            .build();
320
321        assert_that!(config.output_listener).is_some();
322
323        let config = ChromedriverRunConfig::builder()
324            .output_listener_opt(None)
325            .build();
326
327        assert_that!(config.output_listener).is_none();
328    }
329
330    #[test]
331    fn builder_port_accepts_u16_via_setter_into() {
332        let config = ChromedriverRunConfig::builder().port(8080u16).build();
333        assert_that!(config.port).is_equal_to(PortRequest::Specific(Port(8080)));
334    }
335
336    #[test]
337    fn builder_version_accepts_channel_via_setter_into() {
338        let config = ChromedriverRunConfig::builder()
339            .version(Channel::Beta)
340            .build();
341        assert_that!(config.version).is_equal_to(VersionRequest::LatestIn(Channel::Beta));
342    }
343
344    #[test]
345    fn builder_accepts_cache_dir_and_graceful_shutdown() {
346        let shutdown = GracefulShutdown::builder()
347            .unix_sigterm(Duration::from_secs(1))
348            .windows_ctrl_break(Duration::from_secs(2))
349            .build();
350        let config = ChromedriverRunConfig::builder()
351            .cache_dir(PathBuf::from("/tmp/cft-cache"))
352            .graceful_shutdown(shutdown.clone())
353            .build();
354
355        assert_that!(config.cache_dir).is_equal_to(Some(PathBuf::from("/tmp/cft-cache")));
356        assert_that!(config.graceful_shutdown).is_equal_to(shutdown);
357    }
358
359    #[test]
360    fn default_graceful_shutdown_uses_three_second_sigterm_and_ctrl_break() {
361        let expected = GracefulShutdown::builder()
362            .unix_sigterm(Duration::from_secs(3))
363            .windows_ctrl_break(Duration::from_secs(3))
364            .build();
365        assert_that!(default_graceful_shutdown()).is_equal_to(expected);
366    }
367}