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
20pub 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 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 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}