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
562pub async fn open_backend(endpoint: &str, engine: crate::detect::Engine) -> Result<TabBackend> {
566 match engine {
567 crate::detect::Engine::Cdp => {
568 let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
569 CdpClient::connect(endpoint).await?
570 } else {
571 CdpClient::connect_http(endpoint).await?
572 };
573 Ok(TabBackend::Cdp(Arc::new(client)))
574 }
575 crate::detect::Engine::Bidi => {
576 let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
577 BidiClient::connect(endpoint).await?
578 } else {
579 let base = endpoint.trim_end_matches('/');
584 let url = format!("{base}/json/version");
585 let client = reqwest::Client::builder()
586 .timeout(Duration::from_secs(5))
587 .build()?;
588 let resp: Value = client.get(&url).send().await?.json().await?;
589 let ws = resp
590 .get("webSocketDebuggerUrl")
591 .and_then(|x| x.as_str())
592 .ok_or_else(|| anyhow!("webSocketDebuggerUrl missing from {url}"))?
593 .to_string();
594 BidiClient::connect(&ws).await?
595 };
596 client.session_new().await?;
600 Ok(TabBackend::Bidi(Arc::new(client)))
601 }
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use futures_util::{SinkExt, StreamExt};
609 use std::sync::Arc;
610 use tokio::sync::{oneshot, Mutex};
611 use tokio_tungstenite::tungstenite::Message;
612
613 async fn spawn_cdp_mock() -> (String, oneshot::Sender<()>) {
618 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
619 let addr = listener.local_addr().unwrap();
620 let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
621 tokio::spawn(async move {
622 let (stream, _) = listener.accept().await.unwrap();
623 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
624 let mut next_target = 0u32;
625 let mut next_session = 0u32;
626 let mut live = std::collections::HashMap::<String, String>::new();
629 let mut sessions = std::collections::HashMap::<String, String>::new();
632 loop {
633 tokio::select! {
634 _ = &mut stop_rx => break,
635 msg = ws.next() => {
636 let msg = match msg {
637 Some(Ok(m)) => m,
638 _ => break,
639 };
640 if let Message::Text(t) = msg {
641 let req: Value = serde_json::from_str(&t).unwrap();
642 let id = req["id"].as_u64().unwrap();
643 let method = req["method"].as_str().unwrap_or("");
644 let result = match method {
645 "Target.createTarget" => {
646 next_target += 1;
647 let tid = format!("T{next_target}");
648 let url = req
649 .pointer("/params/url")
650 .and_then(|v| v.as_str())
651 .unwrap_or("")
652 .to_string();
653 live.insert(tid.clone(), url);
654 json!({"targetId": tid})
655 }
656 "Target.closeTarget" => {
657 if let Some(tid) = req
658 .pointer("/params/targetId")
659 .and_then(|v| v.as_str())
660 {
661 live.remove(tid);
662 }
663 json!({"success": true})
664 }
665 "Target.attachToTarget" => {
666 next_session += 1;
667 let sid = format!("S{next_session}");
668 if let Some(tid) = req
669 .pointer("/params/targetId")
670 .and_then(|v| v.as_str())
671 {
672 sessions.insert(sid.clone(), tid.to_string());
673 }
674 json!({"sessionId": sid})
675 }
676 "Target.detachFromTarget" => json!({}),
677 "Page.navigate" => {
678 if let (Some(sid), Some(url)) = (
681 req.pointer("/sessionId").and_then(|v| v.as_str()),
682 req.pointer("/params/url").and_then(|v| v.as_str()),
683 ) {
684 if let Some(tid) = sessions.get(sid) {
685 live.insert(tid.clone(), url.to_string());
686 }
687 }
688 json!({})
689 }
690 "Runtime.evaluate" => json!({"result": {"value": 7}}),
691 "Target.getTargets" => {
692 let infos: Vec<Value> = live
693 .iter()
694 .map(|(tid, url)| json!({"targetId": tid, "type": "page", "url": url}))
695 .collect();
696 json!({"targetInfos": infos})
697 }
698 _ => json!({}),
699 };
700 let resp = json!({"id": id, "result": result});
701 ws.send(Message::Text(resp.to_string())).await.unwrap();
702 }
703 }
704 }
705 }
706 });
707 (format!("ws://{addr}"), stop_tx)
708 }
709
710 async fn spawn_bidi_mock() -> (String, oneshot::Sender<()>) {
711 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
712 let addr = listener.local_addr().unwrap();
713 let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
714 tokio::spawn(async move {
715 let (stream, _) = listener.accept().await.unwrap();
716 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
717 let mut next_ctx = 0u32;
718 let mut live = std::collections::HashSet::<String>::new();
719 loop {
720 tokio::select! {
721 _ = &mut stop_rx => break,
722 msg = ws.next() => {
723 let msg = match msg {
724 Some(Ok(m)) => m,
725 _ => break,
726 };
727 if let Message::Text(t) = msg {
728 let req: Value = serde_json::from_str(&t).unwrap();
729 let id = req["id"].as_u64().unwrap();
730 let method = req["method"].as_str().unwrap_or("");
731 let result = match method {
732 "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
733 "browsingContext.create" => {
734 next_ctx += 1;
735 let c = format!("C{next_ctx}");
736 live.insert(c.clone());
737 json!({"context": c})
738 }
739 "browsingContext.close" => {
740 if let Some(c) = req
741 .pointer("/params/context")
742 .and_then(|v| v.as_str())
743 {
744 live.remove(c);
745 }
746 json!({})
747 }
748 "browsingContext.navigate" => json!({"navigation": "N1"}),
749 "script.evaluate" => json!({"result": {"value": 9}}),
750 "browsingContext.getTree" => {
751 let contexts: Vec<Value> = live
752 .iter()
753 .map(|c| json!({"context": c, "url": "", "children": []}))
754 .collect();
755 json!({"contexts": contexts})
756 }
757 _ => json!({}),
758 };
759 let resp = json!({"type": "success", "id": id, "result": result});
762 ws.send(Message::Text(resp.to_string())).await.unwrap();
763 }
764 }
765 }
766 }
767 });
768 (format!("ws://{addr}"), stop_tx)
769 }
770
771 async fn spawn_cdp_freshness_mock() -> (String, Arc<Mutex<Vec<String>>>) {
772 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
773 let addr = listener.local_addr().unwrap();
774 let navigations = Arc::new(Mutex::new(Vec::new()));
775 tokio::spawn({
776 let navigations = navigations.clone();
777 async move {
778 let (stream, _) = listener.accept().await.unwrap();
779 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
780 while let Some(Ok(Message::Text(t))) = ws.next().await {
781 let req: Value = serde_json::from_str(&t).unwrap();
782 let id = req["id"].as_u64().unwrap();
783 let method = req["method"].as_str().unwrap_or("");
784 let result = match method {
785 "Target.attachToTarget" => json!({"sessionId": "S1"}),
786 "Target.detachFromTarget" => json!({}),
787 "Inspector.enable" => json!({}),
788 "Runtime.evaluate" => {
789 let expression = req
790 .pointer("/params/expression")
791 .and_then(|v| v.as_str())
792 .unwrap_or("");
793 let value = if expression == freshness::PAGE_FRESHNESS_EXPR {
794 json!({
795 "href": "https://example.com/app",
796 "ageMs": 700_000.0,
797 "readyState": "complete"
798 })
799 } else if expression == freshness::READY_STATE_EXPR {
800 json!("complete")
801 } else {
802 json!(7)
803 };
804 json!({"result": {"value": value}})
805 }
806 "Page.navigate" => {
807 let url = req
808 .pointer("/params/url")
809 .and_then(|v| v.as_str())
810 .unwrap_or("")
811 .to_string();
812 navigations.lock().await.push(url);
813 json!({})
814 }
815 _ => json!({}),
816 };
817 let resp = json!({"id": id, "result": result});
818 ws.send(Message::Text(resp.to_string())).await.unwrap();
819 }
820 }
821 });
822 (format!("ws://{addr}"), navigations)
823 }
824
825 async fn spawn_cdp_recording_mock() -> (String, Arc<Mutex<Vec<Value>>>) {
826 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
827 let addr = listener.local_addr().unwrap();
828 let seen = Arc::new(Mutex::new(Vec::<Value>::new()));
829 tokio::spawn({
830 let seen = seen.clone();
831 async move {
832 let (stream, _) = listener.accept().await.unwrap();
833 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
834 while let Some(Ok(Message::Text(t))) = ws.next().await {
835 let req: Value = serde_json::from_str(&t).unwrap();
836 seen.lock().await.push(req.clone());
837 let id = req["id"].as_u64().unwrap();
838 let method = req["method"].as_str().unwrap_or("");
839 let result = match method {
840 "Target.createTarget" => json!({"targetId": "T1"}),
841 "Target.attachToTarget" => json!({"sessionId": "S1"}),
842 "Target.getTargets" => json!({"targetInfos": [
843 {"targetId": "T1", "type": "page", "url": "about:blank", "title": ""}
844 ]}),
845 _ => json!({}),
846 };
847 let resp = json!({"id": id, "result": result});
848 ws.send(Message::Text(resp.to_string())).await.unwrap();
849 }
850 }
851 });
852 (format!("ws://{addr}"), seen)
853 }
854
855 #[tokio::test]
856 async fn cdp_backend_create_close_navigate_list_evaluate() {
857 let (url, _stop) = spawn_cdp_mock().await;
858 let backend = open_backend(&url, crate::detect::Engine::Cdp)
859 .await
860 .unwrap();
861 let t1 = backend.create_tab("about:blank").await.unwrap();
862 assert_eq!(t1, "T1");
863 backend.navigate(&t1, "https://example.com/").await.unwrap();
864 let live = backend.live_target_ids().await.unwrap();
865 assert!(live.contains(&t1));
866 let v = backend
867 .evaluate(&t1, "1+1", false, Duration::from_secs(1))
868 .await
869 .unwrap();
870 assert_eq!(v, json!(7));
871 backend.close_tab(&t1).await.unwrap();
872 let live = backend.live_target_ids().await.unwrap();
873 assert!(!live.contains(&t1));
874 }
875
876 #[tokio::test]
877 async fn cdp_create_tab_requests_background_target() {
878 let (url, seen) = spawn_cdp_recording_mock().await;
879 let backend = open_backend(&url, crate::detect::Engine::Cdp)
880 .await
881 .unwrap();
882 let tid = backend.create_tab("https://example.com/").await.unwrap();
883 assert_eq!(tid, "T1");
884 let calls = seen.lock().await;
885 let create = calls
886 .iter()
887 .find(|v| v["method"] == "Target.createTarget")
888 .expect("create call");
889 assert_eq!(
890 create.pointer("/params/url").and_then(Value::as_str),
891 Some("https://example.com/")
892 );
893 assert_eq!(
894 create
895 .pointer("/params/background")
896 .and_then(Value::as_bool),
897 Some(true)
898 );
899 }
900
901 #[tokio::test]
902 async fn cdp_show_tab_activates_and_brings_to_front() {
903 let (url, seen) = spawn_cdp_recording_mock().await;
904 let backend = open_backend(&url, crate::detect::Engine::Cdp)
905 .await
906 .unwrap();
907 backend.show_tab("T1").await.unwrap();
908 let methods: Vec<String> = seen
909 .lock()
910 .await
911 .iter()
912 .filter_map(|v| v["method"].as_str().map(String::from))
913 .collect();
914 assert_eq!(
915 methods,
916 vec![
917 "Target.activateTarget",
918 "Target.attachToTarget",
919 "Page.bringToFront",
920 "Target.detachFromTarget"
921 ]
922 );
923 }
924
925 #[tokio::test]
926 async fn ensure_fresh_reloads_old_http_page() {
927 let (url, navigations) = spawn_cdp_freshness_mock().await;
928 let backend = open_backend(&url, crate::detect::Engine::Cdp)
929 .await
930 .unwrap();
931 backend
932 .ensure_fresh("T1", Duration::from_secs(600))
933 .await
934 .unwrap();
935 assert_eq!(
936 *navigations.lock().await,
937 vec!["https://example.com/app".to_string()]
938 );
939 }
940
941 #[tokio::test]
942 async fn resolve_for_origin_reuses_same_origin_tab() {
943 let (url, _stop) = spawn_cdp_mock().await;
944 let backend = open_backend(&url, crate::detect::Engine::Cdp)
945 .await
946 .unwrap();
947 let t1 = backend.create_tab("about:blank").await.unwrap();
949 backend
950 .navigate(&t1, "https://example.com/login")
951 .await
952 .unwrap();
953 let resolved = backend
956 .resolve_or_create_for_origin("https://example.com/api/v1")
957 .await
958 .unwrap();
959 assert_eq!(resolved, t1);
960 }
961
962 #[tokio::test]
963 async fn resolve_for_origin_creates_tab_when_no_match() {
964 let (url, _stop) = spawn_cdp_mock().await;
965 let backend = open_backend(&url, crate::detect::Engine::Cdp)
966 .await
967 .unwrap();
968 let t1 = backend.create_tab("about:blank").await.unwrap();
969 backend.navigate(&t1, "https://other.test/").await.unwrap();
970 let resolved = backend
973 .resolve_or_create_for_origin("https://example.com/api")
974 .await
975 .unwrap();
976 assert_ne!(resolved, t1);
977 let live = backend.live_target_ids().await.unwrap();
978 assert!(live.contains(&resolved));
979 }
980
981 #[tokio::test]
982 async fn bidi_backend_create_close_navigate_list_evaluate() {
983 let (url, _stop) = spawn_bidi_mock().await;
984 let backend = open_backend(&url, crate::detect::Engine::Bidi)
985 .await
986 .unwrap();
987 let c1 = backend.create_tab("about:blank").await.unwrap();
988 assert_eq!(c1, "C1");
989 backend.navigate(&c1, "https://example.com/").await.unwrap();
990 let live = backend.live_target_ids().await.unwrap();
991 assert!(live.contains(&c1));
992 let v = backend
993 .evaluate(&c1, "1+1", false, Duration::from_secs(1))
994 .await
995 .unwrap();
996 assert_eq!(v, json!(9));
997 backend.close_tab(&c1).await.unwrap();
998 let live = backend.live_target_ids().await.unwrap();
999 assert!(!live.contains(&c1));
1000 }
1001}