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:
@@ -1378,7 +1378,7 @@ async fn handle_snapshot(cmd: &Value, state: &mut DaemonState) -> Result<Value,
|
|||||||
.and_then(|v| v.as_bool())
|
.and_then(|v| v.as_bool())
|
||||||
.unwrap_or(false),
|
.unwrap_or(false),
|
||||||
depth: cmd
|
depth: cmd
|
||||||
.get("depth")
|
.get("maxDepth")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.map(|d| d as usize),
|
.map(|d| d as usize),
|
||||||
cursor: cmd.get("cursor").and_then(|v| v.as_bool()).unwrap_or(false),
|
cursor: cmd.get("cursor").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||||
|
|||||||
+115
-1
@@ -86,6 +86,7 @@ struct TreeNode {
|
|||||||
value_text: Option<String>,
|
value_text: Option<String>,
|
||||||
backend_node_id: Option<i64>,
|
backend_node_id: Option<i64>,
|
||||||
children: Vec<usize>,
|
children: Vec<usize>,
|
||||||
|
parent_idx: Option<usize>,
|
||||||
has_ref: bool,
|
has_ref: bool,
|
||||||
ref_id: Option<String>,
|
ref_id: Option<String>,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
@@ -135,6 +136,61 @@ pub async fn take_snapshot(
|
|||||||
.send_command_no_params("Accessibility.enable", Some(session_id))
|
.send_command_no_params("Accessibility.enable", Some(session_id))
|
||||||
.await?;
|
.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
|
let ax_tree: GetFullAXTreeResult = client
|
||||||
.send_command_typed(
|
.send_command_typed(
|
||||||
"Accessibility.getFullAXTree",
|
"Accessibility.getFullAXTree",
|
||||||
@@ -145,6 +201,39 @@ pub async fn take_snapshot(
|
|||||||
|
|
||||||
let (tree_nodes, root_indices) = build_tree(&ax_tree.nodes);
|
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 tracker = RoleNameTracker::new();
|
||||||
let mut next_ref: usize = ref_map.next_ref_num();
|
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);
|
ref_map.set_next_ref_num(next_ref);
|
||||||
|
|
||||||
let mut output = String::new();
|
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);
|
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,
|
value_text: None,
|
||||||
backend_node_id: None,
|
backend_node_id: None,
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
|
parent_idx: None,
|
||||||
has_ref: false,
|
has_ref: false,
|
||||||
ref_id: None,
|
ref_id: None,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
@@ -407,6 +497,7 @@ fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
|
|||||||
value_text,
|
value_text,
|
||||||
backend_node_id: node.backend_d_o_m_node_id,
|
backend_node_id: node.backend_d_o_m_node_id,
|
||||||
children: Vec::new(),
|
children: Vec::new(),
|
||||||
|
parent_idx: None,
|
||||||
has_ref: false,
|
has_ref: false,
|
||||||
ref_id: None,
|
ref_id: None,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
@@ -420,6 +511,7 @@ fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
|
|||||||
for cid in child_ids {
|
for cid in child_ids {
|
||||||
if let Some(&child_idx) = id_to_idx.get(cid) {
|
if let Some(&child_idx) = id_to_idx.get(cid) {
|
||||||
tree_nodes[i].children.push(child_idx);
|
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)
|
(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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Reference in New Issue
Block a user