Skip to main content

chrome_for_testing_manager/
mgr.rs

1use crate::cache::CacheDir;
2use crate::download;
3use crate::output::{DriverOutputInspectors, DriverOutputListener};
4use crate::port::{Port, PortRequest};
5use crate::version::{SelectedVersion, VersionRequest};
6use crate::{ChromeForTestingArtifact, ChromeForTestingManagerError};
7use chrome_for_testing::{KnownGoodVersions, LastKnownGoodVersions, Platform, Version};
8use rootcause::{Report, bail, option_ext::OptionExt, prelude::ResultExt, report};
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::sync::atomic::AtomicU16;
12use std::time::Duration;
13use tokio::fs;
14use tokio::process::Command;
15use tokio_process_tools::{
16    BroadcastOutputStream, DEFAULT_MAX_BUFFERED_CHUNKS, DEFAULT_MAX_LINE_LENGTH,
17    DEFAULT_READ_CHUNK_SIZE, GracefulShutdown, LineOverflowBehavior, LineParsingOptions,
18    NumBytesExt, Process, ProcessHandle, ReliableWithBackpressure, ReplayEnabled,
19    WaitForLineResult,
20};
21
22/// A downloaded Chrome and `ChromeDriver` pair, with their on-disk executable paths resolved.
23///
24/// Returned by [`ChromeForTestingManager::download`]. Hand it to
25/// [`ChromeForTestingManager::launch_chromedriver`] or [`ChromeForTestingManager::prepare_caps`]
26/// to drive a browser session.
27#[derive(Debug)]
28pub struct LoadedChromePackage {
29    chrome_executable: PathBuf,
30    chromedriver_executable: PathBuf,
31}
32
33impl LoadedChromePackage {
34    /// Path to the cached Chrome browser executable.
35    #[must_use]
36    pub fn chrome_executable(&self) -> &std::path::Path {
37        &self.chrome_executable
38    }
39
40    /// Path to the cached `ChromeDriver` executable.
41    #[must_use]
42    pub fn chromedriver_executable(&self) -> &std::path::Path {
43        &self.chromedriver_executable
44    }
45}
46
47/// Lower-level orchestrator for chrome-for-testing artifacts.
48///
49/// Most users should use [`crate::Chromedriver`], which wraps this manager with sensible defaults
50/// and handles process lifecycle automatically. Reach for `ChromeForTestingManager` directly when
51/// you need finer control:
52///
53/// - **Pre-warm a cache** without spawning chromedriver: call [`Self::resolve_version`] and
54///   [`Self::download`], then drop the result.
55/// - **Run multiple chromedriver instances** off a single resolved version: call
56///   [`Self::launch_chromedriver`] repeatedly with the same `LoadedChromePackage`.
57/// - **Inspect or modify the resolved version** before downloading (channel, available platforms).
58/// - **Pin a custom cache directory** via [`Self::new_with_cache_dir`] (useful in CI).
59/// - **Drive sessions through a non-`thirtyfour`** `WebDriver` client by using the chromedriver
60///   process and port directly.
61#[derive(Debug)]
62pub struct ChromeForTestingManager {
63    client: reqwest::Client,
64    cache_dir: CacheDir,
65    platform: Platform,
66}
67
68impl ChromeForTestingManager {
69    /// Create a manager that uses the platform-default cache directory.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if the current platform is unsupported or the cache directory
74    /// cannot be determined or created.
75    pub fn new() -> Result<Self, Report<ChromeForTestingManagerError>> {
76        Ok(Self {
77            client: reqwest::Client::new(),
78            cache_dir: CacheDir::get_or_create()?,
79            platform: Platform::detect()
80                .context(ChromeForTestingManagerError::UnsupportedPlatform)?,
81        })
82    }
83
84    /// Create a manager that caches downloaded artifacts under `cache_dir`.
85    ///
86    /// The directory is created if it does not exist. Useful in CI to share the cache across
87    /// runs, or to keep artifacts out of the user-default cache location.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if the current platform is unsupported or the directory cannot be created.
92    pub fn new_with_cache_dir(
93        cache_dir: PathBuf,
94    ) -> Result<Self, Report<ChromeForTestingManagerError>> {
95        Ok(Self {
96            client: reqwest::Client::new(),
97            cache_dir: CacheDir::create_at(cache_dir)?,
98            platform: Platform::detect()
99                .context(ChromeForTestingManagerError::UnsupportedPlatform)?,
100        })
101    }
102
103    fn version_dir(&self, version: Version) -> PathBuf {
104        self.cache_dir.path().join(version.to_string())
105    }
106
107    /// # Errors
108    ///
109    /// Returns an error if the cache directory cannot be deleted or re-created.
110    pub async fn clear_cache(&self) -> Result<(), Report<ChromeForTestingManagerError>> {
111        self.cache_dir.clear().await
112    }
113
114    /// Resolve a [`VersionRequest`] against the chrome-for-testing release index.
115    ///
116    /// Returns a [`SelectedVersion`] suitable for [`Self::download`]. No artifacts are downloaded
117    /// at this point; this only performs the HTTP requests needed to determine which version to
118    /// fetch.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if the version manifest cannot be fetched or no matching version exists.
123    pub async fn resolve_version(
124        &self,
125        version_selection: VersionRequest,
126    ) -> Result<SelectedVersion, Report<ChromeForTestingManagerError>> {
127        let selected = match &version_selection {
128            VersionRequest::Latest => {
129                let all = KnownGoodVersions::fetch(&self.client).await.context(
130                    ChromeForTestingManagerError::RequestVersions {
131                        version_request: version_selection.clone(),
132                    },
133                )?;
134                all.versions
135                    .iter()
136                    .filter(|v| v.downloads.chromedriver.is_some())
137                    .max_by_key(|v| v.version)
138                    .cloned()
139                    .map(|v| SelectedVersion::from((v, self.platform)))
140            }
141            VersionRequest::LatestIn(channel) => {
142                let all = LastKnownGoodVersions::fetch(&self.client).await.context(
143                    ChromeForTestingManagerError::RequestVersions {
144                        version_request: version_selection.clone(),
145                    },
146                )?;
147                all.channel(channel)
148                    .cloned()
149                    .map(|v| SelectedVersion::from((v, self.platform)))
150            }
151            VersionRequest::Fixed(version) => {
152                let all = KnownGoodVersions::fetch(&self.client).await.context(
153                    ChromeForTestingManagerError::RequestVersions {
154                        version_request: version_selection.clone(),
155                    },
156                )?;
157                all.versions
158                    .into_iter()
159                    .find(|v| v.version == *version)
160                    .map(|v| SelectedVersion::from((v, self.platform)))
161            }
162        };
163
164        let selected = selected.context(ChromeForTestingManagerError::NoMatchingVersion {
165            version_request: version_selection,
166        })?;
167
168        Ok(selected)
169    }
170
171    /// Download Chrome and `ChromeDriver` for `selected` into the cache directory.
172    ///
173    /// If both binaries already exist on disk this is a no-op and returns the cached paths.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if no platform-matching download exists, the cache directory cannot be
178    /// prepared, or the download / extraction fails.
179    pub async fn download(
180        &self,
181        selected: SelectedVersion,
182    ) -> Result<LoadedChromePackage, Report<ChromeForTestingManagerError>> {
183        let Some(selected_chrome_download) = selected.chrome.clone() else {
184            bail!(ChromeForTestingManagerError::NoChromeDownload {
185                version: selected.version,
186                platform: self.platform,
187            });
188        };
189
190        let Some(selected_chromedriver_download) = selected.chromedriver.clone() else {
191            bail!(ChromeForTestingManagerError::NoChromedriverDownload {
192                version: selected.version,
193                platform: self.platform,
194            });
195        };
196
197        // Check if download is necessary.
198        let version_dir = self.version_dir(selected.version);
199        let platform_dir = version_dir.join(self.platform.to_string());
200        fs::create_dir_all(&platform_dir).await.context(
201            ChromeForTestingManagerError::CreatePlatformDir {
202                platform_dir: platform_dir.clone(),
203            },
204        )?;
205
206        let chrome_executable = platform_dir.join(self.platform.chrome_executable_path());
207        let chromedriver_executable =
208            platform_dir.join(self.platform.chromedriver_executable_path());
209
210        let channel_label = selected
211            .channel
212            .map_or_else(String::new, |ch| ch.to_string());
213
214        // Download chrome if necessary.
215        let is_chrome_downloaded = chrome_executable.exists() && chrome_executable.is_file();
216        if is_chrome_downloaded {
217            tracing::info!(
218                "Chrome {} already installed at {chrome_executable:?}...",
219                selected.version
220            );
221        } else {
222            tracing::info!("Installing {channel_label} Chrome {}", selected.version);
223            download::download_zip(
224                &self.client,
225                &selected_chrome_download.url,
226                &platform_dir,
227                &platform_dir,
228                ChromeForTestingArtifact::Chrome,
229            )
230            .await?;
231        }
232
233        // Download chromedriver if necessary.
234        let is_chromedriver_downloaded =
235            chromedriver_executable.exists() && chromedriver_executable.is_file();
236        if is_chromedriver_downloaded {
237            tracing::info!(
238                "Chromedriver {} already installed at {chromedriver_executable:?}...",
239                selected.version
240            );
241        } else {
242            tracing::info!(
243                "Installing {channel_label} Chromedriver {}",
244                selected.version
245            );
246            download::download_zip(
247                &self.client,
248                &selected_chromedriver_download.url,
249                &platform_dir,
250                &platform_dir,
251                ChromeForTestingArtifact::ChromeDriver,
252            )
253            .await?;
254        }
255
256        Ok(LoadedChromePackage {
257            chrome_executable,
258            chromedriver_executable,
259        })
260    }
261
262    /// Launch a chromedriver process from `loaded` on the requested port.
263    ///
264    /// Returns the spawned process handle, the actual bound port (relevant when
265    /// [`PortRequest::Any`] was used), and the long-lived output inspectors that drive the
266    /// optional [`DriverOutputListener`]. Keep the inspectors alive while you want to receive
267    /// output lines.
268    ///
269    /// The returned [`ProcessHandle`] is not auto-terminated. Either wrap it with
270    /// [`ProcessHandle::terminate_on_drop`] or call its `terminate` method explicitly. The
271    /// `shutdown` argument is only used for the internal cleanup path that fires when
272    /// chromedriver fails to report successful startup. Pass the same value you intend to use
273    /// for graceful shutdown so a startup failure honors your tuned budget.
274    ///
275    /// # Errors
276    ///
277    /// Returns an error if the chromedriver binary cannot be spawned or does not report
278    /// successful startup within 10 seconds.
279    ///
280    /// # Panics
281    ///
282    /// Panics if the chromedriver executable path contains non-Unicode bytes.
283    pub async fn launch_chromedriver(
284        &self,
285        loaded: &LoadedChromePackage,
286        port: PortRequest,
287        output_listener: Option<DriverOutputListener>,
288        shutdown: GracefulShutdown,
289    ) -> Result<
290        (
291            ProcessHandle<BroadcastOutputStream<ReliableWithBackpressure, ReplayEnabled>>,
292            Port,
293            DriverOutputInspectors,
294        ),
295        Report<ChromeForTestingManagerError>,
296    > {
297        let chromedriver_exe_path_str = loaded
298            .chromedriver_executable
299            .to_str()
300            .expect("valid unicode");
301
302        tracing::info!(
303            "Launching chromedriver... {:?}",
304            loaded.chromedriver_executable
305        );
306        let mut command = Command::new(chromedriver_exe_path_str);
307        match port {
308            PortRequest::Any => {}
309            PortRequest::Specific(Port(port)) => {
310                command.arg(format!("--port={port}"));
311            }
312        }
313        let loglevel = chrome_for_testing::chromedriver::LogLevel::Info;
314        command.arg(format!("--log-level={loglevel}"));
315
316        self.apply_chromedriver_creation_flags(&mut command);
317
318        let mut chromedriver_process = Process::new(command)
319            .name("chromedriver")
320            .stdout_and_stderr(|stream| {
321                stream
322                    .broadcast()
323                    .reliable_with_backpressure()
324                    .replay_last_bytes(1.megabytes())
325                    .read_chunk_size(DEFAULT_READ_CHUNK_SIZE)
326                    .max_buffered_chunks(DEFAULT_MAX_BUFFERED_CHUNKS)
327            })
328            .spawn()
329            .context(ChromeForTestingManagerError::SpawnChromedriver {
330                path: loaded.chromedriver_executable.clone(),
331            })?;
332
333        let output_inspectors =
334            DriverOutputInspectors::start(&chromedriver_process, output_listener);
335
336        tracing::info!("Waiting for chromedriver to start...");
337        let started_on_port = Arc::new(AtomicU16::new(0));
338        let started_on_port_clone = started_on_port.clone();
339        let startup_result = chromedriver_process
340            .stdout()
341            .wait_for_line(
342                Duration::from_secs(10),
343                move |line| {
344                    if line.contains("started successfully on port") {
345                        let Some(port) = line
346                            .trim()
347                            .trim_matches('"')
348                            .trim_end_matches('.')
349                            .split(' ')
350                            .next_back()
351                            .and_then(|s| s.parse::<u16>().ok())
352                        else {
353                            tracing::error!(
354                                "Failed to parse port from chromedriver output: {line:?}"
355                            );
356                            return false;
357                        };
358                        started_on_port_clone.store(port, std::sync::atomic::Ordering::Release);
359                        true
360                    } else {
361                        false
362                    }
363                },
364                LineParsingOptions::builder()
365                    .max_line_length(DEFAULT_MAX_LINE_LENGTH)
366                    .overflow_behavior(LineOverflowBehavior::DropAdditionalData)
367                    .buffer_compaction_threshold(None)
368                    .build(),
369            )
370            .await
371            .context(ChromeForTestingManagerError::WaitForChromedriverStartup {
372                path: loaded.chromedriver_executable.clone(),
373            })?;
374        match startup_result {
375            WaitForLineResult::Matched => {}
376            WaitForLineResult::StreamClosed | WaitForLineResult::Timeout => {
377                if let Err(err) = chromedriver_process.terminate(shutdown).await {
378                    tracing::warn!(
379                        error = %err,
380                        "failed to terminate chromedriver after startup failure"
381                    );
382                }
383
384                return Err(report!(
385                    ChromeForTestingManagerError::WaitForChromedriverStartup {
386                        path: loaded.chromedriver_executable.clone(),
387                    }
388                ));
389            }
390        }
391
392        // It SHOULD definitely be terminated.
393        // But the default implementation when "must_be_terminated" raises a panic if not terminated.
394        // Our custom `Drop` impl on `Chromedriver` relaxes this and only logs an ERROR instead.
395        chromedriver_process.must_not_be_terminated();
396
397        Ok((
398            chromedriver_process,
399            Port(Arc::into_inner(started_on_port).unwrap().into_inner()),
400            output_inspectors,
401        ))
402    }
403
404    #[cfg(target_os = "windows")]
405    fn apply_chromedriver_creation_flags<'a>(&self, command: &'a mut Command) -> &'a mut Command {
406        use std::os::windows::process::CommandExt;
407
408        // CREATE_NO_WINDOW (0x08000000) is a Windows-specific process creation flag that prevents
409        // a process from creating a new window. This is relevant for ChromeDriver because:
410        //   - ChromeDriver is typically a console application on Windows.
411        //   - Without this flag, launching ChromeDriver would create a visible console window.
412        //   - In our automation scenario, we don't want users to see this console window popping up.
413        //   - The window isn't necessary since we're already capturing the stdout/stderr streams programmatically.
414        const CREATE_NO_WINDOW: u32 = 0x08000000;
415
416        command.creation_flags(CREATE_NO_WINDOW)
417    }
418
419    #[cfg(not(target_os = "windows"))]
420    #[allow(clippy::unused_self)] // Symmetry with the Windows variant that uses `self`.
421    fn apply_chromedriver_creation_flags<'a>(&self, command: &'a mut Command) -> &'a mut Command {
422        command
423    }
424
425    /// Prepare a [`thirtyfour::ChromeCapabilities`] pre-wired with the loaded Chrome binary path
426    /// and the headless flag set.
427    ///
428    /// Useful when constructing a [`thirtyfour::WebDriver`] manually instead of using
429    /// [`crate::Chromedriver::session`].
430    ///
431    /// # Errors
432    ///
433    /// Returns an error if the underlying capability setup fails.
434    ///
435    /// # Panics
436    ///
437    /// Panics if the cached Chrome executable path contains non-Unicode bytes.
438    #[cfg(feature = "thirtyfour")]
439    #[allow(clippy::unused_self)] // Takes &self for API consistency with other methods.
440    pub fn prepare_caps(
441        &self,
442        loaded: &LoadedChromePackage,
443    ) -> Result<thirtyfour::ChromeCapabilities, Report<ChromeForTestingManagerError>> {
444        use thirtyfour::ChromiumLikeCapabilities;
445
446        tracing::info!(
447            "Registering {:?} in capabilities.",
448            loaded.chrome_executable
449        );
450        let mut caps = thirtyfour::ChromeCapabilities::new();
451        caps.set_headless()
452            .context(ChromeForTestingManagerError::PrepareChromeCapabilities {
453                chrome_executable: loaded.chrome_executable.clone(),
454            })?;
455        caps.set_binary(loaded.chrome_executable.to_str().expect("valid unicode"))
456            .context(ChromeForTestingManagerError::PrepareChromeCapabilities {
457                chrome_executable: loaded.chrome_executable.clone(),
458            })?;
459        Ok(caps)
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use crate::chromedriver::default_graceful_shutdown;
466    use crate::mgr::ChromeForTestingManager;
467    use crate::port::Port;
468    use crate::port::PortRequest;
469    use crate::{Channel, Version, VersionRequest};
470    use assertr::prelude::*;
471    use rootcause::Report;
472    use serial_test::serial;
473
474    #[ctor::ctor(unsafe)]
475    fn init_test_tracing() {
476        tracing_subscriber::fmt().with_test_writer().try_init().ok();
477    }
478
479    #[tokio::test(flavor = "multi_thread")]
480    #[serial]
481    async fn clear_cache_and_download_new() -> Result<(), Report> {
482        let mgr = ChromeForTestingManager::new()?;
483        mgr.clear_cache().await?;
484        let selected = mgr
485            .resolve_version(VersionRequest::LatestIn(Channel::Stable))
486            .await?;
487        let loaded = mgr.download(selected).await?;
488
489        assert_that!(loaded.chrome_executable).exists().is_a_file();
490        assert_that!(loaded.chromedriver_executable)
491            .exists()
492            .is_a_file();
493        Ok(())
494    }
495
496    #[tokio::test(flavor = "multi_thread")]
497    #[serial]
498    async fn resolve_and_download_latest() -> Result<(), Report> {
499        let mgr = ChromeForTestingManager::new()?;
500        let selected = mgr.resolve_version(VersionRequest::Latest).await?;
501        let loaded = mgr.download(selected).await?;
502
503        assert_that!(loaded.chrome_executable).exists().is_a_file();
504        assert_that!(loaded.chromedriver_executable)
505            .exists()
506            .is_a_file();
507        Ok(())
508    }
509
510    #[tokio::test(flavor = "multi_thread")]
511    #[serial]
512    async fn resolve_and_download_latest_in_stable_channel() -> Result<(), Report> {
513        let mgr = ChromeForTestingManager::new()?;
514        let selected = mgr
515            .resolve_version(VersionRequest::LatestIn(Channel::Stable))
516            .await?;
517        let loaded = mgr.download(selected).await?;
518
519        assert_that!(loaded.chrome_executable).exists().is_a_file();
520        assert_that!(loaded.chromedriver_executable)
521            .exists()
522            .is_a_file();
523        Ok(())
524    }
525
526    #[tokio::test(flavor = "multi_thread")]
527    #[serial]
528    async fn resolve_and_download_specific() -> Result<(), Report> {
529        let mgr = ChromeForTestingManager::new()?;
530        let selected = mgr
531            .resolve_version(VersionRequest::Fixed(Version {
532                major: 135,
533                minor: 0,
534                patch: 7019,
535                build: 0,
536            }))
537            .await?;
538        let loaded = mgr.download(selected).await?;
539
540        assert_that!(loaded.chrome_executable).exists().is_a_file();
541        assert_that!(loaded.chromedriver_executable)
542            .exists()
543            .is_a_file();
544        Ok(())
545    }
546
547    #[tokio::test(flavor = "multi_thread")]
548    #[serial]
549    async fn launch_chromedriver_on_specific_port() -> Result<(), Report> {
550        let mgr = ChromeForTestingManager::new()?;
551        let selected = mgr.resolve_version(VersionRequest::Latest).await?;
552        let loaded = mgr.download(selected).await?;
553        let (chromedriver, port, _output_inspectors) = mgr
554            .launch_chromedriver(
555                &loaded,
556                PortRequest::Specific(Port(3333)),
557                None,
558                default_graceful_shutdown(),
559            )
560            .await?;
561        let _chromedriver = chromedriver.terminate_on_drop(default_graceful_shutdown());
562        assert_that!(port).is_equal_to(Port(3333));
563        Ok(())
564    }
565
566    #[tokio::test(flavor = "multi_thread")]
567    #[serial]
568    async fn download_and_launch_chromedriver_on_random_port_and_prepare_thirtyfour_webdriver()
569    -> Result<(), Report> {
570        let mgr = ChromeForTestingManager::new()?;
571        let selected = mgr.resolve_version(VersionRequest::Latest).await?;
572        let loaded = mgr.download(selected).await?;
573        let (chromedriver, port, _output_inspectors) = mgr
574            .launch_chromedriver(&loaded, PortRequest::Any, None, default_graceful_shutdown())
575            .await?;
576        let _chromedriver = chromedriver.terminate_on_drop(default_graceful_shutdown());
577
578        let caps = mgr.prepare_caps(&loaded)?;
579        let driver = thirtyfour::WebDriver::new(format!("http://localhost:{port}"), caps).await?;
580        driver.goto("https://www.google.com").await?;
581
582        let url = driver.current_url().await?;
583        assert_that!(url).has_display_value("https://www.google.com/");
584
585        driver.quit().await?;
586
587        Ok(())
588    }
589}