Skip to main content

browser_control/a11y/
mod.rs

1//! Accessibility-tree snapshots with element refs.
2//!
3//! This module is pure: it turns the JSON returned by CDP
4//! `Accessibility.getFullAXTree` into a compact, Playwright-style YAML
5//! listing that agents can read cheaply, and it hands out stable element
6//! refs (`[ref=eN]`) that the native input path (`crate::session::input`)
7//! resolves back to `backendDOMNodeId`s. Nothing here talks to a browser,
8//! so every rule is unit-tested against canned tree fixtures.
9//!
10//! Why the AX tree rather than an injected JS walker: Chromium's
11//! accessibility tree is already composed across shadow roots, carries the
12//! computed accessible name/role the same way Playwright reports them, and
13//! attaches a `backendDOMNodeId` per node — exactly the handle `DOM.*` and
14//! `Input.*` accept. A DOM walker would have to reimplement accessible-name
15//! computation and could not cross closed shadow roots.
16
17pub mod refs;
18
19pub use refs::{RefEntry, RefTable};
20
21use std::collections::{BTreeMap, HashMap, HashSet};
22
23use anyhow::{anyhow, Result};
24use serde_json::Value;
25
26/// One node of the accessibility tree, normalised from an `AXNode`.
27#[derive(Debug, Clone)]
28pub struct AxNode {
29    pub id: String,
30    pub backend_node_id: Option<u64>,
31    pub role: String,
32    pub name: String,
33    pub value: Option<String>,
34    pub description: Option<String>,
35    pub ignored: bool,
36    /// `properties[].name -> properties[].value.value` (booleans, numbers,
37    /// strings such as `checked: "true" | "mixed"`, `level: 2`, `url`).
38    pub props: BTreeMap<String, Value>,
39    pub children: Vec<String>,
40}
41
42/// The whole tree, indexed by `nodeId`.
43#[derive(Debug, Clone)]
44pub struct AxTree {
45    pub nodes: HashMap<String, AxNode>,
46    pub root: String,
47    parents: HashMap<String, String>,
48}
49
50/// Parse a `Accessibility.getFullAXTree` result (`{ nodes: [...] }`).
51pub fn parse_full_ax_tree(v: &Value) -> Result<AxTree> {
52    let arr = v
53        .get("nodes")
54        .and_then(Value::as_array)
55        .ok_or_else(|| anyhow!("Accessibility.getFullAXTree returned no `nodes` array"))?;
56    let mut nodes = HashMap::with_capacity(arr.len());
57    let mut root: Option<String> = None;
58    let mut first: Option<String> = None;
59    let mut parents = HashMap::new();
60    for raw in arr {
61        let id = raw
62            .get("nodeId")
63            .and_then(Value::as_str)
64            .ok_or_else(|| anyhow!("AXNode without nodeId"))?
65            .to_string();
66        if first.is_none() {
67            first = Some(id.clone());
68        }
69        if raw.get("parentId").and_then(Value::as_str).is_none() && root.is_none() {
70            root = Some(id.clone());
71        }
72        let children: Vec<String> = raw
73            .get("childIds")
74            .and_then(Value::as_array)
75            .map(|c| {
76                c.iter()
77                    .filter_map(Value::as_str)
78                    .map(String::from)
79                    .collect()
80            })
81            .unwrap_or_default();
82        for c in &children {
83            parents.insert(c.clone(), id.clone());
84        }
85        let mut props = BTreeMap::new();
86        if let Some(list) = raw.get("properties").and_then(Value::as_array) {
87            for p in list {
88                if let Some(name) = p.get("name").and_then(Value::as_str) {
89                    let val = p.pointer("/value/value").cloned().unwrap_or(Value::Null);
90                    props.insert(name.to_string(), val);
91                }
92            }
93        }
94        nodes.insert(
95            id.clone(),
96            AxNode {
97                id,
98                backend_node_id: raw.get("backendDOMNodeId").and_then(Value::as_u64),
99                role: ax_string(raw.get("role")).unwrap_or_default(),
100                name: ax_string(raw.get("name")).unwrap_or_default(),
101                value: ax_string(raw.get("value")).filter(|s| !s.is_empty()),
102                description: ax_string(raw.get("description")).filter(|s| !s.is_empty()),
103                ignored: raw.get("ignored").and_then(Value::as_bool).unwrap_or(false),
104                props,
105                children,
106            },
107        );
108    }
109    let root = root
110        .or(first)
111        .ok_or_else(|| anyhow!("Accessibility.getFullAXTree returned an empty tree"))?;
112    Ok(AxTree {
113        nodes,
114        root,
115        parents,
116    })
117}
118
119/// `AXValue { type, value }` → string (numbers rendered with `to_string`).
120fn ax_string(v: Option<&Value>) -> Option<String> {
121    let inner = v?.get("value")?;
122    match inner {
123        Value::String(s) => Some(s.clone()),
124        Value::Number(n) => Some(n.to_string()),
125        Value::Bool(b) => Some(b.to_string()),
126        _ => None,
127    }
128}
129
130/// Identity of the document the tree was taken from: the root
131/// (`RootWebArea`) node's `backendDOMNodeId`, which is the Document node.
132/// It changes on every navigation, so it doubles as a staleness token
133/// for refs. `None` when the tree carries no backend ids (mock/partial).
134pub fn document_token(tree: &AxTree) -> Option<u64> {
135    tree.nodes.get(&tree.root).and_then(|n| n.backend_node_id)
136}
137
138/// Roles that mean "the agent can act on this".
139pub fn is_interactive_role(role: &str) -> bool {
140    matches!(
141        role,
142        "button"
143            | "link"
144            | "textbox"
145            | "searchbox"
146            | "checkbox"
147            | "radio"
148            | "combobox"
149            | "listbox"
150            | "option"
151            | "menuitem"
152            | "menuitemcheckbox"
153            | "menuitemradio"
154            | "slider"
155            | "switch"
156            | "tab"
157            | "spinbutton"
158            | "treeitem"
159    )
160}
161
162fn is_text_role(role: &str) -> bool {
163    matches!(role, "StaticText" | "text" | "InlineTextBox")
164}
165
166/// Nodes that add no information of their own: their children are hoisted
167/// to the parent's level. `RootWebArea` is included because the tool
168/// prints its own header line for the page.
169fn is_structural(node: &AxNode) -> bool {
170    node.ignored
171        || matches!(
172            node.role.as_str(),
173            "generic" | "none" | "presentation" | "LineBreak" | "RootWebArea"
174        )
175}
176
177fn prop_true(node: &AxNode, key: &str) -> bool {
178    matches!(node.props.get(key), Some(Value::Bool(true)))
179}
180
181fn is_hidden(node: &AxNode) -> bool {
182    prop_true(node, "hidden") || prop_true(node, "hiddenRoot")
183}
184
185fn is_focusable(node: &AxNode) -> bool {
186    prop_true(node, "focusable")
187}
188
189fn is_interactive(node: &AxNode) -> bool {
190    is_interactive_role(&node.role) || is_focusable(node)
191}
192
193/// Does this node get a `[ref=eN]`? Interactive/focusable nodes always;
194/// otherwise anything with a name (headings, images, labelled regions) so
195/// `browser_take_screenshot { ref }` and subtree snapshots have something
196/// to point at. Unnamed structural containers stay ref-less to keep the
197/// listing short.
198fn wants_ref(node: &AxNode) -> bool {
199    node.backend_node_id.is_some()
200        && !is_text_role(&node.role)
201        && (is_interactive(node) || !node.name.is_empty())
202}
203
204/// Rendering options for [`render_snapshot`].
205#[derive(Debug, Clone)]
206pub struct SnapshotOptions {
207    /// Keep only interactive elements and their ancestors.
208    pub interactive_only: bool,
209    /// Cut the output at the last line boundary before this many bytes.
210    pub max_chars: usize,
211    /// Render only the subtree rooted at the node with this
212    /// `backendDOMNodeId` (from a previous ref).
213    pub root_backend_id: Option<u64>,
214    /// Number of emitted levels below the root to include; deeper
215    /// content is collapsed into a `… (N more)` marker.
216    pub depth: Option<usize>,
217}
218
219impl Default for SnapshotOptions {
220    fn default() -> Self {
221        Self {
222            interactive_only: false,
223            max_chars: DEFAULT_MAX_CHARS,
224            root_backend_id: None,
225            depth: None,
226        }
227    }
228}
229
230pub const DEFAULT_MAX_CHARS: usize = 50_000;
231
232/// Rendered snapshot.
233#[derive(Debug, Clone)]
234pub struct Snapshot {
235    pub text: String,
236    pub truncated: bool,
237    /// Size before truncation, in bytes.
238    pub total_chars: usize,
239}
240
241/// Render the tree as indented YAML-ish lines, interning refs as it goes.
242pub fn render_snapshot(
243    tree: &AxTree,
244    refs: &mut RefTable,
245    opts: &SnapshotOptions,
246) -> Result<Snapshot> {
247    let start = match opts.root_backend_id {
248        Some(bid) => tree
249            .nodes
250            .values()
251            .find(|n| n.backend_node_id == Some(bid))
252            .map(|n| n.id.clone())
253            .ok_or_else(|| anyhow!("ref not found in the current accessibility tree"))?,
254        None => tree.root.clone(),
255    };
256    let keep = if opts.interactive_only {
257        Some(interactive_closure(tree))
258    } else {
259        None
260    };
261    let mut r = Renderer {
262        tree,
263        refs,
264        keep: keep.as_ref(),
265        interactive_only: opts.interactive_only,
266        out: String::new(),
267    };
268    r.emit(&start, 0, opts.depth);
269    let mut text = r.out;
270    let total_chars = text.len();
271    let mut truncated = false;
272    if text.len() > opts.max_chars {
273        let mut cut = opts.max_chars;
274        while cut > 0 && !text.is_char_boundary(cut) {
275            cut -= 1;
276        }
277        let cut = text[..cut].rfind('\n').unwrap_or(cut);
278        text.truncate(cut);
279        text.push_str(&format!(
280            "\n… [truncated at {} chars; use interactive_only, ref, or depth to narrow]",
281            opts.max_chars
282        ));
283        truncated = true;
284    }
285    Ok(Snapshot {
286        text,
287        truncated,
288        total_chars,
289    })
290}
291
292/// Set of node ids that are interactive themselves or contain an
293/// interactive descendant (so landmarks survive `interactive_only` as
294/// context).
295fn interactive_closure(tree: &AxTree) -> HashSet<String> {
296    let mut keep = HashSet::new();
297    fn walk(tree: &AxTree, id: &str, keep: &mut HashSet<String>) -> bool {
298        let Some(node) = tree.nodes.get(id) else {
299            return false;
300        };
301        if is_hidden(node) {
302            return false;
303        }
304        let mut any = is_interactive(node) && !is_text_role(&node.role);
305        for c in &node.children {
306            if walk(tree, c, keep) {
307                any = true;
308            }
309        }
310        if any {
311            keep.insert(id.to_string());
312        }
313        any
314    }
315    walk(tree, &tree.root, &mut keep);
316    keep
317}
318
319struct Renderer<'a> {
320    tree: &'a AxTree,
321    refs: &'a mut RefTable,
322    keep: Option<&'a HashSet<String>>,
323    interactive_only: bool,
324    out: String,
325}
326
327impl Renderer<'_> {
328    fn visible(&self, node: &AxNode) -> bool {
329        if is_hidden(node) {
330            return false;
331        }
332        if let Some(keep) = self.keep {
333            if !keep.contains(&node.id) {
334                return false;
335            }
336        }
337        true
338    }
339
340    fn emit(&mut self, id: &str, level: usize, depth: Option<usize>) {
341        let Some(node) = self.tree.nodes.get(id) else {
342            return;
343        };
344        if !self.visible(node) {
345            return;
346        }
347        if is_structural(node) {
348            for c in node.children.clone() {
349                self.emit(&c, level, depth);
350            }
351            return;
352        }
353        if node.role == "InlineTextBox" {
354            return;
355        }
356        if is_text_role(&node.role) {
357            if !self.interactive_only && !node.name.trim().is_empty() {
358                self.out.push_str(&"  ".repeat(level));
359                self.out.push_str("- text: ");
360                self.out.push_str(node.name.trim());
361                self.out.push('\n');
362            }
363            return;
364        }
365        let line = self.line_for(node, level);
366        self.out.push_str(&line);
367        self.out.push('\n');
368        match depth {
369            Some(0) => {
370                let n = self.count_lines_below(node);
371                if n > 0 {
372                    self.out.push_str(&"  ".repeat(level + 1));
373                    self.out.push_str(&format!("… ({n} more)\n"));
374                }
375            }
376            _ => {
377                for c in node.children.clone() {
378                    self.emit(&c, level + 1, depth.map(|d| d.saturating_sub(1)));
379                }
380            }
381        }
382    }
383
384    /// Number of lines `emit` would print for the descendants of `node`.
385    fn count_lines_below(&self, node: &AxNode) -> usize {
386        let mut n = 0;
387        for c in &node.children {
388            n += self.count_lines(c);
389        }
390        n
391    }
392
393    fn count_lines(&self, id: &str) -> usize {
394        let Some(node) = self.tree.nodes.get(id) else {
395            return 0;
396        };
397        if !self.visible(node) {
398            return 0;
399        }
400        if is_structural(node) {
401            return node.children.iter().map(|c| self.count_lines(c)).sum();
402        }
403        if node.role == "InlineTextBox" {
404            return 0;
405        }
406        if is_text_role(&node.role) {
407            return usize::from(!self.interactive_only && !node.name.trim().is_empty());
408        }
409        1 + self.count_lines_below(node)
410    }
411
412    fn line_for(&mut self, node: &AxNode, level: usize) -> String {
413        let mut line = String::new();
414        line.push_str(&"  ".repeat(level));
415        line.push_str("- ");
416        line.push_str(&node.role);
417        if !node.name.is_empty() {
418            line.push(' ');
419            line.push_str(&quote(&node.name));
420        }
421        if wants_ref(node) {
422            let r = self
423                .refs
424                .intern(node.backend_node_id.unwrap_or(0), &node.role, &node.name);
425            line.push_str(&format!(" [ref={r}]"));
426        }
427        for (key, label) in [
428            ("disabled", "disabled"),
429            ("expanded", "expanded"),
430            ("selected", "selected"),
431            ("focused", "focused"),
432            ("required", "required"),
433            ("readonly", "readonly"),
434        ] {
435            if prop_true(node, key) {
436                line.push_str(&format!(" [{label}]"));
437            }
438        }
439        for key in ["checked", "pressed"] {
440            match node.props.get(key) {
441                Some(Value::String(s)) if s == "true" => line.push_str(&format!(" [{key}]")),
442                Some(Value::String(s)) if s == "mixed" => line.push_str(&format!(" [{key}=mixed]")),
443                Some(Value::Bool(true)) => line.push_str(&format!(" [{key}]")),
444                _ => {}
445            }
446        }
447        if let Some(Value::Number(n)) = node.props.get("level") {
448            line.push_str(&format!(" [level={n}]"));
449        }
450        if let Some(Value::String(url)) = node.props.get("url") {
451            if node.role == "link" && !url.is_empty() {
452                line.push_str(&format!(" [url={}]", quote(&truncate(url, 100))));
453            }
454        }
455        if let Some(v) = &node.value {
456            if matches!(
457                node.role.as_str(),
458                "textbox" | "searchbox" | "combobox" | "slider" | "spinbutton" | "listbox"
459            ) {
460                line.push_str(&format!(" [value={}]", quote(&truncate(v, 80))));
461            }
462        }
463        line
464    }
465}
466
467fn quote(s: &str) -> String {
468    serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
469}
470
471fn truncate(s: &str, max: usize) -> String {
472    if s.len() <= max {
473        return s.to_string();
474    }
475    let mut cut = max;
476    while cut > 0 && !s.is_char_boundary(cut) {
477        cut -= 1;
478    }
479    format!("{}…", &s[..cut])
480}
481
482// ---------------------------------------------------------------------------
483// find
484// ---------------------------------------------------------------------------
485
486/// Options for [`find`].
487#[derive(Debug, Clone)]
488pub struct FindOptions {
489    pub interactive_only: bool,
490    pub limit: usize,
491}
492
493impl Default for FindOptions {
494    fn default() -> Self {
495        Self {
496            interactive_only: true,
497            limit: DEFAULT_FIND_LIMIT,
498        }
499    }
500}
501
502pub const DEFAULT_FIND_LIMIT: usize = 20;
503
504/// One `find` hit.
505#[derive(Debug, Clone, PartialEq, Eq)]
506pub struct Match {
507    pub r#ref: String,
508    pub role: String,
509    pub name: String,
510    pub value: Option<String>,
511    /// Nearest named landmark/form/dialog/list ancestor, for orientation.
512    pub context: Option<String>,
513    pub score: i32,
514}
515
516/// Map query words that name a role onto the AX roles they cover.
517fn role_aliases(token: &str) -> Option<&'static [&'static str]> {
518    Some(match token {
519        "button" | "btn" => &["button"],
520        "link" | "anchor" => &["link"],
521        "textbox" | "input" | "field" | "textarea" => {
522            &["textbox", "searchbox", "combobox", "spinbutton"]
523        }
524        "checkbox" => &["checkbox"],
525        "radio" => &["radio"],
526        "combobox" | "select" | "dropdown" => &["combobox", "listbox"],
527        "heading" | "header" | "title" => &["heading"],
528        "tab" => &["tab"],
529        "menuitem" | "menu" => &["menuitem", "menuitemcheckbox", "menuitemradio"],
530        "switch" | "toggle" => &["switch"],
531        "option" => &["option"],
532        "slider" => &["slider"],
533        "image" | "img" | "icon" => &["image", "img"],
534        _ => return None,
535    })
536}
537
538fn tokenize(q: &str) -> Vec<String> {
539    q.to_lowercase()
540        .split(|c: char| !c.is_alphanumeric())
541        .filter(|t| !t.is_empty())
542        .map(String::from)
543        .collect()
544}
545
546/// Score every candidate node against a free-text query. Pure text
547/// matching — no model call. Returns at most `opts.limit` matches, best
548/// first, ties broken by document order.
549pub fn find(tree: &AxTree, refs: &mut RefTable, query: &str, opts: &FindOptions) -> Vec<Match> {
550    let tokens = tokenize(query);
551    let mut role_tokens: Vec<&'static [&'static str]> = Vec::new();
552    let mut text_tokens: Vec<String> = Vec::new();
553    for t in &tokens {
554        match role_aliases(t) {
555            Some(roles) => role_tokens.push(roles),
556            None => text_tokens.push(t.clone()),
557        }
558    }
559    let text_query = text_tokens.join(" ");
560    let mut candidates: Vec<(usize, &AxNode)> = Vec::new();
561    collect_candidates(tree, &tree.root, opts.interactive_only, &mut candidates);
562
563    let mut scored: Vec<(i32, usize, &AxNode)> = Vec::new();
564    for (order, node) in candidates {
565        let name = node.name.to_lowercase();
566        let value = node.value.as_deref().unwrap_or("").to_lowercase();
567        let desc = node.description.as_deref().unwrap_or("").to_lowercase();
568        let mut score = 0;
569        let mut text_hit = false;
570        if !text_query.is_empty() {
571            if name == text_query {
572                score += 100;
573                text_hit = true;
574            } else if name.contains(&text_query) {
575                score += 60;
576                text_hit = true;
577            }
578            for t in &text_tokens {
579                if name.contains(t.as_str()) {
580                    score += 10;
581                    text_hit = true;
582                }
583                if value.contains(t.as_str()) || desc.contains(t.as_str()) {
584                    score += 5;
585                    text_hit = true;
586                }
587            }
588        }
589        let mut role_hit = false;
590        if !role_tokens.is_empty() {
591            if role_tokens
592                .iter()
593                .any(|roles| roles.contains(&node.role.as_str()))
594            {
595                score += 8;
596                role_hit = true;
597            } else {
598                score -= 20;
599            }
600        }
601        if !text_hit && !(text_tokens.is_empty() && role_hit) {
602            continue;
603        }
604        if is_interactive_role(&node.role) {
605            score += 15;
606        }
607        if is_focusable(node) {
608            score += 3;
609        }
610        scored.push((score, order, node));
611    }
612    scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
613    scored
614        .into_iter()
615        .take(opts.limit)
616        .map(|(score, _, node)| Match {
617            r#ref: refs.intern(node.backend_node_id.unwrap_or(0), &node.role, &node.name),
618            role: node.role.clone(),
619            name: node.name.clone(),
620            value: node.value.clone(),
621            context: context_for(tree, node),
622            score,
623        })
624        .collect()
625}
626
627fn collect_candidates<'a>(
628    tree: &'a AxTree,
629    id: &str,
630    interactive_only: bool,
631    out: &mut Vec<(usize, &'a AxNode)>,
632) {
633    let Some(node) = tree.nodes.get(id) else {
634        return;
635    };
636    if is_hidden(node) {
637        return;
638    }
639    let eligible = node.backend_node_id.is_some()
640        && !is_structural(node)
641        && !is_text_role(&node.role)
642        && if interactive_only {
643            is_interactive(node)
644        } else {
645            wants_ref(node)
646        };
647    if eligible {
648        out.push((out.len(), node));
649    }
650    for c in &node.children {
651        collect_candidates(tree, c, interactive_only, out);
652    }
653}
654
655fn context_for(tree: &AxTree, node: &AxNode) -> Option<String> {
656    let mut cur = tree.parents.get(&node.id);
657    while let Some(pid) = cur {
658        let p = tree.nodes.get(pid)?;
659        if !p.name.is_empty()
660            && matches!(
661                p.role.as_str(),
662                "form"
663                    | "dialog"
664                    | "alertdialog"
665                    | "navigation"
666                    | "main"
667                    | "region"
668                    | "banner"
669                    | "contentinfo"
670                    | "complementary"
671                    | "list"
672                    | "table"
673                    | "group"
674                    | "article"
675                    | "section"
676                    | "menu"
677                    | "tablist"
678            )
679        {
680            return Some(format!("{} {}", p.role, quote(&p.name)));
681        }
682        cur = tree.parents.get(pid);
683    }
684    None
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use serde_json::json;
691
692    fn node(id: &str, role: &str, name: &str, backend: u64, children: &[&str]) -> Value {
693        json!({
694            "nodeId": id,
695            "backendDOMNodeId": backend,
696            "role": {"type": "role", "value": role},
697            "name": {"type": "computedString", "value": name},
698            "childIds": children,
699        })
700    }
701
702    fn with_parent(mut v: Value, parent: &str) -> Value {
703        v["parentId"] = json!(parent);
704        v
705    }
706
707    fn with_props(mut v: Value, props: Value) -> Value {
708        let list: Vec<Value> = props
709            .as_object()
710            .unwrap()
711            .iter()
712            .map(|(k, val)| json!({"name": k, "value": {"type": "x", "value": val}}))
713            .collect();
714        v["properties"] = json!(list);
715        v
716    }
717
718    /// RootWebArea → generic(ignored) → main → [heading, textbox(desc Search), button, StaticText, link(focusable), hidden button]
719    fn fixture() -> Value {
720        let mut textbox = with_parent(node("5", "textbox", "Query", 105, &[]), "3");
721        textbox["description"] = json!({"type": "computedString", "value": "Search the docs"});
722        textbox["value"] = json!({"type": "string", "value": "hooks"});
723        let textbox = with_props(textbox, json!({"focusable": true, "focused": true}));
724        let link = with_props(
725            with_parent(node("8", "link", "Docs", 108, &["9"]), "3"),
726            json!({"focusable": true, "url": "https://example.com/docs"}),
727        );
728        let hidden = with_props(
729            with_parent(node("10", "button", "Ghost", 110, &[]), "3"),
730            json!({"hidden": true}),
731        );
732        let mut ignored = with_parent(node("2", "generic", "", 102, &["3"]), "1");
733        ignored["ignored"] = json!(true);
734        json!({"nodes": [
735            node("1", "RootWebArea", "Example", 101, &["2"]),
736            ignored,
737            with_parent(node("3", "main", "Content", 103, &["4", "5", "6", "7", "8", "10"]), "2"),
738            with_props(with_parent(node("4", "heading", "Welcome", 104, &[]), "3"), json!({"level": 1})),
739            textbox,
740            with_props(with_parent(node("6", "button", "Submit", 106, &[]), "3"), json!({"focusable": true, "disabled": true})),
741            with_parent(node("7", "StaticText", "Some text", 107, &[]), "3"),
742            link,
743            with_parent(node("9", "StaticText", "Docs", 109, &[]), "8"),
744            hidden,
745        ]})
746    }
747
748    #[test]
749    fn renders_playwright_style_lines_with_refs() {
750        let tree = parse_full_ax_tree(&fixture()).unwrap();
751        assert_eq!(document_token(&tree), Some(101));
752        let mut refs = RefTable::new(101);
753        let snap = render_snapshot(&tree, &mut refs, &SnapshotOptions::default()).unwrap();
754        let expected = "\
755- main \"Content\" [ref=e1]
756  - heading \"Welcome\" [ref=e2] [level=1]
757  - textbox \"Query\" [ref=e3] [focused] [value=\"hooks\"]
758  - button \"Submit\" [ref=e4] [disabled]
759  - text: Some text
760  - link \"Docs\" [ref=e5] [url=\"https://example.com/docs\"]
761    - text: Docs
762";
763        assert_eq!(snap.text, expected);
764        assert!(!snap.truncated);
765        assert_eq!(refs.lookup("e4").unwrap().backend_node_id, 106);
766        // Hidden node never gets a ref.
767        assert!(refs.lookup("e6").is_none());
768    }
769
770    #[test]
771    fn interactive_only_keeps_ancestors_and_drops_text_and_headings() {
772        let tree = parse_full_ax_tree(&fixture()).unwrap();
773        let mut refs = RefTable::new(101);
774        let snap = render_snapshot(
775            &tree,
776            &mut refs,
777            &SnapshotOptions {
778                interactive_only: true,
779                ..Default::default()
780            },
781        )
782        .unwrap();
783        let expected = "\
784- main \"Content\" [ref=e1]
785  - textbox \"Query\" [ref=e2] [focused] [value=\"hooks\"]
786  - button \"Submit\" [ref=e3] [disabled]
787  - link \"Docs\" [ref=e4] [url=\"https://example.com/docs\"]
788";
789        assert_eq!(snap.text, expected);
790    }
791
792    #[test]
793    fn depth_collapses_deeper_levels() {
794        let tree = parse_full_ax_tree(&fixture()).unwrap();
795        let mut refs = RefTable::new(101);
796        let snap = render_snapshot(
797            &tree,
798            &mut refs,
799            &SnapshotOptions {
800                depth: Some(0),
801                ..Default::default()
802            },
803        )
804        .unwrap();
805        assert_eq!(snap.text, "- main \"Content\" [ref=e1]\n  … (6 more)\n");
806        let snap = render_snapshot(
807            &tree,
808            &mut refs,
809            &SnapshotOptions {
810                depth: Some(1),
811                ..Default::default()
812            },
813        )
814        .unwrap();
815        assert!(snap.text.contains("- link \"Docs\" [ref=e5]"));
816        assert!(snap.text.contains("    … (1 more)"));
817        assert!(!snap.text.contains("    - text: Docs"));
818    }
819
820    #[test]
821    fn max_chars_truncates_on_line_boundary() {
822        let tree = parse_full_ax_tree(&fixture()).unwrap();
823        let mut refs = RefTable::new(101);
824        let snap = render_snapshot(
825            &tree,
826            &mut refs,
827            &SnapshotOptions {
828                max_chars: 70,
829                ..Default::default()
830            },
831        )
832        .unwrap();
833        assert!(snap.truncated);
834        assert!(snap.total_chars > 70);
835        // 70 bytes lands mid-way through the textbox line; the cut backs up
836        // to the end of the heading line.
837        let body = snap.text.split("\n…").next().unwrap();
838        assert!(body.ends_with("[level=1]"), "{body:?}");
839        assert!(snap.text.contains("[truncated at 70 chars"));
840    }
841
842    #[test]
843    fn ref_subtree_and_stability_across_renders() {
844        let tree = parse_full_ax_tree(&fixture()).unwrap();
845        let mut refs = RefTable::new(101);
846        render_snapshot(&tree, &mut refs, &SnapshotOptions::default()).unwrap();
847        // Subtree rooted at the link keeps the link's existing ref.
848        let snap = render_snapshot(
849            &tree,
850            &mut refs,
851            &SnapshotOptions {
852                root_backend_id: Some(108),
853                ..Default::default()
854            },
855        )
856        .unwrap();
857        assert_eq!(
858            snap.text,
859            "- link \"Docs\" [ref=e5] [url=\"https://example.com/docs\"]\n  - text: Docs\n"
860        );
861        assert_eq!(refs.len(), 5);
862        let err = render_snapshot(
863            &tree,
864            &mut refs,
865            &SnapshotOptions {
866                root_backend_id: Some(999),
867                ..Default::default()
868            },
869        )
870        .unwrap_err();
871        assert!(err.to_string().contains("ref not found"));
872    }
873
874    #[test]
875    fn find_ranks_exact_then_substring_then_tokens_and_filters_by_role() {
876        let tree = parse_full_ax_tree(&fixture()).unwrap();
877        let mut refs = RefTable::new(101);
878        let hits = find(&tree, &mut refs, "submit", &FindOptions::default());
879        assert_eq!(hits.len(), 1);
880        assert_eq!(hits[0].role, "button");
881        assert_eq!(hits[0].context.as_deref(), Some("main \"Content\""));
882
883        // Description (placeholder-like) matches too, at lower weight.
884        let hits = find(&tree, &mut refs, "search", &FindOptions::default());
885        assert_eq!(hits.len(), 1);
886        assert_eq!(hits[0].name, "Query");
887
888        // Role-only query returns every node of that role.
889        let hits = find(&tree, &mut refs, "link", &FindOptions::default());
890        assert_eq!(hits.len(), 1);
891        assert_eq!(hits[0].name, "Docs");
892
893        // Role + text: a mismatched role token is penalised relative to a
894        // matching one, but the text hit still surfaces the element.
895        let wrong = find(&tree, &mut refs, "docs button", &FindOptions::default());
896        let right = find(&tree, &mut refs, "docs link", &FindOptions::default());
897        assert_eq!(wrong[0].name, "Docs");
898        assert_eq!(right[0].name, "Docs");
899        assert!(wrong[0].score < right[0].score);
900
901        // interactive_only=false surfaces headings.
902        let hits = find(
903            &tree,
904            &mut refs,
905            "welcome",
906            &FindOptions {
907                interactive_only: false,
908                limit: 20,
909            },
910        );
911        assert_eq!(hits.len(), 1);
912        assert_eq!(hits[0].role, "heading");
913        assert!(find(&tree, &mut refs, "welcome", &FindOptions::default()).is_empty());
914
915        // Refs handed out by find are the same ones a snapshot would give.
916        let r = refs.lookup(&hits[0].r#ref).unwrap();
917        assert_eq!(r.backend_node_id, 104);
918    }
919
920    #[test]
921    fn find_respects_limit_and_document_order_ties() {
922        let mut nodes = vec![node("1", "RootWebArea", "", 1, &["2", "3", "4"])];
923        for (i, id) in ["2", "3", "4"].iter().enumerate() {
924            nodes.push(with_props(
925                with_parent(node(id, "button", "Add to cart", 10 + i as u64, &[]), "1"),
926                json!({"focusable": true}),
927            ));
928        }
929        let tree = parse_full_ax_tree(&json!({"nodes": nodes})).unwrap();
930        let mut refs = RefTable::new(1);
931        let hits = find(
932            &tree,
933            &mut refs,
934            "add to cart",
935            &FindOptions {
936                interactive_only: true,
937                limit: 2,
938            },
939        );
940        assert_eq!(hits.len(), 2);
941        assert_eq!(hits[0].r#ref, "e1");
942        assert_eq!(hits[1].r#ref, "e2");
943        assert_eq!(refs.lookup("e1").unwrap().backend_node_id, 10);
944    }
945
946    #[test]
947    fn parse_rejects_missing_nodes() {
948        assert!(parse_full_ax_tree(&json!({})).is_err());
949        assert!(parse_full_ax_tree(&json!({"nodes": []})).is_err());
950    }
951}