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