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    /// Whether this engine's target ids are scoped to the connection.
757    ///
758    /// Firefox mints fresh browsing-context ids for every BiDi session, so an
759    /// id stored by one process means nothing to the next. CDP target ids
760    /// live as long as the tab.
761    pub fn ids_are_session_scoped(&self) -> bool {
762        matches!(self, TabBackend::Bidi(_))
763    }
764
765    /// End the BiDi session, if this is one.
766    ///
767    /// BiDi permits **one session per browser**, so a backend opened and left
768    /// without ending its session makes the browser refuse every later
769    /// connection with "Maximum number of active sessions" — which is exactly
770    /// what a short-lived CLI command does unless it calls this. The socket
771    /// itself needs no attention: the process exits.
772    ///
773    /// A no-op on CDP, which is happy with many concurrent clients.
774    pub async fn release(&self) {
775        if let TabBackend::Bidi(c) = self {
776            let _ = c.session_end().await;
777        }
778    }
779
780    /// Type into whatever currently has focus.
781    ///
782    /// Addresses no node, so it works from a separate process that has no
783    /// access to the MCP server's ref table — which is what lets a shell
784    /// pipeline deliver a secret straight into a field.
785    pub async fn type_into_focused(
786        &self,
787        target_id: &str,
788        text: &str,
789        press_sequentially: bool,
790        submit: bool,
791        timeout: Duration,
792    ) -> Result<()> {
793        match self {
794            TabBackend::Cdp(c) => {
795                let text = text.to_string();
796                crate::session::cdp_session::with_page_session(
797                    c,
798                    target_id,
799                    timeout,
800                    |sid| async move {
801                        crate::session::input::type_focused(
802                            c,
803                            &sid,
804                            &text,
805                            press_sequentially,
806                            submit,
807                        )
808                        .await
809                    },
810                )
811                .await
812            }
813            TabBackend::Bidi(c) => {
814                bidi_bounded(
815                    target_id,
816                    timeout,
817                    input_bidi::type_focused(c, target_id, text, submit),
818                )
819                .await
820            }
821        }
822    }
823
824    /// Press a key, with any modifiers held around it.
825    ///
826    /// Keyboard input goes to whatever currently has focus, so unlike the
827    /// other native actions this addresses no node.
828    pub async fn press_key_on_tab(
829        &self,
830        target_id: &str,
831        chord: &crate::session::keys::Chord,
832        timeout: Duration,
833    ) -> Result<()> {
834        match self {
835            TabBackend::Cdp(c) => {
836                let chord = chord.clone();
837                crate::session::cdp_session::with_page_session(
838                    c,
839                    target_id,
840                    timeout,
841                    |sid| async move { crate::session::input::press_key(c, &sid, &chord).await },
842                )
843                .await
844            }
845            TabBackend::Bidi(c) => {
846                bidi_bounded(
847                    target_id,
848                    timeout,
849                    input_bidi::press_key(c, target_id, chord),
850                )
851                .await
852            }
853        }
854    }
855
856    /// Hover the element with `backend_node_id`.
857    pub async fn hover_node(
858        &self,
859        target_id: &str,
860        backend_node_id: u64,
861        timeout: Duration,
862    ) -> Result<crate::session::input::Point> {
863        match self {
864            TabBackend::Cdp(c) => crate::session::cdp_session::with_page_session(
865                c,
866                target_id,
867                timeout,
868                |sid| async move { crate::session::input::hover(c, &sid, backend_node_id).await },
869            )
870            .await,
871            TabBackend::Bidi(c) => {
872                bidi_bounded(
873                    target_id,
874                    timeout,
875                    input_bidi::hover(c, target_id, backend_node_id),
876                )
877                .await
878            }
879        }
880    }
881
882    /// Replace the element's content with `text` (see
883    /// [`crate::session::input::type_text`]).
884    pub async fn type_into_node(
885        &self,
886        target_id: &str,
887        backend_node_id: u64,
888        text: &str,
889        press_sequentially: bool,
890        submit: bool,
891        timeout: Duration,
892    ) -> Result<()> {
893        match self {
894            TabBackend::Cdp(c) => {
895                let text = text.to_string();
896                crate::session::cdp_session::with_page_session(
897                    c,
898                    target_id,
899                    timeout,
900                    |sid| async move {
901                        crate::session::input::type_text(
902                            c,
903                            &sid,
904                            backend_node_id,
905                            &text,
906                            press_sequentially,
907                            submit,
908                        )
909                        .await
910                    },
911                )
912                .await
913            }
914            TabBackend::Bidi(c) => {
915                bidi_bounded(
916                    target_id,
917                    timeout,
918                    input_bidi::type_text(
919                        c,
920                        target_id,
921                        backend_node_id,
922                        text,
923                        press_sequentially,
924                        submit,
925                    ),
926                )
927                .await
928            }
929        }
930    }
931
932    /// Pointer drag from one element to another.
933    pub async fn drag_nodes(
934        &self,
935        target_id: &str,
936        from: u64,
937        to: u64,
938        timeout: Duration,
939    ) -> Result<()> {
940        match self {
941            TabBackend::Cdp(c) => {
942                crate::session::cdp_session::with_page_session(
943                    c,
944                    target_id,
945                    timeout,
946                    |sid| async move { crate::session::input::drag(c, &sid, from, to).await },
947                )
948                .await
949            }
950            TabBackend::Bidi(c) => {
951                bidi_bounded(target_id, timeout, input_bidi::drag(c, target_id, from, to)).await
952            }
953        }
954    }
955
956    /// Border box of the element in document coordinates, for clipped
957    /// screenshots.
958    pub async fn node_clip_rect(
959        &self,
960        target_id: &str,
961        backend_node_id: u64,
962        timeout: Duration,
963    ) -> Result<Value> {
964        match self {
965            TabBackend::Cdp(c) => {
966                crate::session::cdp_session::with_page_session(
967                    c,
968                    target_id,
969                    timeout,
970                    |sid| async move {
971                        crate::session::input::node_clip_rect(c, &sid, backend_node_id).await
972                    },
973                )
974                .await
975            }
976            TabBackend::Bidi(c) => {
977                bidi_bounded(
978                    target_id,
979                    timeout,
980                    input_bidi::node_clip_rect(c, target_id, backend_node_id),
981                )
982                .await
983            }
984        }
985    }
986
987    /// Fetch the full cookie jar through this backend's *existing* client,
988    /// normalised across engines. Unlike `cli::cookies::fetch_cookies`,
989    /// this reuses the already-open session instead of opening a fresh
990    /// one — required on Firefox, where BiDi permits only one session per
991    /// browser, so a second `session.new` against a server-held browser
992    /// fails or races. Cookies are browser-wide on both engines (CDP
993    /// `Storage.getCookies` with legacy fallback / BiDi `storage.getCookies`),
994    /// so no target id is needed.
995    pub(crate) async fn cookies(&self) -> Result<Vec<NormalCookie>> {
996        match self {
997            TabBackend::Cdp(c) => {
998                let v = c.get_all_cookies().await?;
999                let arr = v
1000                    .get("cookies")
1001                    .and_then(|x| x.as_array())
1002                    .ok_or_else(|| anyhow!("CDP cookie export: missing `cookies` array"))?;
1003                Ok(arr.iter().map(normalize_cdp).collect())
1004            }
1005            TabBackend::Bidi(c) => {
1006                let v = c.send("storage.getCookies", json!({})).await?;
1007                let arr = v
1008                    .get("cookies")
1009                    .and_then(|x| x.as_array())
1010                    .ok_or_else(|| anyhow!("BiDi storage.getCookies: missing `cookies` array"))?;
1011                Ok(arr.iter().map(normalize_bidi).collect())
1012            }
1013        }
1014    }
1015
1016    /// Read the HTTP User-Agent exposed by the browser. When a target is
1017    /// supplied, evaluate in that document so per-target emulation overrides
1018    /// are preserved. Otherwise CDP can answer browser-wide; BiDi falls back
1019    /// to a live (or temporary) browsing context.
1020    pub(crate) async fn user_agent(&self, target_id: Option<&str>) -> Result<String> {
1021        if let Some(target_id) = target_id {
1022            let value = self
1023                .evaluate(
1024                    target_id,
1025                    "navigator.userAgent",
1026                    false,
1027                    Duration::from_secs(5),
1028                )
1029                .await?;
1030            return value
1031                .as_str()
1032                .map(String::from)
1033                .ok_or_else(|| anyhow!("navigator.userAgent returned a non-string value"));
1034        }
1035
1036        if let TabBackend::Cdp(client) = self {
1037            let value = client.send("Browser.getVersion", json!({})).await?;
1038            return value
1039                .get("userAgent")
1040                .and_then(Value::as_str)
1041                .map(String::from)
1042                .ok_or_else(|| anyhow!("Browser.getVersion returned no userAgent"));
1043        }
1044
1045        let (target_id, temporary) = match self.live_targets().await?.into_iter().next() {
1046            Some(target) => (target.id, false),
1047            None => (self.create_tab("about:blank").await?, true),
1048        };
1049        let result = self
1050            .evaluate(
1051                &target_id,
1052                "navigator.userAgent",
1053                false,
1054                Duration::from_secs(5),
1055            )
1056            .await;
1057        if temporary {
1058            let _ = self.close_tab(&target_id).await;
1059        }
1060        let value = result?;
1061        value
1062            .as_str()
1063            .map(String::from)
1064            .ok_or_else(|| anyhow!("navigator.userAgent returned a non-string value"))
1065    }
1066}
1067
1068/// Open the right [`TabBackend`] for a resolved browser endpoint, taking
1069/// care of BiDi's `session.new` handshake. The returned backend is `Clone`
1070/// and owns its underlying client via `Arc`.
1071pub async fn open_backend(endpoint: &str, engine: crate::detect::Engine) -> Result<TabBackend> {
1072    match engine {
1073        crate::detect::Engine::Cdp => {
1074            let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
1075                CdpClient::connect(endpoint).await?
1076            } else {
1077                CdpClient::connect_http(endpoint).await?
1078            };
1079            Ok(TabBackend::Cdp(Arc::new(client)))
1080        }
1081        crate::detect::Engine::Bidi => {
1082            let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
1083                BidiClient::connect(endpoint).await?
1084            } else {
1085                // HTTP discovery for BiDi: fetch /json/version, extract
1086                // webSocketDebuggerUrl, then connect. Firefox geckodriver
1087                // exposes /session via WebDriver classic but BiDi sessions
1088                // need the WS URL — same flow as CDP.
1089                let base = endpoint.trim_end_matches('/');
1090                let url = format!("{base}/json/version");
1091                let client = reqwest::Client::builder()
1092                    .timeout(Duration::from_secs(5))
1093                    .build()?;
1094                let resp: Value = client.get(&url).send().await?.json().await?;
1095                let ws = resp
1096                    .get("webSocketDebuggerUrl")
1097                    .and_then(|x| x.as_str())
1098                    .ok_or_else(|| anyhow!("webSocketDebuggerUrl missing from {url}"))?
1099                    .to_string();
1100                BidiClient::connect(&ws).await?
1101            };
1102            // BiDi requires session.new before any other call. Use the
1103            // existing helper which handles "session already active" via
1104            // session.end + retry.
1105            client.session_new().await?;
1106            Ok(TabBackend::Bidi(Arc::new(client)))
1107        }
1108    }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114    use futures_util::{SinkExt, StreamExt};
1115    use std::sync::Arc;
1116    use tokio::sync::{oneshot, Mutex};
1117    use tokio_tungstenite::tungstenite::Message;
1118
1119    // CDP and BiDi each have their own mock-server tests in lower-level
1120    // modules; these tests focus on the engine-agnostic behaviour of the
1121    // backend wrapper.
1122
1123    async fn spawn_cdp_mock() -> (String, oneshot::Sender<()>) {
1124        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1125        let addr = listener.local_addr().unwrap();
1126        let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
1127        tokio::spawn(async move {
1128            let (stream, _) = listener.accept().await.unwrap();
1129            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1130            let mut next_target = 0u32;
1131            let mut next_session = 0u32;
1132            // target id -> last-known url, so getTargets can report a URL
1133            // and origin resolution has something to match against.
1134            let mut live = std::collections::HashMap::<String, String>::new();
1135            // Sessions attach to a target; remember which so navigate can
1136            // update the right target's url.
1137            let mut sessions = std::collections::HashMap::<String, String>::new();
1138            loop {
1139                tokio::select! {
1140                    _ = &mut stop_rx => break,
1141                    msg = ws.next() => {
1142                        let msg = match msg {
1143                            Some(Ok(m)) => m,
1144                            _ => break,
1145                        };
1146                        if let Message::Text(t) = msg {
1147                            let req: Value = serde_json::from_str(&t).unwrap();
1148                            let id = req["id"].as_u64().unwrap();
1149                            let method = req["method"].as_str().unwrap_or("");
1150                            let result = match method {
1151                                "Target.createTarget" => {
1152                                    next_target += 1;
1153                                    let tid = format!("T{next_target}");
1154                                    let url = req
1155                                        .pointer("/params/url")
1156                                        .and_then(|v| v.as_str())
1157                                        .unwrap_or("")
1158                                        .to_string();
1159                                    live.insert(tid.clone(), url);
1160                                    json!({"targetId": tid})
1161                                }
1162                                "Target.closeTarget" => {
1163                                    if let Some(tid) = req
1164                                        .pointer("/params/targetId")
1165                                        .and_then(|v| v.as_str())
1166                                    {
1167                                        live.remove(tid);
1168                                    }
1169                                    json!({"success": true})
1170                                }
1171                                "Target.attachToTarget" => {
1172                                    next_session += 1;
1173                                    let sid = format!("S{next_session}");
1174                                    if let Some(tid) = req
1175                                        .pointer("/params/targetId")
1176                                        .and_then(|v| v.as_str())
1177                                    {
1178                                        sessions.insert(sid.clone(), tid.to_string());
1179                                    }
1180                                    json!({"sessionId": sid})
1181                                }
1182                                "Target.detachFromTarget" => json!({}),
1183                                "Page.navigate" => {
1184                                    // Update the attached target's url so a
1185                                    // later getTargets reflects the navigation.
1186                                    if let (Some(sid), Some(url)) = (
1187                                        req.pointer("/sessionId").and_then(|v| v.as_str()),
1188                                        req.pointer("/params/url").and_then(|v| v.as_str()),
1189                                    ) {
1190                                        if let Some(tid) = sessions.get(sid) {
1191                                            live.insert(tid.clone(), url.to_string());
1192                                        }
1193                                    }
1194                                    json!({})
1195                                }
1196                                "Runtime.evaluate" => json!({"result": {"value": 7}}),
1197                                "Target.getTargets" => {
1198                                    let infos: Vec<Value> = live
1199                                        .iter()
1200                                        .map(|(tid, url)| json!({"targetId": tid, "type": "page", "url": url}))
1201                                        .collect();
1202                                    json!({"targetInfos": infos})
1203                                }
1204                                _ => json!({}),
1205                            };
1206                            let resp = json!({"id": id, "result": result});
1207                            ws.send(Message::Text(resp.to_string())).await.unwrap();
1208                        }
1209                    }
1210                }
1211            }
1212        });
1213        (format!("ws://{addr}"), stop_tx)
1214    }
1215
1216    async fn spawn_bidi_mock() -> (String, oneshot::Sender<()>) {
1217        let (url, stop, _captures) = spawn_bidi_mock_with_captures().await;
1218        (url, stop)
1219    }
1220
1221    /// Same mock, also returning the recorded `captureScreenshot` params.
1222    async fn spawn_bidi_mock_with_captures() -> (String, oneshot::Sender<()>, Arc<Mutex<Vec<Value>>>)
1223    {
1224        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1225        let addr = listener.local_addr().unwrap();
1226        let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
1227        let captures = Arc::new(Mutex::new(Vec::<Value>::new()));
1228        let captures_task = captures.clone();
1229        tokio::spawn(async move {
1230            let captures = captures_task;
1231            let (stream, _) = listener.accept().await.unwrap();
1232            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1233            let mut next_ctx = 0u32;
1234            let mut live = std::collections::HashSet::<String>::new();
1235            loop {
1236                tokio::select! {
1237                    _ = &mut stop_rx => break,
1238                    msg = ws.next() => {
1239                        let msg = match msg {
1240                            Some(Ok(m)) => m,
1241                            _ => break,
1242                        };
1243                        if let Message::Text(t) = msg {
1244                            let req: Value = serde_json::from_str(&t).unwrap();
1245                            let id = req["id"].as_u64().unwrap();
1246                            let method = req["method"].as_str().unwrap_or("");
1247                            let result = match method {
1248                                "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
1249                                "browsingContext.create" => {
1250                                    next_ctx += 1;
1251                                    let c = format!("C{next_ctx}");
1252                                    live.insert(c.clone());
1253                                    json!({"context": c})
1254                                }
1255                                "browsingContext.close" => {
1256                                    if let Some(c) = req
1257                                        .pointer("/params/context")
1258                                        .and_then(|v| v.as_str())
1259                                    {
1260                                        live.remove(c);
1261                                    }
1262                                    json!({})
1263                                }
1264                                "browsingContext.navigate" => json!({"navigation": "N1"}),
1265                                "script.evaluate"
1266                                    if req["params"]["expression"]
1267                                        .as_str()
1268                                        .is_some_and(|e| e.contains("scrollWidth")) =>
1269                                {
1270                                    json!({"type": "success", "result": {"type": "string", "value": "{\"width\":1000,\"height\":3000}"}, "realm": "R1"})
1271                                }
1272                                "script.evaluate" => json!({"type": "success", "result": {"type": "number", "value": 9}, "realm": "R1"}),
1273                                "browsingContext.captureScreenshot" => {
1274                                    captures.lock().await.push(req["params"].clone());
1275                                    json!({"data": "PNG"})
1276                                }
1277                                "browsingContext.getTree" => {
1278                                    let contexts: Vec<Value> = live
1279                                        .iter()
1280                                        .map(|c| json!({"context": c, "url": "", "children": []}))
1281                                        .collect();
1282                                    json!({"contexts": contexts})
1283                                }
1284                                _ => json!({}),
1285                            };
1286                            // BiDi wire format uses {type, id, result} —
1287                            // not JSON-RPC `{id, result}` — per spec.
1288                            let resp = json!({"type": "success", "id": id, "result": result});
1289                            ws.send(Message::Text(resp.to_string())).await.unwrap();
1290                        }
1291                    }
1292                }
1293            }
1294        });
1295        (format!("ws://{addr}"), stop_tx, captures)
1296    }
1297
1298    async fn spawn_cdp_freshness_mock() -> (String, Arc<Mutex<Vec<String>>>) {
1299        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1300        let addr = listener.local_addr().unwrap();
1301        let navigations = Arc::new(Mutex::new(Vec::new()));
1302        tokio::spawn({
1303            let navigations = navigations.clone();
1304            async move {
1305                let (stream, _) = listener.accept().await.unwrap();
1306                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1307                while let Some(Ok(Message::Text(t))) = ws.next().await {
1308                    let req: Value = serde_json::from_str(&t).unwrap();
1309                    let id = req["id"].as_u64().unwrap();
1310                    let method = req["method"].as_str().unwrap_or("");
1311                    let result = match method {
1312                        "Target.attachToTarget" => json!({"sessionId": "S1"}),
1313                        "Target.detachFromTarget" => json!({}),
1314                        "Inspector.enable" => json!({}),
1315                        "Runtime.evaluate" => {
1316                            let expression = req
1317                                .pointer("/params/expression")
1318                                .and_then(|v| v.as_str())
1319                                .unwrap_or("");
1320                            let value = if expression == freshness::PAGE_FRESHNESS_EXPR {
1321                                json!({
1322                                    "href": "https://example.com/app",
1323                                    "ageMs": 700_000.0,
1324                                    "readyState": "complete"
1325                                })
1326                            } else if expression == freshness::READY_STATE_EXPR {
1327                                json!("complete")
1328                            } else {
1329                                json!(7)
1330                            };
1331                            json!({"result": {"value": value}})
1332                        }
1333                        "Page.navigate" => {
1334                            let url = req
1335                                .pointer("/params/url")
1336                                .and_then(|v| v.as_str())
1337                                .unwrap_or("")
1338                                .to_string();
1339                            navigations.lock().await.push(url);
1340                            json!({})
1341                        }
1342                        _ => json!({}),
1343                    };
1344                    let resp = json!({"id": id, "result": result});
1345                    ws.send(Message::Text(resp.to_string())).await.unwrap();
1346                }
1347            }
1348        });
1349        (format!("ws://{addr}"), navigations)
1350    }
1351
1352    async fn spawn_cdp_recording_mock() -> (String, Arc<Mutex<Vec<Value>>>) {
1353        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1354        let addr = listener.local_addr().unwrap();
1355        let seen = Arc::new(Mutex::new(Vec::<Value>::new()));
1356        tokio::spawn({
1357            let seen = seen.clone();
1358            async move {
1359                let (stream, _) = listener.accept().await.unwrap();
1360                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1361                while let Some(Ok(Message::Text(t))) = ws.next().await {
1362                    let req: Value = serde_json::from_str(&t).unwrap();
1363                    seen.lock().await.push(req.clone());
1364                    let id = req["id"].as_u64().unwrap();
1365                    let method = req["method"].as_str().unwrap_or("");
1366                    let result = match method {
1367                        "Target.createTarget" => json!({"targetId": "T1"}),
1368                        "Target.attachToTarget" => json!({"sessionId": "S1"}),
1369                        "Target.getTargets" => json!({"targetInfos": [
1370                            {"targetId": "T1", "type": "page", "url": "about:blank", "title": ""}
1371                        ]}),
1372                        _ => json!({}),
1373                    };
1374                    let resp = json!({"id": id, "result": result});
1375                    ws.send(Message::Text(resp.to_string())).await.unwrap();
1376                }
1377            }
1378        });
1379        (format!("ws://{addr}"), seen)
1380    }
1381
1382    #[tokio::test]
1383    async fn cdp_backend_create_close_navigate_list_evaluate() {
1384        let (url, _stop) = spawn_cdp_mock().await;
1385        let backend = open_backend(&url, crate::detect::Engine::Cdp)
1386            .await
1387            .unwrap();
1388        let t1 = backend.create_tab("about:blank").await.unwrap();
1389        assert_eq!(t1, "T1");
1390        backend.navigate(&t1, "https://example.com/").await.unwrap();
1391        let live = backend.live_target_ids().await.unwrap();
1392        assert!(live.contains(&t1));
1393        let v = backend
1394            .evaluate(&t1, "1+1", false, Duration::from_secs(1))
1395            .await
1396            .unwrap();
1397        assert_eq!(v, json!(7));
1398        backend.close_tab(&t1).await.unwrap();
1399        let live = backend.live_target_ids().await.unwrap();
1400        assert!(!live.contains(&t1));
1401    }
1402
1403    #[tokio::test]
1404    async fn cdp_create_tab_requests_background_target() {
1405        let (url, seen) = spawn_cdp_recording_mock().await;
1406        let backend = open_backend(&url, crate::detect::Engine::Cdp)
1407            .await
1408            .unwrap();
1409        let tid = backend.create_tab("https://example.com/").await.unwrap();
1410        assert_eq!(tid, "T1");
1411        let calls = seen.lock().await;
1412        let create = calls
1413            .iter()
1414            .find(|v| v["method"] == "Target.createTarget")
1415            .expect("create call");
1416        assert_eq!(
1417            create.pointer("/params/url").and_then(Value::as_str),
1418            Some("https://example.com/")
1419        );
1420        assert_eq!(
1421            create
1422                .pointer("/params/background")
1423                .and_then(Value::as_bool),
1424            Some(true)
1425        );
1426    }
1427
1428    #[tokio::test]
1429    async fn cdp_show_tab_activates_and_brings_to_front() {
1430        let (url, seen) = spawn_cdp_recording_mock().await;
1431        let backend = open_backend(&url, crate::detect::Engine::Cdp)
1432            .await
1433            .unwrap();
1434        backend.show_tab("T1").await.unwrap();
1435        let methods: Vec<String> = seen
1436            .lock()
1437            .await
1438            .iter()
1439            .filter_map(|v| v["method"].as_str().map(String::from))
1440            .collect();
1441        assert_eq!(
1442            methods,
1443            vec![
1444                "Target.activateTarget",
1445                "Target.attachToTarget",
1446                "Page.bringToFront",
1447                "Target.detachFromTarget"
1448            ]
1449        );
1450    }
1451
1452    #[tokio::test]
1453    async fn ensure_fresh_reloads_old_http_page() {
1454        let (url, navigations) = spawn_cdp_freshness_mock().await;
1455        let backend = open_backend(&url, crate::detect::Engine::Cdp)
1456            .await
1457            .unwrap();
1458        backend
1459            .ensure_fresh("T1", Duration::from_secs(600))
1460            .await
1461            .unwrap();
1462        assert_eq!(
1463            *navigations.lock().await,
1464            vec!["https://example.com/app".to_string()]
1465        );
1466    }
1467
1468    #[tokio::test]
1469    async fn resolve_for_origin_reuses_same_origin_tab() {
1470        let (url, _stop) = spawn_cdp_mock().await;
1471        let backend = open_backend(&url, crate::detect::Engine::Cdp)
1472            .await
1473            .unwrap();
1474        // Open a tab and navigate it onto the target origin.
1475        let t1 = backend.create_tab("about:blank").await.unwrap();
1476        backend
1477            .navigate(&t1, "https://example.com/login")
1478            .await
1479            .unwrap();
1480        // A fetch to a different path on the same origin must reuse t1,
1481        // not spin up a fresh tab.
1482        let resolved = backend
1483            .resolve_or_create_for_origin("https://example.com/api/v1")
1484            .await
1485            .unwrap();
1486        assert_eq!(resolved, t1);
1487    }
1488
1489    #[tokio::test]
1490    async fn resolve_for_origin_creates_tab_when_no_match() {
1491        let (url, _stop) = spawn_cdp_mock().await;
1492        let backend = open_backend(&url, crate::detect::Engine::Cdp)
1493            .await
1494            .unwrap();
1495        let t1 = backend.create_tab("about:blank").await.unwrap();
1496        backend.navigate(&t1, "https://other.test/").await.unwrap();
1497        // No live tab on example.com → a new one is created, rooted at the
1498        // origin so the in-page fetch inherits that origin.
1499        let resolved = backend
1500            .resolve_or_create_for_origin("https://example.com/api")
1501            .await
1502            .unwrap();
1503        assert_ne!(resolved, t1);
1504        let live = backend.live_target_ids().await.unwrap();
1505        assert!(live.contains(&resolved));
1506    }
1507
1508    #[tokio::test]
1509    async fn bidi_backend_create_close_navigate_list_evaluate() {
1510        let (url, _stop) = spawn_bidi_mock().await;
1511        let backend = open_backend(&url, crate::detect::Engine::Bidi)
1512            .await
1513            .unwrap();
1514        let c1 = backend.create_tab("about:blank").await.unwrap();
1515        assert_eq!(c1, "C1");
1516        backend.navigate(&c1, "https://example.com/").await.unwrap();
1517        let live = backend.live_target_ids().await.unwrap();
1518        assert!(live.contains(&c1));
1519        let v = backend
1520            .evaluate(&c1, "1+1", false, Duration::from_secs(1))
1521            .await
1522            .unwrap();
1523        assert_eq!(v, json!(9));
1524        backend.close_tab(&c1).await.unwrap();
1525        let live = backend.live_target_ids().await.unwrap();
1526        assert!(!live.contains(&c1));
1527    }
1528
1529    #[tokio::test]
1530    async fn bidi_full_page_screenshot_uses_document_clip() {
1531        let (url, _stop, captures) = spawn_bidi_mock_with_captures().await;
1532        let backend = open_backend(&url, crate::detect::Engine::Bidi)
1533            .await
1534            .unwrap();
1535        let c1 = backend.create_tab("about:blank").await.unwrap();
1536        backend
1537            .screenshot(
1538                &c1,
1539                &ScreenshotOptions {
1540                    full_page: true,
1541                    ..Default::default()
1542                },
1543            )
1544            .await
1545            .unwrap();
1546        backend
1547            .screenshot(&c1, &ScreenshotOptions::default())
1548            .await
1549            .unwrap();
1550        let caps = captures.lock().await;
1551        assert_eq!(caps.len(), 2);
1552        assert_eq!(caps[0]["origin"], "document");
1553        assert_eq!(caps[0]["clip"]["type"], "box");
1554        assert_eq!(caps[0]["clip"]["width"], json!(1000.0));
1555        assert_eq!(caps[0]["clip"]["height"], json!(3000.0));
1556        assert!(caps[1].get("clip").is_none());
1557        backend.shutdown().await;
1558    }
1559}