chrome_for_testing_manager/
lib.rs1use anyhow::Context;
2use chrome_for_testing::api::channel::Channel;
3use chrome_for_testing::api::platform::Platform;
4use chrome_for_testing::api::version::Version;
5use chrome_for_testing::api::{Download, HasVersion};
6use std::fmt::{Display, Formatter};
7use std::path::{Path, PathBuf};
8use std::sync::atomic::AtomicU16;
9use std::sync::Arc;
10use tokio::fs;
11use tokio::process::Command;
12use tokio_process_tools::{ProcessHandle, TerminateOnDrop};
13
14mod download;
15
16pub mod prelude {
17 pub use crate::ChromeForTestingManager;
18 pub use crate::LoadedChromePackage;
19 pub use crate::Port;
20 pub use crate::PortRequest;
21 pub use crate::SelectedVersion;
22 pub use crate::VersionRequest;
23 pub use chrome_for_testing::api::channel::Channel;
24 pub use chrome_for_testing::api::platform::Platform;
25 pub use chrome_for_testing::api::version::Version;
26
27 #[cfg(feature = "thirtyfour")]
28 pub use crate::SpawnedChromedriver;
29}
30
31#[derive(Debug)]
32pub(crate) enum Artifact {
33 Chrome,
34 ChromeDriver,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum VersionRequest {
39 Latest,
42
43 LatestIn(Channel),
46
47 Fixed(Version),
49}
50
51#[derive(Debug)]
52pub struct SelectedVersion {
53 channel: Option<Channel>,
54 version: Version,
55 #[expect(unused)]
56 revision: String,
57 chrome: Option<Download>,
58 chromedriver: Option<Download>,
59}
60
61#[derive(Debug)]
62pub struct LoadedChromePackage {
63 pub chrome_executable: PathBuf,
64 pub chromedriver_executable: PathBuf,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct Port(u16);
69
70impl From<u16> for Port {
71 fn from(value: u16) -> Self {
72 Self(value)
73 }
74}
75
76impl AsRef<u16> for Port {
77 fn as_ref(&self) -> &u16 {
78 &self.0
79 }
80}
81
82impl Display for Port {
83 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
84 self.0.fmt(f)
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum PortRequest {
90 Any,
91 Specific(Port),
92}
93
94#[derive(Debug)]
95pub(crate) struct CacheDir(PathBuf);
96
97impl CacheDir {
98 pub fn get_or_create() -> Self {
99 let project_dirs = directories::ProjectDirs::from("", "", "chromedriver-manager").unwrap();
100
101 let cache_dir = project_dirs.cache_dir();
102 if !cache_dir.exists() {
103 std::fs::create_dir_all(cache_dir).unwrap();
104 }
105
106 Self(cache_dir.to_owned())
107 }
108
109 pub fn path(&self) -> &PathBuf {
110 &self.0
111 }
112
113 pub async fn clear(&self) -> anyhow::Result<()> {
114 tracing::info!("Clearing cache at {:?}...", self.path());
115 fs::remove_dir_all(self.path()).await?;
116 fs::create_dir_all(self.path()).await?;
117 Ok(())
118 }
119}
120
121#[derive(Debug)]
122pub struct ChromeForTestingManager {
123 client: reqwest::Client,
124 cache_dir: CacheDir,
125 platform: Platform,
126}
127
128impl Default for ChromeForTestingManager {
129 fn default() -> Self {
130 Self::new()
131 }
132}
133
134#[cfg(feature = "thirtyfour")]
137pub struct SpawnedChromedriver {
138 #[expect(unused)]
139 chromedriver: TerminateOnDrop,
140 chromedriver_port: Port,
141 loaded: LoadedChromePackage,
142 mgr: ChromeForTestingManager,
143}
144
145#[cfg(feature = "thirtyfour")]
148pub struct WebDriver<'a> {
149 driver: thirtyfour::WebDriver,
150
151 phantom: std::marker::PhantomData<&'a ()>,
152}
153
154#[cfg(feature = "thirtyfour")]
155impl<'a> WebDriver<'a> {
156 pub async fn quit(self) -> thirtyfour::prelude::WebDriverResult<()> {
157 self.driver.quit().await
158 }
159}
160
161#[cfg(feature = "thirtyfour")]
162impl std::ops::Deref for WebDriver<'_> {
163 type Target = thirtyfour::WebDriver;
164
165 fn deref(&self) -> &Self::Target {
166 &self.driver
167 }
168}
169
170#[cfg(feature = "thirtyfour")]
171impl SpawnedChromedriver {
172 pub async fn new_webdriver(&self) -> anyhow::Result<WebDriver<'_>> {
173 self.new_webdriver_with_caps(|_caps| Ok(())).await
174 }
175
176 pub async fn new_webdriver_with_caps(
177 &self,
178 setup: impl Fn(
179 &mut thirtyfour::ChromeCapabilities,
180 ) -> Result<(), thirtyfour::prelude::WebDriverError>,
181 ) -> anyhow::Result<WebDriver<'_>> {
182 let mut caps = self.mgr.prepare_caps(&self.loaded).await?;
183 setup(&mut caps).context("Failed to setup chrome capabilities.")?;
184 let driver = thirtyfour::WebDriver::new(
185 format!("http://localhost:{}", self.chromedriver_port),
186 caps,
187 )
188 .await?;
189 Ok(WebDriver {
190 driver,
191 phantom: std::marker::PhantomData,
192 })
193 }
194}
195
196impl ChromeForTestingManager {
197 pub fn new() -> Self {
198 Self {
199 client: reqwest::Client::new(),
200 cache_dir: CacheDir::get_or_create(),
201 platform: Platform::detect(),
202 }
203 }
204
205 #[cfg(feature = "thirtyfour")]
206 pub async fn latest_stable() -> anyhow::Result<SpawnedChromedriver> {
207 let mgr = ChromeForTestingManager::new();
208 let selected = mgr
209 .resolve_version(VersionRequest::LatestIn(Channel::Stable))
210 .await?;
211 let loaded = mgr.download(selected).await?;
212 let (chromedriver, chromedriver_port) =
213 mgr.launch_chromedriver(&loaded, PortRequest::Any).await?;
214 Ok(SpawnedChromedriver {
215 chromedriver,
216 chromedriver_port,
217 loaded,
218 mgr,
219 })
220 }
221
222 fn version_dir(&self, version: Version) -> PathBuf {
223 self.cache_dir.path().join(version.to_string())
224 }
225
226 pub async fn clear_cache(&self) -> anyhow::Result<()> {
227 self.cache_dir.clear().await
228 }
229
230 pub async fn resolve_version(
231 &self,
232 version_selection: VersionRequest,
233 ) -> Result<SelectedVersion, anyhow::Error> {
234 let selected = match version_selection {
236 VersionRequest::Latest => {
237 fn get_latest<T: HasVersion + Clone>(options: &[T]) -> Option<T> {
238 if options.is_empty() {
239 return None;
240 }
241
242 let mut latest: &T = &options[0];
243
244 for option in &options[1..] {
245 if option.version() > latest.version() {
246 latest = option;
247 }
248 }
249
250 Some(latest.clone())
251 }
252
253 let all =
254 chrome_for_testing::api::known_good_versions::request(self.client.clone())
255 .await
256 .context("Failed to request latest versions.")?;
257 get_latest(&all.versions).map(|v| SelectedVersion {
259 channel: None,
260 version: v.version,
261 revision: v.revision,
262 chrome: v
263 .downloads
264 .chrome
265 .iter()
266 .find(|d| d.platform == self.platform)
267 .cloned(),
268 chromedriver: v.downloads.chromedriver.map(|it| {
269 it.iter()
270 .find(|d| d.platform == self.platform)
271 .unwrap()
272 .to_owned()
273 }),
274 })
275 }
276 VersionRequest::LatestIn(channel) => {
277 let all =
278 chrome_for_testing::api::last_known_good_versions::request(self.client.clone())
279 .await
280 .context("Failed to request latest versions.")?;
281 all.channels
282 .get(&channel)
283 .cloned()
284 .map(|v| SelectedVersion {
285 channel: Some(v.channel),
286 version: v.version,
287 revision: v.revision,
288 chrome: v
289 .downloads
290 .chrome
291 .iter()
292 .find(|d| d.platform == self.platform)
293 .cloned(),
294 chromedriver: v
295 .downloads
296 .chromedriver
297 .iter()
298 .find(|d| d.platform == self.platform)
299 .cloned(),
300 })
301 }
302 VersionRequest::Fixed(_version) => {
303 todo!()
304 }
305 };
306
307 let selected = selected.context("Could not determine version to use")?;
308
309 Ok(selected)
310 }
311
312 pub async fn download(
313 &self,
314 selected: SelectedVersion,
315 ) -> Result<LoadedChromePackage, anyhow::Error> {
316 let selected_chrome_download = match selected.chrome.clone() {
317 Some(download) => download,
318 None => {
319 return Err(anyhow::anyhow!(
320 "No chrome download found for selection {selected:?} using platform {}",
321 self.platform
322 ))
323 }
324 };
325
326 let selected_chromedriver_download = match selected.chromedriver.clone() {
327 Some(download) => download,
328 None => {
329 return Err(anyhow::anyhow!(
330 "No chromedriver download found for {selected:?} using platform {}",
331 self.platform
332 ))
333 }
334 };
335
336 let version_dir = self.version_dir(selected.version);
338 let platform_dir = version_dir.join(self.platform.to_string());
339 fs::create_dir_all(&platform_dir).await?;
340
341 fn determine_chrome_executable(platform_dir: &Path, platform: Platform) -> PathBuf {
342 let unpack_dir = platform_dir.join(format!("chrome-{}", platform));
343 match platform {
344 Platform::Linux64 | Platform::MacX64 => unpack_dir.join("chrome"),
345 Platform::MacArm64 => unpack_dir
346 .join("Google Chrome for Testing.app")
347 .join("Contents")
348 .join("MacOS")
349 .join("Google Chrome for Testing"),
350 Platform::Win32 | Platform::Win64 => unpack_dir.join("chrome.exe"),
351 }
352 }
353
354 let chrome_executable = determine_chrome_executable(&platform_dir, self.platform);
355 let chromedriver_executable = platform_dir
356 .join(format!("chromedriver-{}", self.platform))
357 .join(self.platform.chromedriver_binary_name());
358
359 let is_chrome_downloaded = chrome_executable.exists() && chrome_executable.is_file();
361 if !is_chrome_downloaded {
362 tracing::info!(
363 "Installing {} Chrome {}",
364 match selected.channel {
365 None => "".to_string(),
366 Some(channel) => channel.to_string(),
367 },
368 selected.version,
369 );
370 download::download_zip(
371 &self.client,
372 &selected_chrome_download.url,
373 &platform_dir,
374 &platform_dir,
375 Artifact::Chrome,
376 )
377 .await?;
378 } else {
379 tracing::info!(
380 "Chrome {} already installed at {chrome_executable:?}...",
381 selected.version
382 );
383 }
384
385 let is_chromedriver_downloaded =
387 chromedriver_executable.exists() && chromedriver_executable.is_file();
388 if !is_chromedriver_downloaded {
389 tracing::info!(
390 "Installing {} Chromedriver {}",
391 match selected.channel {
392 None => "".to_string(),
393 Some(channel) => channel.to_string(),
394 },
395 selected.version,
396 );
397 download::download_zip(
398 &self.client,
399 &selected_chromedriver_download.url,
400 &platform_dir,
401 &platform_dir,
402 Artifact::ChromeDriver,
403 )
404 .await?;
405 } else {
406 tracing::info!(
407 "Chromedriver {} already installed at {chromedriver_executable:?}...",
408 selected.version
409 );
410 }
411
412 Ok(LoadedChromePackage {
413 chrome_executable,
414 chromedriver_executable,
415 })
416 }
417
418 pub async fn launch_chromedriver(
419 &self,
420 loaded: &LoadedChromePackage,
421 port: PortRequest,
422 ) -> Result<(TerminateOnDrop, Port), anyhow::Error> {
423 let chromedriver_exe_path_str = loaded
424 .chromedriver_executable
425 .to_str()
426 .expect("valid unicode");
427
428 tracing::info!(
429 "Launching chromedriver... {:?}",
430 loaded.chromedriver_executable
431 );
432 let mut command = Command::new(chromedriver_exe_path_str);
433 match port {
434 PortRequest::Any => {}
435 PortRequest::Specific(Port(port)) => {
436 command.arg(format!("--port={}", port));
437 }
438 };
439 let loglevel = chrome_for_testing::chromedriver::LogLevel::Info;
440 command.arg(format!("--log-level={loglevel}"));
441
442 self.apply_chromedriver_creation_flags(&mut command);
443
444 let chromedriver_process = ProcessHandle::spawn("chromedriver", command)
445 .context("Failed to spawn chromedriver process.")?;
446
447 let _out_inspector = chromedriver_process.stdout().inspect(|stdout_line| {
448 tracing::debug!(stdout_line, "chromedriver log");
449 });
450 let _err_inspector = chromedriver_process.stdout().inspect(|stderr_line| {
451 tracing::debug!(stderr_line, "chromedriver log");
452 });
453
454 tracing::info!("Waiting for chromedriver to start...");
455 let started_on_port = Arc::new(AtomicU16::new(0));
456 let started_on_port_clone = started_on_port.clone();
457 chromedriver_process
458 .stdout()
459 .wait_for_with_timeout(
460 move |line| {
461 if line.contains("started successfully on port") {
462 let port = line
463 .trim()
464 .trim_matches('"')
465 .trim_end_matches('.')
466 .split(' ')
467 .last()
468 .expect("port as segment after last space")
469 .parse::<u16>()
470 .expect("port to be a u16");
471 started_on_port_clone.store(port, std::sync::atomic::Ordering::Release);
472 true
473 } else {
474 false
475 }
476 },
477 std::time::Duration::from_secs(10),
478 )
479 .await?;
480
481 Ok((
482 chromedriver_process.terminate_on_drop(
483 std::time::Duration::from_secs(10),
484 std::time::Duration::from_secs(10),
485 ),
486 Port(Arc::into_inner(started_on_port).unwrap().into_inner()),
487 ))
488 }
489
490 #[cfg(target_os = "windows")]
491 fn apply_chromedriver_creation_flags<'a>(&self, command: &'a mut Command) -> &'a mut Command {
492 use std::os::windows::process::CommandExt;
493
494 const CREATE_NO_WINDOW: u32 = 0x08000000;
501
502 command.creation_flags(CREATE_NO_WINDOW)
503 }
504
505 #[cfg(not(target_os = "windows"))]
506 fn apply_chromedriver_creation_flags<'a>(&self, command: &'a mut Command) -> &'a mut Command {
507 command
508 }
509
510 #[cfg(feature = "thirtyfour")]
511 pub async fn prepare_caps(
512 &self,
513 loaded: &LoadedChromePackage,
514 ) -> Result<thirtyfour::ChromeCapabilities, anyhow::Error> {
515 tracing::info!(
516 "Registering {:?} in capabilities.",
517 loaded.chrome_executable
518 );
519 use thirtyfour::ChromiumLikeCapabilities;
520 let mut caps = thirtyfour::ChromeCapabilities::new();
521 caps.set_headless()?;
522 caps.set_binary(loaded.chrome_executable.to_str().expect("valid unicode"))?;
523 Ok(caps)
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use crate::{ChromeForTestingManager, Port, PortRequest, VersionRequest};
530 use assertr::prelude::*;
531 use chrome_for_testing::api::channel::Channel;
532 use serial_test::serial;
533 use thirtyfour::ChromiumLikeCapabilities;
534
535 #[ctor::ctor]
536 fn init_test_tracing() {
537 tracing_subscriber::fmt().with_test_writer().try_init().ok();
538 }
539
540 #[tokio::test(flavor = "multi_thread")]
541 #[serial]
542 async fn clear_cache_and_download_new() -> anyhow::Result<()> {
543 let mgr = ChromeForTestingManager::new();
544 mgr.clear_cache().await?;
545 let selected = mgr
546 .resolve_version(VersionRequest::LatestIn(Channel::Stable))
547 .await?;
548 let loaded = mgr.download(selected).await?;
549
550 assert_that(loaded.chrome_executable).exists().is_a_file();
551 assert_that(loaded.chromedriver_executable)
552 .exists()
553 .is_a_file();
554 Ok(())
555 }
556
557 #[tokio::test(flavor = "multi_thread")]
558 #[serial]
559 async fn resolve_and_download_latest() -> anyhow::Result<()> {
560 let mgr = ChromeForTestingManager::new();
561 let selected = mgr.resolve_version(VersionRequest::Latest).await?;
562 let loaded = mgr.download(selected).await?;
563
564 assert_that(loaded.chrome_executable).exists().is_a_file();
565 assert_that(loaded.chromedriver_executable)
566 .exists()
567 .is_a_file();
568 Ok(())
569 }
570
571 #[tokio::test(flavor = "multi_thread")]
572 #[serial]
573 async fn resolve_and_download_latest_in_stable_channel() -> anyhow::Result<()> {
574 let mgr = ChromeForTestingManager::new();
575 let selected = mgr
576 .resolve_version(VersionRequest::LatestIn(Channel::Stable))
577 .await?;
578 let loaded = mgr.download(selected).await?;
579
580 assert_that(loaded.chrome_executable).exists().is_a_file();
581 assert_that(loaded.chromedriver_executable)
582 .exists()
583 .is_a_file();
584 Ok(())
585 }
586
587 #[tokio::test(flavor = "multi_thread")]
588 #[serial]
589 async fn launch_chromedriver_on_specific_port() -> anyhow::Result<()> {
590 let mgr = ChromeForTestingManager::new();
591 let selected = mgr.resolve_version(VersionRequest::Latest).await?;
592 let loaded = mgr.download(selected).await?;
593 let (_chromedriver, port) = mgr
594 .launch_chromedriver(&loaded, PortRequest::Specific(Port(3333)))
595 .await?;
596 assert_that(port).is_equal_to(Port(3333));
597 Ok(())
598 }
599
600 #[tokio::test(flavor = "multi_thread")]
601 #[serial]
602 async fn download_and_launch_chromedriver_on_random_port_and_prepare_thirtyfour_webdriver(
603 ) -> anyhow::Result<()> {
604 let mgr = ChromeForTestingManager::new();
605 let selected = mgr.resolve_version(VersionRequest::Latest).await?;
606 let loaded = mgr.download(selected).await?;
607 let (_chromedriver, port) = mgr.launch_chromedriver(&loaded, PortRequest::Any).await?;
608
609 let caps = mgr.prepare_caps(&loaded).await?;
610 let driver = thirtyfour::WebDriver::new(format!("http://localhost:{port}"), caps).await?;
611 driver.goto("https://www.google.com").await?;
612
613 let url = driver.current_url().await?;
614 assert_that(url).has_display_value("https://www.google.com/");
615
616 driver.quit().await?;
617
618 Ok(())
619 }
620
621 #[tokio::test(flavor = "multi_thread")]
622 #[serial]
623 #[cfg(feature = "thirtyfour")]
624 async fn latest_stable() -> anyhow::Result<()> {
625 let chromedriver = ChromeForTestingManager::latest_stable().await?;
626 let driver = chromedriver.new_webdriver().await?;
627
628 driver.goto("https://www.google.com").await?;
629
630 let url = driver.current_url().await?;
631 assert_that(url).has_display_value("https://www.google.com/");
632
633 driver.quit().await?;
634
635 Ok(())
636 }
637
638 #[tokio::test(flavor = "multi_thread")]
639 #[serial]
640 #[cfg(feature = "thirtyfour")]
641 async fn latest_stable_with_caps() -> anyhow::Result<()> {
642 let chromedriver = ChromeForTestingManager::latest_stable().await?;
643 let driver = chromedriver
644 .new_webdriver_with_caps(|caps| caps.unset_headless())
645 .await?;
646
647 driver.goto("https://www.google.com").await?;
648
649 let url = driver.current_url().await?;
650 assert_that(url).has_display_value("https://www.google.com/");
651
652 driver.quit().await?;
653
654 Ok(())
655 }
656}