Skip to main content

browser_automation_cli/native/cdp/
discovery.rs

1#![allow(missing_docs)]
2use std::time::Duration;
3
4use chromiumoxide::browser::Browser;
5use futures::StreamExt;
6
7use super::types::BrowserVersionInfo;
8
9/// Default timeout for CDP discovery HTTP requests.
10const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
11
12/// Discover the CDP WebSocket URL for the given host and port.
13///
14/// Tries three methods in order: `/json/version`, `/json/list`, and a direct
15/// WebSocket connection to `/devtools/browser`. The returned URL has its
16/// host/port rewritten to match the requested target.
17///
18/// An optional `query` string (without the leading `?`) is appended to the
19/// final WebSocket URL so that user-supplied URL parameters (e.g.
20/// `?mode=Hello`) are forwarded to the remote endpoint.
21pub async fn discover_cdp_url(
22    host: &str,
23    port: u16,
24    query: Option<&str>,
25) -> Result<String, String> {
26    discover_cdp_url_with_timeout(host, port, query, DEFAULT_DISCOVERY_TIMEOUT).await
27}
28
29/// Like [`discover_cdp_url`] but with a custom request timeout.
30pub async fn discover_cdp_url_with_timeout(
31    host: &str,
32    port: u16,
33    query: Option<&str>,
34    timeout: Duration,
35) -> Result<String, String> {
36    // GAP-013: retry the full discovery cascade on transient network/timeouts.
37    let cfg = crate::retry::RetryConfig::cdp();
38    crate::retry::retry_async(cfg, || async {
39        discover_cdp_url_once(host, port, query, timeout).await
40    })
41    .await
42}
43
44/// Single-pass discovery cascade (no retry). Used by tests and by the retry wrapper.
45pub(crate) async fn discover_cdp_url_once(
46    host: &str,
47    port: u16,
48    query: Option<&str>,
49    timeout: Duration,
50) -> Result<String, String> {
51    // Primary: /json/version (standard path)
52    let version_err = match fetch_cdp_info(host, port, timeout).await {
53        Ok(info) => {
54            if let Some(ws_url) = info.web_socket_debugger_url {
55                return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query));
56            }
57            format!(
58                "No webSocketDebuggerUrl in /json/version at {}:{}",
59                host, port
60            )
61        }
62        Err(e) => e,
63    };
64
65    // Fallback: /json/list (returns target list; look for the browser target)
66    let list_err = match fetch_cdp_list(host, port, timeout).await {
67        Ok(ws_url) => return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query)),
68        Err(e) => e,
69    };
70
71    // Final fallback: direct WebSocket at /devtools/browser.
72    // Chrome 136+ with UI-based remote debugging (chrome://inspect) exposes
73    // CDP over WebSocket but does not serve HTTP discovery endpoints.
74    match discover_cdp_ws(host, port, timeout).await {
75        Ok(ws_url) => Ok(append_query(&ws_url, query)),
76        Err(ws_err) => Err(format!(
77            "All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
78            host, port, version_err, list_err, ws_err
79        )),
80    }
81}
82
83/// Bracket an IPv6 address for use in URLs. No-op for IPv4 or already-bracketed addresses.
84fn bracket_ipv6(host: &str) -> String {
85    if host.contains(':') && !host.starts_with('[') {
86        format!("[{}]", host)
87    } else {
88        host.to_string()
89    }
90}
91
92/// Fetch `/json/version` from the given host:port and parse the response.
93async fn fetch_cdp_info(
94    host: &str,
95    port: u16,
96    timeout: Duration,
97) -> Result<BrowserVersionInfo, String> {
98    let url = format!("http://{}:{}/json/version", bracket_ipv6(host), port);
99
100    let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
101        .await
102        .map_err(|_| format!("Timeout connecting to CDP at {}:{}", host, port))?
103        .map_err(|e| format!("Failed to connect to CDP at {}:{}: {}", host, port, e))?;
104
105    serde_json::from_str(&body).map_err(|e| format!("Invalid /json/version response: {}", e))
106}
107
108/// Rewrite the host and port in a WebSocket URL to match the target we
109/// actually connected to. Chrome's `/json/version` always returns
110/// `ws://127.0.0.1:<local-port>/...` which is unreachable when the
111/// browser is on a remote machine or behind a port-forward.
112fn rewrite_ws_host(ws_url: &str, host: &str, port: u16) -> String {
113    if let Ok(mut parsed) = url::Url::parse(ws_url) {
114        let _ = parsed.set_host(Some(&bracket_ipv6(host)));
115        let _ = parsed.set_port(Some(port));
116        parsed.to_string()
117    } else {
118        ws_url.to_string()
119    }
120}
121
122/// Append a query string to a URL, preserving any existing query parameters.
123fn append_query(url: &str, query: Option<&str>) -> String {
124    match query {
125        Some(q) if !q.is_empty() => {
126            if let Ok(mut parsed) = url::Url::parse(url) {
127                {
128                    let mut pairs = parsed.query_pairs_mut();
129                    pairs.extend_pairs(url::form_urlencoded::parse(q.as_bytes()));
130                }
131                parsed.to_string()
132            } else {
133                // Fallback: raw string append
134                if url.contains('?') {
135                    format!("{}&{}", url, q)
136                } else {
137                    format!("{}?{}", url, q)
138                }
139            }
140        }
141        _ => url.to_string(),
142    }
143}
144
145/// Fetch `/json/list` and extract the `webSocketDebuggerUrl` from the first
146/// target with `type == "browser"`, or the first target if none has that type.
147async fn fetch_cdp_list(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
148    let url = format!("http://{}:{}/json/list", bracket_ipv6(host), port);
149
150    let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
151        .await
152        .map_err(|_| format!("Timeout connecting to /json/list at {}:{}", host, port))?
153        .map_err(|e| {
154            format!(
155                "Failed to connect to /json/list at {}:{}: {}",
156                host, port, e
157            )
158        })?;
159
160    let targets: Vec<serde_json::Value> =
161        serde_json::from_str(&body).map_err(|e| format!("Invalid /json/list response: {}", e))?;
162
163    // Prefer targets with type "browser", fall back to first target with a ws URL
164    let browser_target = targets
165        .iter()
166        .find(|t| t.get("type").and_then(|v| v.as_str()) == Some("browser"));
167
168    let target = browser_target.or_else(|| targets.first());
169
170    target
171        .and_then(|t| t.get("webSocketDebuggerUrl"))
172        .and_then(|v| v.as_str())
173        .map(|s| s.to_string())
174        .ok_or_else(|| "No webSocketDebuggerUrl found in /json/list targets".to_string())
175}
176
177/// Discover a CDP endpoint by connecting via chromiumoxide `Browser::connect`
178/// and verifying `Browser.getVersion` / version() succeeds.
179/// Returns the WebSocket URL on success.
180async fn discover_cdp_ws(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
181    let ws_url = format!("ws://{}:{}/devtools/browser", bracket_ipv6(host), port);
182
183    tokio::time::timeout(timeout, async {
184        let (mut browser, mut handler) = Browser::connect(&ws_url)
185            .await
186            .map_err(|e| format!("WebSocket connect failed at {}: {}", ws_url, e))?;
187
188        let handle = tokio::spawn(async move {
189            while let Some(h) = handler.next().await {
190                if h.is_err() {
191                    break;
192                }
193            }
194        });
195
196        let version = browser
197            .version()
198            .await
199            .map_err(|e| format!("Browser.getVersion failed: {e}"))?;
200        let _ = version.product;
201        let _ = browser.close().await;
202        let _ = handle.await;
203        Ok::<(), String>(())
204    })
205    .await
206    .map_err(|_| format!("Timeout connecting to WebSocket at {}", ws_url))?
207    .map(|()| ws_url)
208}
209
210async fn reqwest_get_string(url: &str) -> Result<String, String> {
211    let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
212    resp.text().await.map_err(|e| e.to_string())
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use tokio::io::{AsyncReadExt, AsyncWriteExt};
219    use tokio::net::TcpListener;
220
221    const HTTP_404: &str =
222        "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
223
224    fn http_200(body: &str) -> String {
225        format!(
226            "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
227            body.len(), body
228        )
229    }
230
231    async fn accept_http(listener: &TcpListener, response: &str) {
232        let (mut s, _) = listener.accept().await.unwrap();
233        let mut buf = [0u8; 1024];
234        let _ = s.read(&mut buf).await;
235        s.write_all(response.as_bytes()).await.unwrap();
236    }
237
238    #[tokio::test]
239    async fn discovers_ws_url_from_json_version() {
240        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
241        let port = listener.local_addr().unwrap().port();
242        let server = tokio::spawn(async move {
243            accept_http(
244                &listener,
245                &http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
246            )
247            .await;
248        });
249
250        let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
251        assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
252        server.await.unwrap();
253    }
254
255    #[tokio::test]
256    async fn returns_error_when_version_returns_invalid_json() {
257        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
258        let port = listener.local_addr().unwrap().port();
259        let server = tokio::spawn(async move {
260            accept_http(&listener, &http_200("not-json")).await;
261            // /json/list and ws fallback both fail (server closes)
262        });
263
264        // Use single-pass discovery: outer retry would re-hit a one-shot mock server.
265        let err = discover_cdp_url_once("127.0.0.1", port, None, Duration::from_secs(2))
266            .await
267            .unwrap_err();
268        assert!(
269            err.contains("Invalid /json/version response") || err.contains("/json/version"),
270            "unexpected discovery error: {err}"
271        );
272        server.await.unwrap();
273    }
274
275    #[tokio::test]
276    async fn falls_back_to_json_list_on_version_404() {
277        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
278        let port = listener.local_addr().unwrap().port();
279        let server = tokio::spawn(async move {
280            accept_http(&listener, HTTP_404).await;
281            accept_http(
282                &listener,
283                &http_200(r#"[{"type":"browser","webSocketDebuggerUrl":"ws://127.0.0.1:1234/devtools/browser/abc"}]"#),
284            ).await;
285        });
286
287        let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
288        assert!(ws_url.contains("/devtools/browser/abc"));
289        assert!(ws_url.contains(&port.to_string()));
290        server.await.unwrap();
291    }
292
293    #[tokio::test]
294    async fn all_discovery_methods_fail_when_http_404_and_no_browser() {
295        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
296        let port = listener.local_addr().unwrap().port();
297        let server = tokio::spawn(async move {
298            // /json/version -> 404, /json/list -> 404; WS connect has no CDP browser
299            accept_http(&listener, HTTP_404).await;
300            accept_http(&listener, HTTP_404).await;
301        });
302
303        let err = discover_cdp_url("127.0.0.1", port, None).await.unwrap_err();
304        assert!(
305            err.contains("All CDP discovery methods failed"),
306            "unexpected error: {err}"
307        );
308        let _ = server.await;
309    }
310
311    #[test]
312    fn rewrite_ws_host_replaces_host_and_port() {
313        let original = "ws://127.0.0.1:9222/devtools/browser/abc";
314        let rewritten = rewrite_ws_host(original, "10.211.55.12", 9223);
315        assert_eq!(rewritten, "ws://10.211.55.12:9223/devtools/browser/abc");
316    }
317
318    #[test]
319    fn rewrite_ws_host_handles_ipv6() {
320        let original = "ws://127.0.0.1:9222/devtools/browser/abc";
321        let rewritten = rewrite_ws_host(original, "::1", 9222);
322        assert_eq!(rewritten, "ws://[::1]:9222/devtools/browser/abc");
323    }
324
325    #[test]
326    fn append_query_adds_params_to_url_without_query() {
327        let url = "ws://127.0.0.1:9222/devtools/browser/abc";
328        let result = append_query(url, Some("mode=Hello"));
329        assert_eq!(
330            result,
331            "ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello"
332        );
333    }
334
335    #[test]
336    fn append_query_merges_with_existing_query() {
337        let url = "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz";
338        let result = append_query(url, Some("mode=Hello"));
339        assert_eq!(
340            result,
341            "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz&mode=Hello"
342        );
343    }
344
345    #[test]
346    fn append_query_noop_for_none() {
347        let url = "ws://127.0.0.1:9222/devtools/browser/abc";
348        let result = append_query(url, None);
349        assert_eq!(result, url);
350    }
351
352    #[test]
353    fn append_query_noop_for_empty() {
354        let url = "ws://127.0.0.1:9222/devtools/browser/abc";
355        let result = append_query(url, Some(""));
356        assert_eq!(result, url);
357    }
358
359    #[test]
360    fn append_query_handles_multiple_params() {
361        let url = "ws://127.0.0.1:9222/devtools/browser/abc";
362        let result = append_query(url, Some("mode=Hello&token=abc"));
363        assert_eq!(
364            result,
365            "ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello&token=abc"
366        );
367    }
368
369    #[tokio::test]
370    async fn discover_preserves_query_params() {
371        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
372        let port = listener.local_addr().unwrap().port();
373        let server = tokio::spawn(async move {
374            accept_http(
375                &listener,
376                &http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
377            )
378            .await;
379        });
380
381        let ws_url = discover_cdp_url("127.0.0.1", port, Some("mode=Hello"))
382            .await
383            .unwrap();
384        assert_eq!(ws_url, format!("ws://127.0.0.1:{}/?mode=Hello", port));
385        server.await.unwrap();
386    }
387}