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