chrome_for_testing_manager/
mgr.rs

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