Compare commits

..
Author SHA1 Message Date
leeguooooo 3d1132af90 chore(release): bump to 0.27.0-fork.3 — click paint-settle + wait --gone 2026-05-09 02:45:45 +09:00
leeguooooo 90ba44cd38 feat(wait): add --gone / --hidden flags so users can fail fast on closed UIs
Pairs with the click paint-settle fix: even with that, a thread builder
that clicks "Add post" can race a misbehaving handler that closes the
parent modal instead of mounting the next textbox. To make that case
observable instead of silently corrupting the next inserttext, you can
now write:

  click @add-post
  wait .modal --gone --timeout 2000   # asserts modal stays mounted
  inserttext "tweet 3"

If the modal vanished, `wait --gone` succeeds — flip the assertion to
`wait .modal` (default visible) to fail-fast on disappearance.

Implementation just sets `state: "detached"` (or "hidden") on the wait
command — daemon-side `wait_for_selector` already supported these
states; only the CLI parser was missing the user-facing flag.

Also accepts `--detached` as alias for `--gone` to match the daemon's
internal vocabulary.
2026-05-09 02:45:34 +09:00
leeguooooo 52f8ead0f2 fix(click): wait for paint to settle so SPA renders complete before next command
Closes a real-world race that broke X multi-tweet thread composition
(and similar SPA flows): clicking "Add post" returned immediately,
inserttext fired before React had committed the new textarea, the
keystroke landed on the dialog wrapper, and X interpreted the stray
input as a request to dismiss the modal.

After mouseReleased we now wait for two requestAnimationFrame ticks
plus a microtask boundary (~33ms at 60fps, bounded). That's enough
for React/Vue/Svelte to commit any state update scheduled by the
click handler. Errors during the wait are swallowed — a click never
fails because of post-processing.

Opt out for perf-sensitive scripts that don't drive SPA UIs:
  AGENT_BROWSER_CLICK_WAIT_STABLE=0
2026-05-09 02:45:21 +09:00
5 changed files with 109 additions and 6 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.2"
version = "0.27.0-fork.3"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.2"
version = "0.27.0-fork.3"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+73 -3
View File
@@ -614,17 +614,44 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
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
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>() {
Ok(json!({ "id": id, "action": "wait", "timeout": timeout }))
} 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 {
Err(ParseError::MissingArguments {
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();
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());
}
}
+33
View File
@@ -884,6 +884,38 @@ pub async fn tap_touch(
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(
client: &CdpClient,
session_id: &str,
@@ -955,6 +987,7 @@ async fn dispatch_click(
)
.await?;
wait_for_paint_settled(client, session_id).await;
Ok(())
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.2",
"version": "0.27.0-fork.3",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module",
"files": [