fix: snapshot --selector scopes to the matched element subtree (#825)

* 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 <body> 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>
This commit is contained in:
Chris Tate
2026-03-15 09:22:55 -05:00
committed by GitHub
co-authored by ctate
parent bc94eaf94f
commit 6636ac0e74
2 changed files with 116 additions and 2 deletions
+1 -1
View File
@@ -1378,7 +1378,7 @@ async fn handle_snapshot(cmd: &Value, state: &mut DaemonState) -> Result<Value,
.and_then(|v| v.as_bool())
.unwrap_or(false),
depth: cmd
.get("depth")
.get("maxDepth")
.and_then(|v| v.as_u64())
.map(|d| d as usize),
cursor: cmd.get("cursor").and_then(|v| v.as_bool()).unwrap_or(false),
+115 -1
View File
@@ -86,6 +86,7 @@ struct TreeNode {
value_text: Option<String>,
backend_node_id: Option<i64>,
children: Vec<usize>,
parent_idx: Option<usize>,
has_ref: bool,
ref_id: Option<String>,
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<std::collections::HashSet<i64>> =
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<bool> = 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<TreeNode>, Vec<usize>) {
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<TreeNode>, Vec<usize>) {
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<TreeNode>, Vec<usize>) {
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<Vec<AXProperty>>) -> 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<i64>) {
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::*;