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