chrome_for_testing_manager/
mgr.rs1use crate::cache::CacheDir;
2use crate::download;
3use crate::output::{DriverOutputInspectors, DriverOutputListener};
4use crate::port::{Port, PortRequest};
5use crate::version::{SelectedVersion, VersionRequest};
6use crate::{ChromeForTestingArtifact, ChromeForTestingManagerError};
7use chrome_for_testing::{KnownGoodVersions, LastKnownGoodVersions, Platform, Version};
8use rootcause::{Report, bail, option_ext::OptionExt, prelude::ResultExt, report};
9use std::path::PathBuf;
10use std::sync::Arc;
11use std::sync::atomic::AtomicU16;
12use std::time::Duration;
13use tokio::fs;
14use tokio::process::Command;
15use tokio_process_tools::{
16 BroadcastOutputStream, DEFAULT_MAX_BUFFERED_CHUNKS, DEFAULT_MAX_LINE_LENGTH,
17 DEFAULT_READ_CHUNK_SIZE, GracefulShutdown, LineOverflowBehavior, LineParsingOptions,
18 NumBytesExt, Process, ProcessHandle, ReliableWithBackpressure, ReplayEnabled,
19 WaitForLineResult,
20};
21
22#[derive(Debug)]
28pub struct LoadedChromePackage {
29 chrome_executable: PathBuf,
30 chromedriver_executable: PathBuf,
31}
32
33impl LoadedChromePackage {
34 #[must_use]
36 pub fn chrome_executable(&self) -> &std::path::Path {
37 &self.chrome_executable
38 }
39
40 #[must_use]
42 pub fn chromedriver_executable(&self) -> &std::path::Path {
43 &self.chromedriver_executable
44 }
45}
46
47#[derive(Debug)]
62pub struct ChromeForTestingManager {
63 client: reqwest::Client,
64 cache_dir: CacheDir,
65 platform: Platform,
66}
67
68impl ChromeForTestingManager {
69 pub fn new() -> Result<Self, Report<ChromeForTestingManagerError>> {
76 Ok(Self {
77 client: reqwest::Client::new(),
78 cache_dir: CacheDir::get_or_create()?,
79 platform: Platform::detect()
80 .context(ChromeForTestingManagerError::UnsupportedPlatform)?,
81 })
82 }
83
84 pub fn new_with_cache_dir(
93 cache_dir: PathBuf,
94 ) -> Result<Self, Report<ChromeForTestingManagerError>> {
95 Ok(Self {
96 client: reqwest::Client::new(),
97 cache_dir: CacheDir::create_at(cache_dir)?,
98 platform: Platform::detect()
99 .context(ChromeForTestingManagerError::UnsupportedPlatform)?,
100 })
101 }
102
103 fn version_dir(&self, version: Version) -> PathBuf {
104 self.cache_dir.path().join(version.to_string())
105 }
106
107 pub async fn clear_cache(&self) -> Result<(), Report<ChromeForTestingManagerError>> {
111 self.cache_dir.clear().await
112 }
113
114 pub async fn resolve_version(
124 &self,
125 version_selection: VersionRequest,
126 ) -> Result<SelectedVersion, Report<ChromeForTestingManagerError>> {
127 let selected = match &version_selection {
128 VersionRequest::Latest => {
129 let all = KnownGoodVersions::fetch(&self.client).await.context(
130 ChromeForTestingManagerError::RequestVersions {
131 version_request: version_selection.clone(),
132 },
133 )?;
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).await.context(
143 ChromeForTestingManagerError::RequestVersions {
144 version_request: version_selection.clone(),
145 },
146 )?;
147 all.channel(channel)
148 .cloned()
149 .map(|v| SelectedVersion::from((v, self.platform)))
150 }
151 VersionRequest::Fixed(version) => {
152 let all = KnownGoodVersions::fetch(&self.client).await.context(
153 ChromeForTestingManagerError::RequestVersions {
154 version_request: version_selection.clone(),
155 },
156 )?;
157 all.versions
158 .into_iter()
159 .find(|v| v.version == *version)
160 .map(|v| SelectedVersion::from((v, self.platform)))
161 }
162 };
163
164 let selected = selected.context(ChromeForTestingManagerError::NoMatchingVersion {
165 version_request: version_selection,
166 })?;
167
168 Ok(selected)
169 }
170
171 pub async fn download(
180 &self,
181 selected: SelectedVersion,
182 ) -> Result<LoadedChromePackage, Report<ChromeForTestingManagerError>> {
183 let Some(selected_chrome_download) = selected.chrome.clone() else {
184 bail!(ChromeForTestingManagerError::NoChromeDownload {
185 version: selected.version,
186 platform: self.platform,
187 });
188 };
189
190 let Some(selected_chromedriver_download) = selected.chromedriver.clone() else {
191 bail!(ChromeForTestingManagerError::NoChromedriverDownload {
192 version: selected.version,
193 platform: 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.context(
201 ChromeForTestingManagerError::CreatePlatformDir {
202 platform_dir: platform_dir.clone(),
203 },
204 )?;
205
206 let chrome_executable = platform_dir.join(self.platform.chrome_executable_path());
207 let chromedriver_executable =
208 platform_dir.join(self.platform.chromedriver_executable_path());
209
210 let channel_label = selected
211 .channel
212 .map_or_else(String::new, |ch| ch.to_string());
213
214 let is_chrome_downloaded = chrome_executable.exists() && chrome_executable.is_file();
216 if is_chrome_downloaded {
217 tracing::info!(
218 "Chrome {} already installed at {chrome_executable:?}...",
219 selected.version
220 );
221 } else {
222 tracing::info!("Installing {channel_label} Chrome {}", selected.version);
223 download::download_zip(
224 &self.client,
225 &selected_chrome_download.url,
226 &platform_dir,
227 &platform_dir,
228 ChromeForTestingArtifact::Chrome,
229 )
230 .await?;
231 }
232
233 let is_chromedriver_downloaded =
235 chromedriver_executable.exists() && chromedriver_executable.is_file();
236 if is_chromedriver_downloaded {
237 tracing::info!(
238 "Chromedriver {} already installed at {chromedriver_executable:?}...",
239 selected.version
240 );
241 } else {
242 tracing::info!(
243 "Installing {channel_label} Chromedriver {}",
244 selected.version
245 );
246 download::download_zip(
247 &self.client,
248 &selected_chromedriver_download.url,
249 &platform_dir,
250 &platform_dir,
251 ChromeForTestingArtifact::ChromeDriver,
252 )
253 .await?;
254 }
255
256 Ok(LoadedChromePackage {
257 chrome_executable,
258 chromedriver_executable,
259 })
260 }
261
262 pub async fn launch_chromedriver(
284 &self,
285 loaded: &LoadedChromePackage,
286 port: PortRequest,
287 output_listener: Option<DriverOutputListener>,
288 shutdown: GracefulShutdown,
289 ) -> Result<
290 (
291 ProcessHandle<BroadcastOutputStream<ReliableWithBackpressure, ReplayEnabled>>,
292 Port,
293 DriverOutputInspectors,
294 ),
295 Report<ChromeForTestingManagerError>,
296 > {
297 let chromedriver_exe_path_str = loaded
298 .chromedriver_executable
299 .to_str()
300 .expect("valid unicode");
301
302 tracing::info!(
303 "Launching chromedriver... {:?}",
304 loaded.chromedriver_executable
305 );
306 let mut command = Command::new(chromedriver_exe_path_str);
307 match port {
308 PortRequest::Any => {}
309 PortRequest::Specific(Port(port)) => {
310 command.arg(format!("--port={port}"));
311 }
312 }
313 let loglevel = chrome_for_testing::chromedriver::LogLevel::Info;
314 command.arg(format!("--log-level={loglevel}"));
315
316 self.apply_chromedriver_creation_flags(&mut command);
317
318 let mut chromedriver_process = Process::new(command)
319 .name("chromedriver")
320 .stdout_and_stderr(|stream| {
321 stream
322 .broadcast()
323 .reliable_with_backpressure()
324 .replay_last_bytes(1.megabytes())
325 .read_chunk_size(DEFAULT_READ_CHUNK_SIZE)
326 .max_buffered_chunks(DEFAULT_MAX_BUFFERED_CHUNKS)
327 })
328 .spawn()
329 .context(ChromeForTestingManagerError::SpawnChromedriver {
330 path: loaded.chromedriver_executable.clone(),
331 })?;
332
333 let output_inspectors =
334 DriverOutputInspectors::start(&chromedriver_process, output_listener);
335
336 tracing::info!("Waiting for chromedriver to start...");
337 let started_on_port = Arc::new(AtomicU16::new(0));
338 let started_on_port_clone = started_on_port.clone();
339 let startup_result = chromedriver_process
340 .stdout()
341 .wait_for_line(
342 Duration::from_secs(10),
343 move |line| {
344 if line.contains("started successfully on port") {
345 let Some(port) = line
346 .trim()
347 .trim_matches('"')
348 .trim_end_matches('.')
349 .split(' ')
350 .next_back()
351 .and_then(|s| s.parse::<u16>().ok())
352 else {
353 tracing::error!(
354 "Failed to parse port from chromedriver output: {line:?}"
355 );
356 return false;
357 };
358 started_on_port_clone.store(port, std::sync::atomic::Ordering::Release);
359 true
360 } else {
361 false
362 }
363 },
364 LineParsingOptions::builder()
365 .max_line_length(DEFAULT_MAX_LINE_LENGTH)
366 .overflow_behavior(LineOverflowBehavior::DropAdditionalData)
367 .buffer_compaction_threshold(None)
368 .build(),
369 )
370 .await
371 .context(ChromeForTestingManagerError::WaitForChromedriverStartup {
372 path: loaded.chromedriver_executable.clone(),
373 })?;
374 match startup_result {
375 WaitForLineResult::Matched => {}
376 WaitForLineResult::StreamClosed | WaitForLineResult::Timeout => {
377 if let Err(err) = chromedriver_process.terminate(shutdown).await {
378 tracing::warn!(
379 error = %err,
380 "failed to terminate chromedriver after startup failure"
381 );
382 }
383
384 return Err(report!(
385 ChromeForTestingManagerError::WaitForChromedriverStartup {
386 path: loaded.chromedriver_executable.clone(),
387 }
388 ));
389 }
390 }
391
392 chromedriver_process.must_not_be_terminated();
396
397 Ok((
398 chromedriver_process,
399 Port(Arc::into_inner(started_on_port).unwrap().into_inner()),
400 output_inspectors,
401 ))
402 }
403
404 #[cfg(target_os = "windows")]
405 fn apply_chromedriver_creation_flags<'a>(&self, command: &'a mut Command) -> &'a mut Command {
406 use std::os::windows::process::CommandExt;
407
408 const CREATE_NO_WINDOW: u32 = 0x08000000;
415
416 command.creation_flags(CREATE_NO_WINDOW)
417 }
418
419 #[cfg(not(target_os = "windows"))]
420 #[allow(clippy::unused_self)] fn apply_chromedriver_creation_flags<'a>(&self, command: &'a mut Command) -> &'a mut Command {
422 command
423 }
424
425 #[cfg(feature = "thirtyfour")]
439 #[allow(clippy::unused_self)] pub fn prepare_caps(
441 &self,
442 loaded: &LoadedChromePackage,
443 ) -> Result<thirtyfour::ChromeCapabilities, Report<ChromeForTestingManagerError>> {
444 use thirtyfour::ChromiumLikeCapabilities;
445
446 tracing::info!(
447 "Registering {:?} in capabilities.",
448 loaded.chrome_executable
449 );
450 let mut caps = thirtyfour::ChromeCapabilities::new();
451 caps.set_headless()
452 .context(ChromeForTestingManagerError::PrepareChromeCapabilities {
453 chrome_executable: loaded.chrome_executable.clone(),
454 })?;
455 caps.set_binary(loaded.chrome_executable.to_str().expect("valid unicode"))
456 .context(ChromeForTestingManagerError::PrepareChromeCapabilities {
457 chrome_executable: loaded.chrome_executable.clone(),
458 })?;
459 Ok(caps)
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use crate::chromedriver::default_graceful_shutdown;
466 use crate::mgr::ChromeForTestingManager;
467 use crate::port::Port;
468 use crate::port::PortRequest;
469 use crate::{Channel, Version, VersionRequest};
470 use assertr::prelude::*;
471 use rootcause::Report;
472 use serial_test::serial;
473
474 #[ctor::ctor(unsafe)]
475 fn init_test_tracing() {
476 tracing_subscriber::fmt().with_test_writer().try_init().ok();
477 }
478
479 #[tokio::test(flavor = "multi_thread")]
480 #[serial]
481 async fn clear_cache_and_download_new() -> Result<(), Report> {
482 let mgr = ChromeForTestingManager::new()?;
483 mgr.clear_cache().await?;
484 let selected = mgr
485 .resolve_version(VersionRequest::LatestIn(Channel::Stable))
486 .await?;
487 let loaded = mgr.download(selected).await?;
488
489 assert_that!(loaded.chrome_executable).exists().is_a_file();
490 assert_that!(loaded.chromedriver_executable)
491 .exists()
492 .is_a_file();
493 Ok(())
494 }
495
496 #[tokio::test(flavor = "multi_thread")]
497 #[serial]
498 async fn resolve_and_download_latest() -> Result<(), Report> {
499 let mgr = ChromeForTestingManager::new()?;
500 let selected = mgr.resolve_version(VersionRequest::Latest).await?;
501 let loaded = mgr.download(selected).await?;
502
503 assert_that!(loaded.chrome_executable).exists().is_a_file();
504 assert_that!(loaded.chromedriver_executable)
505 .exists()
506 .is_a_file();
507 Ok(())
508 }
509
510 #[tokio::test(flavor = "multi_thread")]
511 #[serial]
512 async fn resolve_and_download_latest_in_stable_channel() -> Result<(), Report> {
513 let mgr = ChromeForTestingManager::new()?;
514 let selected = mgr
515 .resolve_version(VersionRequest::LatestIn(Channel::Stable))
516 .await?;
517 let loaded = mgr.download(selected).await?;
518
519 assert_that!(loaded.chrome_executable).exists().is_a_file();
520 assert_that!(loaded.chromedriver_executable)
521 .exists()
522 .is_a_file();
523 Ok(())
524 }
525
526 #[tokio::test(flavor = "multi_thread")]
527 #[serial]
528 async fn resolve_and_download_specific() -> Result<(), Report> {
529 let mgr = ChromeForTestingManager::new()?;
530 let selected = mgr
531 .resolve_version(VersionRequest::Fixed(Version {
532 major: 135,
533 minor: 0,
534 patch: 7019,
535 build: 0,
536 }))
537 .await?;
538 let loaded = mgr.download(selected).await?;
539
540 assert_that!(loaded.chrome_executable).exists().is_a_file();
541 assert_that!(loaded.chromedriver_executable)
542 .exists()
543 .is_a_file();
544 Ok(())
545 }
546
547 #[tokio::test(flavor = "multi_thread")]
548 #[serial]
549 async fn launch_chromedriver_on_specific_port() -> Result<(), Report> {
550 let mgr = ChromeForTestingManager::new()?;
551 let selected = mgr.resolve_version(VersionRequest::Latest).await?;
552 let loaded = mgr.download(selected).await?;
553 let (chromedriver, port, _output_inspectors) = mgr
554 .launch_chromedriver(
555 &loaded,
556 PortRequest::Specific(Port(3333)),
557 None,
558 default_graceful_shutdown(),
559 )
560 .await?;
561 let _chromedriver = chromedriver.terminate_on_drop(default_graceful_shutdown());
562 assert_that!(port).is_equal_to(Port(3333));
563 Ok(())
564 }
565
566 #[tokio::test(flavor = "multi_thread")]
567 #[serial]
568 async fn download_and_launch_chromedriver_on_random_port_and_prepare_thirtyfour_webdriver()
569 -> Result<(), Report> {
570 let mgr = ChromeForTestingManager::new()?;
571 let selected = mgr.resolve_version(VersionRequest::Latest).await?;
572 let loaded = mgr.download(selected).await?;
573 let (chromedriver, port, _output_inspectors) = mgr
574 .launch_chromedriver(&loaded, PortRequest::Any, None, default_graceful_shutdown())
575 .await?;
576 let _chromedriver = chromedriver.terminate_on_drop(default_graceful_shutdown());
577
578 let caps = mgr.prepare_caps(&loaded)?;
579 let driver = thirtyfour::WebDriver::new(format!("http://localhost:{port}"), caps).await?;
580 driver.goto("https://www.google.com").await?;
581
582 let url = driver.current_url().await?;
583 assert_that!(url).has_display_value("https://www.google.com/");
584
585 driver.quit().await?;
586
587 Ok(())
588 }
589}