Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feat-network-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-browser": minor
---

Add `network wait` subcommand for waiting on specific network responses by URL pattern, with optional `--status`, `--method`, and `--timeout` filters.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ agent-browser network route <url> --body <json> # 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 wait "/api/login" --status 200 # Wait for specific response
agent-browser network wait "/api/data" --method POST # Wait for POST response
```

### Tabs & Windows
Expand Down Expand Up @@ -291,8 +293,10 @@ agent-browser profiler start # Start Chrome DevTools profiling
agent-browser profiler stop [path] # Stop and save profile (.json)
agent-browser console # View console messages (log, error, warn, info)
agent-browser console --clear # Clear console
agent-browser console --follow # Stream console logs in real-time (Ctrl+C to stop)
agent-browser errors # View page errors (uncaught JavaScript exceptions)
agent-browser errors --clear # Clear errors
agent-browser errors --follow # Stream page errors in real-time (Ctrl+C to stop)
agent-browser highlight <sel> # Highlight element
agent-browser inspect # Open Chrome DevTools for the active page
agent-browser state save <path> # Save auth state
Expand Down
37 changes: 35 additions & 2 deletions cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2037,7 +2037,7 @@ fn parse_set(rest: &[&str], id: &str) -> Result<Value, ParseError> {
}

fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
const VALID: &[&str] = &["route", "unroute", "requests"];
const VALID: &[&str] = &["route", "unroute", "requests", "wait"];

match rest.first().copied() {
Some("route") => {
Expand Down Expand Up @@ -2067,13 +2067,46 @@ fn parse_network(rest: &[&str], id: &str) -> Result<Value, ParseError> {
}
Ok(cmd)
}
Some("wait") => {
let url_pattern = rest.get(1).ok_or_else(|| ParseError::MissingArguments {
context: "network wait".to_string(),
usage: "network wait <url-pattern> [--status <code>] [--method <method>] [--timeout <ms>]",
})?;
let mut cmd = json!({ "id": id, "action": "network_wait", "urlPattern": url_pattern });
if let Some(i) = rest.iter().position(|&s| s == "--status") {
if let Some(code) = rest.get(i + 1) {
cmd["status"] = json!(code.parse::<u16>().map_err(|_| {
ParseError::InvalidValue {
message: format!("Invalid status code: {}", code),
usage: "network wait <url-pattern> [--status <code>] [--method <method>] [--timeout <ms>]",
}
})?);
}
}
if let Some(i) = rest.iter().position(|&s| s == "--method") {
if let Some(m) = rest.get(i + 1) {
cmd["method"] = json!(m.to_uppercase());
}
}
if let Some(i) = rest.iter().position(|&s| s == "--timeout") {
if let Some(t) = rest.get(i + 1) {
cmd["timeout"] = json!(t.parse::<u64>().map_err(|_| {
ParseError::InvalidValue {
message: format!("Invalid timeout value: {}", t),
usage: "network wait <url-pattern> [--status <code>] [--method <method>] [--timeout <ms>]",
}
})?);
}
}
Ok(cmd)
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
}),
None => Err(ParseError::MissingArguments {
context: "network".to_string(),
usage: "network <route|unroute|requests> [args...]",
usage: "network <route|unroute|requests|wait> [args...]",
}),
}
}
Expand Down
122 changes: 122 additions & 0 deletions cli/src/native/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,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,
"network_wait" => handle_network_wait(cmd, state).await,
"credentials" => handle_http_credentials(cmd, state).await,
"emulatemedia" => handle_set_media(cmd, state).await,
"auth_save" => handle_auth_save(cmd).await,
Expand Down Expand Up @@ -4764,6 +4765,127 @@ async fn handle_requests(cmd: &Value, state: &mut DaemonState) -> Result<Value,
Ok(json!({ "requests": requests }))
}

async fn handle_network_wait(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
let session_id = mgr.active_session_id()?.to_string();
let url_pattern = cmd
.get("urlPattern")
.and_then(|v| v.as_str())
.ok_or("Missing 'urlPattern' parameter")?;
let status_filter = cmd.get("status").and_then(|v| v.as_u64()).map(|v| v as i64);
let method_filter = cmd.get("method").and_then(|v| v.as_str());
let timeout_ms = cmd.get("timeout").and_then(|v| v.as_u64()).unwrap_or(30000);

// Enable network tracking if not already active
if !state.request_tracking {
state.request_tracking = true;
let _ = mgr
.client
.send_command_no_params("Network.enable", Some(&session_id))
.await;
}

let mut rx = mgr.client.subscribe();
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);

// Track request methods from requestWillBeSent events so we have a
// reliable method for both HTTP/1.1 and HTTP/2 (the `:method`
// pseudo-header in response.requestHeaders only exists for HTTP/2).
let mut request_methods: std::collections::HashMap<String, String> =
std::collections::HashMap::new();

loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(format!(
"Timeout waiting for network response matching '{}'",
url_pattern
));
}

match tokio::time::timeout(remaining, rx.recv()).await {
Ok(Ok(event)) => {
if event.method == "Network.requestWillBeSent"
&& event.session_id.as_deref() == Some(&session_id)
{
if let (Some(request_id), Some(request)) = (
event.params.get("requestId").and_then(|v| v.as_str()),
event.params.get("request"),
) {
let method = request
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("GET")
.to_string();
request_methods.insert(request_id.to_string(), method);
}
}

if event.method == "Network.responseReceived"
&& event.session_id.as_deref() == Some(&session_id)
{
let response = match event.params.get("response") {
Some(r) => r,
None => continue,
};
let resp_url = match response.get("url").and_then(|u| u.as_str()) {
Some(u) => u,
None => continue,
};

if !resp_url.contains(url_pattern) {
continue;
}

let status = response.get("status").and_then(|v| v.as_i64()).unwrap_or(0);
if let Some(expected) = status_filter {
if status != expected {
continue;
}
}

let request_id = event
.params
.get("requestId")
.and_then(|v| v.as_str())
.unwrap_or("");
let resp_method = request_methods
.get(request_id)
.map(|s| s.as_str())
.unwrap_or("GET");
if let Some(expected_method) = method_filter {
if !resp_method.eq_ignore_ascii_case(expected_method) {
continue;
}
}

let resource_type = event
.params
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("Other");

return Ok(json!({
"url": resp_url,
"method": resp_method,
"status": status,
"resourceType": resource_type,
}));
}
}
Ok(Err(_)) => {
return Err("Event channel closed".to_string());
}
Err(_) => {
return Err(format!(
"Timeout waiting for network response matching '{}'",
url_pattern
));
}
}
}
}

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();
Expand Down
25 changes: 25 additions & 0 deletions cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,24 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
}
return;
}
// Network wait result (check before generic URL handler since it also has a "url" field)
if action == Some("network_wait") {
if let (Some(method), Some(url), Some(status)) = (
data.get("method").and_then(|v| v.as_str()),
data.get("url").and_then(|v| v.as_str()),
data.get("status").and_then(|v| v.as_i64()),
) {
let resource_type = data
.get("resourceType")
.and_then(|v| v.as_str())
.unwrap_or("Other");
println!(
"Matched: {} {} -> {} ({})",
method, url, status, resource_type
);
return;
}
}
// Navigation response
if let Some(url) = data.get("url").and_then(|v| v.as_str()) {
if let Some(title) = data.get("title").and_then(|v| v.as_str()) {
Expand Down Expand Up @@ -1721,6 +1739,10 @@ Subcommands:
requests [options] List captured requests
--clear Clear request log
--filter <pattern> Filter by URL pattern
wait <url-pattern> Wait for a response matching URL pattern
--status <code> Match only this HTTP status code
--method <method> Match only this HTTP method (GET, POST, etc.)
--timeout <ms> Timeout in milliseconds (default: 30000)

Global Options:
--json Output as JSON
Expand All @@ -1733,6 +1755,8 @@ Examples:
agent-browser network requests
agent-browser network requests --filter "api"
agent-browser network requests --clear
agent-browser network wait "/api/login" --status 200
agent-browser network wait "/api/data" --method POST --timeout 10000
"##
}

Expand Down Expand Up @@ -2484,6 +2508,7 @@ Network: agent-browser network <action>
route <url> [--abort|--body <json>]
unroute [url]
requests [--clear] [--filter <pattern>]
wait <url-pattern> [--status <code>] [--method <method>] [--timeout <ms>]

Storage:
cookies [get|set|clear] Manage cookies (set supports --url, --domain, --path, --httpOnly, --secure, --sameSite, --expires)
Expand Down