fix: re-query accessibility tree when backend_node_id is stale (#806)
* fix: gracefully fall back to role/name lookup when backend_node_id is stale When the DOM changes between snapshot and click (common with SPAs and dynamic UIs), the stored backend_node_id becomes invalid. Previously, DOM.getBoxModel and DOM.resolveNode failures propagated as hard errors, bypassing the role/name fallback path entirely. Now these failures are caught and the code falls through to a JS-based element lookup. Also adds resolve_object_id_by_role_name so that resolve_element_object_id has a fallback for ref-based lookups (previously it had none), and improves the role matching JS to correctly map implicit ARIA roles (e.g. <input type="submit"> → "button", <a href> → "link"). Closes #805 * test: add e2e regression test for stale ref click fallback (#805) Verifies that clicking a ref whose backend_node_id has become stale (because the DOM was replaced by JavaScript) falls back to role/name lookup instead of failing with "Could not compute box model". * fix: use accessibility tree for stale ref fallback instead of JS heuristic Replace the hand-rolled JS role/name matching (getImplicitRole, getAccessibleName) with a re-query of Accessibility.getFullAXTree — the same data source that built the ref map during snapshot. This guarantees role/name matching is identical to what was stored, preventing silent wrong-element clicks from name computation divergence (e.g. aria-labelledby, <label for>, alt text). Matches v0.19.0 (Playwright) behavior where getByRole always re-queried the live accessibility tree. --------- Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
@@ -1711,3 +1711,95 @@ async fn e2e_inspect() {
|
|||||||
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||||
assert_success(&resp);
|
assert_success(&resp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stale ref fallback (#805): clicking a ref after the DOM has been replaced
|
||||||
|
// should fall back to role/name lookup instead of failing.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn e2e_click_stale_ref_falls_back_to_role_name() {
|
||||||
|
let mut state = DaemonState::new();
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "1", "action": "launch", "headless": true }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
// Navigate to a page with a button that replaces the DOM when clicked.
|
||||||
|
let html = r#"data:text/html,<body>
|
||||||
|
<div id="c">
|
||||||
|
<button onclick="
|
||||||
|
var c = document.getElementById('c');
|
||||||
|
c.innerHTML = '';
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.textContent = 'Target';
|
||||||
|
b.onclick = function() { document.title = 'clicked'; };
|
||||||
|
c.appendChild(b);
|
||||||
|
document.title = 'replaced';
|
||||||
|
">Replace</button>
|
||||||
|
<button>Target</button>
|
||||||
|
</div>
|
||||||
|
</body>"#;
|
||||||
|
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "2", "action": "navigate", "url": html }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
|
||||||
|
// Snapshot to populate the ref_map with backend_node_ids.
|
||||||
|
let resp = execute_command(&json!({ "id": "3", "action": "snapshot" }), &mut state).await;
|
||||||
|
assert_success(&resp);
|
||||||
|
let snapshot = get_data(&resp)["snapshot"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
snapshot.contains("Replace"),
|
||||||
|
"Snapshot should contain Replace button"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
snapshot.contains("Target"),
|
||||||
|
"Snapshot should contain Target button"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Click "Replace" — this removes all DOM nodes and recreates them,
|
||||||
|
// making the backend_node_id for "Target" stale.
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "4", "action": "click", "selector": "e1" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
// Verify the DOM was actually replaced.
|
||||||
|
let resp = execute_command(&json!({ "id": "5", "action": "title" }), &mut state).await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(get_data(&resp)["title"], "replaced");
|
||||||
|
|
||||||
|
// Now click the stale "Target" ref. Before the fix this returned:
|
||||||
|
// "CDP error (DOM.getBoxModel): Could not compute box model."
|
||||||
|
// After the fix it falls back to role/name lookup and succeeds.
|
||||||
|
let resp = execute_command(
|
||||||
|
&json!({ "id": "6", "action": "click", "selector": "e2" }),
|
||||||
|
&mut state,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_success(&resp);
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
|
||||||
|
// Verify the fallback click hit the right (recreated) button.
|
||||||
|
let resp = execute_command(&json!({ "id": "7", "action": "title" }), &mut state).await;
|
||||||
|
assert_success(&resp);
|
||||||
|
assert_eq!(
|
||||||
|
get_data(&resp)["title"],
|
||||||
|
"clicked",
|
||||||
|
"Stale ref should have been resolved via role/name fallback"
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||||
|
assert_success(&resp);
|
||||||
|
}
|
||||||
|
|||||||
+97
-50
@@ -138,8 +138,9 @@ pub async fn resolve_element_center(
|
|||||||
.get(&ref_id)
|
.get(&ref_id)
|
||||||
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
|
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
|
||||||
|
|
||||||
|
// 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 {
|
||||||
let result: DomGetBoxModelResult = client
|
let result: Result<DomGetBoxModelResult, String> = client
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"DOM.getBoxModel",
|
"DOM.getBoxModel",
|
||||||
&DomGetBoxModelParams {
|
&DomGetBoxModelParams {
|
||||||
@@ -149,13 +150,30 @@ pub async fn resolve_element_center(
|
|||||||
},
|
},
|
||||||
Some(session_id),
|
Some(session_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await;
|
||||||
|
|
||||||
return Ok(box_model_center(&result.model));
|
if let Ok(r) = result {
|
||||||
|
return Ok(box_model_center(&r.model));
|
||||||
|
}
|
||||||
|
// backend_node_id is stale; re-query the accessibility tree below
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: use role/name to find via JS
|
// Fallback: re-query the accessibility tree to find a fresh node by role/name
|
||||||
return resolve_by_role_name(client, session_id, &entry.role, &entry.name, entry.nth).await;
|
let fresh_id =
|
||||||
|
find_node_id_by_role_name(client, session_id, &entry.role, &entry.name, entry.nth)
|
||||||
|
.await?;
|
||||||
|
let result: DomGetBoxModelResult = client
|
||||||
|
.send_command_typed(
|
||||||
|
"DOM.getBoxModel",
|
||||||
|
&DomGetBoxModelParams {
|
||||||
|
backend_node_id: Some(fresh_id),
|
||||||
|
node_id: None,
|
||||||
|
object_id: None,
|
||||||
|
},
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(box_model_center(&result.model));
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSS selector
|
// CSS selector
|
||||||
@@ -173,8 +191,9 @@ pub async fn resolve_element_object_id(
|
|||||||
.get(&ref_id)
|
.get(&ref_id)
|
||||||
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
|
.ok_or_else(|| format!("Unknown ref: {}", ref_id))?;
|
||||||
|
|
||||||
|
// 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 {
|
||||||
let result: DomResolveNodeResult = client
|
let result: Result<DomResolveNodeResult, String> = client
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"DOM.resolveNode",
|
"DOM.resolveNode",
|
||||||
&DomResolveNodeParams {
|
&DomResolveNodeParams {
|
||||||
@@ -184,13 +203,35 @@ pub async fn resolve_element_object_id(
|
|||||||
},
|
},
|
||||||
Some(session_id),
|
Some(session_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await;
|
||||||
|
|
||||||
return result
|
if let Ok(r) = result {
|
||||||
.object
|
if let Some(oid) = r.object.object_id {
|
||||||
.object_id
|
return Ok(oid);
|
||||||
.ok_or_else(|| format!("No objectId for ref {}", ref_id));
|
}
|
||||||
|
}
|
||||||
|
// backend_node_id is stale; re-query the accessibility tree below
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: re-query the accessibility tree to find a fresh node by role/name
|
||||||
|
let fresh_id =
|
||||||
|
find_node_id_by_role_name(client, session_id, &entry.role, &entry.name, entry.nth)
|
||||||
|
.await?;
|
||||||
|
let result: DomResolveNodeResult = client
|
||||||
|
.send_command_typed(
|
||||||
|
"DOM.resolveNode",
|
||||||
|
&DomResolveNodeParams {
|
||||||
|
backend_node_id: Some(fresh_id),
|
||||||
|
node_id: None,
|
||||||
|
object_group: Some("agent-browser".to_string()),
|
||||||
|
},
|
||||||
|
Some(session_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return result
|
||||||
|
.object
|
||||||
|
.object_id
|
||||||
|
.ok_or_else(|| format!("No objectId for ref {}", ref_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSS selector fallback
|
// CSS selector fallback
|
||||||
@@ -216,56 +257,62 @@ pub async fn resolve_element_object_id(
|
|||||||
.ok_or_else(|| format!("Element not found: {}", selector_or_ref))
|
.ok_or_else(|| format!("Element not found: {}", selector_or_ref))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_by_role_name(
|
/// Re-query the accessibility tree to find a node matching role+name+nth,
|
||||||
|
/// returning its fresh backendDOMNodeId. This uses the same data source
|
||||||
|
/// (Accessibility.getFullAXTree) that built the ref map during snapshot,
|
||||||
|
/// so role/name matching is guaranteed to be consistent.
|
||||||
|
async fn find_node_id_by_role_name(
|
||||||
client: &CdpClient,
|
client: &CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
role: &str,
|
role: &str,
|
||||||
name: &str,
|
name: &str,
|
||||||
nth: Option<usize>,
|
nth: Option<usize>,
|
||||||
) -> Result<(f64, f64), String> {
|
) -> Result<i64, String> {
|
||||||
let nth_index = nth.unwrap_or(0);
|
let ax_tree: GetFullAXTreeResult = client
|
||||||
let js = format!(
|
|
||||||
r#"(() => {{
|
|
||||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
|
||||||
const matches = [];
|
|
||||||
let node;
|
|
||||||
while (node = walker.nextNode()) {{
|
|
||||||
const r = node.getAttribute('role') || node.tagName.toLowerCase();
|
|
||||||
const n = node.getAttribute('aria-label') || node.textContent.trim().slice(0, 100);
|
|
||||||
if (r === {role} && n === {name}) matches.push(node);
|
|
||||||
}}
|
|
||||||
const el = matches[{nth}];
|
|
||||||
if (!el) return null;
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }};
|
|
||||||
}})()"#,
|
|
||||||
role = serde_json::to_string(role).unwrap_or_default(),
|
|
||||||
name = serde_json::to_string(name).unwrap_or_default(),
|
|
||||||
nth = nth_index,
|
|
||||||
);
|
|
||||||
|
|
||||||
let result: EvaluateResult = client
|
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"Runtime.evaluate",
|
"Accessibility.getFullAXTree",
|
||||||
&EvaluateParams {
|
&serde_json::json!({}),
|
||||||
expression: js,
|
|
||||||
return_by_value: Some(true),
|
|
||||||
await_promise: Some(false),
|
|
||||||
},
|
|
||||||
Some(session_id),
|
Some(session_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let val = result.result.value.unwrap_or(Value::Null);
|
let nth_index = nth.unwrap_or(0);
|
||||||
let x = val.get("x").and_then(|v| v.as_f64());
|
let mut match_count: usize = 0;
|
||||||
let y = val.get("y").and_then(|v| v.as_f64());
|
|
||||||
|
|
||||||
match (x, y) {
|
for node in &ax_tree.nodes {
|
||||||
(Some(x), Some(y)) => Ok((x, y)),
|
if node.ignored.unwrap_or(false) {
|
||||||
_ => Err(format!(
|
continue;
|
||||||
"Could not locate element with role={} name={}",
|
}
|
||||||
role, name
|
let node_role = extract_ax_string(&node.role);
|
||||||
)),
|
let node_name = extract_ax_string(&node.name);
|
||||||
|
if node_role == role && node_name == name {
|
||||||
|
if match_count == nth_index {
|
||||||
|
return node.backend_d_o_m_node_id.ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"AX node has no backendDOMNodeId for role={} name={}",
|
||||||
|
role, name
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
match_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"Could not locate element with role={} name={}",
|
||||||
|
role, name
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_ax_string(value: &Option<AXValue>) -> String {
|
||||||
|
match value {
|
||||||
|
Some(v) => match &v.value {
|
||||||
|
Some(Value::String(s)) => s.clone(),
|
||||||
|
Some(Value::Number(n)) => n.to_string(),
|
||||||
|
Some(Value::Bool(b)) => b.to_string(),
|
||||||
|
_ => String::new(),
|
||||||
|
},
|
||||||
|
None => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user