Skip to main content

browser_control/session/
attach.rs

1//! Attach to a page target and expose engine-agnostic high-level operations.
2//!
3//! [`PageSession`] hides the CDP/BiDi split behind a single async API
4//! (`evaluate`, `navigate`, `screenshot`). The CLI subcommands instantiate
5//! a fresh session per call; the MCP server may pre-build a session backed
6//! by a long-lived BiDi client via [`PageSession::from_bidi_cache`].
7
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use anyhow::{anyhow, Result};
12use regex::Regex;
13use serde_json::{json, Value};
14
15use crate::bidi::BidiClient;
16use crate::cdp::CdpClient;
17use crate::detect::Engine;
18use crate::errors::SessionError;
19use crate::session::freshness;
20use crate::session::targets::{open_bidi, open_cdp, BidiContext, CdpTarget};
21
22/// A bound page-level session. Variants are not constructed directly outside
23/// this module; use [`PageSession::attach`].
24pub enum PageSession {
25    Cdp(CdpPage),
26    /// A BiDi page session. The client is shared via `Arc` so the MCP server
27    /// can keep a single persistent BiDi session across many tool calls
28    /// (Firefox limits a browser to one BiDi session at a time).
29    Bidi(BidiPage),
30}
31
32pub struct CdpPage {
33    pub client: CdpClient,
34    pub session_id: String,
35    pub target_id: String,
36}
37
38pub struct BidiPage {
39    pub client: Arc<BidiClient>,
40    pub context: String,
41    /// True when this `PageSession` opened the BiDi session (`session.new`)
42    /// and is therefore responsible for ending it on close. False for
43    /// sessions built from a shared, cached client (e.g. MCP server) where
44    /// the lifetime is managed externally.
45    owns_session: bool,
46}
47
48impl PageSession {
49    /// Attach to a fresh page session over `engine`.
50    ///
51    /// If `url_regex` is `Some`, the first page target whose URL matches is
52    /// selected; otherwise the first page (or top-level browsing context) is
53    /// used.
54    pub async fn attach(endpoint: &str, engine: Engine, url_regex: Option<&str>) -> Result<Self> {
55        let pattern = url_regex.map(Regex::new).transpose()?;
56        match engine {
57            Engine::Cdp => {
58                let client = open_cdp(endpoint).await?;
59                let target_id = pick_cdp_page(&client, pattern.as_ref()).await?;
60                let session_id = client.attach_to_target(&target_id).await?;
61                // Enable Inspector domain so `Inspector.targetCrashed`
62                // is delivered to this session while evaluates are in
63                // flight. Best-effort; see `TabBackend::evaluate` for
64                // rationale.
65                let _ = client
66                    .send_with_session("Inspector.enable", json!({}), Some(&session_id))
67                    .await;
68                Ok(PageSession::Cdp(CdpPage {
69                    client,
70                    session_id,
71                    target_id,
72                }))
73            }
74            Engine::Bidi => {
75                let client = Arc::new(open_bidi(endpoint).await?);
76                client.session_new().await?;
77                let context = pick_bidi_context(&client, pattern.as_ref()).await?;
78                Ok(PageSession::Bidi(BidiPage {
79                    client,
80                    context,
81                    owns_session: true,
82                }))
83            }
84        }
85    }
86
87    /// Build a BiDi session from a pre-opened, possibly cached client.
88    ///
89    /// The MCP server uses this to share one BiDi client across tool calls;
90    /// `session.new` is invoked only when the client was freshly opened (the
91    /// caller is expected to have done so).
92    pub async fn from_bidi_cache(client: Arc<BidiClient>, url_regex: Option<&str>) -> Result<Self> {
93        let pattern = url_regex.map(Regex::new).transpose()?;
94        let context = pick_bidi_context(&client, pattern.as_ref()).await?;
95        Ok(PageSession::Bidi(BidiPage {
96            client,
97            context,
98            owns_session: false,
99        }))
100    }
101
102    /// Attach to (or create) a page whose document origin matches `origin`.
103    ///
104    /// Strategy:
105    /// 1. List existing page targets / browsing contexts.
106    /// 2. If any has the same origin as `origin`, attach to it.
107    /// 3. Otherwise create a new tab navigated to the origin's root and
108    ///    attach to that tab.
109    ///
110    /// `origin` is parsed for its scheme, host, and port; path/query/fragment
111    /// are ignored when comparing existing target URLs.
112    pub async fn attach_for_origin(endpoint: &str, engine: Engine, origin: &str) -> Result<Self> {
113        let want =
114            url::Url::parse(origin).map_err(|e| anyhow!("invalid origin URL `{origin}`: {e}"))?;
115        let origin_root = origin_root_url(&want);
116        match engine {
117            Engine::Cdp => {
118                let client = open_cdp(endpoint).await?;
119                let target_id = match find_cdp_target_for_origin(&client, &want).await? {
120                    Some(id) => id,
121                    None => create_cdp_tab(&client, &origin_root).await?,
122                };
123                let session_id = client.attach_to_target(&target_id).await?;
124                let _ = client
125                    .send_with_session("Inspector.enable", json!({}), Some(&session_id))
126                    .await;
127                Ok(PageSession::Cdp(CdpPage {
128                    client,
129                    session_id,
130                    target_id,
131                }))
132            }
133            Engine::Bidi => {
134                let client = Arc::new(open_bidi(endpoint).await?);
135                client.session_new().await?;
136                let context = match find_bidi_context_for_origin(&client, &want).await? {
137                    Some(c) => c,
138                    None => create_bidi_tab(&client, &origin_root).await?,
139                };
140                Ok(PageSession::Bidi(BidiPage {
141                    client,
142                    context,
143                    owns_session: true,
144                }))
145            }
146        }
147    }
148
149    /// Evaluate `expression` in the page's main world.
150    ///
151    /// `await_promise = true` mirrors `Runtime.evaluate({awaitPromise:true})`
152    /// and is appropriate for fetch / promise-returning code. The returned
153    /// value is the raw `result.value` from CDP / BiDi after `returnByValue`.
154    ///
155    /// Equivalent to [`evaluate_with_timeout`](Self::evaluate_with_timeout)
156    /// with `timeout = None` (bounded only by the upstream client's protocol
157    /// timeout, currently 30 s). Prefer the bounded form in any path where
158    /// the renderer's responsiveness is uncertain — see the module docs.
159    pub async fn evaluate(&self, expression: &str, await_promise: bool) -> Result<Value> {
160        self.evaluate_with_timeout(expression, await_promise, None)
161            .await
162    }
163
164    /// Bounded variant of [`evaluate`](Self::evaluate).
165    ///
166    /// If `timeout` is `Some`, the call races the upstream send against a
167    /// `tokio::time::sleep`. On expiry, returns a typed
168    /// [`SessionError::TabHung`] tagged with the target's id and URL — this
169    /// is the catch-all for the alive-but-unresponsive renderer case that
170    /// has no protocol event signal (service-worker-paused page, JS infinite
171    /// loop, modal dialog, devtools-paused, embedded admin UIs whose
172    /// renderer ignores `Runtime.evaluate`).
173    ///
174    /// On the CDP arm, the in-flight `Runtime.evaluate` is additionally
175    /// raced against the renderer-crash events
176    /// (`Target.targetCrashed` / `Inspector.targetCrashed`) for this
177    /// target/session — a matching event short-circuits the call with a
178    /// typed [`SessionError::TabCrashed`] instead of waiting for the
179    /// timeout. BiDi has no equivalent protocol event; a context crash
180    /// surfaces as `no such frame/context` on the next request and is
181    /// classified as `TargetGone` by the client layer.
182    ///
183    /// If `timeout` is `None`, the call is bounded only by the underlying
184    /// client's protocol timeout (CDP: 30 s, BiDi: 30 s).
185    pub async fn evaluate_with_timeout(
186        &self,
187        expression: &str,
188        await_promise: bool,
189        timeout: Option<Duration>,
190    ) -> Result<Value> {
191        let target_id = self.target_id();
192        let url = None;
193        match self {
194            PageSession::Cdp(p) => {
195                let inner = async {
196                    let v = p
197                        .client
198                        .send_with_session(
199                            "Runtime.evaluate",
200                            json!({
201                                "expression": expression,
202                                "returnByValue": true,
203                                "awaitPromise": await_promise,
204                            }),
205                            Some(&p.session_id),
206                        )
207                        .await?;
208                    Ok::<Value, anyhow::Error>(v["result"]["value"].clone())
209                };
210                crate::session::crash::evaluate_with_crash_detection(
211                    &p.client,
212                    &p.target_id,
213                    Some(&p.session_id),
214                    inner,
215                    timeout,
216                )
217                .await
218            }
219            PageSession::Bidi(p) => {
220                let inner = async {
221                    let _ = await_promise; // BiDi always awaits per script_evaluate
222                    let v = p.client.script_evaluate(&p.context, expression).await?;
223                    Ok::<Value, anyhow::Error>(crate::bidi::remote_value_to_json(
224                        &crate::bidi::unwrap_script_result(v)?,
225                    ))
226                };
227                match timeout {
228                    None => inner.await,
229                    Some(d) => match tokio::time::timeout(d, inner).await {
230                        Ok(r) => r,
231                        Err(_) => Err(SessionError::TabHung {
232                            target_id,
233                            url,
234                            timeout_ms: d.as_millis() as u64,
235                            hint: "op-timeout",
236                        }
237                        .into()),
238                    },
239                }
240            }
241        }
242    }
243
244    /// Engine-specific target id for diagnostics (CDP `targetId`, BiDi
245    /// browsing context id).
246    pub fn target_id(&self) -> Option<String> {
247        match self {
248            PageSession::Cdp(p) => Some(p.target_id.clone()),
249            PageSession::Bidi(p) => Some(p.context.clone()),
250        }
251    }
252
253    /// Navigate the current page to `url`.
254    pub async fn navigate(&self, url: &str) -> Result<()> {
255        match self {
256            PageSession::Cdp(p) => {
257                p.client
258                    .send_with_session("Page.navigate", json!({"url": url}), Some(&p.session_id))
259                    .await?;
260                Ok(())
261            }
262            PageSession::Bidi(p) => {
263                p.client.browsing_context_navigate(&p.context, url).await?;
264                Ok(())
265            }
266        }
267    }
268
269    /// Reload an old HTTP(S) page before reading auth-sensitive page state.
270    ///
271    /// The age is measured from the document's `performance.timeOrigin`.
272    /// `about:blank`, `chrome://`, `devtools://`, and other non-web pages are
273    /// left untouched.
274    pub async fn ensure_fresh(&self, max_age: Duration) -> Result<()> {
275        let info_value = self
276            .evaluate_with_timeout(
277                freshness::PAGE_FRESHNESS_EXPR,
278                false,
279                Some(freshness::CHECK_TIMEOUT),
280            )
281            .await?;
282        let info = freshness::parse_page_freshness(info_value)?;
283        if !info.should_reload(max_age) {
284            return Ok(());
285        }
286
287        tracing::info!(
288            target = "session",
289            url = %info.href,
290            age_ms = info.age_ms,
291            max_age_ms = max_age.as_millis(),
292            "reloading stale page before reading page context"
293        );
294        tokio::time::timeout(freshness::RELOAD_READY_TIMEOUT, self.navigate(&info.href)).await??;
295        self.wait_until_ready().await
296    }
297
298    async fn wait_until_ready(&self) -> Result<()> {
299        let deadline = Instant::now() + freshness::RELOAD_READY_TIMEOUT;
300        loop {
301            let value = self
302                .evaluate_with_timeout(
303                    freshness::READY_STATE_EXPR,
304                    false,
305                    Some(freshness::CHECK_TIMEOUT),
306                )
307                .await?;
308            if freshness::is_ready(&value) {
309                return Ok(());
310            }
311            if Instant::now() >= deadline {
312                tracing::warn!(
313                    target = "session",
314                    "page reload did not reach document.readyState=complete before continuing"
315                );
316                return Ok(());
317            }
318            tokio::time::sleep(freshness::READY_POLL_INTERVAL).await;
319        }
320    }
321
322    /// Capture a PNG screenshot of the current page; returns base64 data.
323    pub async fn screenshot(&self, full_page: bool) -> Result<String> {
324        match self {
325            PageSession::Cdp(p) => {
326                let v = p
327                    .client
328                    .send_with_session(
329                        "Page.captureScreenshot",
330                        json!({
331                            "format": "png",
332                            "captureBeyondViewport": full_page,
333                        }),
334                        Some(&p.session_id),
335                    )
336                    .await?;
337                v["data"]
338                    .as_str()
339                    .map(|s| s.to_string())
340                    .ok_or_else(|| anyhow!("no screenshot data"))
341            }
342            PageSession::Bidi(p) => {
343                let _ = full_page; // BiDi captures the viewport by default
344                p.client
345                    .browsing_context_capture_screenshot(&p.context, None, None)
346                    .await
347            }
348        }
349    }
350
351    /// Engine this session is bound to.
352    pub fn engine(&self) -> Engine {
353        match self {
354            PageSession::Cdp(_) => Engine::Cdp,
355            PageSession::Bidi(_) => Engine::Bidi,
356        }
357    }
358
359    /// Release the underlying connection. For BiDi sessions that this
360    /// `PageSession` opened, also calls `session.end` so that Firefox (which
361    /// enforces one BiDi session per browser) accepts a fresh `session.new`
362    /// on the next invocation.
363    pub async fn close(self) {
364        match self {
365            PageSession::Cdp(p) => p.client.close().await,
366            PageSession::Bidi(p) => {
367                if p.owns_session {
368                    let _ = p.client.session_end().await;
369                }
370            }
371        }
372    }
373}
374
375/// Attach to a page on `origin_url`'s document origin, evaluate `expression`,
376/// close the session, and retry once on recoverable target-level failures.
377///
378/// This is the shared path for credentialed page-context fetches. Each attempt
379/// resolves the target by origin, so retrying never falls back to an opaque
380/// `about:blank` scratch tab that would drop cookies or trip CORS.
381pub async fn evaluate_for_origin_with_recover_once(
382    endpoint: &str,
383    engine: Engine,
384    origin_url: &str,
385    expression: &str,
386    await_promise: bool,
387    timeout: Duration,
388    max_age: Duration,
389) -> Result<Value> {
390    let first = evaluate_for_origin_once(
391        endpoint,
392        engine,
393        origin_url,
394        expression,
395        await_promise,
396        timeout,
397        max_age,
398    )
399    .await;
400    match first {
401        Ok(v) => Ok(v),
402        Err(e) if crate::errors::is_recoverable_tab_failure(&e) => {
403            tracing::warn!(
404                target = "session",
405                "origin-bound evaluate failed with recoverable error; re-attaching and retrying once: {e:#}"
406            );
407            evaluate_for_origin_once(
408                endpoint,
409                engine,
410                origin_url,
411                expression,
412                await_promise,
413                timeout,
414                max_age,
415            )
416            .await
417        }
418        Err(e) => Err(e),
419    }
420}
421
422async fn evaluate_for_origin_once(
423    endpoint: &str,
424    engine: Engine,
425    origin_url: &str,
426    expression: &str,
427    await_promise: bool,
428    timeout: Duration,
429    max_age: Duration,
430) -> Result<Value> {
431    let session = PageSession::attach_for_origin(endpoint, engine, origin_url).await?;
432    let result = async {
433        session.ensure_fresh(max_age).await?;
434        session
435            .evaluate_with_timeout(expression, await_promise, Some(timeout))
436            .await
437    }
438    .await;
439    session.close().await;
440    result
441}
442
443/// Per-candidate pre-flight probe budget when iterating URL-regex matches.
444///
445/// Each candidate gets this much wall-clock to reply to `Runtime.evaluate("1")`
446/// (CDP) or `script.evaluate("1")` (BiDi). Tight enough that a wedged
447/// renderer (Brave Sleeping Tab, devtools-paused, infinite-loop) fails fast
448/// so we can iterate to the next match; generous enough that a healthy tab
449/// on a loaded machine still answers.
450const PICK_PROBE_TIMEOUT: Duration = Duration::from_millis(500);
451
452async fn pick_cdp_page(client: &CdpClient, pattern: Option<&Regex>) -> Result<String> {
453    let targets = client.list_targets().await?;
454    let pages: Vec<CdpTarget> = CdpTarget::pages(&targets).collect();
455
456    // No regex: keep existing behaviour — take the first page. We do not
457    // probe in this branch because there's typically only one candidate and
458    // the caller hasn't expressed which they want; failing fast on a wedged
459    // single page would be more surprising than just letting the op timeout
460    // handle it.
461    let Some(re) = pattern else {
462        return pages
463            .into_iter()
464            .next()
465            .map(|t| t.id)
466            .ok_or_else(|| anyhow!("no page target found"));
467    };
468
469    let matches: Vec<CdpTarget> = pages.into_iter().filter(|t| re.is_match(&t.url)).collect();
470    if matches.is_empty() {
471        return Err(anyhow!("no CDP page target matched URL regex"));
472    }
473
474    // Probe each match in order. Return the first responsive one. If all
475    // are unresponsive, surface a TabHung with the count so the caller
476    // gets an actionable error instead of a 10-second op timeout.
477    let mut hung_count = 0usize;
478    let mut last_target: Option<String> = None;
479    let mut last_url: Option<String> = None;
480    for t in &matches {
481        let target_id = t.id.clone();
482        last_target = Some(target_id.clone());
483        last_url = Some(t.url.clone());
484        if probe_cdp_target(client, &target_id, PICK_PROBE_TIMEOUT).await {
485            return Ok(target_id);
486        }
487        hung_count += 1;
488    }
489    let err: anyhow::Error = SessionError::TabHung {
490        target_id: last_target,
491        url: last_url,
492        timeout_ms: PICK_PROBE_TIMEOUT.as_millis() as u64,
493        hint: "all-matches-hung",
494    }
495    .into();
496    Err(err.context(format!(
497        "URL regex matched {hung_count} page(s) but none responded to a {}ms probe",
498        PICK_PROBE_TIMEOUT.as_millis()
499    )))
500}
501
502/// Probe a CDP target by attaching a transient session and evaluating `1`.
503///
504/// Returns `true` if the target answered within `budget`. Best-effort detach
505/// on the way out; the probe outcome doesn't depend on the detach succeeding.
506async fn probe_cdp_target(client: &CdpClient, target_id: &str, budget: Duration) -> bool {
507    let attach = tokio::time::timeout(
508        budget,
509        client.send(
510            "Target.attachToTarget",
511            json!({ "targetId": target_id, "flatten": true }),
512        ),
513    )
514    .await;
515    let session_id = match attach {
516        Ok(Ok(v)) => match v.get("sessionId").and_then(|s| s.as_str()) {
517            Some(s) => s.to_string(),
518            None => return false,
519        },
520        _ => return false,
521    };
522    let eval = client.send_with_session(
523        "Runtime.evaluate",
524        json!({
525            "expression": "1",
526            "returnByValue": true,
527            "awaitPromise": false,
528        }),
529        Some(&session_id),
530    );
531    let alive = matches!(tokio::time::timeout(budget, eval).await, Ok(Ok(_)));
532    let _ = client
533        .send(
534            "Target.detachFromTarget",
535            json!({ "sessionId": session_id }),
536        )
537        .await;
538    alive
539}
540
541async fn pick_bidi_context(client: &BidiClient, pattern: Option<&Regex>) -> Result<String> {
542    let tree = client.send("browsingContext.getTree", json!({})).await?;
543    let contexts = BidiContext::from_tree(&tree);
544
545    // No regex: existing "first top-level context" behaviour.
546    let Some(re) = pattern else {
547        return contexts
548            .into_iter()
549            .next()
550            .map(|c| c.context)
551            .ok_or_else(|| anyhow!("no top-level browsing context"));
552    };
553
554    let matches: Vec<BidiContext> = contexts
555        .into_iter()
556        .filter(|c| re.is_match(&c.url))
557        .collect();
558    if matches.is_empty() {
559        return Err(anyhow!("no BiDi context matched URL regex"));
560    }
561
562    let mut hung_count = 0usize;
563    let mut last_ctx: Option<String> = None;
564    let mut last_url: Option<String> = None;
565    for c in &matches {
566        let ctx = c.context.clone();
567        last_ctx = Some(ctx.clone());
568        last_url = Some(c.url.clone());
569        if probe_bidi_context(client, &ctx, PICK_PROBE_TIMEOUT).await {
570            return Ok(ctx);
571        }
572        hung_count += 1;
573    }
574    let err: anyhow::Error = SessionError::TabHung {
575        target_id: last_ctx,
576        url: last_url,
577        timeout_ms: PICK_PROBE_TIMEOUT.as_millis() as u64,
578        hint: "all-matches-hung",
579    }
580    .into();
581    Err(err.context(format!(
582        "URL regex matched {hung_count} context(s) but none responded to a {}ms probe",
583        PICK_PROBE_TIMEOUT.as_millis()
584    )))
585}
586
587/// Probe a BiDi browsing context via `script.evaluate("1")`.
588///
589/// BiDi has no per-target attach; the existing session covers all contexts.
590/// Returns `true` if the context answered within `budget`.
591async fn probe_bidi_context(client: &BidiClient, context: &str, budget: Duration) -> bool {
592    matches!(
593        tokio::time::timeout(budget, client.script_evaluate(context, "1")).await,
594        Ok(Ok(_))
595    )
596}
597
598/// True when both URLs share scheme, host, and effective port.
599pub(crate) fn same_origin(a: &url::Url, b: &url::Url) -> bool {
600    a.scheme() == b.scheme()
601        && a.host_str() == b.host_str()
602        && a.port_or_known_default() == b.port_or_known_default()
603}
604
605/// Strip everything after the origin: e.g. `https://x/y?z` → `https://x/`.
606pub(crate) fn origin_root_url(u: &url::Url) -> String {
607    let scheme = u.scheme();
608    let host = u.host_str().unwrap_or("");
609    match (u.port(), u.port_or_known_default()) {
610        // Only emit a port when it's non-default for the scheme.
611        (Some(p), _) => format!("{scheme}://{host}:{p}/"),
612        (None, _) => format!("{scheme}://{host}/"),
613    }
614}
615
616async fn find_cdp_target_for_origin(client: &CdpClient, want: &url::Url) -> Result<Option<String>> {
617    let targets = client.list_targets().await?;
618    let found = CdpTarget::pages(&targets).find_map(|t| {
619        let parsed = url::Url::parse(&t.url).ok()?;
620        same_origin(&parsed, want).then_some(t.id)
621    });
622    Ok(found)
623}
624
625async fn create_cdp_tab(client: &CdpClient, url: &str) -> Result<String> {
626    let v = client
627        .send(
628            "Target.createTarget",
629            json!({ "url": url, "background": true }),
630        )
631        .await?;
632    v.get("targetId")
633        .and_then(|x| x.as_str())
634        .map(|s| s.to_string())
635        .ok_or_else(|| anyhow!("Target.createTarget did not return targetId"))
636}
637
638async fn find_bidi_context_for_origin(
639    client: &BidiClient,
640    want: &url::Url,
641) -> Result<Option<String>> {
642    let tree = client.send("browsingContext.getTree", json!({})).await?;
643    Ok(BidiContext::from_tree(&tree).into_iter().find_map(|c| {
644        let parsed = url::Url::parse(&c.url).ok()?;
645        same_origin(&parsed, want).then_some(c.context)
646    }))
647}
648
649async fn create_bidi_tab(client: &BidiClient, url: &str) -> Result<String> {
650    let v = client
651        .send("browsingContext.create", json!({ "type": "tab" }))
652        .await?;
653    let ctx = v
654        .get("context")
655        .and_then(|x| x.as_str())
656        .ok_or_else(|| anyhow!("browsingContext.create did not return context"))?
657        .to_string();
658    client.browsing_context_navigate(&ctx, url).await?;
659    Ok(ctx)
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665    use futures_util::{SinkExt, StreamExt};
666    use std::sync::{
667        atomic::{AtomicUsize, Ordering},
668        Arc,
669    };
670    use tokio::sync::Mutex;
671    use tokio_tungstenite::tungstenite::Message;
672
673    async fn spawn_cdp_mock(targets: Vec<Value>) -> String {
674        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
675        let addr = listener.local_addr().unwrap();
676        tokio::spawn(async move {
677            let (stream, _) = listener.accept().await.unwrap();
678            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
679            while let Some(Ok(Message::Text(t))) = ws.next().await {
680                let req: Value = serde_json::from_str(&t).unwrap();
681                let id = req["id"].as_u64().unwrap();
682                let method = req["method"].as_str().unwrap_or("");
683                let result = match method {
684                    "Target.getTargets" => json!({"targetInfos": targets.clone()}),
685                    "Target.attachToTarget" => json!({"sessionId": "S1"}),
686                    "Target.createTarget" => json!({"targetId": "NEW"}),
687                    "Runtime.evaluate" => json!({"result": {"value": "ok"}}),
688                    "Page.navigate" => json!({}),
689                    "Page.captureScreenshot" => json!({"data": "PNGDATA"}),
690                    _ => json!({}),
691                };
692                let resp = json!({"id": id, "result": result});
693                ws.send(Message::Text(resp.to_string())).await.unwrap();
694            }
695        });
696        format!("ws://{addr}")
697    }
698
699    async fn spawn_cdp_origin_eval_mock(
700        targets: Vec<Value>,
701        fail_first_eval: bool,
702    ) -> (String, Arc<Mutex<Vec<Value>>>, Arc<Mutex<Vec<String>>>) {
703        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
704        let addr = listener.local_addr().unwrap();
705        let targets = Arc::new(targets);
706        let created_params = Arc::new(Mutex::new(Vec::new()));
707        let attached_targets = Arc::new(Mutex::new(Vec::new()));
708        let eval_count = Arc::new(AtomicUsize::new(0));
709
710        tokio::spawn({
711            let targets = targets.clone();
712            let created_params = created_params.clone();
713            let attached_targets = attached_targets.clone();
714            let eval_count = eval_count.clone();
715            async move {
716                loop {
717                    let Ok((stream, _)) = listener.accept().await else {
718                        break;
719                    };
720                    let targets = targets.clone();
721                    let created_params = created_params.clone();
722                    let attached_targets = attached_targets.clone();
723                    let eval_count = eval_count.clone();
724                    tokio::spawn(async move {
725                        let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
726                        while let Some(Ok(Message::Text(t))) = ws.next().await {
727                            let req: Value = serde_json::from_str(&t).unwrap();
728                            let id = req["id"].as_u64().unwrap();
729                            let method = req["method"].as_str().unwrap_or("");
730                            if method == "Runtime.evaluate"
731                                && fail_first_eval
732                                && eval_count.fetch_add(1, Ordering::SeqCst) == 0
733                            {
734                                let resp = json!({
735                                    "id": id,
736                                    "error": {
737                                        "code": -32000,
738                                        "message": "No target with given id",
739                                    }
740                                });
741                                ws.send(Message::Text(resp.to_string())).await.unwrap();
742                                continue;
743                            }
744                            let result = match method {
745                                "Target.getTargets" => {
746                                    json!({"targetInfos": targets.as_ref().clone()})
747                                }
748                                "Target.createTarget" => {
749                                    created_params.lock().await.push(req["params"].clone());
750                                    json!({"targetId": "NEW"})
751                                }
752                                "Target.attachToTarget" => {
753                                    let target_id = req
754                                        .pointer("/params/targetId")
755                                        .and_then(|v| v.as_str())
756                                        .unwrap_or("")
757                                        .to_string();
758                                    let mut attached = attached_targets.lock().await;
759                                    attached.push(target_id);
760                                    json!({"sessionId": format!("S{}", attached.len())})
761                                }
762                                "Target.detachFromTarget" => json!({}),
763                                "Inspector.enable" => json!({}),
764                                "Runtime.evaluate" => {
765                                    let expression = req
766                                        .pointer("/params/expression")
767                                        .and_then(|v| v.as_str())
768                                        .unwrap_or("");
769                                    let value = if expression == freshness::READY_STATE_EXPR {
770                                        json!("complete")
771                                    } else if expression == freshness::PAGE_FRESHNESS_EXPR {
772                                        json!({
773                                            "href": "https://example.com/login",
774                                            "ageMs": 0.0,
775                                            "readyState": "complete"
776                                        })
777                                    } else {
778                                        json!("ok")
779                                    };
780                                    json!({"result": {"value": value}})
781                                }
782                                _ => json!({}),
783                            };
784                            let resp = json!({"id": id, "result": result});
785                            ws.send(Message::Text(resp.to_string())).await.unwrap();
786                        }
787                    });
788                }
789            }
790        });
791
792        (format!("ws://{addr}"), created_params, attached_targets)
793    }
794
795    #[test]
796    fn same_origin_basic() {
797        let a = url::Url::parse("https://example.com/path?q=1").unwrap();
798        let b = url::Url::parse("https://example.com/other").unwrap();
799        let c = url::Url::parse("https://other.test/path").unwrap();
800        let d = url::Url::parse("http://example.com/").unwrap();
801        assert!(same_origin(&a, &b));
802        assert!(!same_origin(&a, &c));
803        assert!(!same_origin(&a, &d));
804    }
805
806    #[test]
807    fn origin_root_strips_path_and_default_port() {
808        let u = url::Url::parse("https://example.com/foo/bar?x=1#z").unwrap();
809        assert_eq!(origin_root_url(&u), "https://example.com/");
810        let u2 = url::Url::parse("http://localhost:8080/foo").unwrap();
811        assert_eq!(origin_root_url(&u2), "http://localhost:8080/");
812    }
813
814    #[tokio::test]
815    async fn attach_for_origin_reuses_matching_tab() {
816        let url = spawn_cdp_mock(vec![
817            json!({"targetId":"a","type":"page","url":"https://other.test/x"}),
818            json!({"targetId":"b","type":"page","url":"https://example.com/login"}),
819        ])
820        .await;
821        let s = PageSession::attach_for_origin(&url, Engine::Cdp, "https://example.com/api/v1")
822            .await
823            .unwrap();
824        match s {
825            PageSession::Cdp(p) => assert_eq!(p.target_id, "b"),
826            _ => panic!("expected CDP"),
827        }
828    }
829
830    #[tokio::test]
831    async fn attach_for_origin_creates_tab_when_no_match() {
832        let url = spawn_cdp_mock(vec![
833            json!({"targetId":"a","type":"page","url":"https://other.test/"}),
834        ])
835        .await;
836        let s = PageSession::attach_for_origin(&url, Engine::Cdp, "https://example.com/api")
837            .await
838            .unwrap();
839        match s {
840            PageSession::Cdp(p) => assert_eq!(p.target_id, "NEW"),
841            _ => panic!("expected CDP"),
842        }
843    }
844
845    #[tokio::test]
846    async fn evaluate_for_origin_creates_origin_tab_when_no_match() {
847        let (url, created_params, attached_targets) = spawn_cdp_origin_eval_mock(
848            vec![json!({"targetId":"a","type":"page","url":"https://other.test/"})],
849            false,
850        )
851        .await;
852        let value = evaluate_for_origin_with_recover_once(
853            &url,
854            Engine::Cdp,
855            "https://example.com/api",
856            "1+1",
857            true,
858            Duration::from_secs(1),
859            freshness::DEFAULT_MAX_AGE,
860        )
861        .await
862        .unwrap();
863        assert_eq!(value, json!("ok"));
864        let created = created_params.lock().await;
865        assert_eq!(created.len(), 1);
866        assert_eq!(created[0]["url"], "https://example.com/");
867        assert_eq!(created[0]["background"], true);
868        assert_eq!(*attached_targets.lock().await, vec!["NEW".to_string()]);
869    }
870
871    #[tokio::test]
872    async fn evaluate_for_origin_reattaches_and_retries_once() {
873        let (url, created_params, attached_targets) = spawn_cdp_origin_eval_mock(
874            vec![json!({"targetId":"A","type":"page","url":"https://example.com/login"})],
875            true,
876        )
877        .await;
878        let value = evaluate_for_origin_with_recover_once(
879            &url,
880            Engine::Cdp,
881            "https://example.com/api",
882            "1+1",
883            true,
884            Duration::from_secs(1),
885            freshness::DEFAULT_MAX_AGE,
886        )
887        .await
888        .unwrap();
889        assert_eq!(value, json!("ok"));
890        assert!(created_params.lock().await.is_empty());
891        assert_eq!(
892            *attached_targets.lock().await,
893            vec!["A".to_string(), "A".to_string()]
894        );
895    }
896
897    #[tokio::test]
898    async fn attach_cdp_picks_first_page_when_no_regex() {
899        let url = spawn_cdp_mock(vec![
900            json!({"targetId":"a","type":"page","url":"https://example.com/"}),
901            json!({"targetId":"b","type":"page","url":"https://other.test/"}),
902        ])
903        .await;
904        let s = PageSession::attach(&url, Engine::Cdp, None).await.unwrap();
905        match s {
906            PageSession::Cdp(p) => {
907                assert_eq!(p.target_id, "a");
908                assert_eq!(p.session_id, "S1");
909            }
910            _ => panic!("expected CDP"),
911        }
912    }
913
914    #[tokio::test]
915    async fn attach_cdp_url_regex_selects_matching() {
916        let url = spawn_cdp_mock(vec![
917            json!({"targetId":"a","type":"page","url":"https://example.com/"}),
918            json!({"targetId":"b","type":"page","url":"https://other.test/"}),
919        ])
920        .await;
921        let s = PageSession::attach(&url, Engine::Cdp, Some(r"other"))
922            .await
923            .unwrap();
924        match s {
925            PageSession::Cdp(p) => assert_eq!(p.target_id, "b"),
926            _ => panic!("expected CDP"),
927        }
928    }
929
930    #[tokio::test]
931    async fn attach_cdp_url_regex_no_match_errors() {
932        let url = spawn_cdp_mock(vec![
933            json!({"targetId":"a","type":"page","url":"https://example.com/"}),
934        ])
935        .await;
936        let err = match PageSession::attach(&url, Engine::Cdp, Some("nomatch")).await {
937            Ok(_) => panic!("expected error"),
938            Err(e) => e,
939        };
940        assert!(err.to_string().contains("no CDP page target matched"));
941    }
942
943    #[tokio::test]
944    async fn evaluate_round_trip_cdp() {
945        let url = spawn_cdp_mock(vec![
946            json!({"targetId":"a","type":"page","url":"https://example.com/"}),
947        ])
948        .await;
949        let s = PageSession::attach(&url, Engine::Cdp, None).await.unwrap();
950        let v = s.evaluate("1+1", false).await.unwrap();
951        assert_eq!(v, json!("ok"));
952        s.close().await;
953    }
954
955    #[tokio::test]
956    async fn screenshot_round_trip_cdp() {
957        let url = spawn_cdp_mock(vec![
958            json!({"targetId":"a","type":"page","url":"https://example.com/"}),
959        ])
960        .await;
961        let s = PageSession::attach(&url, Engine::Cdp, None).await.unwrap();
962        let b64 = s.screenshot(false).await.unwrap();
963        assert_eq!(b64, "PNGDATA");
964        s.close().await;
965    }
966
967    /// Spawn a CDP mock that answers `Target.getTargets` / `attachToTarget`
968    /// normally but **never replies to `Runtime.evaluate`** — simulating the
969    /// iLO-style wedge where the renderer is alive but refuses to service JS.
970    async fn spawn_cdp_mock_eval_hangs(targets: Vec<Value>) -> String {
971        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
972        let addr = listener.local_addr().unwrap();
973        tokio::spawn(async move {
974            let (stream, _) = listener.accept().await.unwrap();
975            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
976            while let Some(Ok(Message::Text(t))) = ws.next().await {
977                let req: Value = serde_json::from_str(&t).unwrap();
978                let id = req["id"].as_u64().unwrap();
979                let method = req["method"].as_str().unwrap_or("");
980                if method == "Runtime.evaluate" {
981                    // Drop the request on the floor. No response, ever.
982                    continue;
983                }
984                let result = match method {
985                    "Target.getTargets" => json!({"targetInfos": targets.clone()}),
986                    "Target.attachToTarget" => json!({"sessionId": "S1"}),
987                    _ => json!({}),
988                };
989                let resp = json!({"id": id, "result": result});
990                ws.send(Message::Text(resp.to_string())).await.unwrap();
991            }
992        });
993        format!("ws://{addr}")
994    }
995
996    /// Test #1: the iLO-style wedge. `evaluate_with_timeout` returns a typed
997    /// `TabHung` within the bound — not the 30 s upstream `REQUEST_TIMEOUT`.
998    #[tokio::test]
999    async fn evaluate_with_timeout_returns_tab_hung_on_no_reply() {
1000        let url = spawn_cdp_mock_eval_hangs(vec![
1001            json!({"targetId":"iLO","type":"page","url":"https://192.168.2.28/"}),
1002        ])
1003        .await;
1004        let s = PageSession::attach(&url, Engine::Cdp, None).await.unwrap();
1005        let start = std::time::Instant::now();
1006        let err = s
1007            .evaluate_with_timeout("1+1", false, Some(Duration::from_millis(300)))
1008            .await
1009            .expect_err("must return TabHung");
1010        let elapsed = start.elapsed();
1011        assert!(
1012            elapsed < Duration::from_secs(1),
1013            "did not honour 300ms bound, took {elapsed:?}"
1014        );
1015        let downcast = err.downcast_ref::<SessionError>().expect("typed error");
1016        match downcast {
1017            SessionError::TabHung {
1018                target_id,
1019                timeout_ms,
1020                hint,
1021                ..
1022            } => {
1023                assert_eq!(target_id.as_deref(), Some("iLO"));
1024                assert_eq!(*timeout_ms, 300);
1025                assert_eq!(*hint, "op-timeout");
1026            }
1027            other => panic!("expected TabHung, got {other:?}"),
1028        }
1029        s.close().await;
1030    }
1031
1032    /// Test #16 (partial): a stuck eval on one PageSession does not block a
1033    /// concurrent eval on a sibling PageSession sharing the same browser. We
1034    /// model the "sibling" by opening a second mock — same protocol, two
1035    /// CdpClient instances. The point of the test is to verify that the
1036    /// timeout/error path on one session is isolated from the other.
1037    #[tokio::test]
1038    async fn stuck_eval_does_not_block_sibling_session() {
1039        let bad = spawn_cdp_mock_eval_hangs(vec![
1040            json!({"targetId":"BAD","type":"page","url":"https://192.168.2.28/"}),
1041        ])
1042        .await;
1043        let good = spawn_cdp_mock(vec![
1044            json!({"targetId":"GOOD","type":"page","url":"https://example.com/"}),
1045        ])
1046        .await;
1047
1048        let s_bad = PageSession::attach(&bad, Engine::Cdp, None).await.unwrap();
1049        let s_good = PageSession::attach(&good, Engine::Cdp, None).await.unwrap();
1050
1051        // Run both concurrently. The bad one should fast-fail; the good one
1052        // should succeed independently.
1053        let bad_fut = s_bad.evaluate_with_timeout("1+1", false, Some(Duration::from_millis(200)));
1054        let good_fut = s_good.evaluate_with_timeout("1+1", false, Some(Duration::from_secs(5)));
1055        let (bad_res, good_res) = tokio::join!(bad_fut, good_fut);
1056
1057        assert!(bad_res.is_err(), "bad session must surface TabHung");
1058        assert_eq!(good_res.unwrap(), json!("ok"));
1059
1060        s_bad.close().await;
1061        s_good.close().await;
1062    }
1063
1064    /// CDP mock that selectively wedges `Runtime.evaluate` based on which
1065    /// `sessionId` is in use. The mock maps each `attachToTarget` to a
1066    /// distinct sessionId, so the test can decide "evals on tab X hang,
1067    /// evals on tab Y succeed."
1068    async fn spawn_cdp_mock_per_target_eval(
1069        targets: Vec<Value>,
1070        wedged_targets: Vec<&'static str>,
1071    ) -> String {
1072        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1073        let addr = listener.local_addr().unwrap();
1074        tokio::spawn(async move {
1075            let (stream, _) = listener.accept().await.unwrap();
1076            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1077            // sessionId → wedge flag
1078            let mut session_wedge: std::collections::HashMap<String, bool> =
1079                std::collections::HashMap::new();
1080            let mut next_session: u32 = 0;
1081            while let Some(Ok(Message::Text(t))) = ws.next().await {
1082                let req: Value = serde_json::from_str(&t).unwrap();
1083                let id = req["id"].as_u64().unwrap();
1084                let method = req["method"].as_str().unwrap_or("");
1085                if method == "Runtime.evaluate" {
1086                    if let Some(sid) = req.get("sessionId").and_then(|v| v.as_str()) {
1087                        if session_wedge.get(sid).copied().unwrap_or(false) {
1088                            // Drop on the floor.
1089                            continue;
1090                        }
1091                    }
1092                }
1093                let result = match method {
1094                    "Target.getTargets" => json!({"targetInfos": targets.clone()}),
1095                    "Target.attachToTarget" => {
1096                        let target_id = req
1097                            .get("params")
1098                            .and_then(|p| p.get("targetId"))
1099                            .and_then(|v| v.as_str())
1100                            .unwrap_or("")
1101                            .to_string();
1102                        next_session += 1;
1103                        let sid = format!("S{next_session}");
1104                        let wedge = wedged_targets.iter().any(|w| *w == target_id);
1105                        session_wedge.insert(sid.clone(), wedge);
1106                        json!({"sessionId": sid})
1107                    }
1108                    "Target.detachFromTarget" => json!({}),
1109                    "Runtime.evaluate" => json!({"result": {"value": "ok"}}),
1110                    _ => json!({}),
1111                };
1112                let resp = json!({"id": id, "result": result});
1113                ws.send(Message::Text(resp.to_string())).await.unwrap();
1114            }
1115        });
1116        format!("ws://{addr}")
1117    }
1118
1119    /// Regex matches two pages; the first is wedged, the second answers
1120    /// the probe. We pick the second.
1121    #[tokio::test]
1122    async fn pick_cdp_iterates_past_hung_match() {
1123        let url = spawn_cdp_mock_per_target_eval(
1124            vec![
1125                json!({"targetId":"DEAD","type":"page","url":"https://twitch.tv/gametechnology"}),
1126                json!({"targetId":"LIVE","type":"page","url":"https://gametechnology.somewhere.com"}),
1127            ],
1128            vec!["DEAD"],
1129        )
1130        .await;
1131        let s = PageSession::attach(&url, Engine::Cdp, Some(r"gametechnology"))
1132            .await
1133            .expect("must iterate past the wedged tab and pick LIVE");
1134        match s {
1135            PageSession::Cdp(p) => assert_eq!(p.target_id, "LIVE"),
1136            _ => panic!("expected CDP"),
1137        }
1138    }
1139
1140    /// Regex matches two pages and both are wedged → typed TabHung with
1141    /// the `all-matches-hung` hint. Must complete within
1142    /// 2 × PICK_PROBE_TIMEOUT + slack (one probe per match).
1143    #[tokio::test]
1144    async fn pick_cdp_all_matches_hung_returns_tab_hung() {
1145        let url = spawn_cdp_mock_per_target_eval(
1146            vec![
1147                json!({"targetId":"A","type":"page","url":"https://example.com/foo"}),
1148                json!({"targetId":"B","type":"page","url":"https://example.com/bar"}),
1149            ],
1150            vec!["A", "B"],
1151        )
1152        .await;
1153        let start = std::time::Instant::now();
1154        let err = match PageSession::attach(&url, Engine::Cdp, Some(r"example\.com")).await {
1155            Ok(_) => panic!("all matches wedged → must error"),
1156            Err(e) => e,
1157        };
1158        let elapsed = start.elapsed();
1159        assert!(
1160            elapsed < PICK_PROBE_TIMEOUT * 2 + Duration::from_millis(500),
1161            "took too long: {elapsed:?}"
1162        );
1163        let typed = err.downcast_ref::<SessionError>().expect("typed error");
1164        match typed {
1165            SessionError::TabHung { hint, .. } => {
1166                assert_eq!(*hint, "all-matches-hung");
1167            }
1168            other => panic!("expected TabHung, got {other:?}"),
1169        }
1170        let text = format!("{err:#}");
1171        assert!(
1172            text.contains("URL regex matched 2 page(s)"),
1173            "context missing count: {text}"
1174        );
1175    }
1176
1177    /// Regex matches one healthy page → picks it, the probe is a no-op
1178    /// for behaviour (just confirms responsiveness) and we still attach.
1179    #[tokio::test]
1180    async fn pick_cdp_single_healthy_match_is_picked() {
1181        let url = spawn_cdp_mock_per_target_eval(
1182            vec![json!({"targetId":"OK","type":"page","url":"https://example.com/x"})],
1183            vec![],
1184        )
1185        .await;
1186        let s = PageSession::attach(&url, Engine::Cdp, Some(r"example"))
1187            .await
1188            .unwrap();
1189        match s {
1190            PageSession::Cdp(p) => assert_eq!(p.target_id, "OK"),
1191            _ => panic!("expected CDP"),
1192        }
1193    }
1194}