Skip to main content

browser_control/session/
capture.rs

1//! Passive console and network capture for the MCP server.
2//!
3//! The MCP server keeps one CDP WebSocket open for its lifetime, and CDP
4//! pushes `Runtime.*` / `Log.*` / `Network.*` events to any attached session
5//! that has those domains enabled. This module keeps one long-lived flat
6//! session per *touched* tab (any tab a tool call has routed to), enables
7//! the domains once, and routes the pushed events into bounded per-tab ring
8//! buffers that `browser_console_messages` / `browser_network_requests`
9//! read on demand.
10//!
11//! This is the sanctioned exception to the "no idle work" rule in
12//! `docs/specs/boundaries.md`: nothing here polls or wakes on a timer. The
13//! only background task blocks on the event channel and does a mutex push
14//! per event; when the browser is quiet, it is parked.
15//!
16//! Lifecycle: state for a tab is dropped when the tab is closed through the
17//! MCP server (`forget`), when the browser detaches the session
18//! (`Target.detachedFromTarget`), when the renderer crashes
19//! (`Inspector.targetCrashed`), and wholesale on `browser_select` (`reset`)
20//! or when the socket closes. Nothing in here ever returns `TabHung` /
21//! `TabCrashed`: attach failures are swallowed and retried on the next touch,
22//! so the recover-once flows elsewhere are unaffected.
23//!
24//! Firefox (WebDriver BiDi) uses the same hub with a different ingress: one
25//! global `session.subscribe` per backend for `log.entryAdded`,
26//! `network.beforeRequestSent` / `responseCompleted` / `fetchError`, and
27//! `browsingContext.navigationStarted` / `contextDestroyed`, routed by the
28//! browsing-context id, which *is* the target id. Response bodies are not
29//! available on BiDi (no `getResponseBody` equivalent without browser-side
30//! retention), so `browser_network_body` stays Chromium-only.
31//!
32//! Opt-out: `BROWSER_CONTROL_CAPTURE=0` (or `false`) disables attachment
33//! entirely on both engines. `Runtime.enable` is observable by some anti-bot
34//! scripts, and a user logging into such a site through `browser_show` may
35//! prefer the server not to touch their tabs.
36
37use std::collections::{HashMap, VecDeque};
38use std::sync::{Arc, Mutex, Weak};
39use std::time::Duration;
40
41use anyhow::{anyhow, Result};
42use base64::Engine as _;
43use regex::Regex;
44use serde::Serialize;
45use serde_json::{json, Value};
46use tokio::sync::{broadcast, Notify, OnceCell};
47
48use crate::bidi::{BidiClient, BidiEvent};
49use crate::cdp::{CdpClient, CdpEvent};
50use crate::session::backend::TabBackend;
51
52/// Agent-facing explanation for `browser_network_body` on Firefox.
53pub const BIDI_NO_BODIES_HINT: &str = "response bodies are not captured on Firefox; use browser_fetch to re-issue the request, or switch to a Chromium browser via browser_select";
54
55/// Console entries kept per tab.
56pub const CONSOLE_CAP: usize = 1000;
57/// Network entries kept per tab.
58pub const NETWORK_CAP: usize = 500;
59/// Bytes of rendered text kept per console entry.
60pub const CONSOLE_TEXT_CAP: usize = 4096;
61/// Bytes of URL kept per entry.
62pub const URL_CAP: usize = 2048;
63/// Bound on the whole attach + enable sequence.
64const ATTACH_TIMEOUT: Duration = Duration::from_secs(5);
65/// How long a reader (or `browser_navigate`) waits for an in-flight attach.
66pub const TOUCH_WAIT: Duration = Duration::from_secs(2);
67/// Default cap for `browser_network_body`.
68pub const BODY_DEFAULT_MAX: usize = 256 * 1024;
69/// Hard cap for `browser_network_body`, aligned with `browser_curl`.
70pub const BODY_HARD_MAX: usize = crate::cli::curl::MCP_RESPONSE_LIMIT;
71/// Browser-side body buffer per captured tab (`Network.enable`).
72const NET_MAX_TOTAL_BUFFER: u64 = 32 * 1024 * 1024;
73const NET_MAX_RESOURCE_BUFFER: u64 = 8 * 1024 * 1024;
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "lowercase")]
77pub enum Level {
78    Error,
79    Warn,
80    Info,
81    Log,
82    Debug,
83}
84
85impl Level {
86    fn label(self) -> &'static str {
87        match self {
88            Level::Error => "error",
89            Level::Warn => "warn",
90            Level::Info => "info",
91            Level::Log => "log",
92            Level::Debug => "debug",
93        }
94    }
95}
96
97/// One captured console line.
98#[derive(Debug, Clone, Serialize)]
99pub struct ConsoleEntry {
100    pub seq: u64,
101    /// Epoch milliseconds.
102    pub ts_ms: f64,
103    pub level: Level,
104    /// `console.<type>`, `exception`, or the `Log.entryAdded` source
105    /// (`network`, `security`, `deprecation`, …).
106    pub source: String,
107    pub text: String,
108    pub url: Option<String>,
109    /// 1-based.
110    pub line: Option<u32>,
111    pub column: Option<u32>,
112    /// Document URL when the entry was captured.
113    pub page_url: String,
114    /// Full exception description (JSON output only).
115    pub stack: Option<String>,
116    pub network_request_id: Option<String>,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
120#[serde(rename_all = "lowercase")]
121pub enum NetState {
122    Pending,
123    Finished,
124    Failed,
125    Redirected,
126}
127
128/// One captured network request.
129#[derive(Debug, Clone, Serialize)]
130pub struct NetworkEntry {
131    pub seq: u64,
132    /// CDP `Network.RequestId`, verbatim — pass to `browser_network_body`.
133    pub request_id: String,
134    /// Epoch milliseconds.
135    pub ts_ms: f64,
136    #[serde(skip)]
137    monotonic_start: f64,
138    pub method: String,
139    pub url: String,
140    pub resource_type: Option<String>,
141    pub status: Option<u16>,
142    pub status_text: Option<String>,
143    pub mime_type: Option<String>,
144    pub from_cache: bool,
145    pub encoded_bytes: Option<u64>,
146    pub duration_ms: Option<f64>,
147    pub failed: Option<String>,
148    pub state: NetState,
149    pub page_url: String,
150    pub has_post_data: bool,
151}
152
153/// Per-tab capture state.
154#[derive(Debug)]
155struct TabCapture {
156    /// Hub-owned flat session, once attached.
157    session_id: Option<String>,
158    /// Domains enabled and `page_url` seeded.
159    ready: bool,
160    notify: Arc<Notify>,
161    page_url: String,
162    console: VecDeque<ConsoleEntry>,
163    network: VecDeque<NetworkEntry>,
164    next_seq: u64,
165    dropped_console: u64,
166    dropped_network: u64,
167}
168
169impl TabCapture {
170    fn new() -> Self {
171        Self {
172            session_id: None,
173            ready: false,
174            notify: Arc::new(Notify::new()),
175            page_url: String::new(),
176            console: VecDeque::new(),
177            network: VecDeque::new(),
178            next_seq: 1,
179            dropped_console: 0,
180            dropped_network: 0,
181        }
182    }
183
184    fn push_console(&mut self, mut e: ConsoleEntry) {
185        e.seq = self.next_seq;
186        self.next_seq += 1;
187        e.page_url = self.page_url.clone();
188        if self.console.len() >= CONSOLE_CAP {
189            self.console.pop_front();
190            self.dropped_console += 1;
191        }
192        self.console.push_back(e);
193    }
194
195    fn push_network(&mut self, mut e: NetworkEntry) {
196        e.seq = self.next_seq;
197        self.next_seq += 1;
198        e.page_url = self.page_url.clone();
199        if self.network.len() >= NETWORK_CAP {
200            self.network.pop_front();
201            self.dropped_network += 1;
202        }
203        self.network.push_back(e);
204    }
205
206    fn network_mut(&mut self, request_id: &str) -> Option<&mut NetworkEntry> {
207        self.network
208            .iter_mut()
209            .rev()
210            .find(|e| e.request_id == request_id)
211    }
212}
213
214/// What the one-time BiDi `session.subscribe` managed to arm.
215#[derive(Debug, Clone, Copy)]
216struct BidiCapabilities {
217    /// `network.*` events were accepted (Firefox 124+).
218    network: bool,
219}
220
221#[derive(Default)]
222struct HubInner {
223    tabs: HashMap<String, TabCapture>,
224    /// CDP only: hub session id → target id. BiDi routes by context id.
225    sessions: HashMap<String, String>,
226    /// Either engine's router (only one backend is live at a time).
227    router: Option<tokio::task::JoinHandle<()>>,
228    /// BiDi only: the global subscription, performed once per backend by
229    /// the first attach task and shared by later ones; replaced on `reset`.
230    bidi: Option<Arc<OnceCell<BidiCapabilities>>>,
231    lost_events: u64,
232    disabled: bool,
233}
234
235impl HubInner {
236    /// Route one CDP event. Pure and synchronous so it is unit-testable
237    /// with synthetic events.
238    fn route(&mut self, ev: CdpEvent) {
239        let Some(sid) = ev.session_id.as_deref() else {
240            if ev.method == "Target.detachedFromTarget" {
241                if let Some(sid) = ev.params.get("sessionId").and_then(Value::as_str) {
242                    if let Some(tid) = self.sessions.remove(sid) {
243                        self.tabs.remove(&tid);
244                    }
245                }
246            }
247            return;
248        };
249        // Fast-path discard of chatty events before any map lookup.
250        if matches!(
251            ev.method.as_str(),
252            "Network.dataReceived"
253                | "Network.requestServedFromCache"
254                | "Network.resourceChangedPriority"
255                | "Network.responseReceivedExtraInfo"
256                | "Network.requestWillBeSentExtraInfo"
257                | "Runtime.executionContextCreated"
258                | "Runtime.executionContextDestroyed"
259                | "Runtime.executionContextsCleared"
260                | "Page.lifecycleEvent"
261                | "Page.frameStartedLoading"
262                | "Page.frameStoppedLoading"
263                | "Page.domContentEventFired"
264                | "Page.loadEventFired"
265        ) {
266            return;
267        }
268        let Some(tid) = self.sessions.get(sid).cloned() else {
269            return;
270        };
271        if matches!(
272            ev.method.as_str(),
273            "Inspector.targetCrashed" | "Inspector.detached"
274        ) {
275            self.sessions.remove(sid);
276            self.tabs.remove(&tid);
277            return;
278        }
279        let Some(tab) = self.tabs.get_mut(&tid) else {
280            return;
281        };
282        let p = &ev.params;
283        match ev.method.as_str() {
284            "Runtime.consoleAPICalled" => {
285                if let Some(e) = console_entry_from_api(p) {
286                    tab.push_console(e);
287                }
288            }
289            "Runtime.exceptionThrown" => tab.push_console(console_entry_from_exception(p)),
290            "Log.entryAdded" => {
291                if let Some(e) = console_entry_from_log(p) {
292                    tab.push_console(e);
293                }
294            }
295            "Network.requestWillBeSent" => {
296                let rid = p["requestId"].as_str().unwrap_or_default().to_string();
297                if let Some(redirect) = p.get("redirectResponse") {
298                    if let Some(prev) = tab.network_mut(&rid) {
299                        apply_response(prev, redirect);
300                        prev.state = NetState::Redirected;
301                        prev.duration_ms =
302                            duration_ms(prev.monotonic_start, p["timestamp"].as_f64());
303                    }
304                }
305                let req = &p["request"];
306                tab.push_network(NetworkEntry {
307                    seq: 0,
308                    request_id: rid,
309                    ts_ms: p["wallTime"].as_f64().unwrap_or(0.0) * 1000.0,
310                    monotonic_start: p["timestamp"].as_f64().unwrap_or(0.0),
311                    method: req["method"].as_str().unwrap_or("GET").to_string(),
312                    url: truncate(req["url"].as_str().unwrap_or_default(), URL_CAP),
313                    resource_type: p["type"].as_str().map(String::from),
314                    status: None,
315                    status_text: None,
316                    mime_type: None,
317                    from_cache: false,
318                    encoded_bytes: None,
319                    duration_ms: None,
320                    failed: None,
321                    state: NetState::Pending,
322                    page_url: String::new(),
323                    has_post_data: req["hasPostData"].as_bool().unwrap_or(false),
324                });
325            }
326            "Network.responseReceived" => {
327                let rid = p["requestId"].as_str().unwrap_or_default();
328                let rtype = p["type"].as_str().map(String::from);
329                if let Some(e) = tab.network_mut(rid) {
330                    apply_response(e, &p["response"]);
331                    if e.resource_type.is_none() {
332                        e.resource_type = rtype;
333                    }
334                }
335            }
336            "Network.loadingFinished" => {
337                let rid = p["requestId"].as_str().unwrap_or_default();
338                if let Some(e) = tab.network_mut(rid) {
339                    e.encoded_bytes = p["encodedDataLength"].as_f64().map(|b| b as u64);
340                    e.duration_ms = duration_ms(e.monotonic_start, p["timestamp"].as_f64());
341                    if e.state == NetState::Pending {
342                        e.state = NetState::Finished;
343                    }
344                }
345            }
346            "Network.loadingFailed" => {
347                let rid = p["requestId"].as_str().unwrap_or_default();
348                if let Some(e) = tab.network_mut(rid) {
349                    let mut why = p["errorText"].as_str().unwrap_or("failed").to_string();
350                    if p["canceled"].as_bool().unwrap_or(false) {
351                        why.push_str(" (canceled)");
352                    }
353                    if let Some(b) = p["blockedReason"].as_str() {
354                        why.push_str(&format!(" blocked: {b}"));
355                    }
356                    e.failed = Some(why);
357                    e.state = NetState::Failed;
358                    e.duration_ms = duration_ms(e.monotonic_start, p["timestamp"].as_f64());
359                }
360            }
361            "Page.frameNavigated" => {
362                let frame = &p["frame"];
363                if frame.get("parentId").is_none() {
364                    if let Some(u) = frame["url"].as_str() {
365                        tab.page_url = truncate(u, URL_CAP);
366                    }
367                }
368            }
369            _ => {}
370        }
371    }
372}
373
374impl HubInner {
375    /// Route one WebDriver BiDi event. The browsing-context id is the
376    /// target id, so no session map is involved; events for untouched
377    /// contexts (including child frames) cost one hash miss.
378    fn route_bidi(&mut self, ev: BidiEvent) {
379        let p = &ev.params;
380        let ctx = match ev.method.as_str() {
381            "log.entryAdded" => p["source"]["context"].as_str(),
382            _ => p["context"].as_str(),
383        };
384        let Some(ctx) = ctx else {
385            return;
386        };
387        if ev.method == "browsingContext.contextDestroyed" {
388            self.tabs.remove(ctx);
389            return;
390        }
391        let Some(tab) = self.tabs.get_mut(ctx) else {
392            return;
393        };
394        match ev.method.as_str() {
395            "log.entryAdded" => {
396                if let Some(e) = console_entry_from_bidi(p) {
397                    tab.push_console(e);
398                }
399            }
400            "network.beforeRequestSent" => {
401                let req = &p["request"];
402                let rid = req["request"].as_str().unwrap_or_default().to_string();
403                let start = bidi_secs(p);
404                if p["redirectCount"].as_u64().unwrap_or(0) > 0 {
405                    if let Some(prev) = tab.network_mut(&rid) {
406                        if prev.state != NetState::Redirected {
407                            prev.state = NetState::Redirected;
408                            if prev.duration_ms.is_none() {
409                                prev.duration_ms = duration_ms(prev.monotonic_start, start);
410                            }
411                        }
412                    }
413                }
414                let is_navigation = p["navigation"].as_str().is_some();
415                tab.push_network(NetworkEntry {
416                    seq: 0,
417                    request_id: rid,
418                    ts_ms: p["timestamp"].as_f64().unwrap_or(0.0),
419                    monotonic_start: start.unwrap_or(0.0),
420                    method: req["method"].as_str().unwrap_or("GET").to_string(),
421                    url: truncate(req["url"].as_str().unwrap_or_default(), URL_CAP),
422                    resource_type: bidi_resource_type(
423                        req,
424                        is_navigation,
425                        p["initiator"]["type"].as_str(),
426                        None,
427                    ),
428                    status: None,
429                    status_text: None,
430                    mime_type: None,
431                    from_cache: false,
432                    encoded_bytes: None,
433                    duration_ms: None,
434                    failed: None,
435                    state: NetState::Pending,
436                    page_url: String::new(),
437                    has_post_data: req["bodySize"].as_u64().unwrap_or(0) > 0,
438                });
439            }
440            "network.responseCompleted" => {
441                let rid = p["request"]["request"].as_str().unwrap_or_default();
442                let end = bidi_secs(p);
443                if let Some(e) = tab.network_mut(rid) {
444                    apply_bidi_response(e, &p["response"]);
445                    e.duration_ms = duration_ms(e.monotonic_start, end);
446                    if e.resource_type.is_none() {
447                        e.resource_type =
448                            bidi_resource_type(&p["request"], false, None, e.mime_type.as_deref());
449                    }
450                    if e.state == NetState::Pending {
451                        e.state = NetState::Finished;
452                    }
453                }
454            }
455            "network.fetchError" => {
456                let rid = p["request"]["request"].as_str().unwrap_or_default();
457                let end = bidi_secs(p);
458                if let Some(e) = tab.network_mut(rid) {
459                    e.failed = Some(p["errorText"].as_str().unwrap_or("failed").to_string());
460                    e.state = NetState::Failed;
461                    e.duration_ms = duration_ms(e.monotonic_start, end);
462                }
463            }
464            "browsingContext.navigationStarted" => {
465                if let Some(u) = p["url"].as_str() {
466                    tab.page_url = truncate(u, URL_CAP);
467                }
468            }
469            _ => {}
470        }
471    }
472}
473
474/// BiDi timestamps are epoch milliseconds; the shared `duration_ms` works
475/// in seconds.
476fn bidi_secs(p: &Value) -> Option<f64> {
477    p["timestamp"].as_f64().map(|t| t / 1000.0)
478}
479
480fn apply_bidi_response(e: &mut NetworkEntry, r: &Value) {
481    e.status = r["status"].as_u64().map(|s| s as u16);
482    e.status_text = r["statusText"]
483        .as_str()
484        .filter(|s| !s.is_empty())
485        .map(String::from);
486    e.mime_type = r["mimeType"]
487        .as_str()
488        .filter(|s| !s.is_empty())
489        .map(String::from);
490    e.from_cache = r["fromCache"].as_bool().unwrap_or(false);
491    e.encoded_bytes = r["bytesReceived"].as_f64().map(|b| b as u64);
492}
493
494/// Derive a CDP-style resource label for a BiDi request so
495/// `resource_type` filters behave the same on both engines. Firefox
496/// hard-codes `initiator.type = "other"`, so it is only consulted for
497/// preflights; `request.initiatorType` / `destination` (Firefox 129+) and,
498/// at response time, the MIME type do the real work.
499fn bidi_resource_type(
500    req: &Value,
501    is_navigation: bool,
502    initiator_type: Option<&str>,
503    mime: Option<&str>,
504) -> Option<String> {
505    if is_navigation {
506        return Some("Document".into());
507    }
508    if initiator_type == Some("preflight") {
509        return Some("Preflight".into());
510    }
511    let destination = req["destination"].as_str().unwrap_or("");
512    let by_initiator = match req["initiatorType"].as_str().unwrap_or("") {
513        "xmlhttprequest" => Some("XHR"),
514        "fetch" => Some("Fetch"),
515        "script" => Some("Script"),
516        "css" => Some("Stylesheet"),
517        "img" | "image" | "input" => Some("Image"),
518        "font" => Some("Font"),
519        "iframe" | "frame" => Some("Document"),
520        "beacon" => Some("Ping"),
521        "audio" | "video" | "track" => Some("Media"),
522        "link" if destination == "style" => Some("Stylesheet"),
523        _ => None,
524    };
525    if let Some(t) = by_initiator {
526        return Some(t.into());
527    }
528    let by_destination = match destination {
529        "document" | "iframe" | "frame" => Some("Document"),
530        "script" | "worker" | "sharedworker" | "serviceworker" => Some("Script"),
531        "style" => Some("Stylesheet"),
532        "image" => Some("Image"),
533        "font" => Some("Font"),
534        "manifest" => Some("Manifest"),
535        "audio" | "video" => Some("Media"),
536        _ => None,
537    };
538    if let Some(t) = by_destination {
539        return Some(t.into());
540    }
541    let m = mime?.to_ascii_lowercase();
542    let by_mime = if m.starts_with("text/css") {
543        "Stylesheet"
544    } else if m.contains("javascript") || m.contains("ecmascript") {
545        "Script"
546    } else if m.starts_with("image/") {
547        "Image"
548    } else if m.starts_with("font/") || m.starts_with("application/font") {
549        "Font"
550    } else if m.starts_with("application/json") {
551        "Fetch"
552    } else {
553        return None;
554    };
555    Some(by_mime.into())
556}
557
558/// `log.entryAdded` → console entry. Console entries take their level
559/// from the console method (BiDi reports `console.log` as `info`);
560/// JavaScript errors become `exception` entries with a rendered stack.
561fn console_entry_from_bidi(p: &Value) -> Option<ConsoleEntry> {
562    let fallback_level = match p["level"].as_str() {
563        Some("error") => Level::Error,
564        Some("warn") => Level::Warn,
565        Some("debug") => Level::Debug,
566        _ => Level::Info,
567    };
568    let (level, source, text, stack) = if p["type"].as_str() == Some("javascript") {
569        let full = p["text"].as_str().unwrap_or("Uncaught exception");
570        let first = full.lines().next().unwrap_or(full).to_string();
571        (
572            Level::Error,
573            "exception".to_string(),
574            first,
575            Some(bidi_stack_string(full, &p["stackTrace"])),
576        )
577    } else {
578        let method = p["method"].as_str().unwrap_or("log");
579        let level = match method {
580            "error" | "assert" => Level::Error,
581            "warn" => Level::Warn,
582            "info" => Level::Info,
583            "debug" | "trace" => Level::Debug,
584            "clear" | "group" | "groupCollapsed" | "groupEnd" | "profile" | "profileEnd" => {
585                return None
586            }
587            "log" | "dir" | "dirxml" | "table" | "count" | "countReset" | "timeEnd" | "timeLog" => {
588                Level::Log
589            }
590            _ => fallback_level,
591        };
592        // `warn` → `console.warning` so a `pattern` matches on both engines.
593        let source = format!(
594            "console.{}",
595            if method == "warn" { "warning" } else { method }
596        );
597        // Firefox formats object arguments in `text` as `[object Object]`,
598        // so render the structured `args` when present and fall back to
599        // `text` only when the entry carries none.
600        let text = match p["args"].as_array().filter(|a| !a.is_empty()) {
601            Some(args) => args
602                .iter()
603                .map(|a| render_bidi_value(a, false))
604                .collect::<Vec<_>>()
605                .join(" "),
606            None => p["text"].as_str().unwrap_or_default().to_string(),
607        };
608        (level, source, text, None)
609    };
610    let (url, line, column) = location(&p["stackTrace"]["callFrames"][0]);
611    Some(ConsoleEntry {
612        seq: 0,
613        ts_ms: p["timestamp"].as_f64().unwrap_or(0.0),
614        level,
615        source,
616        text: truncate(&text, CONSOLE_TEXT_CAP),
617        url,
618        line,
619        column,
620        page_url: String::new(),
621        stack,
622        network_request_id: None,
623    })
624}
625
626/// `text` plus one `    at fn (url:line:col)` line per BiDi stack frame
627/// (1-based), mirroring CDP's `exception.description`.
628fn bidi_stack_string(text: &str, stack: &Value) -> String {
629    let mut out = text.to_string();
630    if let Some(frames) = stack["callFrames"].as_array() {
631        for f in frames {
632            let (url, line, col) = location(f);
633            out.push_str(&format!(
634                "\n    at {} ({}:{}:{})",
635                f["functionName"].as_str().unwrap_or("<anonymous>"),
636                url.unwrap_or_default(),
637                line.unwrap_or(0),
638                col.unwrap_or(0)
639            ));
640        }
641    }
642    out
643}
644
645/// Render a BiDi `script.RemoteValue` for console output.
646pub fn render_bidi_remote_value(v: &Value) -> String {
647    render_bidi_value(v, false)
648}
649
650fn render_bidi_value(v: &Value, nested: bool) -> String {
651    let join = |items: &[Value]| {
652        items
653            .iter()
654            .map(|i| render_bidi_value(i, true))
655            .collect::<Vec<_>>()
656            .join(", ")
657    };
658    let pairs = |items: &[Value], sep: &str| {
659        items
660            .iter()
661            .map(|pair| {
662                let k = match pair.get(0) {
663                    Some(Value::String(s)) => s.clone(),
664                    Some(other) => render_bidi_value(other, true),
665                    None => String::new(),
666                };
667                let val = pair
668                    .get(1)
669                    .map(|x| render_bidi_value(x, true))
670                    .unwrap_or_default();
671                format!("{k}{sep}{val}")
672            })
673            .collect::<Vec<_>>()
674            .join(", ")
675    };
676    match v["type"].as_str().unwrap_or("undefined") {
677        "string" => {
678            let s = v["value"].as_str().unwrap_or("");
679            if nested {
680                json!(s).to_string()
681            } else {
682                s.to_string()
683            }
684        }
685        "number" => match &v["value"] {
686            Value::Number(n) => n.to_string(),
687            Value::String(s) => s.clone(),
688            _ => "NaN".into(),
689        },
690        "boolean" => v["value"]
691            .as_bool()
692            .map(|b| b.to_string())
693            .unwrap_or_default(),
694        "null" => "null".into(),
695        "undefined" => "undefined".into(),
696        "bigint" => format!("{}n", v["value"].as_str().unwrap_or("0")),
697        "array" => match v["value"].as_array() {
698            Some(items) => format!("[{}]", join(items)),
699            None => "Array".into(),
700        },
701        "object" => match v["value"].as_array() {
702            Some(items) => format!("{{{}}}", pairs(items, ": ")),
703            None => "Object".into(),
704        },
705        "map" => match v["value"].as_array() {
706            Some(items) => format!("Map {{{}}}", pairs(items, " => ")),
707            None => "Map".into(),
708        },
709        "set" => match v["value"].as_array() {
710            Some(items) => format!("Set {{{}}}", join(items)),
711            None => "Set".into(),
712        },
713        "regexp" => format!(
714            "/{}/{}",
715            v["value"]["pattern"].as_str().unwrap_or(""),
716            v["value"]["flags"].as_str().unwrap_or("")
717        ),
718        "date" => v["value"].as_str().unwrap_or("Date").to_string(),
719        "error" => "Error".into(),
720        "node" => v["value"]["localName"]
721            .as_str()
722            .map(|n| format!("<{n}>"))
723            .unwrap_or_else(|| "Node".into()),
724        "function" => "function".into(),
725        other => other.to_string(),
726    }
727}
728
729fn duration_ms(start: f64, end: Option<f64>) -> Option<f64> {
730    end.filter(|_| start > 0.0)
731        .map(|e| ((e - start) * 1000.0).max(0.0))
732}
733
734fn apply_response(e: &mut NetworkEntry, r: &Value) {
735    e.status = r["status"].as_u64().map(|s| s as u16);
736    e.status_text = r["statusText"]
737        .as_str()
738        .filter(|s| !s.is_empty())
739        .map(String::from);
740    e.mime_type = r["mimeType"]
741        .as_str()
742        .filter(|s| !s.is_empty())
743        .map(String::from);
744    e.from_cache = r["fromDiskCache"].as_bool().unwrap_or(false)
745        || r["fromServiceWorker"].as_bool().unwrap_or(false)
746        || r["fromPrefetchCache"].as_bool().unwrap_or(false);
747}
748
749fn truncate(s: &str, max: usize) -> String {
750    if s.len() <= max {
751        return s.to_string();
752    }
753    let mut cut = max;
754    while cut > 0 && !s.is_char_boundary(cut) {
755        cut -= 1;
756    }
757    format!("{}…", &s[..cut])
758}
759
760fn location(frame: &Value) -> (Option<String>, Option<u32>, Option<u32>) {
761    let url = frame["url"]
762        .as_str()
763        .filter(|u| !u.is_empty())
764        .map(|u| truncate(u, URL_CAP));
765    let line = frame["lineNumber"].as_u64().map(|l| l as u32 + 1);
766    let col = frame["columnNumber"].as_u64().map(|c| c as u32 + 1);
767    (url, line, col)
768}
769
770fn console_entry_from_api(p: &Value) -> Option<ConsoleEntry> {
771    let kind = p["type"].as_str().unwrap_or("log");
772    let level = match kind {
773        "error" | "assert" => Level::Error,
774        "warning" => Level::Warn,
775        "info" => Level::Info,
776        "debug" => Level::Debug,
777        "clear" | "startGroup" | "startGroupCollapsed" | "endGroup" | "profile" | "profileEnd" => {
778            return None
779        }
780        _ => Level::Log,
781    };
782    let text = p["args"]
783        .as_array()
784        .map(|args| {
785            args.iter()
786                .map(render_remote_object)
787                .collect::<Vec<_>>()
788                .join(" ")
789        })
790        .unwrap_or_default();
791    let (url, line, column) = location(&p["stackTrace"]["callFrames"][0]);
792    Some(ConsoleEntry {
793        seq: 0,
794        ts_ms: p["timestamp"].as_f64().unwrap_or(0.0),
795        level,
796        source: format!("console.{kind}"),
797        text: truncate(&text, CONSOLE_TEXT_CAP),
798        url,
799        line,
800        column,
801        page_url: String::new(),
802        stack: None,
803        network_request_id: None,
804    })
805}
806
807fn console_entry_from_exception(p: &Value) -> ConsoleEntry {
808    let d = &p["exceptionDetails"];
809    let description = d["exception"]["description"]
810        .as_str()
811        .filter(|s| !s.is_empty())
812        .map(String::from);
813    let text = description
814        .as_deref()
815        .and_then(|s| s.lines().next())
816        .filter(|s| !s.is_empty())
817        .map(String::from)
818        .unwrap_or_else(|| {
819            d["text"]
820                .as_str()
821                .unwrap_or("Uncaught exception")
822                .to_string()
823        });
824    let (mut url, mut line, mut column) = location(&d["stackTrace"]["callFrames"][0]);
825    if url.is_none() {
826        url = d["url"]
827            .as_str()
828            .filter(|u| !u.is_empty())
829            .map(String::from);
830        line = d["lineNumber"].as_u64().map(|l| l as u32 + 1);
831        column = d["columnNumber"].as_u64().map(|c| c as u32 + 1);
832    }
833    ConsoleEntry {
834        seq: 0,
835        ts_ms: p["timestamp"].as_f64().unwrap_or(0.0),
836        level: Level::Error,
837        source: "exception".into(),
838        text: truncate(&text, CONSOLE_TEXT_CAP),
839        url,
840        line,
841        column,
842        page_url: String::new(),
843        stack: description,
844        network_request_id: None,
845    }
846}
847
848fn console_entry_from_log(p: &Value) -> Option<ConsoleEntry> {
849    let e = &p["entry"];
850    let level = match e["level"].as_str().unwrap_or("info") {
851        "error" => Level::Error,
852        "warning" => Level::Warn,
853        "verbose" => Level::Debug,
854        _ => Level::Info,
855    };
856    let text = e["text"].as_str()?.to_string();
857    let (mut url, mut line, mut column) = location(&e["stackTrace"]["callFrames"][0]);
858    if url.is_none() {
859        url = e["url"]
860            .as_str()
861            .filter(|u| !u.is_empty())
862            .map(|u| truncate(u, URL_CAP));
863        line = e["lineNumber"].as_u64().map(|l| l as u32 + 1);
864        column = None;
865    }
866    Some(ConsoleEntry {
867        seq: 0,
868        ts_ms: e["timestamp"].as_f64().unwrap_or(0.0),
869        level,
870        source: e["source"].as_str().unwrap_or("other").to_string(),
871        text: truncate(&text, CONSOLE_TEXT_CAP),
872        url,
873        line,
874        column,
875        page_url: String::new(),
876        stack: None,
877        network_request_id: e["networkRequestId"].as_str().map(String::from),
878    })
879}
880
881/// Render a `Runtime.RemoteObject` the way DevTools' console preview
882/// does, without any round trip: primitives verbatim, objects from their
883/// `preview`, everything else by `description`.
884pub fn render_remote_object(o: &Value) -> String {
885    if o["type"] == "string" {
886        if let Some(s) = o["value"].as_str() {
887            return s.to_string();
888        }
889    }
890    if let Some(v) = o.get("value") {
891        if !v.is_null() || o["type"] == "object" {
892            return v.to_string();
893        }
894    }
895    if let Some(u) = o["unserializableValue"].as_str() {
896        return u.to_string();
897    }
898    if let Some(preview) = o.get("preview") {
899        return render_preview(preview);
900    }
901    if let Some(d) = o["description"].as_str() {
902        return d.to_string();
903    }
904    o["type"].as_str().unwrap_or("undefined").to_string()
905}
906
907fn render_preview(preview: &Value) -> String {
908    if preview["subtype"] == "error" {
909        if let Some(d) = preview["description"].as_str() {
910            return d.lines().next().unwrap_or(d).to_string();
911        }
912    }
913    let is_array = preview["subtype"] == "array";
914    let props: Vec<String> = preview["properties"]
915        .as_array()
916        .map(|ps| {
917            ps.iter()
918                .map(|p| {
919                    let val = match p.get("valuePreview") {
920                        Some(vp) => render_preview(vp),
921                        None => match p["type"].as_str() {
922                            Some("string") => json!(p["value"].as_str().unwrap_or("")).to_string(),
923                            Some("object") | Some("function") => p["value"]
924                                .as_str()
925                                .map(String::from)
926                                .unwrap_or_else(|| p["type"].as_str().unwrap_or("").to_string()),
927                            _ => p["value"].as_str().unwrap_or("undefined").to_string(),
928                        },
929                    };
930                    if is_array {
931                        val
932                    } else {
933                        format!("{}: {val}", p["name"].as_str().unwrap_or("?"))
934                    }
935                })
936                .collect()
937        })
938        .unwrap_or_default();
939    let overflow = preview["overflow"].as_bool().unwrap_or(false);
940    let mut body = props.join(", ");
941    if overflow {
942        body.push_str(if props.is_empty() { "…" } else { ", …" });
943    }
944    if is_array {
945        format!("[{body}]")
946    } else {
947        let desc = preview["description"].as_str().unwrap_or("Object");
948        if desc == "Object" {
949            format!("{{{body}}}")
950        } else {
951            format!("{desc} {{{body}}}")
952        }
953    }
954}
955
956/// Epoch milliseconds → `YYYY-MM-DDTHH:MM:SS.mmmZ`.
957pub fn fmt_iso_ms(ts_ms: f64) -> String {
958    if ts_ms <= 0.0 {
959        return "-".into();
960    }
961    let secs = (ts_ms / 1000.0).floor() as i64;
962    let millis = (ts_ms - secs as f64 * 1000.0).round().clamp(0.0, 999.0) as u32;
963    let base = crate::registry::format_unix_seconds_as_iso8601(secs);
964    format!("{}.{millis:03}Z", base.trim_end_matches('Z'))
965}
966
967fn fmt_bytes(n: u64) -> String {
968    if n < 1024 {
969        format!("{n}B")
970    } else if n < 1024 * 1024 {
971        format!("{:.1}KB", n as f64 / 1024.0)
972    } else {
973        format!("{:.1}MB", n as f64 / (1024.0 * 1024.0))
974    }
975}
976
977/// One console entry as a single text line (no page separator).
978pub fn format_console_line(e: &ConsoleEntry) -> String {
979    let loc = match (&e.url, e.line, e.column) {
980        (Some(u), Some(l), Some(c)) => format!("{u}:{l}:{c}"),
981        (Some(u), Some(l), None) => format!("{u}:{l}"),
982        (Some(u), None, _) => u.clone(),
983        (None, _, _) => e.source.clone(),
984    };
985    format!(
986        "[{}] {} {loc}  {}",
987        e.level.label(),
988        fmt_iso_ms(e.ts_ms),
989        e.text.replace('\n', "\\n")
990    )
991}
992
993/// One network entry as a single text line.
994pub fn format_network_line(e: &NetworkEntry) -> String {
995    let outcome = match e.state {
996        NetState::Pending => "→ pending".to_string(),
997        NetState::Failed => format!("→ failed {}", e.failed.as_deref().unwrap_or("")),
998        NetState::Finished | NetState::Redirected => {
999            let mut s = format!(
1000                "→ {}",
1001                e.status
1002                    .map(|s| s.to_string())
1003                    .unwrap_or_else(|| "?".into())
1004            );
1005            if let Some(m) = &e.mime_type {
1006                s.push(' ');
1007                s.push_str(m);
1008            }
1009            if let Some(b) = e.encoded_bytes {
1010                s.push(' ');
1011                s.push_str(&fmt_bytes(b));
1012            }
1013            if let Some(d) = e.duration_ms {
1014                s.push_str(&format!(" {}ms", d.round() as u64));
1015            }
1016            if e.from_cache {
1017                s.push_str(" (cache)");
1018            }
1019            if e.state == NetState::Redirected {
1020                s.push_str(" [redirect]");
1021            }
1022            s
1023        }
1024    };
1025    let rtype = e
1026        .resource_type
1027        .as_deref()
1028        .map(|t| format!(" [{t}]"))
1029        .unwrap_or_default();
1030    format!(
1031        "{}  {:<6} {}  {outcome}{rtype}",
1032        e.request_id, e.method, e.url
1033    )
1034}
1035
1036/// Filters for `read_console`.
1037#[derive(Debug, Default)]
1038pub struct ConsoleQuery {
1039    pub pattern: Option<Regex>,
1040    pub only_errors: bool,
1041    pub limit: usize,
1042    pub clear: bool,
1043}
1044
1045#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1046pub enum StatusFilter {
1047    Exact(u16),
1048    /// `2xx` → 2, etc.
1049    Class(u16),
1050    Failed,
1051    Pending,
1052}
1053
1054impl StatusFilter {
1055    pub fn parse(s: &str) -> Result<Self> {
1056        let s = s.trim().to_ascii_lowercase();
1057        match s.as_str() {
1058            "failed" => return Ok(StatusFilter::Failed),
1059            "pending" => return Ok(StatusFilter::Pending),
1060            _ => {}
1061        }
1062        if let Some(cls) = s.strip_suffix("xx") {
1063            if let Ok(c) = cls.parse::<u16>() {
1064                if (1..=5).contains(&c) {
1065                    return Ok(StatusFilter::Class(c));
1066                }
1067            }
1068        }
1069        s.parse::<u16>().map(StatusFilter::Exact).map_err(|_| {
1070            anyhow!("`status` must be a code (404), a class (4xx), \"failed\", or \"pending\"")
1071        })
1072    }
1073
1074    fn matches(self, e: &NetworkEntry) -> bool {
1075        match self {
1076            StatusFilter::Failed => e.state == NetState::Failed,
1077            StatusFilter::Pending => e.state == NetState::Pending,
1078            StatusFilter::Exact(c) => e.status == Some(c),
1079            StatusFilter::Class(c) => e.status.is_some_and(|s| s / 100 == c),
1080        }
1081    }
1082}
1083
1084/// Filters for `read_network`.
1085#[derive(Debug, Default)]
1086pub struct NetworkQuery {
1087    pub url_pattern: Option<Regex>,
1088    pub method: Option<String>,
1089    pub status: Option<StatusFilter>,
1090    pub resource_type: Option<String>,
1091    pub limit: usize,
1092    pub clear: bool,
1093}
1094
1095/// Result of a console read.
1096#[derive(Debug, Serialize)]
1097pub struct ConsoleReport {
1098    pub target_id: String,
1099    pub page_url: String,
1100    pub matched: usize,
1101    pub buffered: usize,
1102    pub evicted: u64,
1103    pub lost_events: u64,
1104    pub entries: Vec<ConsoleEntry>,
1105}
1106
1107/// Result of a network read.
1108#[derive(Debug, Serialize)]
1109pub struct NetworkReport {
1110    pub target_id: String,
1111    pub page_url: String,
1112    pub matched: usize,
1113    pub buffered: usize,
1114    pub evicted: u64,
1115    pub lost_events: u64,
1116    pub entries: Vec<NetworkEntry>,
1117}
1118
1119/// Result of a body fetch.
1120#[derive(Debug)]
1121pub struct BodyResult {
1122    pub request_id: String,
1123    pub url: String,
1124    pub status: Option<u16>,
1125    pub mime_type: Option<String>,
1126    pub bytes: Vec<u8>,
1127    pub total_bytes: usize,
1128    pub truncated: bool,
1129}
1130
1131/// Counters shared by the console and network header lines.
1132struct HeaderStats<'a> {
1133    target_id: &'a str,
1134    page_url: &'a str,
1135    shown: usize,
1136    matched: usize,
1137    buffered: usize,
1138    evicted: u64,
1139    lost: u64,
1140}
1141
1142fn header_line(kind: &str, s: &HeaderStats<'_>) -> String {
1143    format!(
1144        "{kind} tab={} page={}  showing {} of {} matched ({} buffered, {} evicted, {} events lost)\n",
1145        s.target_id,
1146        if s.page_url.is_empty() {
1147            "-"
1148        } else {
1149            s.page_url
1150        },
1151        s.shown,
1152        s.matched,
1153        s.buffered,
1154        s.evicted,
1155        s.lost
1156    )
1157}
1158
1159/// Render a console report as text with `-- page: <url> --` separators.
1160pub fn format_console_text(r: &ConsoleReport) -> String {
1161    let mut out = header_line(
1162        "console",
1163        &HeaderStats {
1164            target_id: &r.target_id,
1165            page_url: &r.page_url,
1166            shown: r.entries.len(),
1167            matched: r.matched,
1168            buffered: r.buffered,
1169            evicted: r.evicted,
1170            lost: r.lost_events,
1171        },
1172    );
1173    let mut last_page: Option<&str> = None;
1174    for e in &r.entries {
1175        if last_page != Some(e.page_url.as_str()) {
1176            out.push_str(&format!("-- page: {} --\n", e.page_url));
1177            last_page = Some(&e.page_url);
1178        }
1179        out.push_str(&format_console_line(e));
1180        out.push('\n');
1181    }
1182    out
1183}
1184
1185/// Render a network report as text.
1186pub fn format_network_text(r: &NetworkReport) -> String {
1187    let mut out = header_line(
1188        "network",
1189        &HeaderStats {
1190            target_id: &r.target_id,
1191            page_url: &r.page_url,
1192            shown: r.entries.len(),
1193            matched: r.matched,
1194            buffered: r.buffered,
1195            evicted: r.evicted,
1196            lost: r.lost_events,
1197        },
1198    );
1199    let mut last_page: Option<&str> = None;
1200    for e in &r.entries {
1201        if last_page != Some(e.page_url.as_str()) {
1202            out.push_str(&format!("-- page: {} --\n", e.page_url));
1203            last_page = Some(&e.page_url);
1204        }
1205        out.push_str(&format_network_line(e));
1206        out.push('\n');
1207    }
1208    out
1209}
1210
1211/// The capture hub. Cheap to clone via `Arc` on `ServerState`.
1212pub struct CaptureHub {
1213    inner: Arc<Mutex<HubInner>>,
1214}
1215
1216impl Default for CaptureHub {
1217    fn default() -> Self {
1218        Self::new()
1219    }
1220}
1221
1222impl Drop for CaptureHub {
1223    fn drop(&mut self) {
1224        if let Ok(mut g) = self.inner.lock() {
1225            if let Some(h) = g.router.take() {
1226                h.abort();
1227            }
1228        }
1229    }
1230}
1231
1232fn capture_disabled_error() -> anyhow::Error {
1233    anyhow!("console/network capture is disabled for this MCP server (BROWSER_CONTROL_CAPTURE=0); unset it and restart the server to capture")
1234}
1235
1236fn no_capture_error(target_id: &str) -> anyhow::Error {
1237    anyhow!(
1238        "no capture for tab {target_id}: the MCP server attaches to a tab when a tool first touches it (browser_navigate, browser_tab_select, …); navigate or select the tab, act, then read again. Capture runs on Chromium (CDP) and Firefox (BiDi) and can be disabled with BROWSER_CONTROL_CAPTURE=0"
1239    )
1240}
1241
1242impl CaptureHub {
1243    pub fn new() -> Self {
1244        let disabled = std::env::var("BROWSER_CONTROL_CAPTURE")
1245            .map(|v| {
1246                let v = v.trim().to_ascii_lowercase();
1247                v == "0" || v == "false" || v == "off"
1248            })
1249            .unwrap_or(false);
1250        Self {
1251            inner: Arc::new(Mutex::new(HubInner {
1252                disabled,
1253                ..Default::default()
1254            })),
1255        }
1256    }
1257
1258    fn lock(&self) -> std::sync::MutexGuard<'_, HubInner> {
1259        self.inner.lock().unwrap_or_else(|p| p.into_inner())
1260    }
1261
1262    /// Whether capture is disabled by environment.
1263    pub fn disabled(&self) -> bool {
1264        self.lock().disabled
1265    }
1266
1267    /// Start capturing `target_id` if not already. Synchronous and
1268    /// non-blocking: the attach (CDP) or subscribe + seed (BiDi) runs on a
1269    /// background task.
1270    pub fn touch(&self, backend: &TabBackend, target_id: &str) {
1271        let weak = Arc::downgrade(&self.inner);
1272        let mut g = self.lock();
1273        if g.disabled || g.tabs.contains_key(target_id) {
1274            return;
1275        }
1276        g.tabs.insert(target_id.to_string(), TabCapture::new());
1277        match backend {
1278            TabBackend::Cdp(client) => {
1279                if g.router.is_none() {
1280                    let rx = client.subscribe();
1281                    g.router = Some(tokio::spawn(run_router(rx, weak.clone(), HubInner::route)));
1282                }
1283                drop(g);
1284                tokio::spawn(attach_task(client.clone(), target_id.to_string(), weak));
1285            }
1286            TabBackend::Bidi(client) => {
1287                if g.router.is_none() {
1288                    let rx = client.subscribe();
1289                    g.router = Some(tokio::spawn(run_router(
1290                        rx,
1291                        weak.clone(),
1292                        HubInner::route_bidi,
1293                    )));
1294                }
1295                let cell = g
1296                    .bidi
1297                    .get_or_insert_with(|| Arc::new(OnceCell::new()))
1298                    .clone();
1299                drop(g);
1300                tokio::spawn(bidi_attach_task(
1301                    client.clone(),
1302                    target_id.to_string(),
1303                    cell,
1304                    weak,
1305                ));
1306            }
1307        }
1308    }
1309
1310    /// `touch`, then wait (bounded by [`TOUCH_WAIT`]) until the tab's
1311    /// domains are enabled so events from the caller's next action are
1312    /// captured. Never errors.
1313    pub async fn touch_and_wait(&self, backend: &TabBackend, target_id: &str) {
1314        self.touch(backend, target_id);
1315        self.wait_ready(target_id).await;
1316    }
1317
1318    async fn wait_ready(&self, target_id: &str) {
1319        let deadline = tokio::time::Instant::now() + TOUCH_WAIT;
1320        loop {
1321            let notify = {
1322                let g = self.lock();
1323                match g.tabs.get(target_id) {
1324                    None => return,
1325                    Some(t) if t.ready => return,
1326                    Some(t) => t.notify.clone(),
1327                }
1328            };
1329            let notified = notify.notified();
1330            tokio::pin!(notified);
1331            notified.as_mut().enable();
1332            // Re-check after arming so a completion between the two locks
1333            // cannot be missed.
1334            {
1335                let g = self.lock();
1336                match g.tabs.get(target_id) {
1337                    None => return,
1338                    Some(t) if t.ready => return,
1339                    Some(_) => {}
1340                }
1341            }
1342            if tokio::time::timeout_at(deadline, notified).await.is_err() {
1343                return;
1344            }
1345        }
1346    }
1347
1348    /// Drop the state for a closed tab and detach the hub session.
1349    pub fn forget(&self, backend: &TabBackend, target_id: &str) {
1350        let sid = {
1351            let mut g = self.lock();
1352            let sid = g.tabs.remove(target_id).and_then(|t| t.session_id);
1353            g.sessions.retain(|_, t| t != target_id);
1354            sid
1355        };
1356        if let (Some(sid), TabBackend::Cdp(client)) = (sid, backend) {
1357            let client = client.clone();
1358            tokio::spawn(async move {
1359                let _ = client
1360                    .send("Target.detachFromTarget", json!({ "sessionId": sid }))
1361                    .await;
1362            });
1363        }
1364    }
1365
1366    /// Forget everything (browser switch). No RPCs: dropping the backend
1367    /// closes the socket, and the browser discards its sessions and
1368    /// subscriptions.
1369    pub fn reset(&self) {
1370        let mut g = self.lock();
1371        if let Some(h) = g.router.take() {
1372            h.abort();
1373        }
1374        g.tabs.clear();
1375        g.sessions.clear();
1376        g.bidi = None;
1377        g.lost_events = 0;
1378    }
1379
1380    /// Number of tabs currently captured (diagnostics / tests).
1381    pub fn captured_tabs(&self) -> Vec<String> {
1382        self.lock().tabs.keys().cloned().collect()
1383    }
1384
1385    /// Read (and optionally clear) the console buffer of a tab.
1386    pub async fn read_console(&self, target_id: &str, q: &ConsoleQuery) -> Result<ConsoleReport> {
1387        self.wait_ready(target_id).await;
1388        let mut g = self.lock();
1389        if g.disabled {
1390            return Err(capture_disabled_error());
1391        }
1392        let lost = g.lost_events;
1393        let tab = g
1394            .tabs
1395            .get_mut(target_id)
1396            .ok_or_else(|| no_capture_error(target_id))?;
1397        let buffered = tab.console.len();
1398        let all: Vec<&ConsoleEntry> = tab
1399            .console
1400            .iter()
1401            .filter(|e| !q.only_errors || e.level == Level::Error)
1402            .filter(|e| match &q.pattern {
1403                Some(re) => re.is_match(&format!("{} {}", format_console_line(e), e.page_url)),
1404                None => true,
1405            })
1406            .collect();
1407        let matched = all.len();
1408        let skip = matched.saturating_sub(q.limit);
1409        let entries: Vec<ConsoleEntry> = all.into_iter().skip(skip).cloned().collect();
1410        let report = ConsoleReport {
1411            target_id: target_id.to_string(),
1412            page_url: tab.page_url.clone(),
1413            matched,
1414            buffered,
1415            evicted: tab.dropped_console,
1416            lost_events: lost,
1417            entries,
1418        };
1419        if q.clear {
1420            tab.console.clear();
1421            tab.dropped_console = 0;
1422        }
1423        Ok(report)
1424    }
1425
1426    /// Read (and optionally clear) the network buffer of a tab.
1427    pub async fn read_network(&self, target_id: &str, q: &NetworkQuery) -> Result<NetworkReport> {
1428        self.wait_ready(target_id).await;
1429        let mut g = self.lock();
1430        if g.disabled {
1431            return Err(capture_disabled_error());
1432        }
1433        let lost = g.lost_events;
1434        let network_armed = g
1435            .bidi
1436            .as_ref()
1437            .and_then(|c| c.get())
1438            .map(|c| c.network)
1439            .unwrap_or(true);
1440        let tab = g
1441            .tabs
1442            .get_mut(target_id)
1443            .ok_or_else(|| no_capture_error(target_id))?;
1444        if !network_armed {
1445            return Err(anyhow!(
1446                "network capture is unavailable on this Firefox: session.subscribe for network.* was rejected (needs Firefox 124 or newer); console capture still works"
1447            ));
1448        }
1449        let buffered = tab.network.len();
1450        let method = q.method.as_ref().map(|m| m.to_ascii_uppercase());
1451        let rtype = q.resource_type.as_ref().map(|t| t.to_ascii_lowercase());
1452        let all: Vec<&NetworkEntry> = tab
1453            .network
1454            .iter()
1455            .filter(|e| match &q.url_pattern {
1456                Some(re) => re.is_match(&e.url),
1457                None => true,
1458            })
1459            .filter(|e| match &method {
1460                Some(m) => e.method.eq_ignore_ascii_case(m),
1461                None => true,
1462            })
1463            .filter(|e| match &rtype {
1464                Some(t) => e
1465                    .resource_type
1466                    .as_deref()
1467                    .is_some_and(|r| r.eq_ignore_ascii_case(t)),
1468                None => true,
1469            })
1470            .filter(|e| q.status.map(|s| s.matches(e)).unwrap_or(true))
1471            .collect();
1472        let matched = all.len();
1473        let skip = matched.saturating_sub(q.limit);
1474        let entries: Vec<NetworkEntry> = all.into_iter().skip(skip).cloned().collect();
1475        let report = NetworkReport {
1476            target_id: target_id.to_string(),
1477            page_url: tab.page_url.clone(),
1478            matched,
1479            buffered,
1480            evicted: tab.dropped_network,
1481            lost_events: lost,
1482            entries,
1483        };
1484        if q.clear {
1485            tab.network.clear();
1486            tab.dropped_network = 0;
1487        }
1488        Ok(report)
1489    }
1490
1491    /// Fetch a captured response body through the hub's session.
1492    pub async fn response_body(
1493        &self,
1494        backend: &TabBackend,
1495        target_id: &str,
1496        request_id: &str,
1497        max_bytes: usize,
1498        timeout: Duration,
1499    ) -> Result<BodyResult> {
1500        let TabBackend::Cdp(client) = backend else {
1501            return Err(anyhow!("{BIDI_NO_BODIES_HINT}"));
1502        };
1503        self.wait_ready(target_id).await;
1504        let (sid, url, status, mime_type) = {
1505            let g = self.lock();
1506            let tab = g
1507                .tabs
1508                .get(target_id)
1509                .ok_or_else(|| no_capture_error(target_id))?;
1510            let sid = tab
1511                .session_id
1512                .clone()
1513                .ok_or_else(|| no_capture_error(target_id))?;
1514            let entry = tab
1515                .network
1516                .iter()
1517                .rev()
1518                .find(|e| e.request_id == request_id)
1519                .ok_or_else(|| {
1520                    anyhow!(
1521                        "unknown request id `{request_id}` for tab {target_id}: it was evicted from the {NETWORK_CAP}-entry buffer or belongs to another tab; list requests with browser_network_requests first"
1522                    )
1523                })?;
1524            if entry.state == NetState::Pending {
1525                return Err(anyhow!(
1526                    "response for request `{request_id}` has not finished yet; retry after the request completes"
1527                ));
1528            }
1529            (
1530                sid,
1531                entry.url.clone(),
1532                entry.status,
1533                entry.mime_type.clone(),
1534            )
1535        };
1536        let v = match tokio::time::timeout(
1537            timeout,
1538            client.send_with_session(
1539                "Network.getResponseBody",
1540                json!({ "requestId": request_id }),
1541                Some(&sid),
1542            ),
1543        )
1544        .await
1545        {
1546            Ok(Ok(v)) => v,
1547            Ok(Err(e)) => {
1548                let msg = format!("{e:#}").to_ascii_lowercase();
1549                if msg.contains("no resource with given identifier")
1550                    || msg.contains("no data found for resource")
1551                {
1552                    return Err(anyhow!(
1553                        "body for request `{request_id}` is no longer available in the browser (evicted after navigation or buffer overflow); re-issue the request and fetch the body promptly"
1554                    ));
1555                }
1556                return Err(e);
1557            }
1558            Err(_) => {
1559                return Err(anyhow!(
1560                    "Network.getResponseBody for `{request_id}` timed out after {:?}",
1561                    timeout
1562                ))
1563            }
1564        };
1565        let raw = v["body"].as_str().unwrap_or_default();
1566        let mut bytes = if v["base64Encoded"].as_bool().unwrap_or(false) {
1567            base64::engine::general_purpose::STANDARD
1568                .decode(raw)
1569                .map_err(|e| anyhow!("decoding response body: {e}"))?
1570        } else {
1571            raw.as_bytes().to_vec()
1572        };
1573        let total_bytes = bytes.len();
1574        let cap = max_bytes.min(BODY_HARD_MAX);
1575        let truncated = total_bytes > cap;
1576        if truncated {
1577            bytes.truncate(cap);
1578        }
1579        Ok(BodyResult {
1580            request_id: request_id.to_string(),
1581            url,
1582            status,
1583            mime_type,
1584            bytes,
1585            total_bytes,
1586            truncated,
1587        })
1588    }
1589}
1590
1591/// Attach to the target and enable the capture domains. Registers the
1592/// session id *before* enabling so replayed events are routed.
1593async fn attach_task(client: Arc<CdpClient>, target_id: String, weak: Weak<Mutex<HubInner>>) {
1594    let attempt = async {
1595        let sid = client.attach_to_target(&target_id).await?;
1596        let registered = {
1597            let Some(inner) = weak.upgrade() else {
1598                return Err(anyhow!("hub gone"));
1599            };
1600            let mut g = inner.lock().unwrap_or_else(|p| p.into_inner());
1601            match g.tabs.get_mut(&target_id) {
1602                Some(tab) => {
1603                    tab.session_id = Some(sid.clone());
1604                    g.sessions.insert(sid.clone(), target_id.clone());
1605                    true
1606                }
1607                None => false,
1608            }
1609        };
1610        if !registered {
1611            // Forgotten while attaching: release the session.
1612            let _ = client
1613                .send("Target.detachFromTarget", json!({ "sessionId": sid }))
1614                .await;
1615            return Err(anyhow!("tab forgotten during attach"));
1616        }
1617        // Seed the document URL *before* enabling domains: `Runtime.enable`
1618        // replays existing console messages immediately, and they must be
1619        // stamped with the page they came from.
1620        let page_url = client
1621            .send_with_session("Page.getNavigationHistory", json!({}), Some(&sid))
1622            .await
1623            .ok()
1624            .and_then(|v| {
1625                let idx = v["currentIndex"].as_u64()? as usize;
1626                v["entries"][idx]["url"].as_str().map(String::from)
1627            })
1628            .unwrap_or_default();
1629        if let Some(inner) = weak.upgrade() {
1630            let mut g = inner.lock().unwrap_or_else(|p| p.into_inner());
1631            if let Some(tab) = g.tabs.get_mut(&target_id) {
1632                if tab.page_url.is_empty() {
1633                    tab.page_url = truncate(&page_url, URL_CAP);
1634                }
1635            }
1636        }
1637        for (method, params) in [
1638            ("Inspector.enable", json!({})),
1639            ("Page.enable", json!({})),
1640            ("Runtime.enable", json!({})),
1641            ("Log.enable", json!({})),
1642            (
1643                "Network.enable",
1644                json!({
1645                    "maxTotalBufferSize": NET_MAX_TOTAL_BUFFER,
1646                    "maxResourceBufferSize": NET_MAX_RESOURCE_BUFFER,
1647                    "maxPostDataSize": 0,
1648                }),
1649            ),
1650        ] {
1651            if let Err(e) = client.send_with_session(method, params, Some(&sid)).await {
1652                tracing::debug!(target = %target_id, %method, error = %e, "capture enable failed");
1653            }
1654        }
1655        Ok::<_, anyhow::Error>(())
1656    };
1657    let outcome = tokio::time::timeout(ATTACH_TIMEOUT, attempt).await;
1658    finish_attach(&weak, &target_id, outcome);
1659}
1660
1661/// Shared tail of the attach tasks: mark the tab ready, or drop it so the
1662/// next touch retries. Waiters are notified either way.
1663fn finish_attach(
1664    weak: &Weak<Mutex<HubInner>>,
1665    target_id: &str,
1666    outcome: Result<Result<()>, tokio::time::error::Elapsed>,
1667) {
1668    let Some(inner) = weak.upgrade() else {
1669        return;
1670    };
1671    let mut g = inner.lock().unwrap_or_else(|p| p.into_inner());
1672    match outcome {
1673        Ok(Ok(())) => {
1674            if let Some(tab) = g.tabs.get_mut(target_id) {
1675                tab.ready = true;
1676                tab.notify.notify_waiters();
1677            }
1678            return;
1679        }
1680        Ok(Err(e)) => {
1681            tracing::debug!(target = %target_id, error = %e, "capture attach failed");
1682        }
1683        Err(_) => {
1684            tracing::debug!(target = %target_id, "capture attach timed out");
1685        }
1686    }
1687    if let Some(tab) = g.tabs.remove(target_id) {
1688        tab.notify.notify_waiters();
1689    }
1690    g.sessions.retain(|_, t| t != target_id);
1691}
1692
1693/// One global `session.subscribe` per BiDi backend. The console call is
1694/// required; the network call is optional so a Firefox older than 124
1695/// degrades to console-only capture.
1696async fn bidi_subscribe(client: Arc<BidiClient>) -> Result<BidiCapabilities> {
1697    client
1698        .send(
1699            "session.subscribe",
1700            json!({ "events": [
1701                "log.entryAdded",
1702                "browsingContext.navigationStarted",
1703                "browsingContext.contextDestroyed",
1704            ] }),
1705        )
1706        .await?;
1707    let network = match client
1708        .send(
1709            "session.subscribe",
1710            json!({ "events": [
1711                "network.beforeRequestSent",
1712                "network.responseCompleted",
1713                "network.fetchError",
1714            ] }),
1715        )
1716        .await
1717    {
1718        Ok(_) => true,
1719        Err(e) => {
1720            tracing::debug!(error = %e, "BiDi network capture unavailable");
1721            false
1722        }
1723    };
1724    Ok(BidiCapabilities { network })
1725}
1726
1727/// BiDi counterpart of `attach_task`: arm the global subscription (first
1728/// caller only) and seed the tab's document URL from `getTree`.
1729async fn bidi_attach_task(
1730    client: Arc<BidiClient>,
1731    target_id: String,
1732    sub: Arc<OnceCell<BidiCapabilities>>,
1733    weak: Weak<Mutex<HubInner>>,
1734) {
1735    let attempt = async {
1736        // Seed the document URL before arming the subscription so replayed
1737        // or immediate events are stamped with the right page. When the
1738        // subscription already exists (second tab), events may land in the
1739        // brief window before the seed; those are backfilled below.
1740        let v = client
1741            .send(
1742                "browsingContext.getTree",
1743                json!({ "root": target_id, "maxDepth": 0 }),
1744            )
1745            .await?;
1746        let page_url = truncate(
1747            v["contexts"][0]["url"].as_str().unwrap_or_default(),
1748            URL_CAP,
1749        );
1750        if let Some(inner) = weak.upgrade() {
1751            let mut g = inner.lock().unwrap_or_else(|p| p.into_inner());
1752            if let Some(tab) = g.tabs.get_mut(&target_id) {
1753                if tab.page_url.is_empty() {
1754                    tab.page_url = page_url.clone();
1755                }
1756                for e in tab.console.iter_mut().filter(|e| e.page_url.is_empty()) {
1757                    e.page_url = page_url.clone();
1758                }
1759                for e in tab.network.iter_mut().filter(|e| e.page_url.is_empty()) {
1760                    e.page_url = page_url.clone();
1761                }
1762            }
1763        }
1764        sub.get_or_try_init(|| bidi_subscribe(client.clone()))
1765            .await?;
1766        Ok::<_, anyhow::Error>(())
1767    };
1768    let outcome = tokio::time::timeout(ATTACH_TIMEOUT, attempt).await;
1769    finish_attach(&weak, &target_id, outcome);
1770}
1771
1772/// Drain a broadcast channel into the hub through `route`. Exits when the
1773/// socket closes or the hub is dropped.
1774async fn run_router<E: Clone + Send + 'static>(
1775    mut rx: broadcast::Receiver<E>,
1776    weak: Weak<Mutex<HubInner>>,
1777    route: fn(&mut HubInner, E),
1778) {
1779    loop {
1780        match rx.recv().await {
1781            Ok(ev) => {
1782                let Some(inner) = weak.upgrade() else {
1783                    return;
1784                };
1785                let mut g = inner.lock().unwrap_or_else(|p| p.into_inner());
1786                route(&mut g, ev);
1787            }
1788            Err(broadcast::error::RecvError::Lagged(n)) => {
1789                if let Some(inner) = weak.upgrade() {
1790                    let mut g = inner.lock().unwrap_or_else(|p| p.into_inner());
1791                    g.lost_events += n;
1792                }
1793            }
1794            Err(broadcast::error::RecvError::Closed) => {
1795                if let Some(inner) = weak.upgrade() {
1796                    let mut g = inner.lock().unwrap_or_else(|p| p.into_inner());
1797                    g.tabs.clear();
1798                    g.sessions.clear();
1799                    g.bidi = None;
1800                    g.router = None;
1801                }
1802                return;
1803            }
1804        }
1805    }
1806}
1807
1808#[cfg(test)]
1809mod tests {
1810    use super::*;
1811    use futures_util::{SinkExt, StreamExt};
1812    use tokio_tungstenite::tungstenite::Message;
1813
1814    fn ev(method: &str, session: Option<&str>, params: Value) -> CdpEvent {
1815        CdpEvent {
1816            method: method.into(),
1817            params,
1818            session_id: session.map(String::from),
1819        }
1820    }
1821
1822    fn hub_with_tab() -> HubInner {
1823        let mut h = HubInner::default();
1824        let mut tab = TabCapture::new();
1825        tab.session_id = Some("S9".into());
1826        tab.ready = true;
1827        tab.page_url = "https://app.test/x".into();
1828        h.tabs.insert("T1".into(), tab);
1829        h.sessions.insert("S9".into(), "T1".into());
1830        h
1831    }
1832
1833    #[test]
1834    fn console_api_renders_args_and_location() {
1835        let mut h = hub_with_tab();
1836        h.route(ev(
1837            "Runtime.consoleAPICalled",
1838            Some("S9"),
1839            json!({
1840                "type": "warning",
1841                "timestamp": 1756816496120.0,
1842                "args": [
1843                    {"type": "string", "value": "Deprecated"},
1844                    {"type": "number", "value": 3},
1845                    {"type": "object", "preview": {"description": "Object", "overflow": true,
1846                        "properties": [{"name": "a", "type": "number", "value": "1"},
1847                                       {"name": "b", "type": "string", "value": "x"}]}},
1848                    {"type": "object", "subtype": "array", "preview": {"subtype": "array", "overflow": false,
1849                        "properties": [{"name": "0", "type": "number", "value": "1"}]}},
1850                    {"type": "undefined"},
1851                    {"type": "number", "unserializableValue": "NaN"},
1852                    {"type": "function", "description": "function f() {}"}
1853                ],
1854                "stackTrace": {"callFrames": [{"url": "https://app.test/a.js", "lineNumber": 11, "columnNumber": 4}]}
1855            }),
1856        ));
1857        let tab = &h.tabs["T1"];
1858        assert_eq!(tab.console.len(), 1);
1859        let e = &tab.console[0];
1860        assert_eq!(e.level, Level::Warn);
1861        assert_eq!(e.source, "console.warning");
1862        assert_eq!(
1863            e.text,
1864            "Deprecated 3 {a: 1, b: \"x\", …} [1] undefined NaN function f() {}"
1865        );
1866        assert_eq!(e.line, Some(12));
1867        assert_eq!(e.column, Some(5));
1868        assert_eq!(e.page_url, "https://app.test/x");
1869        assert_eq!(
1870            format_console_line(e),
1871            "[warn] 2025-09-02T12:34:56.120Z https://app.test/a.js:12:5  Deprecated 3 {a: 1, b: \"x\", …} [1] undefined NaN function f() {}"
1872        );
1873    }
1874
1875    #[test]
1876    fn exception_uses_first_description_line_and_keeps_stack() {
1877        let mut h = hub_with_tab();
1878        h.route(ev(
1879            "Runtime.exceptionThrown",
1880            Some("S9"),
1881            json!({
1882                "timestamp": 1.0,
1883                "exceptionDetails": {
1884                    "text": "Uncaught",
1885                    "url": "https://app.test/a.js",
1886                    "lineNumber": 39, "columnNumber": 8,
1887                    "exception": {"description": "TypeError: x is not a function\n    at f (a.js:40:9)"}
1888                }
1889            }),
1890        ));
1891        let e = &h.tabs["T1"].console[0];
1892        assert_eq!(e.level, Level::Error);
1893        assert_eq!(e.source, "exception");
1894        assert_eq!(e.text, "TypeError: x is not a function");
1895        assert!(e.stack.as_deref().unwrap().contains("at f"));
1896        assert_eq!((e.line, e.column), (Some(40), Some(9)));
1897    }
1898
1899    #[test]
1900    fn log_entry_maps_levels_and_group_types_skipped() {
1901        let mut h = hub_with_tab();
1902        h.route(ev(
1903            "Log.entryAdded",
1904            Some("S9"),
1905            json!({"entry": {"source": "network", "level": "error", "text": "Failed to load resource: 404",
1906                             "timestamp": 2.0, "url": "https://app.test/api/me", "networkRequestId": "1.7"}}),
1907        ));
1908        h.route(ev(
1909            "Runtime.consoleAPICalled",
1910            Some("S9"),
1911            json!({"type": "startGroup", "args": [], "timestamp": 3.0}),
1912        ));
1913        let tab = &h.tabs["T1"];
1914        assert_eq!(tab.console.len(), 1);
1915        let e = &tab.console[0];
1916        assert_eq!(e.source, "network");
1917        assert_eq!(e.network_request_id.as_deref(), Some("1.7"));
1918        assert!(format_console_line(e).contains("https://app.test/api/me  Failed"));
1919    }
1920
1921    fn net_triplet(h: &mut HubInner, rid: &str, url: &str, status: u64) {
1922        h.route(ev(
1923            "Network.requestWillBeSent",
1924            Some("S9"),
1925            json!({"requestId": rid, "timestamp": 100.0, "wallTime": 1756816496.0, "type": "XHR",
1926                   "request": {"method": "get", "url": url, "hasPostData": false}}),
1927        ));
1928        h.route(ev(
1929            "Network.responseReceived",
1930            Some("S9"),
1931            json!({"requestId": rid, "type": "XHR",
1932                   "response": {"status": status, "statusText": "OK", "mimeType": "application/json"}}),
1933        ));
1934        h.route(ev(
1935            "Network.loadingFinished",
1936            Some("S9"),
1937            json!({"requestId": rid, "timestamp": 100.084, "encodedDataLength": 312}),
1938        ));
1939    }
1940
1941    #[test]
1942    fn network_lifecycle_failed_and_redirect() {
1943        let mut h = hub_with_tab();
1944        net_triplet(&mut h, "1.1", "https://app.test/api/me", 401);
1945        h.route(ev(
1946            "Network.requestWillBeSent",
1947            Some("S9"),
1948            json!({"requestId": "1.2", "timestamp": 200.0, "wallTime": 1756816497.0, "type": "Script",
1949                   "request": {"method": "GET", "url": "https://cdn.test/app.js"}}),
1950        ));
1951        h.route(ev(
1952            "Network.loadingFailed",
1953            Some("S9"),
1954            json!({"requestId": "1.2", "timestamp": 200.5, "errorText": "net::ERR_BLOCKED_BY_CLIENT", "canceled": false}),
1955        ));
1956        // Redirect chain reuses the request id.
1957        h.route(ev(
1958            "Network.requestWillBeSent",
1959            Some("S9"),
1960            json!({"requestId": "1.3", "timestamp": 300.0, "wallTime": 1.0, "type": "Document",
1961                   "request": {"method": "GET", "url": "https://app.test/old"}}),
1962        ));
1963        h.route(ev(
1964            "Network.requestWillBeSent",
1965            Some("S9"),
1966            json!({"requestId": "1.3", "timestamp": 300.1, "wallTime": 1.1, "type": "Document",
1967                   "redirectResponse": {"status": 302, "mimeType": "text/html"},
1968                   "request": {"method": "GET", "url": "https://app.test/new"}}),
1969        ));
1970        let tab = &h.tabs["T1"];
1971        assert_eq!(tab.network.len(), 4);
1972        let a = &tab.network[0];
1973        assert_eq!(a.method, "get");
1974        assert_eq!(a.state, NetState::Finished);
1975        assert_eq!(a.status, Some(401));
1976        assert_eq!(a.encoded_bytes, Some(312));
1977        assert_eq!(a.duration_ms.map(|d| d.round()), Some(84.0));
1978        assert_eq!(
1979            format_network_line(a),
1980            "1.1  get    https://app.test/api/me  → 401 application/json 312B 84ms [XHR]"
1981        );
1982        let b = &tab.network[1];
1983        assert_eq!(b.state, NetState::Failed);
1984        assert_eq!(
1985            format_network_line(b),
1986            "1.2  GET    https://cdn.test/app.js  → failed net::ERR_BLOCKED_BY_CLIENT [Script]"
1987        );
1988        let c = &tab.network[2];
1989        assert_eq!(c.state, NetState::Redirected);
1990        assert_eq!(c.status, Some(302));
1991        assert!(
1992            format_network_line(c).ends_with("→ 302 text/html 100ms [redirect] [Document]"),
1993            "{}",
1994            format_network_line(c)
1995        );
1996        assert_eq!(tab.network[3].state, NetState::Pending);
1997        assert!(format_network_line(&tab.network[3]).contains("→ pending"));
1998    }
1999
2000    #[test]
2001    fn unknown_sessions_ignored_and_detach_or_crash_drops_tab() {
2002        let mut h = hub_with_tab();
2003        h.route(ev(
2004            "Runtime.consoleAPICalled",
2005            Some("S-other"),
2006            json!({"type": "log", "args": [{"type": "string", "value": "x"}]}),
2007        ));
2008        assert!(h.tabs["T1"].console.is_empty());
2009        h.route(ev("Inspector.targetCrashed", Some("S9"), json!({})));
2010        assert!(h.tabs.is_empty());
2011        assert!(h.sessions.is_empty());
2012
2013        let mut h = hub_with_tab();
2014        h.route(ev(
2015            "Target.detachedFromTarget",
2016            None,
2017            json!({"sessionId": "S9", "targetId": "T1"}),
2018        ));
2019        assert!(h.tabs.is_empty());
2020    }
2021
2022    #[test]
2023    fn frame_navigated_updates_page_url_for_main_frame_only() {
2024        let mut h = hub_with_tab();
2025        h.route(ev(
2026            "Page.frameNavigated",
2027            Some("S9"),
2028            json!({"frame": {"id": "child", "parentId": "main", "url": "https://iframe.test/"}}),
2029        ));
2030        assert_eq!(h.tabs["T1"].page_url, "https://app.test/x");
2031        h.route(ev(
2032            "Page.frameNavigated",
2033            Some("S9"),
2034            json!({"frame": {"id": "main", "url": "https://app.test/login"}}),
2035        ));
2036        assert_eq!(h.tabs["T1"].page_url, "https://app.test/login");
2037        h.route(ev(
2038            "Runtime.consoleAPICalled",
2039            Some("S9"),
2040            json!({"type": "log", "args": [{"type": "string", "value": "after"}], "timestamp": 5.0}),
2041        ));
2042        assert_eq!(h.tabs["T1"].console[0].page_url, "https://app.test/login");
2043    }
2044
2045    #[test]
2046    fn buffers_evict_at_cap_and_count() {
2047        let mut h = hub_with_tab();
2048        for i in 0..(CONSOLE_CAP + 3) {
2049            h.route(ev(
2050                "Runtime.consoleAPICalled",
2051                Some("S9"),
2052                json!({"type": "log", "args": [{"type": "string", "value": format!("m{i}")}], "timestamp": 1.0}),
2053            ));
2054        }
2055        let tab = &h.tabs["T1"];
2056        assert_eq!(tab.console.len(), CONSOLE_CAP);
2057        assert_eq!(tab.dropped_console, 3);
2058        assert_eq!(tab.console.front().unwrap().text, "m3");
2059    }
2060
2061    #[test]
2062    fn status_filter_parses_and_matches() {
2063        assert_eq!(
2064            StatusFilter::parse("404").unwrap(),
2065            StatusFilter::Exact(404)
2066        );
2067        assert_eq!(StatusFilter::parse("4xx").unwrap(), StatusFilter::Class(4));
2068        assert_eq!(StatusFilter::parse("FAILED").unwrap(), StatusFilter::Failed);
2069        assert!(StatusFilter::parse("nope").is_err());
2070        assert!(StatusFilter::parse("9xx").is_err());
2071    }
2072
2073    #[test]
2074    fn fmt_iso_ms_renders_millis() {
2075        assert_eq!(fmt_iso_ms(1756816496120.0), "2025-09-02T12:34:56.120Z");
2076        assert_eq!(fmt_iso_ms(0.0), "-");
2077    }
2078
2079    #[tokio::test]
2080    async fn read_console_filters_limits_and_clears() {
2081        let hub = CaptureHub::new();
2082        {
2083            let mut g = hub.lock();
2084            *g = hub_with_tab();
2085        }
2086        {
2087            let mut g = hub.lock();
2088            for (i, lvl) in ["log", "error", "log", "error"].iter().enumerate() {
2089                g.route(ev(
2090                    "Runtime.consoleAPICalled",
2091                    Some("S9"),
2092                    json!({"type": lvl, "args": [{"type": "string", "value": format!("msg{i}")}], "timestamp": 1.0}),
2093                ));
2094            }
2095        }
2096        let r = hub
2097            .read_console(
2098                "T1",
2099                &ConsoleQuery {
2100                    pattern: None,
2101                    only_errors: true,
2102                    limit: 1,
2103                    clear: false,
2104                },
2105            )
2106            .await
2107            .unwrap();
2108        assert_eq!(r.matched, 2);
2109        assert_eq!(r.buffered, 4);
2110        assert_eq!(r.entries.len(), 1);
2111        assert_eq!(r.entries[0].text, "msg3");
2112        let text = format_console_text(&r);
2113        assert!(text.starts_with("console tab=T1 page=https://app.test/x  showing 1 of 2 matched (4 buffered, 0 evicted, 0 events lost)\n-- page: https://app.test/x --\n[error]"));
2114
2115        let r = hub
2116            .read_console(
2117                "T1",
2118                &ConsoleQuery {
2119                    pattern: Some(Regex::new("msg[02]").unwrap()),
2120                    only_errors: false,
2121                    limit: 100,
2122                    clear: true,
2123                },
2124            )
2125            .await
2126            .unwrap();
2127        assert_eq!(r.matched, 2);
2128        let r = hub
2129            .read_console(
2130                "T1",
2131                &ConsoleQuery {
2132                    limit: 10,
2133                    ..Default::default()
2134                },
2135            )
2136            .await
2137            .unwrap();
2138        assert_eq!(r.buffered, 0);
2139
2140        let err = hub
2141            .read_console("T-none", &ConsoleQuery::default())
2142            .await
2143            .unwrap_err();
2144        assert!(err.to_string().contains("no capture for tab T-none"));
2145    }
2146
2147    #[tokio::test]
2148    async fn read_network_filters() {
2149        let hub = CaptureHub::new();
2150        {
2151            let mut g = hub.lock();
2152            *g = hub_with_tab();
2153            net_triplet(&mut g, "1.1", "https://app.test/api/me", 401);
2154            net_triplet(&mut g, "1.2", "https://app.test/api/list", 200);
2155            net_triplet(&mut g, "1.3", "https://cdn.test/x.png", 200);
2156        }
2157        let r = hub
2158            .read_network(
2159                "T1",
2160                &NetworkQuery {
2161                    url_pattern: Some(Regex::new("app\\.test").unwrap()),
2162                    status: Some(StatusFilter::Class(2)),
2163                    limit: 10,
2164                    ..Default::default()
2165                },
2166            )
2167            .await
2168            .unwrap();
2169        assert_eq!(r.matched, 1);
2170        assert_eq!(r.entries[0].request_id, "1.2");
2171        let r = hub
2172            .read_network(
2173                "T1",
2174                &NetworkQuery {
2175                    method: Some("GET".into()),
2176                    resource_type: Some("xhr".into()),
2177                    limit: 2,
2178                    ..Default::default()
2179                },
2180            )
2181            .await
2182            .unwrap();
2183        assert_eq!(r.matched, 3);
2184        assert_eq!(r.entries.len(), 2);
2185        assert_eq!(r.entries[0].request_id, "1.2");
2186    }
2187
2188    /// Mock that answers attach, records enables, and pushes capture
2189    /// events on the attached session right after `Network.enable`.
2190    async fn spawn_event_mock(fail_attach: bool) -> (String, Arc<Mutex<Vec<String>>>) {
2191        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2192        let addr = listener.local_addr().unwrap();
2193        let seen = Arc::new(Mutex::new(Vec::<String>::new()));
2194        tokio::spawn({
2195            let seen = seen.clone();
2196            async move {
2197                let (stream, _) = listener.accept().await.unwrap();
2198                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
2199                while let Some(Ok(Message::Text(t))) = ws.next().await {
2200                    let req: Value = serde_json::from_str(&t).unwrap();
2201                    let id = req["id"].as_u64().unwrap();
2202                    let method = req["method"].as_str().unwrap_or("").to_string();
2203                    seen.lock().unwrap().push(method.clone());
2204                    let sid = req["sessionId"].as_str().unwrap_or("").to_string();
2205                    let resp = match method.as_str() {
2206                        "Target.attachToTarget" if fail_attach => {
2207                            json!({"id": id, "error": {"code": -32000, "message": "No target with given id found"}})
2208                        }
2209                        "Target.attachToTarget" => json!({"id": id, "result": {"sessionId": "S9"}}),
2210                        "Page.getNavigationHistory" => json!({"id": id, "result": {
2211                            "currentIndex": 0, "entries": [{"url": "https://app.test/x"}]}}),
2212                        "Network.getResponseBody" => json!({"id": id, "result": {
2213                            "body": base64::engine::general_purpose::STANDARD.encode(b"{\"ok\":true}"),
2214                            "base64Encoded": true}}),
2215                        _ => json!({"id": id, "result": {}}),
2216                    };
2217                    ws.send(Message::Text(resp.to_string())).await.unwrap();
2218                    if method == "Network.enable" {
2219                        for ev in [
2220                            json!({"method": "Runtime.consoleAPICalled", "sessionId": sid,
2221                                   "params": {"type": "error", "timestamp": 1.0,
2222                                              "args": [{"type": "string", "value": "boom"}]}}),
2223                            json!({"method": "Network.requestWillBeSent", "sessionId": sid,
2224                                   "params": {"requestId": "7.1", "timestamp": 1.0, "wallTime": 1.0, "type": "Fetch",
2225                                              "request": {"method": "GET", "url": "https://app.test/api"}}}),
2226                            json!({"method": "Network.responseReceived", "sessionId": sid,
2227                                   "params": {"requestId": "7.1", "type": "Fetch",
2228                                              "response": {"status": 200, "mimeType": "application/json"}}}),
2229                            json!({"method": "Network.loadingFinished", "sessionId": sid,
2230                                   "params": {"requestId": "7.1", "timestamp": 1.05, "encodedDataLength": 11}}),
2231                        ] {
2232                            ws.send(Message::Text(ev.to_string())).await.unwrap();
2233                        }
2234                    }
2235                }
2236            }
2237        });
2238        (format!("ws://{addr}"), seen)
2239    }
2240
2241    #[tokio::test]
2242    async fn attach_enables_domains_routes_events_and_fetches_body() {
2243        let (url, seen) = spawn_event_mock(false).await;
2244        let client = Arc::new(CdpClient::connect(&url).await.unwrap());
2245        let backend = TabBackend::Cdp(client);
2246        let hub = CaptureHub::new();
2247        hub.touch_and_wait(&backend, "T1").await;
2248        // Events are pushed by the mock right after Network.enable; give the
2249        // router a moment to drain them (test-side only).
2250        for _ in 0..50 {
2251            if hub
2252                .read_network(
2253                    "T1",
2254                    &NetworkQuery {
2255                        limit: 10,
2256                        ..Default::default()
2257                    },
2258                )
2259                .await
2260                .map(|r| {
2261                    r.entries
2262                        .first()
2263                        .is_some_and(|e| e.state == NetState::Finished)
2264                })
2265                .unwrap_or(false)
2266            {
2267                break;
2268            }
2269            tokio::time::sleep(Duration::from_millis(10)).await;
2270        }
2271        {
2272            let methods = seen.lock().unwrap();
2273            let enables: Vec<&String> = methods.iter().filter(|m| m.ends_with(".enable")).collect();
2274            assert_eq!(
2275                enables,
2276                vec![
2277                    "Inspector.enable",
2278                    "Page.enable",
2279                    "Runtime.enable",
2280                    "Log.enable",
2281                    "Network.enable"
2282                ]
2283            );
2284        }
2285        let c = hub
2286            .read_console(
2287                "T1",
2288                &ConsoleQuery {
2289                    limit: 10,
2290                    ..Default::default()
2291                },
2292            )
2293            .await
2294            .unwrap();
2295        assert_eq!(c.entries.len(), 1);
2296        assert_eq!(c.entries[0].text, "boom");
2297        assert_eq!(c.page_url, "https://app.test/x");
2298        let n = hub
2299            .read_network(
2300                "T1",
2301                &NetworkQuery {
2302                    limit: 10,
2303                    ..Default::default()
2304                },
2305            )
2306            .await
2307            .unwrap();
2308        assert_eq!(n.entries.len(), 1);
2309        assert_eq!(n.entries[0].state, NetState::Finished);
2310
2311        let body = hub
2312            .response_body(&backend, "T1", "7.1", 4, Duration::from_secs(2))
2313            .await
2314            .unwrap();
2315        assert_eq!(body.total_bytes, 11);
2316        assert!(body.truncated);
2317        assert_eq!(body.bytes, b"{\"ok");
2318        let err = hub
2319            .response_body(&backend, "T1", "nope", 100, Duration::from_secs(2))
2320            .await
2321            .unwrap_err();
2322        assert!(err.to_string().contains("unknown request id"));
2323
2324        hub.forget(&backend, "T1");
2325        assert!(hub.captured_tabs().is_empty());
2326        tokio::time::sleep(Duration::from_millis(20)).await;
2327        assert!(seen
2328            .lock()
2329            .unwrap()
2330            .iter()
2331            .any(|m| m == "Target.detachFromTarget"));
2332    }
2333
2334    #[tokio::test]
2335    async fn attach_failure_leaves_no_state_and_retries_on_next_touch() {
2336        let (url, seen) = spawn_event_mock(true).await;
2337        let client = Arc::new(CdpClient::connect(&url).await.unwrap());
2338        let backend = TabBackend::Cdp(client);
2339        let hub = CaptureHub::new();
2340        hub.touch_and_wait(&backend, "T1").await;
2341        assert!(hub.captured_tabs().is_empty());
2342        hub.touch_and_wait(&backend, "T1").await;
2343        assert_eq!(
2344            seen.lock()
2345                .unwrap()
2346                .iter()
2347                .filter(|m| *m == "Target.attachToTarget")
2348                .count(),
2349            2
2350        );
2351        let err = hub
2352            .read_console("T1", &ConsoleQuery::default())
2353            .await
2354            .unwrap_err();
2355        assert!(err.to_string().contains("no capture"));
2356    }
2357
2358    #[tokio::test]
2359    async fn disabled_via_env_never_attaches() {
2360        let hub = {
2361            let _guard = crate::test_support::ENV_LOCK.lock().unwrap();
2362            std::env::set_var("BROWSER_CONTROL_CAPTURE", "0");
2363            let hub = CaptureHub::new();
2364            std::env::remove_var("BROWSER_CONTROL_CAPTURE");
2365            hub
2366        };
2367        assert!(hub.disabled());
2368        let (url, seen) = spawn_event_mock(false).await;
2369        let client = Arc::new(CdpClient::connect(&url).await.unwrap());
2370        let backend = TabBackend::Cdp(client);
2371        hub.touch_and_wait(&backend, "T1").await;
2372        assert!(hub.captured_tabs().is_empty());
2373        assert!(seen.lock().unwrap().is_empty());
2374    }
2375
2376    // -- WebDriver BiDi ingress ----------------------------------------------
2377
2378    fn bev(method: &str, params: Value) -> BidiEvent {
2379        BidiEvent {
2380            method: method.into(),
2381            params,
2382        }
2383    }
2384
2385    fn bidi_hub_with_tab() -> HubInner {
2386        let mut h = HubInner::default();
2387        let mut tab = TabCapture::new();
2388        tab.ready = true;
2389        tab.page_url = "https://app.test/x".into();
2390        h.tabs.insert("C1".into(), tab);
2391        h
2392    }
2393
2394    #[test]
2395    fn bidi_console_uses_text_and_location() {
2396        let mut h = bidi_hub_with_tab();
2397        h.route_bidi(bev(
2398            "log.entryAdded",
2399            json!({
2400                "type": "console", "level": "warn", "method": "warn",
2401                "text": "Deprecated 3", "args": [],
2402                "timestamp": 1756816496120.0,
2403                "source": {"realm": "R1", "context": "C1"},
2404                "stackTrace": {"callFrames": [{"url": "https://app.test/a.js", "lineNumber": 11, "columnNumber": 4, "functionName": "f"}]}
2405            }),
2406        ));
2407        let e = &h.tabs["C1"].console[0];
2408        assert_eq!(e.level, Level::Warn);
2409        assert_eq!(e.source, "console.warning");
2410        assert_eq!((e.line, e.column), (Some(12), Some(5)));
2411        assert_eq!(e.page_url, "https://app.test/x");
2412        assert_eq!(
2413            format_console_line(e),
2414            "[warn] 2025-09-02T12:34:56.120Z https://app.test/a.js:12:5  Deprecated 3"
2415        );
2416    }
2417
2418    #[test]
2419    fn bidi_console_null_text_renders_args() {
2420        let mut h = bidi_hub_with_tab();
2421        h.route_bidi(bev(
2422            "log.entryAdded",
2423            json!({
2424                "type": "console", "level": "info", "method": "log", "text": null, "timestamp": 1.0,
2425                "source": {"context": "C1"},
2426                "args": [
2427                    {"type": "string", "value": "Deprecated"},
2428                    {"type": "number", "value": 3},
2429                    {"type": "number", "value": "NaN"},
2430                    {"type": "boolean", "value": true},
2431                    {"type": "null"},
2432                    {"type": "undefined"},
2433                    {"type": "object", "value": [["a", {"type": "number", "value": 1}], ["b", {"type": "string", "value": "x"}]]},
2434                    {"type": "array", "value": [{"type": "number", "value": 1}]},
2435                    {"type": "node", "value": {"localName": "div"}},
2436                    {"type": "function"},
2437                    {"type": "map", "value": [[{"type": "string", "value": "k"}, {"type": "number", "value": 2}]]},
2438                    {"type": "object"}
2439                ]
2440            }),
2441        ));
2442        let e = &h.tabs["C1"].console[0];
2443        assert_eq!(e.level, Level::Log);
2444        assert_eq!(e.source, "console.log");
2445        assert_eq!(
2446            e.text,
2447            "Deprecated 3 NaN true null undefined {a: 1, b: \"x\"} [1] <div> function Map {\"k\" => 2} Object"
2448        );
2449        assert!(e.url.is_none());
2450    }
2451
2452    #[test]
2453    fn bidi_console_group_methods_skipped() {
2454        let mut h = bidi_hub_with_tab();
2455        for m in ["group", "groupEnd", "clear"] {
2456            h.route_bidi(bev(
2457                "log.entryAdded",
2458                json!({"type": "console", "level": "info", "method": m, "text": "x", "timestamp": 1.0, "source": {"context": "C1"}}),
2459            ));
2460        }
2461        assert!(h.tabs["C1"].console.is_empty());
2462    }
2463
2464    #[test]
2465    fn bidi_javascript_error_is_exception_with_stack() {
2466        let mut h = bidi_hub_with_tab();
2467        h.route_bidi(bev(
2468            "log.entryAdded",
2469            json!({
2470                "type": "javascript", "level": "error",
2471                "text": "TypeError: x is not a function", "timestamp": 2.0,
2472                "source": {"context": "C1"},
2473                "stackTrace": {"callFrames": [{"url": "https://app.test/a.js", "lineNumber": 11, "columnNumber": 4, "functionName": "f"}]}
2474            }),
2475        ));
2476        let e = &h.tabs["C1"].console[0];
2477        assert_eq!(e.level, Level::Error);
2478        assert_eq!(e.source, "exception");
2479        assert_eq!(e.text, "TypeError: x is not a function");
2480        assert!(e
2481            .stack
2482            .as_deref()
2483            .unwrap()
2484            .contains("at f (https://app.test/a.js:12:5)"));
2485        assert_eq!((e.line, e.column), (Some(12), Some(5)));
2486    }
2487
2488    fn bidi_net_triplet(h: &mut HubInner, rid: &str, url: &str, status: u64) {
2489        h.route_bidi(bev(
2490            "network.beforeRequestSent",
2491            json!({"context": "C1", "navigation": null, "redirectCount": 0, "timestamp": 1756816496000.0,
2492                   "initiator": {"type": "other"},
2493                   "request": {"request": rid, "url": url, "method": "get", "bodySize": 0, "initiatorType": "xmlhttprequest"}}),
2494        ));
2495        h.route_bidi(bev(
2496            "network.responseCompleted",
2497            json!({"context": "C1", "timestamp": 1756816496084.0, "redirectCount": 0,
2498                   "request": {"request": rid, "url": url, "method": "get"},
2499                   "response": {"status": status, "statusText": "OK", "mimeType": "application/json", "fromCache": false, "bytesReceived": 312}}),
2500        ));
2501    }
2502
2503    #[test]
2504    fn bidi_network_triplet_failed_and_redirect() {
2505        let mut h = bidi_hub_with_tab();
2506        bidi_net_triplet(&mut h, "1", "https://app.test/api/me", 401);
2507        h.route_bidi(bev(
2508            "network.beforeRequestSent",
2509            json!({"context": "C1", "navigation": null, "redirectCount": 0, "timestamp": 200000.0,
2510                   "initiator": {"type": "other"},
2511                   "request": {"request": "2", "url": "https://cdn.test/app.js", "method": "GET", "bodySize": 0, "destination": "script"}}),
2512        ));
2513        h.route_bidi(bev(
2514            "network.fetchError",
2515            json!({"context": "C1", "timestamp": 200500.0, "errorText": "NS_ERROR_ABORT",
2516                   "request": {"request": "2"}}),
2517        ));
2518        h.route_bidi(bev(
2519            "network.beforeRequestSent",
2520            json!({"context": "C1", "navigation": "N1", "redirectCount": 0, "timestamp": 300000.0,
2521                   "initiator": {"type": "other"},
2522                   "request": {"request": "3", "url": "https://app.test/old", "method": "GET", "bodySize": 12}}),
2523        ));
2524        h.route_bidi(bev(
2525            "network.responseCompleted",
2526            json!({"context": "C1", "timestamp": 300100.0, "redirectCount": 0,
2527                   "request": {"request": "3"},
2528                   "response": {"status": 302, "mimeType": "text/html", "bytesReceived": 0}}),
2529        ));
2530        h.route_bidi(bev(
2531            "network.beforeRequestSent",
2532            json!({"context": "C1", "navigation": "N1", "redirectCount": 1, "timestamp": 300100.0,
2533                   "initiator": {"type": "other"},
2534                   "request": {"request": "3", "url": "https://app.test/new", "method": "GET", "bodySize": 0}}),
2535        ));
2536        let tab = &h.tabs["C1"];
2537        assert_eq!(tab.network.len(), 4);
2538        assert_eq!(
2539            format_network_line(&tab.network[0]),
2540            "1  get    https://app.test/api/me  → 401 application/json 312B 84ms [XHR]"
2541        );
2542        assert_eq!(
2543            format_network_line(&tab.network[1]),
2544            "2  GET    https://cdn.test/app.js  → failed NS_ERROR_ABORT [Script]"
2545        );
2546        let c = &tab.network[2];
2547        assert_eq!(c.state, NetState::Redirected);
2548        assert_eq!(c.status, Some(302));
2549        assert!(c.has_post_data);
2550        assert!(
2551            format_network_line(c).ends_with("→ 302 text/html 0B 100ms [redirect] [Document]"),
2552            "{}",
2553            format_network_line(c)
2554        );
2555        assert_eq!(tab.network[3].state, NetState::Pending);
2556        assert_eq!(tab.network[3].resource_type.as_deref(), Some("Document"));
2557    }
2558
2559    #[test]
2560    fn bidi_resource_type_tiers() {
2561        let t = |req: Value, nav: bool, init: Option<&str>, mime: Option<&str>| {
2562            bidi_resource_type(&req, nav, init, mime)
2563        };
2564        assert_eq!(t(json!({}), true, None, None).as_deref(), Some("Document"));
2565        assert_eq!(
2566            t(json!({}), false, Some("preflight"), None).as_deref(),
2567            Some("Preflight")
2568        );
2569        assert_eq!(
2570            t(json!({"initiatorType": "fetch"}), false, None, None).as_deref(),
2571            Some("Fetch")
2572        );
2573        assert_eq!(
2574            t(
2575                json!({"initiatorType": "link", "destination": "style"}),
2576                false,
2577                None,
2578                None
2579            )
2580            .as_deref(),
2581            Some("Stylesheet")
2582        );
2583        assert_eq!(
2584            t(json!({"destination": "image"}), false, None, None).as_deref(),
2585            Some("Image")
2586        );
2587        assert_eq!(
2588            t(json!({}), false, None, Some("text/css")).as_deref(),
2589            Some("Stylesheet")
2590        );
2591        assert_eq!(
2592            t(
2593                json!({}),
2594                false,
2595                None,
2596                Some("application/json; charset=utf-8")
2597            )
2598            .as_deref(),
2599            Some("Fetch")
2600        );
2601        assert_eq!(t(json!({}), false, Some("other"), Some("text/plain")), None);
2602    }
2603
2604    #[test]
2605    fn bidi_navigation_started_updates_top_level_page_url_only() {
2606        let mut h = bidi_hub_with_tab();
2607        h.route_bidi(bev(
2608            "browsingContext.navigationStarted",
2609            json!({"context": "C-child", "navigation": "N2", "url": "https://iframe.test/"}),
2610        ));
2611        assert_eq!(h.tabs["C1"].page_url, "https://app.test/x");
2612        h.route_bidi(bev(
2613            "browsingContext.navigationStarted",
2614            json!({"context": "C1", "navigation": "N3", "url": "https://app.test/login"}),
2615        ));
2616        assert_eq!(h.tabs["C1"].page_url, "https://app.test/login");
2617        h.route_bidi(bev(
2618            "log.entryAdded",
2619            json!({"type": "console", "level": "info", "method": "log", "text": "after", "timestamp": 5.0, "source": {"context": "C1"}}),
2620        ));
2621        assert_eq!(h.tabs["C1"].console[0].page_url, "https://app.test/login");
2622    }
2623
2624    #[test]
2625    fn bidi_untouched_context_ignored_and_context_destroyed_drops() {
2626        let mut h = bidi_hub_with_tab();
2627        h.route_bidi(bev(
2628            "log.entryAdded",
2629            json!({"type": "console", "level": "info", "method": "log", "text": "x", "timestamp": 1.0, "source": {"context": "C-other"}}),
2630        ));
2631        h.route_bidi(bev(
2632            "network.beforeRequestSent",
2633            json!({"context": null, "request": {"request": "9", "url": "u"}, "timestamp": 1.0}),
2634        ));
2635        assert!(h.tabs["C1"].console.is_empty());
2636        assert!(h.tabs["C1"].network.is_empty());
2637        h.route_bidi(bev(
2638            "browsingContext.contextDestroyed",
2639            json!({"context": "C1", "url": "https://app.test/x", "children": []}),
2640        ));
2641        assert!(h.tabs.is_empty());
2642    }
2643
2644    #[derive(Clone, Copy, PartialEq)]
2645    enum Reject {
2646        None,
2647        Network,
2648        Console,
2649    }
2650
2651    /// BiDi-framed mock: records requests, answers `session.subscribe`
2652    /// (optionally rejecting one of the two calls), `getTree {root}`, and
2653    /// pushes console/network/foreign-context events right after the last
2654    /// accepted subscribe.
2655    async fn spawn_bidi_event_mock(reject: Reject) -> (String, Arc<Mutex<Vec<Value>>>) {
2656        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2657        let addr = listener.local_addr().unwrap();
2658        let seen = Arc::new(Mutex::new(Vec::<Value>::new()));
2659        tokio::spawn({
2660            let seen = seen.clone();
2661            async move {
2662                let (stream, _) = listener.accept().await.unwrap();
2663                let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
2664                while let Some(Ok(Message::Text(t))) = ws.next().await {
2665                    let req: Value = serde_json::from_str(&t).unwrap();
2666                    seen.lock().unwrap().push(req.clone());
2667                    let id = req["id"].as_u64().unwrap();
2668                    let method = req["method"].as_str().unwrap_or("").to_string();
2669                    let first_event = req["params"]["events"][0]
2670                        .as_str()
2671                        .unwrap_or("")
2672                        .to_string();
2673                    let is_network_sub =
2674                        method == "session.subscribe" && first_event.starts_with("network.");
2675                    let is_console_sub =
2676                        method == "session.subscribe" && first_event.starts_with("log.");
2677                    let rejected = (is_network_sub && reject == Reject::Network)
2678                        || (is_console_sub && reject == Reject::Console);
2679                    let resp = if rejected {
2680                        json!({"type": "error", "id": id, "error": "invalid argument", "message": "unknown event"})
2681                    } else {
2682                        let result = match method.as_str() {
2683                            "session.subscribe" => json!({"subscription": "SUB1"}),
2684                            "browsingContext.getTree" => json!({"contexts": [
2685                                {"context": "C1", "url": "https://app.test/x", "children": []}
2686                            ]}),
2687                            _ => json!({}),
2688                        };
2689                        json!({"type": "success", "id": id, "result": result})
2690                    };
2691                    ws.send(Message::Text(resp.to_string())).await.unwrap();
2692                    let push_now = (is_network_sub && reject != Reject::Network)
2693                        || (is_console_sub && reject == Reject::Network);
2694                    if push_now {
2695                        for ev in [
2696                            json!({"type": "event", "method": "log.entryAdded", "params": {
2697                                "type": "console", "level": "error", "method": "error", "text": "boom",
2698                                "timestamp": 1.0, "source": {"context": "C1"}}}),
2699                            json!({"type": "event", "method": "log.entryAdded", "params": {
2700                                "type": "console", "level": "info", "method": "log", "text": "foreign",
2701                                "timestamp": 1.0, "source": {"context": "C-other"}}}),
2702                            json!({"type": "event", "method": "network.beforeRequestSent", "params": {
2703                                "context": "C1", "navigation": null, "redirectCount": 0, "timestamp": 1000.0,
2704                                "initiator": {"type": "other"},
2705                                "request": {"request": "7.1", "url": "https://app.test/api", "method": "GET", "bodySize": 0, "initiatorType": "fetch"}}}),
2706                            json!({"type": "event", "method": "network.responseCompleted", "params": {
2707                                "context": "C1", "timestamp": 1050.0, "redirectCount": 0,
2708                                "request": {"request": "7.1"},
2709                                "response": {"status": 200, "mimeType": "application/json", "bytesReceived": 11}}}),
2710                        ] {
2711                            ws.send(Message::Text(ev.to_string())).await.unwrap();
2712                        }
2713                    }
2714                }
2715            }
2716        });
2717        (format!("ws://{addr}"), seen)
2718    }
2719
2720    async fn bidi_backend(url: &str) -> TabBackend {
2721        TabBackend::Bidi(Arc::new(BidiClient::connect(url).await.unwrap()))
2722    }
2723
2724    async fn poll_network(hub: &CaptureHub, target: &str) -> usize {
2725        for _ in 0..100 {
2726            if let Ok(r) = hub
2727                .read_network(
2728                    target,
2729                    &NetworkQuery {
2730                        limit: 10,
2731                        ..Default::default()
2732                    },
2733                )
2734                .await
2735            {
2736                if r.matched > 0 {
2737                    return r.matched;
2738                }
2739            }
2740            tokio::time::sleep(Duration::from_millis(10)).await;
2741        }
2742        0
2743    }
2744
2745    #[tokio::test]
2746    async fn bidi_touch_subscribes_seeds_page_url_and_routes_events() {
2747        let (url, seen) = spawn_bidi_event_mock(Reject::None).await;
2748        let backend = bidi_backend(&url).await;
2749        let hub = CaptureHub::new();
2750        hub.touch_and_wait(&backend, "C1").await;
2751        assert_eq!(poll_network(&hub, "C1").await, 1);
2752        {
2753            let reqs = seen.lock().unwrap();
2754            let subs: Vec<&Value> = reqs
2755                .iter()
2756                .filter(|r| r["method"] == "session.subscribe")
2757                .collect();
2758            assert_eq!(subs.len(), 2);
2759            assert_eq!(
2760                subs[0]["params"]["events"],
2761                json!([
2762                    "log.entryAdded",
2763                    "browsingContext.navigationStarted",
2764                    "browsingContext.contextDestroyed"
2765                ])
2766            );
2767            assert_eq!(
2768                subs[1]["params"]["events"],
2769                json!([
2770                    "network.beforeRequestSent",
2771                    "network.responseCompleted",
2772                    "network.fetchError"
2773                ])
2774            );
2775            let tree = reqs
2776                .iter()
2777                .find(|r| r["method"] == "browsingContext.getTree")
2778                .expect("getTree");
2779            assert_eq!(tree["params"]["root"], "C1");
2780            assert_eq!(tree["params"]["maxDepth"], 0);
2781        }
2782        let c = hub
2783            .read_console(
2784                "C1",
2785                &ConsoleQuery {
2786                    limit: 10,
2787                    ..Default::default()
2788                },
2789            )
2790            .await
2791            .unwrap();
2792        assert_eq!(c.entries.len(), 1);
2793        assert_eq!(c.entries[0].text, "boom");
2794        assert_eq!(c.entries[0].page_url, "https://app.test/x");
2795        let n = hub
2796            .read_network(
2797                "C1",
2798                &NetworkQuery {
2799                    limit: 10,
2800                    ..Default::default()
2801                },
2802            )
2803            .await
2804            .unwrap();
2805        assert_eq!(n.entries[0].state, NetState::Finished);
2806        assert_eq!(n.entries[0].resource_type.as_deref(), Some("Fetch"));
2807
2808        let err = hub
2809            .response_body(&backend, "C1", "7.1", 100, Duration::from_secs(1))
2810            .await
2811            .unwrap_err();
2812        assert!(err.to_string().contains("browser_fetch"));
2813
2814        // A second tab reuses the subscription.
2815        hub.touch_and_wait(&backend, "C2").await;
2816        let before = seen.lock().unwrap().len();
2817        hub.forget(&backend, "C2");
2818        tokio::time::sleep(Duration::from_millis(20)).await;
2819        let reqs = seen.lock().unwrap();
2820        assert_eq!(
2821            reqs.iter()
2822                .filter(|r| r["method"] == "session.subscribe")
2823                .count(),
2824            2
2825        );
2826        assert_eq!(reqs.len(), before, "forget must not send RPCs on BiDi");
2827    }
2828
2829    #[tokio::test]
2830    async fn bidi_network_subscribe_rejected_degrades_to_console_only() {
2831        let (url, _seen) = spawn_bidi_event_mock(Reject::Network).await;
2832        let backend = bidi_backend(&url).await;
2833        let hub = CaptureHub::new();
2834        hub.touch_and_wait(&backend, "C1").await;
2835        let mut text = String::new();
2836        for _ in 0..100 {
2837            let c = hub
2838                .read_console(
2839                    "C1",
2840                    &ConsoleQuery {
2841                        limit: 10,
2842                        ..Default::default()
2843                    },
2844                )
2845                .await
2846                .unwrap();
2847            if let Some(e) = c.entries.first() {
2848                text = e.text.clone();
2849                break;
2850            }
2851            tokio::time::sleep(Duration::from_millis(10)).await;
2852        }
2853        assert_eq!(text, "boom");
2854        let err = hub
2855            .read_network("C1", &NetworkQuery::default())
2856            .await
2857            .unwrap_err();
2858        assert!(err.to_string().contains("Firefox 124"), "{err}");
2859    }
2860
2861    #[tokio::test]
2862    async fn bidi_console_subscribe_failure_leaves_no_state_and_retries() {
2863        let (url, seen) = spawn_bidi_event_mock(Reject::Console).await;
2864        let backend = bidi_backend(&url).await;
2865        let hub = CaptureHub::new();
2866        hub.touch_and_wait(&backend, "C1").await;
2867        assert!(hub.captured_tabs().is_empty());
2868        hub.touch_and_wait(&backend, "C1").await;
2869        assert!(hub.captured_tabs().is_empty());
2870        assert_eq!(
2871            seen.lock()
2872                .unwrap()
2873                .iter()
2874                .filter(|r| r["method"] == "session.subscribe")
2875                .count(),
2876            2
2877        );
2878    }
2879}