1use anyhow::{anyhow, Result};
9use serde_json::{json, Value};
10use tokio::sync::broadcast;
11
12pub mod protocol;
13use protocol::{CdpError, Request, Response};
14
15use crate::errors::{is_cdp_target_gone, SessionError, TargetKind};
16use crate::transport::{Decoded, Protocol, RequestError, WsRpc, CONNECT_TIMEOUT, REQUEST_TIMEOUT};
17
18#[derive(Debug, Clone)]
19pub struct CdpEvent {
20 pub method: String,
21 pub params: Value,
22 pub session_id: Option<String>,
23}
24
25pub struct CdpProtocol;
27
28impl Protocol for CdpProtocol {
29 type ProtoError = CdpError;
30 type Event = CdpEvent;
31
32 fn encode_request(
33 id: u64,
34 method: &str,
35 params: Value,
36 session_id: Option<&str>,
37 ) -> Result<String> {
38 let req = Request {
39 id,
40 method,
41 params,
42 session_id: session_id.map(|s| s.to_string()),
43 };
44 Ok(serde_json::to_string(&req)?)
45 }
46
47 fn decode_frame(text: &str) -> Decoded<CdpError, CdpEvent> {
48 let resp: Response = match serde_json::from_str(text) {
49 Ok(r) => r,
50 Err(_) => return Decoded::Ignore,
51 };
52 if let Some(id) = resp.id {
53 let result = if let Some(err) = resp.error {
54 Err(err)
55 } else {
56 Ok(resp.result)
57 };
58 Decoded::Reply { id, result }
59 } else if let Some(method) = resp.method {
60 Decoded::Event(CdpEvent {
61 method,
62 params: resp.params,
63 session_id: resp.session_id,
64 })
65 } else {
66 Decoded::Ignore
67 }
68 }
69
70 fn closed_error() -> CdpError {
71 CdpError {
72 code: -1,
73 message: "connection closed".into(),
74 }
75 }
76}
77
78pub struct CdpClient {
79 rpc: WsRpc<CdpProtocol>,
80}
81
82impl CdpClient {
83 pub async fn connect(ws_url: &str) -> Result<Self> {
85 Ok(Self {
86 rpc: WsRpc::connect(ws_url, "CDP").await?,
87 })
88 }
89
90 pub async fn connect_http(base_url: &str) -> Result<Self> {
92 let base = base_url.trim_end_matches('/');
93 let url = format!("{base}/json/version");
94 let client = reqwest::Client::builder()
95 .timeout(CONNECT_TIMEOUT)
96 .build()
97 .map_err(|e| anyhow!("building reqwest client: {e}"))?;
98 let http_resp = client
102 .get(&url)
103 .send()
104 .await?
105 .error_for_status()
106 .map_err(|e| anyhow!("fetching {url}: {e}"))?;
107 let resp: Value = http_resp.json().await?;
108 let ws_url = resp
109 .get("webSocketDebuggerUrl")
110 .and_then(|v| v.as_str())
111 .ok_or_else(|| anyhow!("webSocketDebuggerUrl missing from {url}"))?
112 .to_string();
113 Self::connect(&ws_url).await
114 }
115
116 pub async fn send(&self, method: &str, params: Value) -> Result<Value> {
118 self.send_with_session(method, params, None).await
119 }
120
121 pub async fn send_with_session(
122 &self,
123 method: &str,
124 params: Value,
125 session_id: Option<&str>,
126 ) -> Result<Value> {
127 match self.rpc.request(method, params, session_id).await {
128 Ok(v) => Ok(v),
129 Err(RequestError::Protocol(e)) => Err(classify_cdp_error(e, session_id.is_some())),
130 Err(RequestError::Timeout) => match session_id {
131 Some(sid) => Err(anyhow!(
132 "CDP request {method} (session {sid}) timed out after {:?}",
133 REQUEST_TIMEOUT
134 )),
135 None => Err(anyhow!(
136 "CDP request {method} timed out after {:?}",
137 REQUEST_TIMEOUT
138 )),
139 },
140 Err(RequestError::Transport(e)) => Err(e),
141 }
142 }
143
144 pub fn subscribe(&self) -> broadcast::Receiver<CdpEvent> {
146 self.rpc.subscribe()
147 }
148
149 pub async fn attach_to_target(&self, target_id: &str) -> Result<String> {
151 let v = self
152 .send(
153 "Target.attachToTarget",
154 json!({ "targetId": target_id, "flatten": true }),
155 )
156 .await?;
157 v.get("sessionId")
158 .and_then(|v| v.as_str())
159 .map(|s| s.to_string())
160 .ok_or_else(|| anyhow!("sessionId missing from Target.attachToTarget response"))
161 }
162
163 pub async fn list_targets(&self) -> Result<Vec<Value>> {
165 let v = self.send("Target.getTargets", Value::Null).await?;
166 match v.get("targetInfos") {
167 Some(Value::Array(a)) => Ok(a.clone()),
168 _ => Ok(vec![]),
169 }
170 }
171
172 pub async fn close(self) {
175 self.rpc.close().await;
176 }
177}
178
179fn classify_cdp_error(err: CdpError, attached: bool) -> anyhow::Error {
185 if attached && is_cdp_target_gone(&err.message) {
186 return SessionError::TargetGone {
187 kind: TargetKind::Cdp,
188 details: format!("CDP error {}: {}", err.code, err.message),
189 }
190 .into();
191 }
192 anyhow!(err)
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use futures_util::{SinkExt, StreamExt};
199 use std::time::Duration;
200 use tokio::sync::oneshot;
201 use tokio_tungstenite::tungstenite::Message;
202
203 #[tokio::test]
204 async fn round_trip_request_response() {
205 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
206 let addr = listener.local_addr().unwrap();
207 tokio::spawn(async move {
208 let (stream, _) = listener.accept().await.unwrap();
209 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
210 while let Some(Ok(msg)) = ws.next().await {
211 if let Message::Text(t) = msg {
212 let req: Value = serde_json::from_str(&t).unwrap();
213 let id = req["id"].as_u64().unwrap();
214 let resp = json!({"id": id, "result": {"ok": true, "echo": req["method"]}});
215 ws.send(Message::Text(resp.to_string())).await.unwrap();
216 }
217 }
218 });
219 let url = format!("ws://{}", addr);
220 let client = CdpClient::connect(&url).await.unwrap();
221 let v = client
222 .send("Page.navigate", json!({"url": "about:blank"}))
223 .await
224 .unwrap();
225 assert_eq!(v["ok"], true);
226 assert_eq!(v["echo"], "Page.navigate");
227 client.close().await;
228 }
229
230 #[tokio::test]
231 async fn broadcast_event_to_subscriber() {
232 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
233 let addr = listener.local_addr().unwrap();
234 let (ready_tx, ready_rx) = oneshot::channel::<()>();
235 tokio::spawn(async move {
236 let (stream, _) = listener.accept().await.unwrap();
237 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
238 let _ = ready_rx.await;
240 let evt = json!({
241 "method": "Target.targetCreated",
242 "params": {"targetInfo": {"targetId": "abc"}},
243 "sessionId": "S1"
244 });
245 ws.send(Message::Text(evt.to_string())).await.unwrap();
246 while let Some(Ok(_)) = ws.next().await {}
248 });
249
250 let url = format!("ws://{}", addr);
251 let client = CdpClient::connect(&url).await.unwrap();
252 let mut rx = client.subscribe();
253 ready_tx.send(()).unwrap();
254
255 let evt = tokio::time::timeout(Duration::from_secs(5), rx.recv())
256 .await
257 .expect("event timeout")
258 .expect("event recv");
259 assert_eq!(evt.method, "Target.targetCreated");
260 assert_eq!(evt.session_id.as_deref(), Some("S1"));
261 assert_eq!(evt.params["targetInfo"]["targetId"], "abc");
262 client.close().await;
263 }
264
265 #[tokio::test]
269 async fn send_with_session_classifies_target_gone() {
270 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
271 let addr = listener.local_addr().unwrap();
272 tokio::spawn(async move {
273 let (stream, _) = listener.accept().await.unwrap();
274 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
275 while let Some(Ok(Message::Text(t))) = ws.next().await {
276 let req: Value = serde_json::from_str(&t).unwrap();
277 let id = req["id"].as_u64().unwrap();
278 let resp = json!({
279 "id": id,
280 "error": {"code": -32000, "message": "No target with given id found: T42"}
281 });
282 ws.send(Message::Text(resp.to_string())).await.unwrap();
283 }
284 });
285 let client = CdpClient::connect(&format!("ws://{addr}")).await.unwrap();
286 let err = client
287 .send_with_session("Runtime.evaluate", json!({}), Some("S1"))
288 .await
289 .expect_err("must error");
290 let typed = err
291 .downcast_ref::<crate::errors::SessionError>()
292 .expect("typed SessionError");
293 match typed {
294 crate::errors::SessionError::TargetGone { kind, details } => {
295 assert_eq!(*kind, crate::errors::TargetKind::Cdp);
296 assert!(details.contains("No target with given id"));
297 }
298 other => panic!("expected TargetGone, got {other:?}"),
299 }
300 client.close().await;
301 }
302
303 #[tokio::test]
308 async fn send_root_does_not_classify_target_gone() {
309 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
310 let addr = listener.local_addr().unwrap();
311 tokio::spawn(async move {
312 let (stream, _) = listener.accept().await.unwrap();
313 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
314 while let Some(Ok(Message::Text(t))) = ws.next().await {
315 let req: Value = serde_json::from_str(&t).unwrap();
316 let id = req["id"].as_u64().unwrap();
317 let resp = json!({
318 "id": id,
319 "error": {"code": -32000, "message": "No target with given id found: T42"}
320 });
321 ws.send(Message::Text(resp.to_string())).await.unwrap();
322 }
323 });
324 let client = CdpClient::connect(&format!("ws://{addr}")).await.unwrap();
325 let err = client
326 .send("Target.attachToTarget", json!({}))
327 .await
328 .expect_err("must error");
329 assert!(
330 err.downcast_ref::<crate::errors::SessionError>().is_none(),
331 "root-session error must NOT classify as TargetGone"
332 );
333 client.close().await;
334 }
335
336 #[tokio::test]
342 async fn connect_times_out_when_upgrade_hangs() {
343 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
344 let addr = listener.local_addr().unwrap();
345 tokio::spawn(async move {
347 let (_stream, _) = listener.accept().await.unwrap();
348 std::future::pending::<()>().await;
349 });
350
351 let url = format!("ws://{addr}");
352 let start = std::time::Instant::now();
353 let err = match CdpClient::connect(&url).await {
354 Ok(_) => panic!("connect must fail when upgrade hangs"),
355 Err(e) => e,
356 };
357 let elapsed = start.elapsed();
358
359 assert!(
361 elapsed < CONNECT_TIMEOUT + Duration::from_secs(2),
362 "connect did not honour the 5s bound (took {elapsed:?})"
363 );
364 let msg = format!("{err:#}");
365 assert!(
366 msg.contains("timed out"),
367 "error should mention timeout, got: {msg}"
368 );
369 }
370}