Skip to main content

chrome_for_testing_manager/
chromedriver.rs

1use crate::mgr::{ChromeForTestingManager, LoadedChromePackage, VersionRequest};
2use crate::port::{Port, PortRequest};
3use anyhow::anyhow;
4use chrome_for_testing::Channel;
5use std::fmt::{Debug, Formatter};
6use std::process::ExitStatus;
7use std::time::Duration;
8use tokio::runtime::RuntimeFlavor;
9use tokio_process_tools::broadcast::BroadcastOutputStream;
10use tokio_process_tools::{TerminateOnDrop, TerminationError};
11
12/// A wrapper struct for a spawned chromedriver process.
13/// Keep this alive until your test is complete.
14///
15/// Automatically terminates the spawned chromedriver process when dropped.
16///
17/// You can always manually call `terminate`, but the on-drop automation makes it much safer in
18/// quickly panicking contexts, such as tests.
19pub struct Chromedriver {
20    /// The manager instance used to resolve a version, download it and starting the chromedriver.
21    mgr: ChromeForTestingManager,
22
23    /// Chrome and chromedriver binaries used for testing.
24    loaded: LoadedChromePackage,
25
26    /// The running chromedriver process. Terminated when dropped.
27    ///
28    /// Always stores a process handle. The value is only taken out on termination,
29    /// notifying our `Drop` impl that the process was gracefully terminated when seeing `None`.
30    process: Option<TerminateOnDrop<BroadcastOutputStream>>,
31
32    /// The port the chromedriver process listens on.
33    port: Port,
34}
35
36impl Debug for Chromedriver {
37    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
38        f.debug_struct("Chromedriver")
39            .field("mgr", &self.mgr)
40            .field("loaded", &self.loaded)
41            .field("process", &self.process)
42            .field("port", &self.port)
43            .finish()
44    }
45}
46
47impl Chromedriver {
48    /// Resolve, download, and launch a chromedriver process.
49    ///
50    /// # Errors
51    ///
52    /// Returns an error if the runtime is not multithreaded, version resolution fails,
53    /// the download fails, or the chromedriver process cannot be spawned.
54    pub async fn run(version: VersionRequest, port: PortRequest) -> anyhow::Result<Chromedriver> {
55        // Assert that async-drop will work.
56        // This is the only way of constructing a `Chromedriver` instance,
57        // so it's safe to do this here.
58        match tokio::runtime::Handle::current().runtime_flavor() {
59            RuntimeFlavor::MultiThread => { /* we want this */ }
60            unsupported_flavor => {
61                return Err(anyhow!(indoc::formatdoc! {r#"
62                    The Chromedriver type requires a multithreaded tokio runtime,
63                    as we rely on async-drop functionality not available on a single-threaded runtime.
64
65                    Detected runtime flavor: {unsupported_flavor:?}.
66
67                    If you are writing a test, annotate it with `#[tokio::test(flavor = "multi_thread")]`.
68                "#}));
69            }
70        }
71
72        let mgr = ChromeForTestingManager::new()?;
73        let selected = mgr.resolve_version(version).await?;
74        let loaded = mgr.download(selected).await?;
75        let (process_handle, actual_port) = mgr.launch_chromedriver(&loaded, port).await?;
76        Ok(Chromedriver {
77            process: Some(
78                process_handle.terminate_on_drop(Duration::from_secs(3), Duration::from_secs(3)),
79            ),
80            port: actual_port,
81            loaded,
82            mgr,
83        })
84    }
85
86    /// Shortcut for [`Self::run`] with the latest stable channel version on any port.
87    ///
88    /// # Errors
89    ///
90    /// See [`Self::run`].
91    pub async fn run_latest_stable() -> anyhow::Result<Chromedriver> {
92        Self::run(VersionRequest::LatestIn(Channel::Stable), PortRequest::Any).await
93    }
94
95    /// Shortcut for [`Self::run`] with the latest beta channel version on any port.
96    ///
97    /// # Errors
98    ///
99    /// See [`Self::run`].
100    pub async fn run_latest_beta() -> anyhow::Result<Chromedriver> {
101        Self::run(VersionRequest::LatestIn(Channel::Beta), PortRequest::Any).await
102    }
103
104    /// Shortcut for [`Self::run`] with the latest dev channel version on any port.
105    ///
106    /// # Errors
107    ///
108    /// See [`Self::run`].
109    pub async fn run_latest_dev() -> anyhow::Result<Chromedriver> {
110        Self::run(VersionRequest::LatestIn(Channel::Dev), PortRequest::Any).await
111    }
112
113    /// Shortcut for [`Self::run`] with the latest canary channel version on any port.
114    ///
115    /// # Errors
116    ///
117    /// See [`Self::run`].
118    pub async fn run_latest_canary() -> anyhow::Result<Chromedriver> {
119        Self::run(VersionRequest::LatestIn(Channel::Canary), PortRequest::Any).await
120    }
121
122    /// Gracefully terminate the chromedriver process with default timeouts (3s each).
123    ///
124    /// # Errors
125    ///
126    /// Returns an error if the process cannot be terminated within the timeout.
127    pub async fn terminate(self) -> Result<ExitStatus, TerminationError> {
128        self.terminate_with_timeouts(Duration::from_secs(3), Duration::from_secs(3))
129            .await
130    }
131
132    /// Gracefully terminate the chromedriver process with custom timeouts.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the process cannot be terminated within the given timeouts.
137    #[expect(clippy::missing_panics_doc)] // Process handle is always present; only taken here.
138    pub async fn terminate_with_timeouts(
139        mut self,
140        interrupt_timeout: Duration,
141        terminate_timeout: Duration,
142    ) -> Result<ExitStatus, TerminationError> {
143        self.process
144            .take()
145            .expect("present")
146            .terminate(interrupt_timeout, terminate_timeout)
147            .await
148    }
149
150    /// Execute an async closure with a `WebDriver` session.
151    /// The session will be automatically cleaned up after the closure completes.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if session creation fails or the user closure returns an error.
156    #[cfg(feature = "thirtyfour")]
157    pub async fn with_session(
158        &self,
159        f: impl AsyncFnOnce(&crate::session::Session) -> Result<(), crate::session::SessionError>,
160    ) -> anyhow::Result<()> {
161        self.with_custom_session(|_caps| Ok(()), f).await
162    }
163
164    /// Execute an async closure with a custom-configured `WebDriver` session.
165    /// The session will be automatically cleaned up after the closure completes.
166    ///
167    /// # Errors
168    ///
169    /// Returns an error if capability setup, session creation, or the user closure fails.
170    #[cfg(feature = "thirtyfour")]
171    pub async fn with_custom_session<F>(
172        &self,
173        setup: impl Fn(
174            &mut thirtyfour::ChromeCapabilities,
175        ) -> Result<(), thirtyfour::prelude::WebDriverError>,
176        f: F,
177    ) -> anyhow::Result<()>
178    where
179        F: for<'a> AsyncFnOnce(
180            &'a crate::session::Session,
181        ) -> Result<(), crate::session::SessionError>,
182    {
183        use crate::session::Session;
184        use anyhow::Context;
185        use futures::FutureExt;
186
187        let mut caps = self.mgr.prepare_caps(&self.loaded)?;
188        setup(&mut caps).context("Failed to set up chrome capabilities.")?;
189        let driver =
190            thirtyfour::WebDriver::new(format!("http://localhost:{}", self.port), caps).await?;
191
192        let session = Session { driver };
193
194        // Execute the user function.
195        let maybe_panicked = core::panic::AssertUnwindSafe(f(&session))
196            .catch_unwind()
197            .await;
198
199        // No matter what happened, clean up the session!
200        session.quit().await?;
201
202        // Handle panics.
203        let result = maybe_panicked.map_err(|err| {
204            let err = anyhow::anyhow!("{err:?}");
205            crate::session::SessionError::Panic {
206                reason: err.to_string(),
207            }
208        })?;
209
210        // Map the `SessionError` into an `anyhow::Error`.
211        result.map_err(Into::into)
212    }
213}