Skip to main content

roboticus_browser/
cdp.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5use tracing::debug;
6
7use roboticus_core::{Result, RoboticusError};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CdpTarget {
11    pub id: String,
12    pub title: String,
13    pub url: String,
14    #[serde(rename = "type")]
15    pub target_type: String,
16    #[serde(rename = "webSocketDebuggerUrl")]
17    pub ws_url: Option<String>,
18}
19
20/// Low-level HTTP client for the Chrome DevTools Protocol JSON endpoints.
21///
22/// # Security
23///
24/// The CDP port (`http://127.0.0.1:<port>`) is accessible to **all** local
25/// processes. Any program running on the same host can list targets, attach
26/// debuggers, and execute arbitrary JavaScript in browser contexts. In
27/// production deployments, callers should consider firewall rules or network
28/// namespaces to restrict access to the CDP port.
29pub struct CdpClient {
30    http_base: String,
31    client: reqwest::Client,
32    command_id: AtomicU64,
33}
34
35impl CdpClient {
36    pub fn new(port: u16) -> Result<Self> {
37        Ok(Self {
38            http_base: format!("http://127.0.0.1:{port}"),
39            client: reqwest::Client::builder()
40                .timeout(std::time::Duration::from_secs(10))
41                .build()
42                .map_err(|e| RoboticusError::Network(format!("HTTP client init failed: {e}")))?,
43            command_id: AtomicU64::new(1),
44        })
45    }
46
47    pub fn next_id(&self) -> u64 {
48        self.command_id.fetch_add(1, Ordering::SeqCst)
49    }
50
51    pub fn build_command(&self, method: &str, params: Value) -> Value {
52        json!({
53            "id": self.next_id(),
54            "method": method,
55            "params": params,
56        })
57    }
58
59    pub async fn list_targets(&self) -> Result<Vec<CdpTarget>> {
60        let url = format!("{}/json/list", self.http_base);
61        let resp = self
62            .client
63            .get(&url)
64            .send()
65            .await
66            .map_err(|e| RoboticusError::Network(format!("CDP list targets failed: {e}")))?;
67
68        let targets: Vec<CdpTarget> = resp
69            .json()
70            .await
71            .map_err(|e| RoboticusError::Network(format!("CDP parse targets failed: {e}")))?;
72
73        debug!(count = targets.len(), "listed CDP targets");
74        Ok(targets)
75    }
76
77    pub async fn new_tab(&self, url: &str) -> Result<CdpTarget> {
78        let api_url = format!("{}/json/new?{}", self.http_base, url);
79        let resp = self
80            .client
81            .get(&api_url)
82            .send()
83            .await
84            .map_err(|e| RoboticusError::Network(format!("CDP new tab failed: {e}")))?;
85
86        let target: CdpTarget = resp
87            .json()
88            .await
89            .map_err(|e| RoboticusError::Network(format!("CDP parse new tab failed: {e}")))?;
90
91        debug!(id = %target.id, url = %target.url, "opened new tab");
92        Ok(target)
93    }
94
95    pub async fn close_tab(&self, target_id: &str) -> Result<()> {
96        let url = format!("{}/json/close/{}", self.http_base, target_id);
97        self.client
98            .get(&url)
99            .send()
100            .await
101            .map_err(|e| RoboticusError::Network(format!("CDP close tab failed: {e}")))?;
102        debug!(id = target_id, "closed tab");
103        Ok(())
104    }
105
106    pub async fn version(&self) -> Result<Value> {
107        let url = format!("{}/json/version", self.http_base);
108        let resp = self
109            .client
110            .get(&url)
111            .send()
112            .await
113            .map_err(|e| RoboticusError::Network(format!("CDP version failed: {e}")))?;
114
115        resp.json()
116            .await
117            .map_err(|e| RoboticusError::Network(format!("CDP version parse failed: {e}")))
118    }
119
120    pub fn navigate_command(&self, url: &str) -> Value {
121        self.build_command("Page.navigate", json!({ "url": url }))
122    }
123
124    pub fn evaluate_command(&self, expression: &str) -> Value {
125        self.build_command(
126            "Runtime.evaluate",
127            json!({
128                "expression": expression,
129                "returnByValue": true,
130            }),
131        )
132    }
133
134    pub fn screenshot_command(&self) -> Value {
135        self.build_command(
136            "Page.captureScreenshot",
137            json!({
138                "format": "png",
139                "quality": 80,
140            }),
141        )
142    }
143
144    pub fn get_document_command(&self) -> Value {
145        self.build_command("DOM.getDocument", json!({}))
146    }
147
148    pub fn click_command(&self, x: f64, y: f64) -> Value {
149        self.build_command(
150            "Input.dispatchMouseEvent",
151            json!({
152                "type": "mousePressed",
153                "x": x,
154                "y": y,
155                "button": "left",
156                "clickCount": 1,
157            }),
158        )
159    }
160
161    pub fn type_text_command(&self, text: &str) -> Value {
162        self.build_command(
163            "Input.insertText",
164            json!({
165                "text": text,
166            }),
167        )
168    }
169
170    pub fn pdf_command(&self) -> Value {
171        self.build_command(
172            "Page.printToPDF",
173            json!({
174                "printBackground": true,
175            }),
176        )
177    }
178
179    pub fn get_cookies_command(&self) -> Value {
180        self.build_command("Network.getCookies", json!({}))
181    }
182
183    pub fn clear_cookies_command(&self) -> Value {
184        self.build_command("Network.clearBrowserCookies", json!({}))
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn cdp_client_new() {
194        let client = CdpClient::new(9222).unwrap();
195        assert_eq!(client.http_base, "http://127.0.0.1:9222");
196    }
197
198    #[test]
199    fn command_ids_increment() {
200        let client = CdpClient::new(9222).unwrap();
201        let id1 = client.next_id();
202        let id2 = client.next_id();
203        assert_eq!(id2, id1 + 1);
204    }
205
206    #[test]
207    fn build_command_structure() {
208        let client = CdpClient::new(9222).unwrap();
209        let cmd = client.build_command("Page.navigate", json!({"url": "https://example.com"}));
210        assert!(cmd.get("id").is_some());
211        assert_eq!(cmd["method"], "Page.navigate");
212        assert_eq!(cmd["params"]["url"], "https://example.com");
213    }
214
215    #[test]
216    fn navigate_command() {
217        let client = CdpClient::new(9222).unwrap();
218        let cmd = client.navigate_command("https://test.com");
219        assert_eq!(cmd["method"], "Page.navigate");
220        assert_eq!(cmd["params"]["url"], "https://test.com");
221    }
222
223    #[test]
224    fn evaluate_command() {
225        let client = CdpClient::new(9222).unwrap();
226        let cmd = client.evaluate_command("document.title");
227        assert_eq!(cmd["method"], "Runtime.evaluate");
228        assert_eq!(cmd["params"]["expression"], "document.title");
229    }
230
231    #[test]
232    fn screenshot_command() {
233        let client = CdpClient::new(9222).unwrap();
234        let cmd = client.screenshot_command();
235        assert_eq!(cmd["method"], "Page.captureScreenshot");
236    }
237
238    #[test]
239    fn click_command() {
240        let client = CdpClient::new(9222).unwrap();
241        let cmd = client.click_command(100.0, 200.0);
242        assert_eq!(cmd["method"], "Input.dispatchMouseEvent");
243        assert_eq!(cmd["params"]["x"], 100.0);
244        assert_eq!(cmd["params"]["y"], 200.0);
245    }
246
247    #[test]
248    fn type_text_command() {
249        let client = CdpClient::new(9222).unwrap();
250        let cmd = client.type_text_command("hello");
251        assert_eq!(cmd["method"], "Input.insertText");
252        assert_eq!(cmd["params"]["text"], "hello");
253    }
254
255    #[test]
256    fn pdf_command() {
257        let client = CdpClient::new(9222).unwrap();
258        let cmd = client.pdf_command();
259        assert_eq!(cmd["method"], "Page.printToPDF");
260    }
261
262    #[test]
263    fn cookie_commands() {
264        let client = CdpClient::new(9222).unwrap();
265        let get = client.get_cookies_command();
266        assert_eq!(get["method"], "Network.getCookies");
267        let clear = client.clear_cookies_command();
268        assert_eq!(clear["method"], "Network.clearBrowserCookies");
269    }
270
271    #[test]
272    fn get_document_command() {
273        let client = CdpClient::new(9222).unwrap();
274        let cmd = client.get_document_command();
275        assert_eq!(cmd["method"], "DOM.getDocument");
276        assert!(cmd.get("id").is_some());
277        assert!(cmd.get("params").is_some());
278    }
279
280    #[test]
281    fn cdp_target_serde_roundtrip() {
282        let target = CdpTarget {
283            id: "ABC123".into(),
284            title: "Test Page".into(),
285            url: "https://example.com".into(),
286            target_type: "page".into(),
287            ws_url: Some("ws://127.0.0.1:9222/devtools/page/ABC123".into()),
288        };
289        let json = serde_json::to_string(&target).unwrap();
290        let back: CdpTarget = serde_json::from_str(&json).unwrap();
291        assert_eq!(back.id, "ABC123");
292        assert_eq!(back.title, "Test Page");
293        assert_eq!(back.url, "https://example.com");
294        assert_eq!(back.target_type, "page");
295        assert!(back.ws_url.is_some());
296    }
297
298    #[test]
299    fn cdp_target_serde_without_ws_url() {
300        let json_str = r#"{
301            "id": "DEF456",
302            "title": "Background",
303            "url": "chrome://newtab",
304            "type": "background_page"
305        }"#;
306        let target: CdpTarget = serde_json::from_str(json_str).unwrap();
307        assert_eq!(target.id, "DEF456");
308        assert_eq!(target.target_type, "background_page");
309        assert!(target.ws_url.is_none());
310    }
311
312    #[test]
313    fn custom_port_http_base() {
314        let client = CdpClient::new(9333).unwrap();
315        assert_eq!(client.http_base, "http://127.0.0.1:9333");
316    }
317
318    #[test]
319    fn command_ids_are_sequential() {
320        let client = CdpClient::new(9222).unwrap();
321        let cmd1 = client.build_command("A", json!({}));
322        let cmd2 = client.build_command("B", json!({}));
323        let cmd3 = client.build_command("C", json!({}));
324        let id1 = cmd1["id"].as_u64().unwrap();
325        let id2 = cmd2["id"].as_u64().unwrap();
326        let id3 = cmd3["id"].as_u64().unwrap();
327        assert_eq!(id2, id1 + 1);
328        assert_eq!(id3, id2 + 1);
329    }
330
331    #[test]
332    fn all_command_builders_have_correct_structure() {
333        let client = CdpClient::new(9222).unwrap();
334
335        // Each builder should produce: id, method, params
336        let cmds = vec![
337            client.navigate_command("https://example.com"),
338            client.evaluate_command("1+1"),
339            client.screenshot_command(),
340            client.get_document_command(),
341            client.click_command(10.0, 20.0),
342            client.type_text_command("hello"),
343            client.pdf_command(),
344            client.get_cookies_command(),
345            client.clear_cookies_command(),
346        ];
347
348        for cmd in &cmds {
349            assert!(cmd.get("id").is_some(), "missing id in command: {cmd}");
350            assert!(
351                cmd.get("method").is_some(),
352                "missing method in command: {cmd}"
353            );
354            assert!(
355                cmd.get("params").is_some(),
356                "missing params in command: {cmd}"
357            );
358        }
359    }
360
361    #[tokio::test]
362    async fn list_targets_connection_refused() {
363        // Use a port that is (almost certainly) not listening
364        let client = CdpClient::new(19999).unwrap();
365        let result = client.list_targets().await;
366        assert!(result.is_err());
367        let err_str = result.unwrap_err().to_string();
368        assert!(
369            err_str.contains("CDP list targets failed") || err_str.contains("Network"),
370            "unexpected error: {err_str}"
371        );
372    }
373
374    #[tokio::test]
375    async fn new_tab_connection_refused() {
376        let client = CdpClient::new(19999).unwrap();
377        let result = client.new_tab("https://example.com").await;
378        assert!(result.is_err());
379        let err_str = result.unwrap_err().to_string();
380        assert!(
381            err_str.contains("CDP new tab failed") || err_str.contains("Network"),
382            "unexpected error: {err_str}"
383        );
384    }
385
386    #[tokio::test]
387    async fn close_tab_connection_refused() {
388        let client = CdpClient::new(19999).unwrap();
389        let result = client.close_tab("some-target-id").await;
390        assert!(result.is_err());
391        let err_str = result.unwrap_err().to_string();
392        assert!(
393            err_str.contains("CDP close tab failed") || err_str.contains("Network"),
394            "unexpected error: {err_str}"
395        );
396    }
397
398    #[tokio::test]
399    async fn version_connection_refused() {
400        let client = CdpClient::new(19999).unwrap();
401        let result = client.version().await;
402        assert!(result.is_err());
403        let err_str = result.unwrap_err().to_string();
404        assert!(
405            err_str.contains("CDP version failed") || err_str.contains("Network"),
406            "unexpected error: {err_str}"
407        );
408    }
409}