Skip to main content

browser_automation_cli/native/
snapshot.rs

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}', // BOM / Zero Width No-Break Space
48    '\u{200B}', // Zero Width Space
49    '\u{200C}', // Zero Width Non-Joiner
50    '\u{200D}', // Zero Width Joiner
51    '\u{2060}', // Word Joiner
52    '\u{00A0}', // Non-Breaking Space ( )
53];
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    // Create an empty node
86    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/// The type of a hidden form input found inside a cursor-interactive element.
130#[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/// Information about a cursor-interactive element (elements with cursor:pointer, onclick, tabindex, etc.)
154#[derive(Clone)]
155struct CursorElementInfo {
156    kind: String, // "clickable", "focusable", "editable"
157    hints: Vec<String>,
158    text: String, // textContent from the DOM element (fallback when ARIA name is empty)
159    hidden_input_kind: Option<HiddenInputKind>,
160    hidden_input_checked: Option<String>, // "true", "false", or "mixed" (tristate)
161}
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    // If a CSS selector is provided, resolve the set of backendNodeIds that
210    // belong to the DOM subtree rooted at the matched element.  We use this
211    // set to pick the right AX subtree root(s) later.
212    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            // A throwing evaluation (e.g. an invalid CSS selector like a
231            // snapshot ref "@e1") still yields an objectId — for the
232            // exception object. Passing that to DOM.describeNode produces the
233            // cryptic "Object id doesn't reference a Node"; fail clearly
234            // instead, and only accept results that are actually DOM nodes.
235            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            // Request the full DOM subtree (depth: -1) so we can collect all
251            // backendNodeIds that live under the matched element.
252            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    // Ensure domains are enabled on the iframe session (defensive fallback
282    // in case the attach-time enable in execute_command was missed).
283    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    // When a selector is given, find AX nodes whose backendDOMNodeId falls
302    // within the target DOM subtree and pick the top-level ones as roots.
303    let effective_roots = if let Some(ref id_set) = selector_backend_ids {
304        // Mark which tree_nodes belong to the target DOM subtree.
305        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        // An AX node is a "top-level" match if it is in the subtree but its
311        // parent (in the AX tree) is not.
312        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    // Pre-collect cursor-interactive elements so we can mark them with refs during tree building
340    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            // ref elements that are cursor-interactive
362            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    // Populate cursor_info for ref-bearing nodes
399    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            // CDP has no batch resolve API, so we parallelize individual calls.
419            // Phase 1: resolve all backend node IDs to JS object IDs in parallel.
420            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            // Phase 2: fetch hrefs for all resolved objects in parallel.
440            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    // Recurse into child iframes: for each Iframe node with a backend_node_id,
484    // resolve the child frame ID and take a snapshot of its content.
485    // We only recurse from the main frame (frame_id == None) to avoid
486    // unbounded depth; nested iframes within iframes are not expanded.
487    if frame_id.is_none() {
488        let mut iframe_snapshots: Vec<(String, String)> = Vec::new(); // (ref_id, child_snapshot)
489        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                // Snapshot the child frame; errors are silently ignored
499                // (e.g. cross-origin iframes)
500                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        // Insert each child snapshot after its Iframe line in the output
521        for (ref_id, child_text) in iframe_snapshots {
522            let marker = format!("[ref={}]", ref_id);
523            if let Some(pos) = output.find(&marker) {
524                // Find the end of the Iframe line
525                let line_end = output[pos..]
526                    .find('\n')
527                    .map(|i| pos + i)
528                    .unwrap_or(output.len());
529                // Determine the indent of the Iframe line
530                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; // one level deeper
534                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                // Ensure there's a newline to insert after
542                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
568/// Resolve the child frame ID for an iframe element given its backendNodeId.
569async fn resolve_iframe_frame_id(
570    client: &CdpClient,
571    session_id: &str,
572    backend_node_id: i64,
573) -> Result<String, String> {
574    // depth: 1 ensures contentDocument is included in the response
575    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    // Try contentDocument.frameId first (standard for iframes)
584    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    // Fallback: the node itself may have a frameId
594    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    // Single JS evaluation that matches the v0.19.0 Node.js findCursorInteractiveElements():
607    // - Uses querySelectorAll('*') to walk all elements
608    // - Checks getComputedStyle(el).cursor === 'pointer'
609    // - Checks onclick attribute/handler and tabindex
610    // - Skips interactiveTags (a, button, input, select, textarea, details, summary)
611    // - Skips elements with interactive ARIA roles
612    // - Deduplicates inherited cursor:pointer from parent
613    // - Skips empty text and zero-size elements
614    // - Tags each matched element with data-__ab-ci for batch backendNodeId resolution
615    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    // Batch-resolve backendNodeIds: use DOM.getDocument to get the root nodeId,
718    // then DOM.querySelectorAll to get all tagged elements in a single call.
719    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    // Resolve backendNodeIds for each DOM node using concurrent CDP calls.
751    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    // Build a map from data-__ab-ci index to backendNodeId.
765    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                // attributes is a flat array: [name, value, name, value, ...]
777                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    // Clean up the data attributes we injected for backendNodeId resolution.
791    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    // Build the map
811    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        // Role differentiation: v0.19.0 uses 'clickable' for cursor:pointer or onclick,
816        // 'focusable' for tabindex-only elements.
817        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
889/// Promote LabelText/generic nodes that wrap a hidden radio/checkbox input.
890/// When a `<label>` contains a `display:none` `<input type="radio">`, Chrome excludes
891/// the input from the AX tree entirely, leaving only the label with role="LabelText"
892/// and an empty name. We detect these via cursor-interactive scanning and promote
893/// the label to the correct input role so consumers see role="radio" in data.refs.
894fn 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    // Build parent-child relationships
962    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    // Process StaticText aggregation
974    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        // Continuous StaticText nodes at the same level are an artifact of HTML structure rather than semantic meaning.
982        // They typically represent a single continuous piece of text on the page that was split due to inline elements, formatting tags, or other structural reasons.
983        // Thus, continuous StaticText children are aggregated into the first one.
984        let mut start = 0;
985        while start < children_indices.len() {
986            // Skip non-StaticText nodes
987            if tree_nodes[children_indices[start]].role != "StaticText" {
988                start += 1;
989                continue;
990            }
991
992            // Find the end of the current StaticText sequence
993            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 we have a sequence of at least two StaticText
1001            if end > start + 1 {
1002                // Collect and aggregate all names from the sequence
1003                let aggregated_name: String = (start..end)
1004                    .map(|idx| tree_nodes[children_indices[idx]].name.clone())
1005                    .collect();
1006                // Always aggregate into the first node of the sequence
1007                tree_nodes[children_indices[start]].name = aggregated_name;
1008                // Clear the rest of the nodes in the sequence (from start+1 to end-1)
1009                for j in (start + 1)..end {
1010                    tree_nodes[children_indices[j]].clear();
1011                }
1012            }
1013            start = end;
1014        }
1015
1016        // Deduplicate redundant StaticText
1017        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    // Set depths
1026    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    // Reduce unnecessary indentation and rendering
1065    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        // Ignored node -- still render children
1070        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    // Skip root WebArea wrapper
1085    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        // In interactive mode, skip non-interactive but render children
1094        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    // Use ARIA name if available, only fall back to cursor-interactive textContent in interactive mode since their visible text in child nodes is filtered out
1104    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    // Properties
1122    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    // Add cursor-interactive kind & hints
1162    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    // Value
1171    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            // Mark ancestors
1197            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>,    // level
1254    Option<String>, // checked
1255    Option<bool>,   // expanded
1256    Option<bool>,   // selected
1257    Option<bool>,   // disabled
1258    Option<bool>,   // required
1259);
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/// Build the set of texts to de-duplicate cursor-interactive elements against.
1303///
1304/// All ref-bearing ARIA tree nodes have their names stored in `ref_map` during
1305/// tree construction, so the ref-map entries are the single source of truth.
1306/// This avoids fragile parsing of the rendered tree text.
1307#[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
1317/// Recursively collect all `backendNodeId` values from a CDP DOM node tree
1318/// (as returned by `DOM.describeNode` with `depth: -1`).
1319fn 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    // Shadow DOM and content documents
1329    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        // Radio/checkbox lines have attributes before ref (e.g. [checked=false, ref=e1])
1368        // so "ref=" appears without a leading "[" — compact_tree must still keep them.
1369        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    // -----------------------------------------------------------------------
1411    // Cursor-interactive text dedup (Issue #841 regression guard)
1412    // -----------------------------------------------------------------------
1413
1414    #[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    // -----------------------------------------------------------------------
1455    // resolve_ax_session tests (Issue #925 regression guard)
1456    // Cross-origin iframes must use a dedicated session without frameId.
1457    // Same-origin iframes must use the parent session with frameId.
1458    // -----------------------------------------------------------------------
1459
1460    #[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    // -----------------------------------------------------------------------
1501    // promote_hidden_inputs
1502    // -----------------------------------------------------------------------
1503
1504    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        // button should be untouched
1552        assert_eq!(nodes[2].role, "button");
1553    }
1554
1555    #[test]
1556    fn test_promote_preserves_existing_name() {
1557        // If AX tree already has a name, don't overwrite with textContent
1558        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"); // preserved, not overwritten
1569    }
1570
1571    #[test]
1572    fn test_promote_skips_without_hidden_input() {
1573        // Cursor-interactive label WITHOUT a hidden input should not be promoted
1574        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"); // unchanged
1581    }
1582}