Skip to main content

browser_control/session/
backend.rs

1//! Engine-agnostic tab backend used by the named-tab registry and the
2//! scratch-recovery wrapper.
3//!
4//! Tab operations on Chromium-family browsers go through CDP
5//! (`Target.*` + per-target session attach), and on Firefox via WebDriver
6//! BiDi (`browsingContext.*` + `script.evaluate`). The named-tab CLI and
7//! the scratch-tab recovery wrapper are engine-independent and just need
8//! these four primitives:
9//!
10//! - **create** a fresh tab at a URL (`about:blank` if unspecified).
11//! - **close** a tab by its engine-specific id.
12//! - **navigate** an existing tab to a URL.
13//! - **list** every live top-level tab id.
14//!
15//! Plus one more for the eval/fetch path:
16//!
17//! - **evaluate** a JS expression in a tab, returning the result value.
18//!
19//! `target_id` is an opaque `String` on both engines — CDP's `targetId`
20//! and BiDi's `context` are both opaque ids the registry stores verbatim.
21
22use 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
37/// Wall-clock bound for `navigate`/`screenshot`. `evaluate` takes its
38/// timeout from the caller (op-specific budgets), but navigate/screenshot
39/// have no caller-supplied budget, so they default to this. Picked below
40/// the 30s CDP `REQUEST_TIMEOUT` so a wedged op surfaces as a typed,
41/// *recoverable* `TabHung`/`TabCrashed` before the client's generic
42/// "CDP request timed out" string (which is not in the recoverable needle
43/// list) can fire and defeat recover-once.
44const NAV_OP_TIMEOUT: Duration = Duration::from_secs(20);
45
46/// Output encoding for [`TabBackend::screenshot`].
47#[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
77/// JPEG quality used when the caller picks `jpeg` without a `quality`.
78pub const DEFAULT_JPEG_QUALITY: u8 = 80;
79
80/// Options for [`TabBackend::screenshot`]. The default is byte-for-byte
81/// the previous behaviour: viewport PNG, no clip, no downscale.
82#[derive(Debug, Clone, Default)]
83pub struct ScreenshotOptions {
84    pub full_page: bool,
85    /// `{x, y, width, height}` in document coordinates.
86    pub clip: Option<Value>,
87    pub format: ImageFormat,
88    /// JPEG only, 1-100.
89    pub quality: Option<u8>,
90    /// Downscale so the output is at most this many device pixels wide.
91    pub max_width: Option<u32>,
92}
93
94/// Document-coordinate rectangle a downscaled capture covers when no clip
95/// was given: the whole page for `full_page`, otherwise the layout
96/// viewport. Built from `Page.getLayoutMetrics`.
97fn 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/// Engine-agnostic tab operations. Two variants because CDP and BiDi
126/// have different protocols and clients; the methods abstract over the
127/// difference.
128#[derive(Clone)]
129pub enum TabBackend {
130    Cdp(Arc<CdpClient>),
131    Bidi(Arc<BidiClient>),
132}
133
134/// Lightweight view of a live tab returned by [`TabBackend::live_targets`].
135/// Used by `tab list --all` to merge the named-tab registry with the
136/// browser's current target/context set.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct LiveTarget {
139    pub id: String,
140    pub url: String,
141    pub title: String,
142}
143
144/// Bound a BiDi operation so a wedged context surfaces as recoverable
145/// `TabHung` rather than the 30 s transport timeout.
146async 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    /// Release the engine session before the client goes away. Firefox
165    /// does not end a BiDi session when its WebSocket closes, so a backend
166    /// that is dropped without `session.end` leaves the browser refusing
167    /// every later `session.new` ("Maximum number of active sessions").
168    /// CDP has nothing to release. Best-effort and idempotent.
169    pub async fn shutdown(&self) {
170        if let TabBackend::Bidi(c) = self {
171            let _ = c.session_end().await;
172        }
173    }
174
175    /// Create a fresh top-level tab. Returns the engine-specific id
176    /// (CDP `targetId`, BiDi `context`) the registry stores verbatim.
177    /// `url` defaults to `about:blank`.
178    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    /// Close a tab by id. Best-effort — both CDP and BiDi handle a
198    /// missing id gracefully, and the caller's intent ("this tab is
199    /// gone") is satisfied either way.
200    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    /// Navigate an existing tab to `url`. CDP requires attaching a
213    /// transient session; BiDi takes the context id directly.
214    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                // Enable the Inspector domain so `Inspector.targetCrashed`
229                // is delivered while the navigate is in flight. Best-effort,
230                // same rationale as `evaluate`.
231                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                // Bound by timeout + renderer-crash detection so a wedged
239                // navigate surfaces as recoverable `TabHung`/`TabCrashed`
240                // (recover-once), not a 30s non-recoverable client timeout.
241                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                // BiDi has no crash event; a wedged navigate must still be
260                // bounded so it surfaces as recoverable `TabHung` rather
261                // than the 30s client `SEND_TIMEOUT`. A dead context comes
262                // back as `no such context` which the `TargetGone`
263                // classifier already treats as recoverable.
264                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    /// Make a tab visible and focused inside the browser window. This is
280    /// intentionally explicit: normal automation creates/navigates tabs in
281    /// the background so agents don't steal the user's foreground app unless
282    /// they need interactive debugging or login.
283    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    /// Return a tab suitable for `show`: prefer an existing live tab, create
322    /// `about:blank` if the browser currently has none.
323    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    /// Reload an old HTTP(S) tab before reading auth-sensitive page state.
331    ///
332    /// The age is measured from the document's `performance.timeOrigin`.
333    /// Non-web pages such as `about:blank` are left untouched.
334    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    /// Snapshot of every live top-level tab id in the browser.
387    /// Used by the registry's sweep-on-read to drop rows whose target
388    /// no longer exists.
389    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    /// Snapshot of every live top-level tab with id + URL + title. Used by
399    /// `tab list --all` to merge the named-tab registry with the
400    /// browser's view of the world. CDP filters to `type == "page"`; BiDi
401    /// returns every top-level browsing context.
402    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                // BiDi getTree doesn't expose page titles directly on the
422                // context node; leave blank for now.
423                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    /// Resolve a target whose document origin matches `url`'s origin,
436    /// reusing a live tab already on that origin if one exists and creating
437    /// one rooted at the origin otherwise. Returns the engine-specific id.
438    ///
439    /// This is the routing primitive for `browser_fetch`: running the
440    /// in-page fetch from a same-origin document is what lets cookies and
441    /// credentials propagate and lets the response bypass CORS. Routing a
442    /// fetch through an `about:blank` scratch tab (this backend's default
443    /// active tab) gives it an opaque origin, which silently breaks
444    /// authenticated and CORS-sensitive requests — see `cli::fetch`'s
445    /// origin-bound path for the same contract.
446    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    /// Evaluate `expression` in `target_id`'s main world, returning the
460    /// raw result value (after `returnByValue`). Bounded by `timeout`;
461    /// expiry returns typed [`SessionError::TabHung`].
462    ///
463    /// CDP path attaches a transient session, calls `Runtime.evaluate`,
464    /// detaches. BiDi path calls `script.evaluate` against the context.
465    /// On BiDi, `await_promise` is ignored — BiDi always awaits per
466    /// `script.evaluate` semantics.
467    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                // Enable the Inspector domain on the attached session so
488                // `Inspector.targetCrashed` is delivered while the
489                // evaluate is in flight. Best-effort: older Chromium
490                // builds and headless variants may answer with an
491                // empty result but never raise — failing the enable
492                // would silently mute crash detection, so we proceed.
493                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; // BiDi always awaits
528                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    /// Capture a screenshot of `target_id` and return base64-encoded bytes.
547    ///
548    /// CDP path attaches a transient session, calls `Page.captureScreenshot`,
549    /// detaches. BiDi path calls `browsingContext.captureScreenshot` — the
550    /// BiDi protocol always captures the viewport (no `full_page`
551    /// equivalent) and has no downscale, so `full_page` and `max_width` are
552    /// honoured only on CDP.
553    ///
554    /// When `opts.clip` is `Some({x, y, width, height})` (document
555    /// coordinates, as produced by [`crate::dom::scripts::GET_CLIP_RECT_JS`])
556    /// the capture is restricted to that rectangle, which takes precedence
557    /// over `full_page`. `opts.max_width` downscales through `clip.scale`,
558    /// which needs no emulation override and no restore step.
559    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                        // A clip rectangle lives outside the viewport in the
569                        // general case (the element was scrolled into view by
570                        // the caller, but may still be taller than the
571                        // viewport), so force `captureBeyondViewport` whenever
572                        // clipping.
573                        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                // BiDi has no full-page flag; a document-origin box clip of
642                // the document's scroll size captures the whole page.
643                // `max_width` has no BiDi equivalent (no `scale`) and is
644                // ignored here.
645                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    // -----------------------------------------------------------------
663    // Native accessibility + input (ref-based interaction).
664    //
665    // CDP uses the browser's accessibility tree and `Input.*` on a transient
666    // session (`crate::session::input`); BiDi uses an injected DOM walker
667    // with a page-side ref registry and `input.performActions`
668    // (`crate::session::input_bidi`). Both feed the shared `crate::a11y`
669    // renderer and ref table.
670    // -----------------------------------------------------------------
671
672    /// Full accessibility tree (`Accessibility.getFullAXTree`). `depth`
673    /// bounds the tree the browser serialises; `None` means everything.
674    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    /// Identity of the current document (see [`crate::session::input::document_token`]).
712    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    /// Click the element with `backend_node_id`. Returns the viewport point
730    /// that was clicked.
731    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    /// Hover the element with `backend_node_id`.
757    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    /// Replace the element's content with `text` (see
783    /// [`crate::session::input::type_text`]).
784    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    /// Pointer drag from one element to another.
833    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    /// Border box of the element in document coordinates, for clipped
857    /// screenshots.
858    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    /// Fetch the full cookie jar through this backend's *existing* client,
888    /// normalised across engines. Unlike `cli::cookies::fetch_cookies`,
889    /// this reuses the already-open session instead of opening a fresh
890    /// one — required on Firefox, where BiDi permits only one session per
891    /// browser, so a second `session.new` against a server-held browser
892    /// fails or races. Cookies are browser-wide on both engines (CDP
893    /// `Storage.getCookies` with legacy fallback / BiDi `storage.getCookies`),
894    /// so no target id is needed.
895    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    /// Read the HTTP User-Agent exposed by the browser. When a target is
917    /// supplied, evaluate in that document so per-target emulation overrides
918    /// are preserved. Otherwise CDP can answer browser-wide; BiDi falls back
919    /// to a live (or temporary) browsing context.
920    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
968/// Open the right [`TabBackend`] for a resolved browser endpoint, taking
969/// care of BiDi's `session.new` handshake. The returned backend is `Clone`
970/// and owns its underlying client via `Arc`.
971pub 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                // HTTP discovery for BiDi: fetch /json/version, extract
986                // webSocketDebuggerUrl, then connect. Firefox geckodriver
987                // exposes /session via WebDriver classic but BiDi sessions
988                // need the WS URL — same flow as CDP.
989                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            // BiDi requires session.new before any other call. Use the
1003            // existing helper which handles "session already active" via
1004            // session.end + retry.
1005            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    // CDP and BiDi each have their own mock-server tests in lower-level
1020    // modules; these tests focus on the engine-agnostic behaviour of the
1021    // backend wrapper.
1022
1023    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            // target id -> last-known url, so getTargets can report a URL
1033            // and origin resolution has something to match against.
1034            let mut live = std::collections::HashMap::<String, String>::new();
1035            // Sessions attach to a target; remember which so navigate can
1036            // update the right target's url.
1037            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                                    // Update the attached target's url so a
1085                                    // later getTargets reflects the navigation.
1086                                    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    /// Same mock, also returning the recorded `captureScreenshot` params.
1122    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                            // BiDi wire format uses {type, id, result} —
1187                            // not JSON-RPC `{id, result}` — per spec.
1188                            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        // Open a tab and navigate it onto the target origin.
1375        let t1 = backend.create_tab("about:blank").await.unwrap();
1376        backend
1377            .navigate(&t1, "https://example.com/login")
1378            .await
1379            .unwrap();
1380        // A fetch to a different path on the same origin must reuse t1,
1381        // not spin up a fresh tab.
1382        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        // No live tab on example.com → a new one is created, rooted at the
1398        // origin so the in-page fetch inherits that origin.
1399        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}