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