From ceaee0095248f3b48d0fa86045c8a6440132d2df Mon Sep 17 00:00:00 2001 From: ChunHao Chen Date: Mon, 23 Mar 2026 23:17:17 +0800 Subject: [PATCH] feat: add network request detail and filtering for request tracking (#935) * feat: add network request detail and filtering for request tracking - Add `network request ` command to view full request/response details including response body via CDP Network.getResponseBody - Add --type, --method, --status filter flags to `network requests` - --type: comma-separated resource types (xhr,fetch,document) - --method: filter by HTTP method - --status: supports exact (200), class (2xx), range (400-499) - Extend TrackedRequest with request_id, post_data, status, response_headers, mime_type fields - Update Network.responseReceived handler to also populate tracked_requests (previously only updated HAR entries) - Add tests for parse commands and matches_status_filter - Update README, SKILL.md, docs, and help text Closes #932 * fix: show request ID and status in network requests output --- README.md | 4 + cli/src/commands.rs | 74 +++++++++++++- cli/src/native/actions.rs | 178 ++++++++++++++++++++++++++++----- cli/src/native/parity_tests.rs | 40 ++++++++ cli/src/output.rs | 17 +++- docs/src/app/commands/page.mdx | 4 + skills/agent-browser/SKILL.md | 4 + 7 files changed, 293 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 02d2e42..fb6ebfa 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,10 @@ agent-browser network route --body # Mock response agent-browser network unroute [url] # Remove routes agent-browser network requests # View tracked requests agent-browser network requests --filter api # Filter requests +agent-browser network requests --type xhr,fetch # Filter by resource type +agent-browser network requests --method POST # Filter by HTTP method +agent-browser network requests --status 2xx # Filter by status (200, 2xx, 400-499) +agent-browser network request # View full request/response detail agent-browser network har start # Start HAR recording agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted) ``` diff --git a/cli/src/commands.rs b/cli/src/commands.rs index d29797f..f72fe64 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -2078,7 +2078,7 @@ fn parse_set(rest: &[&str], id: &str) -> Result { /// Parse network interception, request inspection, and HAR recording commands. fn parse_network(rest: &[&str], id: &str) -> Result { - const VALID: &[&str] = &["route", "unroute", "requests", "har"]; + const VALID: &[&str] = &["route", "unroute", "requests", "request", "har"]; match rest.first().copied() { Some("route") => { @@ -2102,12 +2102,34 @@ fn parse_network(rest: &[&str], id: &str) -> Result { let clear = rest.contains(&"--clear"); let filter_idx = rest.iter().position(|&s| s == "--filter"); let filter = filter_idx.and_then(|i| rest.get(i + 1).copied()); + let type_idx = rest.iter().position(|&s| s == "--type"); + let rtype = type_idx.and_then(|i| rest.get(i + 1).copied()); + let method_idx = rest.iter().position(|&s| s == "--method"); + let method = method_idx.and_then(|i| rest.get(i + 1).copied()); + let status_idx = rest.iter().position(|&s| s == "--status"); + let status = status_idx.and_then(|i| rest.get(i + 1).copied()); let mut cmd = json!({ "id": id, "action": "requests", "clear": clear }); if let Some(f) = filter { cmd["filter"] = json!(f); } + if let Some(t) = rtype { + cmd["type"] = json!(t); + } + if let Some(m) = method { + cmd["method"] = json!(m); + } + if let Some(s) = status { + cmd["status"] = json!(s); + } Ok(cmd) } + Some("request") => { + let request_id = rest.get(1).ok_or_else(|| ParseError::MissingArguments { + context: "network request".to_string(), + usage: "network request ", + })?; + Ok(json!({ "id": id, "action": "request_detail", "requestId": request_id })) + } Some("har") => { const HAR_VALID: &[&str] = &["start", "stop"]; match rest.get(1).copied() { @@ -2135,7 +2157,7 @@ fn parse_network(rest: &[&str], id: &str) -> Result { }), None => Err(ParseError::MissingArguments { context: "network".to_string(), - usage: "network [args...]", + usage: "network [args...]", }), } } @@ -2742,6 +2764,54 @@ mod tests { assert!(matches!(result, Err(ParseError::MissingArguments { .. }))); } + #[test] + fn test_network_requests_type_filter() { + let cmd = + parse_command(&args("network requests --type xhr,fetch"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "requests"); + assert_eq!(cmd["type"], "xhr,fetch"); + } + + #[test] + fn test_network_requests_method_filter() { + let cmd = parse_command(&args("network requests --method POST"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "requests"); + assert_eq!(cmd["method"], "POST"); + } + + #[test] + fn test_network_requests_status_filter() { + let cmd = parse_command(&args("network requests --status 2xx"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "requests"); + assert_eq!(cmd["status"], "2xx"); + } + + #[test] + fn test_network_requests_combined_filters() { + let cmd = parse_command( + &args("network requests --filter api --type xhr --method GET --status 200"), + &default_flags(), + ) + .unwrap(); + assert_eq!(cmd["filter"], "api"); + assert_eq!(cmd["type"], "xhr"); + assert_eq!(cmd["method"], "GET"); + assert_eq!(cmd["status"], "200"); + } + + #[test] + fn test_network_request_detail() { + let cmd = parse_command(&args("network request 1234.5"), &default_flags()).unwrap(); + assert_eq!(cmd["action"], "request_detail"); + assert_eq!(cmd["requestId"], "1234.5"); + } + + #[test] + fn test_network_request_detail_requires_id() { + let result = parse_command(&args("network request"), &default_flags()); + assert!(matches!(result, Err(ParseError::MissingArguments { .. }))); + } + // === Screenshot === #[test] diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 4c29404..ab73cb3 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -109,6 +109,16 @@ pub struct TrackedRequest { pub timestamp: u64, #[serde(rename = "resourceType")] pub resource_type: String, + #[serde(rename = "requestId")] + pub request_id: String, + #[serde(rename = "postData", skip_serializing_if = "Option::is_none")] + pub post_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(rename = "responseHeaders", skip_serializing_if = "Option::is_none")] + pub response_headers: Option, + #[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")] + pub mime_type: Option, } pub struct FetchPausedRequest { @@ -549,7 +559,7 @@ impl DaemonState { .unwrap_or("Other") .to_string(); self.har_entries.push(HarEntry { - request_id, + request_id: request_id.clone(), wall_time, method: method.clone(), url: url.clone(), @@ -587,11 +597,21 @@ impl DaemonState { headers, timestamp, resource_type, + request_id, + post_data: request + .get("postData") + .and_then(|v| v.as_str()) + .map(String::from), + status: None, + response_headers: None, + mime_type: None, }); } } } - "Network.responseReceived" if self.har_recording => { + "Network.responseReceived" + if self.har_recording || self.request_tracking => + { if let Some(response) = event.params.get("response") { let request_id = event .params @@ -625,20 +645,39 @@ impl DaemonState { .and_then(|v| v.as_i64()) .unwrap_or(-1); let cdp_timing = response.get("timing").cloned(); - if let Some(entry) = self - .har_entries - .iter_mut() - .rev() - .find(|e| e.request_id == request_id) - { - entry.status = status; - entry.status_text = status_text; - entry.mime_type = mime_type; - entry.http_version = http_version; - entry.response_headers = response_headers; - entry.redirect_url = redirect_url; - entry.response_body_size = encoded_data_length; - entry.cdp_timing = cdp_timing; + if self.har_recording { + if let Some(entry) = self + .har_entries + .iter_mut() + .rev() + .find(|e| e.request_id == request_id) + { + entry.status = status; + entry.status_text = status_text; + entry.mime_type = mime_type; + entry.http_version = http_version; + entry.response_headers = response_headers; + entry.redirect_url = redirect_url; + entry.response_body_size = encoded_data_length; + entry.cdp_timing = cdp_timing; + } + } + if self.request_tracking { + let resp_headers = response.get("headers").cloned(); + let resp_mime = response + .get("mimeType") + .and_then(|v| v.as_str()) + .map(String::from); + if let Some(entry) = self + .tracked_requests + .iter_mut() + .rev() + .find(|e| e.request_id == request_id) + { + entry.status = status; + entry.mime_type = resp_mime; + entry.response_headers = resp_headers; + } } } } @@ -1051,6 +1090,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value { "route" => handle_route(cmd, state).await, "unroute" => handle_unroute(cmd, state).await, "requests" => handle_requests(cmd, state).await, + "request_detail" => handle_request_detail(cmd, state).await, "credentials" => handle_http_credentials(cmd, state).await, "emulatemedia" => handle_set_media(cmd, state).await, "auth_save" => handle_auth_save(cmd).await, @@ -5611,6 +5651,25 @@ async fn handle_unroute(cmd: &Value, state: &mut DaemonState) -> Result, filter: &str) -> bool { + let Some(code) = status else { return false }; + let f = filter.to_lowercase(); + if let Ok(exact) = f.parse::() { + return code == exact; + } + if f.len() == 3 && f.ends_with("xx") { + if let Ok(prefix) = f[..1].parse::() { + return code / 100 == prefix; + } + } + if let Some((lo, hi)) = f.split_once('-') { + if let (Ok(lo), Ok(hi)) = (lo.parse::(), hi.parse::()) { + return code >= lo && code <= hi; + } + } + false +} + async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result { if cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false) { state.tracked_requests.clear(); @@ -5630,19 +5689,88 @@ async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result = if let Some(f) = filter { - state - .tracked_requests - .iter() - .filter(|r| r.url.contains(f)) - .collect() - } else { - state.tracked_requests.iter().collect() - }; + let type_filter = cmd.get("type").and_then(|v| v.as_str()); + let method_filter = cmd.get("method").and_then(|v| v.as_str()); + let status_filter = cmd.get("status").and_then(|v| v.as_str()); + + let type_list: Vec = type_filter + .map(|t| t.split(',').map(|s| s.trim().to_lowercase()).collect()) + .unwrap_or_default(); + + let requests: Vec<&TrackedRequest> = state + .tracked_requests + .iter() + .filter(|r| { + if let Some(f) = filter { + if !r.url.contains(f) { + return false; + } + } + if !type_list.is_empty() && !type_list.contains(&r.resource_type.to_lowercase()) { + return false; + } + if let Some(m) = method_filter { + if !r.method.eq_ignore_ascii_case(m) { + return false; + } + } + if let Some(s) = status_filter { + if !matches_status_filter(r.status, s) { + return false; + } + } + true + }) + .collect(); Ok(json!({ "requests": requests })) } +async fn handle_request_detail(cmd: &Value, state: &mut DaemonState) -> Result { + let request_id = cmd + .get("requestId") + .and_then(|v| v.as_str()) + .ok_or("Missing 'requestId' parameter")?; + + let entry = state + .tracked_requests + .iter() + .find(|r| r.request_id == request_id) + .ok_or("Request not found")?; + + let mut result = serde_json::to_value(entry).unwrap_or(json!({})); + + if let Some(ref mgr) = state.browser { + if let Ok(session_id) = mgr.active_session_id() { + if let Ok(body_result) = mgr + .client + .send_command( + "Network.getResponseBody", + Some(json!({ "requestId": request_id })), + Some(session_id), + ) + .await + { + let base64_encoded = body_result + .get("base64Encoded") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let body = body_result + .get("body") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if base64_encoded { + result["responseBody"] = json!(format!("[base64, {} chars]", body.len())); + } else { + result["responseBody"] = json!(body); + } + } + } + } + + Ok(result) +} + async fn handle_http_credentials(cmd: &Value, state: &DaemonState) -> Result { let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let session_id = mgr.active_session_id()?.to_string(); diff --git a/cli/src/native/parity_tests.rs b/cli/src/native/parity_tests.rs index 9eef3d8..5ff5d0b 100644 --- a/cli/src/native/parity_tests.rs +++ b/cli/src/native/parity_tests.rs @@ -172,6 +172,7 @@ const DOCUMENTED_ACTIONS: &[&str] = &[ "route", "unroute", "requests", + "request_detail", "credentials", "auth_save", "auth_login", @@ -545,6 +546,11 @@ async fn test_tracked_request_struct() { headers: json!({"Accept": "text/html"}), timestamp: 12345, resource_type: "Document".to_string(), + request_id: "1.1".to_string(), + post_data: None, + status: Some(200), + response_headers: None, + mime_type: Some("text/html".to_string()), }; let serialized = serde_json::to_value(&tr).unwrap(); assert_eq!(serialized["url"], "https://example.com/api"); @@ -565,6 +571,11 @@ async fn test_request_tracking_state() { headers: json!({}), timestamp: 1, resource_type: "Document".to_string(), + request_id: "1.1".to_string(), + post_data: None, + status: None, + response_headers: None, + mime_type: None, }); state.tracked_requests.push(super::actions::TrackedRequest { url: "https://other.com".to_string(), @@ -572,6 +583,11 @@ async fn test_request_tracking_state() { headers: json!({}), timestamp: 2, resource_type: "XHR".to_string(), + request_id: "1.2".to_string(), + post_data: None, + status: None, + response_headers: None, + mime_type: None, }); assert_eq!(state.tracked_requests.len(), 2); @@ -589,6 +605,30 @@ async fn test_request_tracking_state() { assert!(state.tracked_requests.is_empty()); } +#[test] +fn test_matches_status_filter() { + use super::actions::matches_status_filter; + + // Exact match + assert!(matches_status_filter(Some(200), "200")); + assert!(!matches_status_filter(Some(201), "200")); + + // Class match (Nxx) + assert!(matches_status_filter(Some(200), "2xx")); + assert!(matches_status_filter(Some(299), "2xx")); + assert!(!matches_status_filter(Some(301), "2xx")); + assert!(matches_status_filter(Some(404), "4xx")); + + // Range match + assert!(matches_status_filter(Some(400), "400-499")); + assert!(matches_status_filter(Some(499), "400-499")); + assert!(!matches_status_filter(Some(500), "400-499")); + + // None status + assert!(!matches_status_filter(None, "200")); + assert!(!matches_status_filter(None, "2xx")); +} + #[tokio::test] async fn test_addscript_and_addinitscript_separate_dispatch() { let mut state = DaemonState::new(); diff --git a/cli/src/output.rs b/cli/src/output.rs index 69aad87..06875d5 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -384,7 +384,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou .get("resourceType") .and_then(|v| v.as_str()) .unwrap_or(""); - println!("{} {} ({})", method, url, resource_type); + let request_id = req.get("requestId").and_then(|v| v.as_str()).unwrap_or(""); + let status = req.get("status").and_then(|v| v.as_i64()); + match status { + Some(s) => println!( + "[{}] {} {} ({}) {}", + request_id, method, url, resource_type, s + ), + None => println!("[{}] {} {} ({})", request_id, method, url, resource_type), + } } } return; @@ -1735,6 +1743,10 @@ Subcommands: requests [options] List captured requests --clear Clear request log --filter Filter by URL pattern + --type Filter by resource type (comma-separated: xhr,fetch,document) + --method Filter by HTTP method (GET, POST, etc.) + --status Filter by status (200, 2xx, 400-499) + request View full request/response detail (including body) har [path] Record and export a HAR file Global Options: @@ -1747,7 +1759,10 @@ Examples: agent-browser network unroute agent-browser network requests agent-browser network requests --filter "api" + agent-browser network requests --type xhr,fetch + agent-browser network requests --method POST --status 2xx agent-browser network requests --clear + agent-browser network request 1234.5 agent-browser network har start agent-browser network har stop ./capture.har "## diff --git a/docs/src/app/commands/page.mdx b/docs/src/app/commands/page.mdx index 52bae91..9258655 100644 --- a/docs/src/app/commands/page.mdx +++ b/docs/src/app/commands/page.mdx @@ -173,6 +173,10 @@ agent-browser network unroute [url] # Remove routes agent-browser network requests # View tracked requests agent-browser network requests --clear # Clear request log agent-browser network requests --filter # Filter by URL pattern +agent-browser network requests --type xhr,fetch # Filter by resource type +agent-browser network requests --method POST # Filter by HTTP method +agent-browser network requests --status 2xx # Filter by status (200, 2xx, 400-499) +agent-browser network request # View full request/response detail agent-browser network har start # Start HAR recording agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted) ``` diff --git a/skills/agent-browser/SKILL.md b/skills/agent-browser/SKILL.md index c5d323d..c4bfa93 100644 --- a/skills/agent-browser/SKILL.md +++ b/skills/agent-browser/SKILL.md @@ -150,6 +150,10 @@ agent-browser --download-path ./downloads open # Set default download dir # Network agent-browser network requests # Inspect tracked requests +agent-browser network requests --type xhr,fetch # Filter by resource type +agent-browser network requests --method POST # Filter by HTTP method +agent-browser network requests --status 2xx # Filter by status (200, 2xx, 400-499) +agent-browser network request # View full request/response detail agent-browser network route "**/api/*" --abort # Block matching requests agent-browser network har start # Start HAR recording agent-browser network har stop ./capture.har # Stop and save HAR file