Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0db6604105 | ||
|
|
007fd1b27f | ||
|
|
3d1132af90 | ||
|
|
90ba44cd38 | ||
|
|
52f8ead0f2 |
Generated
+1
-1
@@ -45,7 +45,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.2"
|
version = "0.27.0-fork.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-browser-stealth"
|
name = "agent-browser-stealth"
|
||||||
version = "0.27.0-fork.2"
|
version = "0.27.0-fork.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Fast browser automation CLI for AI agents"
|
description = "Fast browser automation CLI for AI agents"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
|
|||||||
+73
-3
@@ -614,17 +614,44 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
return Ok(cmd);
|
return Ok(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --gone / --hidden: wait for an element to leave the DOM or
|
||||||
|
// become invisible. Useful after a click that's supposed to
|
||||||
|
// close a dialog, so the next command fails fast instead of
|
||||||
|
// racing into a half-rendered UI.
|
||||||
|
let state_override = if rest.iter().any(|&s| s == "--gone" || s == "--detached") {
|
||||||
|
Some("detached")
|
||||||
|
} else if rest.iter().any(|&s| s == "--hidden") {
|
||||||
|
Some("hidden")
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Default: selector or timeout
|
// Default: selector or timeout
|
||||||
if let Some(arg) = rest.first() {
|
// First non-flag positional is selector or numeric timeout
|
||||||
|
let positional = rest.iter().find(|&&s| !s.starts_with("--"));
|
||||||
|
let timeout_ms = rest
|
||||||
|
.iter()
|
||||||
|
.position(|&s| s == "--timeout")
|
||||||
|
.and_then(|idx| rest.get(idx + 1))
|
||||||
|
.and_then(|s| s.parse::<u64>().ok());
|
||||||
|
|
||||||
|
if let Some(arg) = positional {
|
||||||
if let Ok(timeout) = arg.parse::<u64>() {
|
if let Ok(timeout) = arg.parse::<u64>() {
|
||||||
Ok(json!({ "id": id, "action": "wait", "timeout": timeout }))
|
Ok(json!({ "id": id, "action": "wait", "timeout": timeout }))
|
||||||
} else {
|
} else {
|
||||||
Ok(json!({ "id": id, "action": "wait", "selector": arg }))
|
let mut cmd = json!({ "id": id, "action": "wait", "selector": arg });
|
||||||
|
if let Some(state) = state_override {
|
||||||
|
cmd["state"] = json!(state);
|
||||||
|
}
|
||||||
|
if let Some(t) = timeout_ms {
|
||||||
|
cmd["timeout"] = json!(t);
|
||||||
|
}
|
||||||
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(ParseError::MissingArguments {
|
Err(ParseError::MissingArguments {
|
||||||
context: "wait".to_string(),
|
context: "wait".to_string(),
|
||||||
usage: "wait <selector|ms|--url|--load|--fn|--text>",
|
usage: "wait <selector|ms> [--gone|--hidden] [--timeout ms]",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5185,4 +5212,47 @@ mod tests {
|
|||||||
let cmd = parse_command(&args("find role button"), &default_flags()).unwrap();
|
let cmd = parse_command(&args("find role button"), &default_flags()).unwrap();
|
||||||
assert_eq!(cmd["subaction"], "click");
|
assert_eq!(cmd["subaction"], "click");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === wait --gone / --hidden ===
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_selector_default_visible() {
|
||||||
|
let cmd = parse_command(&args("wait .toast"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "wait");
|
||||||
|
assert_eq!(cmd["selector"], ".toast");
|
||||||
|
assert!(cmd.get("state").is_none(), "default state stays implicit");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_selector_gone_sets_detached_state() {
|
||||||
|
let cmd = parse_command(&args("wait .toast --gone"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["selector"], ".toast");
|
||||||
|
assert_eq!(cmd["state"], "detached");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_selector_hidden_sets_hidden_state() {
|
||||||
|
let cmd = parse_command(&args("wait .toast --hidden"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["state"], "hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_gone_with_timeout() {
|
||||||
|
let cmd = parse_command(
|
||||||
|
&args("wait .modal --gone --timeout 2000"),
|
||||||
|
&default_flags(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cmd["selector"], ".modal");
|
||||||
|
assert_eq!(cmd["state"], "detached");
|
||||||
|
assert_eq!(cmd["timeout"], 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_numeric_timeout_still_works() {
|
||||||
|
// `wait 500` keeps meaning "sleep 500ms", not "wait for selector 500"
|
||||||
|
let cmd = parse_command(&args("wait 500"), &default_flags()).unwrap();
|
||||||
|
assert_eq!(cmd["timeout"], 500);
|
||||||
|
assert!(cmd.get("selector").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,6 +163,30 @@ pub async fn resolve_element_center(
|
|||||||
|
|
||||||
// Try cached backend_node_id first (fast path)
|
// Try cached backend_node_id first (fast path)
|
||||||
if let Some(backend_node_id) = entry.backend_node_id {
|
if let Some(backend_node_id) = entry.backend_node_id {
|
||||||
|
// Identity check: React often re-uses the same DOM node when
|
||||||
|
// re-rendering — backendNodeId stays the same but accessibleName
|
||||||
|
// / role changes. Without this verification, `click @e20` (saved
|
||||||
|
// when the button said "Add post") happily clicks the *same*
|
||||||
|
// node that now says "Post all", silently submitting the thread.
|
||||||
|
//
|
||||||
|
// Set AGENT_BROWSER_VERIFY_REF=0 to skip (saves one CDP
|
||||||
|
// roundtrip per ref-based interaction; only safe if you know
|
||||||
|
// the page is static between snapshot and click).
|
||||||
|
if std::env::var("AGENT_BROWSER_VERIFY_REF").as_deref() != Ok("0") {
|
||||||
|
if let Err(e) = verify_ref_identity(
|
||||||
|
client,
|
||||||
|
effective_session_id,
|
||||||
|
backend_node_id,
|
||||||
|
&ref_id,
|
||||||
|
&entry.role,
|
||||||
|
&entry.name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let result: Result<DomGetBoxModelResult, String> = client
|
let result: Result<DomGetBoxModelResult, String> = client
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"DOM.getBoxModel",
|
"DOM.getBoxModel",
|
||||||
@@ -230,6 +254,24 @@ pub async fn resolve_element_object_id(
|
|||||||
|
|
||||||
// Try cached backend_node_id first (fast path)
|
// Try cached backend_node_id first (fast path)
|
||||||
if let Some(backend_node_id) = entry.backend_node_id {
|
if let Some(backend_node_id) = entry.backend_node_id {
|
||||||
|
// Same identity guard as resolve_element_center — see that
|
||||||
|
// function for why React DOM-node-reuse breaks ref-based
|
||||||
|
// interactions if we skip this.
|
||||||
|
if std::env::var("AGENT_BROWSER_VERIFY_REF").as_deref() != Ok("0") {
|
||||||
|
if let Err(e) = verify_ref_identity(
|
||||||
|
client,
|
||||||
|
effective_session_id,
|
||||||
|
backend_node_id,
|
||||||
|
&ref_id,
|
||||||
|
&entry.role,
|
||||||
|
&entry.name,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let result: Result<DomResolveNodeResult, String> = client
|
let result: Result<DomResolveNodeResult, String> = client
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"DOM.resolveNode",
|
"DOM.resolveNode",
|
||||||
@@ -333,6 +375,57 @@ fn resolve_frame_session<'a>(
|
|||||||
.unwrap_or(session_id)
|
.unwrap_or(session_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Verify that the cached backendNodeId still has the same accessible role
|
||||||
|
/// and name it had when the snapshot ran. Catches the case where React (or
|
||||||
|
/// any reconciler) reused the DOM node for a different component instance
|
||||||
|
/// — same physical node, different semantics.
|
||||||
|
///
|
||||||
|
/// On mismatch, returns an actionable error naming both the snapshot label
|
||||||
|
/// and the current label so the agent can re-snapshot intelligently.
|
||||||
|
/// On any CDP failure (e.g. node deleted), returns Ok(()) so the caller's
|
||||||
|
/// existing fallback (`find_node_id_by_role_name`) takes over.
|
||||||
|
async fn verify_ref_identity(
|
||||||
|
client: &CdpClient,
|
||||||
|
session_id: &str,
|
||||||
|
backend_node_id: i64,
|
||||||
|
ref_id: &str,
|
||||||
|
expected_role: &str,
|
||||||
|
expected_name: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let params = serde_json::json!({
|
||||||
|
"backendNodeId": backend_node_id,
|
||||||
|
"fetchRelatives": false,
|
||||||
|
});
|
||||||
|
let resp: Result<GetFullAXTreeResult, String> = client
|
||||||
|
.send_command_typed("Accessibility.getPartialAXTree", ¶ms, Some(session_id))
|
||||||
|
.await;
|
||||||
|
let Ok(tree) = resp else {
|
||||||
|
// Node likely gone; let the box-model call fail and trigger fallback.
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
// Find the AXNode for our backendNodeId. fetchRelatives=false still
|
||||||
|
// returns ancestors; the target node has the matching backendNodeId.
|
||||||
|
let Some(node) = tree
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.find(|n| n.backend_d_o_m_node_id == Some(backend_node_id))
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let actual_role = extract_ax_string(&node.role);
|
||||||
|
let actual_name = extract_ax_string(&node.name);
|
||||||
|
if actual_role == expected_role && actual_name == expected_name {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(format!(
|
||||||
|
"Ref {} no longer matches its snapshot. Was [{} \"{}\"], now [{} \"{}\"].\n\
|
||||||
|
The DOM mutated between snapshot and interaction (typical with React/Vue \
|
||||||
|
reusing nodes during re-render). Take a fresh snapshot, then re-target.\n\
|
||||||
|
To bypass this guard set AGENT_BROWSER_VERIFY_REF=0.",
|
||||||
|
ref_id, expected_role, expected_name, actual_role, actual_name,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-query the accessibility tree to find a node matching role+name+nth,
|
/// Re-query the accessibility tree to find a node matching role+name+nth,
|
||||||
/// returning its fresh backendDOMNodeId. This uses the same data source
|
/// returning its fresh backendDOMNodeId. This uses the same data source
|
||||||
/// (Accessibility.getFullAXTree) that built the ref map during snapshot,
|
/// (Accessibility.getFullAXTree) that built the ref map during snapshot,
|
||||||
|
|||||||
@@ -884,6 +884,38 @@ pub async fn tap_touch(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// After a click is dispatched, give the page two animation frames + a
|
||||||
|
/// microtask boundary to let React/Vue/Svelte commit any state update
|
||||||
|
/// scheduled by the click handler. Without this wait, follow-up commands
|
||||||
|
/// (e.g. `inserttext` against the textbox the click was supposed to mount)
|
||||||
|
/// race the renderer and can land on stale or wrong elements.
|
||||||
|
///
|
||||||
|
/// The wait is bounded to ~33ms in the common case (two RAFs at 60fps) and
|
||||||
|
/// returns immediately on any error — never an exception path.
|
||||||
|
///
|
||||||
|
/// Set `AGENT_BROWSER_CLICK_WAIT_STABLE=0` to disable for perf-sensitive
|
||||||
|
/// scripts that don't drive SPA UIs.
|
||||||
|
async fn wait_for_paint_settled(client: &CdpClient, session_id: &str) {
|
||||||
|
if std::env::var("AGENT_BROWSER_CLICK_WAIT_STABLE").as_deref() == Ok("0") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let script = "new Promise(resolve => \
|
||||||
|
requestAnimationFrame(() => \
|
||||||
|
requestAnimationFrame(() => \
|
||||||
|
queueMicrotask(() => resolve(true)))))";
|
||||||
|
let _ = client
|
||||||
|
.send_command_typed::<_, Value>(
|
||||||
|
"Runtime.evaluate",
|
||||||
|
&EvaluateParams {
|
||||||
|
expression: script.to_string(),
|
||||||
|
return_by_value: Some(true),
|
||||||
|
await_promise: Some(true),
|
||||||
|
},
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
async fn dispatch_click(
|
async fn dispatch_click(
|
||||||
client: &CdpClient,
|
client: &CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
@@ -955,6 +987,7 @@ async fn dispatch_click(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
wait_for_paint_settled(client, session_id).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agent-browser-stealth",
|
"name": "agent-browser-stealth",
|
||||||
"version": "0.27.0-fork.2",
|
"version": "0.27.0-fork.4",
|
||||||
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
Reference in New Issue
Block a user