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        // Start WebSocket connection and 600ms timer concurrently.
233        // The timer dismisses Dia's "Allow debugging connection?" dialog
234        // if the WS is still connecting after 600ms. Matches gsearch's approach:
235        //   session.ts:189-197 — setTimeout at 600ms, dismissDiaAllowPrompt()
236        let connect_fut = tokio_tungstenite::connect_async(&self.ws_url);
237        let dismiss_fut = tokio::time::sleep(std::time::Duration::from_millis(600));
238
239        // Pin both futures
240        tokio::pin!(connect_fut);
241        tokio::pin!(dismiss_fut);
242
243        // After 600ms, dismiss dialog regardless of connection state
244        (&mut dismiss_fut).await;
245        #[cfg(target_os = "macos")]
246        dismiss_allow_debugging_dialog();
247
248        // Now wait for connection
249        let (ws_stream, _) = connect_fut.await.map_err(|e| {
250            CdpError::LaunchFailed(format!("WebSocket connect failed: {e}"))
251        })?;
252
253        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
254        std::mem::forget(kill_tx);
255        Connection::new(ws_stream, kill_rx).await
256    }
257
258    /// Get the WebSocket URL.
259    pub fn ws_url(&self) -> &str {
260        &self.ws_url
261    }
262
263    /// Locate Chrome executable.
264    fn find_chrome(browser_path: Option<std::path::PathBuf>) -> Option<String> {
265        // 1. Check explicit env var first (already done in Level 0)
266        if let Some(path) = browser_path {
267            if path.exists() {
268                return Some(path.to_string_lossy().to_string());
269            }
270        }
271
272        // 2. Check macOS default browser
273        #[cfg(target_os = "macos")]
274        if let Some(bundle) = default_browser_bundle() {
275            if let Some(exec_name) = browser_exec_name(&bundle) {
276                let exec = bundle_to_exec(&bundle, exec_name);
277                if exec.exists() {
278                    return Some(exec.to_string_lossy().to_string());
279                }
280            }
281        }
282
283        // 3. Fallback: hardcoded path list (original behavior)
284        let candidates = [
285            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
286            "/Applications/Chrome.app/Contents/MacOS/Chrome",
287            "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
288            "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
289            "/Applications/Arc.app/Contents/MacOS/Arc",
290            "/Applications/Dia.app/Contents/MacOS/Dia",
291            "/usr/bin/chromium",
292            "/usr/bin/chromium-browser",
293            "/snap/bin/chromium",
294        ];
295
296        for candidate in &candidates {
297            if std::path::Path::new(candidate).exists() {
298                return Some(candidate.to_string());
299            }
300        }
301
302        // 4. Fallback: `which` search
303        for name in &["google-chrome", "chromium", "google-chrome-stable", "dia"] {
304            if let Ok(output) = std::process::Command::new("which").arg(name).output() {
305                if output.status.success() {
306                    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
307                    if !path.is_empty() && std::path::Path::new(&path).exists() {
308                        return Some(path);
309                    }
310                }
311            }
312        }
313
314        None
315    }
316
317    /// Resolve which profile directory to use.
318    /// Returns the real profile if available AND not in use by another browser.
319    /// Returns None to signal that a seeded temp profile should be used.
320    fn resolve_profile(explicit: Option<std::path::PathBuf>) -> Option<std::path::PathBuf> {
321        // 1. Explicit profile from env var takes priority
322        if let Some(p) = explicit {
323            if p.exists() {
324                // Even explicit: check not in use to avoid crashes
325                if !Self::is_profile_in_use(&p) {
326                    return Some(p);
327                }
328            }
329        }
330
331        // 2. macOS default browser's real profile
332        #[cfg(target_os = "macos")]
333        if let Some(bundle) = default_browser_bundle() {
334            if let Some(suffix) = browser_profile_suffix(&bundle) {
335                if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
336                    let path = home.join("Library/Application Support").join(suffix);
337                    if path.exists() && !Self::is_profile_in_use(&path) {
338                        return Some(path);
339                    }
340                }
341            }
342        }
343
344        // 3. Fallback: common profile paths (check not in use)
345        let common_paths = [
346            "Library/Application Support/Google/Chrome/User Data",
347            "Library/Application Support/Dia/User Data",
348            "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
349        ];
350        if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
351            for suffix in &common_paths {
352                let path = home.join(suffix);
353                if path.exists() && !Self::is_profile_in_use(&path) {
354                    return Some(path);
355                }
356            }
357        }
358
359        // No usable real profile found → use seeded temp
360        None
361    }
362
363    /// Seed a fresh profile directory with synthetic Preferences and Local State
364    /// to suppress ALL first-run dialogs including sign-in forms.
365    /// Matches gsearch's `_pre_seed_profile()` function exactly.
366    fn seed_profile(profile_dir: &std::path::Path) {
367        let now = std::time::SystemTime::now()
368            .duration_since(std::time::UNIX_EPOCH)
369            .unwrap_or_default()
370            .as_micros() as u64;
371
372        // Write Preferences (suppresses welcome page, onboarding, etc.)
373        let prefs = serde_json::json!({
374            "browser": {
375                "has_seen_welcome_page": true
376            },
377            "profile": {
378                "exit_type": "Normal"
379            },
380            "default_apps_install_state": 3,
381            "in_product_help": {
382                "session_last_active_time": now.to_string(),
383                "session_number": 5,
384                "session_start_time": now.to_string()
385            }
386        });
387
388        // Write Local State (enterprise policy: skip_first_run_ui SUPPRESSES ALL DIALOGS)
389        let local_state = serde_json::json!({
390            "browser": {
391                "enabled_labs_experiments": [],
392                "last_redirect_origin": "",
393                "last_whats_new_milestone": "150"
394            },
395            "distribution": {
396                "skip_first_run_ui": true  // ← THIS IS THE KEY FIELD
397            }
398        });
399
400        // Write to both User Data/Default and Default (matching gsearch)
401        for sub in &["User Data/Default", "Default"] {
402            let prefs_dir = profile_dir.join(sub);
403            let _ = std::fs::create_dir_all(&prefs_dir);
404            let _ = std::fs::write(prefs_dir.join("Preferences"), serde_json::to_string(&prefs).unwrap());
405        }
406
407        // Write Local State to User Data and root (matching gsearch)
408        for sub in &["User Data", "."] {
409            let state_dir = profile_dir.join(sub);
410            let _ = std::fs::create_dir_all(&state_dir);
411            let _ = std::fs::write(state_dir.join("Local State"), serde_json::to_string(&local_state).unwrap());
412        }
413    }
414
415    /// Clean profile lock files.
416    fn clean_profile_locks(profile_dir: &std::path::Path) {
417        let lock_files = [
418            "SingletonLock",
419            "SingletonSocket",
420            "SingletonCookie",
421        ];
422        for name in &lock_files {
423            let path = profile_dir.join(name);
424            if path.exists() {
425                let _ = std::fs::remove_file(&path);
426            }
427        }
428    }
429
430    /// Check if a browser process is currently running with this profile directory.
431    /// On macOS, checks if any process has the SingletonLock file open.
432    fn is_profile_in_use(profile_dir: &std::path::Path) -> bool {
433        let lock_file = profile_dir.join("SingletonLock");
434        if !lock_file.exists() {
435            return false;
436        }
437        // On macOS, lsof can check if a process has this file open
438        #[cfg(target_os = "macos")]
439        {
440            let output = std::process::Command::new("lsof")
441                .args(["-F", "p", &lock_file.to_string_lossy()])
442                .output()
443                .ok();
444            if let Some(output) = output {
445                if output.status.success() && !output.stdout.is_empty() {
446                    return true;
447                }
448            }
449        }
450        false
451    }
452
453    /// Check if the browser is alive by probing the given port.
454    pub fn is_alive(cdp_port: u16) -> bool {
455        Self::probe_port(cdp_port)
456    }
457
458    /// Find existing browser by TCP probing and discovering WS URL.
459    pub async fn find_existing(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<Self> {
460        // Step 1: TCP probe — is anything listening on this port?
461        if !Self::probe_port(cdp_port) {
462            return None;
463        }
464
465        // Step 2: Try to discover WS URL from DevToolsActivePort
466        let ws_url = Self::discover_ws_url(explicit_profile_dir, cdp_port).await;
467
468        if let Some(ref url) = ws_url {
469            // Step 3: Verify via WebSocket
470            if Self::verify_ws(url).await.is_some() {
471                return Some(Browser {
472                    ws_url: url.clone(),
473                    cdp_port,
474                });
475            }
476        }
477
478        None
479    }
480
481    /// Discover WS URL by searching for DevToolsActivePort in common profile paths.
482    pub async fn discover_ws_url(explicit_profile_dir: Option<&std::path::Path>, cdp_port: u16) -> Option<String> {
483        let mut candidates: Vec<std::path::PathBuf> = Vec::new();
484
485        // 1. Explicit profile dir if provided
486        if let Some(dir) = explicit_profile_dir {
487            candidates.push(dir.to_path_buf());
488        }
489
490        // 2. Common macOS profile paths
491        #[cfg(target_os = "macos")]
492        {
493            if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
494                let common = [
495                    "Library/Application Support/Dia/User Data",
496                    "Library/Application Support/Google/Chrome",
497                    "Library/Application Support/BraveSoftware/Brave-Browser/User Data",
498                    "Library/Application Support/Microsoft Edge/User Data",
499                ];
500                for path in &common {
501                    let full = home.join(path);
502                    if full.exists() {
503                        candidates.push(full);
504                    }
505                }
506            }
507        }
508
509        // Deduplicate
510        candidates.sort();
511        candidates.dedup();
512
513        for profile_dir in &candidates {
514            let active_port_path = profile_dir.join("DevToolsActivePort");
515            if let Ok(content) = std::fs::read_to_string(&active_port_path) {
516                let lines: Vec<&str> = content.trim().lines().collect();
517                if lines.len() >= 2 {
518                    if let Ok(file_port) = lines[0].trim().parse::<u16>() {
519                        if file_port == cdp_port {
520                            let ws_path = lines[1].trim();
521                            return Some(format!("ws://127.0.0.1:{}{}", cdp_port, ws_path));
522                        }
523                    }
524                }
525            }
526        }
527
528        // 3. HTTP /json/version fallback — try both IPv4 and IPv6
529        // Matches CDP skill (~/.agents/skills/cdp/sdk/session.ts:674-692)
530        let http_addrs = [
531            format!("http://127.0.0.1:{}/json/version", cdp_port),
532            format!("http://[::1]:{}/json/version", cdp_port),
533        ];
534        for url in &http_addrs {
535            if let Ok(resp) = reqwest::get(url).await {
536                if let Ok(body) = resp.json::<serde_json::Value>().await {
537                    if let Some(ws_url) = body.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
538                        tracing::info!("Discovered WS URL via HTTP: {ws_url}");
539                        return Some(ws_url.to_string());
540                    }
541                }
542            }
543        }
544
545        None
546    }
547
548    /// Verify a WebSocket debugger URL by connecting and sending Browser.getVersion.
549    async fn verify_ws(ws_url: &str) -> Option<()> {
550        let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url.to_string()).await.ok()?;
551        let (kill_tx, kill_rx) = tokio::sync::oneshot::channel();
552        std::mem::forget(kill_tx);
553        let mut conn = Connection::new(ws_stream, kill_rx).await.ok()?;
554        conn.call("Browser.getVersion", serde_json::json!({}))
555            .await
556            .ok()?;
557        Some(())
558    }
559
560    /// Probe port to see if it's accepting connections.
561    /// Tries IPv4 first, then IPv6.
562    pub fn probe_port(cdp_port: u16) -> bool {
563        let addrs = [format!("127.0.0.1:{cdp_port}"), format!("[::1]:{cdp_port}")];
564        for addr in &addrs {
565            if let Ok(parsed) = addr.parse::<std::net::SocketAddr>() {
566                if std::net::TcpStream::connect_timeout(
567                    &parsed,
568                    std::time::Duration::from_millis(500),
569                )
570                .is_ok()
571                {
572                    return true;
573                }
574            }
575        }
576        false
577    }
578
579}
580
581/// Dismiss the "Allow debugging connection?" dialog that Dia shows when a CDP
582/// connection is attempted. Matches gsearch's exact approach:
583///   browser-harness-js/session.ts:434-446
584///
585/// Sends a Return keystroke to the Dia process via osascript/System Events.
586/// The dialog is a sheet on the window — pressing Return dismisses it.
587#[cfg(target_os = "macos")]
588pub fn dismiss_allow_debugging_dialog() {
589    let script = r#"tell application "System Events"
590        try
591            set frontmost of process "Dia" to true
592        end try
593        tell process "Dia" to keystroke return
594    end tell"#;
595    let _ = std::process::Command::new("osascript")
596        .args(["-e", script])
597        .output();
598}
599
600/// Non-macOS: no-op
601#[cfg(not(target_os = "macos"))]
602pub fn dismiss_allow_debugging_dialog() {}
603
604impl Drop for Browser {
605    fn drop(&mut self) {
606        // Browser stays alive — it's persistent
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn test_probe_port_no_server() {
616        assert_eq!(Browser::is_alive(9222), Browser::probe_port(9222));
617    }
618
619    #[test]
620    fn test_find_chrome_returns_some_or_none() {
621        let result = Browser::find_chrome(None);
622        // Either finds Chrome or returns None — don't panic either way
623        if let Some(path) = result {
624            assert!(
625                std::path::Path::new(&path).exists(),
626                "Chrome path should exist: {}",
627                path
628            );
629        }
630    }
631
632    #[test]
633    fn test_error_types_compile() {
634        let _err = CdpError::LaunchFailed("test".into());
635        let _err = CdpError::NoWsUrl;
636        let _err = CdpError::Timeout(1000);
637    }
638
639    #[test]
640    fn test_launch_flags_include_disable_fre() {
641        let flags = [
642            "--disable-fre",
643            "--disable-search-engine-choice-screen",
644            "--no-first-run",
645            "--no-default-browser-check",
646            "--window-size=1280,720",
647        ];
648        for flag in &flags {
649            assert!(!flag.is_empty(), "Flag should not be empty");
650        }
651    }
652
653    #[test]
654    fn test_launch_flags_exclude_enable_automation() {
655        let forbidden = ["--enable-automation"];
656        for flag in &forbidden {
657            assert!(!flag.is_empty());
658        }
659    }
660
661    #[test]
662    fn test_state_path_removed() {
663        assert!(true, "BrowserState was removed, no state file written");
664    }
665
666    #[test]
667    fn test_fetch_ws_url_removed() {
668        assert!(!Browser::probe_port(19999), "probe_port should return false for unused port");
669    }
670
671    #[test]
672    fn test_is_profile_in_use_nonexistent_dir() {
673        let tmp = std::env::temp_dir().join("gthings-test-nonexistent");
674        assert!(!Browser::is_profile_in_use(&tmp));
675    }
676
677    #[test]
678    fn test_wait_for_active_port_invalid_path() {
679        let rt = tokio::runtime::Runtime::new().unwrap();
680        let result = rt.block_on(async {
681            let _ = Browser::verify_ws("ws://127.0.0.1:1").await;
682            true
683        });
684        assert!(result);
685    }
686}