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::targets::{BidiContext, CdpTarget};
35
36/// Wall-clock bound for `navigate`/`screenshot`. `evaluate` takes its
37/// timeout from the caller (op-specific budgets), but navigate/screenshot
38/// have no caller-supplied budget, so they default to this. Picked below
39/// the 30s CDP `REQUEST_TIMEOUT` so a wedged op surfaces as a typed,
40/// *recoverable* `TabHung`/`TabCrashed` before the client's generic
41/// "CDP request timed out" string (which is not in the recoverable needle
42/// list) can fire and defeat recover-once.
43const NAV_OP_TIMEOUT: Duration = Duration::from_secs(20);
44
45/// Engine-agnostic tab operations. Two variants because CDP and BiDi
46/// have different protocols and clients; the methods abstract over the
47/// difference.
48#[derive(Clone)]
49pub enum TabBackend {
50    Cdp(Arc<CdpClient>),
51    Bidi(Arc<BidiClient>),
52}
53
54/// Lightweight view of a live tab returned by [`TabBackend::live_targets`].
55/// Used by `tab list --all` to merge the named-tab registry with the
56/// browser's current target/context set.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct LiveTarget {
59    pub id: String,
60    pub url: String,
61    pub title: String,
62}
63
64impl TabBackend {
65    /// Create a fresh top-level tab. Returns the engine-specific id
66    /// (CDP `targetId`, BiDi `context`) the registry stores verbatim.
67    /// `url` defaults to `about:blank`.
68    pub async fn create_tab(&self, url: &str) -> Result<String> {
69        let url = if url.is_empty() { "about:blank" } else { url };
70        match self {
71            TabBackend::Cdp(c) => {
72                let v = c
73                    .send(
74                        "Target.createTarget",
75                        json!({ "url": url, "background": true }),
76                    )
77                    .await?;
78                v.get("targetId")
79                    .and_then(|x| x.as_str())
80                    .map(String::from)
81                    .ok_or_else(|| anyhow!("Target.createTarget returned no targetId"))
82            }
83            TabBackend::Bidi(c) => c.browsing_context_create(url).await,
84        }
85    }
86
87    /// Close a tab by id. Best-effort — both CDP and BiDi handle a
88    /// missing id gracefully, and the caller's intent ("this tab is
89    /// gone") is satisfied either way.
90    pub async fn close_tab(&self, target_id: &str) -> Result<()> {
91        match self {
92            TabBackend::Cdp(c) => {
93                let _ = c
94                    .send("Target.closeTarget", json!({ "targetId": target_id }))
95                    .await?;
96                Ok(())
97            }
98            TabBackend::Bidi(c) => c.browsing_context_close(target_id).await,
99        }
100    }
101
102    /// Navigate an existing tab to `url`. CDP requires attaching a
103    /// transient session; BiDi takes the context id directly.
104    pub async fn navigate(&self, target_id: &str, url: &str) -> Result<()> {
105        match self {
106            TabBackend::Cdp(c) => {
107                let attach = c
108                    .send(
109                        "Target.attachToTarget",
110                        json!({ "targetId": target_id, "flatten": true }),
111                    )
112                    .await?;
113                let session_id = attach
114                    .get("sessionId")
115                    .and_then(|v| v.as_str())
116                    .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
117                    .to_string();
118                // Enable the Inspector domain so `Inspector.targetCrashed`
119                // is delivered while the navigate is in flight. Best-effort,
120                // same rationale as `evaluate`.
121                let _ = c
122                    .send_with_session("Inspector.enable", json!({}), Some(&session_id))
123                    .await;
124                let inner = async {
125                    c.send_with_session("Page.navigate", json!({ "url": url }), Some(&session_id))
126                        .await
127                };
128                // Bound by timeout + renderer-crash detection so a wedged
129                // navigate surfaces as recoverable `TabHung`/`TabCrashed`
130                // (recover-once), not a 30s non-recoverable client timeout.
131                let result = crate::session::crash::evaluate_with_crash_detection(
132                    c,
133                    target_id,
134                    Some(&session_id),
135                    inner,
136                    Some(NAV_OP_TIMEOUT),
137                )
138                .await;
139                let _ = c
140                    .send(
141                        "Target.detachFromTarget",
142                        json!({ "sessionId": session_id }),
143                    )
144                    .await;
145                result?;
146                Ok(())
147            }
148            TabBackend::Bidi(c) => {
149                // BiDi has no crash event; a wedged navigate must still be
150                // bounded so it surfaces as recoverable `TabHung` rather
151                // than the 30s client `SEND_TIMEOUT`. A dead context comes
152                // back as `no such context` which the `TargetGone`
153                // classifier already treats as recoverable.
154                let fut = c.browsing_context_navigate(target_id, url);
155                match tokio::time::timeout(NAV_OP_TIMEOUT, fut).await {
156                    Ok(r) => r.map(|_| ()),
157                    Err(_) => Err(SessionError::TabHung {
158                        target_id: Some(target_id.to_string()),
159                        url: Some(url.to_string()),
160                        timeout_ms: NAV_OP_TIMEOUT.as_millis() as u64,
161                        hint: "op-timeout",
162                    }
163                    .into()),
164                }
165            }
166        }
167    }
168
169    /// Make a tab visible and focused inside the browser window. This is
170    /// intentionally explicit: normal automation creates/navigates tabs in
171    /// the background so agents don't steal the user's foreground app unless
172    /// they need interactive debugging or login.
173    pub async fn show_tab(&self, target_id: &str) -> Result<()> {
174        match self {
175            TabBackend::Cdp(c) => {
176                let _ = c
177                    .send("Target.activateTarget", json!({ "targetId": target_id }))
178                    .await?;
179                let attach = c
180                    .send(
181                        "Target.attachToTarget",
182                        json!({ "targetId": target_id, "flatten": true }),
183                    )
184                    .await?;
185                let session_id = attach
186                    .get("sessionId")
187                    .and_then(|v| v.as_str())
188                    .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
189                    .to_string();
190                let result = c
191                    .send_with_session("Page.bringToFront", json!({}), Some(&session_id))
192                    .await;
193                let _ = c
194                    .send(
195                        "Target.detachFromTarget",
196                        json!({ "sessionId": session_id }),
197                    )
198                    .await;
199                result?;
200                Ok(())
201            }
202            TabBackend::Bidi(c) => {
203                let _ = c
204                    .send("browsingContext.activate", json!({ "context": target_id }))
205                    .await?;
206                Ok(())
207            }
208        }
209    }
210
211    /// Return a tab suitable for `show`: prefer an existing live tab, create
212    /// `about:blank` if the browser currently has none.
213    pub async fn target_for_show(&self) -> Result<String> {
214        if let Some(t) = self.live_targets().await?.into_iter().next() {
215            return Ok(t.id);
216        }
217        self.create_tab("about:blank").await
218    }
219
220    /// Reload an old HTTP(S) tab before reading auth-sensitive page state.
221    ///
222    /// The age is measured from the document's `performance.timeOrigin`.
223    /// Non-web pages such as `about:blank` are left untouched.
224    pub async fn ensure_fresh(&self, target_id: &str, max_age: Duration) -> Result<()> {
225        let info_value = self
226            .evaluate(
227                target_id,
228                freshness::PAGE_FRESHNESS_EXPR,
229                false,
230                freshness::CHECK_TIMEOUT,
231            )
232            .await?;
233        let info = freshness::parse_page_freshness(info_value)?;
234        if !info.should_reload(max_age) {
235            return Ok(());
236        }
237
238        tracing::info!(
239            target = "session",
240            target_id = %target_id,
241            url = %info.href,
242            age_ms = info.age_ms,
243            max_age_ms = max_age.as_millis(),
244            "reloading stale tab before reading page context"
245        );
246        self.navigate(target_id, &info.href).await?;
247        self.wait_until_ready(target_id).await
248    }
249
250    async fn wait_until_ready(&self, target_id: &str) -> Result<()> {
251        let deadline = Instant::now() + freshness::RELOAD_READY_TIMEOUT;
252        loop {
253            let value = self
254                .evaluate(
255                    target_id,
256                    freshness::READY_STATE_EXPR,
257                    false,
258                    freshness::CHECK_TIMEOUT,
259                )
260                .await?;
261            if freshness::is_ready(&value) {
262                return Ok(());
263            }
264            if Instant::now() >= deadline {
265                tracing::warn!(
266                    target = "session",
267                    target_id = %target_id,
268                    "tab reload did not reach document.readyState=complete before continuing"
269                );
270                return Ok(());
271            }
272            tokio::time::sleep(freshness::READY_POLL_INTERVAL).await;
273        }
274    }
275
276    /// Snapshot of every live top-level tab id in the browser.
277    /// Used by the registry's sweep-on-read to drop rows whose target
278    /// no longer exists.
279    pub async fn live_target_ids(&self) -> Result<HashSet<String>> {
280        Ok(self
281            .live_targets()
282            .await?
283            .into_iter()
284            .map(|t| t.id)
285            .collect())
286    }
287
288    /// Snapshot of every live top-level tab with id + URL + title. Used by
289    /// `tab list --all` to merge the named-tab registry with the
290    /// browser's view of the world. CDP filters to `type == "page"`; BiDi
291    /// returns every top-level browsing context.
292    pub async fn live_targets(&self) -> Result<Vec<LiveTarget>> {
293        match self {
294            TabBackend::Cdp(c) => {
295                let v: Value = c.send("Target.getTargets", json!({})).await?;
296                let arr = v
297                    .get("targetInfos")
298                    .and_then(|x| x.as_array())
299                    .cloned()
300                    .unwrap_or_default();
301                Ok(CdpTarget::pages(&arr)
302                    .map(|t| LiveTarget {
303                        id: t.id,
304                        url: t.url,
305                        title: t.title,
306                    })
307                    .collect())
308            }
309            TabBackend::Bidi(c) => {
310                let v: Value = c.send("browsingContext.getTree", json!({})).await?;
311                // BiDi getTree doesn't expose page titles directly on the
312                // context node; leave blank for now.
313                Ok(BidiContext::from_tree(&v)
314                    .into_iter()
315                    .map(|ctx| LiveTarget {
316                        id: ctx.context,
317                        url: ctx.url,
318                        title: String::new(),
319                    })
320                    .collect())
321            }
322        }
323    }
324
325    /// Resolve a target whose document origin matches `url`'s origin,
326    /// reusing a live tab already on that origin if one exists and creating
327    /// one rooted at the origin otherwise. Returns the engine-specific id.
328    ///
329    /// This is the routing primitive for `browser_fetch`: running the
330    /// in-page fetch from a same-origin document is what lets cookies and
331    /// credentials propagate and lets the response bypass CORS. Routing a
332    /// fetch through an `about:blank` scratch tab (this backend's default
333    /// active tab) gives it an opaque origin, which silently breaks
334    /// authenticated and CORS-sensitive requests — see `cli::fetch`'s
335    /// origin-bound path for the same contract.
336    pub async fn resolve_or_create_for_origin(&self, url: &str) -> Result<String> {
337        let want = url::Url::parse(url).map_err(|e| anyhow!("invalid fetch URL `{url}`: {e}"))?;
338        for t in self.live_targets().await? {
339            if let Ok(parsed) = url::Url::parse(&t.url) {
340                if crate::session::attach::same_origin(&parsed, &want) {
341                    return Ok(t.id);
342                }
343            }
344        }
345        let root = crate::session::attach::origin_root_url(&want);
346        self.create_tab(&root).await
347    }
348
349    /// Evaluate `expression` in `target_id`'s main world, returning the
350    /// raw result value (after `returnByValue`). Bounded by `timeout`;
351    /// expiry returns typed [`SessionError::TabHung`].
352    ///
353    /// CDP path attaches a transient session, calls `Runtime.evaluate`,
354    /// detaches. BiDi path calls `script.evaluate` against the context.
355    /// On BiDi, `await_promise` is ignored — BiDi always awaits per
356    /// `script.evaluate` semantics.
357    pub async fn evaluate(
358        &self,
359        target_id: &str,
360        expression: &str,
361        await_promise: bool,
362        timeout: Duration,
363    ) -> Result<Value> {
364        match self {
365            TabBackend::Cdp(c) => {
366                let attach = c
367                    .send(
368                        "Target.attachToTarget",
369                        json!({ "targetId": target_id, "flatten": true }),
370                    )
371                    .await?;
372                let session_id = attach
373                    .get("sessionId")
374                    .and_then(|v| v.as_str())
375                    .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
376                    .to_string();
377                // Enable the Inspector domain on the attached session so
378                // `Inspector.targetCrashed` is delivered while the
379                // evaluate is in flight. Best-effort: older Chromium
380                // builds and headless variants may answer with an
381                // empty result but never raise — failing the enable
382                // would silently mute crash detection, so we proceed.
383                let _ = c
384                    .send_with_session("Inspector.enable", json!({}), Some(&session_id))
385                    .await;
386                let inner = async {
387                    let v = c
388                        .send_with_session(
389                            "Runtime.evaluate",
390                            json!({
391                                "expression": expression,
392                                "returnByValue": true,
393                                "awaitPromise": await_promise,
394                            }),
395                            Some(&session_id),
396                        )
397                        .await?;
398                    Ok::<Value, anyhow::Error>(v["result"]["value"].clone())
399                };
400                let value = crate::session::crash::evaluate_with_crash_detection(
401                    c,
402                    target_id,
403                    Some(&session_id),
404                    inner,
405                    Some(timeout),
406                )
407                .await;
408                let _ = c
409                    .send(
410                        "Target.detachFromTarget",
411                        json!({ "sessionId": session_id }),
412                    )
413                    .await;
414                value
415            }
416            TabBackend::Bidi(c) => {
417                let _ = await_promise; // BiDi always awaits
418                let fut = c.script_evaluate(target_id, expression);
419                match tokio::time::timeout(timeout, fut).await {
420                    Ok(Ok(v)) => Ok(v["result"]["value"].clone()),
421                    Ok(Err(e)) => Err(e),
422                    Err(_) => Err(SessionError::TabHung {
423                        target_id: Some(target_id.to_string()),
424                        url: None,
425                        timeout_ms: timeout.as_millis() as u64,
426                        hint: "op-timeout",
427                    }
428                    .into()),
429                }
430            }
431        }
432    }
433
434    /// Capture a PNG screenshot of `target_id` and return base64-encoded
435    /// bytes.
436    ///
437    /// CDP path attaches a transient session, calls
438    /// `Page.captureScreenshot({format:"png", captureBeyondViewport:full_page})`,
439    /// detaches. BiDi path calls `browsingContext.captureScreenshot` —
440    /// the BiDi protocol always captures the viewport (no `full_page`
441    /// equivalent), so `full_page` is honoured only on CDP.
442    ///
443    /// When `clip` is `Some({x, y, width, height})` (document coordinates, as
444    /// produced by [`crate::dom::scripts::GET_CLIP_RECT_JS`]) the capture is
445    /// restricted to that rectangle, which takes precedence over `full_page`.
446    pub async fn screenshot(
447        &self,
448        target_id: &str,
449        full_page: bool,
450        clip: Option<Value>,
451    ) -> Result<String> {
452        match self {
453            TabBackend::Cdp(c) => {
454                let attach = c
455                    .send(
456                        "Target.attachToTarget",
457                        json!({ "targetId": target_id, "flatten": true }),
458                    )
459                    .await?;
460                let session_id = attach
461                    .get("sessionId")
462                    .and_then(|v| v.as_str())
463                    .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
464                    .to_string();
465                // Enable the Inspector domain so `Inspector.targetCrashed`
466                // is delivered while the capture is in flight. Best-effort,
467                // same rationale as `evaluate`.
468                let _ = c
469                    .send_with_session("Inspector.enable", json!({}), Some(&session_id))
470                    .await;
471                // A clip rectangle lives outside the viewport in the general
472                // case (the element was scrolled into view by the caller, but
473                // may still be taller than the viewport), so force
474                // `captureBeyondViewport` whenever clipping.
475                let mut params = json!({
476                    "format": "png",
477                    "captureBeyondViewport": full_page || clip.is_some(),
478                });
479                if let Some(rect) = &clip {
480                    params["clip"] = json!({
481                        "x": rect["x"],
482                        "y": rect["y"],
483                        "width": rect["width"],
484                        "height": rect["height"],
485                        "scale": 1,
486                    });
487                }
488                let inner = async {
489                    c.send_with_session("Page.captureScreenshot", params, Some(&session_id))
490                        .await
491                };
492                // Bound by timeout + renderer-crash detection so a wedged
493                // capture surfaces as recoverable `TabHung`/`TabCrashed`,
494                // not a 30s non-recoverable client timeout.
495                let v = crate::session::crash::evaluate_with_crash_detection(
496                    c,
497                    target_id,
498                    Some(&session_id),
499                    inner,
500                    Some(NAV_OP_TIMEOUT),
501                )
502                .await;
503                let _ = c
504                    .send(
505                        "Target.detachFromTarget",
506                        json!({ "sessionId": session_id }),
507                    )
508                    .await;
509                let v = v?;
510                v["data"]
511                    .as_str()
512                    .map(|s| s.to_string())
513                    .ok_or_else(|| anyhow!("Page.captureScreenshot returned no data"))
514            }
515            TabBackend::Bidi(c) => {
516                let _ = full_page; // BiDi captures the viewport by default
517                let fut = c.browsing_context_capture_screenshot(target_id, clip);
518                match tokio::time::timeout(NAV_OP_TIMEOUT, fut).await {
519                    Ok(r) => r,
520                    Err(_) => Err(SessionError::TabHung {
521                        target_id: Some(target_id.to_string()),
522                        url: None,
523                        timeout_ms: NAV_OP_TIMEOUT.as_millis() as u64,
524                        hint: "op-timeout",
525                    }
526                    .into()),
527                }
528            }
529        }
530    }
531
532    /// Fetch the full cookie jar through this backend's *existing* client,
533    /// normalised across engines. Unlike `cli::cookies::fetch_cookies`,
534    /// this reuses the already-open session instead of opening a fresh
535    /// one — required on Firefox, where BiDi permits only one session per
536    /// browser, so a second `session.new` against a server-held browser
537    /// fails or races. Cookies are browser-wide on both engines (CDP
538    /// `Storage.getCookies` with legacy fallback / BiDi `storage.getCookies`),
539    /// so no target id is needed.
540    pub(crate) async fn cookies(&self) -> Result<Vec<NormalCookie>> {
541        match self {
542            TabBackend::Cdp(c) => {
543                let v = c.get_all_cookies().await?;
544                let arr = v
545                    .get("cookies")
546                    .and_then(|x| x.as_array())
547                    .ok_or_else(|| anyhow!("CDP cookie export: missing `cookies` array"))?;
548                Ok(arr.iter().map(normalize_cdp).collect())
549            }
550            TabBackend::Bidi(c) => {
551                let v = c.send("storage.getCookies", json!({})).await?;
552                let arr = v
553                    .get("cookies")
554                    .and_then(|x| x.as_array())
555                    .ok_or_else(|| anyhow!("BiDi storage.getCookies: missing `cookies` array"))?;
556                Ok(arr.iter().map(normalize_bidi).collect())
557            }
558        }
559    }
560}
561
562/// Open the right [`TabBackend`] for a resolved browser endpoint, taking
563/// care of BiDi's `session.new` handshake. The returned backend is `Clone`
564/// and owns its underlying client via `Arc`.
565pub async fn open_backend(endpoint: &str, engine: crate::detect::Engine) -> Result<TabBackend> {
566    match engine {
567        crate::detect::Engine::Cdp => {
568            let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
569                CdpClient::connect(endpoint).await?
570            } else {
571                CdpClient::connect_http(endpoint).await?
572            };
573            Ok(TabBackend::Cdp(Arc::new(client)))
574        }
575        crate::detect::Engine::Bidi => {
576            let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
577                BidiClient::connect(endpoint).await?
578            } else {
579                // HTTP discovery for BiDi: fetch /json/version, extract
580                // webSocketDebuggerUrl, then connect. Firefox geckodriver
581                // exposes /session via WebDriver classic but BiDi sessions
582                // need the WS URL — same flow as CDP.
583                let base = endpoint.trim_end_matches('/');
584                let url = format!("{base}/json/version");
585                let client = reqwest::Client::builder()
586                    .timeout(Duration::from_secs(5))
587                    .build()?;
588                let resp: Value = client.get(&url).send().await?.json().await?;
589                let ws = resp
590                    .get("webSocketDebuggerUrl")
591                    .and_then(|x| x.as_str())
592                    .ok_or_else(|| anyhow!("webSocketDebuggerUrl missing from {url}"))?
593                    .to_string();
594                BidiClient::connect(&ws).await?
595            };
596            // BiDi requires session.new before any other call. Use the
597            // existing helper which handles "session already active" via
598            // session.end + retry.
599            client.session_new().await?;
600            Ok(TabBackend::Bidi(Arc::new(client)))
601        }
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608    use futures_util::{SinkExt, StreamExt};
609    use std::sync::Arc;
610    use tokio::sync::{oneshot, Mutex};
611    use tokio_tungstenite::tungstenite::Message;
612
613    // CDP and BiDi each have their own mock-server tests in lower-level
614    // modules; these tests focus on the engine-agnostic behaviour of the
615    // backend wrapper.
616
617    async fn spawn_cdp_mock() -> (String, oneshot::Sender<()>) {
618        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
619        let addr = listener.local_addr().unwrap();
620        let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
621        tokio::spawn(async move {
622            let (stream, _) = listener.accept().await.unwrap();
623            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
624            let mut next_target = 0u32;
625            let mut next_session = 0u32;
626            // target id -> last-known url, so getTargets can report a URL
627            // and origin resolution has something to match against.
628            let mut live = std::collections::HashMap::<String, String>::new();
629            // Sessions attach to a target; remember which so navigate can
630            // update the right target's url.
631            let mut sessions = std::collections::HashMap::<String, String>::new();
632            loop {
633                tokio::select! {
634                    _ = &mut stop_rx => break,
635                    msg = ws.next() => {
636                        let msg = match msg {
637                            Some(Ok(m)) => m,
638                            _ => break,
639                        };
640                        if let Message::Text(t) = msg {
641                            let req: Value = serde_json::from_str(&t).unwrap();
642                            let id = req["id"].as_u64().unwrap();
643                            let method = req["method"].as_str().unwrap_or("");
644                            let result = match method {
645                                "Target.createTarget" => {
646                                    next_target += 1;
647                                    let tid = format!("T{next_target}");
648                                    let url = req
649                                        .pointer("/params/url")
650                                        .and_then(|v| v.as_str())
651                                        .unwrap_or("")
652                                        .to_string();
653                                    live.insert(tid.clone(), url);
654                                    json!({"targetId": tid})
655                                }
656                                "Target.closeTarget" => {
657                                    if let Some(tid) = req
658                                        .pointer("/params/targetId")
659                                        .and_then(|v| v.as_str())
660                                    {
661                                        live.remove(tid);
662                                    }
663                                    json!({"success": true})
664                                }
665                                "Target.attachToTarget" => {
666                                    next_session += 1;
667                                    let sid = format!("S{next_session}");
668                                    if let Some(tid) = req
669                                        .pointer("/params/targetId")
670                                        .and_then(|v| v.as_str())
671                                    {
672                                        sessions.insert(sid.clone(), tid.to_string());
673                                    }
674                                    json!({"sessionId": sid})
675                                }
676                                "Target.detachFromTarget" => json!({}),
677                                "Page.navigate" => {
678                                    // Update the attached target's url so a
679                                    // later getTargets reflects the navigation.
680                                    if let (Some(sid), Some(url)) = (
681                                        req.pointer("/sessionId").and_then(|v| v.as_str()),
682                                        req.pointer("/params/url").and_then(|v| v.as_str()),
683                                    ) {
684                                        if let Some(tid) = sessions.get(sid) {
685                                            live.insert(tid.clone(), url.to_string());
686                                        }
687                                    }
688                                    json!({})
689                                }
690                                "Runtime.evaluate" => json!({"result": {"value": 7}}),
691                                "Target.getTargets" => {
692                                    let infos: Vec<Value> = live
693                                        .iter()
694                                        .map(|(tid, url)| json!({"targetId": tid, "type": "page", "url": url}))
695                                        .collect();
696                                    json!({"targetInfos": infos})
697                                }
698                                _ => json!({}),
699                            };
700                            let resp = json!({"id": id, "result": result});
701                            ws.send(Message::Text(resp.to_string())).await.unwrap();
702                        }
703                    }
704                }
705            }
706        });
707        (format!("ws://{addr}"), stop_tx)
708    }
709
710    async fn spawn_bidi_mock() -> (String, oneshot::Sender<()>) {
711        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
712        let addr = listener.local_addr().unwrap();
713        let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
714        tokio::spawn(async move {
715            let (stream, _) = listener.accept().await.unwrap();
716            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
717            let mut next_ctx = 0u32;
718            let mut live = std::collections::HashSet::<String>::new();
719            loop {
720                tokio::select! {
721                    _ = &mut stop_rx => break,
722                    msg = ws.next() => {
723                        let msg = match msg {
724                            Some(Ok(m)) => m,
725                            _ => break,
726                        };
727                        if let Message::Text(t) = msg {
728                            let req: Value = serde_json::from_str(&t).unwrap();
729                            let id = req["id"].as_u64().unwrap();
730                            let method = req["method"].as_str().unwrap_or("");
731                            let result = match method {
732                                "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
733                                "browsingContext.create" => {
734                                    next_ctx += 1;
735                                    let c = format!("C{next_ctx}");
736                                    live.insert(c.clone());
737                                    json!({"context": c})
738                                }
739                                "browsingContext.close" => {
740                                    if let Some(c) = req
741                                        .pointer("/params/context")
742                                        .and_then(|v| v.as_str())
743                                    {
744                                        live.remove(c);
745                                    }
746                                    json!({})
747                                }
748                                "browsingContext.navigate" => json!({"navigation": "N1"}),
749                                "script.evaluate" => json!({"result": {"value": 9}}),
750                                "browsingContext.getTree" => {
751                                    let contexts: Vec<Value> = live
752                                        .iter()
753                                        .map(|c| json!({"context": c, "url": "", "children": []}))
754                                        .collect();
755                                    json!({"contexts": contexts})
756                                }
757                                _ => json!({}),
758                            };
759                            // BiDi wire format uses {type, id, result} —
760                            // not JSON-RPC `{id, result}` — per spec.
761                            let resp = json!({"type": "success", "id": id, "result": result});
762                            ws.send(Message::Text(resp.to_string())).await.unwrap();
763                        }
764                    }
765                }
766            }
767        });
768        (format!("ws://{addr}"), stop_tx)
769    }
770
771    async fn spawn_cdp_freshness_mock() -> (String, Arc<Mutex<Vec<String>>>) {
772        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
773        let addr = listener.local_addr().unwrap();
774        let navigations = Arc::new(Mutex::new(Vec::new()));
775        tokio::spawn({
776            let navigations = navigations.clone();
777            async move {
778                let (stream, _) = listener.accept().await.unwrap();
779                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
780                while let Some(Ok(Message::Text(t))) = ws.next().await {
781                    let req: Value = serde_json::from_str(&t).unwrap();
782                    let id = req["id"].as_u64().unwrap();
783                    let method = req["method"].as_str().unwrap_or("");
784                    let result = match method {
785                        "Target.attachToTarget" => json!({"sessionId": "S1"}),
786                        "Target.detachFromTarget" => json!({}),
787                        "Inspector.enable" => json!({}),
788                        "Runtime.evaluate" => {
789                            let expression = req
790                                .pointer("/params/expression")
791                                .and_then(|v| v.as_str())
792                                .unwrap_or("");
793                            let value = if expression == freshness::PAGE_FRESHNESS_EXPR {
794                                json!({
795                                    "href": "https://example.com/app",
796                                    "ageMs": 700_000.0,
797                                    "readyState": "complete"
798                                })
799                            } else if expression == freshness::READY_STATE_EXPR {
800                                json!("complete")
801                            } else {
802                                json!(7)
803                            };
804                            json!({"result": {"value": value}})
805                        }
806                        "Page.navigate" => {
807                            let url = req
808                                .pointer("/params/url")
809                                .and_then(|v| v.as_str())
810                                .unwrap_or("")
811                                .to_string();
812                            navigations.lock().await.push(url);
813                            json!({})
814                        }
815                        _ => json!({}),
816                    };
817                    let resp = json!({"id": id, "result": result});
818                    ws.send(Message::Text(resp.to_string())).await.unwrap();
819                }
820            }
821        });
822        (format!("ws://{addr}"), navigations)
823    }
824
825    async fn spawn_cdp_recording_mock() -> (String, Arc<Mutex<Vec<Value>>>) {
826        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
827        let addr = listener.local_addr().unwrap();
828        let seen = Arc::new(Mutex::new(Vec::<Value>::new()));
829        tokio::spawn({
830            let seen = seen.clone();
831            async move {
832                let (stream, _) = listener.accept().await.unwrap();
833                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
834                while let Some(Ok(Message::Text(t))) = ws.next().await {
835                    let req: Value = serde_json::from_str(&t).unwrap();
836                    seen.lock().await.push(req.clone());
837                    let id = req["id"].as_u64().unwrap();
838                    let method = req["method"].as_str().unwrap_or("");
839                    let result = match method {
840                        "Target.createTarget" => json!({"targetId": "T1"}),
841                        "Target.attachToTarget" => json!({"sessionId": "S1"}),
842                        "Target.getTargets" => json!({"targetInfos": [
843                            {"targetId": "T1", "type": "page", "url": "about:blank", "title": ""}
844                        ]}),
845                        _ => json!({}),
846                    };
847                    let resp = json!({"id": id, "result": result});
848                    ws.send(Message::Text(resp.to_string())).await.unwrap();
849                }
850            }
851        });
852        (format!("ws://{addr}"), seen)
853    }
854
855    #[tokio::test]
856    async fn cdp_backend_create_close_navigate_list_evaluate() {
857        let (url, _stop) = spawn_cdp_mock().await;
858        let backend = open_backend(&url, crate::detect::Engine::Cdp)
859            .await
860            .unwrap();
861        let t1 = backend.create_tab("about:blank").await.unwrap();
862        assert_eq!(t1, "T1");
863        backend.navigate(&t1, "https://example.com/").await.unwrap();
864        let live = backend.live_target_ids().await.unwrap();
865        assert!(live.contains(&t1));
866        let v = backend
867            .evaluate(&t1, "1+1", false, Duration::from_secs(1))
868            .await
869            .unwrap();
870        assert_eq!(v, json!(7));
871        backend.close_tab(&t1).await.unwrap();
872        let live = backend.live_target_ids().await.unwrap();
873        assert!(!live.contains(&t1));
874    }
875
876    #[tokio::test]
877    async fn cdp_create_tab_requests_background_target() {
878        let (url, seen) = spawn_cdp_recording_mock().await;
879        let backend = open_backend(&url, crate::detect::Engine::Cdp)
880            .await
881            .unwrap();
882        let tid = backend.create_tab("https://example.com/").await.unwrap();
883        assert_eq!(tid, "T1");
884        let calls = seen.lock().await;
885        let create = calls
886            .iter()
887            .find(|v| v["method"] == "Target.createTarget")
888            .expect("create call");
889        assert_eq!(
890            create.pointer("/params/url").and_then(Value::as_str),
891            Some("https://example.com/")
892        );
893        assert_eq!(
894            create
895                .pointer("/params/background")
896                .and_then(Value::as_bool),
897            Some(true)
898        );
899    }
900
901    #[tokio::test]
902    async fn cdp_show_tab_activates_and_brings_to_front() {
903        let (url, seen) = spawn_cdp_recording_mock().await;
904        let backend = open_backend(&url, crate::detect::Engine::Cdp)
905            .await
906            .unwrap();
907        backend.show_tab("T1").await.unwrap();
908        let methods: Vec<String> = seen
909            .lock()
910            .await
911            .iter()
912            .filter_map(|v| v["method"].as_str().map(String::from))
913            .collect();
914        assert_eq!(
915            methods,
916            vec![
917                "Target.activateTarget",
918                "Target.attachToTarget",
919                "Page.bringToFront",
920                "Target.detachFromTarget"
921            ]
922        );
923    }
924
925    #[tokio::test]
926    async fn ensure_fresh_reloads_old_http_page() {
927        let (url, navigations) = spawn_cdp_freshness_mock().await;
928        let backend = open_backend(&url, crate::detect::Engine::Cdp)
929            .await
930            .unwrap();
931        backend
932            .ensure_fresh("T1", Duration::from_secs(600))
933            .await
934            .unwrap();
935        assert_eq!(
936            *navigations.lock().await,
937            vec!["https://example.com/app".to_string()]
938        );
939    }
940
941    #[tokio::test]
942    async fn resolve_for_origin_reuses_same_origin_tab() {
943        let (url, _stop) = spawn_cdp_mock().await;
944        let backend = open_backend(&url, crate::detect::Engine::Cdp)
945            .await
946            .unwrap();
947        // Open a tab and navigate it onto the target origin.
948        let t1 = backend.create_tab("about:blank").await.unwrap();
949        backend
950            .navigate(&t1, "https://example.com/login")
951            .await
952            .unwrap();
953        // A fetch to a different path on the same origin must reuse t1,
954        // not spin up a fresh tab.
955        let resolved = backend
956            .resolve_or_create_for_origin("https://example.com/api/v1")
957            .await
958            .unwrap();
959        assert_eq!(resolved, t1);
960    }
961
962    #[tokio::test]
963    async fn resolve_for_origin_creates_tab_when_no_match() {
964        let (url, _stop) = spawn_cdp_mock().await;
965        let backend = open_backend(&url, crate::detect::Engine::Cdp)
966            .await
967            .unwrap();
968        let t1 = backend.create_tab("about:blank").await.unwrap();
969        backend.navigate(&t1, "https://other.test/").await.unwrap();
970        // No live tab on example.com → a new one is created, rooted at the
971        // origin so the in-page fetch inherits that origin.
972        let resolved = backend
973            .resolve_or_create_for_origin("https://example.com/api")
974            .await
975            .unwrap();
976        assert_ne!(resolved, t1);
977        let live = backend.live_target_ids().await.unwrap();
978        assert!(live.contains(&resolved));
979    }
980
981    #[tokio::test]
982    async fn bidi_backend_create_close_navigate_list_evaluate() {
983        let (url, _stop) = spawn_bidi_mock().await;
984        let backend = open_backend(&url, crate::detect::Engine::Bidi)
985            .await
986            .unwrap();
987        let c1 = backend.create_tab("about:blank").await.unwrap();
988        assert_eq!(c1, "C1");
989        backend.navigate(&c1, "https://example.com/").await.unwrap();
990        let live = backend.live_target_ids().await.unwrap();
991        assert!(live.contains(&c1));
992        let v = backend
993            .evaluate(&c1, "1+1", false, Duration::from_secs(1))
994            .await
995            .unwrap();
996        assert_eq!(v, json!(9));
997        backend.close_tab(&c1).await.unwrap();
998        let live = backend.live_target_ids().await.unwrap();
999        assert!(!live.contains(&c1));
1000    }
1001}