Skip to main content

chrome_for_testing_manager/
mgr.rs

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