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}