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:
+72
-2
@@ -2078,7 +2078,7 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
|
||||
/// Parse network interception, request inspection, and HAR recording commands.
|
||||
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() {
|
||||
Some("route") => {
|
||||
@@ -2102,12 +2102,34 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
|
||||
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 <requestId>",
|
||||
})?;
|
||||
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<Value, ParseError> {
|
||||
}),
|
||||
None => Err(ParseError::MissingArguments {
|
||||
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 { .. })));
|
||||
}
|
||||
|
||||
#[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]
|
||||
|
||||
+153
-25
@@ -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<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 {
|
||||
@@ -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<Value, S
|
||||
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> {
|
||||
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<Value,
|
||||
}
|
||||
|
||||
let filter = cmd.get("filter").and_then(|v| v.as_str());
|
||||
let requests: Vec<&TrackedRequest> = 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<String> = 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<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> {
|
||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||
let session_id = mgr.active_session_id()?.to_string();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+16
-1
@@ -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 <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
|
||||
|
||||
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
|
||||
"##
|
||||
|
||||
Reference in New Issue
Block a user