Skip to main content

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