use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tracing::debug;
use roboticus_core::{Result, RoboticusError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CdpTarget {
pub id: String,
pub title: String,
pub url: String,
#[serde(rename = "type")]
pub target_type: String,
#[serde(rename = "webSocketDebuggerUrl")]
pub ws_url: Option<String>,
}
pub struct CdpClient {
http_base: String,
client: reqwest::Client,
command_id: AtomicU64,
}
impl CdpClient {
pub fn new(port: u16) -> Result<Self> {
Ok(Self {
http_base: format!("http://127.0.0.1:{port}"),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| RoboticusError::Network(format!("HTTP client init failed: {e}")))?,
command_id: AtomicU64::new(1),
})
}
pub fn next_id(&self) -> u64 {
self.command_id.fetch_add(1, Ordering::SeqCst)
}
pub fn build_command(&self, method: &str, params: Value) -> Value {
json!({
"id": self.next_id(),
"method": method,
"params": params,
})
}
pub async fn list_targets(&self) -> Result<Vec<CdpTarget>> {
let url = format!("{}/json/list", self.http_base);
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| RoboticusError::Network(format!("CDP list targets failed: {e}")))?;
let targets: Vec<CdpTarget> = resp
.json()
.await
.map_err(|e| RoboticusError::Network(format!("CDP parse targets failed: {e}")))?;
debug!(count = targets.len(), "listed CDP targets");
Ok(targets)
}
pub async fn new_tab(&self, url: &str) -> Result<CdpTarget> {
let api_url = format!("{}/json/new?{}", self.http_base, url);
let resp = self
.client
.get(&api_url)
.send()
.await
.map_err(|e| RoboticusError::Network(format!("CDP new tab failed: {e}")))?;
let target: CdpTarget = resp
.json()
.await
.map_err(|e| RoboticusError::Network(format!("CDP parse new tab failed: {e}")))?;
debug!(id = %target.id, url = %target.url, "opened new tab");
Ok(target)
}
pub async fn close_tab(&self, target_id: &str) -> Result<()> {
let url = format!("{}/json/close/{}", self.http_base, target_id);
self.client
.get(&url)
.send()
.await
.map_err(|e| RoboticusError::Network(format!("CDP close tab failed: {e}")))?;
debug!(id = target_id, "closed tab");
Ok(())
}
pub async fn version(&self) -> Result<Value> {
let url = format!("{}/json/version", self.http_base);
let resp = self
.client
.get(&url)
.send()
.await
.map_err(|e| RoboticusError::Network(format!("CDP version failed: {e}")))?;
resp.json()
.await
.map_err(|e| RoboticusError::Network(format!("CDP version parse failed: {e}")))
}
pub fn navigate_command(&self, url: &str) -> Value {
self.build_command("Page.navigate", json!({ "url": url }))
}
pub fn evaluate_command(&self, expression: &str) -> Value {
self.build_command(
"Runtime.evaluate",
json!({
"expression": expression,
"returnByValue": true,
}),
)
}
pub fn screenshot_command(&self) -> Value {
self.build_command(
"Page.captureScreenshot",
json!({
"format": "png",
"quality": 80,
}),
)
}
pub fn get_document_command(&self) -> Value {
self.build_command("DOM.getDocument", json!({}))
}
pub fn click_command(&self, x: f64, y: f64) -> Value {
self.build_command(
"Input.dispatchMouseEvent",
json!({
"type": "mousePressed",
"x": x,
"y": y,
"button": "left",
"clickCount": 1,
}),
)
}
pub fn type_text_command(&self, text: &str) -> Value {
self.build_command(
"Input.insertText",
json!({
"text": text,
}),
)
}
pub fn pdf_command(&self) -> Value {
self.build_command(
"Page.printToPDF",
json!({
"printBackground": true,
}),
)
}
pub fn get_cookies_command(&self) -> Value {
self.build_command("Network.getCookies", json!({}))
}
pub fn clear_cookies_command(&self) -> Value {
self.build_command("Network.clearBrowserCookies", json!({}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cdp_client_new() {
let client = CdpClient::new(9222).unwrap();
assert_eq!(client.http_base, "http://127.0.0.1:9222");
}
#[test]
fn command_ids_increment() {
let client = CdpClient::new(9222).unwrap();
let id1 = client.next_id();
let id2 = client.next_id();
assert_eq!(id2, id1 + 1);
}
#[test]
fn build_command_structure() {
let client = CdpClient::new(9222).unwrap();
let cmd = client.build_command("Page.navigate", json!({"url": "https://example.com"}));
assert!(cmd.get("id").is_some());
assert_eq!(cmd["method"], "Page.navigate");
assert_eq!(cmd["params"]["url"], "https://example.com");
}
#[test]
fn navigate_command() {
let client = CdpClient::new(9222).unwrap();
let cmd = client.navigate_command("https://test.com");
assert_eq!(cmd["method"], "Page.navigate");
assert_eq!(cmd["params"]["url"], "https://test.com");
}
#[test]
fn evaluate_command() {
let client = CdpClient::new(9222).unwrap();
let cmd = client.evaluate_command("document.title");
assert_eq!(cmd["method"], "Runtime.evaluate");
assert_eq!(cmd["params"]["expression"], "document.title");
}
#[test]
fn screenshot_command() {
let client = CdpClient::new(9222).unwrap();
let cmd = client.screenshot_command();
assert_eq!(cmd["method"], "Page.captureScreenshot");
}
#[test]
fn click_command() {
let client = CdpClient::new(9222).unwrap();
let cmd = client.click_command(100.0, 200.0);
assert_eq!(cmd["method"], "Input.dispatchMouseEvent");
assert_eq!(cmd["params"]["x"], 100.0);
assert_eq!(cmd["params"]["y"], 200.0);
}
#[test]
fn type_text_command() {
let client = CdpClient::new(9222).unwrap();
let cmd = client.type_text_command("hello");
assert_eq!(cmd["method"], "Input.insertText");
assert_eq!(cmd["params"]["text"], "hello");
}
#[test]
fn pdf_command() {
let client = CdpClient::new(9222).unwrap();
let cmd = client.pdf_command();
assert_eq!(cmd["method"], "Page.printToPDF");
}
#[test]
fn cookie_commands() {
let client = CdpClient::new(9222).unwrap();
let get = client.get_cookies_command();
assert_eq!(get["method"], "Network.getCookies");
let clear = client.clear_cookies_command();
assert_eq!(clear["method"], "Network.clearBrowserCookies");
}
#[test]
fn get_document_command() {
let client = CdpClient::new(9222).unwrap();
let cmd = client.get_document_command();
assert_eq!(cmd["method"], "DOM.getDocument");
assert!(cmd.get("id").is_some());
assert!(cmd.get("params").is_some());
}
#[test]
fn cdp_target_serde_roundtrip() {
let target = CdpTarget {
id: "ABC123".into(),
title: "Test Page".into(),
url: "https://example.com".into(),
target_type: "page".into(),
ws_url: Some("ws://127.0.0.1:9222/devtools/page/ABC123".into()),
};
let json = serde_json::to_string(&target).unwrap();
let back: CdpTarget = serde_json::from_str(&json).unwrap();
assert_eq!(back.id, "ABC123");
assert_eq!(back.title, "Test Page");
assert_eq!(back.url, "https://example.com");
assert_eq!(back.target_type, "page");
assert!(back.ws_url.is_some());
}
#[test]
fn cdp_target_serde_without_ws_url() {
let json_str = r#"{
"id": "DEF456",
"title": "Background",
"url": "chrome://newtab",
"type": "background_page"
}"#;
let target: CdpTarget = serde_json::from_str(json_str).unwrap();
assert_eq!(target.id, "DEF456");
assert_eq!(target.target_type, "background_page");
assert!(target.ws_url.is_none());
}
#[test]
fn custom_port_http_base() {
let client = CdpClient::new(9333).unwrap();
assert_eq!(client.http_base, "http://127.0.0.1:9333");
}
#[test]
fn command_ids_are_sequential() {
let client = CdpClient::new(9222).unwrap();
let cmd1 = client.build_command("A", json!({}));
let cmd2 = client.build_command("B", json!({}));
let cmd3 = client.build_command("C", json!({}));
let id1 = cmd1["id"].as_u64().unwrap();
let id2 = cmd2["id"].as_u64().unwrap();
let id3 = cmd3["id"].as_u64().unwrap();
assert_eq!(id2, id1 + 1);
assert_eq!(id3, id2 + 1);
}
#[test]
fn all_command_builders_have_correct_structure() {
let client = CdpClient::new(9222).unwrap();
let cmds = vec![
client.navigate_command("https://example.com"),
client.evaluate_command("1+1"),
client.screenshot_command(),
client.get_document_command(),
client.click_command(10.0, 20.0),
client.type_text_command("hello"),
client.pdf_command(),
client.get_cookies_command(),
client.clear_cookies_command(),
];
for cmd in &cmds {
assert!(cmd.get("id").is_some(), "missing id in command: {cmd}");
assert!(
cmd.get("method").is_some(),
"missing method in command: {cmd}"
);
assert!(
cmd.get("params").is_some(),
"missing params in command: {cmd}"
);
}
}
#[tokio::test]
async fn list_targets_connection_refused() {
let client = CdpClient::new(19999).unwrap();
let result = client.list_targets().await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("CDP list targets failed") || err_str.contains("Network"),
"unexpected error: {err_str}"
);
}
#[tokio::test]
async fn new_tab_connection_refused() {
let client = CdpClient::new(19999).unwrap();
let result = client.new_tab("https://example.com").await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("CDP new tab failed") || err_str.contains("Network"),
"unexpected error: {err_str}"
);
}
#[tokio::test]
async fn close_tab_connection_refused() {
let client = CdpClient::new(19999).unwrap();
let result = client.close_tab("some-target-id").await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("CDP close tab failed") || err_str.contains("Network"),
"unexpected error: {err_str}"
);
}
#[tokio::test]
async fn version_connection_refused() {
let client = CdpClient::new(19999).unwrap();
let result = client.version().await;
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("CDP version failed") || err_str.contains("Network"),
"unexpected error: {err_str}"
);
}
}