1#![allow(missing_docs)]
2use std::collections::HashMap;
3
4use serde_json::Value;
5
6use super::cdp::client::CdpClient;
7use super::cdp::types::{
8 AXNode, AXProperty, AXValue, EvaluateParams, EvaluateResult, GetFullAXTreeResult,
9};
10use super::element::{resolve_ax_session, RefMap};
11
12const INTERACTIVE_ROLES: &[&str] = &[
13 "button",
14 "link",
15 "textbox",
16 "checkbox",
17 "radio",
18 "combobox",
19 "listbox",
20 "menuitem",
21 "menuitemcheckbox",
22 "menuitemradio",
23 "option",
24 "searchbox",
25 "slider",
26 "spinbutton",
27 "switch",
28 "tab",
29 "treeitem",
30 "Iframe",
31];
32
33const CONTENT_ROLES: &[&str] = &[
34 "heading",
35 "cell",
36 "gridcell",
37 "columnheader",
38 "rowheader",
39 "listitem",
40 "article",
41 "region",
42 "main",
43 "navigation",
44];
45
46const INVISIBLE_CHARS: &[char] = &[
47 '\u{FEFF}', '\u{200B}', '\u{200C}', '\u{200D}', '\u{2060}', '\u{00A0}', ];
54
55#[derive(Default)]
56pub struct SnapshotOptions {
57 pub selector: Option<String>,
58 pub interactive: bool,
59 pub compact: bool,
60 pub depth: Option<usize>,
61 pub urls: bool,
62}
63
64struct TreeNode {
65 role: String,
66 name: String,
67 level: Option<i64>,
68 checked: Option<String>,
69 expanded: Option<bool>,
70 selected: Option<bool>,
71 disabled: Option<bool>,
72 required: Option<bool>,
73 value_text: Option<String>,
74 backend_node_id: Option<i64>,
75 children: Vec<usize>,
76 parent_idx: Option<usize>,
77 has_ref: bool,
78 ref_id: Option<String>,
79 depth: usize,
80 cursor_info: Option<CursorElementInfo>,
81 url: Option<String>,
82}
83
84impl TreeNode {
85 fn empty() -> Self {
87 Self {
88 role: String::new(),
89 name: String::new(),
90 level: None,
91 checked: None,
92 expanded: None,
93 selected: None,
94 disabled: None,
95 required: None,
96 value_text: None,
97 backend_node_id: None,
98 children: Vec::new(),
99 parent_idx: None,
100 has_ref: false,
101 ref_id: None,
102 depth: 0,
103 cursor_info: None,
104 url: None,
105 }
106 }
107
108 fn clear(&mut self) {
109 self.role = String::new();
110 self.name = String::new();
111 self.level = None;
112 self.checked = None;
113 self.expanded = None;
114 self.selected = None;
115 self.disabled = None;
116 self.required = None;
117 self.value_text = None;
118 self.backend_node_id = None;
119 self.children.clear();
120 self.parent_idx = None;
121 self.has_ref = false;
122 self.url = None;
123 self.ref_id = None;
124 self.depth = 0;
125 self.cursor_info = None;
126 }
127}
128
129#[derive(Clone, Copy)]
131enum HiddenInputKind {
132 Radio,
133 Checkbox,
134}
135
136impl HiddenInputKind {
137 fn parse(s: &str) -> Option<Self> {
138 match s {
139 "radio" => Some(Self::Radio),
140 "checkbox" => Some(Self::Checkbox),
141 _ => None,
142 }
143 }
144
145 fn as_role(&self) -> &str {
146 match self {
147 Self::Radio => "radio",
148 Self::Checkbox => "checkbox",
149 }
150 }
151}
152
153#[derive(Clone)]
155struct CursorElementInfo {
156 kind: String, hints: Vec<String>,
158 text: String, hidden_input_kind: Option<HiddenInputKind>,
160 hidden_input_checked: Option<String>, }
162
163struct RoleNameTracker {
164 counts: HashMap<String, usize>,
165 entries: Vec<(usize, String)>,
166}
167
168impl RoleNameTracker {
169 fn new() -> Self {
170 Self {
171 counts: HashMap::new(),
172 entries: Vec::new(),
173 }
174 }
175
176 fn track(&mut self, role: &str, name: &str, node_idx: usize) -> usize {
177 let key = format!("{}:{}", role, name);
178 let count = self.counts.entry(key.clone()).or_insert(0);
179 let nth = *count;
180 *count += 1;
181 self.entries.push((node_idx, key));
182 nth
183 }
184
185 fn get_duplicates(&self) -> HashMap<String, usize> {
186 self.counts
187 .iter()
188 .filter(|(_, &count)| count > 1)
189 .map(|(key, &count)| (key.clone(), count))
190 .collect()
191 }
192}
193
194pub async fn take_snapshot(
195 client: &CdpClient,
196 session_id: &str,
197 options: &SnapshotOptions,
198 ref_map: &mut RefMap,
199 frame_id: Option<&str>,
200 iframe_sessions: &HashMap<String, String>,
201) -> Result<String, String> {
202 client
203 .send_command_no_params("DOM.enable", Some(session_id))
204 .await?;
205 client
206 .send_command_no_params("Accessibility.enable", Some(session_id))
207 .await?;
208
209 let selector_backend_ids: Option<std::collections::HashSet<i64>> =
213 if let Some(ref selector) = options.selector {
214 let js = format!(
215 "document.querySelector({})",
216 serde_json::to_string(selector).unwrap_or_default()
217 );
218 let result: EvaluateResult = client
219 .send_command_typed(
220 "Runtime.evaluate",
221 &EvaluateParams {
222 expression: js,
223 return_by_value: Some(false),
224 await_promise: Some(false),
225 },
226 Some(session_id),
227 )
228 .await?;
229
230 if let Some(exception) = result.exception_details {
236 let detail = exception
237 .exception
238 .and_then(|e| e.description)
239 .unwrap_or(exception.text);
240 return Err(format!("Invalid selector '{}': {}", selector, detail));
241 }
242 if result.result.subtype.as_deref() != Some("node") {
243 return Err(format!("Selector '{}' did not match any element", selector));
244 }
245 let object_id = result
246 .result
247 .object_id
248 .ok_or_else(|| format!("Selector '{}' did not match any element", selector))?;
249
250 let describe: Value = client
253 .send_command(
254 "DOM.describeNode",
255 Some(serde_json::json!({ "objectId": object_id, "depth": -1 })),
256 Some(session_id),
257 )
258 .await?;
259
260 let root_node = describe
261 .get("node")
262 .ok_or_else(|| format!("Could not resolve DOM node for selector '{}'", selector))?;
263
264 let mut ids = std::collections::HashSet::new();
265 collect_backend_node_ids(root_node, &mut ids);
266
267 if ids.is_empty() {
268 return Err(format!(
269 "Could not resolve backendNodeId for selector '{}'",
270 selector
271 ));
272 }
273
274 Some(ids)
275 } else {
276 None
277 };
278
279 let (ax_params, effective_session_id) =
280 resolve_ax_session(frame_id, session_id, iframe_sessions);
281 if effective_session_id != session_id {
284 let _ = client
285 .send_command_no_params("DOM.enable", Some(effective_session_id))
286 .await;
287 let _ = client
288 .send_command_no_params("Accessibility.enable", Some(effective_session_id))
289 .await;
290 }
291 let ax_tree: GetFullAXTreeResult = client
292 .send_command_typed(
293 "Accessibility.getFullAXTree",
294 &ax_params,
295 Some(effective_session_id),
296 )
297 .await?;
298
299 let (mut tree_nodes, root_indices) = build_tree(&ax_tree.nodes);
300
301 let effective_roots = if let Some(ref id_set) = selector_backend_ids {
304 let in_subtree: Vec<bool> = tree_nodes
306 .iter()
307 .map(|n| n.backend_node_id.is_some_and(|bid| id_set.contains(&bid)))
308 .collect();
309
310 let mut roots = Vec::new();
313 for (idx, node) in tree_nodes.iter().enumerate() {
314 if !in_subtree[idx] {
315 continue;
316 }
317 let parent_in_subtree = node.parent_idx.is_some_and(|pidx| in_subtree[pidx]);
318 if !parent_in_subtree {
319 roots.push(idx);
320 }
321 }
322
323 if roots.is_empty() {
324 return Err(format!(
325 "No accessibility node found for selector '{}'",
326 options.selector.as_deref().unwrap_or("")
327 ));
328 }
329 roots
330 } else {
331 root_indices
332 };
333
334 let mut tracker = RoleNameTracker::new();
335 let mut next_ref: usize = ref_map.next_ref_num();
336
337 let mut nodes_with_refs: Vec<(usize, usize)> = Vec::new();
338
339 let cursor_elements: HashMap<i64, CursorElementInfo> =
341 find_cursor_interactive_elements(client, session_id)
342 .await
343 .unwrap_or_default();
344
345 promote_hidden_inputs(&mut tree_nodes, &cursor_elements);
346
347 for (idx, node) in tree_nodes.iter().enumerate() {
348 let role = node.role.as_str();
349 let mut should_ref = if INTERACTIVE_ROLES.contains(&role) {
350 true
351 } else if CONTENT_ROLES.contains(&role) {
352 !node.name.is_empty()
353 } else {
354 false
355 };
356
357 if node
358 .backend_node_id
359 .is_some_and(|bid| cursor_elements.contains_key(&bid))
360 {
361 should_ref = true;
363 }
364
365 if should_ref {
366 let nth = tracker.track(role, &node.name, idx);
367 nodes_with_refs.push((idx, nth));
368 }
369 }
370
371 let duplicates = tracker.get_duplicates();
372
373 for (idx, nth) in &nodes_with_refs {
374 let node = &tree_nodes[*idx];
375 let key = format!("{}:{}", node.role, node.name);
376 let actual_nth = if duplicates.contains_key(&key) {
377 Some(*nth)
378 } else {
379 None
380 };
381
382 let ref_id = format!("e{}", next_ref);
383 next_ref += 1;
384
385 ref_map.add_with_frame(
386 ref_id.clone(),
387 tree_nodes[*idx].backend_node_id,
388 &tree_nodes[*idx].role,
389 &tree_nodes[*idx].name,
390 actual_nth,
391 frame_id,
392 );
393
394 tree_nodes[*idx].has_ref = true;
395 tree_nodes[*idx].ref_id = Some(ref_id);
396 }
397
398 for (idx, _) in &nodes_with_refs {
400 if let Some(bid) = tree_nodes[*idx].backend_node_id {
401 if let Some(cursor_info) = cursor_elements.get(&bid) {
402 tree_nodes[*idx].cursor_info = Some((*cursor_info).clone());
403 }
404 }
405 }
406
407 ref_map.set_next_ref_num(next_ref);
408
409 if options.urls {
410 let link_nodes: Vec<(usize, i64)> = tree_nodes
411 .iter()
412 .enumerate()
413 .filter(|(_, n)| n.role == "link" && n.has_ref && n.backend_node_id.is_some())
414 .filter_map(|(i, n)| n.backend_node_id.map(|bid| (i, bid)))
415 .collect();
416
417 if !link_nodes.is_empty() {
418 let resolve_futs = link_nodes.iter().map(|&(idx, bid)| async move {
421 let resolved = client
422 .send_command(
423 "DOM.resolveNode",
424 Some(serde_json::json!({ "backendNodeId": bid })),
425 Some(session_id),
426 )
427 .await;
428 let obj_id = resolved.ok().and_then(|r| {
429 r.get("object")
430 .and_then(|o| o.get("objectId"))
431 .and_then(|v| v.as_str())
432 .map(|s| s.to_string())
433 });
434 (idx, obj_id)
435 });
436 let resolved: Vec<(usize, Option<String>)> =
437 futures_util::future::join_all(resolve_futs).await;
438
439 let href_futs: Vec<_> = resolved
441 .iter()
442 .filter_map(|(idx, obj_id)| {
443 let oid = obj_id.as_ref()?;
444 Some(async move {
445 let result = client
446 .send_command(
447 "Runtime.callFunctionOn",
448 Some(serde_json::json!({
449 "objectId": oid,
450 "functionDeclaration": "function() { return this.href || ''; }",
451 "returnByValue": true,
452 })),
453 Some(session_id),
454 )
455 .await;
456 let href = result.ok().and_then(|r| {
457 r.get("result")
458 .and_then(|r| r.get("value"))
459 .and_then(|v| v.as_str())
460 .filter(|s| !s.is_empty())
461 .map(|s| s.to_string())
462 });
463 (*idx, href)
464 })
465 })
466 .collect();
467 let hrefs: Vec<(usize, Option<String>)> =
468 futures_util::future::join_all(href_futs).await;
469
470 for (idx, href) in hrefs {
471 if let Some(url) = href {
472 tree_nodes[idx].url = Some(url);
473 }
474 }
475 }
476 }
477
478 let mut output = String::new();
479 for &root_idx in &effective_roots {
480 render_tree(&tree_nodes, root_idx, 0, &mut output, options);
481 }
482
483 if frame_id.is_none() {
488 let mut iframe_snapshots: Vec<(String, String)> = Vec::new(); for node in tree_nodes.iter() {
490 if node.role != "Iframe" || !node.has_ref {
491 continue;
492 }
493 let Some(bid) = node.backend_node_id else {
494 continue;
495 };
496 let ref_id = node.ref_id.as_deref().unwrap_or("");
497 if let Ok(child_fid) = resolve_iframe_frame_id(client, session_id, bid).await {
498 if let Ok(child_text) = Box::pin(take_snapshot(
501 client,
502 session_id,
503 options,
504 ref_map,
505 Some(&child_fid),
506 iframe_sessions,
507 ))
508 .await
509 {
510 if !child_text.is_empty()
511 && child_text != "(empty page)"
512 && child_text != "(no interactive elements)"
513 {
514 iframe_snapshots.push((ref_id.to_string(), child_text));
515 }
516 }
517 }
518 }
519
520 for (ref_id, child_text) in iframe_snapshots {
522 let marker = format!("[ref={}]", ref_id);
523 if let Some(pos) = output.find(&marker) {
524 let line_end = output[pos..]
526 .find('\n')
527 .map(|i| pos + i)
528 .unwrap_or(output.len());
529 let line_start = output[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
531 let iframe_line = &output[line_start..line_end];
532 let iframe_indent = iframe_line.len() - iframe_line.trim_start().len();
533 let child_indent = iframe_indent + 2; let prefix = " ".repeat(child_indent);
535
536 let indented_child: String = child_text
537 .lines()
538 .map(|line| format!("{}{}\n", prefix, line))
539 .collect();
540
541 if line_end == output.len() {
543 output.push('\n');
544 output.push_str(&indented_child);
545 } else {
546 output.insert_str(line_end + 1, &indented_child);
547 }
548 }
549 }
550 }
551
552 if options.compact {
553 output = compact_tree(&output, options.interactive);
554 }
555
556 let trimmed = output.trim().to_string();
557
558 if trimmed.is_empty() {
559 if options.interactive {
560 return Ok("(no interactive elements)".to_string());
561 }
562 return Ok("(empty page)".to_string());
563 }
564
565 Ok(trimmed)
566}
567
568async fn resolve_iframe_frame_id(
570 client: &CdpClient,
571 session_id: &str,
572 backend_node_id: i64,
573) -> Result<String, String> {
574 let describe: Value = client
576 .send_command(
577 "DOM.describeNode",
578 Some(serde_json::json!({ "backendNodeId": backend_node_id, "depth": 1 })),
579 Some(session_id),
580 )
581 .await?;
582
583 if let Some(frame_id) = describe
585 .get("node")
586 .and_then(|n| n.get("contentDocument"))
587 .and_then(|cd| cd.get("frameId"))
588 .and_then(|v| v.as_str())
589 {
590 return Ok(frame_id.to_string());
591 }
592
593 describe
595 .get("node")
596 .and_then(|n| n.get("frameId"))
597 .and_then(|v| v.as_str())
598 .map(|s| s.to_string())
599 .ok_or_else(|| "Could not resolve iframe frame ID".to_string())
600}
601
602async fn find_cursor_interactive_elements(
603 client: &CdpClient,
604 session_id: &str,
605) -> Result<HashMap<i64, CursorElementInfo>, String> {
606 let js = r#"
616(function() {
617 var results = [];
618 if (!document.body) return results;
619
620 var interactiveRoles = {
621 'button':1, 'link':1, 'textbox':1, 'checkbox':1, 'radio':1, 'combobox':1, 'listbox':1,
622 'menuitem':1, 'menuitemcheckbox':1, 'menuitemradio':1, 'option':1, 'searchbox':1,
623 'slider':1, 'spinbutton':1, 'switch':1, 'tab':1, 'treeitem':1
624 };
625 var interactiveTags = {
626 'a':1, 'button':1, 'input':1, 'select':1, 'textarea':1, 'details':1, 'summary':1
627 };
628
629 var allElements = document.body.querySelectorAll('*');
630 for (var i = 0; i < allElements.length; i++) {
631 var el = allElements[i];
632
633 if (el.closest && el.closest('[hidden], [aria-hidden="true"]')) continue;
634
635 var tagName = el.tagName.toLowerCase();
636 if (interactiveTags[tagName]) continue;
637
638 var role = el.getAttribute('role');
639 if (role && interactiveRoles[role.toLowerCase()]) continue;
640
641 var computedStyle = getComputedStyle(el);
642 var hasCursorPointer = computedStyle.cursor === 'pointer';
643 var hasOnClick = el.hasAttribute('onclick') || el.onclick !== null;
644 var tabIndex = el.getAttribute('tabindex');
645 var hasTabIndex = tabIndex !== null && tabIndex !== '-1';
646 var ce = el.getAttribute('contenteditable');
647 var isEditable = ce === '' || ce === 'true';
648
649 if (!hasCursorPointer && !hasOnClick && !hasTabIndex && !isEditable) continue;
650
651 // Skip elements that only inherit cursor:pointer from an ancestor
652 if (hasCursorPointer && !hasOnClick && !hasTabIndex && !isEditable) {
653 var parent = el.parentElement;
654 if (parent && getComputedStyle(parent).cursor === 'pointer') continue;
655 }
656
657 var text = (el.textContent || '').trim().slice(0, 100);
658
659 var rect = el.getBoundingClientRect();
660 if (rect.width === 0 || rect.height === 0) continue;
661
662 // Detect hidden radio/checkbox inputs inside this element (common pattern:
663 // <label> wrapping a display:none <input type="radio"> styled as a card).
664 // Note: we only check display/visibility/hidden, NOT opacity:0 or sr-only,
665 // because those inputs remain in Chrome's AX tree and already appear as
666 // role="radio" without promotion.
667 var hiddenInputType = null;
668 var hiddenInputChecked = null;
669 var hiddenInput = el.querySelector('input[type="radio"], input[type="checkbox"]');
670 if (hiddenInput) {
671 var hiddenInputStyle = getComputedStyle(hiddenInput);
672 var isInputHidden = hiddenInputStyle.display === 'none' || hiddenInputStyle.visibility === 'hidden' || hiddenInput.hidden;
673 if (isInputHidden) {
674 hiddenInputType = hiddenInput.type;
675 hiddenInputChecked = hiddenInput.indeterminate ? 'mixed' : String(hiddenInput.checked);
676 }
677 }
678
679 el.setAttribute('data-__ab-ci', String(results.length));
680 results.push({
681 text: text,
682 tagName: tagName,
683 hasOnClick: hasOnClick,
684 hasCursorPointer: hasCursorPointer,
685 hasTabIndex: hasTabIndex,
686 isEditable: isEditable,
687 hiddenInputType: hiddenInputType,
688 hiddenInputChecked: hiddenInputChecked
689 });
690 }
691 return results;
692})()
693"#;
694
695 let result: EvaluateResult = client
696 .send_command_typed(
697 "Runtime.evaluate",
698 &EvaluateParams {
699 expression: js.to_string(),
700 return_by_value: Some(true),
701 await_promise: Some(false),
702 },
703 Some(session_id),
704 )
705 .await?;
706
707 let elements: Vec<Value> = result
708 .result
709 .value
710 .and_then(|v| serde_json::from_value::<Vec<Value>>(v).ok())
711 .unwrap_or_default();
712
713 if elements.is_empty() {
714 return Ok(HashMap::new());
715 }
716
717 let doc: Value = client
720 .send_command(
721 "DOM.getDocument",
722 Some(serde_json::json!({ "depth": 0 })),
723 Some(session_id),
724 )
725 .await?;
726
727 let root_node_id = doc
728 .get("root")
729 .and_then(|r| r.get("nodeId"))
730 .and_then(|v| v.as_i64())
731 .ok_or("DOM.getDocument did not return root nodeId")?;
732
733 let query_result: Value = client
734 .send_command(
735 "DOM.querySelectorAll",
736 Some(serde_json::json!({
737 "nodeId": root_node_id,
738 "selector": "[data-__ab-ci]"
739 })),
740 Some(session_id),
741 )
742 .await?;
743
744 let node_ids: Vec<i64> = query_result
745 .get("nodeIds")
746 .and_then(|v| v.as_array())
747 .map(|arr| arr.iter().filter_map(|v| v.as_i64()).collect())
748 .unwrap_or_default();
749
750 let describe_futures: Vec<_> = node_ids
752 .iter()
753 .map(|&node_id| {
754 client.send_command(
755 "DOM.describeNode",
756 Some(serde_json::json!({ "nodeId": node_id })),
757 Some(session_id),
758 )
759 })
760 .collect();
761
762 let describe_results = futures_util::future::join_all(describe_futures).await;
763
764 let mut idx_to_backend: HashMap<usize, i64> = HashMap::new();
766 for desc in describe_results.into_iter().flatten() {
767 let backend_id = desc
768 .get("node")
769 .and_then(|n| n.get("backendNodeId"))
770 .and_then(|v| v.as_i64());
771 let ci_attr = desc
772 .get("node")
773 .and_then(|n| n.get("attributes"))
774 .and_then(|a| a.as_array())
775 .and_then(|attrs| {
776 attrs
778 .iter()
779 .enumerate()
780 .find(|(_, v)| v.as_str() == Some("data-__ab-ci"))
781 .and_then(|(i, _)| attrs.get(i + 1))
782 .and_then(|v| v.as_str())
783 .and_then(|s| s.parse::<usize>().ok())
784 });
785 if let (Some(bid), Some(idx)) = (backend_id, ci_attr) {
786 idx_to_backend.insert(idx, bid);
787 }
788 }
789
790 let cleanup_js =
792 r#"(function(){ var els = document.querySelectorAll('[data-__ab-ci]'); for (var i = 0; i < els.length; i++) els[i].removeAttribute('data-__ab-ci'); return els.length; })()"#.to_string();
793 if let Err(e) = client
794 .send_command_typed::<EvaluateParams, EvaluateResult>(
795 "Runtime.evaluate",
796 &EvaluateParams {
797 expression: cleanup_js,
798 return_by_value: Some(true),
799 await_promise: Some(false),
800 },
801 Some(session_id),
802 )
803 .await
804 {
805 eprintln!(
806 "[browser-automation-cli] Warning: failed to clean up data-__ab-ci attributes: {e}"
807 );
808 }
809
810 let mut map: HashMap<i64, CursorElementInfo> = HashMap::new();
812 for (i, elem) in elements.iter().enumerate() {
813 let backend_node_id = idx_to_backend.get(&i).copied();
814
815 let has_cursor_pointer = elem
818 .get("hasCursorPointer")
819 .and_then(|v| v.as_bool())
820 .unwrap_or(false);
821 let has_on_click = elem
822 .get("hasOnClick")
823 .and_then(|v| v.as_bool())
824 .unwrap_or(false);
825 let has_tab_index = elem
826 .get("hasTabIndex")
827 .and_then(|v| v.as_bool())
828 .unwrap_or(false);
829 let is_editable = elem
830 .get("isEditable")
831 .and_then(|v| v.as_bool())
832 .unwrap_or(false);
833
834 let kind = if has_cursor_pointer || has_on_click {
835 "clickable"
836 } else if is_editable {
837 "editable"
838 } else {
839 "focusable"
840 };
841
842 let mut hints: Vec<String> = Vec::new();
843 if has_cursor_pointer {
844 hints.push("cursor:pointer".to_string());
845 }
846 if has_on_click {
847 hints.push("onclick".to_string());
848 }
849 if has_tab_index {
850 hints.push("tabindex".to_string());
851 }
852 if is_editable {
853 hints.push("contenteditable".to_string());
854 }
855
856 let text = elem
857 .get("text")
858 .and_then(|v| v.as_str())
859 .unwrap_or("")
860 .trim()
861 .to_string();
862
863 let hidden_input_kind = elem
864 .get("hiddenInputType")
865 .and_then(|v| v.as_str())
866 .and_then(HiddenInputKind::parse);
867 let hidden_input_checked = elem
868 .get("hiddenInputChecked")
869 .and_then(|v| v.as_str())
870 .map(|s| s.to_string());
871
872 if let Some(bid) = backend_node_id {
873 map.insert(
874 bid,
875 CursorElementInfo {
876 kind: kind.to_string(),
877 hints,
878 text,
879 hidden_input_kind,
880 hidden_input_checked,
881 },
882 );
883 }
884 }
885
886 Ok(map)
887}
888
889fn promote_hidden_inputs(
895 tree_nodes: &mut [TreeNode],
896 cursor_elements: &HashMap<i64, CursorElementInfo>,
897) {
898 for node in tree_nodes.iter_mut() {
899 if !matches!(node.role.as_str(), "LabelText" | "generic") {
900 continue;
901 }
902 let cursor_info = match node
903 .backend_node_id
904 .and_then(|bid| cursor_elements.get(&bid))
905 {
906 Some(info) => info,
907 None => continue,
908 };
909 if let Some(input_kind) = cursor_info.hidden_input_kind {
910 node.role = input_kind.as_role().to_string();
911 if node.name.is_empty() && !cursor_info.text.is_empty() {
912 node.name = cursor_info.text.clone();
913 }
914 if let Some(ref checked) = cursor_info.hidden_input_checked {
915 node.checked = Some(checked.clone());
916 }
917 }
918 }
919}
920
921fn build_tree(nodes: &[AXNode]) -> (Vec<TreeNode>, Vec<usize>) {
922 let mut tree_nodes: Vec<TreeNode> = Vec::with_capacity(nodes.len());
923 let mut id_to_idx: HashMap<String, usize> = HashMap::new();
924
925 for (i, node) in nodes.iter().enumerate() {
926 let role = extract_ax_string(&node.role);
927 let name = extract_ax_string(&node.name);
928 let value_text = extract_ax_string_opt(&node.value);
929
930 let (level, checked, expanded, selected, disabled, required) =
931 extract_properties(&node.properties);
932
933 if (node.ignored.unwrap_or(false) && role != "RootWebArea") || role == "InlineTextBox" {
934 tree_nodes.push(TreeNode::empty());
935 id_to_idx.insert(node.node_id.clone(), i);
936 continue;
937 }
938
939 tree_nodes.push(TreeNode {
940 role,
941 name,
942 level,
943 checked,
944 expanded,
945 selected,
946 disabled,
947 required,
948 value_text,
949 backend_node_id: node.backend_d_o_m_node_id,
950 children: Vec::new(),
951 parent_idx: None,
952 has_ref: false,
953 ref_id: None,
954 depth: 0,
955 cursor_info: None,
956 url: None,
957 });
958 id_to_idx.insert(node.node_id.clone(), i);
959 }
960
961 for (i, node) in nodes.iter().enumerate() {
963 if let Some(ref child_ids) = node.child_ids {
964 for cid in child_ids {
965 if let Some(&child_idx) = id_to_idx.get(cid) {
966 tree_nodes[i].children.push(child_idx);
967 tree_nodes[child_idx].parent_idx = Some(i);
968 }
969 }
970 }
971 }
972
973 for i in 0..tree_nodes.len() {
975 if tree_nodes[i].role.is_empty() || tree_nodes[i].children.is_empty() {
976 continue;
977 }
978
979 let children_indices: Vec<usize> = tree_nodes[i].children.clone();
980
981 let mut start = 0;
985 while start < children_indices.len() {
986 if tree_nodes[children_indices[start]].role != "StaticText" {
988 start += 1;
989 continue;
990 }
991
992 let mut end = start + 1;
994 while end < children_indices.len()
995 && tree_nodes[children_indices[end]].role == "StaticText"
996 {
997 end += 1;
998 }
999
1000 if end > start + 1 {
1002 let aggregated_name: String = (start..end)
1004 .map(|idx| tree_nodes[children_indices[idx]].name.clone())
1005 .collect();
1006 tree_nodes[children_indices[start]].name = aggregated_name;
1008 for j in (start + 1)..end {
1010 tree_nodes[children_indices[j]].clear();
1011 }
1012 }
1013 start = end;
1014 }
1015
1016 if children_indices.len() == 1
1018 && tree_nodes[children_indices[0]].role == "StaticText"
1019 && tree_nodes[i].name == tree_nodes[children_indices[0]].name
1020 {
1021 tree_nodes[children_indices[0]].clear();
1022 }
1023 }
1024
1025 let mut root_indices = Vec::new();
1027 let children_exist: Vec<bool> = nodes.iter().map(|_| false).collect();
1028 let mut is_child = children_exist;
1029 for node in &tree_nodes {
1030 for &child in &node.children {
1031 is_child[child] = true;
1032 }
1033 }
1034 for (i, &is_c) in is_child.iter().enumerate() {
1035 if !is_c {
1036 root_indices.push(i);
1037 }
1038 }
1039
1040 fn set_depth(nodes: &mut [TreeNode], idx: usize, depth: usize) {
1041 nodes[idx].depth = depth;
1042 let children: Vec<usize> = nodes[idx].children.clone();
1043 for child_idx in children {
1044 set_depth(nodes, child_idx, depth + 1);
1045 }
1046 }
1047
1048 for &root in &root_indices {
1049 set_depth(&mut tree_nodes, root, 0);
1050 }
1051
1052 (tree_nodes, root_indices)
1053}
1054
1055fn render_tree(
1056 nodes: &[TreeNode],
1057 idx: usize,
1058 indent: usize,
1059 output: &mut String,
1060 options: &SnapshotOptions,
1061) {
1062 let node = &nodes[idx];
1063
1064 if node.role.is_empty()
1066 || (node.role == "generic" && !node.has_ref && node.children.len() <= 1)
1067 || (node.role == "StaticText" && node.name.replace(INVISIBLE_CHARS, "").is_empty())
1068 {
1069 for &child in &node.children {
1071 render_tree(nodes, child, indent, output, options);
1072 }
1073 return;
1074 }
1075
1076 if let Some(max_depth) = options.depth {
1077 if indent > max_depth {
1078 return;
1079 }
1080 }
1081
1082 let role = &node.role;
1083
1084 if role == "RootWebArea" || role == "WebArea" {
1086 for &child in &node.children {
1087 render_tree(nodes, child, indent, output, options);
1088 }
1089 return;
1090 }
1091
1092 if options.interactive && !node.has_ref {
1093 for &child in &node.children {
1095 render_tree(nodes, child, indent, output, options);
1096 }
1097 return;
1098 }
1099
1100 let prefix = " ".repeat(indent);
1101 let mut line = format!("{}- {}", prefix, role);
1102
1103 let unescaped_display_name = if !node.name.is_empty() {
1105 &node.name
1106 } else if options.interactive {
1107 if let Some(ref ci) = node.cursor_info {
1108 &ci.text
1109 } else {
1110 &node.name
1111 }
1112 } else {
1113 &node.name
1114 };
1115 if !unescaped_display_name.is_empty() {
1116 if let Ok(display_name) = serde_json::to_string(&unescaped_display_name) {
1117 line.push_str(&format!(" {}", display_name.replace(INVISIBLE_CHARS, "")));
1118 }
1119 }
1120
1121 let mut attrs = Vec::new();
1123
1124 if let Some(level) = node.level {
1125 attrs.push(format!("level={}", level));
1126 }
1127 if let Some(ref checked) = node.checked {
1128 attrs.push(format!("checked={}", checked));
1129 }
1130 if let Some(expanded) = node.expanded {
1131 attrs.push(format!("expanded={}", expanded));
1132 }
1133 if let Some(selected) = node.selected {
1134 if selected {
1135 attrs.push("selected".to_string());
1136 }
1137 }
1138 if let Some(disabled) = node.disabled {
1139 if disabled {
1140 attrs.push("disabled".to_string());
1141 }
1142 }
1143 if let Some(required) = node.required {
1144 if required {
1145 attrs.push("required".to_string());
1146 }
1147 }
1148
1149 if let Some(ref ref_id) = node.ref_id {
1150 attrs.push(format!("ref={}", ref_id));
1151 }
1152
1153 if let Some(ref url) = node.url {
1154 attrs.push(format!("url={}", url));
1155 }
1156
1157 if !attrs.is_empty() {
1158 line.push_str(&format!(" [{}]", attrs.join(", ")));
1159 }
1160
1161 if let Some(ref cursor_info) = node.cursor_info {
1163 line.push_str(&format!(
1164 " {} [{}]",
1165 cursor_info.kind,
1166 cursor_info.hints.join(", ")
1167 ));
1168 }
1169
1170 if let Some(ref val) = node.value_text {
1172 if !val.is_empty() && val != &node.name {
1173 line.push_str(&format!(": {}", val));
1174 }
1175 }
1176
1177 output.push_str(&line);
1178 output.push('\n');
1179
1180 for &child in &node.children {
1181 render_tree(nodes, child, indent + 1, output, options);
1182 }
1183}
1184
1185fn compact_tree(tree: &str, interactive: bool) -> String {
1186 let lines: Vec<&str> = tree.lines().collect();
1187 if lines.is_empty() {
1188 return String::new();
1189 }
1190
1191 let mut keep = vec![false; lines.len()];
1192
1193 for (i, line) in lines.iter().enumerate() {
1194 if line.contains("ref=") || line.contains(": ") {
1195 keep[i] = true;
1196 let my_indent = count_indent(line);
1198 for j in (0..i).rev() {
1199 let ancestor_indent = count_indent(lines[j]);
1200 if ancestor_indent < my_indent {
1201 keep[j] = true;
1202 if ancestor_indent == 0 {
1203 break;
1204 }
1205 }
1206 }
1207 }
1208 }
1209
1210 let result: Vec<&str> = lines
1211 .iter()
1212 .enumerate()
1213 .filter(|(i, _)| keep[*i])
1214 .map(|(_, line)| *line)
1215 .collect();
1216
1217 let output = result.join("\n");
1218 if output.trim().is_empty() && interactive {
1219 return "(no interactive elements)".to_string();
1220 }
1221 output
1222}
1223
1224fn count_indent(line: &str) -> usize {
1225 let trimmed = line.trim_start();
1226 (line.len() - trimmed.len()) / 2
1227}
1228
1229fn extract_ax_string(value: &Option<AXValue>) -> String {
1230 match value {
1231 Some(v) => match &v.value {
1232 Some(Value::String(s)) => s.clone(),
1233 Some(Value::Number(n)) => n.to_string(),
1234 Some(Value::Bool(b)) => b.to_string(),
1235 _ => String::new(),
1236 },
1237 None => String::new(),
1238 }
1239}
1240
1241fn extract_ax_string_opt(value: &Option<AXValue>) -> Option<String> {
1242 match value {
1243 Some(v) => match &v.value {
1244 Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
1245 Some(Value::Number(n)) => Some(n.to_string()),
1246 _ => None,
1247 },
1248 None => None,
1249 }
1250}
1251
1252type NodeProperties = (
1253 Option<i64>, Option<String>, Option<bool>, Option<bool>, Option<bool>, Option<bool>, );
1260
1261fn extract_properties(props: &Option<Vec<AXProperty>>) -> NodeProperties {
1262 let mut level = None;
1263 let mut checked = None;
1264 let mut expanded = None;
1265 let mut selected = None;
1266 let mut disabled = None;
1267 let mut required = None;
1268
1269 if let Some(properties) = props {
1270 for prop in properties {
1271 match prop.name.as_str() {
1272 "level" => {
1273 level = prop.value.value.as_ref().and_then(|v| v.as_i64());
1274 }
1275 "checked" => {
1276 checked = prop.value.value.as_ref().map(|v| match v {
1277 Value::String(s) => s.clone(),
1278 Value::Bool(b) => b.to_string(),
1279 _ => "false".to_string(),
1280 });
1281 }
1282 "expanded" => {
1283 expanded = prop.value.value.as_ref().and_then(|v| v.as_bool());
1284 }
1285 "selected" => {
1286 selected = prop.value.value.as_ref().and_then(|v| v.as_bool());
1287 }
1288 "disabled" => {
1289 disabled = prop.value.value.as_ref().and_then(|v| v.as_bool());
1290 }
1291 "required" => {
1292 required = prop.value.value.as_ref().and_then(|v| v.as_bool());
1293 }
1294 _ => {}
1295 }
1296 }
1297 }
1298
1299 (level, checked, expanded, selected, disabled, required)
1300}
1301
1302#[cfg(test)]
1308fn build_dedup_set(ref_map: &RefMap) -> std::collections::HashSet<String> {
1309 ref_map
1310 .entries_sorted()
1311 .into_iter()
1312 .filter(|(_, entry)| !entry.name.is_empty())
1313 .map(|(_, entry)| entry.name.to_lowercase())
1314 .collect()
1315}
1316
1317fn collect_backend_node_ids(node: &Value, ids: &mut std::collections::HashSet<i64>) {
1320 if let Some(id) = node.get("backendNodeId").and_then(|v| v.as_i64()) {
1321 ids.insert(id);
1322 }
1323 if let Some(children) = node.get("children").and_then(|v| v.as_array()) {
1324 for child in children {
1325 collect_backend_node_ids(child, ids);
1326 }
1327 }
1328 if let Some(shadow) = node.get("shadowRoots").and_then(|v| v.as_array()) {
1330 for child in shadow {
1331 collect_backend_node_ids(child, ids);
1332 }
1333 }
1334 if let Some(doc) = node.get("contentDocument") {
1335 collect_backend_node_ids(doc, ids);
1336 }
1337}
1338
1339#[cfg(test)]
1340mod tests {
1341 use super::*;
1342
1343 #[test]
1344 fn test_interactive_roles() {
1345 assert!(INTERACTIVE_ROLES.contains(&"button"));
1346 assert!(INTERACTIVE_ROLES.contains(&"textbox"));
1347 assert!(!INTERACTIVE_ROLES.contains(&"heading"));
1348 }
1349
1350 #[test]
1351 fn test_content_roles() {
1352 assert!(CONTENT_ROLES.contains(&"heading"));
1353 assert!(!CONTENT_ROLES.contains(&"button"));
1354 }
1355
1356 #[test]
1357 fn test_compact_tree_basic() {
1358 let tree = "- navigation\n - link \"Home\" [ref=e1]\n - link \"About\" [ref=e2]\n- main\n - heading \"Title\"\n - paragraph\n - text: Hello\n";
1359 let result = compact_tree(tree, false);
1360 assert!(result.contains("[ref=e1]"));
1361 assert!(result.contains("[ref=e2]"));
1362 assert!(result.contains("Hello"));
1363 }
1364
1365 #[test]
1366 fn test_compact_tree_radio_checkbox() {
1367 let tree = "- form\n - radio \"Single unit\" [checked=false, ref=e1]\n - checkbox \"I agree\" [checked=false, ref=e2]\n - button \"Submit\" [ref=e3]\n";
1370 let result = compact_tree(tree, true);
1371 assert!(
1372 result.contains("radio \"Single unit\""),
1373 "radio should be kept"
1374 );
1375 assert!(
1376 result.contains("checkbox \"I agree\""),
1377 "checkbox should be kept"
1378 );
1379 assert!(
1380 result.contains("button \"Submit\""),
1381 "button should be kept"
1382 );
1383 }
1384
1385 #[test]
1386 fn test_compact_tree_empty_interactive() {
1387 let result = compact_tree("- generic\n", true);
1388 assert_eq!(result, "(no interactive elements)");
1389 }
1390
1391 #[test]
1392 fn test_count_indent() {
1393 assert_eq!(count_indent("- heading"), 0);
1394 assert_eq!(count_indent(" - link"), 1);
1395 assert_eq!(count_indent(" - text"), 2);
1396 }
1397
1398 #[test]
1399 fn test_role_name_tracker() {
1400 let mut tracker = RoleNameTracker::new();
1401 assert_eq!(tracker.track("button", "Submit", 0), 0);
1402 assert_eq!(tracker.track("button", "Submit", 1), 1);
1403 assert_eq!(tracker.track("button", "Cancel", 2), 0);
1404
1405 let dups = tracker.get_duplicates();
1406 assert!(dups.contains_key("button:Submit"));
1407 assert!(!dups.contains_key("button:Cancel"));
1408 }
1409
1410 #[test]
1415 fn test_dedup_set_from_ref_map_names() {
1416 let mut ref_map = RefMap::new();
1417 ref_map.add("e1".to_string(), Some(1), "link", "Example Link", None);
1418 ref_map.add("e2".to_string(), Some(2), "button", "Submit", None);
1419
1420 let set = build_dedup_set(&ref_map);
1421 assert!(set.contains("example link"));
1422 assert!(set.contains("submit"));
1423 assert!(!set.contains("other text"));
1424 }
1425
1426 #[test]
1427 fn test_dedup_set_case_insensitive() {
1428 let mut ref_map = RefMap::new();
1429 ref_map.add("e1".to_string(), Some(1), "button", "Submit Form", None);
1430
1431 let set = build_dedup_set(&ref_map);
1432 assert!(set.contains("submit form"));
1433 assert!(!set.contains("Submit Form"));
1434 }
1435
1436 #[test]
1437 fn test_dedup_set_empty_inputs() {
1438 let ref_map = RefMap::new();
1439 let set = build_dedup_set(&ref_map);
1440 assert!(set.is_empty());
1441 }
1442
1443 #[test]
1444 fn test_dedup_set_skips_empty_names() {
1445 let mut ref_map = RefMap::new();
1446 ref_map.add("e1".to_string(), Some(1), "generic", "", None);
1447 ref_map.add("e2".to_string(), Some(2), "button", "OK", None);
1448
1449 let set = build_dedup_set(&ref_map);
1450 assert_eq!(set.len(), 1);
1451 assert!(set.contains("ok"));
1452 }
1453
1454 #[test]
1461 fn test_cross_origin_iframe_uses_dedicated_session() {
1462 let parent_session = "parent-session";
1463 let iframe_frame_id = "cross-origin-iframe-frame";
1464 let iframe_session = "cross-origin-iframe-session";
1465
1466 let mut iframe_sessions = HashMap::new();
1467 iframe_sessions.insert(iframe_frame_id.to_string(), iframe_session.to_string());
1468
1469 let (params, session) =
1470 resolve_ax_session(Some(iframe_frame_id), parent_session, &iframe_sessions);
1471
1472 assert_eq!(session, iframe_session);
1473 assert_eq!(params, serde_json::json!({}));
1474 }
1475
1476 #[test]
1477 fn test_same_origin_iframe_uses_parent_session_with_frame_id() {
1478 let parent_session = "parent-session";
1479 let iframe_frame_id = "same-origin-iframe-frame";
1480 let iframe_sessions = HashMap::new();
1481
1482 let (params, session) =
1483 resolve_ax_session(Some(iframe_frame_id), parent_session, &iframe_sessions);
1484
1485 assert_eq!(session, parent_session);
1486 assert_eq!(params, serde_json::json!({ "frameId": iframe_frame_id }));
1487 }
1488
1489 #[test]
1490 fn test_main_frame_uses_parent_session() {
1491 let parent_session = "parent-session";
1492 let iframe_sessions = HashMap::new();
1493
1494 let (params, session) = resolve_ax_session(None, parent_session, &iframe_sessions);
1495
1496 assert_eq!(session, parent_session);
1497 assert_eq!(params, serde_json::json!({}));
1498 }
1499
1500 fn make_node(role: &str, name: &str, backend_node_id: Option<i64>) -> TreeNode {
1505 let mut node = TreeNode::empty();
1506 node.role = role.to_string();
1507 node.name = name.to_string();
1508 node.backend_node_id = backend_node_id;
1509 node
1510 }
1511
1512 fn make_cursor_info(
1513 hidden_kind: Option<HiddenInputKind>,
1514 hidden_checked: Option<&str>,
1515 text: &str,
1516 ) -> CursorElementInfo {
1517 CursorElementInfo {
1518 kind: "clickable".to_string(),
1519 hints: vec!["cursor:pointer".to_string()],
1520 text: text.to_string(),
1521 hidden_input_kind: hidden_kind,
1522 hidden_input_checked: hidden_checked.map(|s| s.to_string()),
1523 }
1524 }
1525
1526 #[test]
1527 fn test_promote_label_with_hidden_radio() {
1528 let mut nodes = vec![
1529 make_node("LabelText", "", Some(1)),
1530 make_node("LabelText", "", Some(2)),
1531 make_node("button", "Submit", Some(3)),
1532 ];
1533 let mut cursor_elements = HashMap::new();
1534 cursor_elements.insert(
1535 1,
1536 make_cursor_info(Some(HiddenInputKind::Radio), Some("false"), "Option A"),
1537 );
1538 cursor_elements.insert(
1539 2,
1540 make_cursor_info(Some(HiddenInputKind::Radio), Some("true"), "Option B"),
1541 );
1542
1543 promote_hidden_inputs(&mut nodes, &cursor_elements);
1544
1545 assert_eq!(nodes[0].role, "radio");
1546 assert_eq!(nodes[0].name, "Option A");
1547 assert_eq!(nodes[0].checked, Some("false".to_string()));
1548 assert_eq!(nodes[1].role, "radio");
1549 assert_eq!(nodes[1].name, "Option B");
1550 assert_eq!(nodes[1].checked, Some("true".to_string()));
1551 assert_eq!(nodes[2].role, "button");
1553 }
1554
1555 #[test]
1556 fn test_promote_preserves_existing_name() {
1557 let mut nodes = vec![make_node("LabelText", "AX Name", Some(1))];
1559 let mut cursor_elements = HashMap::new();
1560 cursor_elements.insert(
1561 1,
1562 make_cursor_info(Some(HiddenInputKind::Radio), Some("false"), "Text Content"),
1563 );
1564
1565 promote_hidden_inputs(&mut nodes, &cursor_elements);
1566
1567 assert_eq!(nodes[0].role, "radio");
1568 assert_eq!(nodes[0].name, "AX Name"); }
1570
1571 #[test]
1572 fn test_promote_skips_without_hidden_input() {
1573 let mut nodes = vec![make_node("LabelText", "", Some(1))];
1575 let mut cursor_elements = HashMap::new();
1576 cursor_elements.insert(1, make_cursor_info(None, None, "Click me"));
1577
1578 promote_hidden_inputs(&mut nodes, &cursor_elements);
1579
1580 assert_eq!(nodes[0].role, "LabelText"); }
1582}