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 version_err = match fetch_cdp_info(host, port, timeout).await {
38 Ok(info) => {
39 if let Some(ws_url) = info.web_socket_debugger_url {
40 return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query));
41 }
42 format!(
43 "No webSocketDebuggerUrl in /json/version at {}:{}",
44 host, port
45 )
46 }
47 Err(e) => e,
48 };
49
50 let list_err = match fetch_cdp_list(host, port, timeout).await {
52 Ok(ws_url) => return Ok(append_query(&rewrite_ws_host(&ws_url, host, port), query)),
53 Err(e) => e,
54 };
55
56 match discover_cdp_ws(host, port, timeout).await {
60 Ok(ws_url) => Ok(append_query(&ws_url, query)),
61 Err(ws_err) => Err(format!(
62 "All CDP discovery methods failed for {}:{}: /json/version: {}; /json/list: {}; WebSocket: {}",
63 host, port, version_err, list_err, ws_err
64 )),
65 }
66}
67
68fn bracket_ipv6(host: &str) -> String {
70 if host.contains(':') && !host.starts_with('[') {
71 format!("[{}]", host)
72 } else {
73 host.to_string()
74 }
75}
76
77async fn fetch_cdp_info(
79 host: &str,
80 port: u16,
81 timeout: Duration,
82) -> Result<BrowserVersionInfo, String> {
83 let url = format!("http://{}:{}/json/version", bracket_ipv6(host), port);
84
85 let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
86 .await
87 .map_err(|_| format!("Timeout connecting to CDP at {}:{}", host, port))?
88 .map_err(|e| format!("Failed to connect to CDP at {}:{}: {}", host, port, e))?;
89
90 serde_json::from_str(&body).map_err(|e| format!("Invalid /json/version response: {}", e))
91}
92
93fn rewrite_ws_host(ws_url: &str, host: &str, port: u16) -> String {
98 if let Ok(mut parsed) = url::Url::parse(ws_url) {
99 let _ = parsed.set_host(Some(&bracket_ipv6(host)));
100 let _ = parsed.set_port(Some(port));
101 parsed.to_string()
102 } else {
103 ws_url.to_string()
104 }
105}
106
107fn append_query(url: &str, query: Option<&str>) -> String {
109 match query {
110 Some(q) if !q.is_empty() => {
111 if let Ok(mut parsed) = url::Url::parse(url) {
112 {
113 let mut pairs = parsed.query_pairs_mut();
114 pairs.extend_pairs(url::form_urlencoded::parse(q.as_bytes()));
115 }
116 parsed.to_string()
117 } else {
118 if url.contains('?') {
120 format!("{}&{}", url, q)
121 } else {
122 format!("{}?{}", url, q)
123 }
124 }
125 }
126 _ => url.to_string(),
127 }
128}
129
130async fn fetch_cdp_list(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
133 let url = format!("http://{}:{}/json/list", bracket_ipv6(host), port);
134
135 let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
136 .await
137 .map_err(|_| format!("Timeout connecting to /json/list at {}:{}", host, port))?
138 .map_err(|e| {
139 format!(
140 "Failed to connect to /json/list at {}:{}: {}",
141 host, port, e
142 )
143 })?;
144
145 let targets: Vec<serde_json::Value> =
146 serde_json::from_str(&body).map_err(|e| format!("Invalid /json/list response: {}", e))?;
147
148 let browser_target = targets
150 .iter()
151 .find(|t| t.get("type").and_then(|v| v.as_str()) == Some("browser"));
152
153 let target = browser_target.or_else(|| targets.first());
154
155 target
156 .and_then(|t| t.get("webSocketDebuggerUrl"))
157 .and_then(|v| v.as_str())
158 .map(|s| s.to_string())
159 .ok_or_else(|| "No webSocketDebuggerUrl found in /json/list targets".to_string())
160}
161
162async fn discover_cdp_ws(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
166 let ws_url = format!("ws://{}:{}/devtools/browser", bracket_ipv6(host), port);
167
168 tokio::time::timeout(timeout, async {
169 let (mut browser, mut handler) = Browser::connect(&ws_url)
170 .await
171 .map_err(|e| format!("WebSocket connect failed at {}: {}", ws_url, e))?;
172
173 let handle = tokio::spawn(async move {
174 while let Some(h) = handler.next().await {
175 if h.is_err() {
176 break;
177 }
178 }
179 });
180
181 let version = browser
182 .version()
183 .await
184 .map_err(|e| format!("Browser.getVersion failed: {e}"))?;
185 let _ = version.product;
186 let _ = browser.close().await;
187 let _ = handle.await;
188 Ok::<(), String>(())
189 })
190 .await
191 .map_err(|_| format!("Timeout connecting to WebSocket at {}", ws_url))?
192 .map(|()| ws_url)
193}
194
195async fn reqwest_get_string(url: &str) -> Result<String, String> {
196 let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
197 resp.text().await.map_err(|e| e.to_string())
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use tokio::io::{AsyncReadExt, AsyncWriteExt};
204 use tokio::net::TcpListener;
205
206 const HTTP_404: &str =
207 "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
208
209 fn http_200(body: &str) -> String {
210 format!(
211 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
212 body.len(), body
213 )
214 }
215
216 async fn accept_http(listener: &TcpListener, response: &str) {
217 let (mut s, _) = listener.accept().await.unwrap();
218 let mut buf = [0u8; 1024];
219 let _ = s.read(&mut buf).await;
220 s.write_all(response.as_bytes()).await.unwrap();
221 }
222
223 #[tokio::test]
224 async fn discovers_ws_url_from_json_version() {
225 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
226 let port = listener.local_addr().unwrap().port();
227 let server = tokio::spawn(async move {
228 accept_http(
229 &listener,
230 &http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
231 )
232 .await;
233 });
234
235 let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
236 assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
237 server.await.unwrap();
238 }
239
240 #[tokio::test]
241 async fn returns_error_when_version_returns_invalid_json() {
242 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
243 let port = listener.local_addr().unwrap().port();
244 let server = tokio::spawn(async move {
245 accept_http(&listener, &http_200("not-json")).await;
246 });
248
249 let err = discover_cdp_url("127.0.0.1", port, None).await.unwrap_err();
250 assert!(err.contains("Invalid /json/version response"));
251 server.await.unwrap();
252 }
253
254 #[tokio::test]
255 async fn falls_back_to_json_list_on_version_404() {
256 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
257 let port = listener.local_addr().unwrap().port();
258 let server = tokio::spawn(async move {
259 accept_http(&listener, HTTP_404).await;
260 accept_http(
261 &listener,
262 &http_200(r#"[{"type":"browser","webSocketDebuggerUrl":"ws://127.0.0.1:1234/devtools/browser/abc"}]"#),
263 ).await;
264 });
265
266 let ws_url = discover_cdp_url("127.0.0.1", port, None).await.unwrap();
267 assert!(ws_url.contains("/devtools/browser/abc"));
268 assert!(ws_url.contains(&port.to_string()));
269 server.await.unwrap();
270 }
271
272 #[tokio::test]
273 async fn all_discovery_methods_fail_when_http_404_and_no_browser() {
274 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
275 let port = listener.local_addr().unwrap().port();
276 let server = tokio::spawn(async move {
277 accept_http(&listener, HTTP_404).await;
279 accept_http(&listener, HTTP_404).await;
280 });
281
282 let err = discover_cdp_url("127.0.0.1", port, None).await.unwrap_err();
283 assert!(
284 err.contains("All CDP discovery methods failed"),
285 "unexpected error: {err}"
286 );
287 let _ = server.await;
288 }
289
290 #[test]
291 fn rewrite_ws_host_replaces_host_and_port() {
292 let original = "ws://127.0.0.1:9222/devtools/browser/abc";
293 let rewritten = rewrite_ws_host(original, "10.211.55.12", 9223);
294 assert_eq!(rewritten, "ws://10.211.55.12:9223/devtools/browser/abc");
295 }
296
297 #[test]
298 fn rewrite_ws_host_handles_ipv6() {
299 let original = "ws://127.0.0.1:9222/devtools/browser/abc";
300 let rewritten = rewrite_ws_host(original, "::1", 9222);
301 assert_eq!(rewritten, "ws://[::1]:9222/devtools/browser/abc");
302 }
303
304 #[test]
305 fn append_query_adds_params_to_url_without_query() {
306 let url = "ws://127.0.0.1:9222/devtools/browser/abc";
307 let result = append_query(url, Some("mode=Hello"));
308 assert_eq!(
309 result,
310 "ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello"
311 );
312 }
313
314 #[test]
315 fn append_query_merges_with_existing_query() {
316 let url = "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz";
317 let result = append_query(url, Some("mode=Hello"));
318 assert_eq!(
319 result,
320 "ws://127.0.0.1:9222/devtools/browser/abc?token=xyz&mode=Hello"
321 );
322 }
323
324 #[test]
325 fn append_query_noop_for_none() {
326 let url = "ws://127.0.0.1:9222/devtools/browser/abc";
327 let result = append_query(url, None);
328 assert_eq!(result, url);
329 }
330
331 #[test]
332 fn append_query_noop_for_empty() {
333 let url = "ws://127.0.0.1:9222/devtools/browser/abc";
334 let result = append_query(url, Some(""));
335 assert_eq!(result, url);
336 }
337
338 #[test]
339 fn append_query_handles_multiple_params() {
340 let url = "ws://127.0.0.1:9222/devtools/browser/abc";
341 let result = append_query(url, Some("mode=Hello&token=abc"));
342 assert_eq!(
343 result,
344 "ws://127.0.0.1:9222/devtools/browser/abc?mode=Hello&token=abc"
345 );
346 }
347
348 #[tokio::test]
349 async fn discover_preserves_query_params() {
350 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
351 let port = listener.local_addr().unwrap().port();
352 let server = tokio::spawn(async move {
353 accept_http(
354 &listener,
355 &http_200(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#),
356 )
357 .await;
358 });
359
360 let ws_url = discover_cdp_url("127.0.0.1", port, Some("mode=Hello"))
361 .await
362 .unwrap();
363 assert_eq!(ws_url, format!("ws://127.0.0.1:{}/?mode=Hello", port));
364 server.await.unwrap();
365 }
366}