chrome_for_testing_manager/
mgr.rs1use crate::cache::CacheDir;
2use crate::download;
3use crate::port::{Port, PortRequest};
4use anyhow::Context;
5use chrome_for_testing::api::channel::Channel;
6use chrome_for_testing::api::known_good_versions::VersionWithoutChannel;
7use chrome_for_testing::api::last_known_good_versions::VersionInChannel;
8use chrome_for_testing::api::platform::Platform;
9use chrome_for_testing::api::version::Version;
10use chrome_for_testing::api::{Download, HasVersion};
11use std::path::{Path, PathBuf};
12use std::sync::atomic::AtomicU16;
13use std::sync::Arc;
14use tokio::fs;
15use tokio::process::Command;
16use tokio_process_tools::{ProcessHandle, TerminateOnDrop};
17
18#[derive(Debug)]
19pub(crate) enum Artifact {
20 Chrome,
21 ChromeDriver,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum VersionRequest {
26 Latest,
29
30 LatestIn(Channel),
33
34 Fixed(Version),
36}
37
38#[derive(Debug)]
39pub struct SelectedVersion {
40 channel: Option<Channel>,
41 version: Version,
42 #[expect(unused)]
43 revision: String,
44 chrome: Option<Download>,
45 chromedriver: Option<Download>,
46}
47
48impl From<(VersionWithoutChannel, Platform)> for SelectedVersion {
49 fn from((v, p): (VersionWithoutChannel, Platform)) -> Self {
50 let chrome_download = v.downloads.chrome.iter().find(|d| d.platform == p).cloned();
51 let chromedriver_download = v
52 .downloads
53 .chromedriver
54 .map(|it| it.iter().find(|d| d.platform == p).unwrap().to_owned());
55
56 SelectedVersion {
57 channel: None,
58 version: v.version,
59 revision: v.revision,
60 chrome: chrome_download,
61 chromedriver: chromedriver_download,
62 }
63 }
64}
65
66impl From<(VersionInChannel, Platform)> for SelectedVersion {
67 fn from((v, p): (VersionInChannel, Platform)) -> Self {
68 let chrome_download = v.downloads.chrome.iter().find(|d| d.platform == p).cloned();
69 let chromedriver_download = v
70 .downloads
71 .chromedriver
72 .iter()
73 .find(|d| d.platform == p)
74 .cloned();
75
76 SelectedVersion {
77 channel: Some(v.channel),
78 version: v.version,
79 revision: v.revision,
80 chrome: chrome_download,
81 chromedriver: chromedriver_download,
82 }
83 }
84}
85
86#[derive(Debug)]
87pub struct LoadedChromePackage {
88 #[expect(unused)]
89 pub chrome_executable: PathBuf,
90 pub chromedriver_executable: PathBuf,
91}
92
93#[derive(Debug)]
94pub struct ChromeForTestingManager {
95 client: reqwest::Client,
96 cache_dir: CacheDir,
97 platform: Platform,
98}
99
100impl Default for ChromeForTestingManager {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl ChromeForTestingManager {
107 pub fn new() -> Self {
108 Self {
109 client: reqwest::Client::new(),
110 cache_dir: CacheDir::get_or_create(),
111 platform: Platform::detect(),
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<()> {
120 self.cache_dir.clear().await
121 }
122
123 pub(crate) async fn resolve_version(
124 &self,
125 version_selection: VersionRequest,
126 ) -> Result<SelectedVersion, anyhow::Error> {
127 let selected = match version_selection {
128 VersionRequest::Latest => {
129 fn get_latest<T: HasVersion + Clone>(options: &[T]) -> Option<T> {
130 if options.is_empty() {
131 return None;
132 }
133
134 let mut latest: &T = &options[0];
135
136 for option in &options[1..] {
137 if option.version() > latest.version() {
138 latest = option;
139 }
140 }
141
142 Some(latest.clone())
143 }
144
145 let all =
146 chrome_for_testing::api::known_good_versions::request(self.client.clone())
147 .await
148 .context("Failed to request latest versions.")?;
149 get_latest(&all.versions).map(|v| SelectedVersion::from((v, self.platform)))
151 }
152 VersionRequest::LatestIn(channel) => {
153 let all =
154 chrome_for_testing::api::last_known_good_versions::request(self.client.clone())
155 .await
156 .context("Failed to request latest versions.")?;
157 all.channels
158 .get(&channel)
159 .cloned()
160 .map(|v| SelectedVersion::from((v, self.platform)))
161 }
162 VersionRequest::Fixed(version) => {
163 let all =
164 chrome_for_testing::api::known_good_versions::request(self.client.clone())
165 .await
166 .context("Failed to request latest versions.")?;
167 all.versions
168 .into_iter()
169 .find(|v| v.version == version)
170 .map(|v| SelectedVersion::from((v, self.platform)))
171 }
172 };
173
174 let selected = selected.context("Could not determine version to use")?;
175
176 Ok(selected)
177 }
178
179 pub(crate) async fn download(
180 &self,
181 selected: SelectedVersion,
182 ) -> Result<LoadedChromePackage, anyhow::Error> {
183 let selected_chrome_download = match selected.chrome.clone() {
184 Some(download) => download,
185 None => {
186 return Err(anyhow::anyhow!(
187 "No chrome download found for selection {selected:?} using platform {}",
188 self.platform
189 ))
190 }
191 };
192
193 let selected_chromedriver_download = match selected.chromedriver.clone() {
194 Some(download) => download,
195 None => {
196 return Err(anyhow::anyhow!(
197 "No chromedriver download found for {selected:?} using platform {}",
198 self.platform
199 ))
200 }
201 };
202
203 let version_dir = self.version_dir(selected.version);
205 let platform_dir = version_dir.join(self.platform.to_string());
206 fs::create_dir_all(&platform_dir).await?;
207
208 fn determine_chrome_executable(platform_dir: &Path, platform: Platform) -> PathBuf {
209 let unpack_dir = platform_dir.join(format!("chrome-{}", platform));
210 match platform {
211 Platform::Linux64 | Platform::MacX64 => unpack_dir.join("chrome"),
212 Platform::MacArm64 => unpack_dir
213 .join("Google Chrome for Testing.app")
214 .join("Contents")
215 .join("MacOS")
216 .join("Google Chrome for Testing"),
217 Platform::Win32 | Platform::Win64 => unpack_dir.join("chrome.exe"),
218 }
219 }
220
221 let chrome_executable = determine_chrome_executable(&platform_dir, self.platform);
222 let chromedriver_executable = platform_dir
223 .join(format!("chromedriver-{}", self.platform))
224 .join(self.platform.chromedriver_binary_name());
225
226 let is_chrome_downloaded = chrome_executable.exists() && chrome_executable.is_file();
228 if !is_chrome_downloaded {
229 tracing::info!(
230 "Installing {} Chrome {}",
231 match selected.channel {
232 None => "".to_string(),
233 Some(channel) => channel.to_string(),
234 },
235 selected.version,
236 );
237 download::download_zip(
238 &self.client,
239 &selected_chrome_download.url,
240 &platform_dir,
241 &platform_dir,
242 Artifact::Chrome,
243 )
244 .await?;
245 } else {
246 tracing::info!(
247 "Chrome {} already installed at {chrome_executable:?}...",
248 selected.version
249 );
250 }
251
252 let is_chromedriver_downloaded =
254 chromedriver_executable.exists() && chromedriver_executable.is_file();
255 if !is_chromedriver_downloaded {
256 tracing::info!(
257 "Installing {} Chromedriver {}",
258 match selected.channel {
259 None => "".to_string(),
260 Some(channel) => channel.to_string(),
261 },
262 selected.version,
263 );
264 download::download_zip(
265 &self.client,
266 &selected_chromedriver_download.url,
267 &platform_dir,
268 &platform_dir,
269 Artifact::ChromeDriver,
270 )
271 .await?;
272 } else {
273 tracing::info!(
274 "Chromedriver {} already installed at {chromedriver_executable:?}...",
275 selected.version
276 );
277 }
278
279 Ok(LoadedChromePackage {
280 chrome_executable,
281 chromedriver_executable,
282 })
283 }
284
285 pub(crate) async fn launch_chromedriver(
286 &self,
287 loaded: &LoadedChromePackage,
288 port: PortRequest,
289 ) -> Result<(TerminateOnDrop, Port), anyhow::Error> {
290 let chromedriver_exe_path_str = loaded
291 .chromedriver_executable
292 .to_str()
293 .expect("valid unicode");
294
295 tracing::info!(
296 "Launching chromedriver... {:?}",
297 loaded.chromedriver_executable
298 );
299 let mut command = Command::new(chromedriver_exe_path_str);
300 match port {
301 PortRequest::Any => {}
302 PortRequest::Specific(Port(port)) => {
303 command.arg(format!("--port={}", port));
304 }
305 };
306 let loglevel = chrome_for_testing::chromedriver::LogLevel::Info;
307 command.arg(format!("--log-level={loglevel}"));
308
309 self.apply_chromedriver_creation_flags(&mut command);
310
311 let chromedriver_process = ProcessHandle::spawn("chromedriver", command)
312 .context("Failed to spawn chromedriver process.")?;
313
314 let _out_inspector = chromedriver_process.stdout().inspect(|stdout_line| {
315 tracing::debug!(stdout_line, "chromedriver log");
316 });
317 let _err_inspector = chromedriver_process.stdout().inspect(|stderr_line| {
318 tracing::debug!(stderr_line, "chromedriver log");
319 });
320
321 tracing::info!("Waiting for chromedriver to start...");
322 let started_on_port = Arc::new(AtomicU16::new(0));
323 let started_on_port_clone = started_on_port.clone();
324 chromedriver_process
325 .stdout()
326 .wait_for_with_timeout(
327 move |line| {
328 if line.contains("started successfully on port") {
329 let port = line
330 .trim()
331 .trim_matches('"')
332 .trim_end_matches('.')
333 .split(' ')
334 .last()
335 .expect("port as segment after last space")
336 .parse::<u16>()
337 .expect("port to be a u16");
338 started_on_port_clone.store(port, std::sync::atomic::Ordering::Release);
339 true
340 } else {
341 false
342 }
343 },
344 std::time::Duration::from_secs(10),
345 )
346 .await?;
347
348 Ok((
349 chromedriver_process.terminate_on_drop(
350 std::time::Duration::from_secs(10),
351 std::time::Duration::from_secs(10),
352 ),
353 Port(Arc::into_inner(started_on_port).unwrap().into_inner()),
354 ))
355 }
356
357 #[cfg(target_os = "windows")]
358 fn apply_chromedriver_creation_flags<'a>(&self, command: &'a mut Command) -> &'a mut Command {
359 use std::os::windows::process::CommandExt;
360
361 const CREATE_NO_WINDOW: u32 = 0x08000000;
368
369 command.creation_flags(CREATE_NO_WINDOW)
370 }
371
372 #[cfg(not(target_os = "windows"))]
373 fn apply_chromedriver_creation_flags<'a>(&self, command: &'a mut Command) -> &'a mut Command {
374 command
375 }
376
377 #[cfg(feature = "thirtyfour")]
378 pub(crate) async fn prepare_caps(
379 &self,
380 loaded: &LoadedChromePackage,
381 ) -> Result<thirtyfour::ChromeCapabilities, anyhow::Error> {
382 tracing::info!(
383 "Registering {:?} in capabilities.",
384 loaded.chrome_executable
385 );
386 use thirtyfour::ChromiumLikeCapabilities;
387 let mut caps = thirtyfour::ChromeCapabilities::new();
388 caps.set_headless()?;
389 caps.set_binary(loaded.chrome_executable.to_str().expect("valid unicode"))?;
390 Ok(caps)
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use crate::mgr::ChromeForTestingManager;
397 use crate::port::Port;
398 use crate::port::PortRequest;
399 use crate::prelude::*;
400 use assertr::prelude::*;
401 use chrome_for_testing::api::channel::Channel;
402 use serial_test::serial;
403
404 #[ctor::ctor]
405 fn init_test_tracing() {
406 tracing_subscriber::fmt().with_test_writer().try_init().ok();
407 }
408
409 #[tokio::test(flavor = "multi_thread")]
410 #[serial]
411 async fn clear_cache_and_download_new() -> anyhow::Result<()> {
412 let mgr = ChromeForTestingManager::new();
413 mgr.clear_cache().await?;
414 let selected = mgr
415 .resolve_version(VersionRequest::LatestIn(Channel::Stable))
416 .await?;
417 let loaded = mgr.download(selected).await?;
418
419 assert_that(loaded.chrome_executable).exists().is_a_file();
420 assert_that(loaded.chromedriver_executable)
421 .exists()
422 .is_a_file();
423 Ok(())
424 }
425
426 #[tokio::test(flavor = "multi_thread")]
427 #[serial]
428 async fn resolve_and_download_latest() -> anyhow::Result<()> {
429 let mgr = ChromeForTestingManager::new();
430 let selected = mgr.resolve_version(VersionRequest::Latest).await?;
431 let loaded = mgr.download(selected).await?;
432
433 assert_that(loaded.chrome_executable).exists().is_a_file();
434 assert_that(loaded.chromedriver_executable)
435 .exists()
436 .is_a_file();
437 Ok(())
438 }
439
440 #[tokio::test(flavor = "multi_thread")]
441 #[serial]
442 async fn resolve_and_download_latest_in_stable_channel() -> anyhow::Result<()> {
443 let mgr = ChromeForTestingManager::new();
444 let selected = mgr
445 .resolve_version(VersionRequest::LatestIn(Channel::Stable))
446 .await?;
447 let loaded = mgr.download(selected).await?;
448
449 assert_that(loaded.chrome_executable).exists().is_a_file();
450 assert_that(loaded.chromedriver_executable)
451 .exists()
452 .is_a_file();
453 Ok(())
454 }
455
456 #[tokio::test(flavor = "multi_thread")]
457 #[serial]
458 async fn resolve_and_download_specific() -> anyhow::Result<()> {
459 let mgr = ChromeForTestingManager::new();
460 let selected = mgr
461 .resolve_version(VersionRequest::Fixed(Version {
462 major: 135,
463 minor: 0,
464 patch: 7019,
465 build: 0,
466 }))
467 .await?;
468 let loaded = mgr.download(selected).await?;
469
470 assert_that(loaded.chrome_executable).exists().is_a_file();
471 assert_that(loaded.chromedriver_executable)
472 .exists()
473 .is_a_file();
474 Ok(())
475 }
476
477 #[tokio::test(flavor = "multi_thread")]
478 #[serial]
479 async fn launch_chromedriver_on_specific_port() -> anyhow::Result<()> {
480 let mgr = ChromeForTestingManager::new();
481 let selected = mgr.resolve_version(VersionRequest::Latest).await?;
482 let loaded = mgr.download(selected).await?;
483 let (_chromedriver, port) = mgr
484 .launch_chromedriver(&loaded, PortRequest::Specific(Port(3333)))
485 .await?;
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 ) -> anyhow::Result<()> {
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) = mgr.launch_chromedriver(&loaded, PortRequest::Any).await?;
498
499 let caps = mgr.prepare_caps(&loaded).await?;
500 let driver = thirtyfour::WebDriver::new(format!("http://localhost:{port}"), caps).await?;
501 driver.goto("https://www.google.com").await?;
502
503 let url = driver.current_url().await?;
504 assert_that(url).has_display_value("https://www.google.com/");
505
506 driver.quit().await?;
507
508 Ok(())
509 }
510}