Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
43 changes: 39 additions & 4 deletions cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1116,11 +1116,13 @@ pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError
}
"console" => {
let clear = rest.contains(&"--clear");
Ok(json!({ "id": id, "action": "console", "clear": clear }))
let follow = rest.contains(&"--follow") || rest.contains(&"-f");
Ok(json!({ "id": id, "action": "console", "clear": clear, "follow": follow }))
}
"errors" => {
let clear = rest.contains(&"--clear");
Ok(json!({ "id": id, "action": "errors", "clear": clear }))
let follow = rest.contains(&"--follow") || rest.contains(&"-f");
Ok(json!({ "id": id, "action": "errors", "clear": clear, "follow": follow }))
}
"highlight" => {
let sel = rest.first().ok_or_else(|| ParseError::MissingArguments {
Expand Down Expand Up @@ -2037,7 +2039,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 +2069,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
38 changes: 34 additions & 4 deletions cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,24 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
}
return;
}
// Network wait result
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;
}
}
// Cleared (cookies or request log)
if let Some(cleared) = data.get("cleared").and_then(|v| v.as_bool()) {
if cleared {
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 @@ -2086,12 +2110,13 @@ Examples:
r##"
agent-browser console - View console logs

Usage: agent-browser console [--clear]
Usage: agent-browser console [--clear] [--follow]

View browser console output (log, warn, error, info).

Options:
--clear Clear console log buffer
--follow, -f Stream console logs in real-time (until Ctrl+C)

Global Options:
--json Output as JSON
Expand All @@ -2100,18 +2125,21 @@ Global Options:
Examples:
agent-browser console
agent-browser console --clear
agent-browser console --follow
agent-browser console --follow --json
"##
}
"errors" => {
r##"
agent-browser errors - View page errors

Usage: agent-browser errors [--clear]
Usage: agent-browser errors [--clear] [--follow]

View JavaScript errors and uncaught exceptions.

Options:
--clear Clear error buffer
--follow, -f Stream page errors in real-time (until Ctrl+C)

Global Options:
--json Output as JSON
Expand All @@ -2120,6 +2148,7 @@ Global Options:
Examples:
agent-browser errors
agent-browser errors --clear
agent-browser errors --follow
"##
}

Expand Down Expand Up @@ -2484,6 +2513,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 All @@ -2502,8 +2532,8 @@ Debug:
profiler start|stop [path] Record Chrome DevTools profile
record start <path> [url] Start video recording (WebM)
record stop Stop and save video
console [--clear] View console logs
errors [--clear] View page errors
console [--clear] [-f] View console logs (--follow to stream)
errors [--clear] [-f] View page errors (--follow to stream)
highlight <sel> Highlight element
inspect Open Chrome DevTools for the active page
clipboard <op> [text] Read/write clipboard (read, write, copy, paste)
Expand Down
Loading