feat: add network request detail and filtering for request tracking (#935)

* feat: add network request detail and filtering for request tracking

- Add `network request <requestId>` 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
This commit is contained in:
ChunHao Chen
2026-03-23 10:17:17 -05:00
committed by GitHub
parent 5c5c0d8081
commit ceaee00952
7 changed files with 293 additions and 28 deletions
+4
View File
@@ -270,6 +270,10 @@ agent-browser network route <url> --body <json> # Mock response
agent-browser network unroute [url] # Remove routes agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests agent-browser network requests # View tracked requests
agent-browser network requests --filter api # Filter 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 <requestId> # View full request/response detail
agent-browser network har start # Start HAR recording agent-browser network har start # Start HAR recording
agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted) agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted)
``` ```
+72 -2
View File
@@ -2078,7 +2078,7 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
/// Parse network interception, request inspection, and HAR recording commands. /// Parse network interception, request inspection, and HAR recording commands.
fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> { fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["route", "unroute", "requests", "har"]; const VALID: &[&str] = &["route", "unroute", "requests", "request", "har"];
match rest.first().copied() { match rest.first().copied() {
Some("route") => { Some("route") => {
@@ -2102,12 +2102,34 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
let clear = rest.contains(&"--clear"); let clear = rest.contains(&"--clear");
let filter_idx = rest.iter().position(|&s| s == "--filter"); let filter_idx = rest.iter().position(|&s| s == "--filter");
let filter = filter_idx.and_then(|i| rest.get(i + 1).copied()); 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 }); let mut cmd = json!({ "id": id, "action": "requests", "clear": clear });
if let Some(f) = filter { if let Some(f) = filter {
cmd["filter"] = json!(f); 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) Ok(cmd)
} }
Some("request") => {
let request_id = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "network request".to_string(),
usage: "network request <requestId>",
})?;
Ok(json!({ "id": id, "action": "request_detail", "requestId": request_id }))
}
Some("har") => { Some("har") => {
const HAR_VALID: &[&str] = &["start", "stop"]; const HAR_VALID: &[&str] = &["start", "stop"];
match rest.get(1).copied() { match rest.get(1).copied() {
@@ -2135,7 +2157,7 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
}), }),
None => Err(ParseError::MissingArguments { None => Err(ParseError::MissingArguments {
context: "network".to_string(), context: "network".to_string(),
usage: "network <route|unroute|requests|har> [args...]", usage: "network <route|unroute|requests|request|har> [args...]",
}), }),
} }
} }
@@ -2742,6 +2764,54 @@ mod tests {
assert!(matches!(result, Err(ParseError::MissingArguments { .. }))); 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 === // === Screenshot ===
#[test] #[test]
+153 -25
View File
@@ -109,6 +109,16 @@ pub struct TrackedRequest {
pub timestamp: u64, pub timestamp: u64,
#[serde(rename = "resourceType")] #[serde(rename = "resourceType")]
pub resource_type: String, pub resource_type: String,
#[serde(rename = "requestId")]
pub request_id: String,
#[serde(rename = "postData", skip_serializing_if = "Option::is_none")]
pub post_data: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<i64>,
#[serde(rename = "responseHeaders", skip_serializing_if = "Option::is_none")]
pub response_headers: Option<Value>,
#[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
} }
pub struct FetchPausedRequest { pub struct FetchPausedRequest {
@@ -549,7 +559,7 @@ impl DaemonState {
.unwrap_or("Other") .unwrap_or("Other")
.to_string(); .to_string();
self.har_entries.push(HarEntry { self.har_entries.push(HarEntry {
request_id, request_id: request_id.clone(),
wall_time, wall_time,
method: method.clone(), method: method.clone(),
url: url.clone(), url: url.clone(),
@@ -587,11 +597,21 @@ impl DaemonState {
headers, headers,
timestamp, timestamp,
resource_type, 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") { if let Some(response) = event.params.get("response") {
let request_id = event let request_id = event
.params .params
@@ -625,20 +645,39 @@ impl DaemonState {
.and_then(|v| v.as_i64()) .and_then(|v| v.as_i64())
.unwrap_or(-1); .unwrap_or(-1);
let cdp_timing = response.get("timing").cloned(); let cdp_timing = response.get("timing").cloned();
if let Some(entry) = self if self.har_recording {
.har_entries if let Some(entry) = self
.iter_mut() .har_entries
.rev() .iter_mut()
.find(|e| e.request_id == request_id) .rev()
{ .find(|e| e.request_id == request_id)
entry.status = status; {
entry.status_text = status_text; entry.status = status;
entry.mime_type = mime_type; entry.status_text = status_text;
entry.http_version = http_version; entry.mime_type = mime_type;
entry.response_headers = response_headers; entry.http_version = http_version;
entry.redirect_url = redirect_url; entry.response_headers = response_headers;
entry.response_body_size = encoded_data_length; entry.redirect_url = redirect_url;
entry.cdp_timing = cdp_timing; 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, "route" => handle_route(cmd, state).await,
"unroute" => handle_unroute(cmd, state).await, "unroute" => handle_unroute(cmd, state).await,
"requests" => handle_requests(cmd, state).await, "requests" => handle_requests(cmd, state).await,
"request_detail" => handle_request_detail(cmd, state).await,
"credentials" => handle_http_credentials(cmd, state).await, "credentials" => handle_http_credentials(cmd, state).await,
"emulatemedia" => handle_set_media(cmd, state).await, "emulatemedia" => handle_set_media(cmd, state).await,
"auth_save" => handle_auth_save(cmd).await, "auth_save" => handle_auth_save(cmd).await,
@@ -5611,6 +5651,25 @@ async fn handle_unroute(cmd: &Value, state: &mut DaemonState) -> Result<Value, S
Ok(json!({ "unrouted": label })) Ok(json!({ "unrouted": label }))
} }
pub fn matches_status_filter(status: Option<i64>, filter: &str) -> bool {
let Some(code) = status else { return false };
let f = filter.to_lowercase();
if let Ok(exact) = f.parse::<i64>() {
return code == exact;
}
if f.len() == 3 && f.ends_with("xx") {
if let Ok(prefix) = f[..1].parse::<i64>() {
return code / 100 == prefix;
}
}
if let Some((lo, hi)) = f.split_once('-') {
if let (Ok(lo), Ok(hi)) = (lo.parse::<i64>(), hi.parse::<i64>()) {
return code >= lo && code <= hi;
}
}
false
}
async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> { async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
if cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false) { if cmd.get("clear").and_then(|v| v.as_bool()).unwrap_or(false) {
state.tracked_requests.clear(); state.tracked_requests.clear();
@@ -5630,19 +5689,88 @@ async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value,
} }
let filter = cmd.get("filter").and_then(|v| v.as_str()); let filter = cmd.get("filter").and_then(|v| v.as_str());
let requests: Vec<&TrackedRequest> = if let Some(f) = filter { let type_filter = cmd.get("type").and_then(|v| v.as_str());
state let method_filter = cmd.get("method").and_then(|v| v.as_str());
.tracked_requests let status_filter = cmd.get("status").and_then(|v| v.as_str());
.iter()
.filter(|r| r.url.contains(f)) let type_list: Vec<String> = type_filter
.collect() .map(|t| t.split(',').map(|s| s.trim().to_lowercase()).collect())
} else { .unwrap_or_default();
state.tracked_requests.iter().collect()
}; 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 })) Ok(json!({ "requests": requests }))
} }
async fn handle_request_detail(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
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<Value, String> { async fn handle_http_credentials(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?; let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string(); let session_id = mgr.active_session_id()?.to_string();
+40
View File
@@ -172,6 +172,7 @@ const DOCUMENTED_ACTIONS: &[&str] = &[
"route", "route",
"unroute", "unroute",
"requests", "requests",
"request_detail",
"credentials", "credentials",
"auth_save", "auth_save",
"auth_login", "auth_login",
@@ -545,6 +546,11 @@ async fn test_tracked_request_struct() {
headers: json!({"Accept": "text/html"}), headers: json!({"Accept": "text/html"}),
timestamp: 12345, timestamp: 12345,
resource_type: "Document".to_string(), 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(); let serialized = serde_json::to_value(&tr).unwrap();
assert_eq!(serialized["url"], "https://example.com/api"); assert_eq!(serialized["url"], "https://example.com/api");
@@ -565,6 +571,11 @@ async fn test_request_tracking_state() {
headers: json!({}), headers: json!({}),
timestamp: 1, timestamp: 1,
resource_type: "Document".to_string(), 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 { state.tracked_requests.push(super::actions::TrackedRequest {
url: "https://other.com".to_string(), url: "https://other.com".to_string(),
@@ -572,6 +583,11 @@ async fn test_request_tracking_state() {
headers: json!({}), headers: json!({}),
timestamp: 2, timestamp: 2,
resource_type: "XHR".to_string(), 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); assert_eq!(state.tracked_requests.len(), 2);
@@ -589,6 +605,30 @@ async fn test_request_tracking_state() {
assert!(state.tracked_requests.is_empty()); 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] #[tokio::test]
async fn test_addscript_and_addinitscript_separate_dispatch() { async fn test_addscript_and_addinitscript_separate_dispatch() {
let mut state = DaemonState::new(); let mut state = DaemonState::new();
+16 -1
View File
@@ -384,7 +384,15 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
.get("resourceType") .get("resourceType")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(""); .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; return;
@@ -1735,6 +1743,10 @@ Subcommands:
requests [options] List captured requests requests [options] List captured requests
--clear Clear request log --clear Clear request log
--filter <pattern> Filter by URL pattern --filter <pattern> Filter by URL pattern
--type <types> Filter by resource type (comma-separated: xhr,fetch,document)
--method <method> Filter by HTTP method (GET, POST, etc.)
--status <code> Filter by status (200, 2xx, 400-499)
request <requestId> View full request/response detail (including body)
har <start|stop> [path] Record and export a HAR file har <start|stop> [path] Record and export a HAR file
Global Options: Global Options:
@@ -1747,7 +1759,10 @@ Examples:
agent-browser network unroute agent-browser network unroute
agent-browser network requests agent-browser network requests
agent-browser network requests --filter "api" 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 requests --clear
agent-browser network request 1234.5
agent-browser network har start agent-browser network har start
agent-browser network har stop ./capture.har agent-browser network har stop ./capture.har
"## "##
+4
View File
@@ -173,6 +173,10 @@ agent-browser network unroute [url] # Remove routes
agent-browser network requests # View tracked requests agent-browser network requests # View tracked requests
agent-browser network requests --clear # Clear request log agent-browser network requests --clear # Clear request log
agent-browser network requests --filter <pat> # Filter by URL pattern agent-browser network requests --filter <pat> # 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 <requestId> # View full request/response detail
agent-browser network har start # Start HAR recording agent-browser network har start # Start HAR recording
agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted) agent-browser network har stop [output.har] # Stop and save HAR (temp path if omitted)
``` ```
+4
View File
@@ -150,6 +150,10 @@ agent-browser --download-path ./downloads open <url> # Set default download dir
# Network # Network
agent-browser network requests # Inspect tracked requests 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 <requestId> # View full request/response detail
agent-browser network route "**/api/*" --abort # Block matching requests agent-browser network route "**/api/*" --abort # Block matching requests
agent-browser network har start # Start HAR recording agent-browser network har start # Start HAR recording
agent-browser network har stop ./capture.har # Stop and save HAR file agent-browser network har stop ./capture.har # Stop and save HAR file