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}