1pub 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#[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 pub props: BTreeMap<String, Value>,
39 pub children: Vec<String>,
40}
41
42#[derive(Debug, Clone)]
44pub struct AxTree {
45 pub nodes: HashMap<String, AxNode>,
46 pub root: String,
47 parents: HashMap<String, String>,
48}
49
50pub 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
119fn 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
130pub fn document_token(tree: &AxTree) -> Option<u64> {
135 tree.nodes.get(&tree.root).and_then(|n| n.backend_node_id)
136}
137
138pub 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
166fn 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
193fn 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#[derive(Debug, Clone)]
206pub struct SnapshotOptions {
207 pub interactive_only: bool,
209 pub max_chars: usize,
211 pub root_backend_id: Option<u64>,
214 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#[derive(Debug, Clone)]
234pub struct Snapshot {
235 pub text: String,
236 pub truncated: bool,
237 pub total_chars: usize,
239}
240
241pub 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
292fn 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 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("e(&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#[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#[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 pub context: Option<String>,
513 pub score: i32,
514}
515
516fn 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
546pub 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 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 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 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 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 let hits = find(&tree, &mut refs, "search", &FindOptions::default());
885 assert_eq!(hits.len(), 1);
886 assert_eq!(hits[0].name, "Query");
887
888 let hits = find(&tree, &mut refs, "link", &FindOptions::default());
890 assert_eq!(hits.len(), 1);
891 assert_eq!(hits[0].name, "Docs");
892
893 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 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 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}