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 async fn hover_node(
758 &self,
759 target_id: &str,
760 backend_node_id: u64,
761 timeout: Duration,
762 ) -> Result<crate::session::input::Point> {
763 match self {
764 TabBackend::Cdp(c) => crate::session::cdp_session::with_page_session(
765 c,
766 target_id,
767 timeout,
768 |sid| async move { crate::session::input::hover(c, &sid, backend_node_id).await },
769 )
770 .await,
771 TabBackend::Bidi(c) => {
772 bidi_bounded(
773 target_id,
774 timeout,
775 input_bidi::hover(c, target_id, backend_node_id),
776 )
777 .await
778 }
779 }
780 }
781
782 pub async fn type_into_node(
785 &self,
786 target_id: &str,
787 backend_node_id: u64,
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_text(
802 c,
803 &sid,
804 backend_node_id,
805 &text,
806 press_sequentially,
807 submit,
808 )
809 .await
810 },
811 )
812 .await
813 }
814 TabBackend::Bidi(c) => {
815 bidi_bounded(
816 target_id,
817 timeout,
818 input_bidi::type_text(
819 c,
820 target_id,
821 backend_node_id,
822 text,
823 press_sequentially,
824 submit,
825 ),
826 )
827 .await
828 }
829 }
830 }
831
832 pub async fn drag_nodes(
834 &self,
835 target_id: &str,
836 from: u64,
837 to: u64,
838 timeout: Duration,
839 ) -> Result<()> {
840 match self {
841 TabBackend::Cdp(c) => {
842 crate::session::cdp_session::with_page_session(
843 c,
844 target_id,
845 timeout,
846 |sid| async move { crate::session::input::drag(c, &sid, from, to).await },
847 )
848 .await
849 }
850 TabBackend::Bidi(c) => {
851 bidi_bounded(target_id, timeout, input_bidi::drag(c, target_id, from, to)).await
852 }
853 }
854 }
855
856 pub async fn node_clip_rect(
859 &self,
860 target_id: &str,
861 backend_node_id: u64,
862 timeout: Duration,
863 ) -> Result<Value> {
864 match self {
865 TabBackend::Cdp(c) => {
866 crate::session::cdp_session::with_page_session(
867 c,
868 target_id,
869 timeout,
870 |sid| async move {
871 crate::session::input::node_clip_rect(c, &sid, backend_node_id).await
872 },
873 )
874 .await
875 }
876 TabBackend::Bidi(c) => {
877 bidi_bounded(
878 target_id,
879 timeout,
880 input_bidi::node_clip_rect(c, target_id, backend_node_id),
881 )
882 .await
883 }
884 }
885 }
886
887 pub(crate) async fn cookies(&self) -> Result<Vec<NormalCookie>> {
896 match self {
897 TabBackend::Cdp(c) => {
898 let v = c.get_all_cookies().await?;
899 let arr = v
900 .get("cookies")
901 .and_then(|x| x.as_array())
902 .ok_or_else(|| anyhow!("CDP cookie export: missing `cookies` array"))?;
903 Ok(arr.iter().map(normalize_cdp).collect())
904 }
905 TabBackend::Bidi(c) => {
906 let v = c.send("storage.getCookies", json!({})).await?;
907 let arr = v
908 .get("cookies")
909 .and_then(|x| x.as_array())
910 .ok_or_else(|| anyhow!("BiDi storage.getCookies: missing `cookies` array"))?;
911 Ok(arr.iter().map(normalize_bidi).collect())
912 }
913 }
914 }
915
916 pub(crate) async fn user_agent(&self, target_id: Option<&str>) -> Result<String> {
921 if let Some(target_id) = target_id {
922 let value = self
923 .evaluate(
924 target_id,
925 "navigator.userAgent",
926 false,
927 Duration::from_secs(5),
928 )
929 .await?;
930 return value
931 .as_str()
932 .map(String::from)
933 .ok_or_else(|| anyhow!("navigator.userAgent returned a non-string value"));
934 }
935
936 if let TabBackend::Cdp(client) = self {
937 let value = client.send("Browser.getVersion", json!({})).await?;
938 return value
939 .get("userAgent")
940 .and_then(Value::as_str)
941 .map(String::from)
942 .ok_or_else(|| anyhow!("Browser.getVersion returned no userAgent"));
943 }
944
945 let (target_id, temporary) = match self.live_targets().await?.into_iter().next() {
946 Some(target) => (target.id, false),
947 None => (self.create_tab("about:blank").await?, true),
948 };
949 let result = self
950 .evaluate(
951 &target_id,
952 "navigator.userAgent",
953 false,
954 Duration::from_secs(5),
955 )
956 .await;
957 if temporary {
958 let _ = self.close_tab(&target_id).await;
959 }
960 let value = result?;
961 value
962 .as_str()
963 .map(String::from)
964 .ok_or_else(|| anyhow!("navigator.userAgent returned a non-string value"))
965 }
966}
967
968pub async fn open_backend(endpoint: &str, engine: crate::detect::Engine) -> Result<TabBackend> {
972 match engine {
973 crate::detect::Engine::Cdp => {
974 let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
975 CdpClient::connect(endpoint).await?
976 } else {
977 CdpClient::connect_http(endpoint).await?
978 };
979 Ok(TabBackend::Cdp(Arc::new(client)))
980 }
981 crate::detect::Engine::Bidi => {
982 let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
983 BidiClient::connect(endpoint).await?
984 } else {
985 let base = endpoint.trim_end_matches('/');
990 let url = format!("{base}/json/version");
991 let client = reqwest::Client::builder()
992 .timeout(Duration::from_secs(5))
993 .build()?;
994 let resp: Value = client.get(&url).send().await?.json().await?;
995 let ws = resp
996 .get("webSocketDebuggerUrl")
997 .and_then(|x| x.as_str())
998 .ok_or_else(|| anyhow!("webSocketDebuggerUrl missing from {url}"))?
999 .to_string();
1000 BidiClient::connect(&ws).await?
1001 };
1002 client.session_new().await?;
1006 Ok(TabBackend::Bidi(Arc::new(client)))
1007 }
1008 }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014 use futures_util::{SinkExt, StreamExt};
1015 use std::sync::Arc;
1016 use tokio::sync::{oneshot, Mutex};
1017 use tokio_tungstenite::tungstenite::Message;
1018
1019 async fn spawn_cdp_mock() -> (String, oneshot::Sender<()>) {
1024 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1025 let addr = listener.local_addr().unwrap();
1026 let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
1027 tokio::spawn(async move {
1028 let (stream, _) = listener.accept().await.unwrap();
1029 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1030 let mut next_target = 0u32;
1031 let mut next_session = 0u32;
1032 let mut live = std::collections::HashMap::<String, String>::new();
1035 let mut sessions = std::collections::HashMap::<String, String>::new();
1038 loop {
1039 tokio::select! {
1040 _ = &mut stop_rx => break,
1041 msg = ws.next() => {
1042 let msg = match msg {
1043 Some(Ok(m)) => m,
1044 _ => break,
1045 };
1046 if let Message::Text(t) = msg {
1047 let req: Value = serde_json::from_str(&t).unwrap();
1048 let id = req["id"].as_u64().unwrap();
1049 let method = req["method"].as_str().unwrap_or("");
1050 let result = match method {
1051 "Target.createTarget" => {
1052 next_target += 1;
1053 let tid = format!("T{next_target}");
1054 let url = req
1055 .pointer("/params/url")
1056 .and_then(|v| v.as_str())
1057 .unwrap_or("")
1058 .to_string();
1059 live.insert(tid.clone(), url);
1060 json!({"targetId": tid})
1061 }
1062 "Target.closeTarget" => {
1063 if let Some(tid) = req
1064 .pointer("/params/targetId")
1065 .and_then(|v| v.as_str())
1066 {
1067 live.remove(tid);
1068 }
1069 json!({"success": true})
1070 }
1071 "Target.attachToTarget" => {
1072 next_session += 1;
1073 let sid = format!("S{next_session}");
1074 if let Some(tid) = req
1075 .pointer("/params/targetId")
1076 .and_then(|v| v.as_str())
1077 {
1078 sessions.insert(sid.clone(), tid.to_string());
1079 }
1080 json!({"sessionId": sid})
1081 }
1082 "Target.detachFromTarget" => json!({}),
1083 "Page.navigate" => {
1084 if let (Some(sid), Some(url)) = (
1087 req.pointer("/sessionId").and_then(|v| v.as_str()),
1088 req.pointer("/params/url").and_then(|v| v.as_str()),
1089 ) {
1090 if let Some(tid) = sessions.get(sid) {
1091 live.insert(tid.clone(), url.to_string());
1092 }
1093 }
1094 json!({})
1095 }
1096 "Runtime.evaluate" => json!({"result": {"value": 7}}),
1097 "Target.getTargets" => {
1098 let infos: Vec<Value> = live
1099 .iter()
1100 .map(|(tid, url)| json!({"targetId": tid, "type": "page", "url": url}))
1101 .collect();
1102 json!({"targetInfos": infos})
1103 }
1104 _ => json!({}),
1105 };
1106 let resp = json!({"id": id, "result": result});
1107 ws.send(Message::Text(resp.to_string())).await.unwrap();
1108 }
1109 }
1110 }
1111 }
1112 });
1113 (format!("ws://{addr}"), stop_tx)
1114 }
1115
1116 async fn spawn_bidi_mock() -> (String, oneshot::Sender<()>) {
1117 let (url, stop, _captures) = spawn_bidi_mock_with_captures().await;
1118 (url, stop)
1119 }
1120
1121 async fn spawn_bidi_mock_with_captures() -> (String, oneshot::Sender<()>, Arc<Mutex<Vec<Value>>>)
1123 {
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 let captures = Arc::new(Mutex::new(Vec::<Value>::new()));
1128 let captures_task = captures.clone();
1129 tokio::spawn(async move {
1130 let captures = captures_task;
1131 let (stream, _) = listener.accept().await.unwrap();
1132 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1133 let mut next_ctx = 0u32;
1134 let mut live = std::collections::HashSet::<String>::new();
1135 loop {
1136 tokio::select! {
1137 _ = &mut stop_rx => break,
1138 msg = ws.next() => {
1139 let msg = match msg {
1140 Some(Ok(m)) => m,
1141 _ => break,
1142 };
1143 if let Message::Text(t) = msg {
1144 let req: Value = serde_json::from_str(&t).unwrap();
1145 let id = req["id"].as_u64().unwrap();
1146 let method = req["method"].as_str().unwrap_or("");
1147 let result = match method {
1148 "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
1149 "browsingContext.create" => {
1150 next_ctx += 1;
1151 let c = format!("C{next_ctx}");
1152 live.insert(c.clone());
1153 json!({"context": c})
1154 }
1155 "browsingContext.close" => {
1156 if let Some(c) = req
1157 .pointer("/params/context")
1158 .and_then(|v| v.as_str())
1159 {
1160 live.remove(c);
1161 }
1162 json!({})
1163 }
1164 "browsingContext.navigate" => json!({"navigation": "N1"}),
1165 "script.evaluate"
1166 if req["params"]["expression"]
1167 .as_str()
1168 .is_some_and(|e| e.contains("scrollWidth")) =>
1169 {
1170 json!({"type": "success", "result": {"type": "string", "value": "{\"width\":1000,\"height\":3000}"}, "realm": "R1"})
1171 }
1172 "script.evaluate" => json!({"type": "success", "result": {"type": "number", "value": 9}, "realm": "R1"}),
1173 "browsingContext.captureScreenshot" => {
1174 captures.lock().await.push(req["params"].clone());
1175 json!({"data": "PNG"})
1176 }
1177 "browsingContext.getTree" => {
1178 let contexts: Vec<Value> = live
1179 .iter()
1180 .map(|c| json!({"context": c, "url": "", "children": []}))
1181 .collect();
1182 json!({"contexts": contexts})
1183 }
1184 _ => json!({}),
1185 };
1186 let resp = json!({"type": "success", "id": id, "result": result});
1189 ws.send(Message::Text(resp.to_string())).await.unwrap();
1190 }
1191 }
1192 }
1193 }
1194 });
1195 (format!("ws://{addr}"), stop_tx, captures)
1196 }
1197
1198 async fn spawn_cdp_freshness_mock() -> (String, Arc<Mutex<Vec<String>>>) {
1199 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1200 let addr = listener.local_addr().unwrap();
1201 let navigations = Arc::new(Mutex::new(Vec::new()));
1202 tokio::spawn({
1203 let navigations = navigations.clone();
1204 async move {
1205 let (stream, _) = listener.accept().await.unwrap();
1206 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1207 while let Some(Ok(Message::Text(t))) = ws.next().await {
1208 let req: Value = serde_json::from_str(&t).unwrap();
1209 let id = req["id"].as_u64().unwrap();
1210 let method = req["method"].as_str().unwrap_or("");
1211 let result = match method {
1212 "Target.attachToTarget" => json!({"sessionId": "S1"}),
1213 "Target.detachFromTarget" => json!({}),
1214 "Inspector.enable" => json!({}),
1215 "Runtime.evaluate" => {
1216 let expression = req
1217 .pointer("/params/expression")
1218 .and_then(|v| v.as_str())
1219 .unwrap_or("");
1220 let value = if expression == freshness::PAGE_FRESHNESS_EXPR {
1221 json!({
1222 "href": "https://example.com/app",
1223 "ageMs": 700_000.0,
1224 "readyState": "complete"
1225 })
1226 } else if expression == freshness::READY_STATE_EXPR {
1227 json!("complete")
1228 } else {
1229 json!(7)
1230 };
1231 json!({"result": {"value": value}})
1232 }
1233 "Page.navigate" => {
1234 let url = req
1235 .pointer("/params/url")
1236 .and_then(|v| v.as_str())
1237 .unwrap_or("")
1238 .to_string();
1239 navigations.lock().await.push(url);
1240 json!({})
1241 }
1242 _ => json!({}),
1243 };
1244 let resp = json!({"id": id, "result": result});
1245 ws.send(Message::Text(resp.to_string())).await.unwrap();
1246 }
1247 }
1248 });
1249 (format!("ws://{addr}"), navigations)
1250 }
1251
1252 async fn spawn_cdp_recording_mock() -> (String, Arc<Mutex<Vec<Value>>>) {
1253 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1254 let addr = listener.local_addr().unwrap();
1255 let seen = Arc::new(Mutex::new(Vec::<Value>::new()));
1256 tokio::spawn({
1257 let seen = seen.clone();
1258 async move {
1259 let (stream, _) = listener.accept().await.unwrap();
1260 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1261 while let Some(Ok(Message::Text(t))) = ws.next().await {
1262 let req: Value = serde_json::from_str(&t).unwrap();
1263 seen.lock().await.push(req.clone());
1264 let id = req["id"].as_u64().unwrap();
1265 let method = req["method"].as_str().unwrap_or("");
1266 let result = match method {
1267 "Target.createTarget" => json!({"targetId": "T1"}),
1268 "Target.attachToTarget" => json!({"sessionId": "S1"}),
1269 "Target.getTargets" => json!({"targetInfos": [
1270 {"targetId": "T1", "type": "page", "url": "about:blank", "title": ""}
1271 ]}),
1272 _ => json!({}),
1273 };
1274 let resp = json!({"id": id, "result": result});
1275 ws.send(Message::Text(resp.to_string())).await.unwrap();
1276 }
1277 }
1278 });
1279 (format!("ws://{addr}"), seen)
1280 }
1281
1282 #[tokio::test]
1283 async fn cdp_backend_create_close_navigate_list_evaluate() {
1284 let (url, _stop) = spawn_cdp_mock().await;
1285 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1286 .await
1287 .unwrap();
1288 let t1 = backend.create_tab("about:blank").await.unwrap();
1289 assert_eq!(t1, "T1");
1290 backend.navigate(&t1, "https://example.com/").await.unwrap();
1291 let live = backend.live_target_ids().await.unwrap();
1292 assert!(live.contains(&t1));
1293 let v = backend
1294 .evaluate(&t1, "1+1", false, Duration::from_secs(1))
1295 .await
1296 .unwrap();
1297 assert_eq!(v, json!(7));
1298 backend.close_tab(&t1).await.unwrap();
1299 let live = backend.live_target_ids().await.unwrap();
1300 assert!(!live.contains(&t1));
1301 }
1302
1303 #[tokio::test]
1304 async fn cdp_create_tab_requests_background_target() {
1305 let (url, seen) = spawn_cdp_recording_mock().await;
1306 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1307 .await
1308 .unwrap();
1309 let tid = backend.create_tab("https://example.com/").await.unwrap();
1310 assert_eq!(tid, "T1");
1311 let calls = seen.lock().await;
1312 let create = calls
1313 .iter()
1314 .find(|v| v["method"] == "Target.createTarget")
1315 .expect("create call");
1316 assert_eq!(
1317 create.pointer("/params/url").and_then(Value::as_str),
1318 Some("https://example.com/")
1319 );
1320 assert_eq!(
1321 create
1322 .pointer("/params/background")
1323 .and_then(Value::as_bool),
1324 Some(true)
1325 );
1326 }
1327
1328 #[tokio::test]
1329 async fn cdp_show_tab_activates_and_brings_to_front() {
1330 let (url, seen) = spawn_cdp_recording_mock().await;
1331 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1332 .await
1333 .unwrap();
1334 backend.show_tab("T1").await.unwrap();
1335 let methods: Vec<String> = seen
1336 .lock()
1337 .await
1338 .iter()
1339 .filter_map(|v| v["method"].as_str().map(String::from))
1340 .collect();
1341 assert_eq!(
1342 methods,
1343 vec![
1344 "Target.activateTarget",
1345 "Target.attachToTarget",
1346 "Page.bringToFront",
1347 "Target.detachFromTarget"
1348 ]
1349 );
1350 }
1351
1352 #[tokio::test]
1353 async fn ensure_fresh_reloads_old_http_page() {
1354 let (url, navigations) = spawn_cdp_freshness_mock().await;
1355 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1356 .await
1357 .unwrap();
1358 backend
1359 .ensure_fresh("T1", Duration::from_secs(600))
1360 .await
1361 .unwrap();
1362 assert_eq!(
1363 *navigations.lock().await,
1364 vec!["https://example.com/app".to_string()]
1365 );
1366 }
1367
1368 #[tokio::test]
1369 async fn resolve_for_origin_reuses_same_origin_tab() {
1370 let (url, _stop) = spawn_cdp_mock().await;
1371 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1372 .await
1373 .unwrap();
1374 let t1 = backend.create_tab("about:blank").await.unwrap();
1376 backend
1377 .navigate(&t1, "https://example.com/login")
1378 .await
1379 .unwrap();
1380 let resolved = backend
1383 .resolve_or_create_for_origin("https://example.com/api/v1")
1384 .await
1385 .unwrap();
1386 assert_eq!(resolved, t1);
1387 }
1388
1389 #[tokio::test]
1390 async fn resolve_for_origin_creates_tab_when_no_match() {
1391 let (url, _stop) = spawn_cdp_mock().await;
1392 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1393 .await
1394 .unwrap();
1395 let t1 = backend.create_tab("about:blank").await.unwrap();
1396 backend.navigate(&t1, "https://other.test/").await.unwrap();
1397 let resolved = backend
1400 .resolve_or_create_for_origin("https://example.com/api")
1401 .await
1402 .unwrap();
1403 assert_ne!(resolved, t1);
1404 let live = backend.live_target_ids().await.unwrap();
1405 assert!(live.contains(&resolved));
1406 }
1407
1408 #[tokio::test]
1409 async fn bidi_backend_create_close_navigate_list_evaluate() {
1410 let (url, _stop) = spawn_bidi_mock().await;
1411 let backend = open_backend(&url, crate::detect::Engine::Bidi)
1412 .await
1413 .unwrap();
1414 let c1 = backend.create_tab("about:blank").await.unwrap();
1415 assert_eq!(c1, "C1");
1416 backend.navigate(&c1, "https://example.com/").await.unwrap();
1417 let live = backend.live_target_ids().await.unwrap();
1418 assert!(live.contains(&c1));
1419 let v = backend
1420 .evaluate(&c1, "1+1", false, Duration::from_secs(1))
1421 .await
1422 .unwrap();
1423 assert_eq!(v, json!(9));
1424 backend.close_tab(&c1).await.unwrap();
1425 let live = backend.live_target_ids().await.unwrap();
1426 assert!(!live.contains(&c1));
1427 }
1428
1429 #[tokio::test]
1430 async fn bidi_full_page_screenshot_uses_document_clip() {
1431 let (url, _stop, captures) = spawn_bidi_mock_with_captures().await;
1432 let backend = open_backend(&url, crate::detect::Engine::Bidi)
1433 .await
1434 .unwrap();
1435 let c1 = backend.create_tab("about:blank").await.unwrap();
1436 backend
1437 .screenshot(
1438 &c1,
1439 &ScreenshotOptions {
1440 full_page: true,
1441 ..Default::default()
1442 },
1443 )
1444 .await
1445 .unwrap();
1446 backend
1447 .screenshot(&c1, &ScreenshotOptions::default())
1448 .await
1449 .unwrap();
1450 let caps = captures.lock().await;
1451 assert_eq!(caps.len(), 2);
1452 assert_eq!(caps[0]["origin"], "document");
1453 assert_eq!(caps[0]["clip"]["type"], "box");
1454 assert_eq!(caps[0]["clip"]["width"], json!(1000.0));
1455 assert_eq!(caps[0]["clip"]["height"], json!(3000.0));
1456 assert!(caps[1].get("clip").is_none());
1457 backend.shutdown().await;
1458 }
1459}