fix: resolve Hermes-found CLI bugs (wait --url, find role, invalid selector, polish)
Release binaries / Build macOS ARM64 (push) Has been cancelled
Release binaries / Build macOS x64 (push) Has been cancelled
Release binaries / Build Linux ARM64 (push) Has been cancelled
Release binaries / Build Linux musl ARM64 (push) Has been cancelled
Release binaries / Build Linux musl x64 (push) Has been cancelled
Release binaries / Build Linux x64 (push) Has been cancelled
Release binaries / Build Windows x64 (push) Has been cancelled
Release binaries / Attach binaries to GitHub Release (push) Has been cancelled

- wait --url: the arg parser never read `--timeout`, so a non-matching pattern
  waited the large default and wedged the daemon. Parse it. Also: matching was a
  literal substring (`includes`) so globs never matched — convert `**`/`*`/`?`
  globs to an anchored regex. And `poll_until_true` now bounds each probe with a
  timeout and tolerates transient navigation errors, so a hung `Runtime.evaluate`
  can never block past the deadline (un-wedges the daemon).
- find role <role> [--name]: the query was `[role="X"], X`, which matches a
  literal <X> tag / explicit attribute but NOT implicit-role elements — so
  `find role link` (<a href>) and `find role heading` (<h1>) never matched. Add a
  proper ARIA-role → implicit-element map and broaden accessible-name matching
  (aria-label/title/alt/value/text).
- click on a syntactically-invalid selector returned `✓ Done`: querySelector
  throws, and Runtime.evaluate returned the thrown DOMException as an objectId
  that was clicked as if it were the element. Check exception_details → error.
- output: a title-less page now prints `✓ <url>` instead of an empty title line.
- docs(skill): tab refs are `t2`, not `2` (SKILL.md, electron).

Verified live (isolated launch): wait --url glob matches instantly; non-matching
honors --timeout (2s) and leaves the daemon responsive; find role link/heading
match; invalid selector errors. Unit tests added for the glob + role map + parse.
This commit is contained in:
leeguooooo
2026-06-10 14:48:49 +09:00
parent 372eaf2ef6
commit cf4c27d13d
9 changed files with 230 additions and 48 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ dependencies = [
[[package]]
name = "agent-browser-stealth"
version = "0.27.0-fork.31"
version = "0.27.0-fork.32"
dependencies = [
"aes-gcm",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-browser-stealth"
version = "0.27.0-fork.31"
version = "0.27.0-fork.32"
edition = "2021"
description = "Fast browser automation CLI for AI agents"
license = "Apache-2.0"
+24 -2
View File
@@ -540,7 +540,7 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
// === Wait ===
"wait" => {
// Check for --url flag: wait --url "**/dashboard"
// Check for --url flag: wait --url "**/dashboard" [--timeout ms]
if let Some(idx) = rest.iter().position(|&s| s == "--url" || s == "-u") {
let url = rest
.get(idx + 1)
@@ -548,7 +548,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
context: "wait --url".to_string(),
usage: "wait --url <pattern>",
})?;
return Ok(json!({ "id": id, "action": "waitforurl", "url": url }));
let mut cmd = json!({ "id": id, "action": "waitforurl", "url": url });
// Parse --timeout (without it the default applies — and a
// non-matching pattern would otherwise wait the full default).
if let Some(t_idx) = rest.iter().position(|&s| s == "--timeout") {
if let Some(ms) = rest.get(t_idx + 1).and_then(|s| s.parse::<u64>().ok()) {
cmd["timeout"] = json!(ms);
}
}
return Ok(cmd);
}
// Check for --load flag: wait --load networkidle
@@ -3803,6 +3811,20 @@ mod tests {
assert_eq!(cmd["url"], "**/dashboard");
}
#[test]
fn test_wait_url_with_timeout() {
// --timeout must be parsed for the --url path; without it a non-matching
// pattern waits the full default (and could wedge the daemon).
let cmd = parse_command(
&args("wait --url **/dashboard --timeout 3000"),
&default_flags(),
)
.unwrap();
assert_eq!(cmd["action"], "waitforurl");
assert_eq!(cmd["url"], "**/dashboard");
assert_eq!(cmd["timeout"], 3000);
}
#[test]
fn test_wait_load() {
let cmd = parse_command(&args("wait --load networkidle"), &default_flags()).unwrap();
+159 -20
View File
@@ -3494,16 +3494,53 @@ async fn wait_for_selector(
poll_until_true(client, session_id, &check_fn, timeout_ms).await
}
/// Convert a URL glob (Playwright-style: `*` matches within a path segment,
/// `**` matches across segments, `?` matches one char) to an anchored regex.
fn url_glob_to_regex(glob: &str) -> String {
let mut re = String::from("^");
let mut chars = glob.chars().peekable();
while let Some(c) = chars.next() {
match c {
'*' => {
if chars.peek() == Some(&'*') {
chars.next();
re.push_str(".*"); // ** — any chars incl. '/'
} else {
re.push_str("[^/]*"); // * — any chars except '/'
}
}
'?' => re.push('.'),
'.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
re.push('\\');
re.push(c);
}
_ => re.push(c),
}
}
re.push('$');
re
}
async fn wait_for_url(
client: &super::cdp::client::CdpClient,
session_id: &str,
pattern: &str,
timeout_ms: u64,
) -> Result<(), String> {
let check_fn = format!(
// A pattern with glob metacharacters is matched as a glob (the core skill
// documents `wait --url "**/dashboard"`); otherwise it's a plain substring
// so exact / partial URLs keep working.
let check_fn = if pattern.contains('*') || pattern.contains('?') {
format!(
"(()=>{{try{{return new RegExp({}).test(location.href)}}catch(e){{return false}}}})()",
serde_json::to_string(&url_glob_to_regex(pattern)).unwrap_or_default()
)
} else {
format!(
"location.href.includes({})",
serde_json::to_string(pattern).unwrap_or_default()
);
)
};
poll_until_true(client, session_id, &check_fn, timeout_ms).await
}
@@ -3539,8 +3576,19 @@ async fn poll_until_true(
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
loop {
let result: super::cdp::types::EvaluateResult = client
.send_command_typed(
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
return Err(format!("Wait timed out after {}ms", timeout_ms));
}
// Bound each probe. A `Runtime.evaluate` issued while the page is
// navigating can hang (the execution context is being torn down); without
// a cap the `.await` would block past the deadline forever and wedge the
// daemon's request loop. Cap at the remaining budget (max 2s per probe).
let probe_cap = remaining.min(tokio::time::Duration::from_secs(2));
let probe = tokio::time::timeout(
probe_cap,
client.send_command_typed::<_, super::cdp::types::EvaluateResult>(
"Runtime.evaluate",
&super::cdp::types::EvaluateParams {
expression: expression.to_string(),
@@ -3548,9 +3596,14 @@ async fn poll_until_true(
await_promise: Some(true),
},
Some(session_id),
),
)
.await?;
.await;
// A probe that timed out or errored (e.g. the execution context was
// replaced mid-navigation) is transient — keep polling until the
// deadline rather than failing or hanging.
if let Ok(Ok(result)) = probe {
if result
.result
.value
@@ -3560,12 +3613,14 @@ async fn poll_until_true(
{
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(format!("Wait timed out after {}ms", timeout_ms));
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let nap = tokio::time::Duration::from_millis(100)
.min(deadline.saturating_duration_since(tokio::time::Instant::now()));
if nap.is_zero() {
return Err(format!("Wait timed out after {}ms", timeout_ms));
}
tokio::time::sleep(nap).await;
}
}
@@ -6004,6 +6059,54 @@ async fn execute_subaction(
}
}
/// CSS selector that matches an ARIA `role` — both an explicit `role="X"`
/// attribute AND the HTML elements that carry that role *implicitly*. The naive
/// `[role="X"], X` form fails for every role whose implicit element has a
/// different tag than the role name (e.g. role `link` ⇒ `<a href>`, not `<link>`;
/// role `heading` ⇒ `<h1>`..`<h6>`), which made `find role link/heading` never
/// match real elements.
fn role_to_query(role: &str) -> String {
let implicit = match role {
"link" => "a[href], area[href]",
"button" => "button, input[type=button], input[type=submit], input[type=reset], summary",
"heading" => "h1, h2, h3, h4, h5, h6",
"textbox" => {
"input[type=text], input[type=search], input[type=email], input[type=url], \
input[type=tel], input[type=password], input:not([type]), textarea"
}
"searchbox" => "input[type=search]",
"checkbox" => "input[type=checkbox]",
"radio" => "input[type=radio]",
"combobox" => "select",
"listbox" => "select[multiple]",
"slider" => "input[type=range]",
"spinbutton" => "input[type=number]",
"img" => "img",
"list" => "ul, ol",
"listitem" => "li",
"table" => "table",
"row" => "tr",
"cell" | "gridcell" => "td",
"columnheader" | "rowheader" => "th",
"article" => "article",
"navigation" => "nav",
"main" => "main",
"banner" => "header",
"contentinfo" => "footer",
"complementary" => "aside",
"figure" => "figure",
"separator" => "hr",
"progressbar" => "progress",
"group" => "fieldset",
_ => "",
};
if implicit.is_empty() {
format!("[role=\"{role}\"], {role}")
} else {
format!("[role=\"{role}\"], {implicit}")
}
}
fn build_role_selector(role: &str, name: Option<&str>, exact: bool) -> String {
match name {
Some(n) => {
@@ -6024,27 +6127,25 @@ async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value,
let name = cmd.get("name").and_then(|v| v.as_str());
let exact = cmd.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);
// Accessible-name approximation: aria-label, then title/alt/value, then the
// element's text. Covers links (text), input buttons (value), images (alt).
let name_match = name
.map(|n| {
let nj = serde_json::to_string(n).unwrap_or_default();
if exact {
format!(
"el.getAttribute('aria-label') === {} || el.textContent.trim() === {}",
serde_json::to_string(n).unwrap_or_default(),
serde_json::to_string(n).unwrap_or_default()
)
format!("__an === {nj}")
} else {
format!(
"(el.getAttribute('aria-label') || '').includes({n}) || el.textContent.includes({n})",
n = serde_json::to_string(n).unwrap_or_default()
)
format!("__an.includes({nj})")
}
})
.unwrap_or_else(|| "true".to_string());
let js = format!(
r#"(() => {{
const els = document.querySelectorAll('[role="{role}"], {role}');
const els = document.querySelectorAll({selector});
for (const el of els) {{
const __an = (el.getAttribute('aria-label') || el.getAttribute('title')
|| el.getAttribute('alt') || el.value || el.textContent || '').trim();
if ({name_match}) {{
el.setAttribute('data-agent-browser-located', 'true');
return true;
@@ -6052,7 +6153,7 @@ async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value,
}}
return false;
}})()"#,
role = role,
selector = serde_json::to_string(&role_to_query(role)).unwrap_or_default(),
name_match = name_match,
);
@@ -8430,6 +8531,44 @@ mod tests {
use crate::test_utils::EnvGuard;
use std::fs;
#[test]
fn test_url_glob_to_regex() {
assert_eq!(url_glob_to_regex("**/dashboard"), "^.*/dashboard$");
assert_eq!(url_glob_to_regex("**iana**"), "^.*iana.*$");
assert_eq!(
url_glob_to_regex("https://x.com/**"),
"^https://x\\.com/.*$"
);
// single * stays within a path segment
assert_eq!(url_glob_to_regex("/a/*/c"), "^/a/[^/]*/c$");
assert_eq!(url_glob_to_regex("/p?ge"), "^/p.ge$");
}
#[test]
fn test_url_glob_regex_matches() {
let re = regex_lite::Regex::new(&url_glob_to_regex("**/help/**")).unwrap();
assert!(re.is_match("https://www.iana.org/help/example-domains"));
assert!(!re.is_match("https://www.iana.org/about"));
let re2 = regex_lite::Regex::new(&url_glob_to_regex("https://www.iana.org/**")).unwrap();
assert!(re2.is_match("https://www.iana.org/help/example-domains"));
}
#[test]
fn test_role_to_query_implicit_elements() {
// links are <a href>, not <link>; headings are h1..h6
assert_eq!(
role_to_query("link"),
"[role=\"link\"], a[href], area[href]"
);
assert_eq!(
role_to_query("heading"),
"[role=\"heading\"], h1, h2, h3, h4, h5, h6"
);
assert!(role_to_query("button").contains("button"));
// unknown/custom roles fall back to the attribute + literal tag
assert_eq!(role_to_query("tablist"), "[role=\"tablist\"], tablist");
}
fn unique_socket_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+12
View File
@@ -440,6 +440,18 @@ pub async fn resolve_element_object_id(
)
.await?;
// A syntactically-invalid selector makes `document.querySelector` THROW.
// With returnByValue:false, Runtime.evaluate then returns the thrown
// DOMException as a remote object *with* an objectId — which would otherwise
// be mistaken for "the element" and silently no-op a `.click()` on it. Treat
// any thrown exception as a hard error so a typo'd selector fails loudly.
if let Some(ex) = result.exception_details {
return Err(format!(
"Invalid selector '{}': {}",
selector_or_ref, ex.text
));
}
let object_id = result
.result
.object_id
+12 -4
View File
@@ -228,12 +228,20 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
}
// 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()) {
println!("{} {}", color::success_indicator(), color::bold(title));
let title = data
.get("title")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|t| !t.is_empty());
match title {
Some(t) => {
println!("{} {}", color::success_indicator(), color::bold(t));
println!(" {}", color::dim(url));
return;
}
println!("{}", url);
// Title-less page: show the URL with the checkmark instead of an
// empty title line.
None => println!("{} {}", color::success_indicator(), color::dim(url)),
}
return;
}
if let Some(cdp_url) = data.get("cdpUrl").and_then(|v| v.as_str()) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agent-browser-stealth",
"version": "0.27.0-fork.31",
"version": "0.27.0-fork.32",
"description": "Browser automation CLI for AI agents — stealth fork with anti-detection",
"type": "module",
"packageManager": "pnpm@11.1.3",
+6 -5
View File
@@ -363,13 +363,14 @@ Pass `--hide-scrollbars false` when launching to keep native scrollbars visible.
```bash
agent-browser tab # list open tabs (with stable tabId)
agent-browser tab new https://docs... # open a new tab (and switch to it)
agent-browser tab 2 # switch to tab 2
agent-browser tab close 2 # close tab 2
agent-browser tab t2 # switch to tab t2
agent-browser tab close t2 # close tab t2
```
Stable `tabId`s mean `tab 2` points at the same tab across commands even
when other tabs open or close. After switching, refs from a prior snapshot
on a different tab no longer apply — re-snapshot.
Tab ids are stable strings (`t1`, `t2`, …), never reused within a session, so
the same id keeps referring to the same tab across commands. Positional
integers are **not** accepted — use `t2`, not `2`. After switching, refs from a
prior snapshot on a different tab no longer apply — re-snapshot.
### Run multiple browsers in parallel
+3 -3
View File
@@ -95,8 +95,8 @@ Electron apps often have multiple windows or webviews. Use tab commands to list
# List all available targets (windows, webviews, etc.)
agent-browser tab
# Switch to a specific tab by index
agent-browser tab 2
# Switch to a specific tab by id (t1, t2, …; integers not accepted)
agent-browser tab t2
# Switch by URL pattern
agent-browser tab --url "*settings*"
@@ -117,7 +117,7 @@ agent-browser tab
# 1: [webview] Embedded Content https://example.com/widget
# Switch to a webview
agent-browser tab 1
agent-browser tab t1
# Interact with the webview normally
agent-browser snapshot -i