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 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 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 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 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 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 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 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 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)] 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)] 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}