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