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