1use std::collections::HashSet;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use anyhow::{anyhow, Result};
27use serde_json::{json, Value};
28
29use crate::bidi::BidiClient;
30use crate::cdp::CdpClient;
31use crate::cli::cookies::{normalize_bidi, normalize_cdp, NormalCookie};
32use crate::errors::SessionError;
33use crate::session::freshness;
34use crate::session::input_bidi;
35use crate::session::targets::{BidiContext, CdpTarget};
36
37const NAV_OP_TIMEOUT: Duration = Duration::from_secs(20);
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum ImageFormat {
49 #[default]
50 Png,
51 Jpeg,
52}
53
54impl ImageFormat {
55 pub fn mime(self) -> &'static str {
56 match self {
57 ImageFormat::Png => "image/png",
58 ImageFormat::Jpeg => "image/jpeg",
59 }
60 }
61
62 pub fn cdp_name(self) -> &'static str {
63 match self {
64 ImageFormat::Png => "png",
65 ImageFormat::Jpeg => "jpeg",
66 }
67 }
68
69 pub fn extension(self) -> &'static str {
70 match self {
71 ImageFormat::Png => "png",
72 ImageFormat::Jpeg => "jpg",
73 }
74 }
75}
76
77pub const DEFAULT_JPEG_QUALITY: u8 = 80;
79
80#[derive(Debug, Clone, Default)]
83pub struct ScreenshotOptions {
84 pub full_page: bool,
85 pub clip: Option<Value>,
87 pub format: ImageFormat,
88 pub quality: Option<u8>,
90 pub max_width: Option<u32>,
92}
93
94fn capture_rect(metrics: &Value, full_page: bool) -> Value {
98 let vp = metrics
99 .get("cssLayoutViewport")
100 .or_else(|| metrics.get("layoutViewport"))
101 .cloned()
102 .unwrap_or(Value::Null);
103 if full_page {
104 let cs = metrics
105 .get("cssContentSize")
106 .or_else(|| metrics.get("contentSize"))
107 .cloned()
108 .unwrap_or(Value::Null);
109 json!({
110 "x": 0,
111 "y": 0,
112 "width": cs["width"].as_f64().unwrap_or(0.0),
113 "height": cs["height"].as_f64().unwrap_or(0.0),
114 })
115 } else {
116 json!({
117 "x": vp["pageX"].as_f64().unwrap_or(0.0),
118 "y": vp["pageY"].as_f64().unwrap_or(0.0),
119 "width": vp["clientWidth"].as_f64().unwrap_or(0.0),
120 "height": vp["clientHeight"].as_f64().unwrap_or(0.0),
121 })
122 }
123}
124
125#[derive(Clone)]
129pub enum TabBackend {
130 Cdp(Arc<CdpClient>),
131 Bidi(Arc<BidiClient>),
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct LiveTarget {
139 pub id: String,
140 pub url: String,
141 pub title: String,
142}
143
144async fn bidi_bounded<T>(
147 target_id: &str,
148 timeout: Duration,
149 fut: impl std::future::Future<Output = Result<T>>,
150) -> Result<T> {
151 match tokio::time::timeout(timeout, fut).await {
152 Ok(r) => r,
153 Err(_) => Err(SessionError::TabHung {
154 target_id: Some(target_id.to_string()),
155 url: None,
156 timeout_ms: timeout.as_millis() as u64,
157 hint: "op-timeout",
158 }
159 .into()),
160 }
161}
162
163impl TabBackend {
164 pub async fn shutdown(&self) {
170 if let TabBackend::Bidi(c) = self {
171 let _ = c.session_end().await;
172 }
173 }
174
175 pub async fn create_tab(&self, url: &str) -> Result<String> {
179 let url = if url.is_empty() { "about:blank" } else { url };
180 match self {
181 TabBackend::Cdp(c) => {
182 let v = c
183 .send(
184 "Target.createTarget",
185 json!({ "url": url, "background": true }),
186 )
187 .await?;
188 v.get("targetId")
189 .and_then(|x| x.as_str())
190 .map(String::from)
191 .ok_or_else(|| anyhow!("Target.createTarget returned no targetId"))
192 }
193 TabBackend::Bidi(c) => c.browsing_context_create(url).await,
194 }
195 }
196
197 pub async fn close_tab(&self, target_id: &str) -> Result<()> {
201 match self {
202 TabBackend::Cdp(c) => {
203 let _ = c
204 .send("Target.closeTarget", json!({ "targetId": target_id }))
205 .await?;
206 Ok(())
207 }
208 TabBackend::Bidi(c) => c.browsing_context_close(target_id).await,
209 }
210 }
211
212 pub async fn navigate(&self, target_id: &str, url: &str) -> Result<()> {
215 match self {
216 TabBackend::Cdp(c) => {
217 let attach = c
218 .send(
219 "Target.attachToTarget",
220 json!({ "targetId": target_id, "flatten": true }),
221 )
222 .await?;
223 let session_id = attach
224 .get("sessionId")
225 .and_then(|v| v.as_str())
226 .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
227 .to_string();
228 let _ = c
232 .send_with_session("Inspector.enable", json!({}), Some(&session_id))
233 .await;
234 let inner = async {
235 c.send_with_session("Page.navigate", json!({ "url": url }), Some(&session_id))
236 .await
237 };
238 let result = crate::session::crash::evaluate_with_crash_detection(
242 c,
243 target_id,
244 Some(&session_id),
245 inner,
246 Some(NAV_OP_TIMEOUT),
247 )
248 .await;
249 let _ = c
250 .send(
251 "Target.detachFromTarget",
252 json!({ "sessionId": session_id }),
253 )
254 .await;
255 result?;
256 Ok(())
257 }
258 TabBackend::Bidi(c) => {
259 let fut = c.browsing_context_navigate(target_id, url);
265 match tokio::time::timeout(NAV_OP_TIMEOUT, fut).await {
266 Ok(r) => r.map(|_| ()),
267 Err(_) => Err(SessionError::TabHung {
268 target_id: Some(target_id.to_string()),
269 url: Some(url.to_string()),
270 timeout_ms: NAV_OP_TIMEOUT.as_millis() as u64,
271 hint: "op-timeout",
272 }
273 .into()),
274 }
275 }
276 }
277 }
278
279 pub async fn show_tab(&self, target_id: &str) -> Result<()> {
284 match self {
285 TabBackend::Cdp(c) => {
286 let _ = c
287 .send("Target.activateTarget", json!({ "targetId": target_id }))
288 .await?;
289 let attach = c
290 .send(
291 "Target.attachToTarget",
292 json!({ "targetId": target_id, "flatten": true }),
293 )
294 .await?;
295 let session_id = attach
296 .get("sessionId")
297 .and_then(|v| v.as_str())
298 .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
299 .to_string();
300 let result = c
301 .send_with_session("Page.bringToFront", json!({}), Some(&session_id))
302 .await;
303 let _ = c
304 .send(
305 "Target.detachFromTarget",
306 json!({ "sessionId": session_id }),
307 )
308 .await;
309 result?;
310 Ok(())
311 }
312 TabBackend::Bidi(c) => {
313 let _ = c
314 .send("browsingContext.activate", json!({ "context": target_id }))
315 .await?;
316 Ok(())
317 }
318 }
319 }
320
321 pub async fn target_for_show(&self) -> Result<String> {
324 if let Some(t) = self.live_targets().await?.into_iter().next() {
325 return Ok(t.id);
326 }
327 self.create_tab("about:blank").await
328 }
329
330 pub async fn ensure_fresh(&self, target_id: &str, max_age: Duration) -> Result<()> {
335 let info_value = self
336 .evaluate(
337 target_id,
338 freshness::PAGE_FRESHNESS_EXPR,
339 false,
340 freshness::CHECK_TIMEOUT,
341 )
342 .await?;
343 let info = freshness::parse_page_freshness(info_value)?;
344 if !info.should_reload(max_age) {
345 return Ok(());
346 }
347
348 tracing::info!(
349 target = "session",
350 target_id = %target_id,
351 url = %info.href,
352 age_ms = info.age_ms,
353 max_age_ms = max_age.as_millis(),
354 "reloading stale tab before reading page context"
355 );
356 self.navigate(target_id, &info.href).await?;
357 self.wait_until_ready(target_id).await
358 }
359
360 async fn wait_until_ready(&self, target_id: &str) -> Result<()> {
361 let deadline = Instant::now() + freshness::RELOAD_READY_TIMEOUT;
362 loop {
363 let value = self
364 .evaluate(
365 target_id,
366 freshness::READY_STATE_EXPR,
367 false,
368 freshness::CHECK_TIMEOUT,
369 )
370 .await?;
371 if freshness::is_ready(&value) {
372 return Ok(());
373 }
374 if Instant::now() >= deadline {
375 tracing::warn!(
376 target = "session",
377 target_id = %target_id,
378 "tab reload did not reach document.readyState=complete before continuing"
379 );
380 return Ok(());
381 }
382 tokio::time::sleep(freshness::READY_POLL_INTERVAL).await;
383 }
384 }
385
386 pub async fn live_target_ids(&self) -> Result<HashSet<String>> {
390 Ok(self
391 .live_targets()
392 .await?
393 .into_iter()
394 .map(|t| t.id)
395 .collect())
396 }
397
398 pub async fn live_targets(&self) -> Result<Vec<LiveTarget>> {
403 match self {
404 TabBackend::Cdp(c) => {
405 let v: Value = c.send("Target.getTargets", json!({})).await?;
406 let arr = v
407 .get("targetInfos")
408 .and_then(|x| x.as_array())
409 .cloned()
410 .unwrap_or_default();
411 Ok(CdpTarget::pages(&arr)
412 .map(|t| LiveTarget {
413 id: t.id,
414 url: t.url,
415 title: t.title,
416 })
417 .collect())
418 }
419 TabBackend::Bidi(c) => {
420 let v: Value = c.send("browsingContext.getTree", json!({})).await?;
421 Ok(BidiContext::from_tree(&v)
424 .into_iter()
425 .map(|ctx| LiveTarget {
426 id: ctx.context,
427 url: ctx.url,
428 title: String::new(),
429 })
430 .collect())
431 }
432 }
433 }
434
435 pub async fn resolve_or_create_for_origin(&self, url: &str) -> Result<String> {
447 let want = url::Url::parse(url).map_err(|e| anyhow!("invalid fetch URL `{url}`: {e}"))?;
448 for t in self.live_targets().await? {
449 if let Ok(parsed) = url::Url::parse(&t.url) {
450 if crate::session::attach::same_origin(&parsed, &want) {
451 return Ok(t.id);
452 }
453 }
454 }
455 let root = crate::session::attach::origin_root_url(&want);
456 self.create_tab(&root).await
457 }
458
459 pub async fn evaluate(
468 &self,
469 target_id: &str,
470 expression: &str,
471 await_promise: bool,
472 timeout: Duration,
473 ) -> Result<Value> {
474 match self {
475 TabBackend::Cdp(c) => {
476 let attach = c
477 .send(
478 "Target.attachToTarget",
479 json!({ "targetId": target_id, "flatten": true }),
480 )
481 .await?;
482 let session_id = attach
483 .get("sessionId")
484 .and_then(|v| v.as_str())
485 .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
486 .to_string();
487 let _ = c
494 .send_with_session("Inspector.enable", json!({}), Some(&session_id))
495 .await;
496 let inner = async {
497 let v = c
498 .send_with_session(
499 "Runtime.evaluate",
500 json!({
501 "expression": expression,
502 "returnByValue": true,
503 "awaitPromise": await_promise,
504 }),
505 Some(&session_id),
506 )
507 .await?;
508 Ok::<Value, anyhow::Error>(v["result"]["value"].clone())
509 };
510 let value = crate::session::crash::evaluate_with_crash_detection(
511 c,
512 target_id,
513 Some(&session_id),
514 inner,
515 Some(timeout),
516 )
517 .await;
518 let _ = c
519 .send(
520 "Target.detachFromTarget",
521 json!({ "sessionId": session_id }),
522 )
523 .await;
524 value
525 }
526 TabBackend::Bidi(c) => {
527 let _ = await_promise; let fut = c.script_evaluate(target_id, expression);
529 match tokio::time::timeout(timeout, fut).await {
530 Ok(Ok(v)) => Ok(crate::bidi::remote_value_to_json(
531 &crate::bidi::unwrap_script_result(v)?,
532 )),
533 Ok(Err(e)) => Err(e),
534 Err(_) => Err(SessionError::TabHung {
535 target_id: Some(target_id.to_string()),
536 url: None,
537 timeout_ms: timeout.as_millis() as u64,
538 hint: "op-timeout",
539 }
540 .into()),
541 }
542 }
543 }
544 }
545
546 pub async fn screenshot(&self, target_id: &str, opts: &ScreenshotOptions) -> Result<String> {
560 match self {
561 TabBackend::Cdp(c) => {
562 let opts = opts.clone();
563 crate::session::cdp_session::with_page_session(
564 c,
565 target_id,
566 NAV_OP_TIMEOUT,
567 |sid| async move {
568 let mut params = json!({
574 "format": opts.format.cdp_name(),
575 "captureBeyondViewport": opts.full_page || opts.clip.is_some(),
576 });
577 if opts.format == ImageFormat::Jpeg {
578 params["quality"] = json!(opts.quality.unwrap_or(DEFAULT_JPEG_QUALITY));
579 }
580 let mut clip = opts.clip.as_ref().map(|rect| {
581 json!({
582 "x": rect["x"],
583 "y": rect["y"],
584 "width": rect["width"],
585 "height": rect["height"],
586 "scale": 1,
587 })
588 });
589 if let Some(max_w) = opts.max_width {
590 let metrics = c
591 .send_with_session("Page.getLayoutMetrics", json!({}), Some(&sid))
592 .await?;
593 let dpr = c
594 .send_with_session(
595 "Runtime.evaluate",
596 json!({ "expression": "window.devicePixelRatio", "returnByValue": true }),
597 Some(&sid),
598 )
599 .await
600 .ok()
601 .and_then(|v| v["result"]["value"].as_f64())
602 .filter(|d| *d > 0.0)
603 .unwrap_or(1.0);
604 let rect = match &clip {
605 Some(cl) => cl.clone(),
606 None => capture_rect(&metrics, opts.full_page),
607 };
608 let width = rect["width"].as_f64().unwrap_or(0.0);
609 if width > 0.0 {
610 let scale = (max_w as f64 / (width * dpr)).min(1.0);
611 if scale < 1.0 {
612 let mut scaled = rect;
613 scaled["scale"] = json!(scale);
614 clip = Some(scaled);
615 params["captureBeyondViewport"] = json!(true);
616 }
617 }
618 }
619 if let Some(cl) = clip {
620 params["clip"] = cl;
621 }
622 let v = c
623 .send_with_session("Page.captureScreenshot", params, Some(&sid))
624 .await?;
625 v["data"]
626 .as_str()
627 .map(|s| s.to_string())
628 .ok_or_else(|| anyhow!("Page.captureScreenshot returned no data"))
629 },
630 )
631 .await
632 }
633 TabBackend::Bidi(c) => {
634 let format = match opts.format {
635 ImageFormat::Png => None,
636 ImageFormat::Jpeg => Some(json!({
637 "type": "image/jpeg",
638 "quality": f64::from(opts.quality.unwrap_or(DEFAULT_JPEG_QUALITY)) / 100.0,
639 })),
640 };
641 let full_page = opts.full_page && opts.clip.is_none();
646 let clip = opts.clip.clone();
647 bidi_bounded(target_id, NAV_OP_TIMEOUT, async move {
648 let clip = if full_page {
649 let (w, h) = input_bidi::document_size(c, target_id).await?;
650 Some(json!({ "x": 0, "y": 0, "width": w, "height": h }))
651 } else {
652 clip
653 };
654 c.browsing_context_capture_screenshot(target_id, clip, format)
655 .await
656 })
657 .await
658 }
659 }
660 }
661
662 pub async fn accessibility_tree(
675 &self,
676 target_id: &str,
677 depth: Option<u32>,
678 timeout: Duration,
679 ) -> Result<Value> {
680 match self {
681 TabBackend::Cdp(c) => {
682 crate::session::cdp_session::with_page_session(
683 c,
684 target_id,
685 timeout,
686 |sid| async move {
687 let _ = c
688 .send_with_session("Accessibility.enable", json!({}), Some(&sid))
689 .await;
690 let mut params = json!({});
691 if let Some(d) = depth {
692 params["depth"] = json!(d);
693 }
694 c.send_with_session("Accessibility.getFullAXTree", params, Some(&sid))
695 .await
696 },
697 )
698 .await
699 }
700 TabBackend::Bidi(c) => {
701 bidi_bounded(
702 target_id,
703 timeout,
704 input_bidi::accessibility_tree(c, target_id),
705 )
706 .await
707 }
708 }
709 }
710
711 pub async fn document_token(&self, target_id: &str, timeout: Duration) -> Result<u64> {
713 match self {
714 TabBackend::Cdp(c) => {
715 crate::session::cdp_session::with_page_session(
716 c,
717 target_id,
718 timeout,
719 |sid| async move { crate::session::input::document_token(c, &sid).await },
720 )
721 .await
722 }
723 TabBackend::Bidi(c) => {
724 bidi_bounded(target_id, timeout, input_bidi::document_token(c, target_id)).await
725 }
726 }
727 }
728
729 pub async fn click_node(
732 &self,
733 target_id: &str,
734 backend_node_id: u64,
735 timeout: Duration,
736 ) -> Result<crate::session::input::Point> {
737 match self {
738 TabBackend::Cdp(c) => crate::session::cdp_session::with_page_session(
739 c,
740 target_id,
741 timeout,
742 |sid| async move { crate::session::input::click(c, &sid, backend_node_id).await },
743 )
744 .await,
745 TabBackend::Bidi(c) => {
746 bidi_bounded(
747 target_id,
748 timeout,
749 input_bidi::click(c, target_id, backend_node_id),
750 )
751 .await
752 }
753 }
754 }
755
756 pub fn ids_are_session_scoped(&self) -> bool {
762 matches!(self, TabBackend::Bidi(_))
763 }
764
765 pub async fn release(&self) {
775 if let TabBackend::Bidi(c) = self {
776 let _ = c.session_end().await;
777 }
778 }
779
780 pub async fn type_into_focused(
786 &self,
787 target_id: &str,
788 text: &str,
789 press_sequentially: bool,
790 submit: bool,
791 timeout: Duration,
792 ) -> Result<()> {
793 match self {
794 TabBackend::Cdp(c) => {
795 let text = text.to_string();
796 crate::session::cdp_session::with_page_session(
797 c,
798 target_id,
799 timeout,
800 |sid| async move {
801 crate::session::input::type_focused(
802 c,
803 &sid,
804 &text,
805 press_sequentially,
806 submit,
807 )
808 .await
809 },
810 )
811 .await
812 }
813 TabBackend::Bidi(c) => {
814 bidi_bounded(
815 target_id,
816 timeout,
817 input_bidi::type_focused(c, target_id, text, submit),
818 )
819 .await
820 }
821 }
822 }
823
824 pub async fn press_key_on_tab(
829 &self,
830 target_id: &str,
831 chord: &crate::session::keys::Chord,
832 timeout: Duration,
833 ) -> Result<()> {
834 match self {
835 TabBackend::Cdp(c) => {
836 let chord = chord.clone();
837 crate::session::cdp_session::with_page_session(
838 c,
839 target_id,
840 timeout,
841 |sid| async move { crate::session::input::press_key(c, &sid, &chord).await },
842 )
843 .await
844 }
845 TabBackend::Bidi(c) => {
846 bidi_bounded(
847 target_id,
848 timeout,
849 input_bidi::press_key(c, target_id, chord),
850 )
851 .await
852 }
853 }
854 }
855
856 pub async fn hover_node(
858 &self,
859 target_id: &str,
860 backend_node_id: u64,
861 timeout: Duration,
862 ) -> Result<crate::session::input::Point> {
863 match self {
864 TabBackend::Cdp(c) => crate::session::cdp_session::with_page_session(
865 c,
866 target_id,
867 timeout,
868 |sid| async move { crate::session::input::hover(c, &sid, backend_node_id).await },
869 )
870 .await,
871 TabBackend::Bidi(c) => {
872 bidi_bounded(
873 target_id,
874 timeout,
875 input_bidi::hover(c, target_id, backend_node_id),
876 )
877 .await
878 }
879 }
880 }
881
882 pub async fn type_into_node(
885 &self,
886 target_id: &str,
887 backend_node_id: u64,
888 text: &str,
889 press_sequentially: bool,
890 submit: bool,
891 timeout: Duration,
892 ) -> Result<()> {
893 match self {
894 TabBackend::Cdp(c) => {
895 let text = text.to_string();
896 crate::session::cdp_session::with_page_session(
897 c,
898 target_id,
899 timeout,
900 |sid| async move {
901 crate::session::input::type_text(
902 c,
903 &sid,
904 backend_node_id,
905 &text,
906 press_sequentially,
907 submit,
908 )
909 .await
910 },
911 )
912 .await
913 }
914 TabBackend::Bidi(c) => {
915 bidi_bounded(
916 target_id,
917 timeout,
918 input_bidi::type_text(
919 c,
920 target_id,
921 backend_node_id,
922 text,
923 press_sequentially,
924 submit,
925 ),
926 )
927 .await
928 }
929 }
930 }
931
932 pub async fn drag_nodes(
934 &self,
935 target_id: &str,
936 from: u64,
937 to: u64,
938 timeout: Duration,
939 ) -> Result<()> {
940 match self {
941 TabBackend::Cdp(c) => {
942 crate::session::cdp_session::with_page_session(
943 c,
944 target_id,
945 timeout,
946 |sid| async move { crate::session::input::drag(c, &sid, from, to).await },
947 )
948 .await
949 }
950 TabBackend::Bidi(c) => {
951 bidi_bounded(target_id, timeout, input_bidi::drag(c, target_id, from, to)).await
952 }
953 }
954 }
955
956 pub async fn node_clip_rect(
959 &self,
960 target_id: &str,
961 backend_node_id: u64,
962 timeout: Duration,
963 ) -> Result<Value> {
964 match self {
965 TabBackend::Cdp(c) => {
966 crate::session::cdp_session::with_page_session(
967 c,
968 target_id,
969 timeout,
970 |sid| async move {
971 crate::session::input::node_clip_rect(c, &sid, backend_node_id).await
972 },
973 )
974 .await
975 }
976 TabBackend::Bidi(c) => {
977 bidi_bounded(
978 target_id,
979 timeout,
980 input_bidi::node_clip_rect(c, target_id, backend_node_id),
981 )
982 .await
983 }
984 }
985 }
986
987 pub(crate) async fn cookies(&self) -> Result<Vec<NormalCookie>> {
996 match self {
997 TabBackend::Cdp(c) => {
998 let v = c.get_all_cookies().await?;
999 let arr = v
1000 .get("cookies")
1001 .and_then(|x| x.as_array())
1002 .ok_or_else(|| anyhow!("CDP cookie export: missing `cookies` array"))?;
1003 Ok(arr.iter().map(normalize_cdp).collect())
1004 }
1005 TabBackend::Bidi(c) => {
1006 let v = c.send("storage.getCookies", json!({})).await?;
1007 let arr = v
1008 .get("cookies")
1009 .and_then(|x| x.as_array())
1010 .ok_or_else(|| anyhow!("BiDi storage.getCookies: missing `cookies` array"))?;
1011 Ok(arr.iter().map(normalize_bidi).collect())
1012 }
1013 }
1014 }
1015
1016 pub(crate) async fn user_agent(&self, target_id: Option<&str>) -> Result<String> {
1021 if let Some(target_id) = target_id {
1022 let value = self
1023 .evaluate(
1024 target_id,
1025 "navigator.userAgent",
1026 false,
1027 Duration::from_secs(5),
1028 )
1029 .await?;
1030 return value
1031 .as_str()
1032 .map(String::from)
1033 .ok_or_else(|| anyhow!("navigator.userAgent returned a non-string value"));
1034 }
1035
1036 if let TabBackend::Cdp(client) = self {
1037 let value = client.send("Browser.getVersion", json!({})).await?;
1038 return value
1039 .get("userAgent")
1040 .and_then(Value::as_str)
1041 .map(String::from)
1042 .ok_or_else(|| anyhow!("Browser.getVersion returned no userAgent"));
1043 }
1044
1045 let (target_id, temporary) = match self.live_targets().await?.into_iter().next() {
1046 Some(target) => (target.id, false),
1047 None => (self.create_tab("about:blank").await?, true),
1048 };
1049 let result = self
1050 .evaluate(
1051 &target_id,
1052 "navigator.userAgent",
1053 false,
1054 Duration::from_secs(5),
1055 )
1056 .await;
1057 if temporary {
1058 let _ = self.close_tab(&target_id).await;
1059 }
1060 let value = result?;
1061 value
1062 .as_str()
1063 .map(String::from)
1064 .ok_or_else(|| anyhow!("navigator.userAgent returned a non-string value"))
1065 }
1066}
1067
1068pub async fn open_backend(endpoint: &str, engine: crate::detect::Engine) -> Result<TabBackend> {
1072 match engine {
1073 crate::detect::Engine::Cdp => {
1074 let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
1075 CdpClient::connect(endpoint).await?
1076 } else {
1077 CdpClient::connect_http(endpoint).await?
1078 };
1079 Ok(TabBackend::Cdp(Arc::new(client)))
1080 }
1081 crate::detect::Engine::Bidi => {
1082 let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
1083 BidiClient::connect(endpoint).await?
1084 } else {
1085 let base = endpoint.trim_end_matches('/');
1090 let url = format!("{base}/json/version");
1091 let client = reqwest::Client::builder()
1092 .timeout(Duration::from_secs(5))
1093 .build()?;
1094 let resp: Value = client.get(&url).send().await?.json().await?;
1095 let ws = resp
1096 .get("webSocketDebuggerUrl")
1097 .and_then(|x| x.as_str())
1098 .ok_or_else(|| anyhow!("webSocketDebuggerUrl missing from {url}"))?
1099 .to_string();
1100 BidiClient::connect(&ws).await?
1101 };
1102 client.session_new().await?;
1106 Ok(TabBackend::Bidi(Arc::new(client)))
1107 }
1108 }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114 use futures_util::{SinkExt, StreamExt};
1115 use std::sync::Arc;
1116 use tokio::sync::{oneshot, Mutex};
1117 use tokio_tungstenite::tungstenite::Message;
1118
1119 async fn spawn_cdp_mock() -> (String, oneshot::Sender<()>) {
1124 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1125 let addr = listener.local_addr().unwrap();
1126 let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
1127 tokio::spawn(async move {
1128 let (stream, _) = listener.accept().await.unwrap();
1129 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1130 let mut next_target = 0u32;
1131 let mut next_session = 0u32;
1132 let mut live = std::collections::HashMap::<String, String>::new();
1135 let mut sessions = std::collections::HashMap::<String, String>::new();
1138 loop {
1139 tokio::select! {
1140 _ = &mut stop_rx => break,
1141 msg = ws.next() => {
1142 let msg = match msg {
1143 Some(Ok(m)) => m,
1144 _ => break,
1145 };
1146 if let Message::Text(t) = msg {
1147 let req: Value = serde_json::from_str(&t).unwrap();
1148 let id = req["id"].as_u64().unwrap();
1149 let method = req["method"].as_str().unwrap_or("");
1150 let result = match method {
1151 "Target.createTarget" => {
1152 next_target += 1;
1153 let tid = format!("T{next_target}");
1154 let url = req
1155 .pointer("/params/url")
1156 .and_then(|v| v.as_str())
1157 .unwrap_or("")
1158 .to_string();
1159 live.insert(tid.clone(), url);
1160 json!({"targetId": tid})
1161 }
1162 "Target.closeTarget" => {
1163 if let Some(tid) = req
1164 .pointer("/params/targetId")
1165 .and_then(|v| v.as_str())
1166 {
1167 live.remove(tid);
1168 }
1169 json!({"success": true})
1170 }
1171 "Target.attachToTarget" => {
1172 next_session += 1;
1173 let sid = format!("S{next_session}");
1174 if let Some(tid) = req
1175 .pointer("/params/targetId")
1176 .and_then(|v| v.as_str())
1177 {
1178 sessions.insert(sid.clone(), tid.to_string());
1179 }
1180 json!({"sessionId": sid})
1181 }
1182 "Target.detachFromTarget" => json!({}),
1183 "Page.navigate" => {
1184 if let (Some(sid), Some(url)) = (
1187 req.pointer("/sessionId").and_then(|v| v.as_str()),
1188 req.pointer("/params/url").and_then(|v| v.as_str()),
1189 ) {
1190 if let Some(tid) = sessions.get(sid) {
1191 live.insert(tid.clone(), url.to_string());
1192 }
1193 }
1194 json!({})
1195 }
1196 "Runtime.evaluate" => json!({"result": {"value": 7}}),
1197 "Target.getTargets" => {
1198 let infos: Vec<Value> = live
1199 .iter()
1200 .map(|(tid, url)| json!({"targetId": tid, "type": "page", "url": url}))
1201 .collect();
1202 json!({"targetInfos": infos})
1203 }
1204 _ => json!({}),
1205 };
1206 let resp = json!({"id": id, "result": result});
1207 ws.send(Message::Text(resp.to_string())).await.unwrap();
1208 }
1209 }
1210 }
1211 }
1212 });
1213 (format!("ws://{addr}"), stop_tx)
1214 }
1215
1216 async fn spawn_bidi_mock() -> (String, oneshot::Sender<()>) {
1217 let (url, stop, _captures) = spawn_bidi_mock_with_captures().await;
1218 (url, stop)
1219 }
1220
1221 async fn spawn_bidi_mock_with_captures() -> (String, oneshot::Sender<()>, Arc<Mutex<Vec<Value>>>)
1223 {
1224 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1225 let addr = listener.local_addr().unwrap();
1226 let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
1227 let captures = Arc::new(Mutex::new(Vec::<Value>::new()));
1228 let captures_task = captures.clone();
1229 tokio::spawn(async move {
1230 let captures = captures_task;
1231 let (stream, _) = listener.accept().await.unwrap();
1232 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1233 let mut next_ctx = 0u32;
1234 let mut live = std::collections::HashSet::<String>::new();
1235 loop {
1236 tokio::select! {
1237 _ = &mut stop_rx => break,
1238 msg = ws.next() => {
1239 let msg = match msg {
1240 Some(Ok(m)) => m,
1241 _ => break,
1242 };
1243 if let Message::Text(t) = msg {
1244 let req: Value = serde_json::from_str(&t).unwrap();
1245 let id = req["id"].as_u64().unwrap();
1246 let method = req["method"].as_str().unwrap_or("");
1247 let result = match method {
1248 "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
1249 "browsingContext.create" => {
1250 next_ctx += 1;
1251 let c = format!("C{next_ctx}");
1252 live.insert(c.clone());
1253 json!({"context": c})
1254 }
1255 "browsingContext.close" => {
1256 if let Some(c) = req
1257 .pointer("/params/context")
1258 .and_then(|v| v.as_str())
1259 {
1260 live.remove(c);
1261 }
1262 json!({})
1263 }
1264 "browsingContext.navigate" => json!({"navigation": "N1"}),
1265 "script.evaluate"
1266 if req["params"]["expression"]
1267 .as_str()
1268 .is_some_and(|e| e.contains("scrollWidth")) =>
1269 {
1270 json!({"type": "success", "result": {"type": "string", "value": "{\"width\":1000,\"height\":3000}"}, "realm": "R1"})
1271 }
1272 "script.evaluate" => json!({"type": "success", "result": {"type": "number", "value": 9}, "realm": "R1"}),
1273 "browsingContext.captureScreenshot" => {
1274 captures.lock().await.push(req["params"].clone());
1275 json!({"data": "PNG"})
1276 }
1277 "browsingContext.getTree" => {
1278 let contexts: Vec<Value> = live
1279 .iter()
1280 .map(|c| json!({"context": c, "url": "", "children": []}))
1281 .collect();
1282 json!({"contexts": contexts})
1283 }
1284 _ => json!({}),
1285 };
1286 let resp = json!({"type": "success", "id": id, "result": result});
1289 ws.send(Message::Text(resp.to_string())).await.unwrap();
1290 }
1291 }
1292 }
1293 }
1294 });
1295 (format!("ws://{addr}"), stop_tx, captures)
1296 }
1297
1298 async fn spawn_cdp_freshness_mock() -> (String, Arc<Mutex<Vec<String>>>) {
1299 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1300 let addr = listener.local_addr().unwrap();
1301 let navigations = Arc::new(Mutex::new(Vec::new()));
1302 tokio::spawn({
1303 let navigations = navigations.clone();
1304 async move {
1305 let (stream, _) = listener.accept().await.unwrap();
1306 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1307 while let Some(Ok(Message::Text(t))) = ws.next().await {
1308 let req: Value = serde_json::from_str(&t).unwrap();
1309 let id = req["id"].as_u64().unwrap();
1310 let method = req["method"].as_str().unwrap_or("");
1311 let result = match method {
1312 "Target.attachToTarget" => json!({"sessionId": "S1"}),
1313 "Target.detachFromTarget" => json!({}),
1314 "Inspector.enable" => json!({}),
1315 "Runtime.evaluate" => {
1316 let expression = req
1317 .pointer("/params/expression")
1318 .and_then(|v| v.as_str())
1319 .unwrap_or("");
1320 let value = if expression == freshness::PAGE_FRESHNESS_EXPR {
1321 json!({
1322 "href": "https://example.com/app",
1323 "ageMs": 700_000.0,
1324 "readyState": "complete"
1325 })
1326 } else if expression == freshness::READY_STATE_EXPR {
1327 json!("complete")
1328 } else {
1329 json!(7)
1330 };
1331 json!({"result": {"value": value}})
1332 }
1333 "Page.navigate" => {
1334 let url = req
1335 .pointer("/params/url")
1336 .and_then(|v| v.as_str())
1337 .unwrap_or("")
1338 .to_string();
1339 navigations.lock().await.push(url);
1340 json!({})
1341 }
1342 _ => json!({}),
1343 };
1344 let resp = json!({"id": id, "result": result});
1345 ws.send(Message::Text(resp.to_string())).await.unwrap();
1346 }
1347 }
1348 });
1349 (format!("ws://{addr}"), navigations)
1350 }
1351
1352 async fn spawn_cdp_recording_mock() -> (String, Arc<Mutex<Vec<Value>>>) {
1353 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1354 let addr = listener.local_addr().unwrap();
1355 let seen = Arc::new(Mutex::new(Vec::<Value>::new()));
1356 tokio::spawn({
1357 let seen = seen.clone();
1358 async move {
1359 let (stream, _) = listener.accept().await.unwrap();
1360 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1361 while let Some(Ok(Message::Text(t))) = ws.next().await {
1362 let req: Value = serde_json::from_str(&t).unwrap();
1363 seen.lock().await.push(req.clone());
1364 let id = req["id"].as_u64().unwrap();
1365 let method = req["method"].as_str().unwrap_or("");
1366 let result = match method {
1367 "Target.createTarget" => json!({"targetId": "T1"}),
1368 "Target.attachToTarget" => json!({"sessionId": "S1"}),
1369 "Target.getTargets" => json!({"targetInfos": [
1370 {"targetId": "T1", "type": "page", "url": "about:blank", "title": ""}
1371 ]}),
1372 _ => json!({}),
1373 };
1374 let resp = json!({"id": id, "result": result});
1375 ws.send(Message::Text(resp.to_string())).await.unwrap();
1376 }
1377 }
1378 });
1379 (format!("ws://{addr}"), seen)
1380 }
1381
1382 #[tokio::test]
1383 async fn cdp_backend_create_close_navigate_list_evaluate() {
1384 let (url, _stop) = spawn_cdp_mock().await;
1385 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1386 .await
1387 .unwrap();
1388 let t1 = backend.create_tab("about:blank").await.unwrap();
1389 assert_eq!(t1, "T1");
1390 backend.navigate(&t1, "https://example.com/").await.unwrap();
1391 let live = backend.live_target_ids().await.unwrap();
1392 assert!(live.contains(&t1));
1393 let v = backend
1394 .evaluate(&t1, "1+1", false, Duration::from_secs(1))
1395 .await
1396 .unwrap();
1397 assert_eq!(v, json!(7));
1398 backend.close_tab(&t1).await.unwrap();
1399 let live = backend.live_target_ids().await.unwrap();
1400 assert!(!live.contains(&t1));
1401 }
1402
1403 #[tokio::test]
1404 async fn cdp_create_tab_requests_background_target() {
1405 let (url, seen) = spawn_cdp_recording_mock().await;
1406 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1407 .await
1408 .unwrap();
1409 let tid = backend.create_tab("https://example.com/").await.unwrap();
1410 assert_eq!(tid, "T1");
1411 let calls = seen.lock().await;
1412 let create = calls
1413 .iter()
1414 .find(|v| v["method"] == "Target.createTarget")
1415 .expect("create call");
1416 assert_eq!(
1417 create.pointer("/params/url").and_then(Value::as_str),
1418 Some("https://example.com/")
1419 );
1420 assert_eq!(
1421 create
1422 .pointer("/params/background")
1423 .and_then(Value::as_bool),
1424 Some(true)
1425 );
1426 }
1427
1428 #[tokio::test]
1429 async fn cdp_show_tab_activates_and_brings_to_front() {
1430 let (url, seen) = spawn_cdp_recording_mock().await;
1431 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1432 .await
1433 .unwrap();
1434 backend.show_tab("T1").await.unwrap();
1435 let methods: Vec<String> = seen
1436 .lock()
1437 .await
1438 .iter()
1439 .filter_map(|v| v["method"].as_str().map(String::from))
1440 .collect();
1441 assert_eq!(
1442 methods,
1443 vec![
1444 "Target.activateTarget",
1445 "Target.attachToTarget",
1446 "Page.bringToFront",
1447 "Target.detachFromTarget"
1448 ]
1449 );
1450 }
1451
1452 #[tokio::test]
1453 async fn ensure_fresh_reloads_old_http_page() {
1454 let (url, navigations) = spawn_cdp_freshness_mock().await;
1455 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1456 .await
1457 .unwrap();
1458 backend
1459 .ensure_fresh("T1", Duration::from_secs(600))
1460 .await
1461 .unwrap();
1462 assert_eq!(
1463 *navigations.lock().await,
1464 vec!["https://example.com/app".to_string()]
1465 );
1466 }
1467
1468 #[tokio::test]
1469 async fn resolve_for_origin_reuses_same_origin_tab() {
1470 let (url, _stop) = spawn_cdp_mock().await;
1471 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1472 .await
1473 .unwrap();
1474 let t1 = backend.create_tab("about:blank").await.unwrap();
1476 backend
1477 .navigate(&t1, "https://example.com/login")
1478 .await
1479 .unwrap();
1480 let resolved = backend
1483 .resolve_or_create_for_origin("https://example.com/api/v1")
1484 .await
1485 .unwrap();
1486 assert_eq!(resolved, t1);
1487 }
1488
1489 #[tokio::test]
1490 async fn resolve_for_origin_creates_tab_when_no_match() {
1491 let (url, _stop) = spawn_cdp_mock().await;
1492 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1493 .await
1494 .unwrap();
1495 let t1 = backend.create_tab("about:blank").await.unwrap();
1496 backend.navigate(&t1, "https://other.test/").await.unwrap();
1497 let resolved = backend
1500 .resolve_or_create_for_origin("https://example.com/api")
1501 .await
1502 .unwrap();
1503 assert_ne!(resolved, t1);
1504 let live = backend.live_target_ids().await.unwrap();
1505 assert!(live.contains(&resolved));
1506 }
1507
1508 #[tokio::test]
1509 async fn bidi_backend_create_close_navigate_list_evaluate() {
1510 let (url, _stop) = spawn_bidi_mock().await;
1511 let backend = open_backend(&url, crate::detect::Engine::Bidi)
1512 .await
1513 .unwrap();
1514 let c1 = backend.create_tab("about:blank").await.unwrap();
1515 assert_eq!(c1, "C1");
1516 backend.navigate(&c1, "https://example.com/").await.unwrap();
1517 let live = backend.live_target_ids().await.unwrap();
1518 assert!(live.contains(&c1));
1519 let v = backend
1520 .evaluate(&c1, "1+1", false, Duration::from_secs(1))
1521 .await
1522 .unwrap();
1523 assert_eq!(v, json!(9));
1524 backend.close_tab(&c1).await.unwrap();
1525 let live = backend.live_target_ids().await.unwrap();
1526 assert!(!live.contains(&c1));
1527 }
1528
1529 #[tokio::test]
1530 async fn bidi_full_page_screenshot_uses_document_clip() {
1531 let (url, _stop, captures) = spawn_bidi_mock_with_captures().await;
1532 let backend = open_backend(&url, crate::detect::Engine::Bidi)
1533 .await
1534 .unwrap();
1535 let c1 = backend.create_tab("about:blank").await.unwrap();
1536 backend
1537 .screenshot(
1538 &c1,
1539 &ScreenshotOptions {
1540 full_page: true,
1541 ..Default::default()
1542 },
1543 )
1544 .await
1545 .unwrap();
1546 backend
1547 .screenshot(&c1, &ScreenshotOptions::default())
1548 .await
1549 .unwrap();
1550 let caps = captures.lock().await;
1551 assert_eq!(caps.len(), 2);
1552 assert_eq!(caps[0]["origin"], "document");
1553 assert_eq!(caps[0]["clip"]["type"], "box");
1554 assert_eq!(caps[0]["clip"]["width"], json!(1000.0));
1555 assert_eq!(caps[0]["clip"]["height"], json!(3000.0));
1556 assert!(caps[1].get("clip").is_none());
1557 backend.shutdown().await;
1558 }
1559}