1use 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
52pub 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
55pub const CONSOLE_CAP: usize = 1000;
57pub const NETWORK_CAP: usize = 500;
59pub const CONSOLE_TEXT_CAP: usize = 4096;
61pub const URL_CAP: usize = 2048;
63const ATTACH_TIMEOUT: Duration = Duration::from_secs(5);
65pub const TOUCH_WAIT: Duration = Duration::from_secs(2);
67pub const BODY_DEFAULT_MAX: usize = 256 * 1024;
69pub const BODY_HARD_MAX: usize = crate::cli::curl::MCP_RESPONSE_LIMIT;
71const 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#[derive(Debug, Clone, Serialize)]
99pub struct ConsoleEntry {
100 pub seq: u64,
101 pub ts_ms: f64,
103 pub level: Level,
104 pub source: String,
107 pub text: String,
108 pub url: Option<String>,
109 pub line: Option<u32>,
111 pub column: Option<u32>,
112 pub page_url: String,
114 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#[derive(Debug, Clone, Serialize)]
130pub struct NetworkEntry {
131 pub seq: u64,
132 pub request_id: String,
134 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#[derive(Debug)]
155struct TabCapture {
156 session_id: Option<String>,
158 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#[derive(Debug, Clone, Copy)]
216struct BidiCapabilities {
217 network: bool,
219}
220
221#[derive(Default)]
222struct HubInner {
223 tabs: HashMap<String, TabCapture>,
224 sessions: HashMap<String, String>,
226 router: Option<tokio::task::JoinHandle<()>>,
228 bidi: Option<Arc<OnceCell<BidiCapabilities>>>,
231 lost_events: u64,
232 disabled: bool,
233}
234
235impl HubInner {
236 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 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 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
474fn 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
494fn 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
558fn 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 let source = format!(
594 "console.{}",
595 if method == "warn" { "warning" } else { method }
596 );
597 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
626fn 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
645pub 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
881pub 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
956pub 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
977pub 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
993pub 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#[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 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#[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#[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#[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#[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
1131struct 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
1159pub 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
1185pub 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
1211pub 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 pub fn disabled(&self) -> bool {
1264 self.lock().disabled
1265 }
1266
1267 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 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 {
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 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 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 pub fn captured_tabs(&self) -> Vec<String> {
1382 self.lock().tabs.keys().cloned().collect()
1383 }
1384
1385 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 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 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
1591async 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 let _ = client
1613 .send("Target.detachFromTarget", json!({ "sessionId": sid }))
1614 .await;
1615 return Err(anyhow!("tab forgotten during attach"));
1616 }
1617 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
1661fn 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
1693async 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
1727async 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 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
1772async 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 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 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 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 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 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 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}