Skip to main content

chrome_for_testing_manager/
chromedriver.rs

1use crate::ChromeForTestingManagerError;
2use crate::mgr::{ChromeBinary, ChromeForTestingManager, LoadedBrowserPackage};
3use crate::output::{DriverOutputInspectors, DriverOutputListener};
4use crate::port::{Port, PortRequest};
5#[cfg(feature = "thirtyfour")]
6use crate::session_builder::{InitialCaps, InitialConfig, 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::{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 `Chrome`/`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    version: VersionRequest,
63
64    /// Chrome browser binary to run with `ChromeDriver`.
65    ///
66    /// Defaults to regular [`ChromeBinary::Chrome`]. Use [`ChromeBinary::ChromeHeadlessShell`] for
67    /// headless-only environments that cannot launch the full `Chrome` application.
68    #[builder(default)]
69    chrome_binary: ChromeBinary,
70
71    /// The requested `ChromeDriver` port.
72    ///
73    /// Accepts anything implementing `Into<PortRequest>`, including a bare `u16` and [`Port`].
74    #[builder(default = PortRequest::Any, setter(into))]
75    port: PortRequest,
76
77    /// Optional callback for browser-driver process output lines.
78    #[builder(default, setter(strip_option(fallback = output_listener_opt)))]
79    output_listener: Option<DriverOutputListener>,
80
81    /// Optional override for the cache directory holding downloaded chrome / chromedriver
82    /// artifacts. Defaults to the platform's per-user cache directory.
83    #[builder(default, setter(strip_option(fallback = cache_dir_opt)))]
84    cache_dir: Option<PathBuf>,
85
86    /// Per-platform graceful-shutdown budget applied when the [`Chromedriver`] handle is dropped
87    /// or [`Chromedriver::terminate`] is called.
88    #[builder(default = default_graceful_shutdown())]
89    graceful_shutdown: GracefulShutdown,
90}
91
92impl Default for ChromedriverRunConfig {
93    fn default() -> Self {
94        Self::builder().build()
95    }
96}
97
98impl ChromedriverRunConfig {
99    /// The requested `Chrome` / `ChromeDriver` version.
100    #[must_use]
101    pub const fn version(&self) -> &VersionRequest {
102        &self.version
103    }
104
105    /// The browser package to use with `ChromeDriver`.
106    #[must_use]
107    pub const fn chrome_binary(&self) -> ChromeBinary {
108        self.chrome_binary
109    }
110
111    /// The requested `ChromeDriver` port.
112    #[must_use]
113    pub const fn port(&self) -> PortRequest {
114        self.port
115    }
116
117    /// The optional process-output listener.
118    #[must_use]
119    pub fn output_listener(&self) -> Option<&DriverOutputListener> {
120        self.output_listener.as_ref()
121    }
122
123    /// The configured cache directory override, if any.
124    #[must_use]
125    pub fn cache_dir(&self) -> Option<&Path> {
126        self.cache_dir.as_deref()
127    }
128
129    /// The graceful-shutdown budget used when terminating the `ChromeDriver` process.
130    #[must_use]
131    pub const fn graceful_shutdown(&self) -> &GracefulShutdown {
132        &self.graceful_shutdown
133    }
134}
135
136/// A handle to a spawned chromedriver process plus its resolved Chrome / `ChromeDriver` binaries.
137///
138/// Terminates automatically when dropped, using the budget configured via
139/// `ChromedriverRunConfig::builder().graceful_shutdown(...)`. The on-drop automation keeps tests
140/// safe in the face of panics. Call [`Self::terminate`] to drive the same shutdown explicitly and
141/// surface any error.
142///
143/// Drive browser sessions through [`Self::session`]. Sessions are independent, so multiple of them
144/// can run concurrently against the same chromedriver via `tokio::join!` or `tokio::spawn` on a
145/// multi-thread runtime.
146pub struct Chromedriver {
147    /// The manager instance used to resolve a version, download it and starting the chromedriver.
148    pub(crate) mgr: ChromeForTestingManager,
149
150    /// Browser and chromedriver binaries used for testing.
151    pub(crate) loaded: LoadedBrowserPackage,
152
153    /// The running chromedriver process. Terminated when dropped.
154    ///
155    /// Always stores a process handle. The value is only taken out on termination,
156    /// notifying our `Drop` impl that the process was gracefully terminated when seeing `None`.
157    process:
158        Option<TerminateOnDrop<BroadcastOutputStream<ReliableWithBackpressure, ReplayEnabled>>>,
159
160    /// Long-lived browser-driver output inspectors.
161    output_inspectors: Option<DriverOutputInspectors>,
162
163    /// The port the chromedriver process listens on.
164    port: Port,
165
166    /// Graceful-shutdown budget to use when terminating, including on drop.
167    pub(crate) graceful_shutdown: GracefulShutdown,
168}
169
170impl Debug for Chromedriver {
171    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
172        f.debug_struct("Chromedriver")
173            .field("mgr", &self.mgr)
174            .field("loaded", &self.loaded)
175            .field("process", &self.process)
176            .field("output_inspectors", &self.output_inspectors)
177            .field("port", &self.port)
178            .field("graceful_shutdown", &self.graceful_shutdown)
179            .finish()
180    }
181}
182
183impl Chromedriver {
184    /// Convenience: resolve, download, and launch chromedriver using
185    /// [`ChromedriverRunConfig::default`]. Equivalent to
186    /// `Chromedriver::run(ChromedriverRunConfig::default()).await`.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the runtime is not multithreaded, version resolution fails,
191    /// the download fails, or the chromedriver process cannot be spawned.
192    pub async fn run_default() -> Result<Chromedriver, Report<ChromeForTestingManagerError>> {
193        Self::run(ChromedriverRunConfig::default()).await
194    }
195
196    /// Resolve, download, and launch a chromedriver process.
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if the runtime is not multithreaded, version resolution fails,
201    /// the download fails, or the chromedriver process cannot be spawned.
202    pub async fn run(
203        config: ChromedriverRunConfig,
204    ) -> Result<Chromedriver, Report<ChromeForTestingManagerError>> {
205        // Assert that async-drop will work.
206        // This is the only way of constructing a `Chromedriver` instance,
207        // so it's safe to do this here.
208        match tokio::runtime::Handle::current().runtime_flavor() {
209            RuntimeFlavor::MultiThread => { /* we want this */ }
210            unsupported_flavor => {
211                return Err(report!(ChromeForTestingManagerError::UnsupportedRuntime {
212                    runtime_flavor: unsupported_flavor,
213                }));
214            }
215        }
216
217        let mgr = match config.cache_dir {
218            Some(cache_dir) => ChromeForTestingManager::new_with_cache_dir(cache_dir)?,
219            None => ChromeForTestingManager::new()?,
220        };
221        let selected = mgr.resolve_version(config.version).await?;
222        let loaded = mgr.download_one(&selected, config.chrome_binary).await?;
223        let graceful_shutdown = config.graceful_shutdown;
224        let (process_handle, actual_port, output_inspectors) = mgr
225            .launch_chromedriver(
226                &loaded,
227                config.port,
228                config.output_listener,
229                graceful_shutdown.clone(),
230            )
231            .await?;
232        Ok(Chromedriver {
233            process: Some(process_handle.terminate_on_drop(graceful_shutdown.clone())),
234            output_inspectors: Some(output_inspectors),
235            port: actual_port,
236            loaded,
237            mgr,
238            graceful_shutdown,
239        })
240    }
241
242    /// The port the chromedriver process is listening on.
243    ///
244    /// When constructed with [`PortRequest::Any`] this reflects the OS-assigned port.
245    #[must_use]
246    pub fn port(&self) -> Port {
247        self.port
248    }
249
250    /// Gracefully terminate the chromedriver process with the configured [`GracefulShutdown`],
251    /// configurable via `ChromedriverRunConfig::builder().graceful_shutdown(...)`.
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if the process cannot be terminated within the configured graceful-shutdown
256    /// budget.
257    #[expect(clippy::missing_panics_doc)] // Process handle is always present; only taken here.
258    pub async fn terminate(mut self) -> Result<ExitStatus, Report<ChromeForTestingManagerError>> {
259        let _output_inspectors = self.output_inspectors.take();
260        self.process
261            .take()
262            .expect("present")
263            .terminate(self.graceful_shutdown)
264            .await
265            .context(ChromeForTestingManagerError::TerminateChromedriver { port: self.port })
266    }
267
268    /// Start building a scoped `thirtyfour` [`crate::Session`] against this chromedriver.
269    ///
270    /// This is the primary entry point for running a browser test. The returned
271    /// [`SessionBuilder`] is a fluent, chainable builder with three steps:
272    ///
273    /// 1. (optional) [`SessionBuilder::with_caps`] mutates the
274    ///    [`thirtyfour::ChromeCapabilities`] used to create the session (e.g. unset headless, add
275    ///    Chrome args, register extensions).
276    /// 2. (optional) [`SessionBuilder::with_config`] receives the
277    ///    [`thirtyfour::WebDriverBuilder`] and may set client-side options such as the element
278    ///    poller, request timeout, user-agent, or keep-alive flag before the session is opened.
279    /// 3. (required, terminal) [`SessionBuilder::run`] opens the session, awaits the user
280    ///    closure, and tears the session down once the closure resolves or panics.
281    ///
282    /// Sessions are independent. Multiple sessions can run concurrently against the same
283    /// `Chromedriver` via `tokio::join!` or `tokio::spawn` on a multi-thread runtime.
284    ///
285    /// # Examples
286    ///
287    /// Smallest case (default headless capabilities, default client config):
288    ///
289    /// ```no_run
290    /// use chrome_for_testing_manager::Chromedriver;
291    /// use rootcause::Report;
292    ///
293    /// # async fn run() -> Result<(), Report> {
294    /// Chromedriver::run_default()
295    ///     .await?
296    ///     .session()
297    ///     .run(async |session| {
298    ///         session.goto("https://wikipedia.org").await?;
299    ///         Ok::<(), thirtyfour::error::WebDriverError>(())
300    ///     })
301    ///     .await?;
302    /// # Ok(()) }
303    /// ```
304    ///
305    /// With both setup steps:
306    ///
307    /// ```no_run
308    /// use chrome_for_testing_manager::Chromedriver;
309    /// use rootcause::Report;
310    /// use std::time::Duration;
311    /// use thirtyfour::ChromiumLikeCapabilities;
312    ///
313    /// # async fn run() -> Result<(), Report> {
314    /// Chromedriver::run_default()
315    ///     .await?
316    ///     .session()
317    ///     .with_caps(ChromiumLikeCapabilities::unset_headless)
318    ///     .with_config(|b| b.request_timeout(Duration::from_secs(30)))
319    ///     .run(async |session| {
320    ///         session.goto("https://wikipedia.org").await?;
321    ///         Ok::<(), thirtyfour::error::WebDriverError>(())
322    ///     })
323    ///     .await?;
324    /// # Ok(()) }
325    /// ```
326    #[cfg(feature = "thirtyfour")]
327    #[must_use]
328    pub fn session(&self) -> SessionBuilder<'_, InitialCaps, InitialConfig> {
329        SessionBuilder::new(self)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use assertr::prelude::*;
337
338    #[test]
339    fn run_config_defaults_to_latest_stable_on_any_port() {
340        let config = ChromedriverRunConfig::builder().build();
341
342        assert_that!(config.version()).is_equal_to(VersionRequest::LatestIn(Channel::Stable));
343        assert_that!(config.chrome_binary()).is_equal_to(ChromeBinary::Chrome);
344        assert_that!(config.port()).is_equal_to(PortRequest::Any);
345        assert_that!(config.output_listener()).is_none();
346    }
347
348    #[test]
349    fn run_config_accepts_bare_output_listener() {
350        let listener = DriverOutputListener::new(|_line| {});
351
352        let config = ChromedriverRunConfig::builder()
353            .output_listener(listener)
354            .build();
355
356        assert_that!(config.output_listener()).is_some();
357    }
358
359    #[test]
360    fn run_config_accepts_optional_output_listener() {
361        let listener = DriverOutputListener::new(|_line| {});
362
363        let config = ChromedriverRunConfig::builder()
364            .output_listener_opt(Some(listener))
365            .build();
366
367        assert_that!(config.output_listener()).is_some();
368
369        let config = ChromedriverRunConfig::builder()
370            .output_listener_opt(None)
371            .build();
372
373        assert_that!(config.output_listener()).is_none();
374    }
375
376    #[test]
377    fn builder_port_accepts_u16_via_setter_into() {
378        let config = ChromedriverRunConfig::builder().port(8080u16).build();
379        assert_that!(config.port()).is_equal_to(PortRequest::Specific(Port::new(8080)));
380    }
381
382    #[test]
383    fn builder_version_accepts_channel_via_setter_into() {
384        let config = ChromedriverRunConfig::builder()
385            .version(Channel::Beta)
386            .build();
387        assert_that!(config.version()).is_equal_to(VersionRequest::LatestIn(Channel::Beta));
388    }
389
390    #[test]
391    fn builder_accepts_chrome_headless_shell_binary() {
392        let config = ChromedriverRunConfig::builder()
393            .chrome_binary(ChromeBinary::ChromeHeadlessShell)
394            .build();
395        assert_that!(config.chrome_binary()).is_equal_to(ChromeBinary::ChromeHeadlessShell);
396    }
397
398    #[test]
399    fn builder_accepts_cache_dir_and_graceful_shutdown() {
400        let shutdown = GracefulShutdown::builder()
401            .unix_sigterm(Duration::from_secs(1))
402            .windows_ctrl_break(Duration::from_secs(2))
403            .build();
404        let config = ChromedriverRunConfig::builder()
405            .cache_dir(PathBuf::from("/tmp/cft-cache"))
406            .graceful_shutdown(shutdown.clone())
407            .build();
408
409        assert_that!(config.cache_dir())
410            .is_some()
411            .is_equal_to(Path::new("/tmp/cft-cache"));
412        assert_that!(config.graceful_shutdown()).is_equal_to(shutdown);
413    }
414
415    #[test]
416    fn default_graceful_shutdown_uses_three_second_sigterm_and_ctrl_break() {
417        let expected = GracefulShutdown::builder()
418            .unix_sigterm(Duration::from_secs(3))
419            .windows_ctrl_break(Duration::from_secs(3))
420            .build();
421        assert_that!(default_graceful_shutdown()).is_equal_to(expected);
422    }
423}