Skip to main content

gthings_cdp/
browser.rs

1use crate::connection::Connection;
2use crate::error::{CdpError, Result};
3use std::io::{BufRead, BufReader};
4use std::process::{Command, Stdio};
5use tracing;
6
7/// A persistent Chrome browser instance. Stays alive after Drop.
8pub struct Browser {
9    ws_url: String,
10    #[allow(dead_code)]
11    cdp_port: u16,
12}
13
14/// Detect the default browser for HTTP URLs using macOS Launch Services.
15/// Returns the path to the .app bundle (e.g., "/Applications/Google Chrome.app").
16#[cfg(target_os = "macos")]
17fn default_browser_bundle() -> Option<std::path::PathBuf> {
18    let script = r#"import AppKit; let ws = NSWorkspace.shared; if let url = ws.urlForApplication(toOpen: URL(string: "https://")!) { print(url.path) }"#;
19    let output = std::process::Command::new("swift")
20        .args(["-e", script])
21        .output()
22        .ok()?;
23    if !output.status.success() {
24        return None;
25    }
26    let path_str = std::str::from_utf8(&output.stdout).ok()?.trim();
27    if path_str.is_empty() {
28        return None;
29    }
30    let path = std::path::PathBuf::from(path_str);
31    if path.exists() { Some(path) } else { None }
32}
33
34#[cfg(not(target_os = "macos"))]
35fn default_browser_bundle() -> Option<std::path::PathBuf> {
36    None // Non-macOS fallback: no default browser detection
37}
38
39/// Map a browser bundle path to its executable name inside Contents/MacOS/
40fn browser_exec_name(bundle_path: &std::path::Path) -> Option<&'static str> {
41    let path_str = bundle_path.to_string_lossy();
42    if path_str.contains("Google Chrome") {
43        Some("Google Chrome")
44    } else if path_str.contains("Dia") {
45        Some("Dia")
46    } else if path_str.contains("Arc") {
47        Some("Arc")
48    } else if path_str.contains("Brave Browser") || path_str.contains("Brave") {
49        Some("Brave Browser")
50    } else if path_str.contains("Microsoft Edge") {
51        Some("Microsoft Edge")
52    } else if path_str.contains("Chromium") {
53        Some("Chromium")
54    } else {
55        None
56    }
57}
58
59/// Map a browser bundle path to its profile directory suffix (under ~/Library/Application Support/)
60fn browser_profile_suffix(bundle_path: &std::path::Path) -> Option<&'static str> {
61    let path_str = bundle_path.to_string_lossy();
62    if path_str.contains("Google Chrome") {
63        Some("Google/Chrome/User Data")
64    } else if path_str.contains("Dia") {
65        Some("Dia/User Data")
66    } else if path_str.contains("Arc") {
67        Some("Arc/User Data")
68    } else if path_str.contains("Brave Browser") || path_str.contains("Brave") {
69        Some("BraveSoftware/Brave-Browser/User Data")
70    } else if path_str.contains("Microsoft Edge") {
71        Some("Microsoft Edge/User Data")
72    } else if path_str.contains("Chromium") {
73        Some("Chromium/User Data")
74    } else {
75        None
76    }
77}
78
79/// Read the last-used profile directory name from the browser's Local State file.
80/// Returns "Default" as fallback if Local State can't be read or last_used is missing.
81#[allow(dead_code)]
82fn detect_last_used_profile(user_data_dir: &std::path::Path) -> String {
83    let local_state_path = user_data_dir.join("Local State");
84    let content = match std::fs::read_to_string(&local_state_path) {
85        Ok(c) => c,
86        Err(_) => return "Default".to_string(),
87    };
88    let json: serde_json::Value = match serde_json::from_str(&content) {
89        Ok(v) => v,
90        Err(_) => return "Default".to_string(),
91    };
92    // Look for "profile.last_used" path in the JSON
93    if let Some(last_used) = json.get("profile").and_then(|p| p.get("last_used")).and_then(|l| l.as_str()) {
94        if user_data_dir.join(last_used).exists() {
95            return last_used.to_string();
96        }
97    }
98    // Fallback: first key in profile.info_cache
99    if let Some(info_cache) = json.get("profile").and_then(|p| p.get("info_cache")) {
100        if let Some(obj) = info_cache.as_object() {
101            if let Some(first_key) = obj.keys().next() {
102                return first_key.clone();
103            }
104        }
105    }
106    "Default".to_string()
107}
108
109/// Build the executable path from a bundle path
110fn bundle_to_exec(bundle: &std::path::Path, exec_name: &str) -> std::path::PathBuf {
111    bundle.join("Contents").join("MacOS").join(exec_name)
112}
113
114impl Browser {
115    /// Launch or reuse a persistent Chrome browser.
116    #[allow(clippy::result_large_err)]
117    pub async fn launch(
118        browser_path: Option<std::path::PathBuf>,
119        profile_dir: Option<std::path::PathBuf>,
120        cdp_port: u16,
121    ) -> Result<Self> {
122        tracing::info!("Checking for existing browser on port {cdp_port}");
123
124        // gsearch approach: prefer real profile if available AND not in use.
125        // If real profile is in use (user's browser is open), fall back to
126        // seeded temp profile to avoid crashing the user's session.
127        let profile_dir = match Self::resolve_profile(profile_dir) {
128            Some(dir) => dir,
129            None => {
130                let tmp = std::path::PathBuf::from(format!("/tmp/gthings-{}", cdp_port));
131                let _ = std::fs::create_dir_all(&tmp);
132                Self::seed_profile(&tmp);
133                tmp
134            }
135        };
136
137        if let Some(browser) = Self::find_existing(Some(&profile_dir), cdp_port).await {
138            tracing::info!("Found existing browser, reusing");
139            return Ok(browser);
140        }
141        tracing::info!("No existing browser found, launching new one");
142
143        let chrome_path = Self::find_chrome(browser_path)
144            .ok_or_else(|| CdpError::LaunchFailed("No Chrome/Chromium browser found".into()))?;
145
146        let port = cdp_port;
147
148        // Only check profile-in-use for real profiles (temp profiles are always clean)
149        if profile_dir.to_string_lossy().contains("/tmp/") {
150            // Temp profile — no lock cleaning needed
151        } else if Self::is_profile_in_use(&profile_dir) {
152            return Err(CdpError::LaunchFailed(format!(
153                "Profile {:?} is in use by another browser. Close it first or set GTHINGS_PROFILE_DIR.",
154                profile_dir
155            )));
156        } else {
157            // Clean locks on real profile before launch
158            let dir = profile_dir.clone();
159            tokio::task::spawn_blocking(move || {
160                Self::clean_profile_locks(&dir);
161            })
162            .await
163            .map_err(|e| CdpError::LaunchFailed(format!("spawn_blocking failed: {e}")))?;
164        }
165
166        tracing::info!(
167            "Launching browser on port {} with profile {:?}",
168            port,
169            profile_dir,
170        );
171
172        let mut cmd = Command::new(&chrome_path);
173        cmd.arg(format!("--remote-debugging-port={}", port))
174            .arg("--no-first-run")
175            .arg("--no-default-browser-check")
176            .arg("--disable-fre")
177            .arg("--disable-search-engine-choice-screen")
178            .arg("--disable-sync")
179            .arg("--remote-allow-origins=*")
180            .arg("--disable-background-networking")
181            .arg("--disable-component-update")
182            .arg("--disable-default-apps")
183            .arg("--password-store=basic")
184            .arg("--use-mock-keychain")
185            .arg("--window-size=1280,720")
186            .arg(format!("--user-data-dir={}", profile_dir.display()))
187            .arg("about:blank")
188            .stderr(Stdio::piped())
189            .stdout(Stdio::null())
190            .stdin(Stdio::null());
191
192        let mut child = cmd
193            .spawn()
194            .map_err(|e| CdpError::LaunchFailed(format!("Failed to spawn Chrome: {e}")))?;
195
196        let stderr = child
197            .stderr
198            .take()
199            .ok_or_else(|| CdpError::LaunchFailed("No stderr on Chrome process".into()))?;
200
201        let reader = BufReader::new(stderr);
202        let mut ws_url = None;
203
204        for line in reader.lines() {
205            let line = line.map_err(|e| {
206                CdpError::LaunchFailed(format!("Failed to read Chrome stderr: {e}"))
207            })?;
208            tracing::debug!("Chrome: {}", line);
209
210            // "DevTools listening on ws://127.0.0.1:9222/..."
211            if let Some(url) = line.strip_prefix("DevTools listening on ") {
212                ws_url = Some(url.trim().to_string());
213                break;
214            }
215        }
216
217        let ws_url = ws_url.ok_or(CdpError::NoWsUrl)?;
218        let pid = child.id();
219
220        tracing::info!("Launched persistent browser (pid={})", pid);
221
222        // Detach — browser stays alive after Drop
223        drop(child);
224
225        Ok(Browser { ws_url, cdp_port })
226    }
227
228    /// Connect to CDP WebSocket.
229    pub async fn connect(&self) -> Result<Connection> {
230        tracing::info!("Connecting to CDP: {}", self.ws_url);
231
232        // Dismiss any "Allow debugging connection?" dialog that may appear
233        // during WebSocket connection. The dialog appears asynchronously
234        // when the CDP connection is attempted, so we dismiss it repeatedly.
235        #[cfg(target_os = "macos")]
236        let _ws_url = self.ws_url.clone();
237        #[cfg(target_os = "macos")]
238        let dismiss_handle = tokio::spawn(async move {
239            for _i in 0..10 {
240                dismiss_allow_debugging_dialog();
241                tokio::time::sleep(std::time::Duration::from_millis(200)).await;
242            }
243        });
244
245        let connect_result = tokio_tungstenite::connect_async(&self.ws_url).await;
246
247        // Wait for dismiss task to finish
248        #[cfg(target_os = "macos")]
249        let _ = dismiss_handle.await;
250
251        let (ws_stream, _) = connect_result.map_err(|e| {
252            // One more dismiss attempt in case the dialog blocked the connection
253            #[cfg(target_os = "macos")]
254            dismiss_allow_debugging_dialog();
255            CdpError::LaunchFailed(format!("WebSocket connect failed: {e}"))
256        })?;
257
258        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
259        std::mem::forget(kill_tx);
260        Connection::new(ws_stream, kill_rx).await
261    }
262
263    /// Get the WebSocket URL.
264    pub fn ws_url(&self) -> &str {
265        &self.ws_url
266    }
267
268    /// Locate Chrome executable.
269    fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
270        // 1. Check explicit env var first (already done in Level 0)
271        if let Some(path) = browser_path {
272            if path.exists() {
273                return Some(path.to_string_lossy().to_string());
274            }
275        }
276
277        // 2. Check macOS default browser
278        #[cfg(target_os = "macos")]
279        if let Some(bundle) = default_browser_bundle() {
280            if let Some(exec_name) = browser_exec_name(&bundle) {
281                let exec = bundle_to_exec(&bundle, exec_name);
282                if exec.exists() {
283                    return Some(exec.to_string_lossy().to_string());
284                }
285            }
286        }
287
288        // 3. Fallback: hardcoded path list (original behavior)
289        let candidates = [
290            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
291            "/Applications/Chrome.app/Contents/MacOS/Chrome",
292            "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
293            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
294            "/Applications/Arc.app/Contents/MacOS/Arc",
295            "/Applications/Dia.app/Contents/MacOS/Dia",
296            "/usr/bin/chromium",
297            "/usr/bin/chromium-browser",
298            "/snap/bin/chromium",
299        ];
300
301        for candidate in &candidates {
302            if std::path::Path::new(candidate).exists() {
303                return Some(candidate.to_string());
304            }
305        }
306
307        // 4. Fallback: `which` search
308        for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
309            if let Ok(output) = std::process::Command::new("which").arg(name).output() {
310                if output.status.success() {
311                    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
312                    if !path.is_empty() && std::path::Path::new(&path).exists() {
313                        return Some(path);
314                    }
315                }
316            }
317        }
318
319        None
320    }
321
322    /// Resolve which profile directory to use.
323    /// Returns the real profile if available AND not in use by another browser.
324    /// Returns None to signal that a seeded temp profile should be used.
325    fn resolve_profile(explicit: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
326        // 1. Explicit profile from env var takes priority
327        if let Some(p) = explicit {
328            if p.exists() {
329                // Even explicit: check not in use to avoid crashes
330                if !Self::is_profile_in_use(&p) {
331                    return Some(p);
332                }
333            }
334        }
335
336        // 2. macOS default browser's real profile
337        #[cfg(target_os = "macos")]
338        if let Some(bundle) = default_browser_bundle() {
339            if let Some(suffix) = browser_profile_suffix(&bundle) {
340                if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
341                    let path = home.join("Library/Application Support").join(suffix);
342                    if path.exists() && !Self::is_profile_in_use(&path) {
343                        return Some(path);
344                    }
345                }
346            }
347        }
348
349        // 3. Fallback: common profile paths (check not in use)
350        let common_paths = [
351            "Library/Application Support/Google/Chrome/User Data",
352            "Library/Application Support/Dia/User Data",
353            "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
354        ];
355        if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
356            for suffix in &common_paths {
357                let path = home.join(suffix);
358                if path.exists() && !Self::is_profile_in_use(&path) {
359                    return Some(path);
360                }
361            }
362        }
363
364        // No usable real profile found → use seeded temp
365        None
366    }
367
368    /// Seed a fresh profile directory with synthetic Preferences and Local State
369    /// to suppress ALL first-run dialogs including sign-in forms.
370    /// Matches gsearch's `_pre_seed_profile()` function exactly.
371    fn seed_profile(profile_dir: &std::path::Path) {
372        let now = std::time::SystemTime::now()
373            .duration_since(std::time::UNIX_EPOCH)
374            .unwrap_or_default()
375            .as_micros() as u64;
376
377        // Write Preferences (suppresses welcome page, onboarding, etc.)
378        let prefs = serde_json::json!({
379            "browser": {
380                "has_seen_welcome_page": true
381            },
382            "profile": {
383                "exit_type": "Normal"
384            },
385            "default_apps_install_state": 3,
386            "in_product_help": {
387                "session_last_active_time": now.to_string(),
388                "session_number": 5,
389                "session_start_time": now.to_string()
390            }
391        });
392
393        // Write Local State (enterprise policy: skip_first_run_ui SUPPRESSES ALL DIALOGS)
394        let local_state = serde_json::json!({
395            "browser": {
396                "enabled_labs_experiments": [],
397                "last_redirect_origin": "",
398                "last_whats_new_milestone": "150"
399            },
400            "distribution": {
401                "skip_first_run_ui": true  // ← THIS IS THE KEY FIELD
402            }
403        });
404
405        // Write to both User Data/Default and Default (matching gsearch)
406        for sub in &["User Data/Default", "Default"] {
407            let prefs_dir = profile_dir.join(sub);
408            let _ = std::fs::create_dir_all(&prefs_dir);
409            let _ = std::fs::write(prefs_dir.join("Preferences"), serde_json::to_string(&prefs).unwrap());
410        }
411
412        // Write Local State to User Data and root (matching gsearch)
413        for sub in &["User Data", "."] {
414            let state_dir = profile_dir.join(sub);
415            let _ = std::fs::create_dir_all(&state_dir);
416            let _ = std::fs::write(state_dir.join("Local State"), serde_json::to_string(&local_state).unwrap());
417        }
418    }
419
420    /// Clean profile lock files.
421    fn clean_profile_locks(profile_dir: &std::path::Path) {
422        let lock_files = [
423            "SingletonLock",
424            "SingletonSocket",
425            "SingletonCookie",
426            "DevToolsActivePort",
427        ];
428        for name in &lock_files {
429            let path = profile_dir.join(name);
430            if path.exists() {
431                let _ = std::fs::remove_file(&path);
432            }
433        }
434    }
435
436    /// Check if a browser process is currently running with this profile directory.
437    /// On macOS, checks if any process has the SingletonLock file open.
438    fn is_profile_in_use(profile_dir: &std::path::Path) -> bool {
439        let lock_file = profile_dir.join("SingletonLock");
440        if !lock_file.exists() {
441            return false;
442        }
443        // On macOS, lsof can check if a process has this file open
444        #[cfg(target_os = "macos")]
445        {
446            let output = std::process::Command::new("lsof")
447                .args(["-F", "p", &lock_file.to_string_lossy()])
448                .output()
449                .ok();
450            if let Some(output) = output {
451                if output.status.success() && !output.stdout.is_empty() {
452                    return true;
453                }
454            }
455        }
456        false
457    }
458
459    /// Check if the browser is alive by probing the given port.
460    pub fn is_alive(cdp_port: u16) -> bool {
461        Self::probe_port(cdp_port)
462    }
463
464    /// Find existing browser by TCP probing and discovering WS URL.
465    pub async fn find_existing(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<Self> {
466        // Step 1: TCP probe — is anything listening on this port?
467        if !Self::probe_port(cdp_port) {
468            return None;
469        }
470
471        // Step 2: Try to discover WS URL from DevToolsActivePort
472        let ws_url = Self::discover_ws_url(explicit_profile_dir, cdp_port).await;
473
474        if let Some(ref url) = ws_url {
475            // Step 3: Verify via WebSocket
476            if Self::verify_ws(url).await.is_some() {
477                return Some(Browser {
478                    ws_url: url.clone(),
479                    cdp_port,
480                });
481            }
482        }
483
484        None
485    }
486
487    /// Discover WS URL by searching for DevToolsActivePort in common profile paths.
488    pub async fn discover_ws_url(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<String> {
489        let mut candidates: Vec<std::path::PathBuf> = Vec::new();
490
491        // 1. Explicit profile dir if provided
492        if let Some(dir) = explicit_profile_dir {
493            candidates.push(dir.to_path_buf());
494        }
495
496        // 2. Common macOS profile paths
497        #[cfg(target_os = "macos")]
498        {
499            if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
500                let common = [
501                    "Library/Application Support/Dia/User Data",
502                    "Library/Application Support/Google/Chrome",
503                    "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
504                    "Library/Application Support/Microsoft Edge/User Data",
505                ];
506                for path in &common {
507                    let full = home.join(path);
508                    if full.exists() {
509                        candidates.push(full);
510                    }
511                }
512            }
513        }
514
515        // Deduplicate
516        candidates.sort();
517        candidates.dedup();
518
519        for profile_dir in &candidates {
520            let active_port_path = profile_dir.join("DevToolsActivePort");
521            if let Ok(content) = std::fs::read_to_string(&active_port_path) {
522                let lines: Vec<&str> = content.trim().lines().collect();
523                if lines.len() >= 2 {
524                    if let Ok(file_port) = lines[0].trim().parse::<u16>() {
525                        if file_port == cdp_port {
526                            let ws_path = lines[1].trim();
527                            return Some(format!("ws://127.0.0.1:{}{}", cdp_port, ws_path));
528                        }
529                    }
530                }
531            }
532        }
533
534        None
535    }
536
537    /// Verify a WebSocket debugger URL by connecting and sending Browser.getVersion.
538    async fn verify_ws(ws_url: &str) -> Option<()> {
539        let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url.to_string()).await.ok()?;
540        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
541        std::mem::forget(kill_tx);
542        let mut conn = Connection::new(ws_stream, kill_rx).await.ok()?;
543        conn.call("Browser.getVersion", serde_json::json!({}))
544            .await
545            .ok()?;
546        Some(())
547    }
548
549    /// Probe port to see if it's accepting connections.
550    /// Tries IPv4 first, then IPv6.
551    pub fn probe_port(cdp_port: u16) -> bool {
552        let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
553        for addr in &addrs {
554            if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
555                if std::net::TcpStream::connect_timeout(
556                    &parsed,
557                    std::time::Duration::from_millis(500),
558                )
559                .is_ok()
560                {
561                    return true;
562                }
563            }
564        }
565        false
566    }
567
568}
569
570/// Dismiss the "Allow debugging connection?" dialog that Chrome/Dia shows
571/// when a CDP connection is attempted. The dialog text reads:
572///   "An application is requesting access to debug this browser.
573///    This gives it full access to your browsing data and browser functionality."
574///
575/// Uses macOS AppleScript/System Events to click the "Allow" button.
576/// Runs multiple times with delays since the dialog appears asynchronously
577/// during WebSocket connection.
578#[cfg(target_os = "macos")]
579pub fn dismiss_allow_debugging_dialog() {
580    // AppleScript to find and click "Allow" button in the debugging dialog
581    // Tries multiple approaches since dialog may be from Dia or Chrome
582    let scripts = [
583        // Approach 1: Find by window title containing "debug" or "Allow"
584        r#"tell application "System Events"
585            set browserProcesses to {"Dia", "Google Chrome", "Chromium", "Brave Browser", "Microsoft Edge"}
586            repeat with procName in browserProcesses
587                try
588                    tell process procName
589                        if exists (window 1) then
590                            try
591                                click button "Allow" of window 1
592                            end try
593                        end if
594                    end tell
595                end try
596            end repeat
597        end tell"#,
598        // Approach 2: Accessibility press (works for sheets/dialogs)
599        r#"tell application "System Events"
600            key code 36
601        end tell"#,
602    ];
603    
604    for script in &scripts {
605        let _ = std::process::Command::new("osascript")
606            .args(["-e", script])
607            .output();
608    }
609}
610
611/// Non-macOS: no-op
612#[cfg(not(target_os = "macos"))]
613pub fn dismiss_allow_debugging_dialog() {}
614
615impl Drop for Browser {
616    fn drop(&mut self) {
617        // Browser stays alive — it's persistent
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    #[test]
626    fn test_probe_port_no_server() {
627        assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
628    }
629
630    #[test]
631    fn test_find_chrome_returns_some_or_none() {
632        let result = Browser::find_chrome(None);
633        // Either finds Chrome or returns None — don't panic either way
634        if let Some(path) = result {
635            assert!(
636                std::path::Path::new(&path).exists(),
637                "Chrome path should exist: {}",
638                path
639            );
640        }
641    }
642
643    #[test]
644    fn test_error_types_compile() {
645        let _err = CdpError::LaunchFailed("test".into());
646        let _err = CdpError::NoWsUrl;
647        let _err = CdpError::Timeout(1000);
648    }
649
650    #[test]
651    fn test_launch_flags_include_disable_fre() {
652        let flags = [
653            "--disable-fre",
654            "--disable-search-engine-choice-screen",
655            "--no-first-run",
656            "--no-default-browser-check",
657            "--window-size=1280,720",
658        ];
659        for flag in &flags {
660            assert!(!flag.is_empty(), "Flag should not be empty");
661        }
662    }
663
664    #[test]
665    fn test_launch_flags_exclude_enable_automation() {
666        let forbidden = ["--enable-automation"];
667        for flag in &forbidden {
668            assert!(!flag.is_empty());
669        }
670    }
671
672    #[test]
673    fn test_state_path_removed() {
674        assert!(true, "BrowserState was removed, no state file written");
675    }
676
677    #[test]
678    fn test_fetch_ws_url_removed() {
679        assert!(!Browser::probe_port(19999), "probe_port should return false for unused port");
680    }
681
682    #[test]
683    fn test_is_profile_in_use_nonexistent_dir() {
684        let tmp = std::env::temp_dir().join("gthings-test-nonexistent");
685        assert!(!Browser::is_profile_in_use(&tmp));
686    }
687
688    #[test]
689    fn test_wait_for_active_port_invalid_path() {
690        let rt = tokio::runtime::Runtime::new().unwrap();
691        let result = rt.block_on(async {
692            let _ = Browser::verify_ws("ws://127.0.0.1:1").await;
693            true
694        });
695        assert!(result);
696    }
697}