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