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 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 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 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 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 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
295pub(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 other => json!({ "type": "string", "value": other.to_string() }),
305 }
306}
307
308pub(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
322pub 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 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
372fn 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 #[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 #[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 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}