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    /// `script.callFunction` in the context's default realm. `args` are
219    /// plain JSON primitives (string / number / boolean / null) converted to
220    /// BiDi `LocalValue`s. A `{type:"exception"}` result becomes an error
221    /// carrying `exceptionDetails.text`; otherwise the `RemoteValue` result
222    /// is returned.
223    pub async fn script_call_function(
224        &self,
225        context: &str,
226        function_declaration: &str,
227        args: Vec<Value>,
228    ) -> Result<Value> {
229        let v = self
230            .send(
231                "script.callFunction",
232                json!({
233                    "functionDeclaration": function_declaration,
234                    "target": {"context": context},
235                    "arguments": args.iter().map(to_local_value).collect::<Vec<_>>(),
236                    "awaitPromise": true,
237                    "resultOwnership": "none",
238                }),
239            )
240            .await?;
241        unwrap_script_result(v)
242    }
243
244    /// `input.performActions` with a prebuilt `actions` array.
245    pub async fn input_perform_actions(&self, context: &str, actions: Value) -> Result<()> {
246        self.send(
247            "input.performActions",
248            json!({ "context": context, "actions": actions }),
249        )
250        .await?;
251        Ok(())
252    }
253
254    /// `input.releaseActions`: release any pressed keys / buttons.
255    pub async fn input_release_actions(&self, context: &str) -> Result<()> {
256        self.send("input.releaseActions", json!({ "context": context }))
257            .await?;
258        Ok(())
259    }
260
261    /// `format` is the BiDi `browsingContext.ImageFormat` object
262    /// (`{type, quality}`); `None` keeps the protocol default (PNG).
263    pub async fn browsing_context_capture_screenshot(
264        &self,
265        context: &str,
266        clip: Option<Value>,
267        format: Option<Value>,
268    ) -> Result<String> {
269        let mut params = json!({ "context": context });
270        if let Some(f) = format {
271            params["format"] = f;
272        }
273        if let Some(rect) = clip {
274            // Box clip coordinates are in document space (matching
275            // `GET_CLIP_RECT_JS`), so request the "document" origin.
276            params["origin"] = json!("document");
277            params["clip"] = json!({
278                "type": "box",
279                "x": rect["x"],
280                "y": rect["y"],
281                "width": rect["width"],
282                "height": rect["height"],
283            });
284        }
285        let v = self
286            .send("browsingContext.captureScreenshot", params)
287            .await?;
288        Ok(v["data"]
289            .as_str()
290            .ok_or_else(|| anyhow!("no data"))?
291            .to_string())
292    }
293}
294
295/// JSON primitive → BiDi `script.LocalValue`.
296pub(crate) fn to_local_value(v: &Value) -> Value {
297    match v {
298        Value::String(s) => json!({ "type": "string", "value": s }),
299        Value::Number(n) => json!({ "type": "number", "value": n }),
300        Value::Bool(b) => json!({ "type": "boolean", "value": b }),
301        Value::Null => json!({ "type": "null" }),
302        // Arrays/objects are not needed by callers today; serialise them as
303        // a JSON string so the page can `JSON.parse` if it ever wants to.
304        other => json!({ "type": "string", "value": other.to_string() }),
305    }
306}
307
308/// Unwrap a `script.evaluate` / `script.callFunction` reply: a
309/// `type:"exception"` frame is a *successful* command whose script threw,
310/// so surface its text as an error; otherwise return the `RemoteValue`.
311pub(crate) fn unwrap_script_result(v: Value) -> Result<Value> {
312    if v["type"].as_str() == Some("exception") {
313        let text = v["exceptionDetails"]["text"]
314            .as_str()
315            .unwrap_or("script threw an exception")
316            .to_string();
317        return Err(anyhow!("script exception: {text}"));
318    }
319    Ok(v["result"].clone())
320}
321
322/// Flatten a BiDi `script.RemoteValue` into plain JSON so callers see the
323/// same shape CDP's `returnByValue` produces: objects become JSON objects
324/// (BiDi ships them as `[[key, value], …]` pairs), arrays become arrays,
325/// primitives pass through, and non-serialisable values (nodes, functions,
326/// windows) become `null`.
327pub fn remote_value_to_json(v: &Value) -> Value {
328    let key_of = |k: &Value| -> String {
329        match k {
330            Value::String(s) => s.clone(),
331            other => match remote_value_to_json(other) {
332                Value::String(s) => s,
333                j => j.to_string(),
334            },
335        }
336    };
337    match v["type"].as_str().unwrap_or("undefined") {
338        "string" | "boolean" => v["value"].clone(),
339        "number" => match &v["value"] {
340            n @ Value::Number(_) => n.clone(),
341            // "NaN", "Infinity", "-Infinity", "-0" have no JSON form.
342            Value::String(s) if s == "-0" => json!(0),
343            _ => Value::Null,
344        },
345        "bigint" | "date" => v["value"].clone(),
346        "regexp" => json!(format!(
347            "/{}/{}",
348            v["value"]["pattern"].as_str().unwrap_or(""),
349            v["value"]["flags"].as_str().unwrap_or("")
350        )),
351        "array" | "set" | "nodelist" | "htmlcollection" => match v["value"].as_array() {
352            Some(items) => Value::Array(items.iter().map(remote_value_to_json).collect()),
353            None => Value::Array(vec![]),
354        },
355        "object" | "map" => match v["value"].as_array() {
356            Some(pairs) => {
357                let mut m = serde_json::Map::new();
358                for pair in pairs {
359                    if let Some(k) = pair.get(0) {
360                        let val = pair.get(1).map(remote_value_to_json).unwrap_or(Value::Null);
361                        m.insert(key_of(k), val);
362                    }
363                }
364                Value::Object(m)
365            }
366            None => Value::Object(serde_json::Map::new()),
367        },
368        _ => Value::Null,
369    }
370}
371
372/// Convert a `BidiError` reply into a typed `SessionError::TargetGone` when
373/// its code/message matches a known "gone" indicator (`no such frame`,
374/// `no such context`, `invalid session id`), otherwise pass through as the
375/// generic BiDi error.
376fn classify_bidi_error(err: BidiError) -> anyhow::Error {
377    if is_bidi_target_gone(&err.code, &err.message) {
378        return SessionError::TargetGone {
379            kind: TargetKind::Bidi,
380            details: format!("BiDi error {}: {}", err.code, err.message),
381        }
382        .into();
383    }
384    err.into()
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use futures_util::{SinkExt, StreamExt};
391    use std::time::Duration;
392    use tokio::net::TcpListener;
393    use tokio_tungstenite::accept_async;
394    use tokio_tungstenite::tungstenite::Message;
395
396    async fn spawn_echo_server() -> String {
397        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
398        let addr = listener.local_addr().unwrap();
399        tokio::spawn(async move {
400            if let Ok((stream, _)) = listener.accept().await {
401                let mut ws = accept_async(stream).await.unwrap();
402                while let Some(Ok(msg)) = ws.next().await {
403                    if let Message::Text(text) = msg {
404                        let v: Value = serde_json::from_str(&text).unwrap();
405                        let id = v["id"].as_u64().unwrap();
406                        let method = v["method"].as_str().unwrap().to_string();
407                        let reply = json!({
408                            "id": id,
409                            "type": "success",
410                            "result": {"echoed": method}
411                        });
412                        ws.send(Message::Text(reply.to_string())).await.unwrap();
413                    }
414                }
415            }
416        });
417        format!("ws://{}", addr)
418    }
419
420    #[tokio::test]
421    async fn send_receives_success_result() {
422        let url = spawn_echo_server().await;
423        let client = BidiClient::connect(&url).await.unwrap();
424        let result = client.send("session.status", json!({})).await.unwrap();
425        assert_eq!(result["echoed"], "session.status");
426    }
427
428    #[tokio::test]
429    async fn subscriber_receives_event() {
430        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
431        let addr = listener.local_addr().unwrap();
432        tokio::spawn(async move {
433            let (stream, _) = listener.accept().await.unwrap();
434            let mut ws = accept_async(stream).await.unwrap();
435            let event = json!({
436                "type": "event",
437                "method": "log.entryAdded",
438                "params": {"text": "hello"}
439            });
440            ws.send(Message::Text(event.to_string())).await.unwrap();
441            while ws.next().await.is_some() {}
442        });
443        let url = format!("ws://{}", addr);
444        let client = BidiClient::connect(&url).await.unwrap();
445        let mut rx = client.subscribe();
446        let evt = tokio::time::timeout(Duration::from_secs(5), rx.recv())
447            .await
448            .unwrap()
449            .unwrap();
450        assert_eq!(evt.method, "log.entryAdded");
451        assert_eq!(evt.params["text"], "hello");
452    }
453
454    #[test]
455    fn detects_firefox_active_session_error() {
456        let e: anyhow::Error = BidiError {
457            code: "session not created".to_string(),
458            message: "Maximum number of active sessions.".to_string(),
459        }
460        .into();
461        assert!(is_session_already_active(&e));
462
463        let other: anyhow::Error = BidiError {
464            code: "invalid argument".to_string(),
465            message: "Maximum number of active sessions".to_string(),
466        }
467        .into();
468        assert!(!is_session_already_active(&other));
469
470        let unrelated: anyhow::Error = anyhow!("not a bidi error");
471        assert!(!is_session_already_active(&unrelated));
472    }
473
474    /// BiDi error with a "context gone" code surfaces as typed
475    /// `SessionError::TargetGone`. Mirrors the CDP test so the same
476    /// recovery wrappers can switch on the typed variant.
477    #[tokio::test]
478    async fn send_classifies_target_gone() {
479        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
480        let addr = listener.local_addr().unwrap();
481        tokio::spawn(async move {
482            let (stream, _) = listener.accept().await.unwrap();
483            let mut ws = accept_async(stream).await.unwrap();
484            while let Some(Ok(Message::Text(t))) = ws.next().await {
485                let v: Value = serde_json::from_str(&t).unwrap();
486                let id = v["id"].as_u64().unwrap();
487                let reply = json!({
488                    "id": id,
489                    "type": "error",
490                    "error": "no such frame",
491                    "message": "context C1 not found"
492                });
493                ws.send(Message::Text(reply.to_string())).await.unwrap();
494            }
495        });
496        let client = BidiClient::connect(&format!("ws://{}", addr))
497            .await
498            .unwrap();
499        let err = client
500            .send("script.evaluate", json!({"target": {"context": "C1"}}))
501            .await
502            .expect_err("must error");
503        let typed = err
504            .downcast_ref::<crate::errors::SessionError>()
505            .expect("typed SessionError");
506        match typed {
507            crate::errors::SessionError::TargetGone { kind, details } => {
508                assert_eq!(*kind, crate::errors::TargetKind::Bidi);
509                assert!(details.contains("no such frame"));
510            }
511            other => panic!("expected TargetGone, got {other:?}"),
512        }
513    }
514
515    /// Unrelated BiDi errors (e.g. `invalid argument`) are NOT classified
516    /// as `TargetGone` — they pass through as the regular `BidiError` so
517    /// tab-recovery doesn't fire on schema mistakes.
518    #[tokio::test]
519    async fn send_does_not_classify_unrelated_errors() {
520        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
521        let addr = listener.local_addr().unwrap();
522        tokio::spawn(async move {
523            let (stream, _) = listener.accept().await.unwrap();
524            let mut ws = accept_async(stream).await.unwrap();
525            while let Some(Ok(Message::Text(t))) = ws.next().await {
526                let v: Value = serde_json::from_str(&t).unwrap();
527                let id = v["id"].as_u64().unwrap();
528                let reply = json!({
529                    "id": id,
530                    "type": "error",
531                    "error": "invalid argument",
532                    "message": "missing required field"
533                });
534                ws.send(Message::Text(reply.to_string())).await.unwrap();
535            }
536        });
537        let client = BidiClient::connect(&format!("ws://{}", addr))
538            .await
539            .unwrap();
540        let err = client
541            .send("script.evaluate", json!({}))
542            .await
543            .expect_err("must error");
544        assert!(
545            err.downcast_ref::<crate::errors::SessionError>().is_none(),
546            "non-gone BiDi error must not classify as TargetGone"
547        );
548    }
549
550    #[tokio::test]
551    async fn session_new_retries_after_active_session_error() {
552        use std::sync::atomic::{AtomicUsize, Ordering};
553        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
554        let addr = listener.local_addr().unwrap();
555        tokio::spawn(async move {
556            let (stream, _) = listener.accept().await.unwrap();
557            let mut ws = accept_async(stream).await.unwrap();
558            let attempts = AtomicUsize::new(0);
559            while let Some(Ok(Message::Text(text))) = ws.next().await {
560                let v: Value = serde_json::from_str(&text).unwrap();
561                let id = v["id"].as_u64().unwrap();
562                let method = v["method"].as_str().unwrap();
563                let reply = match method {
564                    "session.new" => {
565                        let n = attempts.fetch_add(1, Ordering::SeqCst);
566                        if n == 0 {
567                            json!({
568                                "id": id,
569                                "type": "error",
570                                "error": "session not created",
571                                "message": "Maximum number of active sessions."
572                            })
573                        } else {
574                            json!({
575                                "id": id,
576                                "type": "success",
577                                "result": {"sessionId": "S2"}
578                            })
579                        }
580                    }
581                    "session.end" => json!({"id": id, "type": "success", "result": {}}),
582                    _ => json!({"id": id, "type": "success", "result": {}}),
583                };
584                ws.send(Message::Text(reply.to_string())).await.unwrap();
585            }
586        });
587        let client = BidiClient::connect(&format!("ws://{}", addr))
588            .await
589            .unwrap();
590        let sid = client.session_new().await.unwrap();
591        assert_eq!(sid, "S2");
592    }
593
594    #[test]
595    fn local_value_conversion() {
596        assert_eq!(
597            to_local_value(&json!("x")),
598            json!({"type": "string", "value": "x"})
599        );
600        assert_eq!(
601            to_local_value(&json!(7)),
602            json!({"type": "number", "value": 7})
603        );
604        assert_eq!(
605            to_local_value(&json!(true)),
606            json!({"type": "boolean", "value": true})
607        );
608        assert_eq!(to_local_value(&Value::Null), json!({"type": "null"}));
609    }
610
611    #[test]
612    fn remote_value_flattens_to_plain_json() {
613        let v = json!({"type": "object", "value": [
614            ["href", {"type": "string", "value": "https://x/"}],
615            ["ageMs", {"type": "number", "value": 12.5}],
616            ["nested", {"type": "array", "value": [{"type": "boolean", "value": true}, {"type": "null"}]}],
617            ["fn", {"type": "function"}],
618            ["nan", {"type": "number", "value": "NaN"}]
619        ]});
620        assert_eq!(
621            remote_value_to_json(&v),
622            json!({"href": "https://x/", "ageMs": 12.5, "nested": [true, null], "fn": null, "nan": null})
623        );
624        assert_eq!(
625            remote_value_to_json(&json!({"type": "string", "value": "s"})),
626            json!("s")
627        );
628        assert_eq!(
629            remote_value_to_json(&json!({"type": "undefined"})),
630            Value::Null
631        );
632        assert_eq!(remote_value_to_json(&json!({"type": "object"})), json!({}));
633    }
634
635    #[test]
636    fn script_result_unwrap() {
637        let ok = unwrap_script_result(json!({
638            "type": "success",
639            "result": {"type": "string", "value": "hi"},
640            "realm": "R1"
641        }))
642        .unwrap();
643        assert_eq!(ok["value"], "hi");
644        let err = unwrap_script_result(json!({
645            "type": "exception",
646            "exceptionDetails": {"text": "ReferenceError: nope"},
647        }))
648        .unwrap_err();
649        assert!(err.to_string().contains("ReferenceError: nope"));
650    }
651
652    /// Records every request; answers `script.callFunction` with an
653    /// exception when the declaration mentions `throw`, else a string.
654    async fn spawn_recording_server() -> (String, std::sync::Arc<std::sync::Mutex<Vec<Value>>>) {
655        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
656        let addr = listener.local_addr().unwrap();
657        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
658        tokio::spawn({
659            let seen = seen.clone();
660            async move {
661                let (stream, _) = listener.accept().await.unwrap();
662                let mut ws = accept_async(stream).await.unwrap();
663                while let Some(Ok(Message::Text(text))) = ws.next().await {
664                    let v: Value = serde_json::from_str(&text).unwrap();
665                    seen.lock().unwrap().push(v.clone());
666                    let id = v["id"].as_u64().unwrap();
667                    let decl = v["params"]["functionDeclaration"].as_str().unwrap_or("");
668                    let result = if decl.contains("throw") {
669                        json!({"type": "exception", "exceptionDetails": {"text": "boom"}, "realm": "R1"})
670                    } else if v["method"] == "script.callFunction" {
671                        json!({"type": "success", "result": {"type": "string", "value": "ok"}, "realm": "R1"})
672                    } else {
673                        json!({})
674                    };
675                    let reply = json!({"id": id, "type": "success", "result": result});
676                    ws.send(Message::Text(reply.to_string())).await.unwrap();
677                }
678            }
679        });
680        (format!("ws://{}", addr), seen)
681    }
682
683    #[tokio::test]
684    async fn call_function_and_input_actions_round_trip() {
685        let (url, seen) = spawn_recording_server().await;
686        let client = BidiClient::connect(&url).await.unwrap();
687        let v = client
688            .script_call_function(
689                "C1",
690                "(function(a){ return a })",
691                vec![json!(5), json!("s")],
692            )
693            .await
694            .unwrap();
695        assert_eq!(v["value"], "ok");
696        let err = client
697            .script_call_function("C1", "(function(){ throw 1 })", vec![])
698            .await
699            .unwrap_err();
700        assert!(err.to_string().contains("boom"));
701        client
702            .input_perform_actions("C1", json!([{"type": "key", "id": "kb", "actions": []}]))
703            .await
704            .unwrap();
705        client.input_release_actions("C1").await.unwrap();
706        let reqs = seen.lock().unwrap();
707        let call = &reqs[0];
708        assert_eq!(call["method"], "script.callFunction");
709        assert_eq!(call["params"]["target"]["context"], "C1");
710        assert_eq!(call["params"]["awaitPromise"], true);
711        assert_eq!(call["params"]["resultOwnership"], "none");
712        assert_eq!(
713            call["params"]["arguments"],
714            json!([{"type": "number", "value": 5}, {"type": "string", "value": "s"}])
715        );
716        assert_eq!(reqs[2]["method"], "input.performActions");
717        assert_eq!(reqs[2]["params"]["context"], "C1");
718        assert_eq!(reqs[2]["params"]["actions"][0]["id"], "kb");
719        assert_eq!(reqs[3]["method"], "input.releaseActions");
720    }
721}