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::targets::{BidiContext, CdpTarget};
35
36const NAV_OP_TIMEOUT: Duration = Duration::from_secs(20);
44
45#[derive(Clone)]
49pub enum TabBackend {
50 Cdp(Arc<CdpClient>),
51 Bidi(Arc<BidiClient>),
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct LiveTarget {
59 pub id: String,
60 pub url: String,
61 pub title: String,
62}
63
64impl TabBackend {
65 pub async fn create_tab(&self, url: &str) -> Result<String> {
69 let url = if url.is_empty() { "about:blank" } else { url };
70 match self {
71 TabBackend::Cdp(c) => {
72 let v = c
73 .send(
74 "Target.createTarget",
75 json!({ "url": url, "background": true }),
76 )
77 .await?;
78 v.get("targetId")
79 .and_then(|x| x.as_str())
80 .map(String::from)
81 .ok_or_else(|| anyhow!("Target.createTarget returned no targetId"))
82 }
83 TabBackend::Bidi(c) => c.browsing_context_create(url).await,
84 }
85 }
86
87 pub async fn close_tab(&self, target_id: &str) -> Result<()> {
91 match self {
92 TabBackend::Cdp(c) => {
93 let _ = c
94 .send("Target.closeTarget", json!({ "targetId": target_id }))
95 .await?;
96 Ok(())
97 }
98 TabBackend::Bidi(c) => c.browsing_context_close(target_id).await,
99 }
100 }
101
102 pub async fn navigate(&self, target_id: &str, url: &str) -> Result<()> {
105 match self {
106 TabBackend::Cdp(c) => {
107 let attach = c
108 .send(
109 "Target.attachToTarget",
110 json!({ "targetId": target_id, "flatten": true }),
111 )
112 .await?;
113 let session_id = attach
114 .get("sessionId")
115 .and_then(|v| v.as_str())
116 .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
117 .to_string();
118 let _ = c
122 .send_with_session("Inspector.enable", json!({}), Some(&session_id))
123 .await;
124 let inner = async {
125 c.send_with_session("Page.navigate", json!({ "url": url }), Some(&session_id))
126 .await
127 };
128 let result = crate::session::crash::evaluate_with_crash_detection(
132 c,
133 target_id,
134 Some(&session_id),
135 inner,
136 Some(NAV_OP_TIMEOUT),
137 )
138 .await;
139 let _ = c
140 .send(
141 "Target.detachFromTarget",
142 json!({ "sessionId": session_id }),
143 )
144 .await;
145 result?;
146 Ok(())
147 }
148 TabBackend::Bidi(c) => {
149 let fut = c.browsing_context_navigate(target_id, url);
155 match tokio::time::timeout(NAV_OP_TIMEOUT, fut).await {
156 Ok(r) => r.map(|_| ()),
157 Err(_) => Err(SessionError::TabHung {
158 target_id: Some(target_id.to_string()),
159 url: Some(url.to_string()),
160 timeout_ms: NAV_OP_TIMEOUT.as_millis() as u64,
161 hint: "op-timeout",
162 }
163 .into()),
164 }
165 }
166 }
167 }
168
169 pub async fn show_tab(&self, target_id: &str) -> Result<()> {
174 match self {
175 TabBackend::Cdp(c) => {
176 let _ = c
177 .send("Target.activateTarget", json!({ "targetId": target_id }))
178 .await?;
179 let attach = c
180 .send(
181 "Target.attachToTarget",
182 json!({ "targetId": target_id, "flatten": true }),
183 )
184 .await?;
185 let session_id = attach
186 .get("sessionId")
187 .and_then(|v| v.as_str())
188 .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
189 .to_string();
190 let result = c
191 .send_with_session("Page.bringToFront", json!({}), Some(&session_id))
192 .await;
193 let _ = c
194 .send(
195 "Target.detachFromTarget",
196 json!({ "sessionId": session_id }),
197 )
198 .await;
199 result?;
200 Ok(())
201 }
202 TabBackend::Bidi(c) => {
203 let _ = c
204 .send("browsingContext.activate", json!({ "context": target_id }))
205 .await?;
206 Ok(())
207 }
208 }
209 }
210
211 pub async fn target_for_show(&self) -> Result<String> {
214 if let Some(t) = self.live_targets().await?.into_iter().next() {
215 return Ok(t.id);
216 }
217 self.create_tab("about:blank").await
218 }
219
220 pub async fn ensure_fresh(&self, target_id: &str, max_age: Duration) -> Result<()> {
225 let info_value = self
226 .evaluate(
227 target_id,
228 freshness::PAGE_FRESHNESS_EXPR,
229 false,
230 freshness::CHECK_TIMEOUT,
231 )
232 .await?;
233 let info = freshness::parse_page_freshness(info_value)?;
234 if !info.should_reload(max_age) {
235 return Ok(());
236 }
237
238 tracing::info!(
239 target = "session",
240 target_id = %target_id,
241 url = %info.href,
242 age_ms = info.age_ms,
243 max_age_ms = max_age.as_millis(),
244 "reloading stale tab before reading page context"
245 );
246 self.navigate(target_id, &info.href).await?;
247 self.wait_until_ready(target_id).await
248 }
249
250 async fn wait_until_ready(&self, target_id: &str) -> Result<()> {
251 let deadline = Instant::now() + freshness::RELOAD_READY_TIMEOUT;
252 loop {
253 let value = self
254 .evaluate(
255 target_id,
256 freshness::READY_STATE_EXPR,
257 false,
258 freshness::CHECK_TIMEOUT,
259 )
260 .await?;
261 if freshness::is_ready(&value) {
262 return Ok(());
263 }
264 if Instant::now() >= deadline {
265 tracing::warn!(
266 target = "session",
267 target_id = %target_id,
268 "tab reload did not reach document.readyState=complete before continuing"
269 );
270 return Ok(());
271 }
272 tokio::time::sleep(freshness::READY_POLL_INTERVAL).await;
273 }
274 }
275
276 pub async fn live_target_ids(&self) -> Result<HashSet<String>> {
280 Ok(self
281 .live_targets()
282 .await?
283 .into_iter()
284 .map(|t| t.id)
285 .collect())
286 }
287
288 pub async fn live_targets(&self) -> Result<Vec<LiveTarget>> {
293 match self {
294 TabBackend::Cdp(c) => {
295 let v: Value = c.send("Target.getTargets", json!({})).await?;
296 let arr = v
297 .get("targetInfos")
298 .and_then(|x| x.as_array())
299 .cloned()
300 .unwrap_or_default();
301 Ok(CdpTarget::pages(&arr)
302 .map(|t| LiveTarget {
303 id: t.id,
304 url: t.url,
305 title: t.title,
306 })
307 .collect())
308 }
309 TabBackend::Bidi(c) => {
310 let v: Value = c.send("browsingContext.getTree", json!({})).await?;
311 Ok(BidiContext::from_tree(&v)
314 .into_iter()
315 .map(|ctx| LiveTarget {
316 id: ctx.context,
317 url: ctx.url,
318 title: String::new(),
319 })
320 .collect())
321 }
322 }
323 }
324
325 pub async fn resolve_or_create_for_origin(&self, url: &str) -> Result<String> {
337 let want = url::Url::parse(url).map_err(|e| anyhow!("invalid fetch URL `{url}`: {e}"))?;
338 for t in self.live_targets().await? {
339 if let Ok(parsed) = url::Url::parse(&t.url) {
340 if crate::session::attach::same_origin(&parsed, &want) {
341 return Ok(t.id);
342 }
343 }
344 }
345 let root = crate::session::attach::origin_root_url(&want);
346 self.create_tab(&root).await
347 }
348
349 pub async fn evaluate(
358 &self,
359 target_id: &str,
360 expression: &str,
361 await_promise: bool,
362 timeout: Duration,
363 ) -> Result<Value> {
364 match self {
365 TabBackend::Cdp(c) => {
366 let attach = c
367 .send(
368 "Target.attachToTarget",
369 json!({ "targetId": target_id, "flatten": true }),
370 )
371 .await?;
372 let session_id = attach
373 .get("sessionId")
374 .and_then(|v| v.as_str())
375 .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
376 .to_string();
377 let _ = c
384 .send_with_session("Inspector.enable", json!({}), Some(&session_id))
385 .await;
386 let inner = async {
387 let v = c
388 .send_with_session(
389 "Runtime.evaluate",
390 json!({
391 "expression": expression,
392 "returnByValue": true,
393 "awaitPromise": await_promise,
394 }),
395 Some(&session_id),
396 )
397 .await?;
398 Ok::<Value, anyhow::Error>(v["result"]["value"].clone())
399 };
400 let value = crate::session::crash::evaluate_with_crash_detection(
401 c,
402 target_id,
403 Some(&session_id),
404 inner,
405 Some(timeout),
406 )
407 .await;
408 let _ = c
409 .send(
410 "Target.detachFromTarget",
411 json!({ "sessionId": session_id }),
412 )
413 .await;
414 value
415 }
416 TabBackend::Bidi(c) => {
417 let _ = await_promise; let fut = c.script_evaluate(target_id, expression);
419 match tokio::time::timeout(timeout, fut).await {
420 Ok(Ok(v)) => Ok(v["result"]["value"].clone()),
421 Ok(Err(e)) => Err(e),
422 Err(_) => Err(SessionError::TabHung {
423 target_id: Some(target_id.to_string()),
424 url: None,
425 timeout_ms: timeout.as_millis() as u64,
426 hint: "op-timeout",
427 }
428 .into()),
429 }
430 }
431 }
432 }
433
434 pub async fn screenshot(
447 &self,
448 target_id: &str,
449 full_page: bool,
450 clip: Option<Value>,
451 ) -> Result<String> {
452 match self {
453 TabBackend::Cdp(c) => {
454 let attach = c
455 .send(
456 "Target.attachToTarget",
457 json!({ "targetId": target_id, "flatten": true }),
458 )
459 .await?;
460 let session_id = attach
461 .get("sessionId")
462 .and_then(|v| v.as_str())
463 .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
464 .to_string();
465 let _ = c
469 .send_with_session("Inspector.enable", json!({}), Some(&session_id))
470 .await;
471 let mut params = json!({
476 "format": "png",
477 "captureBeyondViewport": full_page || clip.is_some(),
478 });
479 if let Some(rect) = &clip {
480 params["clip"] = json!({
481 "x": rect["x"],
482 "y": rect["y"],
483 "width": rect["width"],
484 "height": rect["height"],
485 "scale": 1,
486 });
487 }
488 let inner = async {
489 c.send_with_session("Page.captureScreenshot", params, Some(&session_id))
490 .await
491 };
492 let v = crate::session::crash::evaluate_with_crash_detection(
496 c,
497 target_id,
498 Some(&session_id),
499 inner,
500 Some(NAV_OP_TIMEOUT),
501 )
502 .await;
503 let _ = c
504 .send(
505 "Target.detachFromTarget",
506 json!({ "sessionId": session_id }),
507 )
508 .await;
509 let v = v?;
510 v["data"]
511 .as_str()
512 .map(|s| s.to_string())
513 .ok_or_else(|| anyhow!("Page.captureScreenshot returned no data"))
514 }
515 TabBackend::Bidi(c) => {
516 let _ = full_page; let fut = c.browsing_context_capture_screenshot(target_id, clip);
518 match tokio::time::timeout(NAV_OP_TIMEOUT, fut).await {
519 Ok(r) => r,
520 Err(_) => Err(SessionError::TabHung {
521 target_id: Some(target_id.to_string()),
522 url: None,
523 timeout_ms: NAV_OP_TIMEOUT.as_millis() as u64,
524 hint: "op-timeout",
525 }
526 .into()),
527 }
528 }
529 }
530 }
531
532 pub(crate) async fn cookies(&self) -> Result<Vec<NormalCookie>> {
541 match self {
542 TabBackend::Cdp(c) => {
543 let v = c.get_all_cookies().await?;
544 let arr = v
545 .get("cookies")
546 .and_then(|x| x.as_array())
547 .ok_or_else(|| anyhow!("CDP cookie export: missing `cookies` array"))?;
548 Ok(arr.iter().map(normalize_cdp).collect())
549 }
550 TabBackend::Bidi(c) => {
551 let v = c.send("storage.getCookies", json!({})).await?;
552 let arr = v
553 .get("cookies")
554 .and_then(|x| x.as_array())
555 .ok_or_else(|| anyhow!("BiDi storage.getCookies: missing `cookies` array"))?;
556 Ok(arr.iter().map(normalize_bidi).collect())
557 }
558 }
559 }
560
561 pub(crate) async fn user_agent(&self, target_id: Option<&str>) -> Result<String> {
566 if let Some(target_id) = target_id {
567 let value = self
568 .evaluate(
569 target_id,
570 "navigator.userAgent",
571 false,
572 Duration::from_secs(5),
573 )
574 .await?;
575 return value
576 .as_str()
577 .map(String::from)
578 .ok_or_else(|| anyhow!("navigator.userAgent returned a non-string value"));
579 }
580
581 if let TabBackend::Cdp(client) = self {
582 let value = client.send("Browser.getVersion", json!({})).await?;
583 return value
584 .get("userAgent")
585 .and_then(Value::as_str)
586 .map(String::from)
587 .ok_or_else(|| anyhow!("Browser.getVersion returned no userAgent"));
588 }
589
590 let (target_id, temporary) = match self.live_targets().await?.into_iter().next() {
591 Some(target) => (target.id, false),
592 None => (self.create_tab("about:blank").await?, true),
593 };
594 let result = self
595 .evaluate(
596 &target_id,
597 "navigator.userAgent",
598 false,
599 Duration::from_secs(5),
600 )
601 .await;
602 if temporary {
603 let _ = self.close_tab(&target_id).await;
604 }
605 let value = result?;
606 value
607 .as_str()
608 .map(String::from)
609 .ok_or_else(|| anyhow!("navigator.userAgent returned a non-string value"))
610 }
611}
612
613pub async fn open_backend(endpoint: &str, engine: crate::detect::Engine) -> Result<TabBackend> {
617 match engine {
618 crate::detect::Engine::Cdp => {
619 let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
620 CdpClient::connect(endpoint).await?
621 } else {
622 CdpClient::connect_http(endpoint).await?
623 };
624 Ok(TabBackend::Cdp(Arc::new(client)))
625 }
626 crate::detect::Engine::Bidi => {
627 let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
628 BidiClient::connect(endpoint).await?
629 } else {
630 let base = endpoint.trim_end_matches('/');
635 let url = format!("{base}/json/version");
636 let client = reqwest::Client::builder()
637 .timeout(Duration::from_secs(5))
638 .build()?;
639 let resp: Value = client.get(&url).send().await?.json().await?;
640 let ws = resp
641 .get("webSocketDebuggerUrl")
642 .and_then(|x| x.as_str())
643 .ok_or_else(|| anyhow!("webSocketDebuggerUrl missing from {url}"))?
644 .to_string();
645 BidiClient::connect(&ws).await?
646 };
647 client.session_new().await?;
651 Ok(TabBackend::Bidi(Arc::new(client)))
652 }
653 }
654}
655
656#[cfg(test)]
657mod tests {
658 use super::*;
659 use futures_util::{SinkExt, StreamExt};
660 use std::sync::Arc;
661 use tokio::sync::{oneshot, Mutex};
662 use tokio_tungstenite::tungstenite::Message;
663
664 async fn spawn_cdp_mock() -> (String, oneshot::Sender<()>) {
669 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
670 let addr = listener.local_addr().unwrap();
671 let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
672 tokio::spawn(async move {
673 let (stream, _) = listener.accept().await.unwrap();
674 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
675 let mut next_target = 0u32;
676 let mut next_session = 0u32;
677 let mut live = std::collections::HashMap::<String, String>::new();
680 let mut sessions = std::collections::HashMap::<String, String>::new();
683 loop {
684 tokio::select! {
685 _ = &mut stop_rx => break,
686 msg = ws.next() => {
687 let msg = match msg {
688 Some(Ok(m)) => m,
689 _ => break,
690 };
691 if let Message::Text(t) = msg {
692 let req: Value = serde_json::from_str(&t).unwrap();
693 let id = req["id"].as_u64().unwrap();
694 let method = req["method"].as_str().unwrap_or("");
695 let result = match method {
696 "Target.createTarget" => {
697 next_target += 1;
698 let tid = format!("T{next_target}");
699 let url = req
700 .pointer("/params/url")
701 .and_then(|v| v.as_str())
702 .unwrap_or("")
703 .to_string();
704 live.insert(tid.clone(), url);
705 json!({"targetId": tid})
706 }
707 "Target.closeTarget" => {
708 if let Some(tid) = req
709 .pointer("/params/targetId")
710 .and_then(|v| v.as_str())
711 {
712 live.remove(tid);
713 }
714 json!({"success": true})
715 }
716 "Target.attachToTarget" => {
717 next_session += 1;
718 let sid = format!("S{next_session}");
719 if let Some(tid) = req
720 .pointer("/params/targetId")
721 .and_then(|v| v.as_str())
722 {
723 sessions.insert(sid.clone(), tid.to_string());
724 }
725 json!({"sessionId": sid})
726 }
727 "Target.detachFromTarget" => json!({}),
728 "Page.navigate" => {
729 if let (Some(sid), Some(url)) = (
732 req.pointer("/sessionId").and_then(|v| v.as_str()),
733 req.pointer("/params/url").and_then(|v| v.as_str()),
734 ) {
735 if let Some(tid) = sessions.get(sid) {
736 live.insert(tid.clone(), url.to_string());
737 }
738 }
739 json!({})
740 }
741 "Runtime.evaluate" => json!({"result": {"value": 7}}),
742 "Target.getTargets" => {
743 let infos: Vec<Value> = live
744 .iter()
745 .map(|(tid, url)| json!({"targetId": tid, "type": "page", "url": url}))
746 .collect();
747 json!({"targetInfos": infos})
748 }
749 _ => json!({}),
750 };
751 let resp = json!({"id": id, "result": result});
752 ws.send(Message::Text(resp.to_string())).await.unwrap();
753 }
754 }
755 }
756 }
757 });
758 (format!("ws://{addr}"), stop_tx)
759 }
760
761 async fn spawn_bidi_mock() -> (String, oneshot::Sender<()>) {
762 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
763 let addr = listener.local_addr().unwrap();
764 let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
765 tokio::spawn(async move {
766 let (stream, _) = listener.accept().await.unwrap();
767 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
768 let mut next_ctx = 0u32;
769 let mut live = std::collections::HashSet::<String>::new();
770 loop {
771 tokio::select! {
772 _ = &mut stop_rx => break,
773 msg = ws.next() => {
774 let msg = match msg {
775 Some(Ok(m)) => m,
776 _ => break,
777 };
778 if let Message::Text(t) = msg {
779 let req: Value = serde_json::from_str(&t).unwrap();
780 let id = req["id"].as_u64().unwrap();
781 let method = req["method"].as_str().unwrap_or("");
782 let result = match method {
783 "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
784 "browsingContext.create" => {
785 next_ctx += 1;
786 let c = format!("C{next_ctx}");
787 live.insert(c.clone());
788 json!({"context": c})
789 }
790 "browsingContext.close" => {
791 if let Some(c) = req
792 .pointer("/params/context")
793 .and_then(|v| v.as_str())
794 {
795 live.remove(c);
796 }
797 json!({})
798 }
799 "browsingContext.navigate" => json!({"navigation": "N1"}),
800 "script.evaluate" => json!({"result": {"value": 9}}),
801 "browsingContext.getTree" => {
802 let contexts: Vec<Value> = live
803 .iter()
804 .map(|c| json!({"context": c, "url": "", "children": []}))
805 .collect();
806 json!({"contexts": contexts})
807 }
808 _ => json!({}),
809 };
810 let resp = json!({"type": "success", "id": id, "result": result});
813 ws.send(Message::Text(resp.to_string())).await.unwrap();
814 }
815 }
816 }
817 }
818 });
819 (format!("ws://{addr}"), stop_tx)
820 }
821
822 async fn spawn_cdp_freshness_mock() -> (String, Arc<Mutex<Vec<String>>>) {
823 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
824 let addr = listener.local_addr().unwrap();
825 let navigations = Arc::new(Mutex::new(Vec::new()));
826 tokio::spawn({
827 let navigations = navigations.clone();
828 async move {
829 let (stream, _) = listener.accept().await.unwrap();
830 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
831 while let Some(Ok(Message::Text(t))) = ws.next().await {
832 let req: Value = serde_json::from_str(&t).unwrap();
833 let id = req["id"].as_u64().unwrap();
834 let method = req["method"].as_str().unwrap_or("");
835 let result = match method {
836 "Target.attachToTarget" => json!({"sessionId": "S1"}),
837 "Target.detachFromTarget" => json!({}),
838 "Inspector.enable" => json!({}),
839 "Runtime.evaluate" => {
840 let expression = req
841 .pointer("/params/expression")
842 .and_then(|v| v.as_str())
843 .unwrap_or("");
844 let value = if expression == freshness::PAGE_FRESHNESS_EXPR {
845 json!({
846 "href": "https://example.com/app",
847 "ageMs": 700_000.0,
848 "readyState": "complete"
849 })
850 } else if expression == freshness::READY_STATE_EXPR {
851 json!("complete")
852 } else {
853 json!(7)
854 };
855 json!({"result": {"value": value}})
856 }
857 "Page.navigate" => {
858 let url = req
859 .pointer("/params/url")
860 .and_then(|v| v.as_str())
861 .unwrap_or("")
862 .to_string();
863 navigations.lock().await.push(url);
864 json!({})
865 }
866 _ => json!({}),
867 };
868 let resp = json!({"id": id, "result": result});
869 ws.send(Message::Text(resp.to_string())).await.unwrap();
870 }
871 }
872 });
873 (format!("ws://{addr}"), navigations)
874 }
875
876 async fn spawn_cdp_recording_mock() -> (String, Arc<Mutex<Vec<Value>>>) {
877 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
878 let addr = listener.local_addr().unwrap();
879 let seen = Arc::new(Mutex::new(Vec::<Value>::new()));
880 tokio::spawn({
881 let seen = seen.clone();
882 async move {
883 let (stream, _) = listener.accept().await.unwrap();
884 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
885 while let Some(Ok(Message::Text(t))) = ws.next().await {
886 let req: Value = serde_json::from_str(&t).unwrap();
887 seen.lock().await.push(req.clone());
888 let id = req["id"].as_u64().unwrap();
889 let method = req["method"].as_str().unwrap_or("");
890 let result = match method {
891 "Target.createTarget" => json!({"targetId": "T1"}),
892 "Target.attachToTarget" => json!({"sessionId": "S1"}),
893 "Target.getTargets" => json!({"targetInfos": [
894 {"targetId": "T1", "type": "page", "url": "about:blank", "title": ""}
895 ]}),
896 _ => json!({}),
897 };
898 let resp = json!({"id": id, "result": result});
899 ws.send(Message::Text(resp.to_string())).await.unwrap();
900 }
901 }
902 });
903 (format!("ws://{addr}"), seen)
904 }
905
906 #[tokio::test]
907 async fn cdp_backend_create_close_navigate_list_evaluate() {
908 let (url, _stop) = spawn_cdp_mock().await;
909 let backend = open_backend(&url, crate::detect::Engine::Cdp)
910 .await
911 .unwrap();
912 let t1 = backend.create_tab("about:blank").await.unwrap();
913 assert_eq!(t1, "T1");
914 backend.navigate(&t1, "https://example.com/").await.unwrap();
915 let live = backend.live_target_ids().await.unwrap();
916 assert!(live.contains(&t1));
917 let v = backend
918 .evaluate(&t1, "1+1", false, Duration::from_secs(1))
919 .await
920 .unwrap();
921 assert_eq!(v, json!(7));
922 backend.close_tab(&t1).await.unwrap();
923 let live = backend.live_target_ids().await.unwrap();
924 assert!(!live.contains(&t1));
925 }
926
927 #[tokio::test]
928 async fn cdp_create_tab_requests_background_target() {
929 let (url, seen) = spawn_cdp_recording_mock().await;
930 let backend = open_backend(&url, crate::detect::Engine::Cdp)
931 .await
932 .unwrap();
933 let tid = backend.create_tab("https://example.com/").await.unwrap();
934 assert_eq!(tid, "T1");
935 let calls = seen.lock().await;
936 let create = calls
937 .iter()
938 .find(|v| v["method"] == "Target.createTarget")
939 .expect("create call");
940 assert_eq!(
941 create.pointer("/params/url").and_then(Value::as_str),
942 Some("https://example.com/")
943 );
944 assert_eq!(
945 create
946 .pointer("/params/background")
947 .and_then(Value::as_bool),
948 Some(true)
949 );
950 }
951
952 #[tokio::test]
953 async fn cdp_show_tab_activates_and_brings_to_front() {
954 let (url, seen) = spawn_cdp_recording_mock().await;
955 let backend = open_backend(&url, crate::detect::Engine::Cdp)
956 .await
957 .unwrap();
958 backend.show_tab("T1").await.unwrap();
959 let methods: Vec<String> = seen
960 .lock()
961 .await
962 .iter()
963 .filter_map(|v| v["method"].as_str().map(String::from))
964 .collect();
965 assert_eq!(
966 methods,
967 vec![
968 "Target.activateTarget",
969 "Target.attachToTarget",
970 "Page.bringToFront",
971 "Target.detachFromTarget"
972 ]
973 );
974 }
975
976 #[tokio::test]
977 async fn ensure_fresh_reloads_old_http_page() {
978 let (url, navigations) = spawn_cdp_freshness_mock().await;
979 let backend = open_backend(&url, crate::detect::Engine::Cdp)
980 .await
981 .unwrap();
982 backend
983 .ensure_fresh("T1", Duration::from_secs(600))
984 .await
985 .unwrap();
986 assert_eq!(
987 *navigations.lock().await,
988 vec!["https://example.com/app".to_string()]
989 );
990 }
991
992 #[tokio::test]
993 async fn resolve_for_origin_reuses_same_origin_tab() {
994 let (url, _stop) = spawn_cdp_mock().await;
995 let backend = open_backend(&url, crate::detect::Engine::Cdp)
996 .await
997 .unwrap();
998 let t1 = backend.create_tab("about:blank").await.unwrap();
1000 backend
1001 .navigate(&t1, "https://example.com/login")
1002 .await
1003 .unwrap();
1004 let resolved = backend
1007 .resolve_or_create_for_origin("https://example.com/api/v1")
1008 .await
1009 .unwrap();
1010 assert_eq!(resolved, t1);
1011 }
1012
1013 #[tokio::test]
1014 async fn resolve_for_origin_creates_tab_when_no_match() {
1015 let (url, _stop) = spawn_cdp_mock().await;
1016 let backend = open_backend(&url, crate::detect::Engine::Cdp)
1017 .await
1018 .unwrap();
1019 let t1 = backend.create_tab("about:blank").await.unwrap();
1020 backend.navigate(&t1, "https://other.test/").await.unwrap();
1021 let resolved = backend
1024 .resolve_or_create_for_origin("https://example.com/api")
1025 .await
1026 .unwrap();
1027 assert_ne!(resolved, t1);
1028 let live = backend.live_target_ids().await.unwrap();
1029 assert!(live.contains(&resolved));
1030 }
1031
1032 #[tokio::test]
1033 async fn bidi_backend_create_close_navigate_list_evaluate() {
1034 let (url, _stop) = spawn_bidi_mock().await;
1035 let backend = open_backend(&url, crate::detect::Engine::Bidi)
1036 .await
1037 .unwrap();
1038 let c1 = backend.create_tab("about:blank").await.unwrap();
1039 assert_eq!(c1, "C1");
1040 backend.navigate(&c1, "https://example.com/").await.unwrap();
1041 let live = backend.live_target_ids().await.unwrap();
1042 assert!(live.contains(&c1));
1043 let v = backend
1044 .evaluate(&c1, "1+1", false, Duration::from_secs(1))
1045 .await
1046 .unwrap();
1047 assert_eq!(v, json!(9));
1048 backend.close_tab(&c1).await.unwrap();
1049 let live = backend.live_target_ids().await.unwrap();
1050 assert!(!live.contains(&c1));
1051 }
1052}