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