browser_automation_cli/native/cdp/
discovery.rs1#![allow(missing_docs)]
2use std::time::Duration;
3
4use chromiumoxide::browser::Browser;
5use futures::StreamExt;
6
7use super::types::BrowserVersionInfo;
8
9const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
11
12pub 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
29pub 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 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
44pub(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 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 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 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
83fn bracket_ipv6(host: &str) -> String {
85 if host.contains(':') && !host.starts_with('[') {
86 format!("[{}]", host)
87 } else {
88 host.to_string()
89 }
90}
91
92async 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
108fn 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
122fn 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 if url.contains('?') {
135 format!("{}&{}", url, q)
136 } else {
137 format!("{}?{}", url, q)
138 }
139 }
140 }
141 _ => url.to_string(),
142 }
143}
144
145async 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 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
177async 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 });
263
264 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 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}