feat: embed cursor-interactive elements into snapshot tree (#855)
* feat: embed cursor-interactive elements into snapshot tree * optimize format * fix: address review feedback for e2e_snapshot_cursor_interactive unitest --------- Co-authored-by: 羲洋 <lipengyang.lpy@alibaba-inc.com>
This commit is contained in:
@@ -1512,6 +1512,7 @@ async fn handle_screenshot(cmd: &Value, state: &mut DaemonState) -> Result<Value
|
|||||||
&session_id,
|
&session_id,
|
||||||
&SnapshotOptions {
|
&SnapshotOptions {
|
||||||
interactive: true,
|
interactive: true,
|
||||||
|
cursor: true,
|
||||||
..SnapshotOptions::default()
|
..SnapshotOptions::default()
|
||||||
},
|
},
|
||||||
&mut state.ref_map,
|
&mut state.ref_map,
|
||||||
|
|||||||
+20
-21
@@ -2039,13 +2039,6 @@ async fn e2e_snapshot_cursor_interactive() {
|
|||||||
|
|
||||||
let snapshot = get_data(&resp)["snapshot"].as_str().unwrap();
|
let snapshot = get_data(&resp)["snapshot"].as_str().unwrap();
|
||||||
|
|
||||||
// Cursor section must appear
|
|
||||||
assert!(
|
|
||||||
snapshot.contains("# Cursor-interactive elements:") || snapshot.contains("clickable"),
|
|
||||||
"Cursor section missing from snapshot -i -C:\n{}",
|
|
||||||
snapshot,
|
|
||||||
);
|
|
||||||
|
|
||||||
// v0.19.0 output format: role + hints
|
// v0.19.0 output format: role + hints
|
||||||
assert!(
|
assert!(
|
||||||
snapshot.contains("clickable") && snapshot.contains("[cursor:pointer"),
|
snapshot.contains("clickable") && snapshot.contains("[cursor:pointer"),
|
||||||
@@ -2060,20 +2053,26 @@ async fn e2e_snapshot_cursor_interactive() {
|
|||||||
snapshot,
|
snapshot,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Text dedup: "Link" and "Btn" are in the ARIA tree, so must NOT appear
|
// Text dedup: "Link" and "Btn" are in the ARIA tree, so must NOT suffix
|
||||||
// in the cursor section.
|
// with cursor-interactive info. Verify line by line.
|
||||||
let cursor_section = snapshot
|
for line in snapshot.lines() {
|
||||||
.split("# Cursor-interactive elements:")
|
assert!(
|
||||||
.nth(1)
|
!(line.contains("\"Link\"")
|
||||||
.unwrap_or(snapshot);
|
&& (line.contains("clickable")
|
||||||
assert!(
|
|| line.contains("focusable")
|
||||||
!cursor_section.contains("\"Link\""),
|
|| line.contains("editable"))),
|
||||||
"ARIA link text should be deduped from cursor section"
|
"Standard <a> element should not have cursor-interactive info:\n{}",
|
||||||
);
|
line
|
||||||
assert!(
|
);
|
||||||
!cursor_section.contains("\"Btn\""),
|
assert!(
|
||||||
"ARIA button text should be deduped from cursor section"
|
!(line.contains("\"Btn\"")
|
||||||
);
|
&& (line.contains("clickable")
|
||||||
|
|| line.contains("focusable")
|
||||||
|
|| line.contains("editable"))),
|
||||||
|
"Standard <button> element should not have cursor-interactive info:\n{}",
|
||||||
|
line
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Must complete quickly (< 5s), not hit the 30s CDP timeout
|
// Must complete quickly (< 5s), not hit the 30s CDP timeout
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
+65
-66
@@ -89,6 +89,15 @@ struct TreeNode {
|
|||||||
has_ref: bool,
|
has_ref: bool,
|
||||||
ref_id: Option<String>,
|
ref_id: Option<String>,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
|
/// Cursor-interactive information (only set when options.cursor is true)
|
||||||
|
cursor_info: Option<CursorElementInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Information about a cursor-interactive element (elements with cursor:pointer, onclick, tabindex, etc.)
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CursorElementInfo {
|
||||||
|
kind: String, // "clickable", "focusable", "editable"
|
||||||
|
hints: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RoleNameTracker {
|
struct RoleNameTracker {
|
||||||
@@ -238,12 +247,26 @@ pub async fn take_snapshot(
|
|||||||
|
|
||||||
let mut nodes_with_refs: Vec<(usize, usize)> = Vec::new();
|
let mut nodes_with_refs: Vec<(usize, usize)> = Vec::new();
|
||||||
|
|
||||||
|
// When cursor mode is enabled, pre-collect cursor-interactive elements
|
||||||
|
// so we can mark them with refs during tree building
|
||||||
|
let cursor_elements: HashMap<i64, CursorElementInfo> = if options.cursor {
|
||||||
|
find_cursor_interactive_elements(client, session_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
HashMap::new()
|
||||||
|
};
|
||||||
|
|
||||||
for (idx, node) in tree_nodes.iter().enumerate() {
|
for (idx, node) in tree_nodes.iter().enumerate() {
|
||||||
let role = node.role.as_str();
|
let role = node.role.as_str();
|
||||||
let should_ref = if INTERACTIVE_ROLES.contains(&role) {
|
let should_ref = if INTERACTIVE_ROLES.contains(&role) {
|
||||||
true
|
true
|
||||||
} else if CONTENT_ROLES.contains(&role) {
|
} else if CONTENT_ROLES.contains(&role) {
|
||||||
!node.name.is_empty()
|
!node.name.is_empty()
|
||||||
|
} else if options.cursor {
|
||||||
|
// In cursor mode, also ref elements that are cursor-interactive
|
||||||
|
node.backend_node_id
|
||||||
|
.is_some_and(|bid| cursor_elements.contains_key(&bid))
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
@@ -281,6 +304,17 @@ pub async fn take_snapshot(
|
|||||||
tree_nodes[*idx].ref_id = Some(ref_id);
|
tree_nodes[*idx].ref_id = Some(ref_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Populate cursor_info for ref-bearing nodes when cursor mode is enabled
|
||||||
|
if options.cursor {
|
||||||
|
for (idx, _) in &nodes_with_refs {
|
||||||
|
if let Some(bid) = tree_nodes[*idx].backend_node_id {
|
||||||
|
if let Some(cursor_info) = cursor_elements.get(&bid) {
|
||||||
|
tree_nodes[*idx].cursor_info = Some((*cursor_info).clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
@@ -292,22 +326,7 @@ pub async fn take_snapshot(
|
|||||||
output = compact_tree(&output, options.interactive);
|
output = compact_tree(&output, options.interactive);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut trimmed = output.trim().to_string();
|
let trimmed = output.trim().to_string();
|
||||||
let tree_is_empty = trimmed.is_empty();
|
|
||||||
|
|
||||||
if options.cursor {
|
|
||||||
let cursor_section = find_cursor_interactive_elements(client, session_id, ref_map).await?;
|
|
||||||
if !cursor_section.is_empty() {
|
|
||||||
// v0.19.0 parity: when interactive tree is empty but cursor elements exist,
|
|
||||||
// the cursor elements replace the empty message (no separator).
|
|
||||||
if tree_is_empty {
|
|
||||||
trimmed = cursor_section;
|
|
||||||
} else {
|
|
||||||
trimmed.push_str("\n# Cursor-interactive elements:\n");
|
|
||||||
trimmed.push_str(&cursor_section);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
if options.interactive {
|
if options.interactive {
|
||||||
@@ -322,8 +341,7 @@ pub async fn take_snapshot(
|
|||||||
async fn find_cursor_interactive_elements(
|
async fn find_cursor_interactive_elements(
|
||||||
client: &CdpClient,
|
client: &CdpClient,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
ref_map: &mut RefMap,
|
) -> Result<HashMap<i64, CursorElementInfo>, String> {
|
||||||
) -> Result<String, String> {
|
|
||||||
// Single JS evaluation that matches the v0.19.0 Node.js findCursorInteractiveElements():
|
// Single JS evaluation that matches the v0.19.0 Node.js findCursorInteractiveElements():
|
||||||
// - Uses querySelectorAll('*') to walk all elements
|
// - Uses querySelectorAll('*') to walk all elements
|
||||||
// - Checks getComputedStyle(el).cursor === 'pointer'
|
// - Checks getComputedStyle(el).cursor === 'pointer'
|
||||||
@@ -376,7 +394,6 @@ async fn find_cursor_interactive_elements(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var text = (el.textContent || '').trim().slice(0, 100);
|
var text = (el.textContent || '').trim().slice(0, 100);
|
||||||
if (!text) continue;
|
|
||||||
|
|
||||||
var rect = el.getBoundingClientRect();
|
var rect = el.getBoundingClientRect();
|
||||||
if (rect.width === 0 || rect.height === 0) continue;
|
if (rect.width === 0 || rect.height === 0) continue;
|
||||||
@@ -414,11 +431,9 @@ async fn find_cursor_interactive_elements(
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if elements.is_empty() {
|
if elements.is_empty() {
|
||||||
return Ok(String::new());
|
return Ok(HashMap::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut existing_texts = build_dedup_set(ref_map);
|
|
||||||
|
|
||||||
// Batch-resolve backendNodeIds: use DOM.getDocument to get the root nodeId,
|
// Batch-resolve backendNodeIds: use DOM.getDocument to get the root nodeId,
|
||||||
// then DOM.querySelectorAll to get all tagged elements in a single call.
|
// then DOM.querySelectorAll to get all tagged elements in a single call.
|
||||||
let doc: Value = client
|
let doc: Value = client
|
||||||
@@ -510,25 +525,9 @@ async fn find_cursor_interactive_elements(
|
|||||||
eprintln!("[agent-browser] Warning: failed to clean up data-__ab-ci attributes: {e}");
|
eprintln!("[agent-browser] Warning: failed to clean up data-__ab-ci attributes: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build refs and output lines with v0.19.0-compatible format.
|
// Build the map
|
||||||
let mut next_ref = ref_map.next_ref_num();
|
let mut map: HashMap<i64, CursorElementInfo> = HashMap::new();
|
||||||
let mut lines: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
for (i, elem) in elements.iter().enumerate() {
|
for (i, elem) in elements.iter().enumerate() {
|
||||||
let text = elem
|
|
||||||
.get("text")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Text dedup: skip if this text already appears in the ARIA tree refs (v0.19.0 parity)
|
|
||||||
let text_lower = text.to_lowercase();
|
|
||||||
if existing_texts.contains(&text_lower) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
existing_texts.insert(text_lower);
|
|
||||||
|
|
||||||
let backend_node_id = idx_to_backend.get(&i).copied();
|
let backend_node_id = idx_to_backend.get(&i).copied();
|
||||||
|
|
||||||
// Role differentiation: v0.19.0 uses 'clickable' for cursor:pointer or onclick,
|
// Role differentiation: v0.19.0 uses 'clickable' for cursor:pointer or onclick,
|
||||||
@@ -558,43 +557,32 @@ async fn find_cursor_interactive_elements(
|
|||||||
"focusable"
|
"focusable"
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut hints: Vec<&str> = Vec::new();
|
let mut hints: Vec<String> = Vec::new();
|
||||||
if has_cursor_pointer {
|
if has_cursor_pointer {
|
||||||
hints.push("cursor:pointer");
|
hints.push("cursor:pointer".to_string());
|
||||||
}
|
}
|
||||||
if has_on_click {
|
if has_on_click {
|
||||||
hints.push("onclick");
|
hints.push("onclick".to_string());
|
||||||
}
|
}
|
||||||
if has_tab_index {
|
if has_tab_index {
|
||||||
hints.push("tabindex");
|
hints.push("tabindex".to_string());
|
||||||
}
|
}
|
||||||
if is_editable {
|
if is_editable {
|
||||||
hints.push("contenteditable");
|
hints.push("contenteditable".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let ref_id = format!("e{}", next_ref);
|
if let Some(bid) = backend_node_id {
|
||||||
next_ref += 1;
|
map.insert(
|
||||||
|
bid,
|
||||||
ref_map.add(ref_id.clone(), backend_node_id, kind, &text, None);
|
CursorElementInfo {
|
||||||
|
kind: kind.to_string(),
|
||||||
let escaped = text
|
hints,
|
||||||
.replace('\\', "\\\\")
|
},
|
||||||
.replace('"', "\\\"")
|
);
|
||||||
.replace(['\n', '\r'], " ");
|
}
|
||||||
|
|
||||||
// v0.19.0 output format: - clickable "text" [ref=eN] [cursor:pointer, onclick]
|
|
||||||
lines.push(format!(
|
|
||||||
"- {} \"{}\" [ref={}] [{}]",
|
|
||||||
kind,
|
|
||||||
escaped,
|
|
||||||
ref_id,
|
|
||||||
hints.join(", ")
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ref_map.set_next_ref_num(next_ref);
|
Ok(map)
|
||||||
|
|
||||||
Ok(lines.join("\n"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
|
fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
|
||||||
@@ -626,6 +614,7 @@ fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
|
|||||||
has_ref: false,
|
has_ref: false,
|
||||||
ref_id: None,
|
ref_id: None,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
|
cursor_info: None,
|
||||||
});
|
});
|
||||||
id_to_idx.insert(node.node_id.clone(), i);
|
id_to_idx.insert(node.node_id.clone(), i);
|
||||||
continue;
|
continue;
|
||||||
@@ -647,6 +636,7 @@ fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
|
|||||||
has_ref: false,
|
has_ref: false,
|
||||||
ref_id: None,
|
ref_id: None,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
|
cursor_info: None,
|
||||||
});
|
});
|
||||||
id_to_idx.insert(node.node_id.clone(), i);
|
id_to_idx.insert(node.node_id.clone(), i);
|
||||||
}
|
}
|
||||||
@@ -777,6 +767,15 @@ fn render_tree(
|
|||||||
line.push_str(&format!(" [{}]", attrs.join(", ")));
|
line.push_str(&format!(" [{}]", attrs.join(", ")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add cursor-interactive kind & hints
|
||||||
|
if let Some(ref cursor_info) = node.cursor_info {
|
||||||
|
line.push_str(&format!(
|
||||||
|
" {} [{}]",
|
||||||
|
&cursor_info.kind,
|
||||||
|
&cursor_info.hints.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Value
|
// Value
|
||||||
if let Some(ref val) = node.value_text {
|
if let Some(ref val) = node.value_text {
|
||||||
if !val.is_empty() && val != &node.name {
|
if !val.is_empty() && val != &node.name {
|
||||||
|
|||||||
Reference in New Issue
Block a user