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