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