chrome_for_testing_manager/
lib.rs

1use anyhow::Context;
2use chrome_for_testing::api::channel::Channel;
3use chrome_for_testing::api::platform::Platform;
4use chrome_for_testing::api::version::Version;
5use chrome_for_testing::api::{Download, HasVersion};
6use std::fmt::{Display, Formatter};
7use std::path::{Path, PathBuf};
8use std::sync::atomic::AtomicU16;
9use std::sync::Arc;
10use tokio::fs;
11use tokio::process::Command;
12use tokio_process_tools::{ProcessHandle, TerminateOnDrop};
13
14mod download;
15
16pub mod prelude {
17    pub use crate::ChromeForTestingManager;
18    pub use crate::LoadedChromePackage;
19    pub use crate::Port;
20    pub use crate::PortRequest;
21    pub use crate::SelectedVersion;
22    pub use crate::VersionRequest;
23    pub use chrome_for_testing::api::channel::Channel;
24    pub use chrome_for_testing::api::platform::Platform;
25    pub use chrome_for_testing::api::version::Version;
26
27    #[cfg(feature = "thirtyfour")]
28    pub use crate::SpawnedChromedriver;
29}
30
31#[derive(Debug)]
32pub(crate) enum Artifact {
33    Chrome,
34    ChromeDriver,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum VersionRequest {
39    /// Uses the latest working version. Might not be stable yet.
40    /// You may want to prefer variant [VersionRequest::LatestIn] instead.
41    Latest,
42
43    /// Use the latest release from the given [chrome_for_testing::channel::Channel],
44    /// e.g. the one from the [chrome_for_testing::channel::Channel::Stable] channel.
45    LatestIn(Channel),
46
47    /// Pin a specific version to use.
48    Fixed(Version),
49}
50
51#[derive(Debug)]
52pub struct SelectedVersion {
53    channel: Option<Channel>,
54    version: Version,
55    #[expect(unused)]
56    revision: String,
57    chrome: Option<Download>,
58    chromedriver: Option<Download>,
59}
60
61#[derive(Debug)]
62pub struct LoadedChromePackage {
63    pub chrome_executable: PathBuf,
64    pub chromedriver_executable: PathBuf,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct Port(u16);
69
70impl From<u16> for Port {
71    fn from(value: u16) -> Self {
72        Self(value)
73    }
74}
75
76impl AsRef<u16> for Port {
77    fn as_ref(&self) -> &u16 {
78        &self.0
79    }
80}
81
82impl Display for Port {
83    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
84        self.0.fmt(f)
85    }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum PortRequest {
90    Any,
91    Specific(Port),
92}
93
94#[derive(Debug)]
95pub(crate) struct CacheDir(PathBuf);
96
97impl CacheDir {
98    pub fn get_or_create() -> Self {
99        let project_dirs = directories::ProjectDirs::from("", "", "chromedriver-manager").unwrap();
100
101        let cache_dir = project_dirs.cache_dir();
102        if !cache_dir.exists() {
103            std::fs::create_dir_all(cache_dir).unwrap();
104        }
105
106        Self(cache_dir.to_owned())
107    }
108
109    pub fn path(&self) -> &PathBuf {
110        &self.0
111    }
112
113    pub async fn clear(&self) -> anyhow::Result<()> {
114        tracing::info!("Clearing cache at {:?}...", self.path());
115        fs::remove_dir_all(self.path()).await?;
116        fs::create_dir_all(self.path()).await?;
117        Ok(())
118    }
119}
120
121#[derive(Debug)]
122pub struct ChromeForTestingManager {
123    client: reqwest::Client,
124    cache_dir: CacheDir,
125    platform: Platform,
126}
127
128impl Default for ChromeForTestingManager {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134/// A wrapper struct for a spawned chromedriver process.
135/// Keep this alive until your test is complete.
136#[cfg(feature = "thirtyfour")]
137pub struct SpawnedChromedriver {
138    #[expect(unused)]
139    chromedriver: TerminateOnDrop,
140    chromedriver_port: Port,
141    loaded: LoadedChromePackage,
142    mgr: ChromeForTestingManager,
143}
144
145/// This `thirtyfour::WebDriver` wrapper enforces a keep-alive of the `SpawnedChromedriver` from
146/// which this webdriver was created, by storing its lifetime.
147#[cfg(feature = "thirtyfour")]
148pub struct WebDriver<'a> {
149    driver: thirtyfour::WebDriver,
150
151    phantom: std::marker::PhantomData<&'a ()>,
152}
153
154#[cfg(feature = "thirtyfour")]
155impl<'a> WebDriver<'a> {
156    pub async fn quit(self) -> thirtyfour::prelude::WebDriverResult<()> {
157        self.driver.quit().await
158    }
159}
160
161#[cfg(feature = "thirtyfour")]
162impl std::ops::Deref for WebDriver<'_> {
163    type Target = thirtyfour::WebDriver;
164
165    fn deref(&self) -> &Self::Target {
166        &self.driver
167    }
168}
169
170#[cfg(feature = "thirtyfour")]
171impl SpawnedChromedriver {
172    pub async fn new_webdriver(&self) -> anyhow::Result<WebDriver<'_>> {
173        self.new_webdriver_with_caps(|_caps| Ok(())).await
174    }
175
176    pub async fn new_webdriver_with_caps(
177        &self,
178        setup: impl Fn(
179            &mut thirtyfour::ChromeCapabilities,
180        ) -> Result<(), thirtyfour::prelude::WebDriverError>,
181    ) -> anyhow::Result<WebDriver<'_>> {
182        let mut caps = self.mgr.prepare_caps(&self.loaded).await?;
183        setup(&mut caps).context("Failed to setup chrome capabilities.")?;
184        let driver = thirtyfour::WebDriver::new(
185            format!("http://localhost:{}", self.chromedriver_port),
186            caps,
187        )
188        .await?;
189        Ok(WebDriver {
190            driver,
191            phantom: std::marker::PhantomData,
192        })
193    }
194}
195
196impl ChromeForTestingManager {
197    pub fn new() -> Self {
198        Self {
199            client: reqwest::Client::new(),
200            cache_dir: CacheDir::get_or_create(),
201            platform: Platform::detect(),
202        }
203    }
204
205    #[cfg(feature = "thirtyfour")]
206    pub async fn latest_stable() -> anyhow::Result<SpawnedChromedriver> {
207        let mgr = ChromeForTestingManager::new();
208        let selected = mgr
209            .resolve_version(VersionRequest::LatestIn(Channel::Stable))
210            .await?;
211        let loaded = mgr.download(selected).await?;
212        let (chromedriver, chromedriver_port) =
213            mgr.launch_chromedriver(&loaded, PortRequest::Any).await?;
214        Ok(SpawnedChromedriver {
215            chromedriver,
216            chromedriver_port,
217            loaded,
218            mgr,
219        })
220    }
221
222    fn version_dir(&self, version: Version) -> PathBuf {
223        self.cache_dir.path().join(version.to_string())
224    }
225
226    pub async fn clear_cache(&self) -> anyhow::Result<()> {
227        self.cache_dir.clear().await
228    }
229
230    pub async fn resolve_version(
231        &self,
232        version_selection: VersionRequest,
233    ) -> Result<SelectedVersion, anyhow::Error> {
234        // Determine version to use.
235        let selected = match version_selection {
236            VersionRequest::Latest => {
237                fn get_latest<T: HasVersion + Clone>(options: &[T]) -> Option<T> {
238                    if options.is_empty() {
239                        return None;
240                    }
241
242                    let mut latest: &T = &options[0];
243
244                    for option in &options[1..] {
245                        if option.version() > latest.version() {
246                            latest = option;
247                        }
248                    }
249
250                    Some(latest.clone())
251                }
252
253                let all =
254                    chrome_for_testing::api::known_good_versions::request(self.client.clone())
255                        .await
256                        .context("Failed to request latest versions.")?;
257                // TODO: Search for latest version with both chrome and chromedriver available!
258                get_latest(&all.versions).map(|v| SelectedVersion {
259                    channel: None,
260                    version: v.version,
261                    revision: v.revision,
262                    chrome: v
263                        .downloads
264                        .chrome
265                        .iter()
266                        .find(|d| d.platform == self.platform)
267                        .cloned(),
268                    chromedriver: v.downloads.chromedriver.map(|it| {
269                        it.iter()
270                            .find(|d| d.platform == self.platform)
271                            .unwrap()
272                            .to_owned()
273                    }),
274                })
275            }
276            VersionRequest::LatestIn(channel) => {
277                let all =
278                    chrome_for_testing::api::last_known_good_versions::request(self.client.clone())
279                        .await
280                        .context("Failed to request latest versions.")?;
281                all.channels
282                    .get(&channel)
283                    .cloned()
284                    .map(|v| SelectedVersion {
285                        channel: Some(v.channel),
286                        version: v.version,
287                        revision: v.revision,
288                        chrome: v
289                            .downloads
290                            .chrome
291                            .iter()
292                            .find(|d| d.platform == self.platform)
293                            .cloned(),
294                        chromedriver: v
295                            .downloads
296                            .chromedriver
297                            .iter()
298                            .find(|d| d.platform == self.platform)
299                            .cloned(),
300                    })
301            }
302            VersionRequest::Fixed(_version) => {
303                todo!()
304            }
305        };
306
307        let selected = selected.context("Could not determine version to use")?;
308
309        Ok(selected)
310    }
311
312    pub async fn download(
313        &self,
314        selected: SelectedVersion,
315    ) -> Result<LoadedChromePackage, anyhow::Error> {
316        let selected_chrome_download = match selected.chrome.clone() {
317            Some(download) => download,
318            None => {
319                return Err(anyhow::anyhow!(
320                    "No chrome download found for selection {selected:?} using platform {}",
321                    self.platform
322                ))
323            }
324        };
325
326        let selected_chromedriver_download = match selected.chromedriver.clone() {
327            Some(download) => download,
328            None => {
329                return Err(anyhow::anyhow!(
330                    "No chromedriver download found for {selected:?} using platform {}",
331                    self.platform
332                ))
333            }
334        };
335
336        // Check if download is necessary.
337        let version_dir = self.version_dir(selected.version);
338        let platform_dir = version_dir.join(self.platform.to_string());
339        fs::create_dir_all(&platform_dir).await?;
340
341        fn determine_chrome_executable(platform_dir: &Path, platform: Platform) -> PathBuf {
342            let unpack_dir = platform_dir.join(format!("chrome-{}", platform));
343            match platform {
344                Platform::Linux64 | Platform::MacX64 => unpack_dir.join("chrome"),
345                Platform::MacArm64 => unpack_dir
346                    .join("Google Chrome for Testing.app")
347                    .join("Contents")
348                    .join("MacOS")
349                    .join("Google Chrome for Testing"),
350                Platform::Win32 | Platform::Win64 => unpack_dir.join("chrome.exe"),
351            }
352        }
353
354        let chrome_executable = determine_chrome_executable(&platform_dir, self.platform);
355        let chromedriver_executable = platform_dir
356            .join(format!("chromedriver-{}", self.platform))
357            .join(self.platform.chromedriver_binary_name());
358
359        // Download chrome if necessary.
360        let is_chrome_downloaded = chrome_executable.exists() && chrome_executable.is_file();
361        if !is_chrome_downloaded {
362            tracing::info!(
363                "Installing {} Chrome {}",
364                match selected.channel {
365                    None => "".to_string(),
366                    Some(channel) => channel.to_string(),
367                },
368                selected.version,
369            );
370            download::download_zip(
371                &self.client,
372                &selected_chrome_download.url,
373                &platform_dir,
374                &platform_dir,
375                Artifact::Chrome,
376            )
377            .await?;
378        } else {
379            tracing::info!(
380                "Chrome {} already installed at {chrome_executable:?}...",
381                selected.version
382            );
383        }
384
385        // Download chromedriver if necessary.
386        let is_chromedriver_downloaded =
387            chromedriver_executable.exists() && chromedriver_executable.is_file();
388        if !is_chromedriver_downloaded {
389            tracing::info!(
390                "Installing {} Chromedriver {}",
391                match selected.channel {
392                    None => "".to_string(),
393                    Some(channel) => channel.to_string(),
394                },
395                selected.version,
396            );
397            download::download_zip(
398                &self.client,
399                &selected_chromedriver_download.url,
400                &platform_dir,
401                &platform_dir,
402                Artifact::ChromeDriver,
403            )
404            .await?;
405        } else {
406            tracing::info!(
407                "Chromedriver {} already installed at {chromedriver_executable:?}...",
408                selected.version
409            );
410        }
411
412        Ok(LoadedChromePackage {
413            chrome_executable,
414            chromedriver_executable,
415        })
416    }
417
418    pub async fn launch_chromedriver(
419        &self,
420        loaded: &LoadedChromePackage,
421        port: PortRequest,
422    ) -> Result<(TerminateOnDrop, Port), anyhow::Error> {
423        let chromedriver_exe_path_str = loaded
424            .chromedriver_executable
425            .to_str()
426            .expect("valid unicode");
427
428        tracing::info!(
429            "Launching chromedriver... {:?}",
430            loaded.chromedriver_executable
431        );
432        let mut command = Command::new(chromedriver_exe_path_str);
433        match port {
434            PortRequest::Any => {}
435            PortRequest::Specific(Port(port)) => {
436                command.arg(format!("--port={}", port));
437            }
438        };
439        let loglevel = chrome_for_testing::chromedriver::LogLevel::Info;
440        command.arg(format!("--log-level={loglevel}"));
441
442        self.apply_chromedriver_creation_flags(&mut command);
443
444        let chromedriver_process = ProcessHandle::spawn("chromedriver", command)
445            .context("Failed to spawn chromedriver process.")?;
446
447        let _out_inspector = chromedriver_process.stdout().inspect(|stdout_line| {
448            tracing::debug!(stdout_line, "chromedriver log");
449        });
450        let _err_inspector = chromedriver_process.stdout().inspect(|stderr_line| {
451            tracing::debug!(stderr_line, "chromedriver log");
452        });
453
454        tracing::info!("Waiting for chromedriver to start...");
455        let started_on_port = Arc::new(AtomicU16::new(0));
456        let started_on_port_clone = started_on_port.clone();
457        chromedriver_process
458            .stdout()
459            .wait_for_with_timeout(
460                move |line| {
461                    if line.contains("started successfully on port") {
462                        let port = line
463                            .trim()
464                            .trim_matches('"')
465                            .trim_end_matches('.')
466                            .split(' ')
467                            .last()
468                            .expect("port as segment after last space")
469                            .parse::<u16>()
470                            .expect("port to be a u16");
471                        started_on_port_clone.store(port, std::sync::atomic::Ordering::Release);
472                        true
473                    } else {
474                        false
475                    }
476                },
477                std::time::Duration::from_secs(10),
478            )
479            .await?;
480
481        Ok((
482            chromedriver_process.terminate_on_drop(
483                std::time::Duration::from_secs(10),
484                std::time::Duration::from_secs(10),
485            ),
486            Port(Arc::into_inner(started_on_port).unwrap().into_inner()),
487        ))
488    }
489
490    #[cfg(target_os = "windows")]
491    fn apply_chromedriver_creation_flags<'a>(&self, command: &'a mut Command) -> &'a mut Command {
492        use std::os::windows::process::CommandExt;
493
494        // CREATE_NO_WINDOW (0x08000000) is a Windows-specific process creation flag that prevents
495        // a process from creating a new window. This is relevant for ChromeDriver because:
496        //   - ChromeDriver is typically a console application on Windows.
497        //   - Without this flag, launching ChromeDriver would create a visible console window.
498        //   - In our automation scenario, we don't want users to see this console window popping up.
499        //   - The window isn't necessary since we're already capturing the stdout/stderr streams programmatically.
500        const CREATE_NO_WINDOW: u32 = 0x08000000;
501
502        command.creation_flags(CREATE_NO_WINDOW)
503    }
504
505    #[cfg(not(target_os = "windows"))]
506    fn apply_chromedriver_creation_flags<'a>(&self, command: &'a mut Command) -> &'a mut Command {
507        command
508    }
509
510    #[cfg(feature = "thirtyfour")]
511    pub async fn prepare_caps(
512        &self,
513        loaded: &LoadedChromePackage,
514    ) -> Result<thirtyfour::ChromeCapabilities, anyhow::Error> {
515        tracing::info!(
516            "Registering {:?} in capabilities.",
517            loaded.chrome_executable
518        );
519        use thirtyfour::ChromiumLikeCapabilities;
520        let mut caps = thirtyfour::ChromeCapabilities::new();
521        caps.set_headless()?;
522        caps.set_binary(loaded.chrome_executable.to_str().expect("valid unicode"))?;
523        Ok(caps)
524    }
525}
526
527#[cfg(test)]
528mod tests {
529    use crate::{ChromeForTestingManager, Port, PortRequest, VersionRequest};
530    use assertr::prelude::*;
531    use chrome_for_testing::api::channel::Channel;
532    use serial_test::serial;
533    use thirtyfour::ChromiumLikeCapabilities;
534
535    #[ctor::ctor]
536    fn init_test_tracing() {
537        tracing_subscriber::fmt().with_test_writer().try_init().ok();
538    }
539
540    #[tokio::test(flavor = "multi_thread")]
541    #[serial]
542    async fn clear_cache_and_download_new() -> anyhow::Result<()> {
543        let mgr = ChromeForTestingManager::new();
544        mgr.clear_cache().await?;
545        let selected = mgr
546            .resolve_version(VersionRequest::LatestIn(Channel::Stable))
547            .await?;
548        let loaded = mgr.download(selected).await?;
549
550        assert_that(loaded.chrome_executable).exists().is_a_file();
551        assert_that(loaded.chromedriver_executable)
552            .exists()
553            .is_a_file();
554        Ok(())
555    }
556
557    #[tokio::test(flavor = "multi_thread")]
558    #[serial]
559    async fn resolve_and_download_latest() -> anyhow::Result<()> {
560        let mgr = ChromeForTestingManager::new();
561        let selected = mgr.resolve_version(VersionRequest::Latest).await?;
562        let loaded = mgr.download(selected).await?;
563
564        assert_that(loaded.chrome_executable).exists().is_a_file();
565        assert_that(loaded.chromedriver_executable)
566            .exists()
567            .is_a_file();
568        Ok(())
569    }
570
571    #[tokio::test(flavor = "multi_thread")]
572    #[serial]
573    async fn resolve_and_download_latest_in_stable_channel() -> anyhow::Result<()> {
574        let mgr = ChromeForTestingManager::new();
575        let selected = mgr
576            .resolve_version(VersionRequest::LatestIn(Channel::Stable))
577            .await?;
578        let loaded = mgr.download(selected).await?;
579
580        assert_that(loaded.chrome_executable).exists().is_a_file();
581        assert_that(loaded.chromedriver_executable)
582            .exists()
583            .is_a_file();
584        Ok(())
585    }
586
587    #[tokio::test(flavor = "multi_thread")]
588    #[serial]
589    async fn launch_chromedriver_on_specific_port() -> anyhow::Result<()> {
590        let mgr = ChromeForTestingManager::new();
591        let selected = mgr.resolve_version(VersionRequest::Latest).await?;
592        let loaded = mgr.download(selected).await?;
593        let (_chromedriver, port) = mgr
594            .launch_chromedriver(&loaded, PortRequest::Specific(Port(3333)))
595            .await?;
596        assert_that(port).is_equal_to(Port(3333));
597        Ok(())
598    }
599
600    #[tokio::test(flavor = "multi_thread")]
601    #[serial]
602    async fn download_and_launch_chromedriver_on_random_port_and_prepare_thirtyfour_webdriver(
603    ) -> anyhow::Result<()> {
604        let mgr = ChromeForTestingManager::new();
605        let selected = mgr.resolve_version(VersionRequest::Latest).await?;
606        let loaded = mgr.download(selected).await?;
607        let (_chromedriver, port) = mgr.launch_chromedriver(&loaded, PortRequest::Any).await?;
608
609        let caps = mgr.prepare_caps(&loaded).await?;
610        let driver = thirtyfour::WebDriver::new(format!("http://localhost:{port}"), caps).await?;
611        driver.goto("https://www.google.com").await?;
612
613        let url = driver.current_url().await?;
614        assert_that(url).has_display_value("https://www.google.com/");
615
616        driver.quit().await?;
617
618        Ok(())
619    }
620
621    #[tokio::test(flavor = "multi_thread")]
622    #[serial]
623    #[cfg(feature = "thirtyfour")]
624    async fn latest_stable() -> anyhow::Result<()> {
625        let chromedriver = ChromeForTestingManager::latest_stable().await?;
626        let driver = chromedriver.new_webdriver().await?;
627
628        driver.goto("https://www.google.com").await?;
629
630        let url = driver.current_url().await?;
631        assert_that(url).has_display_value("https://www.google.com/");
632
633        driver.quit().await?;
634
635        Ok(())
636    }
637
638    #[tokio::test(flavor = "multi_thread")]
639    #[serial]
640    #[cfg(feature = "thirtyfour")]
641    async fn latest_stable_with_caps() -> anyhow::Result<()> {
642        let chromedriver = ChromeForTestingManager::latest_stable().await?;
643        let driver = chromedriver
644            .new_webdriver_with_caps(|caps| caps.unset_headless())
645            .await?;
646
647        driver.goto("https://www.google.com").await?;
648
649        let url = driver.current_url().await?;
650        assert_that(url).has_display_value("https://www.google.com/");
651
652        driver.quit().await?;
653
654        Ok(())
655    }
656}