Skip to main content

computer_use/
tree.rs

1//! Numbered accessibility tree text, diff, and the pure helpers the AX walker uses.
2//!
3//! Nothing in this module calls a platform API. The macOS walker fills [`UiNode`]
4//! and the tests build the same nodes by hand.
5
6use serde::{Deserialize, Serialize};
7
8/// Children kept under one parent. Further children become `… +N`.
9pub const CHILD_CAP: usize = 40;
10/// Deepest indented level. The window is depth 0.
11pub const DEPTH_CAP: usize = 12;
12/// Rendered lines before the tree stops. `truncated=true` is an extra line.
13pub const LINE_CAP: usize = 400;
14/// Maximum characters kept in a displayed value.
15pub const VALUE_CAP: usize = 120;
16/// Maximum characters kept from a URL path.
17pub const URL_PATH_CAP: usize = 60;
18
19/// One accessibility node before numbering.
20#[derive(Clone, Debug, PartialEq)]
21pub struct UiNode {
22    /// Role, or `unavailable` when a copy failed for this node only.
23    pub role: String,
24    /// `AXSubrole`, when the platform sent one.
25    pub subrole: Option<String>,
26    /// `AXTitle`.
27    pub title: Option<String>,
28    /// `AXDescription`, used when the title is empty.
29    pub description: Option<String>,
30    /// `AXIdentifier`. Preferred over the title in the diff signature.
31    pub identifier: Option<String>,
32    /// Display value. Secure nodes must leave this empty; the renderer never reads a secret.
33    pub value: Option<String>,
34    /// `AXSecureTextField`, or a subrole containing `Secure`.
35    pub secure: bool,
36    /// Action names, already copied off the element.
37    pub actions: Vec<String>,
38    /// Screen frame in points. Missing when the element has no position.
39    pub frame: Option<NodeFrame>,
40    /// Child count before the child cap. `None` means `children.len()`.
41    pub child_count: Option<usize>,
42    /// Children in platform order. The renderer keeps at most [`CHILD_CAP`].
43    pub children: Vec<UiNode>,
44}
45
46/// Screen rectangle in points.
47#[derive(Clone, Copy, Debug, PartialEq)]
48pub struct NodeFrame {
49    /// Left edge.
50    pub x: f64,
51    /// Top edge.
52    pub y: f64,
53    /// Width.
54    pub width: f64,
55    /// Height.
56    pub height: f64,
57}
58
59/// Frame stored for revalidation. Points are rounded the same way the text is.
60#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub struct SavedFrame {
62    /// Left edge in screen points.
63    pub x: i32,
64    /// Top edge in screen points.
65    pub y: i32,
66    /// Width in screen points.
67    pub width: u32,
68    /// Height in screen points.
69    pub height: u32,
70}
71
72impl From<NodeFrame> for SavedFrame {
73    fn from(frame: NodeFrame) -> Self {
74        Self {
75            x: round_i32(frame.x),
76            y: round_i32(frame.y),
77            width: round_u32(frame.width),
78            height: round_u32(frame.height),
79        }
80    }
81}
82
83/// Element index bound to one process, window, and structural revision.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
85pub struct BoundElement {
86    /// Owning process.
87    pub pid: i32,
88    /// Joined window id.
89    pub window_id: u32,
90    /// Hash of the structural signatures.
91    pub revision: u64,
92    /// Preorder index in that revision.
93    pub index: u32,
94}
95
96impl UiNode {
97    /// A node with a role and no other fields.
98    pub fn bare(role: impl Into<String>) -> Self {
99        Self {
100            role: role.into(),
101            subrole: None,
102            title: None,
103            description: None,
104            identifier: None,
105            value: None,
106            secure: false,
107            actions: Vec::new(),
108            frame: None,
109            child_count: None,
110            children: Vec::new(),
111        }
112    }
113
114    fn real_child_count(&self) -> usize {
115        self.child_count.unwrap_or(self.children.len())
116    }
117}
118
119/// Preorder lookup. Index 0 is `root`, matching [`render`].
120pub fn find_ui(root: &UiNode, index: u32) -> Option<&UiNode> {
121    let mut cursor = 0u32;
122    fn walk<'a>(node: &'a UiNode, index: u32, cursor: &mut u32) -> Option<&'a UiNode> {
123        if *cursor == index {
124            return Some(node);
125        }
126        *cursor = cursor.saturating_add(1);
127        for child in &node.children {
128            if let Some(found) = walk(child, index, cursor) {
129                return Some(found);
130            }
131        }
132        None
133    }
134    walk(root, index, &mut cursor)
135}
136
137/// Lookup by the index stored when the signature was rendered.
138pub fn find_sig(root: &SigNode, index: u32) -> Option<&SigNode> {
139    if root.index == index {
140        return Some(root);
141    }
142    for child in &root.children {
143        if let Some(found) = find_sig(child, index) {
144            return Some(found);
145        }
146    }
147    None
148}
149
150/// Structural signature stored in the session cache. Values are already redacted.
151#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
152pub struct SigNode {
153    /// Preorder index in the render that produced this node.
154    pub index: u32,
155    /// Role string.
156    pub role: String,
157    /// Identifier, otherwise title, otherwise description.
158    pub identity: String,
159    /// Child count before the cap.
160    pub child_count: usize,
161    /// Value as it was printed, without the surrounding quotes.
162    pub value: String,
163    /// Rounded frame. A shift here does not change [`revision_of`].
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub frame: Option<SavedFrame>,
166    /// Secure text field. The raw characters are never stored.
167    #[serde(default, skip_serializing_if = "is_false_bool")]
168    pub secure: bool,
169    /// Children that were numbered. Capped children are omitted.
170    pub children: Vec<SigNode>,
171}
172
173/// Text plus the signature tree used for the next diff.
174#[derive(Clone, Debug, PartialEq)]
175pub struct Rendered {
176    /// Numbered text, ending in a newline. Includes `truncated=true` when a cap fired.
177    pub text: String,
178    /// Whether a child, depth, or line cap fired.
179    pub truncated: bool,
180    /// Signature of the numbered tree.
181    pub root: SigNode,
182}
183
184/// Previous accessibility flags for `AXManualAccessibility` and `AXEnhancedUserInterface`.
185///
186/// `None` means the attribute was missing.
187#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
188pub struct FlagState {
189    /// Previous `AXManualAccessibility` value.
190    pub manual: Option<bool>,
191    /// Previous `AXEnhancedUserInterface` value.
192    pub enhanced: Option<bool>,
193}
194
195/// What to write on attach, and what to write back on teardown.
196#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
197pub struct ElectronEdit {
198    /// Set `AXManualAccessibility` to this value. `None` means leave it alone.
199    pub set_manual: Option<bool>,
200    /// Set `AXEnhancedUserInterface` to this value. `None` means leave it alone.
201    pub set_enhanced: Option<bool>,
202    /// Values to restore. Only fields we changed are `Some`.
203    pub restore: FlagState,
204    /// Values observed before the edit.
205    pub previous: FlagState,
206}
207
208/// Request shared by `tree` and `get-state`.
209#[derive(Clone, Debug, Default, PartialEq, Eq)]
210pub struct TreeQuery {
211    /// Application selector. Empty uses the frontmost app.
212    pub app: Option<String>,
213    /// Window selector. Empty uses the key window.
214    pub window: Option<String>,
215    /// Print the full tree and replace the baseline.
216    pub full: bool,
217    /// Session cache namespace. Empty uses the process id.
218    pub session: Option<String>,
219}
220
221/// Observation returned to the dispatcher.
222#[derive(Clone, Debug, PartialEq, Eq)]
223pub struct TreeReport {
224    /// Target pid.
225    pub pid: i32,
226    /// Joined window id. Zero when no CG window matched.
227    pub window_id: u32,
228    /// Hash of the structural signatures.
229    pub revision: u64,
230    /// `ax` or `osascript`.
231    pub source: String,
232    /// Whether the numbered text hit a cap.
233    pub truncated: bool,
234    /// Chosen text for the `tree` command: the diff once a baseline exists, otherwise the full text.
235    pub tree: String,
236    /// Numbered text, even when [`Self::tree`] is a diff.
237    pub full_text: String,
238    /// Diff against the previous baseline. `None` when this observation created the baseline.
239    pub diff: Option<String>,
240    /// Bundle id when known, otherwise the application name.
241    pub app: Option<String>,
242    /// Extra note. The osascript path sets `source=osascript`.
243    pub note: Option<String>,
244    /// JXA JSON when the native tree was not built.
245    pub legacy: Option<String>,
246}
247
248/// Render `root` with the tree-text rules.
249pub fn render(root: &UiNode) -> Rendered {
250    let mut lines = Vec::new();
251    let mut truncated = false;
252    let mut index = 0;
253    let signature = walk(root, 0, &mut index, &mut lines, &mut truncated);
254    if truncated {
255        lines.push("truncated=true".to_string());
256    }
257    let mut text = lines.join("\n");
258    text.push('\n');
259    Rendered {
260        text,
261        truncated,
262        root: signature,
263    }
264}
265
266/// Diff `current` against `previous` with an LCS on `(role, identity, child_count)`.
267pub fn diff_trees(previous: &SigNode, current: &SigNode) -> String {
268    let mut lines = Vec::new();
269    diff_level(
270        std::slice::from_ref(previous),
271        std::slice::from_ref(current),
272        &mut lines,
273    );
274    if lines.is_empty() {
275        String::new()
276    } else {
277        let mut text = lines.join("\n");
278        text.push('\n');
279        text
280    }
281}
282
283/// Full text when there is no baseline or `force_full` is set. Otherwise the diff.
284pub fn choose_text(previous: Option<&SigNode>, rendered: &Rendered, force_full: bool) -> String {
285    match previous {
286        Some(previous) if !force_full => diff_trees(previous, &rendered.root),
287        _ => rendered.text.clone(),
288    }
289}
290
291/// Stable hash of the structural signatures.
292pub fn revision_of(root: &SigNode) -> u64 {
293    let mut hash = 0xcbf29ce484222325u64;
294    hash_node(root, &mut hash);
295    hash
296}
297
298/// Enable the two Electron attributes when they are false or missing.
299pub fn electron_enable(current: FlagState) -> ElectronEdit {
300    let set_manual = (current.manual != Some(true)).then_some(true);
301    let set_enhanced = (current.enhanced != Some(true)).then_some(true);
302    ElectronEdit {
303        set_manual,
304        set_enhanced,
305        restore: FlagState {
306            manual: restore_value(set_manual.is_some(), current.manual),
307            enhanced: restore_value(set_enhanced.is_some(), current.enhanced),
308        },
309        previous: current,
310    }
311}
312
313/// Values to write back. `None` means that attribute was not changed.
314pub fn electron_restore(edit: &ElectronEdit) -> FlagState {
315    edit.restore.clone()
316}
317
318/// Fake `AXManualAccessibility` / `AXEnhancedUserInterface` map.
319///
320/// The macOS walker uses the same [`electron_enable`] decision. Tests drive
321/// this map instead of a live application element.
322#[derive(Clone, Debug, PartialEq, Eq)]
323pub struct AttributeMap {
324    /// Current `AXManualAccessibility` value. `None` means the attribute is missing.
325    pub manual: Option<bool>,
326    /// Current `AXEnhancedUserInterface` value. `None` means the attribute is missing.
327    pub enhanced: Option<bool>,
328}
329
330impl AttributeMap {
331    /// Set both flags when they are false or missing. Returns the edit to persist.
332    pub fn enable_electron(&mut self) -> ElectronEdit {
333        let edit = electron_enable(FlagState {
334            manual: self.manual,
335            enhanced: self.enhanced,
336        });
337        if edit.set_manual == Some(true) {
338            self.manual = Some(true);
339        }
340        if edit.set_enhanced == Some(true) {
341            self.enhanced = Some(true);
342        }
343        edit
344    }
345
346    /// Write back only the attributes [`enable_electron`] changed.
347    pub fn restore_electron(&mut self, edit: &ElectronEdit) {
348        if let Some(value) = edit.restore.manual {
349            self.manual = Some(value);
350        }
351        if let Some(value) = edit.restore.enhanced {
352            self.enhanced = Some(value);
353        }
354    }
355}
356
357/// Helper pids to walk under a window.
358///
359/// Foreign child pids come first and win over the process scan. Duplicates are
360/// dropped. The result is capped at 3.
361pub fn hosted_helper_pids(app_pid: i32, child_pids: &[i32], scanned: &[i32]) -> Vec<i32> {
362    let mut out = Vec::new();
363    push_unique(&mut out, app_pid, child_pids);
364    if out.is_empty() {
365        push_unique(&mut out, app_pid, scanned);
366    }
367    out.truncate(3);
368    out
369}
370
371/// Whether `child_bundle` looks like a helper of `parent_name` / `parent_bundle`.
372pub fn bundle_matches_parent(
373    parent_name: &str,
374    parent_bundle: Option<&str>,
375    child_bundle: &str,
376) -> bool {
377    let child = child_bundle.to_ascii_lowercase();
378    let squashed = parent_name.to_ascii_lowercase().replace(' ', "");
379    if squashed.len() >= 3 && child.contains(&squashed) {
380        return true;
381    }
382    let Some(parent_bundle) = parent_bundle else {
383        return false;
384    };
385    let parent_bundle = parent_bundle.to_ascii_lowercase();
386    if child != parent_bundle && child.contains(&parent_bundle) {
387        return true;
388    }
389    parent_bundle
390        .rsplit('.')
391        .next()
392        .is_some_and(|label| label.len() >= 3 && child.contains(label))
393}
394
395/// Keep scheme, host, and at most 60 characters of the path. Drop the query.
396pub fn shorten_url(value: &str) -> String {
397    let (scheme, rest) = if let Some(rest) = value.strip_prefix("https://") {
398        ("https://", rest)
399    } else if let Some(rest) = value.strip_prefix("http://") {
400        ("http://", rest)
401    } else {
402        return value.to_string();
403    };
404    let rest = rest.split(['?', '#']).next().unwrap_or(rest);
405    let (host, path) = match rest.find('/') {
406        Some(index) => (&rest[..index], &rest[index..]),
407        None => (rest, ""),
408    };
409    format!("{scheme}{host}{}", truncate_chars(path, URL_PATH_CAP))
410}
411
412fn restore_value(changed: bool, previous: Option<bool>) -> Option<bool> {
413    if changed {
414        Some(previous.unwrap_or(false))
415    } else {
416        None
417    }
418}
419
420fn push_unique(out: &mut Vec<i32>, app_pid: i32, pids: &[i32]) {
421    for pid in pids {
422        if *pid == app_pid || *pid <= 0 || out.contains(pid) {
423            continue;
424        }
425        out.push(*pid);
426        if out.len() == 3 {
427            break;
428        }
429    }
430}
431
432fn walk(
433    node: &UiNode,
434    depth: usize,
435    index: &mut u32,
436    lines: &mut Vec<String>,
437    truncated: &mut bool,
438) -> SigNode {
439    let my_index = *index;
440    *index += 1;
441    let value = display_value(node);
442    let identity = identity_of(node);
443    if lines.len() < LINE_CAP {
444        lines.push(format_line(node, my_index, depth, &value));
445    } else {
446        *truncated = true;
447    }
448    let total = node.real_child_count();
449    let mut children = Vec::new();
450    if depth >= DEPTH_CAP {
451        if total > 0 {
452            *truncated = true;
453        }
454    } else {
455        let show = total.min(CHILD_CAP).min(node.children.len());
456        for child in node.children.iter().take(show) {
457            if lines.len() >= LINE_CAP {
458                *truncated = true;
459                break;
460            }
461            children.push(walk(child, depth + 1, index, lines, truncated));
462        }
463        if total > CHILD_CAP {
464            *truncated = true;
465            if lines.len() < LINE_CAP {
466                let indent = "  ".repeat(depth + 1);
467                lines.push(format!("{indent}… +{}", total - CHILD_CAP));
468            }
469        } else if node.children.len() < total {
470            // The walker stopped early (line budget) and did not attach every child.
471            *truncated = true;
472        }
473    }
474    SigNode {
475        index: my_index,
476        role: node.role.clone(),
477        identity,
478        child_count: total,
479        value,
480        frame: node.frame.map(SavedFrame::from),
481        secure: node.secure || is_secure(node),
482        children,
483    }
484}
485
486/// Identifier, otherwise title, otherwise description. Empty when all are missing.
487pub fn identity_text(node: &UiNode) -> String {
488    identity_of(node)
489}
490
491/// `AXSecureTextField`, or a subrole containing `Secure`.
492pub fn node_is_secure(node: &UiNode) -> bool {
493    node.secure || is_secure(node)
494}
495
496fn is_false_bool(value: &bool) -> bool {
497    !*value
498}
499
500fn format_line(node: &UiNode, index: u32, depth: usize, value: &str) -> String {
501    let indent = "  ".repeat(depth);
502    let mut line = format!("{indent}[{index}] {}", node.role);
503    if let Some(label) = quoted_label(node) {
504        line.push(' ');
505        line.push_str(&quote(&label));
506    }
507    if let Some(frame) = node.frame.map(SavedFrame::from) {
508        line.push_str(&format!(
509            " @{x},{y} {width}x{height}",
510            x = frame.x,
511            y = frame.y,
512            width = frame.width,
513            height = frame.height
514        ));
515    }
516    if node.secure || is_secure(node) {
517        line.push_str(" value=\"••••\"");
518    } else if node.value.is_some() {
519        line.push_str(" value=");
520        line.push_str(&quote(value));
521    }
522    if !node.actions.is_empty() {
523        line.push_str(" actions=");
524        line.push_str(&node.actions.join(","));
525    }
526    line
527}
528
529fn quoted_label(node: &UiNode) -> Option<String> {
530    nonempty(&node.title).or_else(|| nonempty(&node.description))
531}
532
533fn identity_of(node: &UiNode) -> String {
534    nonempty(&node.identifier)
535        .or_else(|| nonempty(&node.title))
536        .or_else(|| nonempty(&node.description))
537        .unwrap_or_default()
538}
539
540fn nonempty(value: &Option<String>) -> Option<String> {
541    value
542        .as_ref()
543        .map(|text| text.trim())
544        .filter(|text| !text.is_empty())
545        .map(str::to_string)
546}
547
548fn display_value(node: &UiNode) -> String {
549    if node.secure || is_secure(node) {
550        return "••••".to_string();
551    }
552    let Some(value) = node.value.as_deref() else {
553        return String::new();
554    };
555    truncate_chars(&shorten_url(value), VALUE_CAP)
556}
557
558fn is_secure(node: &UiNode) -> bool {
559    // macOS reports `AXSecureTextField`; Linux reports the AT-SPI
560    // `Role::PasswordText` (its `Role::name()` is `"password text"`).
561    // Both redacts the value. Windows uses `IsPassword` on the
562    // `IRawElementProviderFragmentRoot`; UIA sets `node.secure` directly
563    // from `IsPassword`, so the dedicated bool field covers it.
564    node.role == "AXSecureTextField"
565        || node.role == "password text"
566        || node
567            .subrole
568            .as_deref()
569            .is_some_and(|subrole| subrole.contains("Secure"))
570}
571
572fn quote(text: &str) -> String {
573    let mut out = String::from("\"");
574    for ch in text.chars() {
575        match ch {
576            '\\' => out.push_str("\\\\"),
577            '"' => out.push_str("\\\""),
578            '\n' | '\r' => out.push(' '),
579            other => out.push(other),
580        }
581    }
582    out.push('"');
583    out
584}
585
586fn round_i32(value: f64) -> i32 {
587    if !value.is_finite() {
588        return 0;
589    }
590    let clamped = value.round().clamp(i32::MIN as f64, i32::MAX as f64);
591    clamped as i32
592}
593
594fn round_u32(value: f64) -> u32 {
595    if !value.is_finite() || value <= 0.0 {
596        return 0;
597    }
598    let clamped = value.round().min(u32::MAX as f64);
599    clamped as u32
600}
601
602fn truncate_chars(text: &str, max: usize) -> String {
603    if text.chars().count() <= max {
604        return text.to_string();
605    }
606    text.chars().take(max).collect()
607}
608
609fn diff_level(previous: &[SigNode], current: &[SigNode], lines: &mut Vec<String>) {
610    let pairs = lcs(previous, current);
611    let mut prev_index = 0;
612    let mut curr_index = 0;
613    for (left, right) in pairs {
614        while prev_index < left {
615            lines.push(removed(&previous[prev_index]));
616            prev_index += 1;
617        }
618        while curr_index < right {
619            lines.push(added(&current[curr_index]));
620            curr_index += 1;
621        }
622        let before = &previous[left];
623        let after = &current[right];
624        if before.value != after.value {
625            lines.push(format!(
626                "~ [{}] {} value: {} -> {}",
627                after.index,
628                after.role,
629                quote(&before.value),
630                quote(&after.value)
631            ));
632        }
633        diff_level(&before.children, &after.children, lines);
634        prev_index = left + 1;
635        curr_index = right + 1;
636    }
637    while prev_index < previous.len() {
638        lines.push(removed(&previous[prev_index]));
639        prev_index += 1;
640    }
641    while curr_index < current.len() {
642        lines.push(added(&current[curr_index]));
643        curr_index += 1;
644    }
645}
646
647fn added(node: &SigNode) -> String {
648    format!("+ [{}] {}", node.index, labeled(node))
649}
650
651fn removed(node: &SigNode) -> String {
652    format!("- [{}] {}", node.index, labeled(node))
653}
654
655fn labeled(node: &SigNode) -> String {
656    if node.identity.is_empty() {
657        node.role.clone()
658    } else {
659        format!("{} {}", node.role, quote(&node.identity))
660    }
661}
662
663fn same_signature(left: &SigNode, right: &SigNode) -> bool {
664    left.role == right.role
665        && left.identity == right.identity
666        && left.child_count == right.child_count
667}
668
669fn lcs(previous: &[SigNode], current: &[SigNode]) -> Vec<(usize, usize)> {
670    let rows = previous.len() + 1;
671    let cols = current.len() + 1;
672    let mut score = vec![0u32; rows * cols];
673    let at = |row: usize, col: usize| row * cols + col;
674    for row in 1..rows {
675        for col in 1..cols {
676            if same_signature(&previous[row - 1], &current[col - 1]) {
677                score[at(row, col)] = score[at(row - 1, col - 1)] + 1;
678            } else {
679                score[at(row, col)] = score[at(row - 1, col)].max(score[at(row, col - 1)]);
680            }
681        }
682    }
683    let mut pairs = Vec::new();
684    let mut row = previous.len();
685    let mut col = current.len();
686    while row > 0 && col > 0 {
687        if same_signature(&previous[row - 1], &current[col - 1]) {
688            pairs.push((row - 1, col - 1));
689            row -= 1;
690            col -= 1;
691        } else if score[at(row - 1, col)] >= score[at(row, col - 1)] {
692            row -= 1;
693        } else {
694            col -= 1;
695        }
696    }
697    pairs.reverse();
698    pairs
699}
700
701fn hash_node(node: &SigNode, hash: &mut u64) {
702    hash_bytes(hash, node.role.as_bytes());
703    hash_bytes(hash, node.identity.as_bytes());
704    hash_bytes(hash, &node.child_count.to_le_bytes());
705    for child in &node.children {
706        hash_node(child, hash);
707    }
708}
709
710fn hash_bytes(hash: &mut u64, bytes: &[u8]) {
711    for byte in bytes {
712        *hash ^= u64::from(*byte);
713        *hash = hash.wrapping_mul(0x100000001b3);
714    }
715    *hash ^= 0xff;
716    *hash = hash.wrapping_mul(0x100000001b3);
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    fn frame(x: f64, y: f64, width: f64, height: f64) -> NodeFrame {
724        NodeFrame {
725            x,
726            y,
727            width,
728            height,
729        }
730    }
731
732    #[test]
733    fn numbering_matches_the_contract_example() {
734        let mut window = UiNode::bare("AXWindow");
735        window.title = Some("Notes".into());
736        window.frame = Some(frame(0.0, 0.0, 800.0, 600.0));
737        let mut button = UiNode::bare("AXButton");
738        button.title = Some("New Note".into());
739        button.frame = Some(frame(12.0, 10.0, 88.0, 24.0));
740        button.actions = vec!["AXPress".into()];
741        let mut text = UiNode::bare("AXTextArea");
742        text.frame = Some(frame(12.0, 40.0, 776.0, 500.0));
743        text.value = Some("hello".into());
744        window.children = vec![button, text];
745        let rendered = render(&window);
746        assert_eq!(
747            rendered.text,
748            "[0] AXWindow \"Notes\" @0,0 800x600\n  [1] AXButton \"New Note\" @12,10 88x24 actions=AXPress\n  [2] AXTextArea @12,40 776x500 value=\"hello\"\n"
749        );
750        assert!(!rendered.truncated);
751        assert_eq!(rendered.root.index, 0);
752        assert_eq!(rendered.root.children[1].index, 2);
753    }
754
755    #[test]
756    fn child_cap_depth_cap_and_line_cap_set_truncated() {
757        let mut parent = UiNode::bare("AXGroup");
758        parent.children = (0..42)
759            .map(|index| {
760                let mut child = UiNode::bare("AXStaticText");
761                child.title = Some(format!("n{index}"));
762                child
763            })
764            .collect();
765        parent.child_count = Some(42);
766        let rendered = render(&parent);
767        assert!(rendered.truncated);
768        assert!(rendered.text.contains("… +2"));
769        assert!(rendered.text.contains("truncated=true"));
770        assert_eq!(rendered.root.children.len(), 40);
771        assert!(rendered.text.contains("[40]"));
772        assert!(!rendered.text.contains("[41]"));
773
774        let mut deep = UiNode::bare("AXGroup");
775        let mut cursor = &mut deep;
776        for _ in 0..14 {
777            cursor.children.push(UiNode::bare("AXGroup"));
778            cursor = cursor.children.last_mut().unwrap();
779        }
780        let rendered = render(&deep);
781        assert!(rendered.truncated);
782        assert!(rendered
783            .text
784            .lines()
785            .any(|line| line.starts_with("truncated=")));
786        let deepest = rendered
787            .text
788            .lines()
789            .filter(|line| line.contains("AXGroup"))
790            .count();
791        assert_eq!(deepest, DEPTH_CAP + 1);
792
793        let mut stopped = UiNode::bare("AXGroup");
794        stopped.children = vec![UiNode::bare("AXStaticText"), UiNode::bare("AXStaticText")];
795        stopped.child_count = Some(5);
796        let rendered = render(&stopped);
797        assert!(rendered.truncated);
798        assert!(rendered.text.contains("truncated=true"));
799        assert!(
800            !rendered.text.contains('…'),
801            "a line-budget stop is not the 40-child marker"
802        );
803
804        let mut wide = UiNode::bare("AXGroup");
805        grow_binary(&mut wide, 0);
806        let rendered = render(&wide);
807        let content: Vec<_> = rendered
808            .text
809            .lines()
810            .filter(|line| *line != "truncated=true")
811            .collect();
812        assert_eq!(content.len(), LINE_CAP);
813        assert!(rendered.truncated);
814    }
815
816    #[test]
817    fn url_strip_and_secure_redaction_drop_the_secret() {
818        assert_eq!(
819            shorten_url("https://example.com/path/to/resource?q=secret"),
820            "https://example.com/path/to/resource"
821        );
822        let long_path = format!("/{}", "a".repeat(80));
823        let shortened = shorten_url(&format!("http://example.com{long_path}?x=1"));
824        assert_eq!(shortened, format!("http://example.com/{}", "a".repeat(59)));
825        assert!(!shortened.contains('?'));
826
827        let mut secret = UiNode::bare("AXSecureTextField");
828        secret.value = Some("hunter2".into());
829        secret.secure = true;
830        let rendered = render(&secret);
831        assert!(rendered.text.contains("value=\"••••\""));
832        assert!(!rendered.text.contains("hunter2"));
833        assert!(!rendered.root.value.contains("hunter2"));
834
835        let mut sub = UiNode::bare("AXTextField");
836        sub.subrole = Some("AXSecureTextField".into());
837        sub.value = Some("hunter2".into());
838        let rendered = render(&sub);
839        assert!(!rendered.text.contains("hunter2"));
840        assert!(rendered.text.contains("••••"));
841    }
842
843    #[test]
844    fn diff_uses_lcs_and_full_replaces_the_printed_text() {
845        let mut before = UiNode::bare("AXWindow");
846        before.identifier = Some("win".into());
847        before.children = vec![
848            labeled("AXButton", "A", "one"),
849            labeled("AXButton", "B", "two"),
850            labeled("AXTextArea", "C", "a"),
851        ];
852        let mut after = UiNode::bare("AXWindow");
853        after.identifier = Some("win".into());
854        after.children = vec![
855            labeled("AXButton", "A", "one"),
856            labeled("AXTextArea", "C", "ab"),
857            labeled("AXButton", "D", "new"),
858        ];
859        let first = render(&before);
860        let second = render(&after);
861        let diff = diff_trees(&first.root, &second.root);
862        assert!(diff.contains("- [2] AXButton \"B\""), "{diff}");
863        assert!(
864            diff.contains("~ [2] AXTextArea value: \"a\" -> \"ab\""),
865            "{diff}"
866        );
867        assert!(diff.contains("+ [3] AXButton \"D\""), "{diff}");
868        assert!(!diff.contains("+ [1]"));
869
870        assert_eq!(choose_text(None, &second, false), second.text);
871        assert_eq!(
872            choose_text(Some(&first.root), &second, true),
873            second.text,
874            "full replaces the baseline text"
875        );
876        let incremental = choose_text(Some(&first.root), &second, false);
877        assert!(incremental.starts_with('-') || incremental.starts_with('~'));
878        assert_ne!(revision_of(&first.root), revision_of(&second.root));
879    }
880
881    #[test]
882    fn electron_set_and_restore_returns_the_previous_values() {
883        let edit = electron_enable(FlagState {
884            manual: Some(false),
885            enhanced: None,
886        });
887        assert_eq!(edit.set_manual, Some(true));
888        assert_eq!(edit.set_enhanced, Some(true));
889        assert_eq!(
890            electron_restore(&edit),
891            FlagState {
892                manual: Some(false),
893                enhanced: Some(false),
894            }
895        );
896        let untouched = electron_enable(FlagState {
897            manual: Some(true),
898            enhanced: Some(true),
899        });
900        assert_eq!(untouched.set_manual, None);
901        assert_eq!(untouched.set_enhanced, None);
902        assert_eq!(
903            electron_restore(&untouched),
904            FlagState {
905                manual: None,
906                enhanced: None,
907            }
908        );
909    }
910
911    #[test]
912    fn attribute_map_sets_and_restores_only_changed_flags() {
913        let mut attrs = AttributeMap {
914            manual: Some(false),
915            enhanced: None,
916        };
917        let edit = attrs.enable_electron();
918        assert_eq!(
919            attrs,
920            AttributeMap {
921                manual: Some(true),
922                enhanced: Some(true),
923            }
924        );
925        assert_eq!(edit.previous.manual, Some(false));
926        assert_eq!(edit.previous.enhanced, None);
927        attrs.restore_electron(&edit);
928        assert_eq!(
929            attrs,
930            AttributeMap {
931                manual: Some(false),
932                enhanced: Some(false),
933            }
934        );
935
936        let mut already = AttributeMap {
937            manual: Some(true),
938            enhanced: Some(true),
939        };
940        let untouched = already.enable_electron();
941        assert_eq!(untouched.set_manual, None);
942        assert_eq!(untouched.set_enhanced, None);
943        already.manual = Some(true);
944        already.restore_electron(&untouched);
945        assert_eq!(already.manual, Some(true));
946        assert_eq!(already.enhanced, Some(true));
947    }
948
949    #[test]
950    fn bound_element_keeps_pid_window_revision_and_index() {
951        let bound = BoundElement {
952            pid: 412,
953            window_id: 88,
954            revision: 3,
955            index: 2,
956        };
957        let text = serde_json::to_string(&bound).unwrap();
958        let back: BoundElement = serde_json::from_str(&text).unwrap();
959        assert_eq!(back, bound);
960    }
961
962    #[test]
963    fn hosted_pid_walker_includes_a_foreign_child_once() {
964        let helpers = hosted_helper_pids(10, &[10, 77, 77, 88], &[77, 99]);
965        assert_eq!(helpers, vec![77, 88]);
966        let scanned = hosted_helper_pids(10, &[], &[10, 55, 55, 66, 67]);
967        assert_eq!(scanned, vec![55, 66, 67]);
968        assert!(bundle_matches_parent(
969            "Google Chrome",
970            Some("com.google.Chrome"),
971            "com.google.Chrome.helper"
972        ));
973    }
974
975    fn grow_binary(node: &mut UiNode, depth: usize) {
976        if depth >= DEPTH_CAP {
977            return;
978        }
979        node.children = vec![UiNode::bare("AXGroup"), UiNode::bare("AXGroup")];
980        for child in &mut node.children {
981            grow_binary(child, depth + 1);
982        }
983    }
984
985    fn labeled(role: &str, identity: &str, value: &str) -> UiNode {
986        let mut node = UiNode::bare(role);
987        node.identifier = Some(identity.into());
988        node.title = Some(identity.into());
989        node.value = Some(value.into());
990        node
991    }
992}