1pub mod protocol;
9
10use anyhow::{anyhow, Result};
11use protocol::*;
12use serde_json::{json, Value};
13use tokio::sync::{broadcast, Mutex};
14
15use crate::errors::{is_bidi_target_gone, SessionError, TargetKind};
16use crate::transport::{Decoded, Protocol, RequestError, WsRpc, REQUEST_TIMEOUT};
17
18fn is_session_already_active(err: &anyhow::Error) -> bool {
22 if let Some(b) = err.downcast_ref::<BidiError>() {
23 let msg = b.message.to_ascii_lowercase();
24 return b.code == "session not created"
25 && (msg.contains("maximum number of active sessions")
26 || msg.contains("session is already created"));
27 }
28 false
29}
30
31#[derive(Debug, Clone)]
32pub struct BidiEvent {
33 pub method: String,
34 pub params: Value,
35}
36
37pub struct BidiProtocol;
39
40impl Protocol for BidiProtocol {
41 type ProtoError = BidiError;
42 type Event = BidiEvent;
43
44 fn encode_request(
45 id: u64,
46 method: &str,
47 params: Value,
48 _session_id: Option<&str>,
49 ) -> Result<String> {
50 let cmd = Command { id, method, params };
51 Ok(serde_json::to_string(&cmd)?)
52 }
53
54 fn decode_frame(text: &str) -> Decoded<BidiError, BidiEvent> {
55 match serde_json::from_str::<IncomingMessage>(text) {
56 Ok(IncomingMessage::Success { id, result }) => Decoded::Reply {
57 id,
58 result: Ok(result),
59 },
60 Ok(IncomingMessage::Error { id, error, message }) => match id {
61 Some(id) => Decoded::Reply {
62 id,
63 result: Err(BidiError {
64 code: error,
65 message,
66 }),
67 },
68 None => Decoded::Ignore,
70 },
71 Ok(IncomingMessage::Event { method, params }) => {
72 Decoded::Event(BidiEvent { method, params })
73 }
74 Err(_) => Decoded::Ignore,
75 }
76 }
77
78 fn closed_error() -> BidiError {
79 BidiError {
80 code: "connection closed".into(),
81 message: "BiDi connection closed".into(),
82 }
83 }
84}
85
86pub struct BidiClient {
87 rpc: WsRpc<BidiProtocol>,
88 session_id: Mutex<Option<String>>,
89}
90
91impl BidiClient {
92 pub async fn connect(ws_url: &str) -> Result<Self> {
93 Ok(Self {
94 rpc: WsRpc::connect(ws_url, "BiDi").await?,
95 session_id: Mutex::new(None),
96 })
97 }
98
99 pub async fn send(&self, method: &str, params: Value) -> Result<Value> {
100 match self.rpc.request(method, params, None).await {
101 Ok(v) => Ok(v),
102 Err(RequestError::Protocol(e)) => Err(classify_bidi_error(e)),
103 Err(RequestError::Timeout) => Err(anyhow!(
104 "BiDi request {method} timed out after {:?}",
105 REQUEST_TIMEOUT
106 )),
107 Err(RequestError::Transport(e)) => Err(e),
108 }
109 }
110
111 pub fn subscribe(&self) -> broadcast::Receiver<BidiEvent> {
112 self.rpc.subscribe()
113 }
114
115 pub async fn close(self) {
118 self.rpc.close().await;
119 }
120
121 pub async fn session_new(&self) -> Result<String> {
122 let v = match self.send("session.new", json!({"capabilities": {}})).await {
123 Ok(v) => v,
124 Err(e) if is_session_already_active(&e) => {
125 tracing::warn!(
130 target = "bidi",
131 "session.new rejected (active session exists); ending and retrying",
132 );
133 let _ = self.send("session.end", json!({})).await;
134 self.send("session.new", json!({"capabilities": {}}))
135 .await?
136 }
137 Err(e) => return Err(e),
138 };
139 let sid = v["sessionId"]
140 .as_str()
141 .ok_or_else(|| anyhow!("no sessionId"))?
142 .to_string();
143 *self.session_id.lock().await = Some(sid.clone());
144 Ok(sid)
145 }
146
147 pub async fn session_end(&self) -> Result<()> {
148 let _ = self.send("session.end", json!({})).await;
150 *self.session_id.lock().await = None;
151 Ok(())
152 }
153
154 pub async fn browsing_context_navigate(&self, context: &str, url: &str) -> Result<Value> {
155 self.send(
156 "browsingContext.navigate",
157 json!({"context": context, "url": url, "wait": "complete"}),
158 )
159 .await
160 }
161
162 pub async fn browsing_context_create(&self, url: &str) -> Result<String> {
166 let v = self
167 .send("browsingContext.create", json!({"type": "tab"}))
168 .await?;
169 let context = v["context"]
170 .as_str()
171 .ok_or_else(|| anyhow!("browsingContext.create returned no context"))?
172 .to_string();
173 if !url.is_empty() && url != "about:blank" {
174 self.browsing_context_navigate(&context, url).await?;
175 }
176 Ok(context)
177 }
178
179 pub async fn browsing_context_close(&self, context: &str) -> Result<()> {
183 let _ = self
184 .send("browsingContext.close", json!({"context": context}))
185 .await;
186 Ok(())
187 }
188
189 pub async fn browsing_context_ids(&self) -> Result<std::collections::HashSet<String>> {
193 let v = self.send("browsingContext.getTree", json!({})).await?;
194 let contexts = v
195 .get("contexts")
196 .and_then(|x| x.as_array())
197 .cloned()
198 .unwrap_or_default();
199 Ok(contexts
200 .iter()
201 .filter_map(|c| c.get("context").and_then(|x| x.as_str()).map(String::from))
202 .collect())
203 }
204
205 pub async fn script_evaluate(&self, context: &str, expression: &str) -> Result<Value> {
206 self.send(
207 "script.evaluate",
208 json!({
209 "expression": expression,
210 "target": {"context": context},
211 "awaitPromise": true,
212 "resultOwnership": "none"
213 }),
214 )
215 .await
216 }
217
218 pub async fn browsing_context_capture_screenshot(
219 &self,
220 context: &str,
221 clip: Option<Value>,
222 ) -> Result<String> {
223 let mut params = json!({ "context": context });
224 if let Some(rect) = clip {
225 params["origin"] = json!("document");
228 params["clip"] = json!({
229 "type": "box",
230 "x": rect["x"],
231 "y": rect["y"],
232 "width": rect["width"],
233 "height": rect["height"],
234 });
235 }
236 let v = self
237 .send("browsingContext.captureScreenshot", params)
238 .await?;
239 Ok(v["data"]
240 .as_str()
241 .ok_or_else(|| anyhow!("no data"))?
242 .to_string())
243 }
244}
245
246fn classify_bidi_error(err: BidiError) -> anyhow::Error {
251 if is_bidi_target_gone(&err.code, &err.message) {
252 return SessionError::TargetGone {
253 kind: TargetKind::Bidi,
254 details: format!("BiDi error {}: {}", err.code, err.message),
255 }
256 .into();
257 }
258 err.into()
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use futures_util::{SinkExt, StreamExt};
265 use std::time::Duration;
266 use tokio::net::TcpListener;
267 use tokio_tungstenite::accept_async;
268 use tokio_tungstenite::tungstenite::Message;
269
270 async fn spawn_echo_server() -> String {
271 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
272 let addr = listener.local_addr().unwrap();
273 tokio::spawn(async move {
274 if let Ok((stream, _)) = listener.accept().await {
275 let mut ws = accept_async(stream).await.unwrap();
276 while let Some(Ok(msg)) = ws.next().await {
277 if let Message::Text(text) = msg {
278 let v: Value = serde_json::from_str(&text).unwrap();
279 let id = v["id"].as_u64().unwrap();
280 let method = v["method"].as_str().unwrap().to_string();
281 let reply = json!({
282 "id": id,
283 "type": "success",
284 "result": {"echoed": method}
285 });
286 ws.send(Message::Text(reply.to_string())).await.unwrap();
287 }
288 }
289 }
290 });
291 format!("ws://{}", addr)
292 }
293
294 #[tokio::test]
295 async fn send_receives_success_result() {
296 let url = spawn_echo_server().await;
297 let client = BidiClient::connect(&url).await.unwrap();
298 let result = client.send("session.status", json!({})).await.unwrap();
299 assert_eq!(result["echoed"], "session.status");
300 }
301
302 #[tokio::test]
303 async fn subscriber_receives_event() {
304 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
305 let addr = listener.local_addr().unwrap();
306 tokio::spawn(async move {
307 let (stream, _) = listener.accept().await.unwrap();
308 let mut ws = accept_async(stream).await.unwrap();
309 let event = json!({
310 "type": "event",
311 "method": "log.entryAdded",
312 "params": {"text": "hello"}
313 });
314 ws.send(Message::Text(event.to_string())).await.unwrap();
315 while ws.next().await.is_some() {}
316 });
317 let url = format!("ws://{}", addr);
318 let client = BidiClient::connect(&url).await.unwrap();
319 let mut rx = client.subscribe();
320 let evt = tokio::time::timeout(Duration::from_secs(5), rx.recv())
321 .await
322 .unwrap()
323 .unwrap();
324 assert_eq!(evt.method, "log.entryAdded");
325 assert_eq!(evt.params["text"], "hello");
326 }
327
328 #[test]
329 fn detects_firefox_active_session_error() {
330 let e: anyhow::Error = BidiError {
331 code: "session not created".to_string(),
332 message: "Maximum number of active sessions.".to_string(),
333 }
334 .into();
335 assert!(is_session_already_active(&e));
336
337 let other: anyhow::Error = BidiError {
338 code: "invalid argument".to_string(),
339 message: "Maximum number of active sessions".to_string(),
340 }
341 .into();
342 assert!(!is_session_already_active(&other));
343
344 let unrelated: anyhow::Error = anyhow!("not a bidi error");
345 assert!(!is_session_already_active(&unrelated));
346 }
347
348 #[tokio::test]
352 async fn send_classifies_target_gone() {
353 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
354 let addr = listener.local_addr().unwrap();
355 tokio::spawn(async move {
356 let (stream, _) = listener.accept().await.unwrap();
357 let mut ws = accept_async(stream).await.unwrap();
358 while let Some(Ok(Message::Text(t))) = ws.next().await {
359 let v: Value = serde_json::from_str(&t).unwrap();
360 let id = v["id"].as_u64().unwrap();
361 let reply = json!({
362 "id": id,
363 "type": "error",
364 "error": "no such frame",
365 "message": "context C1 not found"
366 });
367 ws.send(Message::Text(reply.to_string())).await.unwrap();
368 }
369 });
370 let client = BidiClient::connect(&format!("ws://{}", addr))
371 .await
372 .unwrap();
373 let err = client
374 .send("script.evaluate", json!({"target": {"context": "C1"}}))
375 .await
376 .expect_err("must error");
377 let typed = err
378 .downcast_ref::<crate::errors::SessionError>()
379 .expect("typed SessionError");
380 match typed {
381 crate::errors::SessionError::TargetGone { kind, details } => {
382 assert_eq!(*kind, crate::errors::TargetKind::Bidi);
383 assert!(details.contains("no such frame"));
384 }
385 other => panic!("expected TargetGone, got {other:?}"),
386 }
387 }
388
389 #[tokio::test]
393 async fn send_does_not_classify_unrelated_errors() {
394 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
395 let addr = listener.local_addr().unwrap();
396 tokio::spawn(async move {
397 let (stream, _) = listener.accept().await.unwrap();
398 let mut ws = accept_async(stream).await.unwrap();
399 while let Some(Ok(Message::Text(t))) = ws.next().await {
400 let v: Value = serde_json::from_str(&t).unwrap();
401 let id = v["id"].as_u64().unwrap();
402 let reply = json!({
403 "id": id,
404 "type": "error",
405 "error": "invalid argument",
406 "message": "missing required field"
407 });
408 ws.send(Message::Text(reply.to_string())).await.unwrap();
409 }
410 });
411 let client = BidiClient::connect(&format!("ws://{}", addr))
412 .await
413 .unwrap();
414 let err = client
415 .send("script.evaluate", json!({}))
416 .await
417 .expect_err("must error");
418 assert!(
419 err.downcast_ref::<crate::errors::SessionError>().is_none(),
420 "non-gone BiDi error must not classify as TargetGone"
421 );
422 }
423
424 #[tokio::test]
425 async fn session_new_retries_after_active_session_error() {
426 use std::sync::atomic::{AtomicUsize, Ordering};
427 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
428 let addr = listener.local_addr().unwrap();
429 tokio::spawn(async move {
430 let (stream, _) = listener.accept().await.unwrap();
431 let mut ws = accept_async(stream).await.unwrap();
432 let attempts = AtomicUsize::new(0);
433 while let Some(Ok(Message::Text(text))) = ws.next().await {
434 let v: Value = serde_json::from_str(&text).unwrap();
435 let id = v["id"].as_u64().unwrap();
436 let method = v["method"].as_str().unwrap();
437 let reply = match method {
438 "session.new" => {
439 let n = attempts.fetch_add(1, Ordering::SeqCst);
440 if n == 0 {
441 json!({
442 "id": id,
443 "type": "error",
444 "error": "session not created",
445 "message": "Maximum number of active sessions."
446 })
447 } else {
448 json!({
449 "id": id,
450 "type": "success",
451 "result": {"sessionId": "S2"}
452 })
453 }
454 }
455 "session.end" => json!({"id": id, "type": "success", "result": {}}),
456 _ => json!({"id": id, "type": "success", "result": {}}),
457 };
458 ws.send(Message::Text(reply.to_string())).await.unwrap();
459 }
460 });
461 let client = BidiClient::connect(&format!("ws://{}", addr))
462 .await
463 .unwrap();
464 let sid = client.session_new().await.unwrap();
465 assert_eq!(sid, "S2");
466 }
467}