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:
Chris Tate
2026-03-14 20:54:08 -05:00
committed by GitHub
co-authored by ctate
parent 5f8e993602
commit c07f43e242
2 changed files with 189 additions and 50 deletions
+92
View File
@@ -1711,3 +1711,95 @@ async fn e2e_inspect() {
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
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);
}