Skip to main content

computer_use_linux/
atspi_tree.rs

1use crate::diagnostics::hydrate_session_bus_env;
2use anyhow::{anyhow, Context, Result};
3use atspi::{
4    proxy::{
5        accessible::{AccessibleProxy, ObjectRefExt},
6        action::ActionProxy,
7        editable_text::EditableTextProxy,
8        proxy_ext::{Proxies, ProxyExt},
9        text::TextProxy,
10    },
11    CoordType, ObjectRef, ObjectRefOwned, StateSet,
12};
13// Direct dependency (p2p feature off) — see Cargo.toml for why we bypass
14// atspi's "connection" re-export.
15use atspi_connection::AccessibilityConnection;
16use futures_util::{stream, StreamExt};
17use schemars::JsonSchema;
18use serde::Serialize;
19use std::{collections::VecDeque, future::Future, time::Duration};
20use tokio::time::{sleep_until, timeout, timeout_at, Instant};
21use zbus::{
22    fdo::DBusProxy,
23    names::{BusName, UniqueName},
24    zvariant::{ObjectPath, OwnedObjectPath},
25};
26
27#[derive(Debug, Clone, Serialize, JsonSchema)]
28pub struct AccessibleAppSummary {
29    pub object_ref: String,
30    pub name: Option<String>,
31    pub pid: Option<u32>,
32    pub role: String,
33    pub child_count: i32,
34    pub bounds: Option<Bounds>,
35}
36
37#[derive(Debug, Clone, Serialize, JsonSchema)]
38pub struct AccessibilityNode {
39    pub index: u32,
40    pub parent_index: Option<u32>,
41    pub depth: u32,
42    pub object_ref: String,
43    pub role: String,
44    pub name: Option<String>,
45    pub description: Option<String>,
46    pub child_count: i32,
47    pub bounds: Option<Bounds>,
48    pub states: Vec<String>,
49    pub actions: Vec<AccessibilityAction>,
50    pub value: Option<AccessibilityValue>,
51    pub text: Option<AccessibilityText>,
52    pub supports_editable_text: bool,
53}
54
55#[derive(Debug, Clone, Serialize, JsonSchema)]
56pub struct Bounds {
57    pub x: i32,
58    pub y: i32,
59    pub width: i32,
60    pub height: i32,
61}
62
63#[derive(Debug, Clone, Serialize, JsonSchema)]
64pub struct AccessibilityAction {
65    pub index: i32,
66    pub name: String,
67    pub description: String,
68    pub keybinding: String,
69}
70
71#[derive(Debug, Clone, Serialize, JsonSchema)]
72pub struct AccessibilityValue {
73    pub current: f64,
74    pub minimum: f64,
75    pub maximum: f64,
76    pub minimum_increment: f64,
77    pub text: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize, JsonSchema)]
81pub struct AccessibilityText {
82    pub character_count: i32,
83    pub caret_offset: Option<i32>,
84    pub content: Option<String>,
85    pub truncated: bool,
86    pub selections: Vec<AccessibilityTextSelection>,
87}
88
89#[derive(Debug, Clone, Serialize, JsonSchema)]
90pub struct AccessibilityTextSelection {
91    pub start_offset: i32,
92    pub end_offset: i32,
93}
94
95#[derive(Debug, Clone)]
96pub struct ActionInvocation {
97    pub action_index: i32,
98    pub action_name: Option<String>,
99    pub ok: bool,
100}
101
102#[derive(Debug, Clone)]
103pub enum ValueSetInvocation {
104    Numeric { value: f64 },
105    EditableText { outcome: EditableTextSetOutcome },
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum EditableTextSetOutcome {
110    Verified,
111    MismatchAfterDeadline,
112    ReadbackFailed,
113    ReadbackTimedOut,
114    PasswordUnverifiable,
115}
116
117const MAX_TEXT_READBACK_CHARS: i32 = 4096;
118const EDITABLE_TEXT_READ_CHUNK_UNITS: usize = 4096;
119const EDITABLE_TEXT_VERIFY_TIMEOUT: Duration = Duration::from_secs(1);
120const EDITABLE_TEXT_VERIFY_INTERVAL: Duration = Duration::from_millis(50);
121const MAX_TEXT_SELECTIONS: i32 = 8;
122const DEFAULT_SNAPSHOT_MAX_NODES: usize = 1_000;
123// Larger trees are opt-in; the default and the 10-second deadline stay unchanged.
124const HARD_SNAPSHOT_MAX_NODES: usize = 10_000;
125const DEFAULT_SNAPSHOT_MAX_DEPTH: u32 = 32;
126const HARD_SNAPSHOT_MAX_DEPTH: u32 = 64;
127const CHILD_READ_CONCURRENCY: usize = 16;
128const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
129/// Time past `SNAPSHOT_TIMEOUT` for the node being read to finish; a node still
130/// reading after it is dropped and the tree is returned truncated.
131const SNAPSHOT_HARD_GRACE: Duration = Duration::from_secs(2);
132const MAX_ACTIONS_PER_OBJECT: i32 = 64;
133const ACTION_QUERY_TIMEOUT: Duration = Duration::from_secs(5);
134const MAX_DISCOVERY_ROOTS: usize = 256;
135const ROOT_MATCH_CHILD_LIMIT: usize = 8;
136const MAX_DISCOVERY_CHILD_READS: usize = MAX_DISCOVERY_ROOTS * ROOT_MATCH_CHILD_LIMIT;
137
138fn snapshot_child_read_budgets(max_nodes: usize) -> (usize, usize, usize) {
139    (MAX_DISCOVERY_ROOTS, MAX_DISCOVERY_CHILD_READS, max_nodes)
140}
141
142pub(crate) fn snapshot_limits(
143    requested_max_nodes: Option<usize>,
144    requested_max_depth: Option<u32>,
145) -> (usize, u32) {
146    (
147        requested_max_nodes
148            .unwrap_or(DEFAULT_SNAPSHOT_MAX_NODES)
149            .clamp(1, HARD_SNAPSHOT_MAX_NODES),
150        requested_max_depth
151            .unwrap_or(DEFAULT_SNAPSHOT_MAX_DEPTH)
152            .min(HARD_SNAPSHOT_MAX_DEPTH),
153    )
154}
155
156struct BoundedTraversal<T> {
157    queue: VecDeque<T>,
158    attempted: usize,
159    max_items: usize,
160    /// Set once an enqueue had to drop offered items for lack of capacity.
161    dropped: bool,
162}
163
164impl<T> BoundedTraversal<T> {
165    fn new(max_items: usize) -> Self {
166        Self {
167            queue: VecDeque::new(),
168            attempted: 0,
169            max_items,
170            dropped: false,
171        }
172    }
173
174    fn enqueue(&mut self, items: impl IntoIterator<Item = T>) {
175        let capacity = self.remaining_capacity();
176        let mut items = items.into_iter();
177        self.queue.extend(items.by_ref().take(capacity));
178        if items.next().is_some() {
179            self.dropped = true;
180        }
181    }
182
183    fn pop(&mut self) -> Option<T> {
184        if self.attempted >= self.max_items {
185            return None;
186        }
187        let item = self.queue.pop_front()?;
188        self.attempted += 1;
189        Some(item)
190    }
191
192    fn remaining_capacity(&self) -> usize {
193        self.max_items
194            .saturating_sub(self.attempted.saturating_add(self.queue.len()))
195    }
196
197    /// Drop pending and earlier dropped work, keeping the spent budget, so the
198    /// rest of the budget goes to the subtree enqueued next.
199    fn narrow(&mut self) {
200        self.queue.clear();
201        self.dropped = false;
202    }
203
204    /// True when the budget stopped work that was actually offered. `enqueue`
205    /// is the only gate: it never exceeds `remaining_capacity`, so
206    /// `attempted + queue.len() <= max_items` always holds and the queue can
207    /// never be non-empty at the attempt cap. Dropped items are the whole story.
208    fn truncated(&self) -> bool {
209        self.dropped
210    }
211}
212
213fn bounded_child_count(reported: i32, limit: usize) -> usize {
214    usize::try_from(reported).unwrap_or_default().min(limit)
215}
216
217struct IndexedReadBatch<T> {
218    items: Vec<T>,
219    attempted: usize,
220    /// True when fewer children were attempted than the parent reported, because
221    /// the caller's limit or the shared read budget ran out. Failed reads do not
222    /// count; they were attempted.
223    incomplete: bool,
224}
225
226impl<T> IndexedReadBatch<T> {
227    fn all_failed(&self) -> bool {
228        self.attempted > 0 && self.items.is_empty()
229    }
230}
231
232async fn fetch_indexed_up_to<T, E, F, Fut>(
233    reported: i32,
234    limit: usize,
235    remaining_attempts: &mut usize,
236    fetch: F,
237) -> IndexedReadBatch<T>
238where
239    F: Fn(i32) -> Fut,
240    Fut: Future<Output = std::result::Result<T, E>>,
241{
242    let attempt_count = bounded_child_count(reported, limit).min(*remaining_attempts);
243    *remaining_attempts = (*remaining_attempts).saturating_sub(attempt_count);
244    let end_index = i32::try_from(attempt_count).unwrap_or(i32::MAX);
245
246    let items = stream::iter(0..end_index)
247        .map(fetch)
248        .buffered(CHILD_READ_CONCURRENCY)
249        .filter_map(|result| async move { result.ok() })
250        .collect()
251        .await;
252
253    IndexedReadBatch {
254        items,
255        attempted: attempt_count,
256        incomplete: attempt_count < usize::try_from(reported).unwrap_or_default(),
257    }
258}
259
260async fn children_up_to(
261    proxy: &AccessibleProxy<'_>,
262    limit: usize,
263    remaining_attempts: &mut usize,
264) -> zbus::Result<IndexedReadBatch<ObjectRefOwned>> {
265    if limit == 0 || *remaining_attempts == 0 {
266        // Budget already spent: stay I/O-free. `incomplete` is false here because
267        // nothing was asked of the parent; callers that already hold the node's
268        // child_count (snapshot_tree_inner) derive truncation from that instead.
269        return Ok(IndexedReadBatch {
270            items: Vec::new(),
271            attempted: 0,
272            incomplete: false,
273        });
274    }
275
276    let child_count = proxy.child_count().await?;
277    Ok(
278        fetch_indexed_up_to(child_count, limit, remaining_attempts, |index| {
279            child_at_index(proxy, index)
280        })
281        .await,
282    )
283}
284
285async fn child_at_index(proxy: &AccessibleProxy<'_>, index: i32) -> zbus::Result<ObjectRefOwned> {
286    // WebKit sockets return a well-known bus name. atspi's ObjectRef decoder
287    // only accepts unique names, so resolve the wire reference before using it.
288    let (name, path): (String, OwnedObjectPath) =
289        proxy.inner().call("GetChildAtIndex", &(index,)).await?;
290    if path.as_str() == "/org/a11y/atspi/null" {
291        return Ok(ObjectRef::Null.into());
292    }
293    match BusName::try_from(name)? {
294        BusName::Unique(name) => Ok(ObjectRef::new_owned(name, path)),
295        name => {
296            let owner = DBusProxy::new(proxy.inner().connection())
297                .await?
298                .get_name_owner(name)
299                .await?;
300            Ok(ObjectRef::new_owned(owner, path))
301        }
302    }
303}
304
305pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
306    let conn = connect().await?;
307    let mut remaining_child_reads = limit;
308    let roots = registry_children(&conn, limit, &mut remaining_child_reads).await?;
309    let dbus = DBusProxy::new(conn.connection()).await.ok();
310    let mut apps = Vec::new();
311
312    for object_ref in roots.into_iter().take(limit) {
313        if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
314            apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
315        }
316    }
317
318    Ok(apps)
319}
320
321#[derive(Debug, Clone)]
322pub(crate) struct AccessibilitySnapshot {
323    pub nodes: Vec<AccessibilityNode>,
324    /// True when registry roots were filtered to an app name and/or pid.
325    pub scoped: bool,
326    /// The pid every root belongs to, when roots were matched by pid. None
327    /// when they were matched by app name alone or not filtered at all: those
328    /// roots can belong to any app, so no single pid describes the tree.
329    pub root_pid: Option<u32>,
330    /// True when selected roots satisfy the supplied app/name filter, or no
331    /// filter was supplied. Normal snapshots retain their PID-only fallback;
332    /// action recovery requires this evidence before invoking a fresh node.
333    pub app_filter_matched: bool,
334    /// True when max_nodes, max_depth, the child read budget, or the deadline stopped
335    /// traversal with unread elements left. Failed element reads do not count.
336    pub truncated: bool,
337    /// Name of the AT-SPI window the tree was narrowed to, when an app root
338    /// exposed several windows and exactly one matched the window title.
339    pub window_scope: Option<String>,
340}
341
342pub async fn snapshot_tree(
343    app_name_or_bundle_identifier: Option<&str>,
344    target_pid: Option<u32>,
345    max_nodes: usize,
346    max_depth: u32,
347) -> Result<Vec<AccessibilityNode>> {
348    Ok(snapshot_accessibility_tree(
349        app_name_or_bundle_identifier,
350        target_pid,
351        None,
352        max_nodes,
353        max_depth,
354    )
355    .await?
356    .nodes)
357}
358
359/// Read the accessibility tree of the matching app roots. With
360/// `window_title`, an app root that exposes several windows (Chromium puts
361/// every browser window under one application) is read only through the
362/// window whose name matches it; its other children stay.
363pub(crate) async fn snapshot_accessibility_tree(
364    app_name_or_bundle_identifier: Option<&str>,
365    target_pid: Option<u32>,
366    window_title: Option<&str>,
367    max_nodes: usize,
368    max_depth: u32,
369) -> Result<AccessibilitySnapshot> {
370    let (max_nodes, max_depth) = snapshot_limits(Some(max_nodes), Some(max_depth));
371    // Traversal stops at the deadline and returns what it read, marked truncated.
372    // The outer timeout only bounds app discovery, which runs before any node
373    // is read; the traversal itself ends by `stop_reading` plus the grace.
374    let stop_reading = Instant::now() + SNAPSHOT_TIMEOUT;
375    timeout(
376        SNAPSHOT_TIMEOUT + SNAPSHOT_HARD_GRACE + SNAPSHOT_HARD_GRACE,
377        snapshot_tree_inner(
378            app_name_or_bundle_identifier,
379            target_pid,
380            window_title
381                .map(str::trim)
382                .filter(|title| !title.is_empty()),
383            max_nodes,
384            max_depth,
385            stop_reading,
386        ),
387    )
388    .await
389    .context("AT-SPI snapshot could not find its target before the deadline")?
390}
391
392async fn snapshot_tree_inner(
393    app_name_or_bundle_identifier: Option<&str>,
394    target_pid: Option<u32>,
395    window_title: Option<&str>,
396    max_nodes: usize,
397    max_depth: u32,
398    stop_reading: Instant,
399) -> Result<AccessibilitySnapshot> {
400    let conn = connect().await?;
401    // App discovery is bounded independently so a tiny requested tree still
402    // finds a target registered after the first accessibility root.
403    let (mut remaining_registry_reads, mut remaining_filter_reads, mut remaining_traversal_reads) =
404        snapshot_child_read_budgets(max_nodes);
405    let roots =
406        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
407    let selected_roots = select_roots(
408        &conn,
409        roots,
410        app_name_or_bundle_identifier,
411        target_pid,
412        &mut remaining_filter_reads,
413    )
414    .await;
415    let scoped = selected_roots.scoped;
416    let root_pid = selected_roots.pid;
417    let app_filter_matched = selected_roots.app_filter_matched;
418    let mut nodes = Vec::new();
419    let mut truncated = false;
420    let mut window_scope = None;
421    let mut traversal = BoundedTraversal::new(max_nodes);
422
423    traversal.enqueue(
424        selected_roots
425            .roots
426            .into_iter()
427            .map(|object_ref| (object_ref, 0_u32, None)),
428    );
429
430    // Each node's reads may run past `stop_reading` by the grace, never more: a
431    // slow action query (up to ACTION_QUERY_TIMEOUT) near the deadline cuts the
432    // tree there instead of losing it to the outer timeout.
433    let node_cutoff = stop_reading + SNAPSHOT_HARD_GRACE;
434    while let Some((object_ref, depth, parent_index)) = traversal.pop() {
435        if Instant::now() >= stop_reading {
436            truncated = true;
437            break;
438        }
439        let index = nodes.len() as u32;
440        let remaining = traversal.remaining_capacity();
441        let read = timeout_at(node_cutoff, async {
442            let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
443                return None;
444            };
445            let node = read_node(&proxy, &object_ref, index, parent_index, depth).await;
446            let mut cut = false;
447            let mut scope = None;
448            let child_refs = if depth < max_depth && remaining > 0 {
449                match children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await {
450                    Ok(batch) => {
451                        // A read-budget cut inside the fetch reports `incomplete`; an
452                        // already-exhausted budget returns the I/O-free empty batch, so
453                        // fall back to the child_count read_node already fetched.
454                        cut = batch.incomplete || (batch.attempted == 0 && node.child_count > 0);
455                        match window_title.filter(|_| depth == 0) {
456                            Some(title) => {
457                                let (children, matched) =
458                                    scope_to_window(&conn, batch.items, title).await;
459                                scope = matched;
460                                children
461                            }
462                            None => batch.items,
463                        }
464                    }
465                    Err(_) => Vec::new(),
466                }
467            } else {
468                // Depth or node cap reached: any child this node reports is unread.
469                cut = node.child_count > 0;
470                Vec::new()
471            };
472            Some((node, child_refs, cut, scope))
473        })
474        .await;
475        let Ok(read) = read else {
476            truncated = true;
477            break;
478        };
479        let Some((node, child_refs, cut, scope)) = read else {
480            continue;
481        };
482        truncated |= cut;
483        if scope.is_some() {
484            window_scope = scope;
485        }
486        nodes.push(node);
487
488        traversal.enqueue(
489            child_refs
490                .into_iter()
491                .map(|child| (child, depth + 1, Some(index))),
492        );
493    }
494    truncated |= traversal.truncated();
495
496    Ok(AccessibilitySnapshot {
497        nodes,
498        scoped,
499        root_pid,
500        app_filter_matched,
501        truncated,
502        window_scope,
503    })
504}
505
506/// Children of an app root, narrowed to the window named `title` when the root
507/// holds several windows. Returns the children to read and the matched window
508/// name. Only the other named frames are dropped: popups, menus, dialogs and
509/// unnamed windows are kept, since Chromium puts a window's popups and dialogs
510/// beside it under the app root.
511async fn scope_to_window(
512    conn: &AccessibilityConnection,
513    children: Vec<ObjectRefOwned>,
514    title: &str,
515) -> (Vec<ObjectRefOwned>, Option<String>) {
516    // An app root has a handful of windows; read them in order.
517    let mut labels = Vec::with_capacity(children.len());
518    for child in &children {
519        labels.push(match open_accessible(conn, child).await {
520            Ok(proxy) => (
521                role_name(&proxy).await,
522                optional_string(proxy.name().await.ok()),
523            ),
524            Err(_) => (String::new(), None),
525        });
526    }
527    let Some(chosen) = matching_window(&labels, title) else {
528        return (children, None);
529    };
530    let name = labels[chosen].1.clone();
531    let keep = kept_beside_window(&labels, chosen);
532    let kept = children
533        .into_iter()
534        .zip(keep)
535        .filter_map(|(child, keep)| keep.then_some(child))
536        .collect();
537    (kept, name)
538}
539
540/// Which app root children (`(role, name)`) stay when `chosen` is the targeted
541/// window: it, and every child that is not another named frame.
542fn kept_beside_window(children: &[(String, Option<String>)], chosen: usize) -> Vec<bool> {
543    children
544        .iter()
545        .enumerate()
546        .map(|(index, (role, name))| {
547            index == chosen
548                || role != "frame"
549                || name.as_deref().is_none_or(|name| name.trim().is_empty())
550        })
551        .collect()
552}
553
554pub(crate) fn is_window_role(role: &str) -> bool {
555    matches!(role, "frame" | "window" | "dialog" | "file chooser")
556}
557
558/// Index of the one window among an app root's children (`(role, name)`)
559/// that `title` names, when the root has at least two windows. An exact name
560/// wins, then a case-insensitive one, then the one name that contains
561/// `title`, ignoring case. A tie at the first rule that matches, or no match,
562/// returns `None`, which keeps every window.
563fn matching_window(children: &[(String, Option<String>)], title: &str) -> Option<usize> {
564    let windows: Vec<(usize, &str)> = children
565        .iter()
566        .enumerate()
567        .filter(|(_, (role, _))| is_window_role(role))
568        .map(|(index, (_, name))| (index, name.as_deref().unwrap_or_default().trim()))
569        .collect();
570    if windows.len() < 2 {
571        return None;
572    }
573    let title = title.trim();
574    let lowered = title.to_lowercase();
575    let rules: [&dyn Fn(&str) -> bool; 3] = [
576        &|name| name == title,
577        &|name| name.to_lowercase() == lowered,
578        &|name| name.to_lowercase().contains(&lowered),
579    ];
580    for rule in rules {
581        let mut matches = windows.iter().filter(|(_, name)| rule(name));
582        if let Some((index, _)) = matches.next() {
583            return matches.next().is_none().then_some(*index);
584        }
585    }
586    None
587}
588
589/// Compact description of the AT-SPI element that currently holds keyboard
590/// focus, used as post-input feedback for type_text/press_key.
591#[derive(Debug, Clone, Serialize, JsonSchema)]
592pub struct FocusedElementSummary {
593    pub role: String,
594    pub name: Option<String>,
595    pub editable: bool,
596    pub states: Vec<String>,
597    /// AT-SPI `Role::Terminal`, read from the role enum rather than the
598    /// localized role name. Internal routing only; not part of the output.
599    #[serde(skip)]
600    #[schemars(skip)]
601    pub is_terminal: bool,
602}
603
604impl FocusedElementSummary {
605    /// A field that takes typed text: editable, or a text-entry role.
606    pub(crate) fn accepts_text(&self) -> bool {
607        self.editable
608            || self.states.iter().any(|state| state == "editable")
609            || matches!(
610                self.role.as_str(),
611                "text" | "entry" | "password text" | "combo box"
612            )
613    }
614}
615
616const FOCUS_PROBE_MAX_NODES: usize = 400;
617const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
618
619/// Find the element with the `focused` state inside the target app (by pid) or
620/// across all apps. Best-effort and bounded: returns Ok(None) when no focused
621/// element is reachable through AT-SPI (common for apps without accessibility
622/// support, e.g. Electron without --force-renderer-accessibility).
623/// Outcome of the bounded focused-element search.
624#[derive(Debug, Clone)]
625pub(crate) enum FocusProbe {
626    Found(FocusedElementSummary),
627    /// The search covered the target's whole tree and nothing holds focus.
628    NoneFocused,
629    /// A complete scoped search saw only window/menu chrome, no content.
630    ShellOnly,
631    /// A node, depth, or read limit stopped the search before it finished, so
632    /// the focused element may exist past the limit.
633    Incomplete,
634    /// A pid was given but no AT-SPI app belongs to it (xterm, or Electron
635    /// without --force-renderer-accessibility), so focus cannot be read.
636    NoAccessibleApp,
637}
638
639/// Focused element in the app owning `target_pid`, or across all apps when no
640/// pid is given. With a pid, only that app's tree is searched: falling back to
641/// every other app would report some unrelated widget as the focus.
642pub(crate) async fn probe_focused_element(target_pid: Option<u32>) -> Result<FocusProbe> {
643    focused_element_probe(target_pid, target_pid.is_some()).await
644}
645
646/// The focused element in the app that owns `target_pid`, for callers that
647/// only need an answer when one exists. Every non-`Found` probe result maps to
648/// `None`; see [`probe_focused_element`] for the distinctions.
649pub(crate) async fn focused_element_summary_in_app(
650    target_pid: u32,
651) -> Result<Option<FocusedElementSummary>> {
652    Ok(match focused_element_probe(Some(target_pid), true).await? {
653        FocusProbe::Found(summary) => Some(summary),
654        _ => None,
655    })
656}
657
658async fn focused_element_probe(
659    target_pid: Option<u32>,
660    require_scoped: bool,
661) -> Result<FocusProbe> {
662    let conn = connect().await?;
663    let mut remaining_registry_reads = MAX_DISCOVERY_ROOTS;
664    let roots =
665        registry_children(&conn, MAX_DISCOVERY_ROOTS, &mut remaining_registry_reads).await?;
666    let mut remaining_filter_reads = MAX_DISCOVERY_CHILD_READS;
667    let selected_roots =
668        select_roots(&conn, roots, None, target_pid, &mut remaining_filter_reads).await;
669    if require_scoped && !selected_roots.scoped {
670        return Ok(FocusProbe::NoAccessibleApp);
671    }
672    let mut traversal = BoundedTraversal::new(FOCUS_PROBE_MAX_NODES);
673    let mut remaining_traversal_reads = FOCUS_PROBE_MAX_NODES;
674    let mut incomplete = false;
675    let mut shell_only = true;
676    let mut observed_nodes = 0;
677    let mut focused_container = None;
678    let mut depth_base = 0_u32;
679
680    traversal.enqueue(
681        selected_roots
682            .roots
683            .into_iter()
684            .map(|object_ref| (object_ref, 0_u32, false)),
685    );
686
687    while let Some((object_ref, depth, in_menu)) = traversal.pop() {
688        let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
689            incomplete = true;
690            continue;
691        };
692        let Ok(state) = proxy.get_state().await else {
693            incomplete = true;
694            continue;
695        };
696        observed_nodes += 1;
697        let mut child_menu = false;
698        if shell_only {
699            match accessibility_chrome_context(&role_name(&proxy).await, in_menu) {
700                Some(menu) => child_menu = menu,
701                None => shell_only = false,
702            }
703        }
704        if state.contains(atspi::State::Focused) {
705            let proxies = proxy.proxies().await.ok();
706            let is_terminal = matches!(proxy.get_role().await, Ok(atspi::Role::Terminal));
707            let summary = FocusedElementSummary {
708                role: role_name(&proxy).await,
709                name: optional_string(proxy.name().await.ok()),
710                editable: supports_editable_text(proxies.as_ref()).await,
711                states: state_labels(state),
712                is_terminal,
713            };
714            // Chromium can mark both a document and its contenteditable entry
715            // focused. Do not stop at the ancestor before visiting the editor:
716            // the focus is inside it, so the rest of the budget searches only
717            // its subtree, and nodes left unread elsewhere no longer matter.
718            if focus_is_container(&summary) {
719                focused_container = Some(summary);
720                // Only within one app: across apps a stale focused document in
721                // another app must not hide the real focus.
722                if require_scoped {
723                    traversal.narrow();
724                    // The discarded queue had spent its child reads; the subtree
725                    // gets the rest of the node budget, and its depth counts from
726                    // the container.
727                    remaining_traversal_reads = traversal.remaining_capacity();
728                    depth_base = depth;
729                    incomplete = false;
730                }
731            } else {
732                return Ok(FocusProbe::Found(summary));
733            }
734        }
735        let remaining = traversal.remaining_capacity();
736        if depth - depth_base < FOCUS_PROBE_MAX_DEPTH && remaining > 0 {
737            if let Ok(batch) =
738                children_up_to(&proxy, remaining, &mut remaining_traversal_reads).await
739            {
740                incomplete |= batch.incomplete;
741                // An exhausted shared read budget takes children_up_to's
742                // I/O-free path (attempted 0, incomplete false), so the node's
743                // own child count has to say whether children went unread.
744                // With budget left, attempted 0 just means a leaf: no read.
745                if batch.attempted == 0 && remaining_traversal_reads == 0 && !incomplete {
746                    incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
747                }
748                traversal.enqueue(
749                    batch
750                        .items
751                        .into_iter()
752                        .map(|child| (child, depth + 1, child_menu)),
753                );
754            } else {
755                incomplete = true;
756            }
757        } else if !incomplete {
758            // Depth or node cap: any child this node reports goes unread. One
759            // child-count read settles it, and only until the first cut is seen.
760            incomplete = proxy.child_count().await.map_or(true, |count| count > 0);
761        }
762    }
763    Ok(finish_focus_probe(
764        incomplete || traversal.truncated(),
765        require_scoped && shell_only && observed_nodes > 0,
766        focused_container,
767    ))
768}
769
770pub(crate) fn focus_is_container(element: &FocusedElementSummary) -> bool {
771    !element.editable
772        && !element.states.iter().any(|state| state == "editable")
773        && !element.is_terminal
774        && matches!(
775            element.role.as_str(),
776            "application"
777                | "frame"
778                | "window"
779                | "panel"
780                | "filler"
781                | "section"
782                | "document web"
783                | "document frame"
784                | "document text"
785        )
786}
787
788fn finish_focus_probe(
789    incomplete: bool,
790    shell_only: bool,
791    focused_container: Option<FocusedElementSummary>,
792) -> FocusProbe {
793    // A focused container is not a definitive recipient if descendants were
794    // unread. Preserve uncertainty rather than reporting a non-editable field.
795    if incomplete {
796        FocusProbe::Incomplete
797    } else if let Some(container) = focused_container {
798        FocusProbe::Found(container)
799    } else if shell_only {
800        FocusProbe::ShellOnly
801    } else {
802        FocusProbe::NoneFocused
803    }
804}
805
806/// Window and native menu chrome can exist without Electron renderer content.
807/// This is evidence about tree coverage, not positive Electron detection.
808pub(crate) fn accessibility_chrome_context(role: &str, in_menu: bool) -> Option<bool> {
809    match role {
810        "application" | "frame" | "window" | "panel" | "filler" | "separator" => Some(in_menu),
811        "menu bar" | "menu" | "menu item" | "check menu item" | "radio menu item"
812        | "tearoff menu item" => Some(true),
813        // Electron's native menu buttons are exposed as buttons, not menu items.
814        "button" | "push button" | "label" | "static" | "image" | "icon" if in_menu => Some(true),
815        _ => None,
816    }
817}
818
819pub async fn perform_action(
820    object_ref_id: &str,
821    requested_action: Option<&str>,
822) -> Result<ActionInvocation> {
823    perform_action_inner(object_ref_id, requested_action, false).await
824}
825
826pub(crate) async fn perform_named_action(
827    object_ref_id: &str,
828    name: &str,
829) -> Result<ActionInvocation> {
830    perform_action_inner(object_ref_id, Some(name), true).await
831}
832
833async fn perform_action_inner(
834    object_ref_id: &str,
835    requested_action: Option<&str>,
836    name_only: bool,
837) -> Result<ActionInvocation> {
838    let object_ref = object_ref_from_id(object_ref_id)?;
839    let (action, action_index, action_name) = timeout(ACTION_QUERY_TIMEOUT, async {
840        let conn = connect().await?;
841        let proxy = open_accessible(&conn, &object_ref)
842            .await
843            .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
844        let action = proxy
845            .proxies()
846            .await?
847            .action()
848            .await
849            .context("element does not expose the AT-SPI Action interface")?;
850        let actions = read_actions(&action).await?;
851        let action_index = if name_only {
852            select_named_action_index(&actions, requested_action.unwrap_or_default())?
853        } else {
854            select_action_index(&actions, requested_action)?
855        };
856        let action_name = actions
857            .get(action_index as usize)
858            .map(|action| action.name.clone());
859        Ok::<_, anyhow::Error>((action, action_index, action_name))
860    })
861    .await
862    .context("AT-SPI action revalidation exceeded its 5-second deadline")??;
863    // Only time out the reads: once DoAction is sent, it may already have acted.
864    let ok = action
865        .do_action(action_index)
866        .await
867        .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
868
869    Ok(ActionInvocation {
870        action_index,
871        action_name,
872        ok,
873    })
874}
875
876pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
877    let conn = connect().await?;
878    let object_ref = object_ref_from_id(object_ref_id)?;
879    let proxy = open_accessible(&conn, &object_ref)
880        .await
881        .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
882    let proxies = proxy.proxies().await?;
883    set_element_value_with_proxies(&proxy, &proxies, value).await
884}
885
886async fn set_element_value_with_proxies(
887    proxy: &AccessibleProxy<'_>,
888    proxies: &Proxies<'_>,
889    value: &str,
890) -> Result<ValueSetInvocation> {
891    if let Ok(numeric_value) = value.parse::<f64>() {
892        if let Ok(value_proxy) = proxies.value().await {
893            value_proxy
894                .set_current_value(numeric_value)
895                .await
896                .with_context(|| {
897                    format!("failed to set AT-SPI numeric value to {numeric_value}")
898                })?;
899            return Ok(ValueSetInvocation::Numeric {
900                value: numeric_value,
901            });
902        }
903    }
904
905    if let Ok(editable_text) = proxies.editable_text().await {
906        return set_editable_text_contents(proxy, proxies, &editable_text, value).await;
907    }
908
909    if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
910        return Err(anyhow!(
911            "element exposes the AT-SPI Value interface, but the requested value is not numeric"
912        ));
913    }
914
915    Err(anyhow!(
916        "element does not expose AT-SPI Value or EditableText interfaces"
917    ))
918}
919
920async fn set_editable_text_contents(
921    proxy: &AccessibleProxy<'_>,
922    proxies: &Proxies<'_>,
923    editable_text: &EditableTextProxy<'_>,
924    value: &str,
925) -> Result<ValueSetInvocation> {
926    let ok = editable_text
927        .set_text_contents(value)
928        .await
929        .context("failed to set AT-SPI editable text contents")?;
930    if !ok {
931        return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
932    }
933
934    let outcome = verify_editable_text_contents(proxy, proxies, value).await;
935    Ok(ValueSetInvocation::EditableText { outcome })
936}
937
938#[derive(Debug, Clone, Copy, PartialEq, Eq)]
939enum EditableTextReadbackError {
940    Failed,
941    TimedOut,
942}
943
944async fn readback_within<T, E>(
945    deadline: Instant,
946    read: impl Future<Output = std::result::Result<T, E>>,
947) -> std::result::Result<T, EditableTextReadbackError> {
948    timeout_at(deadline, read)
949        .await
950        .map_err(|_| EditableTextReadbackError::TimedOut)?
951        .map_err(|_| EditableTextReadbackError::Failed)
952}
953
954async fn text_readback_matches(
955    text: &TextProxy<'_>,
956    requested: &str,
957    deadline: Instant,
958) -> std::result::Result<bool, EditableTextReadbackError> {
959    let character_count = readback_within(deadline, text.character_count()).await?;
960    if character_count < 0 {
961        return Err(EditableTextReadbackError::Failed);
962    }
963    if character_count as usize == requested.chars().count() {
964        return unicode_scalar_text_readback_matches(text, requested, deadline).await;
965    }
966
967    let requested_utf16 = requested.encode_utf16().collect::<Vec<_>>();
968    if character_count as usize != requested_utf16.len() {
969        return Ok(false);
970    }
971    utf16_text_readback_matches(text, &requested_utf16, deadline).await
972}
973
974async fn unicode_scalar_text_readback_matches(
975    text: &TextProxy<'_>,
976    requested: &str,
977    deadline: Instant,
978) -> std::result::Result<bool, EditableTextReadbackError> {
979    let requested_chars = requested.chars().collect::<Vec<_>>();
980    let mut offset = 0;
981    while offset < requested_chars.len() {
982        let end = offset
983            .saturating_add(EDITABLE_TEXT_READ_CHUNK_UNITS)
984            .min(requested_chars.len());
985        let chunk = readback_within(deadline, text.get_text(offset as i32, end as i32)).await?;
986        if !chunk
987            .chars()
988            .eq(requested_chars[offset..end].iter().copied())
989        {
990            return Ok(false);
991        }
992        offset = end;
993    }
994
995    Ok(true)
996}
997
998async fn utf16_text_readback_matches(
999    text: &TextProxy<'_>,
1000    requested: &[u16],
1001    deadline: Instant,
1002) -> std::result::Result<bool, EditableTextReadbackError> {
1003    let mut offset = 0;
1004    while offset < requested.len() {
1005        let mut end = offset
1006            .saturating_add(EDITABLE_TEXT_READ_CHUNK_UNITS)
1007            .min(requested.len());
1008        if end < requested.len() && (0xD800..=0xDBFF).contains(&requested[end - 1]) {
1009            end -= 1;
1010        }
1011        let chunk = readback_within(deadline, text.get_text(offset as i32, end as i32)).await?;
1012        if !chunk
1013            .encode_utf16()
1014            .eq(requested[offset..end].iter().copied())
1015        {
1016            return Ok(false);
1017        }
1018        offset = end;
1019    }
1020
1021    Ok(true)
1022}
1023
1024async fn verify_editable_text_contents(
1025    proxy: &AccessibleProxy<'_>,
1026    proxies: &Proxies<'_>,
1027    requested: &str,
1028) -> EditableTextSetOutcome {
1029    let deadline = Instant::now() + EDITABLE_TEXT_VERIFY_TIMEOUT;
1030    match readback_within(deadline, proxy.get_role()).await {
1031        Ok(atspi::Role::PasswordText) => return EditableTextSetOutcome::PasswordUnverifiable,
1032        Ok(_) => {}
1033        Err(EditableTextReadbackError::Failed) => {
1034            return EditableTextSetOutcome::ReadbackFailed;
1035        }
1036        Err(EditableTextReadbackError::TimedOut) => {
1037            return EditableTextSetOutcome::ReadbackTimedOut;
1038        }
1039    }
1040    let text = match readback_within(deadline, proxies.text()).await {
1041        Ok(text) => text,
1042        Err(EditableTextReadbackError::Failed) => {
1043            return EditableTextSetOutcome::ReadbackFailed;
1044        }
1045        Err(EditableTextReadbackError::TimedOut) => {
1046            return EditableTextSetOutcome::ReadbackTimedOut;
1047        }
1048    };
1049
1050    loop {
1051        match text_readback_matches(&text, requested, deadline).await {
1052            Ok(true) => return EditableTextSetOutcome::Verified,
1053            Ok(false) => {}
1054            Err(EditableTextReadbackError::Failed) => {
1055                return EditableTextSetOutcome::ReadbackFailed;
1056            }
1057            Err(EditableTextReadbackError::TimedOut) => {
1058                return EditableTextSetOutcome::ReadbackTimedOut;
1059            }
1060        }
1061
1062        let now = Instant::now();
1063        if now >= deadline {
1064            return EditableTextSetOutcome::MismatchAfterDeadline;
1065        }
1066        sleep_until((now + EDITABLE_TEXT_VERIFY_INTERVAL).min(deadline)).await;
1067        if Instant::now() >= deadline {
1068            return EditableTextSetOutcome::MismatchAfterDeadline;
1069        }
1070    }
1071}
1072
1073async fn connect() -> Result<AccessibilityConnection> {
1074    hydrate_session_bus_env();
1075    AccessibilityConnection::new()
1076        .await
1077        .context("failed to connect to AT-SPI bus")
1078}
1079
1080/// Open an `AccessibleProxy` for an object on the a11y bus.
1081///
1082/// We deliberately avoid `AccessibilityConnection::object_as_accessible` (the
1083/// `P2P` trait). For apps that advertise a peer-to-peer bus address it routes
1084/// reads over that socket, but for apps that don't (notably GTK4 apps such as
1085/// Nautilus / Text Editor / baobab, which don't implement the legacy
1086/// `GetApplicationBusAddress`) it falls back to a proxy built with only a path
1087/// and *no destination*. On the shared a11y bus that proxy can't address the
1088/// app and every call fails with `ServiceUnknown`, which surfaces as an empty
1089/// tree (`role: "unknown"`, `child_count: 0`). `as_accessible_proxy` always
1090/// pins the destination to the object's bus name, so it works for every app
1091/// regardless of P2P support. See issue #31.
1092async fn open_accessible<'r>(
1093    conn: &AccessibilityConnection,
1094    object_ref: &'r ObjectRefOwned,
1095) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
1096    object_ref.as_accessible_proxy(conn.connection()).await
1097}
1098
1099async fn registry_children(
1100    conn: &AccessibilityConnection,
1101    limit: usize,
1102    remaining_child_reads: &mut usize,
1103) -> Result<Vec<ObjectRefOwned>> {
1104    let root = conn
1105        .root_accessible_on_registry()
1106        .await
1107        .context("failed to open AT-SPI registry root")?;
1108    let batch = children_up_to(&root, limit, remaining_child_reads)
1109        .await
1110        .context("failed to read AT-SPI registry children")?;
1111    if batch.all_failed() {
1112        return Err(anyhow!(
1113            "AT-SPI registry reported children, but every indexed child read failed"
1114        ));
1115    }
1116    Ok(batch.items)
1117}
1118
1119struct SelectedRoots {
1120    roots: Vec<ObjectRefOwned>,
1121    scoped: bool,
1122    /// Set only when the roots were chosen because they belong to this pid.
1123    pid: Option<u32>,
1124    app_filter_matched: bool,
1125}
1126
1127/// Normalized app-name filter, or `None` when the caller passed nothing usable.
1128/// A `None` needle with no pid match means the snapshot covers the whole desktop.
1129fn app_name_needle(app_name_or_bundle_identifier: Option<&str>) -> Option<String> {
1130    app_name_or_bundle_identifier
1131        .map(str::trim)
1132        .filter(|value| !value.is_empty())
1133        .map(|value| value.to_ascii_lowercase())
1134}
1135
1136async fn select_roots(
1137    conn: &AccessibilityConnection,
1138    roots: Vec<ObjectRefOwned>,
1139    app_name_or_bundle_identifier: Option<&str>,
1140    target_pid: Option<u32>,
1141    remaining_child_reads: &mut usize,
1142) -> SelectedRoots {
1143    let needle = app_name_needle(app_name_or_bundle_identifier);
1144    let dbus = DBusProxy::new(conn.connection()).await.ok();
1145    let mut remaining = roots;
1146
1147    if let Some(target_pid) = target_pid {
1148        let mut pid_and_filter_matches = Vec::new();
1149        let mut pid_matches = Vec::new();
1150        let mut non_pid_matches = Vec::new();
1151
1152        for object_ref in remaining {
1153            if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
1154                if let Some(needle) = needle.as_deref() {
1155                    if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
1156                        pid_and_filter_matches.push(object_ref);
1157                    } else {
1158                        pid_matches.push(object_ref);
1159                    }
1160                } else {
1161                    pid_matches.push(object_ref);
1162                }
1163            } else {
1164                non_pid_matches.push(object_ref);
1165            }
1166        }
1167
1168        if !pid_and_filter_matches.is_empty() {
1169            return SelectedRoots {
1170                roots: pid_and_filter_matches,
1171                scoped: true,
1172                pid: Some(target_pid),
1173                app_filter_matched: true,
1174            };
1175        }
1176        if !pid_matches.is_empty() {
1177            return SelectedRoots {
1178                roots: pid_matches,
1179                scoped: true,
1180                pid: Some(target_pid),
1181                app_filter_matched: needle.is_none(),
1182            };
1183        }
1184
1185        remaining = non_pid_matches;
1186    }
1187
1188    let Some(needle) = needle.as_deref() else {
1189        return SelectedRoots {
1190            roots: remaining,
1191            scoped: false,
1192            pid: None,
1193            app_filter_matched: true,
1194        };
1195    };
1196
1197    let mut selected = Vec::new();
1198    for object_ref in remaining {
1199        if root_matches(conn, &object_ref, needle, remaining_child_reads).await {
1200            selected.push(object_ref);
1201        }
1202    }
1203
1204    let app_filter_matched = !selected.is_empty();
1205    SelectedRoots {
1206        roots: selected,
1207        scoped: true,
1208        pid: None,
1209        app_filter_matched,
1210    }
1211}
1212
1213async fn root_matches(
1214    conn: &AccessibilityConnection,
1215    object_ref: &ObjectRefOwned,
1216    needle: &str,
1217    remaining_child_reads: &mut usize,
1218) -> bool {
1219    let Ok(proxy) = open_accessible(conn, object_ref).await else {
1220        return object_ref_id(object_ref)
1221            .to_ascii_lowercase()
1222            .contains(needle);
1223    };
1224
1225    if proxy_matches(&proxy, object_ref, needle).await {
1226        return true;
1227    }
1228
1229    for child_ref in children_up_to(&proxy, ROOT_MATCH_CHILD_LIMIT, remaining_child_reads)
1230        .await
1231        .map(|batch| batch.items)
1232        .unwrap_or_default()
1233    {
1234        let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
1235            continue;
1236        };
1237        if proxy_matches(&child_proxy, &child_ref, needle).await {
1238            return true;
1239        }
1240    }
1241
1242    false
1243}
1244
1245async fn proxy_matches(
1246    proxy: &AccessibleProxy<'_>,
1247    object_ref: &ObjectRefOwned,
1248    needle: &str,
1249) -> bool {
1250    let name = proxy.name().await.unwrap_or_default();
1251    let role = proxy.get_role_name().await.unwrap_or_default();
1252    format!("{} {} {}", object_ref_id(object_ref), name, role)
1253        .to_ascii_lowercase()
1254        .contains(needle)
1255}
1256
1257async fn read_app_summary(
1258    proxy: &AccessibleProxy<'_>,
1259    object_ref: &ObjectRefOwned,
1260    dbus: Option<&DBusProxy<'_>>,
1261) -> AccessibleAppSummary {
1262    AccessibleAppSummary {
1263        object_ref: object_ref_id(object_ref),
1264        name: optional_string(proxy.name().await.ok()),
1265        pid: object_ref_pid(dbus, object_ref).await,
1266        role: role_name(proxy).await,
1267        child_count: proxy.child_count().await.unwrap_or_default(),
1268        bounds: bounds(proxy).await,
1269    }
1270}
1271
1272async fn read_node(
1273    proxy: &AccessibleProxy<'_>,
1274    object_ref: &ObjectRefOwned,
1275    index: u32,
1276    parent_index: Option<u32>,
1277    depth: u32,
1278) -> AccessibilityNode {
1279    let proxies = proxy.proxies().await.ok();
1280
1281    AccessibilityNode {
1282        index,
1283        parent_index,
1284        depth,
1285        object_ref: object_ref_id(object_ref),
1286        role: role_name(proxy).await,
1287        name: optional_string(proxy.name().await.ok()),
1288        description: optional_string(proxy.description().await.ok()),
1289        child_count: proxy.child_count().await.unwrap_or_default(),
1290        bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
1291        states: states_from_proxy(proxy).await,
1292        actions: actions_from_proxies(proxies.as_ref()).await,
1293        value: value_from_proxies(proxies.as_ref()).await,
1294        text: text_from_proxies(proxies.as_ref()).await,
1295        supports_editable_text: supports_editable_text(proxies.as_ref()).await,
1296    }
1297}
1298
1299/// Role reported when neither AT-SPI role read succeeds.
1300pub(crate) const UNKNOWN_ROLE: &str = "unknown";
1301
1302async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
1303    if let Ok(role) = proxy.get_role_name().await {
1304        if !role.trim().is_empty() {
1305            return role;
1306        }
1307    }
1308    proxy
1309        .get_role()
1310        .await
1311        .map(|role| format!("{role:?}"))
1312        .unwrap_or_else(|_| UNKNOWN_ROLE.to_string())
1313}
1314
1315async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
1316    bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
1317}
1318
1319/// Pid of the process that owns an AT-SPI object ref (`:bus/path`), from the
1320/// accessibility bus. `None` when the owner is gone or the bus cannot say.
1321pub(crate) async fn object_ref_owner_pid(object_ref_id: &str) -> Result<Option<u32>> {
1322    let object_ref = object_ref_from_id(object_ref_id)?;
1323    let conn = connect().await?;
1324    let dbus = DBusProxy::new(conn.connection()).await.ok();
1325    Ok(object_ref_pid(dbus.as_ref(), &object_ref).await)
1326}
1327
1328async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
1329    let dbus = dbus?;
1330    let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
1331    dbus.get_connection_unix_process_id(bus_name).await.ok()
1332}
1333
1334async fn bounds_from_proxies(
1335    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1336    proxy: &AccessibleProxy<'_>,
1337) -> Option<Bounds> {
1338    let owned_proxies;
1339    let proxies = if let Some(proxies) = proxies {
1340        proxies
1341    } else {
1342        owned_proxies = proxy.proxies().await.ok()?;
1343        &owned_proxies
1344    };
1345    let component = proxies.component().await.ok()?;
1346    let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
1347    normalize_bounds(Bounds {
1348        x,
1349        y,
1350        width,
1351        height,
1352    })
1353}
1354
1355fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
1356    if bounds.width <= 0 || bounds.height <= 0 {
1357        return None;
1358    }
1359    if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
1360        return None;
1361    }
1362    Some(bounds)
1363}
1364
1365async fn actions_from_proxies(
1366    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1367) -> Vec<AccessibilityAction> {
1368    let Some(proxies) = proxies else {
1369        return Vec::new();
1370    };
1371    let Ok(action_proxy) = proxies.action().await else {
1372        return Vec::new();
1373    };
1374
1375    read_actions(&action_proxy)
1376        .await
1377        .unwrap_or_default()
1378        .into_iter()
1379        .enumerate()
1380        .map(|(index, action)| AccessibilityAction {
1381            index: index as i32,
1382            name: action.name,
1383            description: action.description,
1384            keybinding: action.keybinding,
1385        })
1386        .collect()
1387}
1388
1389async fn read_actions(proxy: &ActionProxy<'_>) -> Result<Vec<atspi::Action>> {
1390    // WebKit can advertise GetActions without replying to it. The indexed
1391    // methods work, and must also be used when revalidating a cached action.
1392    // Keep the wire spelling: the current atspi binding asks for "Nactions".
1393    timeout(ACTION_QUERY_TIMEOUT, async {
1394        let count = proxy
1395            .inner()
1396            .get_property::<i32>("NActions")
1397            .await
1398            .context("failed to read AT-SPI action count")?;
1399        if count > MAX_ACTIONS_PER_OBJECT {
1400            return Err(anyhow!(
1401                "AT-SPI action count {count} exceeds the {MAX_ACTIONS_PER_OBJECT}-action read budget"
1402            ));
1403        }
1404        if count < 0 {
1405            return Err(anyhow!("invalid negative AT-SPI action count {count}"));
1406        }
1407        Ok(stream::iter(0..count)
1408            .map(|index| async move {
1409                let (name, description, keybinding) = tokio::join!(
1410                    proxy.get_name(index),
1411                    proxy.get_description(index),
1412                    proxy.get_key_binding(index),
1413                );
1414                atspi::Action {
1415                    name: name.unwrap_or_default(),
1416                    description: description.unwrap_or_default(),
1417                    keybinding: keybinding.unwrap_or_default(),
1418                }
1419            })
1420            .buffered(CHILD_READ_CONCURRENCY)
1421            .collect()
1422            .await)
1423    })
1424    .await
1425    .context("AT-SPI action reads exceeded their 5-second deadline")?
1426}
1427
1428async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
1429    proxy
1430        .get_state()
1431        .await
1432        .map(state_labels)
1433        .unwrap_or_default()
1434}
1435
1436async fn value_from_proxies(
1437    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1438) -> Option<AccessibilityValue> {
1439    let value = proxies?.value().await.ok()?;
1440    Some(AccessibilityValue {
1441        current: value.current_value().await.ok()?,
1442        minimum: value.minimum_value().await.ok()?,
1443        maximum: value.maximum_value().await.ok()?,
1444        minimum_increment: value.minimum_increment().await.ok()?,
1445        text: optional_string(value.text().await.ok()),
1446    })
1447}
1448
1449async fn text_from_proxies(
1450    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
1451) -> Option<AccessibilityText> {
1452    let text = proxies?.text().await.ok()?;
1453    let character_count = text.character_count().await.ok()?.max(0);
1454    let caret_offset = text.caret_offset().await.ok();
1455    let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
1456    let content = if capped_count > 0 {
1457        optional_string(text.get_text(0, capped_count).await.ok())
1458    } else {
1459        None
1460    };
1461    let selection_count = text
1462        .get_nselections()
1463        .await
1464        .unwrap_or_default()
1465        .clamp(0, MAX_TEXT_SELECTIONS);
1466    let mut selections = Vec::new();
1467    for index in 0..selection_count {
1468        if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
1469            selections.push(AccessibilityTextSelection {
1470                start_offset,
1471                end_offset,
1472            });
1473        }
1474    }
1475
1476    Some(AccessibilityText {
1477        character_count,
1478        caret_offset,
1479        content,
1480        truncated: character_count > MAX_TEXT_READBACK_CHARS,
1481        selections,
1482    })
1483}
1484
1485async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
1486    let Some(proxies) = proxies else {
1487        return false;
1488    };
1489    proxies.editable_text().await.is_ok()
1490}
1491
1492fn state_labels(state_set: StateSet) -> Vec<String> {
1493    state_set.iter().map(|state| state.to_string()).collect()
1494}
1495
1496fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
1497    if actions.is_empty() {
1498        return Err(anyhow!("element exposes no AT-SPI actions"));
1499    }
1500
1501    if let Some(requested_action) = requested_action
1502        .map(str::trim)
1503        .filter(|value| !value.is_empty())
1504    {
1505        let requested_action = requested_action.to_ascii_lowercase();
1506        if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
1507            action.name.to_ascii_lowercase() == requested_action
1508                || action.description.to_ascii_lowercase() == requested_action
1509        }) {
1510            return Ok(index as i32);
1511        }
1512
1513        if let Ok(index) = requested_action.parse::<usize>() {
1514            if index < actions.len() {
1515                return Ok(index as i32);
1516            }
1517        }
1518
1519        return Err(anyhow!(
1520            "requested AT-SPI action was not found; available actions: {}",
1521            actions
1522                .iter()
1523                .map(|action| action.name.as_str())
1524                .collect::<Vec<_>>()
1525                .join(", ")
1526        ));
1527    }
1528
1529    Ok(if actions.len() > 1 { 1 } else { 0 })
1530}
1531
1532fn select_named_action_index(actions: &[atspi::Action], name: &str) -> Result<i32> {
1533    actions
1534        .iter()
1535        .position(|action| action.name.eq_ignore_ascii_case(name))
1536        .map(|index| index as i32)
1537        .ok_or_else(|| {
1538            anyhow!("cached AT-SPI action name is no longer available; refresh get_app_state")
1539        })
1540}
1541
1542fn optional_string(value: Option<String>) -> Option<String> {
1543    value
1544        .map(|value| value.trim().to_string())
1545        .filter(|value| !value.is_empty())
1546}
1547
1548fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
1549    let (name, path) = split_object_ref_id(object_ref_id)?;
1550    let name = UniqueName::try_from(name.to_string())
1551        .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
1552    let path = ObjectPath::try_from(path.to_string())
1553        .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
1554    Ok(ObjectRef::new_owned(name, path))
1555}
1556
1557fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
1558    let Some(path_start) = object_ref_id.find('/') else {
1559        return Err(anyhow!(
1560            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1561        ));
1562    };
1563    let (name, path) = object_ref_id.split_at(path_start);
1564    if name.is_empty() || path.is_empty() {
1565        return Err(anyhow!(
1566            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
1567        ));
1568    }
1569    Ok((name, path))
1570}
1571
1572fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
1573    format!(
1574        "{}{}",
1575        object_ref.name_as_str().unwrap_or(""),
1576        object_ref.path_as_str()
1577    )
1578}
1579
1580#[cfg(test)]
1581mod tests {
1582    use super::*;
1583    use std::{
1584        io::{BufRead, BufReader},
1585        process::{Child, Command, Stdio},
1586        sync::{
1587            atomic::{AtomicUsize, Ordering},
1588            Arc, Mutex,
1589        },
1590    };
1591
1592    struct PrivateBus(Child);
1593
1594    impl PrivateBus {
1595        fn start() -> (Self, String) {
1596            let mut bus = Self(
1597                Command::new("dbus-daemon")
1598                    .args(["--session", "--nofork", "--nopidfile", "--print-address=1"])
1599                    .stdout(Stdio::piped())
1600                    .spawn()
1601                    .unwrap(),
1602            );
1603            let mut address = String::new();
1604            BufReader::new(bus.0.stdout.take().unwrap())
1605                .read_line(&mut address)
1606                .unwrap();
1607            (bus, address.trim().to_string())
1608        }
1609    }
1610
1611    impl Drop for PrivateBus {
1612        fn drop(&mut self) {
1613            let _ = self.0.kill();
1614            let _ = self.0.wait();
1615        }
1616    }
1617
1618    const EDITABLE_FIXTURE_PATH: &str = "/editable";
1619
1620    #[derive(Default)]
1621    struct FakeEditableState {
1622        contents: String,
1623        mutations: usize,
1624        character_count_reads: usize,
1625        get_text_reads: usize,
1626        numeric_value: f64,
1627        numeric_writes: usize,
1628    }
1629
1630    #[derive(Clone, Copy)]
1631    enum FakeMutation {
1632        Apply,
1633        NoOp,
1634        Delayed,
1635        Normalize,
1636        Mask,
1637        Reject,
1638        Fail,
1639    }
1640
1641    #[derive(Clone, Copy, Debug)]
1642    enum FakeTextOffsetModel {
1643        UnicodeScalars,
1644        Utf16CodeUnits,
1645    }
1646
1647    #[derive(Clone, Copy)]
1648    enum FakeTextBehavior {
1649        Normal,
1650        CharacterCountError,
1651        GetTextError,
1652        NegativeCount,
1653        StallCharacterCount,
1654    }
1655
1656    #[derive(Clone, Copy)]
1657    struct FakeEditableConfig {
1658        mutation: FakeMutation,
1659        text: FakeTextBehavior,
1660        text_offset_model: FakeTextOffsetModel,
1661        role: atspi::Role,
1662        has_text: bool,
1663        has_value: bool,
1664    }
1665
1666    impl Default for FakeEditableConfig {
1667        fn default() -> Self {
1668            Self {
1669                mutation: FakeMutation::Apply,
1670                text: FakeTextBehavior::Normal,
1671                text_offset_model: FakeTextOffsetModel::Utf16CodeUnits,
1672                role: atspi::Role::Text,
1673                has_text: true,
1674                has_value: false,
1675            }
1676        }
1677    }
1678
1679    struct FakeEditableAccessible(FakeEditableConfig);
1680
1681    #[zbus::interface(name = "org.a11y.atspi.Accessible")]
1682    impl FakeEditableAccessible {
1683        #[zbus(property)]
1684        fn child_count(&self) -> i32 {
1685            0
1686        }
1687
1688        fn get_interfaces(&self) -> Vec<&str> {
1689            let mut interfaces = vec!["org.a11y.atspi.Accessible", "org.a11y.atspi.EditableText"];
1690            if self.0.has_text {
1691                interfaces.push("org.a11y.atspi.Text");
1692            }
1693            if self.0.has_value {
1694                interfaces.push("org.a11y.atspi.Value");
1695            }
1696            interfaces
1697        }
1698
1699        fn get_role(&self) -> atspi::Role {
1700            self.0.role
1701        }
1702    }
1703
1704    struct FakeEditableText {
1705        state: Arc<Mutex<FakeEditableState>>,
1706        mutation: FakeMutation,
1707    }
1708
1709    #[zbus::interface(name = "org.a11y.atspi.EditableText")]
1710    impl FakeEditableText {
1711        fn set_text_contents(&self, new_contents: &str) -> zbus::fdo::Result<bool> {
1712            self.state.lock().unwrap().mutations += 1;
1713            match self.mutation {
1714                FakeMutation::Apply => {
1715                    self.state.lock().unwrap().contents = new_contents.to_string();
1716                }
1717                FakeMutation::NoOp => {}
1718                FakeMutation::Delayed => {
1719                    let state = Arc::clone(&self.state);
1720                    let new_contents = new_contents.to_string();
1721                    tokio::spawn(async move {
1722                        tokio::time::sleep(Duration::from_millis(150)).await;
1723                        state.lock().unwrap().contents = new_contents;
1724                    });
1725                }
1726                FakeMutation::Normalize => {
1727                    self.state.lock().unwrap().contents = new_contents.replace("\r\n", "\n");
1728                }
1729                FakeMutation::Mask => {
1730                    self.state.lock().unwrap().contents = "•".repeat(new_contents.chars().count());
1731                }
1732                FakeMutation::Reject => return Ok(false),
1733                FakeMutation::Fail => {
1734                    return Err(zbus::fdo::Error::Failed(
1735                        "injected SetTextContents failure".into(),
1736                    ));
1737                }
1738            }
1739            Ok(true)
1740        }
1741    }
1742
1743    fn unicode_scalar_offset_to_byte_index(contents: &str, offset: usize) -> Option<usize> {
1744        contents
1745            .char_indices()
1746            .map(|(byte_index, _)| byte_index)
1747            .chain(std::iter::once(contents.len()))
1748            .nth(offset)
1749    }
1750
1751    fn utf16_offset_to_byte_index(contents: &str, offset: usize) -> Option<usize> {
1752        let mut utf16_offset = 0;
1753        for (byte_index, character) in contents.char_indices() {
1754            if utf16_offset == offset {
1755                return Some(byte_index);
1756            }
1757            utf16_offset += character.len_utf16();
1758        }
1759        (utf16_offset == offset).then_some(contents.len())
1760    }
1761
1762    struct FakeText {
1763        state: Arc<Mutex<FakeEditableState>>,
1764        behavior: FakeTextBehavior,
1765        text_offset_model: FakeTextOffsetModel,
1766    }
1767
1768    #[zbus::interface(name = "org.a11y.atspi.Text")]
1769    impl FakeText {
1770        #[zbus(property)]
1771        async fn character_count(&self) -> zbus::fdo::Result<i32> {
1772            self.state.lock().unwrap().character_count_reads += 1;
1773            match self.behavior {
1774                FakeTextBehavior::CharacterCountError => Err(zbus::fdo::Error::Failed(
1775                    "injected CharacterCount failure".into(),
1776                )),
1777                FakeTextBehavior::NegativeCount => Ok(-1),
1778                FakeTextBehavior::StallCharacterCount => std::future::pending().await,
1779                _ => {
1780                    let state = self.state.lock().unwrap();
1781                    let count = match self.text_offset_model {
1782                        FakeTextOffsetModel::UnicodeScalars => state.contents.chars().count(),
1783                        FakeTextOffsetModel::Utf16CodeUnits => {
1784                            state.contents.encode_utf16().count()
1785                        }
1786                    };
1787                    Ok(count as i32)
1788                }
1789            }
1790        }
1791
1792        fn get_text(&self, start_offset: i32, end_offset: i32) -> zbus::fdo::Result<String> {
1793            let mut state = self.state.lock().unwrap();
1794            state.get_text_reads += 1;
1795            if matches!(self.behavior, FakeTextBehavior::GetTextError) {
1796                return Err(zbus::fdo::Error::Failed("injected GetText failure".into()));
1797            }
1798            if start_offset < 0 || end_offset < start_offset {
1799                return Err(zbus::fdo::Error::InvalidArgs("invalid text offsets".into()));
1800            }
1801            let offset_to_byte_index = match self.text_offset_model {
1802                FakeTextOffsetModel::UnicodeScalars => unicode_scalar_offset_to_byte_index,
1803                FakeTextOffsetModel::Utf16CodeUnits => utf16_offset_to_byte_index,
1804            };
1805            let start = offset_to_byte_index(&state.contents, start_offset as usize)
1806                .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid text offsets".into()))?;
1807            let end = offset_to_byte_index(&state.contents, end_offset as usize)
1808                .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid text offsets".into()))?;
1809            Ok(state.contents[start..end].to_string())
1810        }
1811    }
1812
1813    struct FakeValue(Arc<Mutex<FakeEditableState>>);
1814
1815    #[zbus::interface(name = "org.a11y.atspi.Value")]
1816    impl FakeValue {
1817        #[zbus(property)]
1818        fn current_value(&self) -> f64 {
1819            self.0.lock().unwrap().numeric_value
1820        }
1821
1822        #[zbus(property)]
1823        fn set_current_value(&self, value: f64) {
1824            let mut state = self.0.lock().unwrap();
1825            state.numeric_value = value;
1826            state.numeric_writes += 1;
1827        }
1828    }
1829
1830    async fn start_editable_fixture(
1831        initial_contents: &str,
1832        config: FakeEditableConfig,
1833    ) -> (
1834        PrivateBus,
1835        zbus::Connection,
1836        zbus::Connection,
1837        Arc<Mutex<FakeEditableState>>,
1838    ) {
1839        let (bus, address) = PrivateBus::start();
1840        let state = Arc::new(Mutex::new(FakeEditableState {
1841            contents: initial_contents.to_string(),
1842            ..Default::default()
1843        }));
1844        let mut builder = zbus::connection::Builder::address(address.as_str())
1845            .unwrap()
1846            .serve_at(EDITABLE_FIXTURE_PATH, FakeEditableAccessible(config))
1847            .unwrap()
1848            .serve_at(
1849                EDITABLE_FIXTURE_PATH,
1850                FakeEditableText {
1851                    state: Arc::clone(&state),
1852                    mutation: config.mutation,
1853                },
1854            )
1855            .unwrap();
1856        if config.has_text {
1857            builder = builder
1858                .serve_at(
1859                    EDITABLE_FIXTURE_PATH,
1860                    FakeText {
1861                        state: Arc::clone(&state),
1862                        behavior: config.text,
1863                        text_offset_model: config.text_offset_model,
1864                    },
1865                )
1866                .unwrap();
1867        }
1868        if config.has_value {
1869            builder = builder
1870                .serve_at(EDITABLE_FIXTURE_PATH, FakeValue(Arc::clone(&state)))
1871                .unwrap();
1872        }
1873        let peer = builder.build().await.unwrap();
1874        let client = zbus::connection::Builder::address(address.as_str())
1875            .unwrap()
1876            .build()
1877            .await
1878            .unwrap();
1879        (bus, peer, client, state)
1880    }
1881
1882    async fn dispatch_editable_fixture(
1883        client: &zbus::Connection,
1884        peer: &zbus::Connection,
1885        value: &str,
1886    ) -> Result<ValueSetInvocation> {
1887        let proxy = AccessibleProxy::builder(client)
1888            .destination(peer.unique_name().unwrap().clone())
1889            .unwrap()
1890            .path(EDITABLE_FIXTURE_PATH)
1891            .unwrap()
1892            .cache_properties(zbus::proxy::CacheProperties::No)
1893            .build()
1894            .await
1895            .unwrap();
1896        let proxies = proxy.proxies().await.unwrap();
1897        set_element_value_with_proxies(&proxy, &proxies, value).await
1898    }
1899
1900    fn editable_outcome(invocation: ValueSetInvocation) -> EditableTextSetOutcome {
1901        match invocation {
1902            ValueSetInvocation::EditableText { outcome } => outcome,
1903            ValueSetInvocation::Numeric { .. } => panic!("expected editable-text dispatch"),
1904        }
1905    }
1906
1907    struct FakeAccessible {
1908        name: String,
1909        children: Vec<(String, OwnedObjectPath)>,
1910        has_action: bool,
1911    }
1912
1913    #[zbus::interface(name = "org.a11y.atspi.Accessible")]
1914    impl FakeAccessible {
1915        #[zbus(property)]
1916        fn child_count(&self) -> i32 {
1917            self.children.len() as i32
1918        }
1919
1920        #[zbus(property)]
1921        fn name(&self) -> &str {
1922            &self.name
1923        }
1924
1925        fn get_interfaces(&self) -> Vec<&str> {
1926            let mut interfaces = vec!["org.a11y.atspi.Accessible"];
1927            if self.has_action {
1928                interfaces.push("org.a11y.atspi.Action");
1929            }
1930            interfaces
1931        }
1932
1933        fn get_child_at_index(
1934            &self,
1935            index: i32,
1936        ) -> zbus::fdo::Result<((String, OwnedObjectPath),)> {
1937            self.children
1938                .get(index as usize)
1939                .cloned()
1940                .map(|child| (child,))
1941                .ok_or_else(|| zbus::fdo::Error::InvalidArgs("invalid child index".into()))
1942        }
1943    }
1944
1945    struct FakeWebKitAction {
1946        count: i32,
1947        stall_indexed_reads: bool,
1948        name_reads: Arc<AtomicUsize>,
1949    }
1950
1951    impl Default for FakeWebKitAction {
1952        fn default() -> Self {
1953            Self {
1954                count: 1,
1955                stall_indexed_reads: false,
1956                name_reads: Arc::default(),
1957            }
1958        }
1959    }
1960
1961    #[zbus::interface(name = "org.a11y.atspi.Action")]
1962    impl FakeWebKitAction {
1963        async fn get_actions(&self) -> Vec<atspi::Action> {
1964            std::future::pending().await
1965        }
1966
1967        #[zbus(property, name = "NActions")]
1968        fn nactions(&self) -> i32 {
1969            self.count
1970        }
1971
1972        async fn get_name(&self, index: i32) -> &str {
1973            assert!((0..self.count).contains(&index));
1974            self.name_reads.fetch_add(1, Ordering::SeqCst);
1975            if self.stall_indexed_reads {
1976                std::future::pending::<()>().await;
1977            }
1978            "click"
1979        }
1980
1981        async fn get_localized_name(&self, index: i32) -> &str {
1982            assert!((0..self.count).contains(&index));
1983            "Activate"
1984        }
1985
1986        fn get_description(&self, index: i32) -> &str {
1987            assert!((0..self.count).contains(&index));
1988            "Activate the control"
1989        }
1990
1991        fn get_key_binding(&self, index: i32) -> &str {
1992            assert!((0..self.count).contains(&index));
1993            "Return"
1994        }
1995
1996        fn do_action(&self, index: i32) -> bool {
1997            index == 0
1998        }
1999    }
2000
2001    struct FakeQtAction;
2002
2003    #[zbus::interface(name = "org.a11y.atspi.Action")]
2004    impl FakeQtAction {
2005        #[zbus(property, name = "NActions")]
2006        fn nactions(&self) -> i32 {
2007            2
2008        }
2009
2010        fn get_name(&self, index: i32) -> &str {
2011            match index {
2012                0 => "Press",
2013                1 => "Set Focus",
2014                _ => panic!("invalid action index"),
2015            }
2016        }
2017
2018        fn get_description(&self, index: i32) -> &str {
2019            match index {
2020                0 => "Eylemi tetikler",
2021                1 => "Odağı ayarlar",
2022                _ => panic!("invalid action index"),
2023            }
2024        }
2025
2026        fn get_key_binding(&self, _index: i32) -> &str {
2027            ""
2028        }
2029    }
2030
2031    #[tokio::test]
2032    async fn editable_text_true_without_publication_is_mismatch_and_mutates_once() {
2033        let (_bus, peer, client, state) = start_editable_fixture(
2034            "original",
2035            FakeEditableConfig {
2036                mutation: FakeMutation::NoOp,
2037                ..Default::default()
2038            },
2039        )
2040        .await;
2041
2042        let outcome = editable_outcome(
2043            dispatch_editable_fixture(&client, &peer, "modified")
2044                .await
2045                .unwrap(),
2046        );
2047
2048        // A last poll can cross the same fixed deadline. Both outcomes mean
2049        // an unverified write; neither permits verification or replay.
2050        assert!(
2051            matches!(
2052                outcome,
2053                EditableTextSetOutcome::MismatchAfterDeadline
2054                    | EditableTextSetOutcome::ReadbackTimedOut
2055            ),
2056            "{outcome:?}"
2057        );
2058        let state = state.lock().unwrap();
2059        assert_eq!(state.mutations, 1);
2060        assert!(state.character_count_reads > 1);
2061        assert!(state.get_text_reads > 1);
2062        assert_eq!(state.contents, "original");
2063    }
2064
2065    #[tokio::test]
2066    async fn editable_text_exact_readback_accepts_empty_whitespace_and_trailing_newline() {
2067        let (_bus, peer, client, state) =
2068            start_editable_fixture("initial", FakeEditableConfig::default()).await;
2069        let values = [
2070            "",
2071            " \t\n",
2072            "  leading and trailing\t \n",
2073            "trailing newline\n",
2074            "Case and unicode € 内容 😀🦀𐐷",
2075        ];
2076
2077        for value in values {
2078            let outcome = editable_outcome(
2079                dispatch_editable_fixture(&client, &peer, value)
2080                    .await
2081                    .unwrap(),
2082            );
2083            assert_eq!(outcome, EditableTextSetOutcome::Verified);
2084            assert_eq!(state.lock().unwrap().contents, value);
2085        }
2086        assert_eq!(state.lock().unwrap().mutations, values.len());
2087    }
2088
2089    #[tokio::test]
2090    async fn editable_text_non_bmp_readback_supports_scalar_and_utf16_peers() {
2091        let requested = "🦀 GTK verified ";
2092        for text_offset_model in [
2093            FakeTextOffsetModel::UnicodeScalars,
2094            FakeTextOffsetModel::Utf16CodeUnits,
2095        ] {
2096            let (_bus, peer, client, state) = start_editable_fixture(
2097                "",
2098                FakeEditableConfig {
2099                    text_offset_model,
2100                    ..Default::default()
2101                },
2102            )
2103            .await;
2104
2105            let outcome = editable_outcome(
2106                dispatch_editable_fixture(&client, &peer, requested)
2107                    .await
2108                    .unwrap(),
2109            );
2110
2111            assert_eq!(
2112                outcome,
2113                EditableTextSetOutcome::Verified,
2114                "{text_offset_model:?}"
2115            );
2116            let state = state.lock().unwrap();
2117            assert_eq!(state.mutations, 1);
2118            assert_eq!(state.contents, requested);
2119        }
2120    }
2121
2122    #[tokio::test]
2123    async fn editable_text_non_bmp_matching_count_does_not_accept_different_content() {
2124        let requested = "🦀 GTK verified ";
2125        let published = "🦀 GTK verified!";
2126        assert_eq!(requested.chars().count(), published.chars().count());
2127        assert_eq!(
2128            requested.encode_utf16().count(),
2129            published.encode_utf16().count()
2130        );
2131        for text_offset_model in [
2132            FakeTextOffsetModel::UnicodeScalars,
2133            FakeTextOffsetModel::Utf16CodeUnits,
2134        ] {
2135            let (_bus, peer, client, state) = start_editable_fixture(
2136                published,
2137                FakeEditableConfig {
2138                    mutation: FakeMutation::NoOp,
2139                    text_offset_model,
2140                    ..Default::default()
2141                },
2142            )
2143            .await;
2144
2145            let outcome = editable_outcome(
2146                dispatch_editable_fixture(&client, &peer, requested)
2147                    .await
2148                    .unwrap(),
2149            );
2150
2151            // A last poll can cross the same fixed deadline. Both outcomes
2152            // leave the write unverified without extending its deadline.
2153            assert!(
2154                matches!(
2155                    outcome,
2156                    EditableTextSetOutcome::MismatchAfterDeadline
2157                        | EditableTextSetOutcome::ReadbackTimedOut
2158                ),
2159                "{text_offset_model:?}: {outcome:?}"
2160            );
2161            let state = state.lock().unwrap();
2162            assert_eq!(state.mutations, 1);
2163            assert!(state.get_text_reads > 0, "{text_offset_model:?}");
2164            assert_eq!(state.contents, published);
2165        }
2166    }
2167
2168    #[tokio::test]
2169    async fn editable_text_readback_chunks_utf16_without_splitting_non_bmp() {
2170        let (_bus, peer, client, state) = start_editable_fixture(
2171            "",
2172            FakeEditableConfig {
2173                text_offset_model: FakeTextOffsetModel::Utf16CodeUnits,
2174                ..Default::default()
2175            },
2176        )
2177        .await;
2178        let mut expected = "a".repeat(EDITABLE_TEXT_READ_CHUNK_UNITS - 1);
2179        // The crab's surrogate pair straddles the first 4096-unit read boundary.
2180        expected.push_str("🦀é");
2181        expected.push_str(&"内容\n".repeat(1_200));
2182        assert!(expected.encode_utf16().count() > MAX_TEXT_READBACK_CHARS as usize);
2183
2184        let outcome = editable_outcome(
2185            dispatch_editable_fixture(&client, &peer, &expected)
2186                .await
2187                .unwrap(),
2188        );
2189
2190        assert_eq!(outcome, EditableTextSetOutcome::Verified);
2191        let state = state.lock().unwrap();
2192        assert_eq!(state.mutations, 1);
2193        assert!(state.get_text_reads >= 2);
2194        assert_eq!(state.contents, expected);
2195    }
2196
2197    #[tokio::test]
2198    async fn editable_text_readback_waits_for_async_publication() {
2199        let (_bus, peer, client, state) = start_editable_fixture(
2200            "pending",
2201            FakeEditableConfig {
2202                mutation: FakeMutation::Delayed,
2203                ..Default::default()
2204            },
2205        )
2206        .await;
2207
2208        let outcome = editable_outcome(
2209            dispatch_editable_fixture(&client, &peer, "published asynchronously")
2210                .await
2211                .unwrap(),
2212        );
2213
2214        assert_eq!(outcome, EditableTextSetOutcome::Verified);
2215        let state = state.lock().unwrap();
2216        assert_eq!(state.mutations, 1);
2217        assert!(state.character_count_reads > 1);
2218        assert_eq!(state.contents, "published asynchronously");
2219    }
2220
2221    #[tokio::test]
2222    async fn editable_text_normalization_mismatch_is_not_verified() {
2223        let (_bus, peer, client, state) = start_editable_fixture(
2224            "",
2225            FakeEditableConfig {
2226                mutation: FakeMutation::Normalize,
2227                ..Default::default()
2228            },
2229        )
2230        .await;
2231
2232        let outcome = editable_outcome(
2233            dispatch_editable_fixture(&client, &peer, "first\r\nsecond\n")
2234                .await
2235                .unwrap(),
2236        );
2237
2238        assert_eq!(outcome, EditableTextSetOutcome::MismatchAfterDeadline);
2239        let state = state.lock().unwrap();
2240        assert_eq!(state.mutations, 1);
2241        assert!(state.character_count_reads > 1);
2242        assert_eq!(state.contents, "first\nsecond\n");
2243    }
2244
2245    #[tokio::test]
2246    async fn password_text_is_unverifiable_even_when_requested_text_is_bullets() {
2247        let (_bus, peer, client, state) = start_editable_fixture(
2248            "",
2249            FakeEditableConfig {
2250                mutation: FakeMutation::Mask,
2251                role: atspi::Role::PasswordText,
2252                ..Default::default()
2253            },
2254        )
2255        .await;
2256
2257        let outcome = editable_outcome(
2258            dispatch_editable_fixture(&client, &peer, "••••••••")
2259                .await
2260                .unwrap(),
2261        );
2262
2263        assert_eq!(outcome, EditableTextSetOutcome::PasswordUnverifiable);
2264        let state = state.lock().unwrap();
2265        assert_eq!(state.mutations, 1);
2266        assert_eq!(state.character_count_reads, 0);
2267        assert_eq!(state.contents, "••••••••");
2268    }
2269
2270    #[tokio::test]
2271    async fn editable_text_missing_text_and_read_errors_are_structured_outcomes() {
2272        let configs = [
2273            FakeEditableConfig {
2274                has_text: false,
2275                ..Default::default()
2276            },
2277            FakeEditableConfig {
2278                text: FakeTextBehavior::CharacterCountError,
2279                ..Default::default()
2280            },
2281            FakeEditableConfig {
2282                text: FakeTextBehavior::GetTextError,
2283                ..Default::default()
2284            },
2285        ];
2286
2287        for config in configs {
2288            let (_bus, peer, client, state) = start_editable_fixture("before", config).await;
2289            let outcome = editable_outcome(
2290                dispatch_editable_fixture(&client, &peer, "readback may have changed")
2291                    .await
2292                    .unwrap(),
2293            );
2294            assert_eq!(outcome, EditableTextSetOutcome::ReadbackFailed);
2295            assert_eq!(state.lock().unwrap().mutations, 1);
2296        }
2297    }
2298
2299    #[tokio::test]
2300    async fn editable_text_negative_counts_and_stalled_readback_are_structured_outcomes() {
2301        let cases = [
2302            (
2303                FakeTextBehavior::NegativeCount,
2304                EditableTextSetOutcome::ReadbackFailed,
2305            ),
2306            (
2307                FakeTextBehavior::StallCharacterCount,
2308                EditableTextSetOutcome::ReadbackTimedOut,
2309            ),
2310        ];
2311
2312        for (text, expected) in cases {
2313            let (_bus, peer, client, state) = start_editable_fixture(
2314                "before",
2315                FakeEditableConfig {
2316                    text,
2317                    ..Default::default()
2318                },
2319            )
2320            .await;
2321            let outcome = editable_outcome(
2322                dispatch_editable_fixture(&client, &peer, "after")
2323                    .await
2324                    .unwrap(),
2325            );
2326            assert_eq!(outcome, expected);
2327            let state = state.lock().unwrap();
2328            assert_eq!(state.mutations, 1);
2329            assert_eq!(state.get_text_reads, 0);
2330        }
2331    }
2332
2333    #[tokio::test]
2334    async fn editable_text_rejection_and_mutation_errors_remain_errors() {
2335        let cases = [
2336            (FakeMutation::Reject, "rejected"),
2337            (FakeMutation::Fail, "failed to set"),
2338        ];
2339
2340        for (mutation, expected_error) in cases {
2341            let (_bus, peer, client, state) = start_editable_fixture(
2342                "before",
2343                FakeEditableConfig {
2344                    mutation,
2345                    ..Default::default()
2346                },
2347            )
2348            .await;
2349            let error = dispatch_editable_fixture(&client, &peer, "after")
2350                .await
2351                .unwrap_err();
2352            assert!(error.to_string().contains(expected_error));
2353            let state = state.lock().unwrap();
2354            assert_eq!(state.mutations, 1);
2355            assert_eq!(state.character_count_reads, 0);
2356        }
2357    }
2358
2359    #[tokio::test]
2360    async fn numeric_value_dispatch_remains_isolated_from_editable_text() {
2361        let (_bus, peer, client, state) = start_editable_fixture(
2362            "unchanged",
2363            FakeEditableConfig {
2364                has_value: true,
2365                ..Default::default()
2366            },
2367        )
2368        .await;
2369
2370        let invocation = dispatch_editable_fixture(&client, &peer, "42.5")
2371            .await
2372            .unwrap();
2373
2374        match invocation {
2375            ValueSetInvocation::Numeric { value } => assert_eq!(value, 42.5),
2376            ValueSetInvocation::EditableText { .. } => panic!("expected numeric dispatch"),
2377        }
2378        let state = state.lock().unwrap();
2379        assert_eq!(state.numeric_value, 42.5);
2380        assert_eq!(state.numeric_writes, 1);
2381        assert_eq!(state.mutations, 0);
2382        assert_eq!(state.character_count_reads, 0);
2383        assert_eq!(state.contents, "unchanged");
2384    }
2385
2386    #[tokio::test]
2387    async fn qt_action_names_do_not_depend_on_localized_names() {
2388        let (_bus, address) = PrivateBus::start();
2389        let peer = zbus::connection::Builder::address(address.as_str())
2390            .unwrap()
2391            .serve_at("/actions/qt", FakeQtAction)
2392            .unwrap()
2393            .build()
2394            .await
2395            .unwrap();
2396        let client = zbus::connection::Builder::address(address.as_str())
2397            .unwrap()
2398            .build()
2399            .await
2400            .unwrap();
2401        let action = ActionProxy::builder(&client)
2402            .destination(peer.unique_name().unwrap().clone())
2403            .unwrap()
2404            .path("/actions/qt")
2405            .unwrap()
2406            .cache_properties(zbus::proxy::CacheProperties::No)
2407            .build()
2408            .await
2409            .unwrap();
2410
2411        let actions = timeout(Duration::from_secs(1), read_actions(&action))
2412            .await
2413            .expect("Qt action reads must not require GetLocalizedName")
2414            .unwrap();
2415
2416        assert_eq!(actions.len(), 2);
2417        assert_eq!(actions[0].name, "Press");
2418        assert_eq!(actions[0].description, "Eylemi tetikler");
2419        assert_eq!(actions[1].name, "Set Focus");
2420        assert_eq!(actions[1].description, "Odağı ayarlar");
2421        assert_eq!(select_named_action_index(&actions, "press").unwrap(), 0);
2422    }
2423
2424    #[tokio::test]
2425    async fn indexed_children_resolve_webkit_names_on_the_accessibility_bus() {
2426        let (_bus, address) = PrivateBus::start();
2427
2428        let webkit_name = "org.webkit.MockWebProcess";
2429        let child_path = "/org/a11y/webkit/accessible/child";
2430        let webkit = zbus::connection::Builder::address(address.trim())
2431            .unwrap()
2432            .name(webkit_name)
2433            .unwrap()
2434            .serve_at(
2435                child_path,
2436                FakeAccessible {
2437                    name: "WebKit page".into(),
2438                    children: Vec::new(),
2439                    has_action: true,
2440                },
2441            )
2442            .unwrap()
2443            .serve_at(child_path, FakeWebKitAction::default())
2444            .unwrap()
2445            .build()
2446            .await
2447            .unwrap();
2448        let owner = webkit.unique_name().unwrap().to_string();
2449        let gtk = zbus::connection::Builder::address(address.trim())
2450            .unwrap()
2451            .serve_at(
2452                "/org/a11y/atspi/accessible/root",
2453                FakeAccessible {
2454                    name: "GTK socket".into(),
2455                    has_action: false,
2456                    children: vec![
2457                        (webkit_name.into(), child_path.try_into().unwrap()),
2458                        (owner.clone(), child_path.try_into().unwrap()),
2459                        (String::new(), "/org/a11y/atspi/null".try_into().unwrap()),
2460                        (
2461                            "org.webkit.UnownedWebProcess".into(),
2462                            child_path.try_into().unwrap(),
2463                        ),
2464                    ],
2465                },
2466            )
2467            .unwrap()
2468            .build()
2469            .await
2470            .unwrap();
2471        let client = zbus::connection::Builder::address(address.trim())
2472            .unwrap()
2473            .build()
2474            .await
2475            .unwrap();
2476        let proxy = AccessibleProxy::builder(&client)
2477            .destination(gtk.unique_name().unwrap().clone())
2478            .unwrap()
2479            .path("/org/a11y/atspi/accessible/root")
2480            .unwrap()
2481            .build()
2482            .await
2483            .unwrap();
2484        let mut remaining = 4;
2485        let reply = proxy
2486            .inner()
2487            .call_method("GetChildAtIndex", &(0_i32,))
2488            .await
2489            .unwrap();
2490        assert_eq!(reply.body().signature().to_string(), "(so)");
2491        let batch = children_up_to(&proxy, 4, &mut remaining).await.unwrap();
2492
2493        assert_eq!(batch.attempted, 4);
2494        assert_eq!(remaining, 0);
2495        assert!(!batch.incomplete);
2496        assert_eq!(batch.items.len(), 3, "only the unowned name should fail");
2497        assert_eq!(batch.items[0].name_as_str(), Some(owner.as_str()));
2498        assert_eq!(batch.items[0].path_as_str(), child_path);
2499        assert_eq!(batch.items[0], batch.items[1]);
2500        assert!(batch.items[2].is_null());
2501
2502        // The canonical reference remains usable by the normal read/action path.
2503        let child = batch.items[0].as_accessible_proxy(&client).await.unwrap();
2504        assert_eq!(child.name().await.unwrap(), "WebKit page");
2505        assert_eq!(
2506            object_ref_from_id(&object_ref_id(&batch.items[0])).unwrap(),
2507            batch.items[0]
2508        );
2509        let action = child.proxies().await.unwrap().action().await.unwrap();
2510        let actions = timeout(Duration::from_secs(1), read_actions(&action))
2511            .await
2512            .expect("indexed action reads must not wait for GetActions")
2513            .unwrap();
2514        assert_eq!(actions.len(), 1);
2515        assert_eq!(actions[0].name, "click");
2516        assert_eq!(actions[0].description, "Activate the control");
2517        assert_eq!(actions[0].keybinding, "Return");
2518        assert_eq!(select_named_action_index(&actions, "click").unwrap(), 0);
2519        assert!(action.do_action(0).await.unwrap());
2520    }
2521
2522    #[tokio::test]
2523    async fn indexed_actions_bound_untrusted_counts_before_reading_indices() {
2524        let (_bus, address) = PrivateBus::start();
2525        let name_reads = Arc::new(AtomicUsize::new(0));
2526        let counts = [
2527            -1,
2528            MAX_ACTIONS_PER_OBJECT + 1,
2529            i32::MAX,
2530            0,
2531            MAX_ACTIONS_PER_OBJECT,
2532        ];
2533        let mut builder = zbus::connection::Builder::address(address.as_str()).unwrap();
2534        for (index, count) in counts.iter().enumerate() {
2535            builder = builder
2536                .serve_at(
2537                    format!("/actions/{index}"),
2538                    FakeWebKitAction {
2539                        count: *count,
2540                        name_reads: Arc::clone(&name_reads),
2541                        ..Default::default()
2542                    },
2543                )
2544                .unwrap();
2545        }
2546        let peer = builder.build().await.unwrap();
2547        let client = zbus::connection::Builder::address(address.as_str())
2548            .unwrap()
2549            .build()
2550            .await
2551            .unwrap();
2552
2553        for (index, count) in counts.iter().enumerate() {
2554            let action = ActionProxy::builder(&client)
2555                .destination(peer.unique_name().unwrap().clone())
2556                .unwrap()
2557                .path(format!("/actions/{index}"))
2558                .unwrap()
2559                .cache_properties(zbus::proxy::CacheProperties::No)
2560                .build()
2561                .await
2562                .unwrap();
2563            let result = timeout(Duration::from_secs(1), read_actions(&action))
2564                .await
2565                .expect("untrusted counts must not cause unbounded work");
2566            if (0..=MAX_ACTIONS_PER_OBJECT).contains(count) {
2567                assert_eq!(result.unwrap().len(), *count as usize);
2568            } else {
2569                let error = result.unwrap_err().to_string();
2570                assert!(error.contains("action count"), "{error}");
2571                assert_eq!(name_reads.load(Ordering::SeqCst), 0);
2572            }
2573        }
2574        assert_eq!(
2575            name_reads.load(Ordering::SeqCst),
2576            MAX_ACTIONS_PER_OBJECT as usize
2577        );
2578    }
2579
2580    #[tokio::test]
2581    async fn indexed_actions_time_out_the_entire_read() {
2582        let (_bus, address) = PrivateBus::start();
2583        let name_reads = Arc::new(AtomicUsize::new(0));
2584        let peer = zbus::connection::Builder::address(address.as_str())
2585            .unwrap()
2586            .serve_at(
2587                "/actions/stalled",
2588                FakeWebKitAction {
2589                    stall_indexed_reads: true,
2590                    name_reads: Arc::clone(&name_reads),
2591                    ..Default::default()
2592                },
2593            )
2594            .unwrap()
2595            .build()
2596            .await
2597            .unwrap();
2598        let client = zbus::connection::Builder::address(address.as_str())
2599            .unwrap()
2600            .build()
2601            .await
2602            .unwrap();
2603        let action = ActionProxy::builder(&client)
2604            .destination(peer.unique_name().unwrap().clone())
2605            .unwrap()
2606            .path("/actions/stalled")
2607            .unwrap()
2608            .cache_properties(zbus::proxy::CacheProperties::No)
2609            .build()
2610            .await
2611            .unwrap();
2612
2613        let error = timeout(
2614            ACTION_QUERY_TIMEOUT + Duration::from_secs(1),
2615            read_actions(&action),
2616        )
2617        .await
2618        .expect("indexed reads must have an overall deadline")
2619        .unwrap_err();
2620        assert!(error.to_string().contains("5-second deadline"));
2621        assert_eq!(name_reads.load(Ordering::SeqCst), 1);
2622    }
2623
2624    fn app_children(children: &[(&str, &str)]) -> Vec<(String, Option<String>)> {
2625        children
2626            .iter()
2627            .map(|(role, name)| (role.to_string(), Some(name.to_string())))
2628            .collect()
2629    }
2630
2631    #[test]
2632    fn a_window_title_picks_one_of_several_app_windows() {
2633        let chromium = app_children(&[
2634            ("frame", "GitHub - Chromium"),
2635            ("frame", "PlanetScale - Chromium"),
2636            ("menu", ""),
2637        ]);
2638        assert_eq!(
2639            matching_window(&chromium, "PlanetScale - Chromium"),
2640            Some(1)
2641        );
2642        assert_eq!(
2643            matching_window(&chromium, "planetscale - chromium"),
2644            Some(1)
2645        );
2646        // A title substring, as a caller passes it.
2647        assert_eq!(matching_window(&chromium, "PlanetScale"), Some(1));
2648        // Matches every window: keep them all.
2649        assert_eq!(matching_window(&chromium, "Chromium"), None);
2650        assert_eq!(matching_window(&chromium, "Gmail"), None);
2651    }
2652
2653    #[test]
2654    fn an_exact_window_name_wins_over_a_substring_match() {
2655        let windows = app_children(&[("frame", "Docs"), ("frame", "Docs - Settings")]);
2656        assert_eq!(matching_window(&windows, "Docs"), Some(0));
2657    }
2658
2659    #[test]
2660    fn narrowing_keeps_popups_and_dialogs_beside_the_window() {
2661        let chromium = app_children(&[
2662            ("frame", "GitHub - Chromium"),
2663            ("frame", "PlanetScale - Chromium"),
2664            ("window", ""),
2665            ("dialog", "Open File"),
2666            ("menu", ""),
2667        ]);
2668        let chosen = matching_window(&chromium, "PlanetScale").unwrap();
2669        assert_eq!(
2670            kept_beside_window(&chromium, chosen),
2671            [false, true, true, true, true]
2672        );
2673    }
2674
2675    #[test]
2676    fn one_window_is_never_narrowed() {
2677        let single = app_children(&[("frame", "PlanetScale - Chromium"), ("menu", "")]);
2678        assert_eq!(matching_window(&single, "PlanetScale"), None);
2679    }
2680
2681    #[test]
2682    fn split_object_ref_id_separates_bus_name_and_path() {
2683        let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
2684
2685        assert_eq!(name, ":1.42");
2686        assert_eq!(path, "/org/a11y/atspi/accessible/7");
2687    }
2688
2689    #[test]
2690    fn select_action_index_uses_named_action() {
2691        let actions = vec![
2692            atspi::Action {
2693                name: "click".to_string(),
2694                description: "Clicks".to_string(),
2695                keybinding: String::new(),
2696            },
2697            atspi::Action {
2698                name: "show-menu".to_string(),
2699                description: "Shows menu".to_string(),
2700                keybinding: String::new(),
2701            },
2702        ];
2703
2704        assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
2705    }
2706
2707    #[test]
2708    fn cached_action_name_is_revalidated_against_current_actions() {
2709        let mut actions = vec![atspi::Action {
2710            name: "show-menu".into(),
2711            description: "Click".into(),
2712            keybinding: String::new(),
2713        }];
2714        // Neither a stale index nor a matching description may invoke another action.
2715        assert!(select_named_action_index(&actions, "Click").is_err());
2716        actions.push(atspi::Action {
2717            name: "click".into(),
2718            description: String::new(),
2719            keybinding: String::new(),
2720        });
2721        assert_eq!(select_named_action_index(&actions, "Click").unwrap(), 1);
2722    }
2723
2724    #[test]
2725    fn select_action_index_defaults_to_secondary_when_available() {
2726        let actions = vec![
2727            atspi::Action {
2728                name: "click".to_string(),
2729                description: String::new(),
2730                keybinding: String::new(),
2731            },
2732            atspi::Action {
2733                name: "show-menu".to_string(),
2734                description: String::new(),
2735                keybinding: String::new(),
2736            },
2737        ];
2738
2739        assert_eq!(select_action_index(&actions, None).unwrap(), 1);
2740    }
2741
2742    #[test]
2743    fn state_labels_serialize_in_bit_order() {
2744        let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
2745
2746        assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
2747    }
2748
2749    #[test]
2750    fn default_snapshot_limits_cover_deep_gtk4_trees() {
2751        // Nautilus 50 places file-list cells below depth 20 and can expose
2752        // more than 850 raw nodes. Keep the defaults above that known shape.
2753        assert_eq!(snapshot_limits(None, None), (1_000, 32));
2754    }
2755
2756    #[test]
2757    fn app_name_needle_ignores_blank_values_and_normalizes_the_rest() {
2758        assert_eq!(app_name_needle(None), None);
2759        assert_eq!(app_name_needle(Some("")), None);
2760        assert_eq!(app_name_needle(Some("   ")), None);
2761        assert_eq!(
2762            app_name_needle(Some("Calculator")).as_deref(),
2763            Some("calculator")
2764        );
2765        assert_eq!(
2766            app_name_needle(Some(" :1.64/org/a11y/atspi/accessible/root ")).as_deref(),
2767            Some(":1.64/org/a11y/atspi/accessible/root")
2768        );
2769    }
2770
2771    #[test]
2772    fn requested_snapshot_limits_remain_bounded() {
2773        assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
2774        assert_eq!(snapshot_limits(Some(100_000), Some(128)), (10_000, 64));
2775    }
2776
2777    #[test]
2778    fn app_discovery_budget_is_independent_of_requested_tree_size() {
2779        assert_eq!(snapshot_child_read_budgets(1), (256, 2_048, 1));
2780    }
2781
2782    #[test]
2783    fn traversal_attempts_and_queue_share_one_work_budget() {
2784        let mut traversal = BoundedTraversal::new(4);
2785        traversal.enqueue([1]);
2786
2787        assert_eq!(traversal.pop(), Some(1));
2788        traversal.enqueue(2..=10_000);
2789        assert_eq!(traversal.queue, VecDeque::from([2, 3, 4]));
2790
2791        assert_eq!(traversal.pop(), Some(2));
2792        traversal.enqueue(5..=10_000);
2793        assert_eq!(traversal.queue, VecDeque::from([3, 4]));
2794        assert_eq!(traversal.pop(), Some(3));
2795        assert_eq!(traversal.pop(), Some(4));
2796        assert_eq!(traversal.pop(), None);
2797        assert_eq!(traversal.attempted, 4);
2798        assert!(
2799            traversal.truncated(),
2800            "dropped enqueue items must be reported"
2801        );
2802    }
2803
2804    #[test]
2805    fn traversal_that_drains_within_budget_is_not_truncated() {
2806        let mut traversal = BoundedTraversal::new(3);
2807        traversal.enqueue([1, 2, 3]);
2808        assert_eq!(traversal.pop(), Some(1));
2809        assert_eq!(traversal.pop(), Some(2));
2810        assert_eq!(traversal.pop(), Some(3));
2811        assert_eq!(traversal.pop(), None);
2812        assert!(
2813            !traversal.truncated(),
2814            "exactly max_items real nodes is complete"
2815        );
2816    }
2817
2818    #[test]
2819    fn enqueue_at_zero_capacity_marks_the_traversal_truncated() {
2820        let mut traversal = BoundedTraversal::new(2);
2821        traversal.enqueue([1, 2]);
2822        assert_eq!(traversal.pop(), Some(1));
2823        assert_eq!(traversal.pop(), Some(2));
2824        assert!(!traversal.truncated(), "nothing dropped yet");
2825        // Capacity is zero; the offered item is dropped, which is the only
2826        // path that can mark truncation.
2827        traversal.enqueue([3]);
2828        assert!(traversal.queue.is_empty());
2829        assert_eq!(traversal.pop(), None);
2830        assert!(traversal.truncated());
2831    }
2832
2833    #[test]
2834    fn child_count_is_clamped_before_indexed_reads() {
2835        assert_eq!(bounded_child_count(-1, 4), 0);
2836        assert_eq!(bounded_child_count(3, 4), 3);
2837        assert_eq!(bounded_child_count(i32::MAX, 4), 4);
2838    }
2839
2840    #[tokio::test]
2841    async fn indexed_child_reads_consume_attempts_even_when_one_fails() {
2842        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
2843        let mut remaining_attempts = 3;
2844        let batch = fetch_indexed_up_to(100, 10, &mut remaining_attempts, {
2845            let calls = calls.clone();
2846            move |index| {
2847                let calls = calls.clone();
2848                async move {
2849                    calls.lock().unwrap().push(index);
2850                    if index == 1 {
2851                        Err(())
2852                    } else {
2853                        Ok(index)
2854                    }
2855                }
2856            }
2857        })
2858        .await;
2859
2860        assert_eq!(batch.items, vec![0, 2]);
2861        assert_eq!(batch.attempted, 3);
2862        assert!(
2863            batch.incomplete,
2864            "100 reported children, 3 attempted: budget cut must be reported"
2865        );
2866        assert!(!batch.all_failed());
2867        assert_eq!(*calls.lock().unwrap(), vec![0, 1, 2]);
2868        assert_eq!(remaining_attempts, 0);
2869
2870        let no_children = fetch_indexed_up_to(100, 10, &mut remaining_attempts, |_| async {
2871            Ok::<_, ()>(99)
2872        })
2873        .await;
2874        assert!(no_children.items.is_empty());
2875        assert_eq!(no_children.attempted, 0);
2876        assert!(!no_children.all_failed());
2877
2878        let mut failed_attempts = 2;
2879        let all_failed =
2880            fetch_indexed_up_to(2, 2, &mut failed_attempts, |_| async { Err::<i32, _>(()) }).await;
2881        assert!(all_failed.all_failed());
2882    }
2883
2884    fn document() -> FocusedElementSummary {
2885        FocusedElementSummary {
2886            role: "document web".into(),
2887            name: Some("Synthetic editor host".into()),
2888            editable: false,
2889            states: vec!["focused".into()],
2890            is_terminal: false,
2891        }
2892    }
2893
2894    #[test]
2895    fn focused_document_does_not_end_the_search_before_contenteditable() {
2896        assert!(focus_is_container(&document()));
2897        let mut entry = document();
2898        entry.role = "entry".into();
2899        entry.states.push("editable".into());
2900        // Chromium need not implement EditableText for keyboard input.
2901        assert!(!entry.editable);
2902        assert!(!focus_is_container(&entry));
2903    }
2904
2905    #[test]
2906    fn incomplete_descendants_do_not_make_the_document_a_definitive_recipient() {
2907        assert!(matches!(
2908            finish_focus_probe(true, false, Some(document())),
2909            FocusProbe::Incomplete
2910        ));
2911        assert!(matches!(
2912            finish_focus_probe(false, false, Some(document())),
2913            FocusProbe::Found(_)
2914        ));
2915    }
2916
2917    #[test]
2918    fn opt_in_snapshot_budget_reaches_controls_after_embedded_browser_content() {
2919        let (limit, _) = snapshot_limits(Some(4_000), None);
2920        assert_eq!(limit, 4_000);
2921        let mut traversal = BoundedTraversal::new(limit);
2922        traversal.enqueue(0..3_000);
2923        let mut reached_editor = false;
2924        while let Some(node) = traversal.pop() {
2925            reached_editor |= node == 2_500;
2926        }
2927        assert!(reached_editor);
2928        assert!(!traversal.truncated());
2929        let (default, _) = snapshot_limits(None, None);
2930        assert_eq!(default, 1_000);
2931    }
2932
2933    #[test]
2934    fn narrowing_keeps_the_spent_budget_and_forgets_dropped_work() {
2935        let mut traversal = BoundedTraversal::new(4);
2936        traversal.enqueue(0..6);
2937        assert!(traversal.truncated());
2938        assert_eq!(traversal.pop(), Some(0));
2939        traversal.narrow();
2940        assert!(
2941            !traversal.truncated(),
2942            "work dropped outside the subtree no longer counts"
2943        );
2944        assert_eq!(traversal.pop(), None);
2945        traversal.enqueue([10, 11, 12, 13]);
2946        assert_eq!(traversal.pop(), Some(10));
2947        assert_eq!(traversal.pop(), Some(11));
2948        assert_eq!(traversal.pop(), Some(12));
2949        assert_eq!(
2950            traversal.pop(),
2951            None,
2952            "the budget spent before narrowing still counts"
2953        );
2954        assert!(traversal.truncated());
2955    }
2956}