Skip to content
Closed
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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,15 +272,20 @@ agent-browser wait --fn "window.ready === true" # Wait for JS condition
# Use networkidle only when the page is known to become quiet
agent-browser wait --load networkidle

# Wait until the requests that change the page are done, ignoring pings, images, and other frames
agent-browser wait --load networkquiet

# Wait for text/element to disappear
agent-browser wait --fn "!document.body.innerText.includes('Loading...')"
agent-browser wait "#spinner" --state hidden
```

**Load states:** `load`, `domcontentloaded`, `networkidle`
**Load states:** `load`, `domcontentloaded`, `networkidle`, `networkquiet`

After a page change, prefer a selector, text, URL, or JavaScript condition that represents the state you need. Use `load` or `domcontentloaded` when the lifecycle event is the milestone. `networkidle` is supported for pages known to become quiet, but SSE, WebSockets, polling, and long-polling can keep it from resolving.

`networkquiet` resolves once the requests that can change what the page shows have been done for 500 ms: the page's own document, scripts, stylesheets, and XHR or fetch data. Analytics pings, images, fonts, media, prefetches, and other frames' documents are not counted, so a page that keeps sending them still goes quiet. Quiet is counted from the start of the wait, and requests an action sent in the 3 seconds before the wait are waited for too. A fetch that never ends, such as a long poll, can still keep it from resolving.

### Batch Execution

Execute multiple commands in a single invocation. Commands can be passed as quoted arguments or piped as JSON via stdin. This avoids per-command process startup overhead when running multi-step workflows.
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2807,7 +2807,7 @@ fn parse_diff(rest: &[&str], id: &str) -> Result<Value, ParseError> {
} else {
return Err(ParseError::MissingArguments {
context: "diff url --wait-until".to_string(),
usage: "diff url <url1> <url2> --wait-until <load|domcontentloaded|networkidle>",
usage: "diff url <url1> <url2> --wait-until <load|domcontentloaded|networkidle|networkquiet>",
});
}
}
Expand Down
30 changes: 29 additions & 1 deletion cli/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -925,7 +925,7 @@ fn tools() -> Vec<Value> {
wait_tool(TOOL_WAIT_FOR_SELECTOR, "Wait for selector", "Wait for an element to appear.", json!({ "selector": selector_schema() }), &["selector"]),
wait_tool(TOOL_WAIT_FOR_TEXT, "Wait for text", "Wait for text to appear.", json!({ "text": { "type": "string" } }), &["text"]),
wait_tool(TOOL_WAIT_FOR_URL, "Wait for URL", "Wait for the current URL to match a pattern.", json!({ "url": { "type": "string", "description": "URL glob or pattern." } }), &["url"]),
wait_tool(TOOL_WAIT_FOR_LOAD, "Wait for load state", "Wait for a page load state.", json!({ "state": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle"] } }), &["state"]),
wait_tool(TOOL_WAIT_FOR_LOAD, "Wait for load state", "Wait for a page load state.", json!({ "state": { "type": "string", "enum": ["load", "domcontentloaded", "networkidle", "networkquiet"] } }), &["state"]),
wait_tool(TOOL_WAIT_FOR_FUNCTION, "Wait for function", "Wait for a JavaScript expression to become truthy.", json!({ "expression": { "type": "string" } }), &["expression"]),
tool(
TOOL_SCREENSHOT,
Expand Down Expand Up @@ -4850,6 +4850,34 @@ mod tests {
assert_eq!(modes, &vec![json!("all"), json!("text"), json!("none")]);
}

#[test]
fn tool_schema_wait_for_load_states_match_cli() {
use crate::native::browser::WaitUntil;
let tools = tools();
let wait_for_load = tools
.iter()
.find(|t| t["name"].as_str() == Some(TOOL_WAIT_FOR_LOAD))
.unwrap();
let states = wait_for_load["inputSchema"]["properties"]["state"]["enum"]
.as_array()
.unwrap();
// Every state the CLI's `wait --load` reads, and each read as itself
// rather than falling back to `load`.
let read: Vec<_> = states
.iter()
.map(|state| WaitUntil::from_str(state.as_str().unwrap()))
.collect();
assert_eq!(
read,
vec![
WaitUntil::Load,
WaitUntil::DomContentLoaded,
WaitUntil::NetworkIdle,
WaitUntil::NetworkQuiet,
]
);
}

#[test]
fn record_urls_preserve_navigation_schemes() {
for url in ["data:text/html,hello", "about:blank", "https://example.com"] {
Expand Down
55 changes: 50 additions & 5 deletions cli/src/native/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::validation::{is_valid_session_name, session_name_error};

use super::a11y;
use super::auth;
use super::browser::{should_track_target, BrowserManager, WaitUntil};
use super::browser::{should_track_target, wall_now, BrowserManager, InFlight, WaitUntil};
use super::cdp::chrome::{prepare_nss_home, LaunchOptions};
use super::cdp::client::CdpClient;
use super::cdp::types::{
Expand Down Expand Up @@ -597,6 +597,9 @@ pub struct DaemonState {
pub routes: Arc<RwLock<Vec<RouteEntry>>>,
pub tracked_requests: Vec<TrackedRequest>,
pub request_tracking: bool,
/// The active page's page-changing requests not yet finished, for a
/// `networkquiet` wait that starts after the action that sent them.
pub(crate) in_flight: InFlight,
pub active_frame_id: Option<String>,
/// Cross-origin iframe frame_id → dedicated CDP session_id.
/// Populated by Target.attachedToTarget events from Target.setAutoAttach.
Expand Down Expand Up @@ -848,6 +851,7 @@ impl DaemonState {
routes: Arc::new(RwLock::new(Vec::new())),
tracked_requests: Vec::new(),
request_tracking: false,
in_flight: InFlight::default(),
active_frame_id: None,
iframe_sessions: HashMap::new(),
active_iframe_sessions: HashSet::new(),
Expand Down Expand Up @@ -1875,6 +1879,20 @@ impl DaemonState {
false
};

if let (true, Some(browser), Some(session_id)) = (
session_matches,
self.browser.as_ref(),
event.session_id.as_deref(),
) {
self.in_flight.note(
session_id,
&event.method,
&event.params,
browser.page_target_id(session_id),
wall_now(),
);
}

// Allow Network events from cross-origin iframe sessions
// when HAR recording or request tracking is active.
let iframe_network_event = !session_matches
Expand Down Expand Up @@ -6751,8 +6769,7 @@ async fn handle_wait(cmd: &Value, state: &mut DaemonState) -> Result<Value, Stri

if let Some(load_state) = cmd.get("loadState").and_then(|v| v.as_str()) {
let wait_until = WaitUntil::from_str(load_state);
mgr.wait_for_lifecycle_external(wait_until, &session_id)
.await?;
wait_for_load_state(state, wait_until, &session_id).await?;
return Ok(json!({ "waited": "load", "state": load_state }));
}

Expand Down Expand Up @@ -9831,7 +9848,7 @@ async fn handle_waitforurl(cmd: &Value, state: &DaemonState) -> Result<Value, St
Ok(json!({ "url": url }))
}

async fn handle_waitforloadstate(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
async fn handle_waitforloadstate(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 load_state = cmd.get("state").and_then(|v| v.as_str()).unwrap_or("load");
Expand All @@ -9840,14 +9857,42 @@ async fn handle_waitforloadstate(cmd: &Value, state: &DaemonState) -> Result<Val
let wait_until = WaitUntil::from_str(load_state);
let _ = tokio::time::timeout(
tokio::time::Duration::from_millis(timeout_ms),
mgr.wait_for_lifecycle_external(wait_until, &session_id),
wait_for_load_state(state, wait_until, &session_id),
)
.await
.map_err(|_| format!("Timeout waiting for load state: {}", load_state))?;

Ok(json!({ "state": load_state }))
}

/// Waits for `wait_until` on `session_id`. The action a `networkquiet` wait
/// follows sent its requests before the wait could subscribe, so it
/// subscribes, then drains what came before: every request is then either
/// kept in flight ([`InFlight`]) or seen by the wait.
async fn wait_for_load_state(
state: &mut DaemonState,
wait_until: WaitUntil,
session_id: &str,
) -> Result<(), String> {
if wait_until != WaitUntil::NetworkQuiet {
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
return mgr
.wait_for_lifecycle_external(wait_until, session_id)
.await;
}
let mut rx = state
.browser
.as_ref()
.ok_or("Browser not launched")?
.client
.subscribe();
state.drain_cdp_events_background().await?;
let in_flight = state.in_flight.recent(session_id, wall_now());
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
mgr.wait_for_network_quiet(session_id, &mut rx, in_flight)
.await
}

async fn handle_waitforfunction(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
Loading