Skip to main content

browser_control/bidi/
mod.rs

1//! Minimal WebDriver BiDi WebSocket client.
2//!
3//! The socket / reader-task / writer-task / pending-correlation / timeout
4//! machinery lives in the shared [`crate::transport`] (`WsRpc`); this module
5//! supplies only the BiDi-specific framing/typing via the [`BidiProtocol`]
6//! adapter and the convenience methods.
7
8pub 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
18/// Recognise the BiDi error returned when a fresh `session.new` is rejected
19/// because a session already exists on the browser. Firefox reports this as
20/// `session not created` with a "Maximum number of active sessions" message.
21fn 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
37/// BiDi framing/typing adapter for the shared transport.
38pub 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                // Id-less error frames can't be correlated to a request.
69                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    /// Gracefully shut down the transport (flush writer, abort/join reader).
116    /// Dropping the client also aborts the tasks via `WsRpc`'s `Drop`.
117    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                // A previous BiDi session is still active on this browser
126                // (e.g. a prior CLI run exited without calling session.end).
127                // Firefox limits a browser to one session at a time, so end
128                // the stuck one and retry once before giving up.
129                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        // Best effort: ignore errors if no session is active.
149        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    /// `browsingContext.create({type: "tab"})` — opens a fresh top-level
163    /// browsing context. If `url` is non-empty, navigates after create so
164    /// the returned context lands at the desired URL.
165    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    /// `browsingContext.close({context})`. Idempotent against an already-
180    /// closed context (BiDi returns an error but the caller's intent is
181    /// satisfied).
182    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    /// `browsingContext.getTree()` flattened to a set of all live top-level
190    /// context ids. Used by the engine-agnostic tab registry for
191    /// sweep-on-read.
192    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            // Box clip coordinates are in document space (matching
226            // `GET_CLIP_RECT_JS`), so request the "document" origin.
227            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
246/// Convert a `BidiError` reply into a typed `SessionError::TargetGone` when
247/// its code/message matches a known "gone" indicator (`no such frame`,
248/// `no such context`, `invalid session id`), otherwise pass through as the
249/// generic BiDi error.
250fn 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    /// BiDi error with a "context gone" code surfaces as typed
349    /// `SessionError::TargetGone`. Mirrors the CDP test so the same
350    /// recovery wrappers can switch on the typed variant.
351    #[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    /// Unrelated BiDi errors (e.g. `invalid argument`) are NOT classified
390    /// as `TargetGone` — they pass through as the regular `BidiError` so
391    /// tab-recovery doesn't fire on schema mistakes.
392    #[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}