chrome_for_testing_manager/
mgr.rs1use 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
23impl 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 Latest,
38
39 LatestIn(Channel),
42
43 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)] 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 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 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 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 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 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 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 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)] 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)] 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}