Skip to main content

glass/browser/
dom.rs

1//! DOM and accessibility tree parsing.
2//!
3//! Parses CDP `DOM.getDocument` and `Accessibility.getFullAXTree` responses
4//! into typed Rust structures. Provides compact accessibility projection
5//! with configurable node and text limits.
6
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::{HashMap, HashSet};
10
11/// Hard limits for accessibility data retained by a compact observation.
12pub const COMPACT_AX_MAX_NODES: usize = 128;
13pub const COMPACT_AX_MAX_INTERACTIVE: usize = 32;
14pub const COMPACT_AX_TEXT_MAX_BYTES: usize = 4 * 1024;
15pub const COMPACT_AX_ROLE_MAX_BYTES: usize = 64;
16const COMPACT_AX_INTERACTIVE_TEXT_MAX_BYTES: usize = COMPACT_AX_TEXT_MAX_BYTES / 2;
17const COMPACT_AX_OUTLINE_TEXT_MAX_BYTES: usize =
18    COMPACT_AX_TEXT_MAX_BYTES - COMPACT_AX_INTERACTIVE_TEXT_MAX_BYTES;
19const COMPACT_AX_TRUNCATION_MARKER: &str = "…";
20
21/// Shadow piercing limits for compact observation.
22pub const MAX_SHADOW_DEPTH: u8 = 3;
23pub const MAX_SHADOW_HOSTS: usize = 8;
24pub const MAX_SHADOW_PATH_ENTRIES: usize = 3;
25pub const MAX_SHADOW_PATH_ENTRY_BYTES: usize = 64;
26
27/// A simplified accessibility tree node.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AxNode {
30    pub ax_node_id: String,
31    pub backend_dom_node_id: Option<i64>,
32    pub role: String,
33    pub name: String,
34    pub description: String,
35    pub value: Option<String>,
36    pub children: Vec<AxNode>,
37    /// Bounding box: [x, y, width, height]. Filled by the session when needed.
38    pub bounds: Option<[f64; 4]>,
39    pub interactive: bool,
40    /// HTML input type extracted from AX properties (text, email, checkbox, etc.).
41    pub input_type: Option<String>,
42}
43
44/// A bounded semantic accessibility node used only by compact observations.
45#[derive(Debug, Clone, Serialize)]
46pub struct CompactAxNode {
47    pub role: String,
48    #[serde(skip_serializing_if = "String::is_empty")]
49    pub name: String,
50    #[serde(skip_serializing_if = "Vec::is_empty")]
51    pub children: Vec<CompactAxNode>,
52    #[serde(skip_serializing_if = "is_false")]
53    pub interactive: bool,
54}
55
56/// A bounded interactive control included in a compact observation.
57#[derive(Debug, Clone, Serialize)]
58pub struct CompactInteractiveElement {
59    pub reference: String,
60    pub role: String,
61    #[serde(skip_serializing_if = "String::is_empty")]
62    pub name: String,
63    pub backend_dom_node_id: i64,
64    /// Bounded accessibility ancestor labels used for scoped reconciliation.
65    /// This is an internal continuity hint and is not emitted on the wire.
66    #[serde(skip)]
67    pub(crate) ancestor_path: Vec<String>,
68    /// Role+name breadcrumbs for shadow-host ancestry (max 3 entries, 64 bytes each).
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub shadow_host_path: Option<Vec<String>>,
71    /// HTML input type for form controls (text, email, password, checkbox, etc.).
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub input_type: Option<String>,
74    // --- Form value fields (populated only when includeFormValues is set) ---
75    /// Current value of the form control, bounded to 256 bytes. Redacted for passwords.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub value: Option<String>,
78    /// Checked state for checkbox/radio controls.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub checked: Option<bool>,
81    /// Selected option label for `<select>` elements, bounded to 128 bytes.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub selected_option: Option<String>,
84    /// Whether the field is empty (no user input).
85    #[serde(skip_serializing_if = "is_false")]
86    pub empty: bool,
87    /// Whether the field is read-only.
88    #[serde(skip_serializing_if = "is_false")]
89    pub read_only: bool,
90    /// Whether the field has the required attribute.
91    #[serde(skip_serializing_if = "is_false")]
92    pub required: bool,
93}
94
95/// Maximum UTF-8 byte length for a form field value in compact observe.
96pub const FORM_VALUE_MAX_BYTES: usize = 256;
97/// Maximum UTF-8 byte length for a `<select>` option label.
98pub const SELECT_OPTION_MAX_BYTES: usize = 128;
99/// Maximum number of form fields whose values are read per observe.
100pub const FORM_VALUE_MAX_FIELDS: usize = 16;
101
102/// Compact accessibility data with no unbounded text fields.
103#[derive(Debug, Clone)]
104pub struct CompactAccessibilityProjection {
105    pub roots: Vec<CompactAxNode>,
106    pub interactive: Vec<CompactInteractiveElement>,
107    pub truncated: bool,
108    pub nodes_truncated: bool,
109    pub labels_truncated: bool,
110    pub controls_truncated: bool,
111    /// Total interactive controls discovered (before truncation/ranking).
112    pub interactive_discovered: usize,
113    /// Number of interactive controls discovered but omitted due to the 32-control budget.
114    pub omitted_count: usize,
115    /// Whether the interactive list was relevance-ranked before truncation.
116    pub ranking_applied: bool,
117    /// Shadow piercing summary.
118    pub shadow_pierced: ShadowPiercedSummary,
119}
120
121/// Summary of shadow-root piercing during a compact observation.
122#[derive(Debug, Clone, Default)]
123pub struct ShadowPiercedSummary {
124    /// Number of open shadow hosts visited during piercing.
125    pub hosts_visited: usize,
126    /// Number of interactive controls discovered inside shadow roots.
127    pub controls_found: usize,
128    /// Whether piercing was truncated by budget (depth > 3 or hosts > 8).
129    pub truncated: bool,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum CompactRanking {
134    Relevance,
135    DocumentOrder,
136}
137
138struct CompactProjectionState {
139    node_count: usize,
140    interactive_text_remaining: usize,
141    outline_text_remaining: usize,
142    interactive: Vec<CompactInteractiveElement>,
143    truncated: bool,
144    nodes_truncated: bool,
145    labels_truncated: bool,
146    controls_truncated: bool,
147}
148
149impl CompactProjectionState {
150    fn new() -> Self {
151        Self {
152            node_count: 0,
153            interactive_text_remaining: COMPACT_AX_INTERACTIVE_TEXT_MAX_BYTES,
154            outline_text_remaining: COMPACT_AX_OUTLINE_TEXT_MAX_BYTES,
155            interactive: Vec::new(),
156            truncated: false,
157            nodes_truncated: false,
158            labels_truncated: false,
159            controls_truncated: false,
160        }
161    }
162}
163
164fn is_false(value: &bool) -> bool {
165    !*value
166}
167
168/// Extract a linked accessibility tree from `Accessibility.getFullAXTree`.
169pub fn parse_accessibility_tree(raw: &Value) -> Vec<AxNode> {
170    let Some(entries) = raw["nodes"].as_array() else {
171        return Vec::new();
172    };
173
174    let mut by_id = HashMap::new();
175    for entry in entries {
176        if let Some(id) = entry["nodeId"].as_str() {
177            by_id.insert(id.to_string(), entry.clone());
178        }
179    }
180
181    let mut child_ids = HashSet::new();
182    for entry in entries {
183        if let Some(children) = entry["childIds"].as_array() {
184            for child in children.iter().filter_map(Value::as_str) {
185                child_ids.insert(child.to_string());
186            }
187        }
188    }
189
190    let mut roots = Vec::new();
191    for entry in entries {
192        let Some(id) = entry["nodeId"].as_str() else {
193            continue;
194        };
195        let has_parent = entry["parentId"].as_str().is_some() || child_ids.contains(id);
196        if !has_parent {
197            roots.push(id.to_string());
198        }
199    }
200    if roots.is_empty() {
201        roots.extend(
202            entries
203                .iter()
204                .filter_map(|entry| entry["nodeId"].as_str().map(String::from)),
205        );
206    }
207
208    let mut visiting = HashSet::new();
209    roots
210        .iter()
211        .filter_map(|id| build_ax_node(id, &by_id, &mut visiting))
212        .collect()
213}
214
215fn build_ax_node(
216    id: &str,
217    by_id: &HashMap<String, Value>,
218    visiting: &mut HashSet<String>,
219) -> Option<AxNode> {
220    if !visiting.insert(id.to_string()) {
221        return None;
222    }
223    let raw = by_id.get(id)?;
224    let children = raw["childIds"]
225        .as_array()
226        .into_iter()
227        .flatten()
228        .filter_map(Value::as_str)
229        .filter_map(|child_id| build_ax_node(child_id, by_id, visiting))
230        .collect();
231    visiting.remove(id);
232
233    let role = property_text(raw, "role").unwrap_or_else(|| "unknown".to_string());
234    let interactive = is_interactive_role(&role)
235        || raw["properties"].as_array().is_some_and(|properties| {
236            properties.iter().any(|property| {
237                property["name"].as_str() == Some("focusable")
238                    && property["value"]["value"].as_bool() == Some(true)
239            })
240        });
241
242    Some(AxNode {
243        ax_node_id: id.to_string(),
244        backend_dom_node_id: raw["backendDOMNodeId"].as_i64(),
245        role,
246        name: property_text(raw, "name").unwrap_or_default(),
247        description: property_text(raw, "description").unwrap_or_default(),
248        value: property_text(raw, "value"),
249        children,
250        bounds: None,
251        interactive,
252        input_type: extract_input_type(raw),
253    })
254}
255
256fn property_text(raw: &Value, property: &str) -> Option<String> {
257    raw[property]["value"]
258        .as_str()
259        .or_else(|| raw[property].as_str())
260        .map(String::from)
261}
262
263fn is_interactive_role(role: &str) -> bool {
264    matches!(
265        role,
266        "button"
267            | "link"
268            | "textbox"
269            | "checkbox"
270            | "radio"
271            | "combobox"
272            | "menuitem"
273            | "tab"
274            | "option"
275            | "searchbox"
276            | "spinbutton"
277            | "slider"
278            | "switch"
279    )
280}
281
282/// Return accessibility nodes with the `interactive` flag set, in document
283/// order (depth-first).
284pub fn find_interactive_elements(nodes: &[AxNode]) -> Vec<&AxNode> {
285    let mut result = Vec::new();
286    for node in nodes {
287        if node.interactive {
288            result.push(node);
289        }
290        result.extend(find_interactive_elements(&node.children));
291    }
292    result
293}
294
295/// Deterministic relevance score for interactive controls.
296/// Higher score = more important to include when truncating.
297fn control_relevance_score(control: &CompactInteractiveElement) -> i32 {
298    let role_score = match control.role.as_str() {
299        "button" | "link" => 36,
300        "textbox" | "combobox" | "searchbox" | "spinbutton" => 34,
301        "checkbox" | "radio" | "switch" | "menuitem" | "option" => 30,
302        "slider" => 28,
303        "listbox" | "menu" | "tab" | "treeitem" => 24,
304        _ => 12,
305    };
306    let form_score = if control.input_type.is_some() { 8 } else { 0 }
307        + i32::from(control.required) * 5
308        + i32::from(control.checked.is_some() || control.selected_option.is_some()) * 3;
309    let name_score = if control.name.is_empty() { 0 } else { 6 };
310    let shadow_score = if control.shadow_host_path.is_some() {
311        -2
312    } else {
313        0
314    };
315    role_score + form_score + name_score + shadow_score
316}
317
318/// Rank interactive controls by relevance, keeping the most important ones first.
319/// Stable sort preserves document order for equal scores (tie-break).
320fn rank_interactive_controls(controls: &mut [CompactInteractiveElement]) {
321    controls.sort_by(|a, b| {
322        let score_a = control_relevance_score(a);
323        let score_b = control_relevance_score(b);
324        score_b.cmp(&score_a)
325    });
326}
327
328/// Project a full accessibility tree into bounded semantic context.
329///
330/// Every published control has a backend DOM node and a revisioned reference,
331/// so it can be resolved without a fresh full accessibility snapshot.
332pub fn project_compact_accessibility(
333    nodes: &[AxNode],
334    revision: u64,
335) -> CompactAccessibilityProjection {
336    project_compact_accessibility_with_ranking(nodes, revision, CompactRanking::Relevance)
337}
338
339/// Project compact accessibility with an explicit truncation order. The
340/// document-order mode is a compatibility escape hatch for regression tests
341/// and callers comparing pre-ranking observations.
342pub fn project_compact_accessibility_with_ranking(
343    nodes: &[AxNode],
344    revision: u64,
345    ranking: CompactRanking,
346) -> CompactAccessibilityProjection {
347    let mut state = CompactProjectionState::new();
348    let roots = nodes
349        .iter()
350        .filter_map(|node| project_compact_node(node, revision, &mut state, &[]))
351        .collect();
352    let total_discovered = state.interactive.len();
353    let ranking_applied =
354        total_discovered > COMPACT_AX_MAX_INTERACTIVE && ranking == CompactRanking::Relevance;
355
356    if total_discovered > COMPACT_AX_MAX_INTERACTIVE {
357        if ranking == CompactRanking::Relevance {
358            rank_interactive_controls(&mut state.interactive);
359        }
360        let omitted = total_discovered.saturating_sub(COMPACT_AX_MAX_INTERACTIVE);
361        state.interactive.truncate(COMPACT_AX_MAX_INTERACTIVE);
362        state.controls_truncated = true;
363
364        CompactAccessibilityProjection {
365            roots,
366            interactive: state.interactive,
367            truncated: state.truncated,
368            nodes_truncated: state.nodes_truncated,
369            labels_truncated: state.labels_truncated,
370            controls_truncated: true,
371            interactive_discovered: total_discovered,
372            omitted_count: omitted.min(999),
373            ranking_applied,
374            shadow_pierced: ShadowPiercedSummary::default(),
375        }
376    } else {
377        CompactAccessibilityProjection {
378            roots,
379            interactive: state.interactive,
380            truncated: state.truncated,
381            nodes_truncated: state.nodes_truncated,
382            labels_truncated: state.labels_truncated,
383            controls_truncated: state.controls_truncated,
384            interactive_discovered: total_discovered,
385            omitted_count: 0,
386            ranking_applied: false,
387            shadow_pierced: ShadowPiercedSummary::default(),
388        }
389    }
390}
391
392fn project_compact_node(
393    node: &AxNode,
394    revision: u64,
395    state: &mut CompactProjectionState,
396    ancestors: &[String],
397) -> Option<CompactAxNode> {
398    if state.node_count >= COMPACT_AX_MAX_NODES {
399        state.truncated = true;
400        state.nodes_truncated = true;
401        return None;
402    }
403    state.node_count += 1;
404
405    let (role, role_truncated) = truncate_utf8(&node.role, COMPACT_AX_ROLE_MAX_BYTES);
406    state.truncated |= role_truncated;
407    state.labels_truncated |= role_truncated;
408    let mut name = String::new();
409    if node.interactive {
410        let control_name = take_compact_text(
411            &node.name,
412            &mut state.interactive_text_remaining,
413            &mut state.truncated,
414        );
415        state.controls_truncated |= control_name.len() < node.name.len();
416        if let Some(backend_dom_node_id) = node.backend_dom_node_id {
417            state.interactive.push(CompactInteractiveElement {
418                reference: backend_node_reference(revision, backend_dom_node_id),
419                role: role.clone(),
420                name: control_name,
421                backend_dom_node_id,
422                ancestor_path: ancestors.to_vec(),
423                shadow_host_path: None,
424                input_type: node.input_type.clone(),
425                value: None,
426                checked: None,
427                selected_option: None,
428                empty: false,
429                read_only: false,
430                required: false,
431            });
432        } else {
433            state.truncated = true;
434            state.controls_truncated = true;
435        }
436    } else {
437        name = take_compact_text(
438            &node.name,
439            &mut state.outline_text_remaining,
440            &mut state.truncated,
441        );
442        state.labels_truncated |= name.len() < node.name.len();
443    }
444
445    let mut child_ancestors = ancestors.to_vec();
446    let ancestor_label = format!("{}:{}", role, name);
447    if !ancestor_label.trim_end_matches(':').is_empty() {
448        let (label, _) = truncate_utf8(&ancestor_label, 96);
449        child_ancestors.push(label);
450        if child_ancestors.len() > 4 {
451            child_ancestors.remove(0);
452        }
453    }
454    let children = node
455        .children
456        .iter()
457        .filter_map(|child| project_compact_node(child, revision, state, &child_ancestors))
458        .collect();
459    Some(CompactAxNode {
460        role,
461        name,
462        children,
463        interactive: node.interactive,
464    })
465}
466
467/// Format a stable-in-one-revision element reference backed by Chrome's DOM
468/// node identifier. The revision makes stale references fail rather than
469/// silently selecting an element that inherited an ordinal position.
470pub fn backend_node_reference(revision: u64, backend_dom_node_id: i64) -> String {
471    format!("r{revision}:b{backend_dom_node_id}")
472}
473
474fn take_compact_text(text: &str, remaining: &mut usize, truncated: &mut bool) -> String {
475    if text.is_empty() {
476        return String::new();
477    }
478    let (value, was_truncated) = truncate_utf8(text, *remaining);
479    *remaining = remaining.saturating_sub(value.len());
480    *truncated |= was_truncated;
481    value
482}
483
484pub(crate) fn truncate_utf8(text: &str, max_bytes: usize) -> (String, bool) {
485    if text.len() <= max_bytes {
486        return (text.to_string(), false);
487    }
488
489    let marker_len = COMPACT_AX_TRUNCATION_MARKER.len();
490    let mut end = max_bytes.saturating_sub(marker_len);
491    while end > 0 && !text.is_char_boundary(end) {
492        end -= 1;
493    }
494    let mut value = text[..end].to_string();
495    if max_bytes >= marker_len {
496        value.push_str(COMPACT_AX_TRUNCATION_MARKER);
497    }
498    (value, true)
499}
500
501/// Recursively collect visible name text from an accessibility subtree,
502/// joined with newlines.
503pub fn extract_text_content(nodes: &[AxNode]) -> String {
504    let mut parts = Vec::new();
505    extract_text_recursive(nodes, &mut parts);
506    parts.join("\n")
507}
508
509fn extract_text_recursive(nodes: &[AxNode], parts: &mut Vec<String>) {
510    for node in nodes {
511        if !node.name.is_empty() && node.role != "presentation" && node.role != "none" {
512            parts.push(node.name.clone());
513        }
514        extract_text_recursive(&node.children, parts);
515    }
516}
517
518/// Format an accessibility tree as a human-readable indented string for
519/// debugging and inspection.
520pub fn format_tree(nodes: &[AxNode], indent: usize) -> String {
521    let mut output = String::new();
522    for node in nodes {
523        let prefix = "  ".repeat(indent);
524        let interactive_marker = if node.interactive {
525            " [interactive]"
526        } else {
527            ""
528        };
529        let value_preview = node
530            .value
531            .as_ref()
532            .map(|value| {
533                let preview: String = value.chars().take(50).collect();
534                format!(" = \"{preview}\"")
535            })
536            .unwrap_or_default();
537
538        output.push_str(&format!(
539            "{}[{}] \"{}\"{}{}\n",
540            prefix, node.role, node.name, value_preview, interactive_marker
541        ));
542        output.push_str(&format_tree(&node.children, indent + 1));
543    }
544    output
545}
546
547/// Build a mapping from backend DOM node IDs to their shadow host path breadcrumbs
548/// by walking a flattened DOM document response.
549pub fn build_shadow_host_paths(flattened_doc: &Value) -> HashMap<i64, Vec<String>> {
550    let mut paths: HashMap<i64, Vec<String>> = HashMap::new();
551    let Some(nodes) = flattened_doc["nodes"].as_array() else {
552        return paths;
553    };
554
555    // Build lookup tables: nodeId -> (backendNodeId, parentNodeId, isShadowRoot, label)
556    struct NodeMeta {
557        parent_id: Option<i64>,
558        is_shadow_root: bool,
559        label: String,
560    }
561    let mut node_meta: HashMap<i64, NodeMeta> = HashMap::new();
562
563    for node in nodes {
564        let Some(node_id) = node["nodeId"].as_i64() else {
565            continue;
566        };
567        // Skip nodes without a backendNodeId — they can't be interactive controls
568        if node["backendNodeId"].as_i64().is_none() {
569            continue;
570        };
571
572        let is_shadow_root = node["shadowRootType"].as_str() == Some("open");
573        let parent_id = node["parentId"].as_i64();
574
575        // Build a human-readable label for the node
576        let tag = node["localName"]
577            .as_str()
578            .or_else(|| node["nodeName"].as_str())
579            .filter(|n| !n.starts_with('#'))
580            .unwrap_or("");
581        let role_attr = node["attributes"]
582            .as_array()
583            .iter()
584            .flat_map(|a| a.iter())
585            .collect::<Vec<_>>()
586            .windows(2)
587            .find(|w| w[0].as_str() == Some("role"))
588            .and_then(|w| w[1].as_str());
589        let label = if let Some(role) = role_attr {
590            truncate_utf8(role, MAX_SHADOW_PATH_ENTRY_BYTES).0
591        } else if !tag.is_empty() {
592            truncate_utf8(tag, MAX_SHADOW_PATH_ENTRY_BYTES).0
593        } else {
594            "shadow-host".to_string()
595        };
596
597        node_meta.insert(
598            node_id,
599            NodeMeta {
600                parent_id,
601                is_shadow_root,
602                label,
603            },
604        );
605    }
606
607    if node_meta.is_empty() {
608        return paths;
609    }
610
611    // For each leaf node (potential interactive control), trace up to find shadow boundaries
612    for node in nodes {
613        let Some(backend_id) = node["backendNodeId"].as_i64() else {
614            continue;
615        };
616        let Some(mut current_parent) = node["parentId"].as_i64() else {
617            continue;
618        };
619
620        let mut breadcrumbs: Vec<String> = Vec::new();
621        let mut visited = HashSet::new();
622        let mut hosts_found = 0usize;
623
624        loop {
625            if !visited.insert(current_parent) || breadcrumbs.len() >= MAX_SHADOW_PATH_ENTRIES {
626                break;
627            }
628
629            let Some(meta) = node_meta.get(&current_parent) else {
630                // Parent not in our metadata — walk up further
631                if let Some(parent_of_parent) =
632                    node_meta.get(&current_parent).and_then(|m| m.parent_id)
633                {
634                    current_parent = parent_of_parent;
635                    continue;
636                }
637                break;
638            };
639
640            if meta.is_shadow_root {
641                hosts_found += 1;
642                breadcrumbs.push(meta.label.clone());
643                // Move past the shadow root to its host's parent
644                if let Some(next) = meta.parent_id {
645                    current_parent = next;
646                } else {
647                    break;
648                }
649            } else if hosts_found > 0 {
650                // Once we've found at least one shadow boundary, continue up for nested shadows
651                if let Some(next) = meta.parent_id {
652                    current_parent = next;
653                } else {
654                    break;
655                }
656            } else {
657                break; // Not inside a shadow tree
658            }
659        }
660
661        if !breadcrumbs.is_empty() {
662            breadcrumbs.reverse();
663            paths.insert(backend_id, breadcrumbs);
664        }
665    }
666
667    paths
668}
669
670/// Count unique shadow hosts that have pierced controls in the interactive list.
671/// Each host's backendNodeId in the shadow_host_paths map represents one pierced host.
672pub fn count_pierced_shadow_hosts(shadow_paths: &HashMap<i64, Vec<String>>) -> usize {
673    // Each path entry represents nesting; count unique first breadcrumb as host
674    let mut hosts = HashSet::new();
675    for path in shadow_paths.values() {
676        if let Some(first) = path.last() {
677            hosts.insert(first.clone());
678        }
679    }
680    hosts.len().min(MAX_SHADOW_HOSTS)
681}
682
683/// Extract the HTML input type from AX node properties if available.
684pub fn extract_input_type(raw_ax_node: &Value) -> Option<String> {
685    raw_ax_node["properties"]
686        .as_array()?
687        .iter()
688        .find(|prop| prop["name"].as_str() == Some("inputType"))
689        .and_then(|prop| prop["value"]["value"].as_str())
690        .map(String::from)
691}
692#[derive(Debug, Clone, Serialize, Deserialize)]
693pub struct DomNode {
694    pub node_id: i64,
695    pub node_name: String,
696    pub node_value: String,
697    pub children: Vec<DomNode>,
698    pub attributes: Vec<String>,
699    pub bounding_box: Option<[f64; 4]>,
700}
701
702/// Parse a CDP `DOM.getDocument` response into a [`DomNode`] tree. Returns
703/// `None` when the `root` field is absent or malformed.
704pub fn parse_dom_tree(raw: &Value) -> Option<DomNode> {
705    raw.get("root").and_then(parse_dom_node)
706}
707
708fn parse_dom_node(raw: &Value) -> Option<DomNode> {
709    let node_id = raw["nodeId"].as_i64()?;
710    let node_name = raw["nodeName"].as_str().unwrap_or_default().to_string();
711    let node_value = raw["nodeValue"].as_str().unwrap_or_default().to_string();
712    let attributes = raw["attributes"]
713        .as_array()
714        .map(|values| {
715            values
716                .iter()
717                .filter_map(|value| value.as_str().map(String::from))
718                .collect()
719        })
720        .unwrap_or_default();
721    let children = raw["children"]
722        .as_array()
723        .map(|values| values.iter().filter_map(parse_dom_node).collect())
724        .unwrap_or_default();
725
726    Some(DomNode {
727        node_id,
728        node_name,
729        node_value,
730        children,
731        attributes,
732        bounding_box: None,
733    })
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739
740    #[test]
741    fn builds_accessibility_children_and_interactive_index() {
742        let raw = serde_json::json!({
743            "nodes": [
744                {"nodeId": "root", "role": {"value": "RootWebArea"}, "childIds": ["button"]},
745                {"nodeId": "button", "parentId": "root", "backendDOMNodeId": 42,
746                 "role": {"value": "button"}, "name": {"value": "Save"}, "childIds": []}
747            ]
748        });
749        let tree = parse_accessibility_tree(&raw);
750        assert_eq!(tree.len(), 1);
751        assert_eq!(tree[0].children[0].name, "Save");
752        assert_eq!(
753            find_interactive_elements(&tree)[0].backend_dom_node_id,
754            Some(42)
755        );
756        assert!(format_tree(&tree, 0).contains("[interactive]"));
757    }
758
759    #[test]
760    fn formats_unicode_values_without_slicing_bytes() {
761        let node = AxNode {
762            ax_node_id: "1".to_string(),
763            backend_dom_node_id: None,
764            role: "textbox".to_string(),
765            name: "入力".to_string(),
766            description: String::new(),
767            value: Some("日本語".to_string()),
768            children: Vec::new(),
769            bounds: None,
770            interactive: true,
771            input_type: Some("text".to_string()),
772        };
773        assert!(format_tree(&[node], 0).contains("日本語"));
774    }
775
776    #[test]
777    fn compact_projection_publishes_revisioned_backend_references_only() {
778        let nodes = vec![AxNode {
779            ax_node_id: "root".to_string(),
780            backend_dom_node_id: None,
781            role: "RootWebArea".to_string(),
782            name: String::new(),
783            description: String::new(),
784            value: None,
785            children: vec![
786                AxNode {
787                    ax_node_id: "save".to_string(),
788                    backend_dom_node_id: Some(42),
789                    role: "button".to_string(),
790                    name: "Save".to_string(),
791                    description: String::new(),
792                    value: None,
793                    children: Vec::new(),
794                    bounds: None,
795                    interactive: true,
796                    input_type: None,
797                },
798                AxNode {
799                    ax_node_id: "unresolved".to_string(),
800                    backend_dom_node_id: None,
801                    role: "button".to_string(),
802                    name: "Unresolved".to_string(),
803                    description: String::new(),
804                    value: None,
805                    children: Vec::new(),
806                    bounds: None,
807                    interactive: true,
808                    input_type: None,
809                },
810            ],
811            bounds: None,
812            interactive: false,
813            input_type: None,
814        }];
815
816        let projection = project_compact_accessibility(&nodes, 5);
817        assert_eq!(projection.interactive.len(), 1);
818        assert_eq!(projection.interactive[0].reference, "r5:b42");
819        assert_eq!(projection.interactive[0].backend_dom_node_id, 42);
820        assert!(projection.truncated);
821    }
822}