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