From 6636ac0e74a9fa01c96640f35bf5ac5a5d1d42cf Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 15 Mar 2026 09:22:55 -0500 Subject: [PATCH] fix: snapshot --selector scopes to the matched element subtree (#825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: snapshot --selector scopes to the matched element subtree The native Rust daemon accepted the --selector flag but never used it — the full accessibility tree was always returned regardless of the selector. This restores the 0.19.0 behaviour where snapshot --selector returns only the subtree rooted at the matched CSS selector. The implementation resolves the selector via Runtime.evaluate, fetches the full DOM subtree with DOM.describeNode(depth: -1) to collect all descendant backendNodeIds, then filters the AX tree to render only the nodes whose backendDOMNodeId falls within that set. This correctly handles elements like that don't map to a direct AX node. Also fixes handle_snapshot reading "depth" instead of "maxDepth" from the command JSON, which caused --depth to be silently ignored. Fixes #822 * style: run cargo fmt on snapshot.rs --------- Co-authored-by: ctate <366502+ctate@users.noreply.github.com> --- cli/src/native/actions.rs | 2 +- cli/src/native/snapshot.rs | 116 ++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/cli/src/native/actions.rs b/cli/src/native/actions.rs index 6b2e0ad..9ea57cc 100644 --- a/cli/src/native/actions.rs +++ b/cli/src/native/actions.rs @@ -1378,7 +1378,7 @@ async fn handle_snapshot(cmd: &Value, state: &mut DaemonState) -> Result, backend_node_id: Option, children: Vec, + parent_idx: Option, has_ref: bool, ref_id: Option, depth: usize, @@ -135,6 +136,61 @@ pub async fn take_snapshot( .send_command_no_params("Accessibility.enable", Some(session_id)) .await?; + // If a CSS selector is provided, resolve the set of backendNodeIds that + // belong to the DOM subtree rooted at the matched element. We use this + // set to pick the right AX subtree root(s) later. + let selector_backend_ids: Option> = + if let Some(ref selector) = options.selector { + let js = format!( + "document.querySelector({})", + serde_json::to_string(selector).unwrap_or_default() + ); + let result: EvaluateResult = client + .send_command_typed( + "Runtime.evaluate", + &EvaluateParams { + expression: js, + return_by_value: Some(false), + await_promise: Some(false), + }, + Some(session_id), + ) + .await?; + + let object_id = result + .result + .object_id + .ok_or_else(|| format!("Selector '{}' did not match any element", selector))?; + + // Request the full DOM subtree (depth: -1) so we can collect all + // backendNodeIds that live under the matched element. + let describe: Value = client + .send_command( + "DOM.describeNode", + Some(serde_json::json!({ "objectId": object_id, "depth": -1 })), + Some(session_id), + ) + .await?; + + let root_node = describe + .get("node") + .ok_or_else(|| format!("Could not resolve DOM node for selector '{}'", selector))?; + + let mut ids = std::collections::HashSet::new(); + collect_backend_node_ids(root_node, &mut ids); + + if ids.is_empty() { + return Err(format!( + "Could not resolve backendNodeId for selector '{}'", + selector + )); + } + + Some(ids) + } else { + None + }; + let ax_tree: GetFullAXTreeResult = client .send_command_typed( "Accessibility.getFullAXTree", @@ -145,6 +201,39 @@ pub async fn take_snapshot( let (tree_nodes, root_indices) = build_tree(&ax_tree.nodes); + // When a selector is given, find AX nodes whose backendDOMNodeId falls + // within the target DOM subtree and pick the top-level ones as roots. + let effective_roots = if let Some(ref id_set) = selector_backend_ids { + // Mark which tree_nodes belong to the target DOM subtree. + let in_subtree: Vec = tree_nodes + .iter() + .map(|n| n.backend_node_id.is_some_and(|bid| id_set.contains(&bid))) + .collect(); + + // An AX node is a "top-level" match if it is in the subtree but its + // parent (in the AX tree) is not. + let mut roots = Vec::new(); + for (idx, node) in tree_nodes.iter().enumerate() { + if !in_subtree[idx] { + continue; + } + let parent_in_subtree = node.parent_idx.is_some_and(|pidx| in_subtree[pidx]); + if !parent_in_subtree { + roots.push(idx); + } + } + + if roots.is_empty() { + return Err(format!( + "No accessibility node found for selector '{}'", + options.selector.as_deref().unwrap_or("") + )); + } + roots + } else { + root_indices + }; + let mut tracker = RoleNameTracker::new(); let mut next_ref: usize = ref_map.next_ref_num(); @@ -196,7 +285,7 @@ pub async fn take_snapshot( ref_map.set_next_ref_num(next_ref); let mut output = String::new(); - for &root_idx in &root_indices { + for &root_idx in &effective_roots { render_tree(&tree_nodes, root_idx, 0, &mut output, options); } @@ -387,6 +476,7 @@ fn build_tree(nodes: &[AXNode]) -> (Vec, Vec) { value_text: None, backend_node_id: None, children: Vec::new(), + parent_idx: None, has_ref: false, ref_id: None, depth: 0, @@ -407,6 +497,7 @@ fn build_tree(nodes: &[AXNode]) -> (Vec, Vec) { value_text, backend_node_id: node.backend_d_o_m_node_id, children: Vec::new(), + parent_idx: None, has_ref: false, ref_id: None, depth: 0, @@ -420,6 +511,7 @@ fn build_tree(nodes: &[AXNode]) -> (Vec, Vec) { for cid in child_ids { if let Some(&child_idx) = id_to_idx.get(cid) { tree_nodes[i].children.push(child_idx); + tree_nodes[child_idx].parent_idx = Some(i); } } } @@ -671,6 +763,28 @@ fn extract_properties(props: &Option>) -> NodeProperties { (level, checked, expanded, selected, disabled, required) } +/// Recursively collect all `backendNodeId` values from a CDP DOM node tree +/// (as returned by `DOM.describeNode` with `depth: -1`). +fn collect_backend_node_ids(node: &Value, ids: &mut std::collections::HashSet) { + if let Some(id) = node.get("backendNodeId").and_then(|v| v.as_i64()) { + ids.insert(id); + } + if let Some(children) = node.get("children").and_then(|v| v.as_array()) { + for child in children { + collect_backend_node_ids(child, ids); + } + } + // Shadow DOM and content documents + if let Some(shadow) = node.get("shadowRoots").and_then(|v| v.as_array()) { + for child in shadow { + collect_backend_node_ids(child, ids); + } + } + if let Some(doc) = node.get("contentDocument") { + collect_backend_node_ids(doc, ids); + } +} + #[cfg(test)] mod tests { use super::*;