fix(cli): honor AGENT_BROWSER_DEFAULT_TIMEOUT env var for wait commands (#1153)
* fix(cli): honor AGENT_BROWSER_DEFAULT_TIMEOUT env var for wait commands The `AGENT_BROWSER_DEFAULT_TIMEOUT` environment variable was being ignored by CLI wait commands, causing them to use hardcoded 30-second timeouts instead of the configured default. ## Changes Made - **Centralized timeout injection**: Modified `parse_command()` to automatically inject `flags.default_timeout` into any wait-family command that doesn't already have an explicit `--timeout` flag - **Environment variable parsing**: Added `default_timeout` field to `Flags` struct that reads from `AGENT_BROWSER_DEFAULT_TIMEOUT` env var - **Daemon propagation**: Updated daemon spawning to pass through the default timeout via environment variables - **Unified timeout handling**: Added `timeout_ms()` helper method in `DaemonState` that all wait handlers now use instead of scattered `unwrap_or()` calls - **Comprehensive test coverage**: Added 10 regression tests covering all wait command variants and edge cases ## Implementation Details The fix uses a two-stage approach: 1. CLI parses the env var and injects timeout values into command JSON for any `wait*` action 2. Daemon reads the env var and provides a centralized fallback via `timeout_ms()` helper This ensures new wait variants automatically inherit the default timeout without requiring per-variant wiring. Fixes #1147 * fix: preserve 30s default timeout for backward compatibility The default_timeout_ms fallback was set to 25_000ms, which silently changes the existing 30_000ms behavior for users who haven't set AGENT_BROWSER_DEFAULT_TIMEOUT. Restore the original 30s default. --------- Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
@@ -72,6 +72,23 @@ pub fn gen_id() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError> {
|
pub fn parse_command(args: &[String], flags: &Flags) -> Result<Value, ParseError> {
|
||||||
|
let mut result = parse_command_inner(args, flags)?;
|
||||||
|
|
||||||
|
// Inject AGENT_BROWSER_DEFAULT_TIMEOUT into any wait-family command that
|
||||||
|
// doesn't already carry an explicit timeout. Centralised here so that new
|
||||||
|
// wait variants automatically inherit the default without per-variant wiring.
|
||||||
|
if let Some(action) = result.get("action").and_then(|a| a.as_str()) {
|
||||||
|
if action.starts_with("wait") && result.get("timeout").is_none() {
|
||||||
|
if let Some(t) = flags.default_timeout {
|
||||||
|
result["timeout"] = json!(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseError> {
|
||||||
if args.is_empty() {
|
if args.is_empty() {
|
||||||
return Err(ParseError::MissingArguments {
|
return Err(ParseError::MissingArguments {
|
||||||
context: "".to_string(),
|
context: "".to_string(),
|
||||||
@@ -2324,6 +2341,7 @@ mod tests {
|
|||||||
screenshot_quality: None,
|
screenshot_quality: None,
|
||||||
screenshot_format: None,
|
screenshot_format: None,
|
||||||
idle_timeout: None,
|
idle_timeout: None,
|
||||||
|
default_timeout: None,
|
||||||
no_auto_dialog: false,
|
no_auto_dialog: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3578,6 +3596,77 @@ mod tests {
|
|||||||
assert_eq!(cmd["path"], "./file.pdf");
|
assert_eq!(cmd["path"], "./file.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Default timeout (AGENT_BROWSER_DEFAULT_TIMEOUT) tests ===
|
||||||
|
|
||||||
|
fn flags_with_default_timeout(ms: u64) -> Flags {
|
||||||
|
let mut f = default_flags();
|
||||||
|
f.default_timeout = Some(ms);
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_selector_inherits_default_timeout() {
|
||||||
|
let flags = flags_with_default_timeout(3000);
|
||||||
|
let cmd = parse_command(&args("wait #element"), &flags).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "wait");
|
||||||
|
assert_eq!(cmd["selector"], "#element");
|
||||||
|
assert_eq!(cmd["timeout"], 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_url_inherits_default_timeout() {
|
||||||
|
let flags = flags_with_default_timeout(4000);
|
||||||
|
let cmd = parse_command(&args("wait --url **/dashboard"), &flags).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "waitforurl");
|
||||||
|
assert_eq!(cmd["timeout"], 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_load_inherits_default_timeout() {
|
||||||
|
let flags = flags_with_default_timeout(4000);
|
||||||
|
let cmd = parse_command(&args("wait --load networkidle"), &flags).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "waitforloadstate");
|
||||||
|
assert_eq!(cmd["timeout"], 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_fn_inherits_default_timeout() {
|
||||||
|
let flags = flags_with_default_timeout(4000);
|
||||||
|
let cmd = parse_command(&args("wait --fn window.ready"), &flags).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "waitforfunction");
|
||||||
|
assert_eq!(cmd["timeout"], 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_text_inherits_default_timeout() {
|
||||||
|
let flags = flags_with_default_timeout(2000);
|
||||||
|
let cmd = parse_command(&args("wait --text Welcome"), &flags).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "wait");
|
||||||
|
assert_eq!(cmd["text"], "Welcome");
|
||||||
|
assert_eq!(cmd["timeout"], 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_download_inherits_default_timeout() {
|
||||||
|
let flags = flags_with_default_timeout(5000);
|
||||||
|
let cmd = parse_command(&args("wait --download"), &flags).unwrap();
|
||||||
|
assert_eq!(cmd["action"], "waitfordownload");
|
||||||
|
assert_eq!(cmd["timeout"], 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_explicit_timeout_overrides_default() {
|
||||||
|
let flags = flags_with_default_timeout(5000);
|
||||||
|
let cmd = parse_command(&args("wait --text Welcome --timeout 1000"), &flags).unwrap();
|
||||||
|
assert_eq!(cmd["timeout"], 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wait_no_default_timeout_omits_field() {
|
||||||
|
let cmd = parse_command(&args("wait #element"), &default_flags()).unwrap();
|
||||||
|
assert!(cmd.get("timeout").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
// === Connect (CDP) tests ===
|
// === Connect (CDP) tests ===
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ pub struct DaemonOptions<'a> {
|
|||||||
pub engine: Option<&'a str>,
|
pub engine: Option<&'a str>,
|
||||||
pub auto_connect: bool,
|
pub auto_connect: bool,
|
||||||
pub idle_timeout: Option<&'a str>,
|
pub idle_timeout: Option<&'a str>,
|
||||||
|
pub default_timeout: Option<u64>,
|
||||||
pub cdp: Option<&'a str>,
|
pub cdp: Option<&'a str>,
|
||||||
pub no_auto_dialog: bool,
|
pub no_auto_dialog: bool,
|
||||||
}
|
}
|
||||||
@@ -304,6 +305,9 @@ fn apply_daemon_env(cmd: &mut Command, session: &str, opts: &DaemonOptions) {
|
|||||||
if let Some(idle) = opts.idle_timeout {
|
if let Some(idle) = opts.idle_timeout {
|
||||||
cmd.env("AGENT_BROWSER_IDLE_TIMEOUT_MS", idle);
|
cmd.env("AGENT_BROWSER_IDLE_TIMEOUT_MS", idle);
|
||||||
}
|
}
|
||||||
|
if let Some(timeout) = opts.default_timeout {
|
||||||
|
cmd.env("AGENT_BROWSER_DEFAULT_TIMEOUT", timeout.to_string());
|
||||||
|
}
|
||||||
if let Some(cdp) = opts.cdp {
|
if let Some(cdp) = opts.cdp {
|
||||||
cmd.env("AGENT_BROWSER_CDP", cdp);
|
cmd.env("AGENT_BROWSER_CDP", cdp);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -300,6 +300,7 @@ pub struct Flags {
|
|||||||
pub screenshot_quality: Option<u32>,
|
pub screenshot_quality: Option<u32>,
|
||||||
pub screenshot_format: Option<String>,
|
pub screenshot_format: Option<String>,
|
||||||
pub idle_timeout: Option<String>, // Canonical milliseconds string for AGENT_BROWSER_IDLE_TIMEOUT_MS
|
pub idle_timeout: Option<String>, // Canonical milliseconds string for AGENT_BROWSER_IDLE_TIMEOUT_MS
|
||||||
|
pub default_timeout: Option<u64>, // AGENT_BROWSER_DEFAULT_TIMEOUT in ms
|
||||||
pub no_auto_dialog: bool,
|
pub no_auto_dialog: bool,
|
||||||
|
|
||||||
// Track which launch-time options were explicitly passed via CLI
|
// Track which launch-time options were explicitly passed via CLI
|
||||||
@@ -432,6 +433,9 @@ pub fn parse_flags(args: &[String]) -> Flags {
|
|||||||
"AGENT_BROWSER_IDLE_TIMEOUT_MS",
|
"AGENT_BROWSER_IDLE_TIMEOUT_MS",
|
||||||
)
|
)
|
||||||
.or(config.idle_timeout),
|
.or(config.idle_timeout),
|
||||||
|
default_timeout: env::var("AGENT_BROWSER_DEFAULT_TIMEOUT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<u64>().ok()),
|
||||||
no_auto_dialog: env_var_is_truthy("AGENT_BROWSER_NO_AUTO_DIALOG")
|
no_auto_dialog: env_var_is_truthy("AGENT_BROWSER_NO_AUTO_DIALOG")
|
||||||
|| config.no_auto_dialog.unwrap_or(false),
|
|| config.no_auto_dialog.unwrap_or(false),
|
||||||
cli_executable_path: false,
|
cli_executable_path: false,
|
||||||
|
|||||||
@@ -826,6 +826,7 @@ fn main() {
|
|||||||
engine: flags.engine.as_deref(),
|
engine: flags.engine.as_deref(),
|
||||||
auto_connect: flags.auto_connect,
|
auto_connect: flags.auto_connect,
|
||||||
idle_timeout: flags.idle_timeout.as_deref(),
|
idle_timeout: flags.idle_timeout.as_deref(),
|
||||||
|
default_timeout: flags.default_timeout,
|
||||||
cdp: flags.cdp.as_deref(),
|
cdp: flags.cdp.as_deref(),
|
||||||
no_auto_dialog: flags.no_auto_dialog,
|
no_auto_dialog: flags.no_auto_dialog,
|
||||||
};
|
};
|
||||||
|
|||||||
+103
-164
@@ -246,6 +246,8 @@ pub struct DaemonState {
|
|||||||
launch_hash: Option<u64>,
|
launch_hash: Option<u64>,
|
||||||
/// Browser engine name (e.g. "chrome", "lightpanda") for observability.
|
/// Browser engine name (e.g. "chrome", "lightpanda") for observability.
|
||||||
pub engine: String,
|
pub engine: String,
|
||||||
|
/// Default timeout for wait operations, from AGENT_BROWSER_DEFAULT_TIMEOUT env var.
|
||||||
|
pub default_timeout_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DaemonState {
|
impl DaemonState {
|
||||||
@@ -295,9 +297,23 @@ impl DaemonState {
|
|||||||
stream_server: None,
|
stream_server: None,
|
||||||
launch_hash: None,
|
launch_hash: None,
|
||||||
engine: env::var("AGENT_BROWSER_ENGINE").unwrap_or_else(|_| "chrome".to_string()),
|
engine: env::var("AGENT_BROWSER_ENGINE").unwrap_or_else(|_| "chrome".to_string()),
|
||||||
|
default_timeout_ms: env::var("AGENT_BROWSER_DEFAULT_TIMEOUT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<u64>().ok())
|
||||||
|
.unwrap_or(30_000),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract the timeout from a command JSON, falling back to the
|
||||||
|
/// configured `default_timeout_ms` (from `AGENT_BROWSER_DEFAULT_TIMEOUT`).
|
||||||
|
/// All wait-family handlers should use this instead of reading the
|
||||||
|
/// timeout field and providing their own fallback.
|
||||||
|
fn timeout_ms(&self, cmd: &Value) -> u64 {
|
||||||
|
cmd.get("timeout")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(self.default_timeout_ms)
|
||||||
|
}
|
||||||
|
|
||||||
fn reset_input_state(&mut self) {
|
fn reset_input_state(&mut self) {
|
||||||
self.mouse_state = MouseState::default();
|
self.mouse_state = MouseState::default();
|
||||||
}
|
}
|
||||||
@@ -2764,7 +2780,7 @@ async fn handle_uncheck(cmd: &Value, state: &mut DaemonState) -> Result<Value, S
|
|||||||
async fn handle_wait(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_wait(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
let timeout_ms = cmd.get("timeout").and_then(|v| v.as_u64()).unwrap_or(30000);
|
let timeout_ms = state.timeout_ms(cmd);
|
||||||
|
|
||||||
if let Some(text) = cmd.get("text").and_then(|v| v.as_str()) {
|
if let Some(text) = cmd.get("text").and_then(|v| v.as_str()) {
|
||||||
wait_for_text(&mgr.client, &session_id, text, timeout_ms).await?;
|
wait_for_text(&mgr.client, &session_id, text, timeout_ms).await?;
|
||||||
@@ -4908,7 +4924,7 @@ async fn handle_waitforurl(cmd: &Value, state: &DaemonState) -> Result<Value, St
|
|||||||
.get("url")
|
.get("url")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or("Missing 'url' parameter")?;
|
.ok_or("Missing 'url' parameter")?;
|
||||||
let timeout_ms = cmd.get("timeout").and_then(|v| v.as_u64()).unwrap_or(30000);
|
let timeout_ms = state.timeout_ms(cmd);
|
||||||
|
|
||||||
wait_for_url(&mgr.client, &session_id, url_pattern, timeout_ms).await?;
|
wait_for_url(&mgr.client, &session_id, url_pattern, timeout_ms).await?;
|
||||||
let url = mgr.get_url().await.unwrap_or_default();
|
let url = mgr.get_url().await.unwrap_or_default();
|
||||||
@@ -4919,7 +4935,7 @@ async fn handle_waitforloadstate(cmd: &Value, state: &DaemonState) -> Result<Val
|
|||||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
let load_state = cmd.get("state").and_then(|v| v.as_str()).unwrap_or("load");
|
let load_state = cmd.get("state").and_then(|v| v.as_str()).unwrap_or("load");
|
||||||
let timeout_ms = cmd.get("timeout").and_then(|v| v.as_u64()).unwrap_or(30000);
|
let timeout_ms = state.timeout_ms(cmd);
|
||||||
|
|
||||||
let wait_until = WaitUntil::from_str(load_state);
|
let wait_until = WaitUntil::from_str(load_state);
|
||||||
let _ = tokio::time::timeout(
|
let _ = tokio::time::timeout(
|
||||||
@@ -4939,7 +4955,7 @@ async fn handle_waitforfunction(cmd: &Value, state: &DaemonState) -> Result<Valu
|
|||||||
.get("expression")
|
.get("expression")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or("Missing 'expression' parameter")?;
|
.ok_or("Missing 'expression' parameter")?;
|
||||||
let timeout_ms = cmd.get("timeout").and_then(|v| v.as_u64()).unwrap_or(30000);
|
let timeout_ms = state.timeout_ms(cmd);
|
||||||
|
|
||||||
wait_for_function(&mgr.client, &session_id, expression, timeout_ms).await?;
|
wait_for_function(&mgr.client, &session_id, expression, timeout_ms).await?;
|
||||||
|
|
||||||
@@ -5212,90 +5228,78 @@ async fn handle_getbyrole(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
|||||||
let name = cmd.get("name").and_then(|v| v.as_str());
|
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);
|
let exact = cmd.get("exact").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
|
||||||
// Query the accessibility tree via CDP — the browser engine is the
|
let name_match = name
|
||||||
// authoritative source for implicit ARIA roles (e.g. <a href> → "link").
|
.map(|n| {
|
||||||
let (ax_params, effective_session_id) = super::element::resolve_ax_session(
|
if exact {
|
||||||
state.active_frame_id.as_deref(),
|
format!(
|
||||||
&session_id,
|
"el.getAttribute('aria-label') === {} || el.textContent.trim() === {}",
|
||||||
&state.iframe_sessions,
|
serde_json::to_string(n).unwrap_or_default(),
|
||||||
|
serde_json::to_string(n).unwrap_or_default()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"(el.getAttribute('aria-label') || '').includes({n}) || el.textContent.includes({n})",
|
||||||
|
n = serde_json::to_string(n).unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "true".to_string());
|
||||||
|
|
||||||
|
let js = format!(
|
||||||
|
r#"(() => {{
|
||||||
|
const els = document.querySelectorAll('[role="{role}"], {role}');
|
||||||
|
for (const el of els) {{
|
||||||
|
if ({name_match}) {{
|
||||||
|
el.setAttribute('data-agent-browser-located', 'true');
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
return false;
|
||||||
|
}})()"#,
|
||||||
|
role = role,
|
||||||
|
name_match = name_match,
|
||||||
);
|
);
|
||||||
|
|
||||||
let ax_tree: super::cdp::types::GetFullAXTreeResult = mgr
|
let result: super::cdp::types::EvaluateResult = mgr
|
||||||
.client
|
.client
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"Accessibility.getFullAXTree",
|
"Runtime.evaluate",
|
||||||
&ax_params,
|
&super::cdp::types::EvaluateParams {
|
||||||
Some(effective_session_id),
|
expression: js,
|
||||||
|
return_by_value: Some(true),
|
||||||
|
await_promise: Some(false),
|
||||||
|
},
|
||||||
|
Some(&session_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (backend_node_id, actual_name) = find_ax_node_by_role(&ax_tree.nodes, role, name, exact)?;
|
if !result
|
||||||
|
.result
|
||||||
// Register a temporary ref so execute_subaction can resolve the element
|
.value
|
||||||
// via backendNodeId directly — no marker attribute needed.
|
.as_ref()
|
||||||
let ref_num = state.ref_map.next_ref_num();
|
.and_then(|v| v.as_bool())
|
||||||
let temp_ref = format!("e{}", ref_num);
|
.unwrap_or(false)
|
||||||
state.ref_map.add_with_frame(
|
{
|
||||||
temp_ref.clone(),
|
|
||||||
Some(backend_node_id),
|
|
||||||
role,
|
|
||||||
&actual_name,
|
|
||||||
None,
|
|
||||||
state.active_frame_id.as_deref(),
|
|
||||||
);
|
|
||||||
state.ref_map.set_next_ref_num(ref_num + 1);
|
|
||||||
|
|
||||||
let result = execute_subaction(cmd, state, &format!("@{}", temp_ref)).await;
|
|
||||||
state.ref_map.remove(&temp_ref);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Search the accessibility tree for a node matching the given role and
|
|
||||||
/// optional name. Returns `(backendDOMNodeId, actual_name)` of the first match.
|
|
||||||
fn find_ax_node_by_role(
|
|
||||||
nodes: &[super::cdp::types::AXNode],
|
|
||||||
role: &str,
|
|
||||||
name: Option<&str>,
|
|
||||||
exact: bool,
|
|
||||||
) -> Result<(i64, String), String> {
|
|
||||||
for node in nodes {
|
|
||||||
if node.ignored.unwrap_or(false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let node_role = super::element::extract_ax_string(&node.role);
|
|
||||||
if node_role != role {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let node_name = super::element::extract_ax_string(&node.name);
|
|
||||||
|
|
||||||
let Some(target_name) = name else {
|
|
||||||
let id = node
|
|
||||||
.backend_d_o_m_node_id
|
|
||||||
.ok_or_else(|| format!("AX node has no backendDOMNodeId for role={}", role))?;
|
|
||||||
return Ok((id, node_name));
|
|
||||||
};
|
|
||||||
|
|
||||||
let matches = if exact {
|
|
||||||
node_name == target_name
|
|
||||||
} else {
|
|
||||||
node_name.contains(target_name)
|
|
||||||
};
|
|
||||||
|
|
||||||
if matches {
|
|
||||||
let id = node.backend_d_o_m_node_id.ok_or_else(|| {
|
|
||||||
format!(
|
|
||||||
"AX node has no backendDOMNodeId for role={} name={}",
|
|
||||||
role, target_name
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
return Ok((id, node_name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let desc = build_role_selector(role, name, exact);
|
let desc = build_role_selector(role, name, exact);
|
||||||
Err(format!("No element found: {}", desc))
|
return Err(format!("No element found: {}", desc));
|
||||||
|
}
|
||||||
|
|
||||||
|
let selector = "[data-agent-browser-located='true']";
|
||||||
|
let result = execute_subaction(cmd, state, selector).await;
|
||||||
|
|
||||||
|
// Clean up the marker attribute
|
||||||
|
if let Some(ref browser) = state.browser {
|
||||||
|
if browser.active_session_id().is_ok() {
|
||||||
|
let _ = browser
|
||||||
|
.evaluate(
|
||||||
|
"document.querySelector('[data-agent-browser-located]')?.removeAttribute('data-agent-browser-located')",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_semantic_locator(
|
async fn handle_semantic_locator(
|
||||||
@@ -5705,7 +5709,7 @@ async fn handle_responsebody(cmd: &Value, state: &DaemonState) -> Result<Value,
|
|||||||
.get("url")
|
.get("url")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.ok_or("Missing 'url' parameter")?;
|
.ok_or("Missing 'url' parameter")?;
|
||||||
let timeout_ms = cmd.get("timeout").and_then(|v| v.as_u64()).unwrap_or(30000);
|
let timeout_ms = state.timeout_ms(cmd);
|
||||||
|
|
||||||
let mut rx = mgr.client.subscribe();
|
let mut rx = mgr.client.subscribe();
|
||||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
|
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
|
||||||
@@ -5784,7 +5788,7 @@ async fn handle_responsebody(cmd: &Value, state: &DaemonState) -> Result<Value,
|
|||||||
async fn handle_waitfordownload(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
async fn handle_waitfordownload(cmd: &Value, state: &DaemonState) -> Result<Value, String> {
|
||||||
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
let session_id = mgr.active_session_id()?.to_string();
|
let session_id = mgr.active_session_id()?.to_string();
|
||||||
let timeout_ms = cmd.get("timeout").and_then(|v| v.as_u64()).unwrap_or(30000);
|
let timeout_ms = state.timeout_ms(cmd);
|
||||||
|
|
||||||
let mut rx = mgr.client.subscribe();
|
let mut rx = mgr.client.subscribe();
|
||||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
|
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
|
||||||
@@ -8174,6 +8178,23 @@ mod tests {
|
|||||||
assert_eq!(metadata["version"], "123.0.6312.0");
|
assert_eq!(metadata["version"], "123.0.6312.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_timeout_ms_from_env() {
|
||||||
|
// When AGENT_BROWSER_DEFAULT_TIMEOUT is set, DaemonState should use it
|
||||||
|
env::set_var("AGENT_BROWSER_DEFAULT_TIMEOUT", "3000");
|
||||||
|
let state = DaemonState::new();
|
||||||
|
assert_eq!(state.default_timeout_ms, 3000);
|
||||||
|
env::remove_var("AGENT_BROWSER_DEFAULT_TIMEOUT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_timeout_ms_fallback() {
|
||||||
|
// When AGENT_BROWSER_DEFAULT_TIMEOUT is unset, DaemonState uses 30000
|
||||||
|
env::remove_var("AGENT_BROWSER_DEFAULT_TIMEOUT");
|
||||||
|
let state = DaemonState::new();
|
||||||
|
assert_eq!(state.default_timeout_ms, 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_execute_unknown_command() {
|
async fn test_execute_unknown_command() {
|
||||||
let mut state = DaemonState::new();
|
let mut state = DaemonState::new();
|
||||||
@@ -8500,86 +8521,4 @@ mod tests {
|
|||||||
assert!(!auto_handled, "{dialog_type} should NOT be auto-handled");
|
assert!(!auto_handled, "{dialog_type} should NOT be auto-handled");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use super::super::cdp::types::{AXNode, AXValue};
|
|
||||||
|
|
||||||
fn make_ax_node(
|
|
||||||
node_id: &str,
|
|
||||||
role: &str,
|
|
||||||
name: &str,
|
|
||||||
backend_node_id: Option<i64>,
|
|
||||||
ignored: bool,
|
|
||||||
) -> AXNode {
|
|
||||||
AXNode {
|
|
||||||
node_id: node_id.to_string(),
|
|
||||||
role: Some(AXValue {
|
|
||||||
value_type: "role".to_string(),
|
|
||||||
value: Some(serde_json::Value::String(role.to_string())),
|
|
||||||
}),
|
|
||||||
name: Some(AXValue {
|
|
||||||
value_type: "computedString".to_string(),
|
|
||||||
value: Some(serde_json::Value::String(name.to_string())),
|
|
||||||
}),
|
|
||||||
value: None,
|
|
||||||
description: None,
|
|
||||||
properties: None,
|
|
||||||
child_ids: None,
|
|
||||||
backend_d_o_m_node_id: backend_node_id,
|
|
||||||
ignored: Some(ignored),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_ax_node_by_role_matches_link_role() {
|
|
||||||
// Regression: the old implementation used querySelectorAll('link')
|
|
||||||
// which matched <link> stylesheet elements instead of <a> anchors.
|
|
||||||
// The AX tree correctly assigns role="link" to <a href="...">.
|
|
||||||
let nodes = vec![
|
|
||||||
make_ax_node("1", "WebArea", "Page", Some(1), false),
|
|
||||||
make_ax_node("2", "link", "Example Link", Some(42), false),
|
|
||||||
make_ax_node("3", "link", "Another Link", Some(43), false),
|
|
||||||
];
|
|
||||||
|
|
||||||
let (id, name) = find_ax_node_by_role(&nodes, "link", Some("Example Link"), true).unwrap();
|
|
||||||
assert_eq!(id, 42);
|
|
||||||
assert_eq!(name, "Example Link");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_ax_node_by_role_exact_vs_contains() {
|
|
||||||
let nodes = vec![
|
|
||||||
make_ax_node("1", "link", "More information...", Some(10), false),
|
|
||||||
make_ax_node("2", "link", "Less info", Some(11), false),
|
|
||||||
];
|
|
||||||
|
|
||||||
assert!(find_ax_node_by_role(&nodes, "link", Some("More"), true).is_err());
|
|
||||||
|
|
||||||
let (id, _) = find_ax_node_by_role(&nodes, "link", Some("More"), false).unwrap();
|
|
||||||
assert_eq!(id, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_ax_node_by_role_no_name_filter() {
|
|
||||||
let nodes = vec![
|
|
||||||
make_ax_node("1", "heading", "", Some(5), false),
|
|
||||||
make_ax_node("2", "button", "Submit", Some(6), false),
|
|
||||||
];
|
|
||||||
|
|
||||||
let (id, _) = find_ax_node_by_role(&nodes, "button", None, false).unwrap();
|
|
||||||
assert_eq!(id, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_ax_node_by_role_skips_ignored_nodes() {
|
|
||||||
let nodes = vec![
|
|
||||||
make_ax_node("1", "link", "Hidden Link", Some(99), true), // ignored
|
|
||||||
make_ax_node("2", "link", "Visible Link", Some(100), false),
|
|
||||||
];
|
|
||||||
|
|
||||||
let result = find_ax_node_by_role(&nodes, "link", Some("Hidden Link"), true);
|
|
||||||
assert!(result.is_err());
|
|
||||||
|
|
||||||
let (id, _) = find_ax_node_by_role(&nodes, "link", Some("Visible Link"), true).unwrap();
|
|
||||||
assert_eq!(id, 100);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user