1use 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};
9#[cfg(feature = "thirtyfour")]
10use std::collections::VecDeque;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13#[cfg(feature = "thirtyfour")]
14use std::sync::Mutex;
15use std::sync::atomic::AtomicU16;
16use std::time::Duration;
17use tokio::fs;
18use tokio::process::Command;
19use tokio_process_tools::{
20 BroadcastOutputStream, DEFAULT_MAX_BUFFERED_CHUNKS, DEFAULT_MAX_LINE_LENGTH,
21 DEFAULT_READ_CHUNK_SIZE, GracefulShutdown, LineOverflowBehavior, LineParsingOptions,
22 NumBytesExt, Process, ProcessHandle, ReliableWithBackpressure, ReplayEnabled,
23 WaitForLineResult,
24};
25
26type ManagedProcessOutput = BroadcastOutputStream<ReliableWithBackpressure, ReplayEnabled>;
27type ManagedProcessHandle = ProcessHandle<ManagedProcessOutput>;
28#[cfg(feature = "thirtyfour")]
29type RecentBrowserOutput = Arc<Mutex<VecDeque<String>>>;
30
31#[cfg(feature = "thirtyfour")]
32const BROWSER_STARTUP_OUTPUT_LINES: usize = 80;
33#[cfg(feature = "thirtyfour")]
34const DEFAULT_HEADLESS_SHELL_REMOTE_DEBUGGING_ARG: &str = "--remote-debugging-port=0";
35
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
38#[non_exhaustive]
39pub enum ChromeBinary {
40 #[default]
42 Chrome,
43
44 ChromeHeadlessShell,
46}
47
48impl ChromeBinary {
49 const fn label(self) -> &'static str {
50 match self {
51 Self::Chrome => "Chrome",
52 Self::ChromeHeadlessShell => "Chrome Headless Shell",
53 }
54 }
55
56 const fn artifact(self) -> ChromeForTestingArtifact {
57 match self {
58 Self::Chrome => ChromeForTestingArtifact::Chrome,
59 Self::ChromeHeadlessShell => ChromeForTestingArtifact::ChromeHeadlessShell,
60 }
61 }
62
63 fn executable_path(self, platform: Platform) -> &'static Path {
64 match self {
65 Self::Chrome => platform.chrome_executable_path(),
66 Self::ChromeHeadlessShell => platform.chrome_headless_shell_executable_path(),
67 }
68 }
69}
70
71#[cfg(feature = "thirtyfour")]
72#[derive(Debug)]
73pub(crate) struct HeadlessShellSession {
74 process: tokio_process_tools::TerminateOnDrop<ManagedProcessOutput>,
75 debugger_address: String,
76 shutdown: GracefulShutdown,
77}
78
79#[cfg(feature = "thirtyfour")]
80impl HeadlessShellSession {
81 pub(crate) fn debugger_address(&self) -> &str {
82 &self.debugger_address
83 }
84
85 pub(crate) async fn terminate(
86 mut self,
87 ) -> Result<std::process::ExitStatus, Report<ChromeForTestingManagerError>> {
88 self.process.terminate(self.shutdown.clone()).await.context(
89 ChromeForTestingManagerError::TerminateBrowser {
90 debugger_address: self.debugger_address.clone(),
91 },
92 )
93 }
94}
95
96#[derive(Debug, Clone)]
101pub struct LoadedChromePackage {
102 chrome_executable: PathBuf,
103 chromedriver_executable: PathBuf,
104}
105
106impl LoadedChromePackage {
107 fn new(chrome_executable: PathBuf, chromedriver_executable: PathBuf) -> Self {
108 Self {
109 chrome_executable,
110 chromedriver_executable,
111 }
112 }
113
114 #[must_use]
116 pub fn chrome_executable(&self) -> &Path {
117 &self.chrome_executable
118 }
119
120 #[must_use]
122 pub fn chromedriver_executable(&self) -> &Path {
123 &self.chromedriver_executable
124 }
125}
126
127#[derive(Debug, Clone)]
132pub struct LoadedChromeHeadlessShellPackage {
133 chrome_headless_shell_executable: PathBuf,
134 chromedriver_executable: PathBuf,
135}
136
137impl LoadedChromeHeadlessShellPackage {
138 fn new(chrome_headless_shell_executable: PathBuf, chromedriver_executable: PathBuf) -> Self {
139 Self {
140 chrome_headless_shell_executable,
141 chromedriver_executable,
142 }
143 }
144
145 #[must_use]
147 pub fn chrome_headless_shell_executable(&self) -> &Path {
148 &self.chrome_headless_shell_executable
149 }
150
151 #[must_use]
153 pub fn chromedriver_executable(&self) -> &Path {
154 &self.chromedriver_executable
155 }
156}
157
158#[derive(Debug, Clone)]
164#[non_exhaustive]
165pub enum LoadedBrowserPackage {
166 Chrome(LoadedChromePackage),
168
169 ChromeHeadlessShell(LoadedChromeHeadlessShellPackage),
171}
172
173impl LoadedBrowserPackage {
174 fn new(
175 chrome_binary: ChromeBinary,
176 browser_executable: PathBuf,
177 chromedriver_executable: PathBuf,
178 ) -> Self {
179 match chrome_binary {
180 ChromeBinary::Chrome => Self::Chrome(LoadedChromePackage::new(
181 browser_executable,
182 chromedriver_executable,
183 )),
184 ChromeBinary::ChromeHeadlessShell => Self::ChromeHeadlessShell(
185 LoadedChromeHeadlessShellPackage::new(browser_executable, chromedriver_executable),
186 ),
187 }
188 }
189
190 #[must_use]
192 pub const fn chrome_binary(&self) -> ChromeBinary {
193 match self {
194 Self::Chrome(_) => ChromeBinary::Chrome,
195 Self::ChromeHeadlessShell(_) => ChromeBinary::ChromeHeadlessShell,
196 }
197 }
198
199 #[must_use]
201 pub fn browser_executable(&self) -> &Path {
202 match self {
203 Self::Chrome(package) => package.chrome_executable(),
204 Self::ChromeHeadlessShell(package) => package.chrome_headless_shell_executable(),
205 }
206 }
207
208 #[must_use]
210 pub fn chromedriver_executable(&self) -> &Path {
211 match self {
212 Self::Chrome(package) => package.chromedriver_executable(),
213 Self::ChromeHeadlessShell(package) => package.chromedriver_executable(),
214 }
215 }
216}
217
218#[derive(Debug, Clone, Copy)]
219struct RequestedChromeBinaries {
220 chrome: bool,
221 chrome_headless_shell: bool,
222}
223
224impl RequestedChromeBinaries {
225 const fn single(chrome_binary: ChromeBinary) -> Self {
226 match chrome_binary {
227 ChromeBinary::Chrome => Self {
228 chrome: true,
229 chrome_headless_shell: false,
230 },
231 ChromeBinary::ChromeHeadlessShell => Self {
232 chrome: false,
233 chrome_headless_shell: true,
234 },
235 }
236 }
237
238 fn from_slice(
239 chrome_binaries: &[ChromeBinary],
240 ) -> Result<Self, Report<ChromeForTestingManagerError>> {
241 if chrome_binaries.is_empty() {
242 bail!(ChromeForTestingManagerError::EmptyChromeBinaryDownloadRequest);
243 }
244
245 Ok(Self {
246 chrome: chrome_binaries.contains(&ChromeBinary::Chrome),
247 chrome_headless_shell: chrome_binaries.contains(&ChromeBinary::ChromeHeadlessShell),
248 })
249 }
250}
251
252#[derive(Debug)]
253struct DownloadedBrowserArtifacts {
254 chromedriver: PathBuf,
255 chrome: Option<PathBuf>,
256 chrome_headless_shell: Option<PathBuf>,
257}
258
259impl DownloadedBrowserArtifacts {
260 fn package_for(
261 &self,
262 chrome_binary: ChromeBinary,
263 version: Version,
264 platform: Platform,
265 ) -> Result<LoadedBrowserPackage, Report<ChromeForTestingManagerError>> {
266 let browser_executable = self
267 .browser_executable(chrome_binary, version, platform)?
268 .clone();
269
270 Ok(LoadedBrowserPackage::new(
271 chrome_binary,
272 browser_executable,
273 self.chromedriver.clone(),
274 ))
275 }
276
277 fn browser_executable(
278 &self,
279 chrome_binary: ChromeBinary,
280 version: Version,
281 platform: Platform,
282 ) -> Result<&PathBuf, Report<ChromeForTestingManagerError>> {
283 match chrome_binary {
284 ChromeBinary::Chrome => self.chrome.as_ref().ok_or_else(|| {
285 report!(ChromeForTestingManagerError::NoChromeDownload { version, platform })
286 }),
287 ChromeBinary::ChromeHeadlessShell => {
288 self.chrome_headless_shell.as_ref().ok_or_else(|| {
289 report!(
290 ChromeForTestingManagerError::NoChromeHeadlessShellDownload {
291 version,
292 platform,
293 }
294 )
295 })
296 }
297 }
298 }
299}
300
301#[derive(Debug)]
316pub struct ChromeForTestingManager {
317 client: reqwest::Client,
318 cache_dir: CacheDir,
319 platform: Platform,
320}
321
322impl ChromeForTestingManager {
323 pub fn new() -> Result<Self, Report<ChromeForTestingManagerError>> {
330 Ok(Self {
331 client: reqwest::Client::new(),
332 cache_dir: CacheDir::get_or_create()?,
333 platform: Platform::detect().map_err(unsupported_platform_error)?,
334 })
335 }
336
337 pub fn new_with_cache_dir(
346 cache_dir: PathBuf,
347 ) -> Result<Self, Report<ChromeForTestingManagerError>> {
348 Ok(Self {
349 client: reqwest::Client::new(),
350 cache_dir: CacheDir::create_at(cache_dir)?,
351 platform: Platform::detect().map_err(unsupported_platform_error)?,
352 })
353 }
354
355 fn version_dir(&self, version: Version) -> PathBuf {
356 self.cache_dir.path().join(version.to_string())
357 }
358
359 fn platform_dir(&self, version: Version) -> PathBuf {
360 self.version_dir(version).join(self.platform.to_string())
361 }
362
363 async fn ensure_platform_dir(
364 &self,
365 version: Version,
366 ) -> Result<PathBuf, Report<ChromeForTestingManagerError>> {
367 let platform_dir = self.platform_dir(version);
368 fs::create_dir_all(&platform_dir).await.context(
369 ChromeForTestingManagerError::CreatePlatformDir {
370 platform_dir: platform_dir.clone(),
371 },
372 )?;
373 Ok(platform_dir)
374 }
375
376 pub async fn clear_cache(&self) -> Result<(), Report<ChromeForTestingManagerError>> {
380 self.cache_dir.clear().await
381 }
382
383 pub async fn resolve_version(
393 &self,
394 version_selection: VersionRequest,
395 ) -> Result<SelectedVersion, Report<ChromeForTestingManagerError>> {
396 let selected = match &version_selection {
397 VersionRequest::Latest => {
398 let all = KnownGoodVersions::fetch(&self.client)
399 .await
400 .map_err(|err| request_versions_error(err, &version_selection))?;
401 all.versions
402 .iter()
403 .filter(|v| v.downloads.chromedriver.is_some())
404 .max_by_key(|v| v.version)
405 .cloned()
406 .map(|v| SelectedVersion::from((v, self.platform)))
407 }
408 VersionRequest::LatestIn(channel) => {
409 let all = LastKnownGoodVersions::fetch(&self.client)
410 .await
411 .map_err(|err| request_versions_error(err, &version_selection))?;
412 all.channel(channel)
413 .cloned()
414 .map(|v| SelectedVersion::from((v, self.platform)))
415 }
416 VersionRequest::Fixed(version) => {
417 let all = KnownGoodVersions::fetch(&self.client)
418 .await
419 .map_err(|err| request_versions_error(err, &version_selection))?;
420 all.versions
421 .into_iter()
422 .find(|v| v.version == *version)
423 .map(|v| SelectedVersion::from((v, self.platform)))
424 }
425 };
426
427 let selected = selected.context(ChromeForTestingManagerError::NoMatchingVersion {
428 version_request: version_selection,
429 })?;
430
431 Ok(selected)
432 }
433
434 pub async fn download(
446 &self,
447 selected: &SelectedVersion,
448 chrome_binaries: &[ChromeBinary],
449 ) -> Result<Vec<LoadedBrowserPackage>, Report<ChromeForTestingManagerError>> {
450 let requested = RequestedChromeBinaries::from_slice(chrome_binaries)?;
451 let artifacts = self
452 .download_requested_artifacts(selected, requested)
453 .await?;
454 let mut loaded = Vec::with_capacity(chrome_binaries.len());
455 for chrome_binary in chrome_binaries {
456 loaded.push(artifacts.package_for(*chrome_binary, selected.version, self.platform)?);
457 }
458
459 Ok(loaded)
460 }
461
462 pub(crate) async fn download_one(
463 &self,
464 selected: &SelectedVersion,
465 chrome_binary: ChromeBinary,
466 ) -> Result<LoadedBrowserPackage, Report<ChromeForTestingManagerError>> {
467 let artifacts = self
468 .download_requested_artifacts(selected, RequestedChromeBinaries::single(chrome_binary))
469 .await?;
470 artifacts.package_for(chrome_binary, selected.version, self.platform)
471 }
472
473 async fn download_requested_artifacts(
474 &self,
475 selected: &SelectedVersion,
476 requested: RequestedChromeBinaries,
477 ) -> Result<DownloadedBrowserArtifacts, Report<ChromeForTestingManagerError>> {
478 let platform_dir = self.ensure_platform_dir(selected.version).await?;
479
480 let (chromedriver, chrome, chrome_headless_shell) = tokio::try_join!(
481 self.download_chromedriver(selected, &platform_dir),
482 self.download_requested_browser(
483 selected,
484 &platform_dir,
485 ChromeBinary::Chrome,
486 requested.chrome,
487 ),
488 self.download_requested_browser(
489 selected,
490 &platform_dir,
491 ChromeBinary::ChromeHeadlessShell,
492 requested.chrome_headless_shell,
493 ),
494 )?;
495
496 Ok(DownloadedBrowserArtifacts {
497 chromedriver,
498 chrome,
499 chrome_headless_shell,
500 })
501 }
502
503 async fn download_requested_browser(
504 &self,
505 selected: &SelectedVersion,
506 platform_dir: &Path,
507 chrome_binary: ChromeBinary,
508 is_requested: bool,
509 ) -> Result<Option<PathBuf>, Report<ChromeForTestingManagerError>> {
510 if is_requested {
511 self.download_browser(selected, platform_dir, chrome_binary)
512 .await
513 .map(Some)
514 } else {
515 Ok(None)
516 }
517 }
518
519 async fn download_browser(
520 &self,
521 selected: &SelectedVersion,
522 platform_dir: &Path,
523 chrome_binary: ChromeBinary,
524 ) -> Result<PathBuf, Report<ChromeForTestingManagerError>> {
525 let selected_chrome_download = match chrome_binary {
526 ChromeBinary::Chrome => selected.chrome.clone().ok_or_else(|| {
527 report!(ChromeForTestingManagerError::NoChromeDownload {
528 version: selected.version,
529 platform: self.platform,
530 })
531 })?,
532 ChromeBinary::ChromeHeadlessShell => {
533 selected.chrome_headless_shell.clone().ok_or_else(|| {
534 report!(
535 ChromeForTestingManagerError::NoChromeHeadlessShellDownload {
536 version: selected.version,
537 platform: self.platform,
538 }
539 )
540 })?
541 }
542 };
543
544 let chrome_executable = platform_dir.join(chrome_binary.executable_path(self.platform));
545 self.ensure_artifact_downloaded(
546 selected,
547 platform_dir,
548 &chrome_executable,
549 chrome_binary.artifact(),
550 chrome_binary.label(),
551 &selected_chrome_download.url,
552 )
553 .await?;
554
555 Ok(chrome_executable)
556 }
557
558 async fn download_chromedriver(
559 &self,
560 selected: &SelectedVersion,
561 platform_dir: &Path,
562 ) -> Result<PathBuf, Report<ChromeForTestingManagerError>> {
563 let Some(selected_chromedriver_download) = selected.chromedriver.clone() else {
564 bail!(ChromeForTestingManagerError::NoChromedriverDownload {
565 version: selected.version,
566 platform: self.platform,
567 });
568 };
569
570 let chromedriver_executable =
571 platform_dir.join(self.platform.chromedriver_executable_path());
572 self.ensure_artifact_downloaded(
573 selected,
574 platform_dir,
575 &chromedriver_executable,
576 ChromeForTestingArtifact::ChromeDriver,
577 "Chromedriver",
578 &selected_chromedriver_download.url,
579 )
580 .await?;
581
582 Ok(chromedriver_executable)
583 }
584
585 async fn ensure_artifact_downloaded(
586 &self,
587 selected: &SelectedVersion,
588 platform_dir: &Path,
589 executable: &Path,
590 artifact: ChromeForTestingArtifact,
591 label: &str,
592 url: &str,
593 ) -> Result<(), Report<ChromeForTestingManagerError>> {
594 let channel_label = selected
595 .channel
596 .as_ref()
597 .map_or_else(String::new, ToString::to_string);
598
599 if executable.exists() && executable.is_file() {
600 tracing::info!(
601 "{label} {} already installed at {executable:?}...",
602 selected.version
603 );
604 } else {
605 tracing::info!("Installing {channel_label} {label} {}", selected.version);
606 download::download_zip(&self.client, url, platform_dir, platform_dir, artifact).await?;
607 }
608
609 Ok(())
610 }
611
612 pub async fn launch_chromedriver(
634 &self,
635 loaded: &LoadedBrowserPackage,
636 port: PortRequest,
637 output_listener: Option<DriverOutputListener>,
638 shutdown: GracefulShutdown,
639 ) -> Result<
640 (ManagedProcessHandle, Port, DriverOutputInspectors),
641 Report<ChromeForTestingManagerError>,
642 > {
643 let chromedriver_executable = loaded.chromedriver_executable();
644 let chromedriver_exe_path_str = chromedriver_executable.to_str().expect("valid unicode");
645
646 tracing::info!("Launching chromedriver... {chromedriver_executable:?}");
647 let mut command = Command::new(chromedriver_exe_path_str);
648 match port {
649 PortRequest::Any => {}
650 PortRequest::Specific(port) => {
651 command.arg(format!("--port={}", port.as_u16()));
652 }
653 }
654 let loglevel = chrome_for_testing::chromedriver::LogLevel::Info;
655 command.arg(format!("--log-level={loglevel}"));
656
657 self.apply_chromedriver_creation_flags(&mut command);
658
659 let mut chromedriver_process = Process::new(command)
660 .name("chromedriver")
661 .stdout_and_stderr(|stream| {
662 stream
663 .broadcast()
664 .reliable_with_backpressure()
665 .replay_last_bytes(1.megabytes())
666 .read_chunk_size(DEFAULT_READ_CHUNK_SIZE)
667 .max_buffered_chunks(DEFAULT_MAX_BUFFERED_CHUNKS)
668 })
669 .spawn()
670 .context(ChromeForTestingManagerError::SpawnChromedriver {
671 path: chromedriver_executable.to_path_buf(),
672 })?;
673
674 let output_inspectors =
675 DriverOutputInspectors::start(&chromedriver_process, output_listener);
676
677 tracing::info!("Waiting for chromedriver to start...");
678 let started_on_port = Arc::new(AtomicU16::new(0));
679 let started_on_port_clone = started_on_port.clone();
680 let startup_result = chromedriver_process
681 .stdout()
682 .wait_for_line(
683 Duration::from_secs(10),
684 move |line| {
685 if line.contains("started successfully on port") {
686 let Some(port) = line
687 .trim()
688 .trim_matches('"')
689 .trim_end_matches('.')
690 .split(' ')
691 .next_back()
692 .and_then(|s| s.parse::<u16>().ok())
693 else {
694 tracing::error!(
695 "Failed to parse port from chromedriver output: {line:?}"
696 );
697 return false;
698 };
699 started_on_port_clone.store(port, std::sync::atomic::Ordering::Release);
700 true
701 } else {
702 false
703 }
704 },
705 LineParsingOptions::builder()
706 .max_line_length(DEFAULT_MAX_LINE_LENGTH)
707 .overflow_behavior(LineOverflowBehavior::DropAdditionalData)
708 .buffer_compaction_threshold(None)
709 .build(),
710 )
711 .await
712 .context(ChromeForTestingManagerError::WaitForChromedriverStartup {
713 path: chromedriver_executable.to_path_buf(),
714 })?;
715 match startup_result {
716 WaitForLineResult::Matched => {}
717 WaitForLineResult::StreamClosed | WaitForLineResult::Timeout => {
718 if let Err(err) = chromedriver_process.terminate(shutdown).await {
719 tracing::warn!(
720 error = %err,
721 "failed to terminate chromedriver after startup failure"
722 );
723 }
724
725 return Err(report!(
726 ChromeForTestingManagerError::WaitForChromedriverStartup {
727 path: chromedriver_executable.to_path_buf(),
728 }
729 ));
730 }
731 }
732
733 chromedriver_process.must_not_be_terminated();
737
738 Ok((
739 chromedriver_process,
740 Port::new(Arc::into_inner(started_on_port).unwrap().into_inner()),
741 output_inspectors,
742 ))
743 }
744
745 #[cfg(feature = "thirtyfour")]
751 pub(crate) async fn launch_headless_shell_session(
752 &self,
753 loaded: &LoadedChromeHeadlessShellPackage,
754 caps: &thirtyfour::ChromeCapabilities,
755 shutdown: GracefulShutdown,
756 ) -> Result<HeadlessShellSession, Report<ChromeForTestingManagerError>> {
757 let chrome_headless_shell_executable =
758 loaded.chrome_headless_shell_executable().to_path_buf();
759 let chrome_headless_shell_executable_str = chrome_headless_shell_executable
760 .to_str()
761 .expect("valid unicode");
762 tracing::info!("Launching Chrome Headless Shell... {chrome_headless_shell_executable:?}");
763
764 let mut command = Command::new(chrome_headless_shell_executable_str);
765 command.args(headless_shell_launch_args(caps)?);
766
767 let mut browser_process = Process::new(command)
768 .name("chrome-headless-shell")
769 .stdout_and_stderr(|stream| {
770 stream
771 .broadcast()
772 .reliable_with_backpressure()
773 .replay_last_bytes(1.megabytes())
774 .read_chunk_size(DEFAULT_READ_CHUNK_SIZE)
775 .max_buffered_chunks(DEFAULT_MAX_BUFFERED_CHUNKS)
776 })
777 .spawn()
778 .context(ChromeForTestingManagerError::SpawnBrowser {
779 path: chrome_headless_shell_executable.clone(),
780 })?;
781
782 let debugger_address = match wait_for_devtools_address(
783 &mut browser_process,
784 &chrome_headless_shell_executable,
785 )
786 .await
787 {
788 Ok(debugger_address) => debugger_address,
789 Err(err) => {
790 terminate_browser_after_startup_failure(&mut browser_process, shutdown).await;
791 return Err(err);
792 }
793 };
794
795 if let Err(err) = self.create_initial_browser_page(&debugger_address).await {
796 terminate_browser_after_startup_failure(&mut browser_process, shutdown).await;
797 return Err(err);
798 }
799
800 Ok(HeadlessShellSession {
801 process: browser_process.terminate_on_drop(shutdown.clone()),
802 debugger_address,
803 shutdown,
804 })
805 }
806
807 #[cfg(target_os = "windows")]
808 #[expect(clippy::unused_self)]
809 fn apply_chromedriver_creation_flags<'a>(&self, command: &'a mut Command) -> &'a mut Command {
810 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
817
818 command.creation_flags(CREATE_NO_WINDOW)
819 }
820
821 #[cfg(not(target_os = "windows"))]
822 #[expect(clippy::unused_self)]
823 fn apply_chromedriver_creation_flags<'a>(&self, command: &'a mut Command) -> &'a mut Command {
824 command
825 }
826
827 #[cfg(feature = "thirtyfour")]
841 #[allow(clippy::unused_self)] pub fn prepare_caps(
843 &self,
844 loaded: &LoadedBrowserPackage,
845 ) -> Result<thirtyfour::ChromeCapabilities, Report<ChromeForTestingManagerError>> {
846 use thirtyfour::ChromiumLikeCapabilities;
847
848 let browser_executable = loaded.browser_executable();
849 tracing::debug!("Registering {browser_executable:?} in capabilities.");
850 let mut caps = thirtyfour::ChromeCapabilities::new();
851 caps.set_headless()
852 .context(ChromeForTestingManagerError::PrepareChromeCapabilities {
853 browser_executable: browser_executable.to_path_buf(),
854 })?;
855 caps.set_binary(browser_executable.to_str().expect("valid unicode"))
856 .context(ChromeForTestingManagerError::PrepareChromeCapabilities {
857 browser_executable: browser_executable.to_path_buf(),
858 })?;
859 Ok(caps)
860 }
861}
862
863#[cfg(feature = "thirtyfour")]
864fn headless_shell_launch_args(
865 caps: &thirtyfour::ChromeCapabilities,
866) -> Result<Vec<String>, Report<ChromeForTestingManagerError>> {
867 use thirtyfour::BrowserCapabilitiesHelper;
868
869 let mut launch_args = Vec::new();
870 let mut remote_debugging_port_arg = None::<String>;
871
872 for arg in caps.args() {
873 match classify_remote_debugging_arg(&arg) {
874 Some(RemoteDebuggingArg::Pipe) => {
875 return Err(report!(
876 ChromeForTestingManagerError::UnsupportedHeadlessShellRemoteDebuggingArg {
877 arg,
878 }
879 ));
880 }
881 Some(RemoteDebuggingArg::InvalidPort) => {
882 return Err(report!(
883 ChromeForTestingManagerError::InvalidHeadlessShellRemoteDebuggingPortArg {
884 arg,
885 }
886 ));
887 }
888 Some(RemoteDebuggingArg::Port) => {
889 if let Some(first_arg) = &remote_debugging_port_arg {
890 return Err(report!(
891 ChromeForTestingManagerError::ConflictingHeadlessShellRemoteDebuggingArgs {
892 first_arg: first_arg.clone(),
893 second_arg: arg,
894 }
895 ));
896 }
897 remote_debugging_port_arg = Some(arg);
898 }
899 None => launch_args.push(arg),
900 }
901 }
902
903 launch_args.push(
904 remote_debugging_port_arg
905 .unwrap_or_else(|| DEFAULT_HEADLESS_SHELL_REMOTE_DEBUGGING_ARG.to_owned()),
906 );
907 Ok(launch_args)
908}
909
910#[cfg(feature = "thirtyfour")]
911#[derive(Debug, Clone, Copy, PartialEq, Eq)]
912enum RemoteDebuggingArg {
913 Pipe,
914 Port,
915 InvalidPort,
916}
917
918#[cfg(feature = "thirtyfour")]
919fn classify_remote_debugging_arg(arg: &str) -> Option<RemoteDebuggingArg> {
920 if arg == "--remote-debugging-pipe" || arg.starts_with("--remote-debugging-pipe=") {
921 return Some(RemoteDebuggingArg::Pipe);
922 }
923
924 if arg == "--remote-debugging-port" {
925 return Some(RemoteDebuggingArg::InvalidPort);
926 }
927
928 let port = arg.strip_prefix("--remote-debugging-port=")?;
929
930 if port.parse::<u16>().is_ok() {
931 Some(RemoteDebuggingArg::Port)
932 } else {
933 Some(RemoteDebuggingArg::InvalidPort)
934 }
935}
936
937#[cfg(feature = "thirtyfour")]
938async fn wait_for_devtools_address(
939 browser_process: &mut ManagedProcessHandle,
940 chrome_executable: &Path,
941) -> Result<String, Report<ChromeForTestingManagerError>> {
942 let debugger_address = Arc::new(Mutex::new(None::<String>));
943 let debugger_address_for_wait = debugger_address.clone();
944 let recent_output = Arc::new(Mutex::new(VecDeque::new()));
945 let recent_output_for_wait = recent_output.clone();
946 let startup_result = match browser_process
947 .stderr()
948 .wait_for_line(
949 Duration::from_secs(10),
950 move |line| {
951 push_recent_browser_output(&recent_output_for_wait, line.as_ref());
952 let Some(address) = parse_devtools_address(&line) else {
953 return false;
954 };
955 let mut stored = debugger_address_for_wait.lock().expect("not poisoned");
956 *stored = Some(address);
957 true
958 },
959 LineParsingOptions::builder()
960 .max_line_length(DEFAULT_MAX_LINE_LENGTH)
961 .overflow_behavior(LineOverflowBehavior::DropAdditionalData)
962 .buffer_compaction_threshold(None)
963 .build(),
964 )
965 .await
966 {
967 Ok(result) => result,
968 Err(err) => {
969 log_recent_browser_startup_output(chrome_executable, &recent_output);
970 return Err(Report::new_sendsync(err).context(
971 ChromeForTestingManagerError::WaitForBrowserStartup {
972 path: chrome_executable.to_path_buf(),
973 },
974 ));
975 }
976 };
977
978 match startup_result {
979 WaitForLineResult::Matched => debugger_address
980 .lock()
981 .expect("not poisoned")
982 .clone()
983 .context(ChromeForTestingManagerError::WaitForBrowserStartup {
984 path: chrome_executable.to_path_buf(),
985 }),
986 WaitForLineResult::StreamClosed | WaitForLineResult::Timeout => {
987 log_recent_browser_startup_output(chrome_executable, &recent_output);
988 Err(report!(
989 ChromeForTestingManagerError::WaitForBrowserStartup {
990 path: chrome_executable.to_path_buf(),
991 }
992 ))
993 }
994 }
995}
996
997#[cfg(feature = "thirtyfour")]
998fn push_recent_browser_output(recent_output: &RecentBrowserOutput, line: &str) {
999 let mut recent_output = recent_output.lock().expect("not poisoned");
1000 if recent_output.len() == BROWSER_STARTUP_OUTPUT_LINES {
1001 recent_output.pop_front();
1002 }
1003 recent_output.push_back(line.to_owned());
1004}
1005
1006#[cfg(feature = "thirtyfour")]
1007fn log_recent_browser_startup_output(
1008 chrome_executable: &Path,
1009 recent_output: &RecentBrowserOutput,
1010) {
1011 let recent_output = recent_output.lock().expect("not poisoned");
1012 if recent_output.is_empty() {
1013 tracing::error!(
1014 path = %chrome_executable.display(),
1015 "Chrome Headless Shell exited before DevTools startup and produced no captured stderr"
1016 );
1017 return;
1018 }
1019
1020 tracing::error!(
1021 path = %chrome_executable.display(),
1022 "Chrome Headless Shell startup output before DevTools startup failed:\n{}",
1023 recent_output
1024 .iter()
1025 .map(String::as_str)
1026 .collect::<Vec<_>>()
1027 .join("\n")
1028 );
1029}
1030
1031#[cfg(feature = "thirtyfour")]
1032fn parse_devtools_address(line: &str) -> Option<String> {
1033 let (_, after_prefix) = line.split_once("DevTools listening on ws://")?;
1034 let (address, _) = after_prefix.split_once('/')?;
1035 if address.is_empty() {
1036 None
1037 } else {
1038 Some(address.to_owned())
1039 }
1040}
1041
1042#[cfg(feature = "thirtyfour")]
1043async fn terminate_browser_after_startup_failure(
1044 browser_process: &mut ManagedProcessHandle,
1045 shutdown: GracefulShutdown,
1046) {
1047 if let Err(err) = browser_process.terminate(shutdown).await {
1048 tracing::warn!(
1049 error = %err,
1050 "failed to terminate browser after startup failure"
1051 );
1052 }
1053}
1054
1055#[cfg(feature = "thirtyfour")]
1056impl ChromeForTestingManager {
1057 async fn create_initial_browser_page(
1058 &self,
1059 debugger_address: &str,
1060 ) -> Result<(), Report<ChromeForTestingManagerError>> {
1061 self.client
1062 .put(format!("http://{debugger_address}/json/new?about:blank"))
1063 .send()
1064 .await
1065 .context(ChromeForTestingManagerError::CreateInitialBrowserPage {
1066 debugger_address: debugger_address.to_owned(),
1067 })?
1068 .error_for_status()
1069 .context(ChromeForTestingManagerError::CreateInitialBrowserPage {
1070 debugger_address: debugger_address.to_owned(),
1071 })?;
1072 Ok(())
1073 }
1074}
1075
1076fn unsupported_platform_error(err: impl std::fmt::Display) -> Report<ChromeForTestingManagerError> {
1077 report!(ChromeForTestingManagerError::UnsupportedPlatform)
1078 .attach(format!("chrome-for-testing error:\n{err}"))
1079}
1080
1081fn request_versions_error(
1082 err: impl std::fmt::Display,
1083 version_request: &VersionRequest,
1084) -> Report<ChromeForTestingManagerError> {
1085 report!(ChromeForTestingManagerError::RequestVersions {
1086 version_request: version_request.clone(),
1087 })
1088 .attach(format!("chrome-for-testing error:\n{err}"))
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use crate::chromedriver::default_graceful_shutdown;
1094 use crate::mgr::{ChromeBinary, ChromeForTestingManager, LoadedBrowserPackage};
1095 use crate::mgr::{
1096 DEFAULT_HEADLESS_SHELL_REMOTE_DEBUGGING_ARG, RemoteDebuggingArg,
1097 classify_remote_debugging_arg, headless_shell_launch_args, parse_devtools_address,
1098 };
1099 use crate::port::Port;
1100 use crate::port::PortRequest;
1101 use crate::version::SelectedVersion;
1102 use crate::{Channel, Version, VersionRequest};
1103 use assertr::prelude::*;
1104 use rootcause::Report;
1105 use serial_test::serial;
1106 use std::path::{Path, PathBuf};
1107 use thirtyfour::ChromiumLikeCapabilities;
1108
1109 #[ctor::ctor(unsafe)]
1110 fn init_test_tracing() {
1111 tracing_subscriber::fmt().with_test_writer().try_init().ok();
1112 }
1113
1114 #[tokio::test(flavor = "multi_thread")]
1115 #[serial]
1116 async fn clear_cache_and_download_new() -> Result<(), Report> {
1117 let mgr = ChromeForTestingManager::new()?;
1118 mgr.clear_cache().await?;
1119 let selected = mgr
1120 .resolve_version(VersionRequest::LatestIn(Channel::Stable))
1121 .await?;
1122 let loaded = download_regular_chrome(&mgr, selected).await?;
1123
1124 assert_that!(loaded.browser_executable())
1125 .exists()
1126 .is_a_file();
1127 assert_that!(loaded.chromedriver_executable())
1128 .exists()
1129 .is_a_file();
1130 assert_that!(loaded.chrome_binary()).is_equal_to(ChromeBinary::Chrome);
1131 Ok(())
1132 }
1133
1134 #[tokio::test(flavor = "multi_thread")]
1135 #[serial]
1136 async fn resolve_and_download_latest() -> Result<(), Report> {
1137 let mgr = ChromeForTestingManager::new()?;
1138 let selected = mgr.resolve_version(VersionRequest::Latest).await?;
1139 let loaded = download_regular_chrome(&mgr, selected).await?;
1140
1141 assert_that!(loaded.browser_executable())
1142 .exists()
1143 .is_a_file();
1144 assert_that!(loaded.chromedriver_executable())
1145 .exists()
1146 .is_a_file();
1147 assert_that!(loaded.chrome_binary()).is_equal_to(ChromeBinary::Chrome);
1148 Ok(())
1149 }
1150
1151 #[tokio::test(flavor = "multi_thread")]
1152 #[serial]
1153 async fn resolve_and_download_latest_in_stable_channel() -> Result<(), Report> {
1154 let mgr = ChromeForTestingManager::new()?;
1155 let selected = mgr
1156 .resolve_version(VersionRequest::LatestIn(Channel::Stable))
1157 .await?;
1158 let loaded = download_regular_chrome(&mgr, selected).await?;
1159
1160 assert_that!(loaded.browser_executable())
1161 .exists()
1162 .is_a_file();
1163 assert_that!(loaded.chromedriver_executable())
1164 .exists()
1165 .is_a_file();
1166 assert_that!(loaded.chrome_binary()).is_equal_to(ChromeBinary::Chrome);
1167 Ok(())
1168 }
1169
1170 #[tokio::test(flavor = "multi_thread")]
1171 #[serial]
1172 async fn resolve_and_download_specific() -> Result<(), Report> {
1173 let mgr = ChromeForTestingManager::new()?;
1174 let selected = mgr
1175 .resolve_version(VersionRequest::Fixed(Version {
1176 major: 135,
1177 minor: 0,
1178 patch: 7019,
1179 build: 0,
1180 }))
1181 .await?;
1182 let loaded = download_regular_chrome(&mgr, selected).await?;
1183
1184 assert_that!(loaded.browser_executable())
1185 .exists()
1186 .is_a_file();
1187 assert_that!(loaded.chromedriver_executable())
1188 .exists()
1189 .is_a_file();
1190 assert_that!(loaded.chrome_binary()).is_equal_to(ChromeBinary::Chrome);
1191 Ok(())
1192 }
1193
1194 #[test]
1195 fn loaded_browser_package_preserves_browser_type_and_paths() {
1196 let chrome_package = LoadedBrowserPackage::new(
1197 ChromeBinary::Chrome,
1198 PathBuf::from("/cache/chrome"),
1199 PathBuf::from("/cache/chromedriver"),
1200 );
1201 assert_that!(chrome_package.chrome_binary()).is_equal_to(ChromeBinary::Chrome);
1202 assert_that!(chrome_package.browser_executable()).is_equal_to(Path::new("/cache/chrome"));
1203 assert_that!(chrome_package.chromedriver_executable())
1204 .is_equal_to(Path::new("/cache/chromedriver"));
1205 assert_that!(matches!(chrome_package, LoadedBrowserPackage::Chrome(_))).is_true();
1206
1207 let headless_shell_package = LoadedBrowserPackage::new(
1208 ChromeBinary::ChromeHeadlessShell,
1209 PathBuf::from("/cache/chrome-headless-shell"),
1210 PathBuf::from("/cache/chromedriver"),
1211 );
1212 assert_that!(headless_shell_package.chrome_binary())
1213 .is_equal_to(ChromeBinary::ChromeHeadlessShell);
1214 assert_that!(headless_shell_package.browser_executable())
1215 .is_equal_to(Path::new("/cache/chrome-headless-shell"));
1216 assert_that!(headless_shell_package.chromedriver_executable())
1217 .is_equal_to(Path::new("/cache/chromedriver"));
1218 assert_that!(matches!(
1219 headless_shell_package,
1220 LoadedBrowserPackage::ChromeHeadlessShell(_)
1221 ))
1222 .is_true();
1223 }
1224
1225 #[tokio::test(flavor = "multi_thread")]
1226 async fn download_reports_missing_chrome_binary() -> Result<(), Report> {
1227 let mgr = ChromeForTestingManager::new()?;
1228
1229 assert_that!(
1230 mgr.download(&selected_without_downloads(), &[ChromeBinary::Chrome])
1231 .await
1232 )
1233 .is_err();
1234 Ok(())
1235 }
1236
1237 #[tokio::test(flavor = "multi_thread")]
1238 async fn download_reports_missing_chrome_headless_shell_binary() -> Result<(), Report> {
1239 let mgr = ChromeForTestingManager::new()?;
1240
1241 assert_that!(
1242 mgr.download(
1243 &selected_without_downloads(),
1244 &[ChromeBinary::ChromeHeadlessShell]
1245 )
1246 .await
1247 )
1248 .is_err();
1249 Ok(())
1250 }
1251
1252 #[tokio::test(flavor = "multi_thread")]
1253 async fn download_reports_missing_binary_for_combined_request() -> Result<(), Report> {
1254 let mgr = ChromeForTestingManager::new()?;
1255
1256 assert_that!(
1257 mgr.download(
1258 &selected_without_downloads(),
1259 &[ChromeBinary::Chrome, ChromeBinary::ChromeHeadlessShell]
1260 )
1261 .await
1262 )
1263 .is_err();
1264 Ok(())
1265 }
1266
1267 #[tokio::test(flavor = "multi_thread")]
1268 async fn download_reports_empty_browser_request_before_downloading() -> Result<(), Report> {
1269 let mgr = ChromeForTestingManager::new()?;
1270 let result = mgr.download(&selected_without_downloads(), &[]).await;
1271
1272 assert_that!(result)
1273 .is_err()
1274 .derive(ToString::to_string)
1275 .contains("at least one Chrome binary must be requested");
1276 Ok(())
1277 }
1278
1279 #[tokio::test(flavor = "multi_thread")]
1280 #[serial]
1281 async fn launch_chromedriver_on_specific_port() -> Result<(), Report> {
1282 let mgr = ChromeForTestingManager::new()?;
1283 let selected = mgr.resolve_version(VersionRequest::Latest).await?;
1284 let loaded = download_regular_chrome(&mgr, selected).await?;
1285 let (chromedriver, port, _output_inspectors) = mgr
1286 .launch_chromedriver(
1287 &loaded,
1288 PortRequest::Specific(Port::new(3333)),
1289 None,
1290 default_graceful_shutdown(),
1291 )
1292 .await?;
1293 let _chromedriver = chromedriver.terminate_on_drop(default_graceful_shutdown());
1294 assert_that!(port).is_equal_to(Port::new(3333));
1295 Ok(())
1296 }
1297
1298 #[tokio::test(flavor = "multi_thread")]
1299 #[serial]
1300 async fn download_and_launch_chromedriver_on_random_port_and_prepare_thirtyfour_webdriver()
1301 -> Result<(), Report> {
1302 let mgr = ChromeForTestingManager::new()?;
1303 let selected = mgr.resolve_version(VersionRequest::Latest).await?;
1304 let loaded = download_regular_chrome(&mgr, selected).await?;
1305 let (chromedriver, port, _output_inspectors) = mgr
1306 .launch_chromedriver(&loaded, PortRequest::Any, None, default_graceful_shutdown())
1307 .await?;
1308 let _chromedriver = chromedriver.terminate_on_drop(default_graceful_shutdown());
1309
1310 let caps = mgr.prepare_caps(&loaded)?;
1311 let driver = thirtyfour::WebDriver::new(format!("http://localhost:{port}"), caps).await?;
1312 driver.goto("https://www.google.com").await?;
1313
1314 let url = driver.current_url().await?;
1315 assert_that!(url).has_display_value("https://www.google.com/");
1316
1317 driver.quit().await?;
1318
1319 Ok(())
1320 }
1321
1322 #[test]
1323 fn parse_devtools_address_extracts_http_debugger_address() {
1324 assert_that!(parse_devtools_address(
1325 "DevTools listening on ws://127.0.0.1:9222/devtools/browser/abc"
1326 ))
1327 .is_equal_to(Some(String::from("127.0.0.1:9222")));
1328 }
1329
1330 #[test]
1331 fn remote_debugging_args_are_classified_for_headless_shell_sessions() {
1332 assert_that!(classify_remote_debugging_arg("--remote-debugging-pipe"))
1333 .is_equal_to(Some(RemoteDebuggingArg::Pipe));
1334 assert_that!(classify_remote_debugging_arg(
1335 "--remote-debugging-pipe=true"
1336 ))
1337 .is_equal_to(Some(RemoteDebuggingArg::Pipe));
1338 assert_that!(classify_remote_debugging_arg("--remote-debugging-port"))
1339 .is_equal_to(Some(RemoteDebuggingArg::InvalidPort));
1340 assert_that!(classify_remote_debugging_arg("--remote-debugging-port=0"))
1341 .is_equal_to(Some(RemoteDebuggingArg::Port));
1342 assert_that!(classify_remote_debugging_arg(
1343 "--remote-debugging-port=9222"
1344 ))
1345 .is_equal_to(Some(RemoteDebuggingArg::Port));
1346 assert_that!(classify_remote_debugging_arg("--remote-debugging-port="))
1347 .is_equal_to(Some(RemoteDebuggingArg::InvalidPort));
1348 assert_that!(classify_remote_debugging_arg(
1349 "--remote-debugging-port=localhost:9222"
1350 ))
1351 .is_equal_to(Some(RemoteDebuggingArg::InvalidPort));
1352 assert_that!(classify_remote_debugging_arg("--remote-debugging-portable"))
1353 .is_equal_to(None);
1354 assert_that!(classify_remote_debugging_arg("--headless")).is_equal_to(None);
1355 }
1356
1357 #[test]
1358 fn headless_shell_launch_args_add_default_remote_debugging_port() -> Result<(), Report> {
1359 let mut caps = thirtyfour::ChromeCapabilities::new();
1360 caps.add_arg("--headless=new")?;
1361 caps.add_arg("--disable-gpu")?;
1362
1363 assert_that!(headless_shell_launch_args(&caps)?.as_slice()).contains_exactly([
1364 "--headless=new",
1365 "--disable-gpu",
1366 DEFAULT_HEADLESS_SHELL_REMOTE_DEBUGGING_ARG,
1367 ]);
1368 Ok(())
1369 }
1370
1371 #[test]
1372 fn headless_shell_launch_args_use_configured_remote_debugging_port() -> Result<(), Report> {
1373 let mut caps = thirtyfour::ChromeCapabilities::new();
1374 caps.add_arg("--headless=new")?;
1375 caps.add_arg("--remote-debugging-port=9222")?;
1376
1377 assert_that!(headless_shell_launch_args(&caps)?.as_slice())
1378 .contains_exactly(["--headless=new", "--remote-debugging-port=9222"]);
1379 Ok(())
1380 }
1381
1382 #[test]
1383 fn headless_shell_launch_args_reject_remote_debugging_pipe() -> Result<(), Report> {
1384 let mut caps = thirtyfour::ChromeCapabilities::new();
1385 caps.add_arg("--remote-debugging-pipe")?;
1386
1387 assert_that!(headless_shell_launch_args(&caps))
1388 .is_err()
1389 .derive(ToString::to_string)
1390 .contains("unsupported argument \"--remote-debugging-pipe\"");
1391 Ok(())
1392 }
1393
1394 #[test]
1395 fn headless_shell_launch_args_reject_invalid_remote_debugging_port() -> Result<(), Report> {
1396 let mut caps = thirtyfour::ChromeCapabilities::new();
1397 caps.add_arg("--remote-debugging-port")?;
1398
1399 assert_that!(headless_shell_launch_args(&caps))
1400 .is_err()
1401 .derive(ToString::to_string)
1402 .contains("invalid argument \"--remote-debugging-port\"");
1403 Ok(())
1404 }
1405
1406 #[test]
1407 fn headless_shell_launch_args_reject_conflicting_remote_debugging_ports() -> Result<(), Report>
1408 {
1409 let mut caps = thirtyfour::ChromeCapabilities::new();
1410 caps.add_arg("--remote-debugging-port=9222")?;
1411 caps.add_arg("--remote-debugging-port=9223")?;
1412
1413 assert_that!(headless_shell_launch_args(&caps))
1414 .is_err()
1415 .derive(ToString::to_string)
1416 .contains(
1417 "conflicting arguments \"--remote-debugging-port=9222\" and \"--remote-debugging-port=9223\"",
1418 );
1419 Ok(())
1420 }
1421
1422 fn selected_without_downloads() -> SelectedVersion {
1423 SelectedVersion {
1424 channel: None,
1425 version: Version {
1426 major: 135,
1427 minor: 0,
1428 patch: 7019,
1429 build: 0,
1430 },
1431 chrome: None,
1432 chrome_headless_shell: None,
1433 chromedriver: None,
1434 }
1435 }
1436
1437 async fn download_regular_chrome(
1438 mgr: &ChromeForTestingManager,
1439 selected: SelectedVersion,
1440 ) -> Result<LoadedBrowserPackage, Report> {
1441 let loaded = mgr.download(&selected, &[ChromeBinary::Chrome]).await?;
1442 Ok(loaded
1443 .into_iter()
1444 .next()
1445 .expect("one requested binary returns one package"))
1446 }
1447}