1use std::collections::HashSet;
4use std::io;
5use std::path::{Path, PathBuf};
6use std::process::{Child, Command, ExitStatus, Stdio};
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use serde_json::Value;
11use tokio::io::{AsyncReadExt, AsyncWriteExt};
12use tokio::net::TcpStream;
13
14use crate::browser::connector::{connect_browser, ConnectOptions};
15use crate::browser::launcher::{Browser, LaunchResult};
16use crate::core::constants::CHROME_ARGS;
17use crate::core::engine::{EngineAdapter, EngineType};
18use crate::downloads::{DownloadManager, DownloadSetting};
19
20const MANAGED_ARGUMENTS: [&str; 3] = [
21 "--remote-debugging-address",
22 "--remote-debugging-port",
23 "--user-data-dir",
24];
25
26#[derive(Debug, Clone)]
28pub struct RealBrowserOptions {
29 pub engine: EngineType,
31 pub channel: String,
33 pub executable_path: Option<PathBuf>,
35 pub user_data_dir: Option<PathBuf>,
37 pub remote_debugging_port: u16,
39 pub headless: bool,
41 pub args: Vec<String>,
43 pub extra_args: Vec<String>,
45 pub ignore_default_args: Vec<String>,
47 pub ignore_all_default_args: bool,
49 pub startup_timeout: Duration,
51 pub slow_mo: u64,
53 pub timeout: Option<Duration>,
55 pub protocol_timeout: Option<Duration>,
57 pub seed_cookies: Vec<Value>,
59 pub verbose: bool,
61 pub node_executable: Option<PathBuf>,
63 pub node_working_dir: Option<PathBuf>,
65 pub downloads: DownloadSetting,
73}
74
75impl Default for RealBrowserOptions {
76 fn default() -> Self {
77 Self {
78 engine: EngineType::Chromiumoxide,
79 channel: "chrome".to_string(),
80 executable_path: None,
81 user_data_dir: None,
82 remote_debugging_port: 0,
83 headless: false,
84 args: Vec::new(),
85 extra_args: Vec::new(),
86 ignore_default_args: Vec::new(),
87 ignore_all_default_args: false,
88 startup_timeout: Duration::from_secs(30),
89 slow_mo: 0,
90 timeout: None,
91 protocol_timeout: None,
92 seed_cookies: Vec::new(),
93 verbose: false,
94 node_executable: None,
95 node_working_dir: None,
96 downloads: DownloadSetting::Off,
97 }
98 }
99}
100
101impl RealBrowserOptions {
102 pub fn chromiumoxide() -> Self {
104 Self::default()
105 }
106
107 pub fn playwright() -> Self {
109 Self {
110 engine: EngineType::Playwright,
111 slow_mo: 150,
112 ..Self::default()
113 }
114 }
115
116 pub fn puppeteer() -> Self {
118 Self {
119 engine: EngineType::Puppeteer,
120 ..Self::default()
121 }
122 }
123
124 pub fn channel(mut self, channel: impl Into<String>) -> Self {
126 self.channel = channel.into();
127 self
128 }
129
130 pub fn executable_path(mut self, executable_path: impl Into<PathBuf>) -> Self {
132 self.executable_path = Some(executable_path.into());
133 self
134 }
135
136 pub fn user_data_dir(mut self, user_data_dir: impl Into<PathBuf>) -> Self {
138 self.user_data_dir = Some(user_data_dir.into());
139 self
140 }
141
142 pub fn remote_debugging_port(mut self, port: u16) -> Self {
144 self.remote_debugging_port = port;
145 self
146 }
147
148 pub fn headless(mut self, headless: bool) -> Self {
150 self.headless = headless;
151 self
152 }
153
154 pub fn with_args(mut self, args: Vec<String>) -> Self {
156 self.args = args;
157 self
158 }
159
160 pub fn with_extra_args(mut self, args: Vec<String>) -> Self {
162 self.extra_args = args;
163 self
164 }
165
166 pub fn ignore_default_args(mut self, args: Vec<String>) -> Self {
168 self.ignore_default_args = args;
169 self
170 }
171
172 pub fn ignore_all_default_args(mut self) -> Self {
174 self.ignore_all_default_args = true;
175 self
176 }
177
178 pub fn startup_timeout(mut self, timeout: Duration) -> Self {
180 self.startup_timeout = timeout;
181 self
182 }
183
184 pub fn slow_mo(mut self, milliseconds: u64) -> Self {
186 self.slow_mo = milliseconds;
187 self
188 }
189
190 pub fn timeout(mut self, timeout: Duration) -> Self {
192 self.timeout = Some(timeout);
193 self
194 }
195
196 pub fn protocol_timeout(mut self, timeout: Duration) -> Self {
198 self.protocol_timeout = Some(timeout);
199 self
200 }
201
202 pub fn seed_cookies(mut self, cookies: Vec<Value>) -> Self {
204 self.seed_cookies = cookies;
205 self
206 }
207
208 pub fn verbose(mut self, verbose: bool) -> Self {
210 self.verbose = verbose;
211 self
212 }
213
214 pub fn node_executable(mut self, executable: impl Into<PathBuf>) -> Self {
216 self.node_executable = Some(executable.into());
217 self
218 }
219
220 pub fn node_working_dir(mut self, directory: impl Into<PathBuf>) -> Self {
222 self.node_working_dir = Some(directory.into());
223 self
224 }
225
226 pub fn downloads(mut self, downloads: impl Into<DownloadSetting>) -> Self {
233 self.downloads = downloads.into();
234 self
235 }
236
237 pub fn get_user_data_dir(&self) -> PathBuf {
239 self.user_data_dir
240 .clone()
241 .unwrap_or_else(|| default_real_browser_user_data_dir(&self.channel))
242 }
243}
244
245pub struct BrowserProcess {
247 child: Child,
248}
249
250impl BrowserProcess {
251 fn new(child: Child) -> Self {
252 Self { child }
253 }
254
255 pub fn id(&self) -> u32 {
257 self.child.id()
258 }
259
260 pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
262 self.child.try_wait()
263 }
264
265 pub fn kill(&mut self) -> io::Result<()> {
267 if self.child.try_wait()?.is_some() {
268 return Ok(());
269 }
270 self.child.kill()?;
271 self.child.wait()?;
272 Ok(())
273 }
274}
275
276impl std::fmt::Debug for BrowserProcess {
277 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278 formatter
279 .debug_struct("BrowserProcess")
280 .field("id", &self.id())
281 .finish()
282 }
283}
284
285impl Drop for BrowserProcess {
286 fn drop(&mut self) {
287 let _ = self.kill();
288 }
289}
290
291pub struct RealBrowserLaunchResult {
293 pub browser: Browser,
295 pub page: Arc<dyn EngineAdapter>,
297 pub cdp_endpoint: String,
299 pub executable_path: PathBuf,
301 pub user_data_dir: PathBuf,
303 pub browser_process: BrowserProcess,
305 pub downloads: Option<Arc<DownloadManager>>,
310}
311
312impl std::fmt::Debug for RealBrowserLaunchResult {
313 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 formatter
315 .debug_struct("RealBrowserLaunchResult")
316 .field("browser", &self.browser)
317 .field("page", &"<dyn EngineAdapter>")
318 .field("cdp_endpoint", &self.cdp_endpoint)
319 .field("executable_path", &self.executable_path)
320 .field("user_data_dir", &self.user_data_dir)
321 .field("browser_process", &self.browser_process)
322 .field("downloads", &self.downloads)
323 .finish()
324 }
325}
326
327pub fn default_real_browser_user_data_dir(channel: &str) -> PathBuf {
329 let directory_name: String = channel
330 .chars()
331 .map(|character| {
332 if character.is_ascii_alphanumeric() || "_.-".contains(character) {
333 character
334 } else {
335 '-'
336 }
337 })
338 .collect();
339 dirs::home_dir()
340 .unwrap_or_else(|| PathBuf::from("."))
341 .join(".browser-commander")
342 .join("real-browser")
343 .join(directory_name)
344}
345
346fn known_default_user_data_dirs() -> Vec<PathBuf> {
347 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
348
349 #[cfg(target_os = "macos")]
350 {
351 let support = home.join("Library").join("Application Support");
352 return vec![
353 support.join("Google/Chrome"),
354 support.join("Google/Chrome Beta"),
355 support.join("Google/Chrome Canary"),
356 support.join("Google/Chrome Dev"),
357 support.join("Chromium"),
358 support.join("BraveSoftware/Brave-Browser"),
359 support.join("BraveSoftware/Brave-Browser-Beta"),
360 support.join("BraveSoftware/Brave-Browser-Nightly"),
361 support.join("Microsoft Edge"),
362 support.join("Microsoft Edge Beta"),
363 support.join("Microsoft Edge Canary"),
364 support.join("Microsoft Edge Dev"),
365 ];
366 }
367
368 #[cfg(target_os = "windows")]
369 {
370 let local = std::env::var_os("LOCALAPPDATA")
371 .map(PathBuf::from)
372 .unwrap_or_else(|| home.join("AppData/Local"));
373 return vec![
374 local.join("Google/Chrome/User Data"),
375 local.join("Google/Chrome Beta/User Data"),
376 local.join("Google/Chrome Dev/User Data"),
377 local.join("Google/Chrome SxS/User Data"),
378 local.join("Chromium/User Data"),
379 local.join("BraveSoftware/Brave-Browser/User Data"),
380 local.join("BraveSoftware/Brave-Browser-Beta/User Data"),
381 local.join("BraveSoftware/Brave-Browser-Nightly/User Data"),
382 local.join("Microsoft/Edge/User Data"),
383 local.join("Microsoft/Edge Beta/User Data"),
384 local.join("Microsoft/Edge Dev/User Data"),
385 local.join("Microsoft/Edge SxS/User Data"),
386 ];
387 }
388
389 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
390 {
391 vec![
392 home.join(".config/google-chrome"),
393 home.join(".config/google-chrome-beta"),
394 home.join(".config/google-chrome-unstable"),
395 home.join(".config/chromium"),
396 home.join(".config/BraveSoftware/Brave-Browser"),
397 home.join(".config/BraveSoftware/Brave-Browser-Beta"),
398 home.join(".config/BraveSoftware/Brave-Browser-Nightly"),
399 home.join(".config/microsoft-edge"),
400 home.join(".config/microsoft-edge-beta"),
401 home.join(".config/microsoft-edge-dev"),
402 ]
403 }
404}
405
406fn normalize_for_comparison(path: &Path) -> PathBuf {
407 let normalized = std::fs::canonicalize(path).unwrap_or_else(|_| {
408 if path.is_absolute() {
409 path.to_path_buf()
410 } else {
411 std::env::current_dir()
412 .unwrap_or_else(|_| PathBuf::from("."))
413 .join(path)
414 }
415 });
416
417 #[cfg(target_os = "windows")]
418 {
419 return PathBuf::from(normalized.to_string_lossy().to_lowercase());
420 }
421
422 #[cfg(not(target_os = "windows"))]
423 {
424 normalized
425 }
426}
427
428pub fn assert_dedicated_user_data_dir(user_data_dir: &Path) -> Result<(), anyhow::Error> {
430 let requested = normalize_for_comparison(user_data_dir);
431 if known_default_user_data_dirs()
432 .iter()
433 .any(|default| normalize_for_comparison(default) == requested)
434 {
435 return Err(anyhow::anyhow!(
436 "launch_real_browser requires a dedicated user_data_dir, not a browser default profile"
437 ));
438 }
439 Ok(())
440}
441
442fn channel_executable_names(channel: &str) -> Result<&'static [&'static str], anyhow::Error> {
443 match channel {
444 "brave" => Ok(&["brave-browser", "brave-browser-stable", "brave"]),
445 "chrome" => Ok(&["google-chrome", "google-chrome-stable", "chrome"]),
446 "chrome-beta" => Ok(&["google-chrome-beta"]),
447 "chrome-canary" => Ok(&["google-chrome-canary"]),
448 "chrome-dev" => Ok(&["google-chrome-unstable"]),
449 "chromium" => Ok(&["chromium", "chromium-browser"]),
450 "msedge" => Ok(&["microsoft-edge", "microsoft-edge-stable", "msedge"]),
451 "msedge-beta" => Ok(&["microsoft-edge-beta"]),
452 "msedge-canary" => Ok(&["microsoft-edge-canary"]),
453 "msedge-dev" => Ok(&["microsoft-edge-dev"]),
454 _ => Err(anyhow::anyhow!(
455 "unknown browser channel: {channel}; expected chrome, chrome-beta, chrome-canary, chrome-dev, chromium, brave, msedge, msedge-beta, msedge-canary, or msedge-dev"
456 )),
457 }
458}
459
460fn browser_install_candidates(channel: &str) -> Result<Vec<PathBuf>, anyhow::Error> {
461 let names = channel_executable_names(channel)?;
462 let mut candidates = Vec::new();
463
464 #[cfg(target_os = "macos")]
465 {
466 let relative = match channel {
467 "brave" => "Brave Browser.app/Contents/MacOS/Brave Browser",
468 "chrome" => "Google Chrome.app/Contents/MacOS/Google Chrome",
469 "chrome-beta" => "Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
470 "chrome-canary" => "Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
471 "chrome-dev" => "Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
472 "chromium" => "Chromium.app/Contents/MacOS/Chromium",
473 "msedge" => "Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
474 "msedge-beta" => "Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
475 "msedge-canary" => "Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
476 "msedge-dev" => "Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
477 _ => unreachable!("channel was validated above"),
478 };
479 candidates.push(Path::new("/Applications").join(relative));
480 if let Some(home) = dirs::home_dir() {
481 candidates.push(home.join("Applications").join(relative));
482 }
483 }
484
485 #[cfg(target_os = "windows")]
486 {
487 let relative: &[&str] = match channel {
488 "brave" => &["BraveSoftware", "Brave-Browser", "Application", "brave.exe"],
489 "chrome" => &["Google", "Chrome", "Application", "chrome.exe"],
490 "chrome-beta" => &["Google", "Chrome Beta", "Application", "chrome.exe"],
491 "chrome-canary" => &["Google", "Chrome SxS", "Application", "chrome.exe"],
492 "chrome-dev" => &["Google", "Chrome Dev", "Application", "chrome.exe"],
493 "chromium" => &["Chromium", "Application", "chrome.exe"],
494 "msedge" => &["Microsoft", "Edge", "Application", "msedge.exe"],
495 "msedge-beta" => &["Microsoft", "Edge Beta", "Application", "msedge.exe"],
496 "msedge-canary" => &["Microsoft", "Edge SxS", "Application", "msedge.exe"],
497 "msedge-dev" => &["Microsoft", "Edge Dev", "Application", "msedge.exe"],
498 _ => unreachable!("channel was validated above"),
499 };
500 for key in ["PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"] {
501 if let Some(root) = std::env::var_os(key) {
502 let mut candidate = PathBuf::from(root);
503 candidate.extend(relative);
504 candidates.push(candidate);
505 }
506 }
507 }
508
509 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
510 {
511 for name in names {
512 candidates.push(Path::new("/usr/bin").join(name));
513 candidates.push(Path::new("/usr/local/bin").join(name));
514 }
515 if channel == "chrome" {
516 candidates.push(PathBuf::from("/opt/google/chrome/google-chrome"));
517 }
518 }
519
520 if let Some(path) = std::env::var_os("PATH") {
521 for directory in std::env::split_paths(&path) {
522 for name in names {
523 #[cfg(target_os = "windows")]
524 let executable_name = format!("{name}.exe");
525 #[cfg(not(target_os = "windows"))]
526 let executable_name = (*name).to_string();
527 candidates.push(directory.join(executable_name));
528 }
529 }
530 }
531
532 let mut seen = HashSet::new();
533 candidates.retain(|candidate| seen.insert(candidate.clone()));
534 Ok(candidates)
535}
536
537fn is_executable(path: &Path) -> bool {
538 if !path.is_file() {
539 return false;
540 }
541
542 #[cfg(unix)]
543 {
544 use std::os::unix::fs::PermissionsExt;
545 path.metadata()
546 .map(|metadata| metadata.permissions().mode() & 0o111 != 0)
547 .unwrap_or(false)
548 }
549
550 #[cfg(not(unix))]
551 {
552 true
553 }
554}
555
556pub fn resolve_system_browser_executable(
558 options: &RealBrowserOptions,
559) -> Result<PathBuf, anyhow::Error> {
560 let candidates = if let Some(executable_path) = &options.executable_path {
561 vec![normalize_for_comparison(executable_path)]
562 } else {
563 browser_install_candidates(&options.channel)?
564 };
565
566 for candidate in candidates {
567 if is_executable(&candidate) {
568 return Ok(candidate);
569 }
570 }
571
572 if let Some(executable_path) = &options.executable_path {
573 Err(anyhow::anyhow!(
574 "browser executable is not accessible: {}",
575 executable_path.display()
576 ))
577 } else {
578 Err(anyhow::anyhow!(
579 "could not find an installed {} browser; provide executable_path",
580 options.channel
581 ))
582 }
583}
584
585pub fn build_real_browser_args(options: &RealBrowserOptions) -> Result<Vec<String>, anyhow::Error> {
587 let custom_args = options.args.iter().chain(&options.extra_args);
588 for argument in custom_args.clone() {
589 if MANAGED_ARGUMENTS
590 .iter()
591 .any(|managed| argument == managed || argument.starts_with(&format!("{managed}=")))
592 {
593 return Err(anyhow::anyhow!(
594 "{argument} is managed by launch_real_browser"
595 ));
596 }
597 }
598
599 let mut arguments = vec![
600 "--remote-debugging-address=127.0.0.1".to_string(),
601 format!("--remote-debugging-port={}", options.remote_debugging_port),
602 format!("--user-data-dir={}", options.get_user_data_dir().display()),
603 ];
604 if !options.ignore_all_default_args {
605 arguments.extend(
606 CHROME_ARGS
607 .iter()
608 .filter(|argument| {
609 !options
610 .ignore_default_args
611 .iter()
612 .any(|item| item == **argument)
613 })
614 .map(|argument| argument.to_string()),
615 );
616 }
617 if options.headless {
618 arguments.push("--headless=new".to_string());
619 }
620 arguments.extend(options.args.clone());
621 arguments.extend(options.extra_args.clone());
622 Ok(arguments)
623}
624
625fn response_has_cdp_websocket(response: &[u8]) -> bool {
626 if !(response.starts_with(b"HTTP/1.1 200") || response.starts_with(b"HTTP/1.0 200")) {
627 return false;
628 }
629 let Some(header_end) = response.windows(4).position(|window| window == b"\r\n\r\n") else {
630 return false;
631 };
632 serde_json::from_slice::<Value>(&response[header_end + 4..])
633 .ok()
634 .and_then(|value| value.get("webSocketDebuggerUrl").cloned())
635 .and_then(|value| value.as_str().map(str::to_owned))
636 .is_some()
637}
638
639async fn fetch_cdp_version(port: u16, timeout: Duration) -> bool {
640 let request = format!(
641 "GET /json/version HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
642 );
643 let request_future = async {
644 let mut stream = TcpStream::connect(("127.0.0.1", port)).await?;
645 stream.write_all(request.as_bytes()).await?;
646 let mut response = Vec::new();
647 let mut chunk = [0_u8; 4096];
648 loop {
649 let bytes_read = stream.read(&mut chunk).await?;
650 if bytes_read == 0 {
651 break;
652 }
653 response.extend_from_slice(&chunk[..bytes_read]);
654 if response_has_cdp_websocket(&response) {
655 return Ok::<bool, io::Error>(true);
656 }
657 if response.len() > 1024 * 1024 {
658 return Ok(false);
659 }
660 }
661 Ok(response_has_cdp_websocket(&response))
662 };
663 matches!(
664 tokio::time::timeout(timeout, request_future).await,
665 Ok(Ok(true))
666 )
667}
668
669async fn wait_for_cdp_endpoint(
670 options: &RealBrowserOptions,
671 user_data_dir: &Path,
672 browser_process: &mut BrowserProcess,
673) -> Result<String, anyhow::Error> {
674 let started = Instant::now();
675 let active_port_path = user_data_dir.join("DevToolsActivePort");
676
677 while started.elapsed() < options.startup_timeout {
678 if let Some(status) = browser_process.try_wait()? {
679 return Err(anyhow::anyhow!(
680 "browser exited before its DevTools endpoint was ready ({status})"
681 ));
682 }
683
684 let mut port = options.remote_debugging_port;
685 if port == 0 {
686 port = std::fs::read_to_string(&active_port_path)
687 .ok()
688 .and_then(|contents| contents.lines().next()?.parse::<u16>().ok())
689 .unwrap_or(0);
690 }
691
692 if port > 0 {
693 let remaining = options.startup_timeout.saturating_sub(started.elapsed());
694 if fetch_cdp_version(port, remaining.min(Duration::from_millis(500))).await {
695 return Ok(format!("http://127.0.0.1:{port}"));
696 }
697 }
698 tokio::time::sleep(Duration::from_millis(100)).await;
699 }
700
701 Err(anyhow::anyhow!(
702 "timed out after {}ms waiting for the DevTools endpoint",
703 options.startup_timeout.as_millis()
704 ))
705}
706
707fn connection_options(
708 options: &RealBrowserOptions,
709 endpoint: &str,
710) -> Result<ConnectOptions, anyhow::Error> {
711 let mut connection = match options.engine {
712 EngineType::Chromiumoxide => ConnectOptions::chromiumoxide(),
713 EngineType::Playwright => ConnectOptions::playwright(),
714 EngineType::Puppeteer => ConnectOptions::puppeteer(),
715 EngineType::Fantoccini => {
716 return Err(anyhow::anyhow!(
717 "fantoccini does not connect over CDP; use chromiumoxide, playwright, or puppeteer"
718 ));
719 }
720 };
721 connection.cdp_endpoint = Some(endpoint.to_string());
722 connection.slow_mo = options.slow_mo;
723 connection.timeout = options.timeout;
724 connection.protocol_timeout = options.protocol_timeout;
725 connection.seed_cookies = options.seed_cookies.clone();
726 connection.verbose = options.verbose;
727 connection.node_executable = options.node_executable.clone();
728 connection.node_working_dir = options.node_working_dir.clone();
729 connection.downloads = options.downloads.clone();
730 Ok(connection)
731}
732
733pub async fn launch_real_browser(
739 options: RealBrowserOptions,
740) -> Result<RealBrowserLaunchResult, anyhow::Error> {
741 if options.engine == EngineType::Fantoccini {
742 return Err(anyhow::anyhow!(
743 "fantoccini does not connect over CDP; use chromiumoxide, playwright, or puppeteer"
744 ));
745 }
746
747 let user_data_dir = options.get_user_data_dir();
748 assert_dedicated_user_data_dir(&user_data_dir)?;
749 std::fs::create_dir_all(&user_data_dir)?;
750
751 let executable_path = resolve_system_browser_executable(&options)?;
752 let arguments = build_real_browser_args(&options)?;
753 let output = if options.verbose {
754 Stdio::inherit()
755 } else {
756 Stdio::null()
757 };
758 let child = Command::new(&executable_path)
759 .args(arguments)
760 .stdin(Stdio::null())
761 .stdout(output)
762 .stderr(if options.verbose {
763 Stdio::inherit()
764 } else {
765 Stdio::null()
766 })
767 .spawn()
768 .map_err(|error| {
769 anyhow::anyhow!(
770 "failed to start installed browser {}: {error}",
771 executable_path.display()
772 )
773 })?;
774 let mut browser_process = BrowserProcess::new(child);
775
776 let cdp_endpoint =
777 match wait_for_cdp_endpoint(&options, &user_data_dir, &mut browser_process).await {
778 Ok(endpoint) => endpoint,
779 Err(error) => {
780 let _ = browser_process.kill();
781 return Err(error);
782 }
783 };
784
785 let connect_options = connection_options(&options, &cdp_endpoint)?;
786 let LaunchResult {
787 mut browser,
788 page,
789 downloads,
790 } = match connect_browser(connect_options).await {
791 Ok(connection) => connection,
792 Err(error) => {
793 let _ = browser_process.kill();
794 return Err(error);
795 }
796 };
797 browser.user_data_dir = user_data_dir.clone();
798 browser.headless = options.headless;
799
800 Ok(RealBrowserLaunchResult {
801 browser,
802 page,
803 cdp_endpoint,
804 executable_path,
805 user_data_dir,
806 browser_process,
807 downloads,
808 })
809}
810
811pub async fn launch_and_connect_real_browser(
813 options: RealBrowserOptions,
814) -> Result<RealBrowserLaunchResult, anyhow::Error> {
815 launch_real_browser(options).await
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821
822 #[test]
823 fn connection_options_carry_the_download_setting() {
824 let options = RealBrowserOptions::default().downloads(true);
828
829 let connection = connection_options(&options, "http://127.0.0.1:9222").expect("options");
830
831 assert!(matches!(connection.downloads, DownloadSetting::On));
832 }
833
834 #[test]
835 fn default_options_use_native_engine_and_managed_profile() {
836 let options = RealBrowserOptions::default();
837 assert_eq!(options.engine, EngineType::Chromiumoxide);
838 assert_eq!(options.channel, "chrome");
839 assert_eq!(options.remote_debugging_port, 0);
840 assert!(options
841 .get_user_data_dir()
842 .to_string_lossy()
843 .contains("real-browser"));
844 }
845
846 #[test]
847 fn rejects_the_current_platform_default_profiles() {
848 for profile in known_default_user_data_dirs() {
849 let error = assert_dedicated_user_data_dir(&profile).unwrap_err();
850 assert!(error.to_string().contains("dedicated user_data_dir"));
851 }
852 }
853
854 #[test]
855 fn all_required_channels_have_discovery_candidates() {
856 for channel in ["chrome", "chromium", "brave", "msedge"] {
857 assert!(!browser_install_candidates(channel).unwrap().is_empty());
858 }
859
860 let chrome = browser_install_candidates("chrome").unwrap();
861 #[cfg(target_os = "macos")]
862 assert!(chrome.contains(&PathBuf::from(
863 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
864 )));
865 #[cfg(target_os = "windows")]
866 assert!(chrome.iter().any(
867 |candidate| candidate.ends_with(Path::new("Google/Chrome/Application/chrome.exe"))
868 ));
869 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
870 assert!(chrome.contains(&PathBuf::from("/usr/bin/google-chrome")));
871 }
872
873 #[test]
874 fn rejects_fantoccini_before_connecting() {
875 let options = RealBrowserOptions {
876 engine: EngineType::Fantoccini,
877 ..RealBrowserOptions::default()
878 };
879 assert!(connection_options(&options, "http://127.0.0.1:9222").is_err());
880 }
881
882 #[tokio::test]
883 async fn rejects_fantoccini_before_starting_a_browser() {
884 let options = RealBrowserOptions {
885 engine: EngineType::Fantoccini,
886 executable_path: Some(PathBuf::from("missing-browser")),
887 ..RealBrowserOptions::default()
888 };
889
890 let error = launch_real_browser(options).await.unwrap_err();
891 assert!(error.to_string().contains("does not connect over CDP"));
892 }
893
894 #[tokio::test]
895 async fn cdp_probe_does_not_wait_for_the_server_to_close_the_connection() {
896 let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
897 .await
898 .unwrap();
899 let port = listener.local_addr().unwrap().port();
900 let response_body = r#"{"webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser/id"}"#;
901 let response = format!(
902 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{response_body}",
903 response_body.len()
904 );
905 let server = tokio::spawn(async move {
906 let (mut stream, _) = listener.accept().await.unwrap();
907 let mut request = [0_u8; 1024];
908 let _ = stream.read(&mut request).await.unwrap();
909 stream.write_all(response.as_bytes()).await.unwrap();
910 tokio::time::sleep(Duration::from_secs(1)).await;
911 });
912
913 assert!(fetch_cdp_version(port, Duration::from_millis(200)).await);
914 server.abort();
915 }
916}