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        proxy_ext::ProxyExt,
7    },
8    CoordType, ObjectRef, ObjectRefOwned, StateSet,
9};
10// Direct dependency (p2p feature off) — see Cargo.toml for why we bypass
11// atspi's "connection" re-export.
12use atspi_connection::AccessibilityConnection;
13use schemars::JsonSchema;
14use serde::Serialize;
15use std::collections::VecDeque;
16use zbus::{
17    fdo::DBusProxy,
18    names::{BusName, UniqueName},
19    zvariant::ObjectPath,
20};
21
22#[derive(Debug, Clone, Serialize, JsonSchema)]
23pub struct AccessibleAppSummary {
24    pub object_ref: String,
25    pub name: Option<String>,
26    pub pid: Option<u32>,
27    pub role: String,
28    pub child_count: i32,
29    pub bounds: Option<Bounds>,
30}
31
32#[derive(Debug, Clone, Serialize, JsonSchema)]
33pub struct AccessibilityNode {
34    pub index: u32,
35    pub parent_index: Option<u32>,
36    pub depth: u32,
37    pub object_ref: String,
38    pub role: String,
39    pub name: Option<String>,
40    pub description: Option<String>,
41    pub child_count: i32,
42    pub bounds: Option<Bounds>,
43    pub states: Vec<String>,
44    pub actions: Vec<AccessibilityAction>,
45    pub value: Option<AccessibilityValue>,
46    pub text: Option<AccessibilityText>,
47    pub supports_editable_text: bool,
48}
49
50#[derive(Debug, Clone, Serialize, JsonSchema)]
51pub struct Bounds {
52    pub x: i32,
53    pub y: i32,
54    pub width: i32,
55    pub height: i32,
56}
57
58#[derive(Debug, Clone, Serialize, JsonSchema)]
59pub struct AccessibilityAction {
60    pub index: i32,
61    pub name: String,
62    pub description: String,
63    pub keybinding: String,
64}
65
66#[derive(Debug, Clone, Serialize, JsonSchema)]
67pub struct AccessibilityValue {
68    pub current: f64,
69    pub minimum: f64,
70    pub maximum: f64,
71    pub minimum_increment: f64,
72    pub text: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, JsonSchema)]
76pub struct AccessibilityText {
77    pub character_count: i32,
78    pub caret_offset: Option<i32>,
79    pub content: Option<String>,
80    pub truncated: bool,
81    pub selections: Vec<AccessibilityTextSelection>,
82}
83
84#[derive(Debug, Clone, Serialize, JsonSchema)]
85pub struct AccessibilityTextSelection {
86    pub start_offset: i32,
87    pub end_offset: i32,
88}
89
90#[derive(Debug, Clone)]
91pub struct ActionInvocation {
92    pub action_index: i32,
93    pub action_name: Option<String>,
94    pub ok: bool,
95}
96
97#[derive(Debug, Clone)]
98pub enum ValueSetInvocation {
99    Numeric { value: f64 },
100    EditableText,
101}
102
103const MAX_TEXT_READBACK_CHARS: i32 = 4096;
104const MAX_TEXT_SELECTIONS: i32 = 8;
105const DEFAULT_SNAPSHOT_MAX_NODES: usize = 1_000;
106const HARD_SNAPSHOT_MAX_NODES: usize = 2_000;
107const DEFAULT_SNAPSHOT_MAX_DEPTH: u32 = 32;
108const HARD_SNAPSHOT_MAX_DEPTH: u32 = 64;
109
110pub(crate) fn snapshot_limits(
111    requested_max_nodes: Option<usize>,
112    requested_max_depth: Option<u32>,
113) -> (usize, u32) {
114    (
115        requested_max_nodes
116            .unwrap_or(DEFAULT_SNAPSHOT_MAX_NODES)
117            .clamp(1, HARD_SNAPSHOT_MAX_NODES),
118        requested_max_depth
119            .unwrap_or(DEFAULT_SNAPSHOT_MAX_DEPTH)
120            .min(HARD_SNAPSHOT_MAX_DEPTH),
121    )
122}
123
124pub async fn list_accessible_apps(limit: usize) -> Result<Vec<AccessibleAppSummary>> {
125    let conn = connect().await?;
126    let roots = registry_children(&conn).await?;
127    let dbus = DBusProxy::new(conn.connection()).await.ok();
128    let mut apps = Vec::new();
129
130    for object_ref in roots.into_iter().take(limit) {
131        if let Ok(proxy) = open_accessible(&conn, &object_ref).await {
132            apps.push(read_app_summary(&proxy, &object_ref, dbus.as_ref()).await);
133        }
134    }
135
136    Ok(apps)
137}
138
139pub async fn snapshot_tree(
140    app_name_or_bundle_identifier: Option<&str>,
141    target_pid: Option<u32>,
142    max_nodes: usize,
143    max_depth: u32,
144) -> Result<Vec<AccessibilityNode>> {
145    let conn = connect().await?;
146    let roots = registry_children(&conn).await?;
147    let selected_roots =
148        select_roots(&conn, roots, app_name_or_bundle_identifier, target_pid).await;
149    let mut nodes = Vec::new();
150    let mut queue = VecDeque::new();
151
152    for object_ref in selected_roots {
153        queue.push_back((object_ref, 0_u32, None));
154    }
155
156    while let Some((object_ref, depth, parent_index)) = queue.pop_front() {
157        if nodes.len() >= max_nodes {
158            break;
159        }
160
161        let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
162            continue;
163        };
164        let index = nodes.len() as u32;
165        let child_refs = if depth < max_depth {
166            proxy.get_children().await.unwrap_or_default()
167        } else {
168            Vec::new()
169        };
170
171        nodes.push(read_node(&proxy, &object_ref, index, parent_index, depth).await);
172
173        for child in child_refs {
174            queue.push_back((child, depth + 1, Some(index)));
175        }
176    }
177
178    Ok(nodes)
179}
180
181/// Compact description of the AT-SPI element that currently holds keyboard
182/// focus, used as post-input feedback for type_text/press_key.
183#[derive(Debug, Clone, Serialize, JsonSchema)]
184pub struct FocusedElementSummary {
185    pub role: String,
186    pub name: Option<String>,
187    pub editable: bool,
188    pub states: Vec<String>,
189}
190
191const FOCUS_PROBE_MAX_NODES: usize = 400;
192const FOCUS_PROBE_MAX_DEPTH: u32 = 16;
193
194/// Find the element with the `focused` state inside the target app (by pid) or
195/// across all apps. Best-effort and bounded: returns Ok(None) when no focused
196/// element is reachable through AT-SPI (common for apps without accessibility
197/// support, e.g. Electron without --force-renderer-accessibility).
198pub async fn focused_element_summary(
199    target_pid: Option<u32>,
200) -> Result<Option<FocusedElementSummary>> {
201    let conn = connect().await?;
202    let roots = registry_children(&conn).await?;
203    let selected_roots = select_roots(&conn, roots, None, target_pid).await;
204    let mut visited = 0_usize;
205    let mut queue = VecDeque::new();
206
207    for object_ref in selected_roots {
208        queue.push_back((object_ref, 0_u32));
209    }
210
211    while let Some((object_ref, depth)) = queue.pop_front() {
212        if visited >= FOCUS_PROBE_MAX_NODES {
213            break;
214        }
215        visited += 1;
216
217        let Ok(proxy) = open_accessible(&conn, &object_ref).await else {
218            continue;
219        };
220        let Ok(state) = proxy.get_state().await else {
221            continue;
222        };
223        if state.contains(atspi::State::Focused) {
224            let proxies = proxy.proxies().await.ok();
225            return Ok(Some(FocusedElementSummary {
226                role: role_name(&proxy).await,
227                name: optional_string(proxy.name().await.ok()),
228                editable: supports_editable_text(proxies.as_ref()).await,
229                states: state_labels(state),
230            }));
231        }
232        if depth < FOCUS_PROBE_MAX_DEPTH {
233            for child in proxy.get_children().await.unwrap_or_default() {
234                queue.push_back((child, depth + 1));
235            }
236        }
237    }
238
239    Ok(None)
240}
241
242pub async fn perform_action(
243    object_ref_id: &str,
244    requested_action: Option<&str>,
245) -> Result<ActionInvocation> {
246    let conn = connect().await?;
247    let object_ref = object_ref_from_id(object_ref_id)?;
248    let proxy = open_accessible(&conn, &object_ref)
249        .await
250        .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
251    let action = proxy
252        .proxies()
253        .await?
254        .action()
255        .await
256        .context("element does not expose the AT-SPI Action interface")?;
257    let actions = action.get_actions().await.unwrap_or_default();
258    let action_index = select_action_index(&actions, requested_action)?;
259    let action_name = actions
260        .get(action_index as usize)
261        .map(|action| action.name.clone());
262    let ok = action
263        .do_action(action_index)
264        .await
265        .with_context(|| format!("failed to invoke AT-SPI action {action_index}"))?;
266
267    Ok(ActionInvocation {
268        action_index,
269        action_name,
270        ok,
271    })
272}
273
274pub async fn set_element_value(object_ref_id: &str, value: &str) -> Result<ValueSetInvocation> {
275    let conn = connect().await?;
276    let object_ref = object_ref_from_id(object_ref_id)?;
277    let proxy = open_accessible(&conn, &object_ref)
278        .await
279        .with_context(|| format!("failed to open AT-SPI object {object_ref_id}"))?;
280    let proxies = proxy.proxies().await?;
281
282    if let Ok(numeric_value) = value.parse::<f64>() {
283        if let Ok(value_proxy) = proxies.value().await {
284            value_proxy
285                .set_current_value(numeric_value)
286                .await
287                .with_context(|| {
288                    format!("failed to set AT-SPI numeric value to {numeric_value}")
289                })?;
290            return Ok(ValueSetInvocation::Numeric {
291                value: numeric_value,
292            });
293        }
294    }
295
296    if let Ok(editable_text) = proxies.editable_text().await {
297        let ok = editable_text
298            .set_text_contents(value)
299            .await
300            .context("failed to set AT-SPI editable text contents")?;
301        if ok {
302            return Ok(ValueSetInvocation::EditableText);
303        }
304        return Err(anyhow!("AT-SPI EditableText rejected the new contents"));
305    }
306
307    if value.parse::<f64>().is_err() && proxies.value().await.is_ok() {
308        return Err(anyhow!(
309            "element exposes the AT-SPI Value interface, but the requested value is not numeric"
310        ));
311    }
312
313    Err(anyhow!(
314        "element does not expose AT-SPI Value or EditableText interfaces"
315    ))
316}
317
318async fn connect() -> Result<AccessibilityConnection> {
319    hydrate_session_bus_env();
320    AccessibilityConnection::new()
321        .await
322        .context("failed to connect to AT-SPI bus")
323}
324
325/// Open an `AccessibleProxy` for an object on the a11y bus.
326///
327/// We deliberately avoid `AccessibilityConnection::object_as_accessible` (the
328/// `P2P` trait). For apps that advertise a peer-to-peer bus address it routes
329/// reads over that socket, but for apps that don't (notably GTK4 apps such as
330/// Nautilus / Text Editor / baobab, which don't implement the legacy
331/// `GetApplicationBusAddress`) it falls back to a proxy built with only a path
332/// and *no destination*. On the shared a11y bus that proxy can't address the
333/// app and every call fails with `ServiceUnknown`, which surfaces as an empty
334/// tree (`role: "unknown"`, `child_count: 0`). `as_accessible_proxy` always
335/// pins the destination to the object's bus name, so it works for every app
336/// regardless of P2P support. See issue #31.
337async fn open_accessible<'r>(
338    conn: &AccessibilityConnection,
339    object_ref: &'r ObjectRefOwned,
340) -> Result<AccessibleProxy<'r>, atspi::AtspiError> {
341    object_ref.as_accessible_proxy(conn.connection()).await
342}
343
344async fn registry_children(conn: &AccessibilityConnection) -> Result<Vec<ObjectRefOwned>> {
345    let root = conn
346        .root_accessible_on_registry()
347        .await
348        .context("failed to open AT-SPI registry root")?;
349    root.get_children()
350        .await
351        .context("failed to read AT-SPI registry children")
352}
353
354async fn select_roots(
355    conn: &AccessibilityConnection,
356    roots: Vec<ObjectRefOwned>,
357    app_name_or_bundle_identifier: Option<&str>,
358    target_pid: Option<u32>,
359) -> Vec<ObjectRefOwned> {
360    let needle = app_name_or_bundle_identifier
361        .map(str::trim)
362        .filter(|value| !value.is_empty())
363        .map(|value| value.to_ascii_lowercase());
364    let dbus = DBusProxy::new(conn.connection()).await.ok();
365    let mut remaining = roots;
366
367    if let Some(target_pid) = target_pid {
368        let mut pid_and_filter_matches = Vec::new();
369        let mut pid_matches = Vec::new();
370        let mut non_pid_matches = Vec::new();
371
372        for object_ref in remaining {
373            if object_ref_pid(dbus.as_ref(), &object_ref).await == Some(target_pid) {
374                if let Some(needle) = needle.as_deref() {
375                    if root_matches(conn, &object_ref, needle).await {
376                        pid_and_filter_matches.push(object_ref);
377                    } else {
378                        pid_matches.push(object_ref);
379                    }
380                } else {
381                    pid_matches.push(object_ref);
382                }
383            } else {
384                non_pid_matches.push(object_ref);
385            }
386        }
387
388        if !pid_and_filter_matches.is_empty() {
389            return pid_and_filter_matches;
390        }
391        if !pid_matches.is_empty() {
392            return pid_matches;
393        }
394
395        remaining = non_pid_matches;
396    }
397
398    let Some(needle) = needle.as_deref() else {
399        return remaining;
400    };
401
402    let mut selected = Vec::new();
403    for object_ref in remaining {
404        if root_matches(conn, &object_ref, needle).await {
405            selected.push(object_ref);
406        }
407    }
408
409    selected
410}
411
412async fn root_matches(
413    conn: &AccessibilityConnection,
414    object_ref: &ObjectRefOwned,
415    needle: &str,
416) -> bool {
417    let Ok(proxy) = open_accessible(conn, object_ref).await else {
418        return object_ref_id(object_ref)
419            .to_ascii_lowercase()
420            .contains(needle);
421    };
422
423    if proxy_matches(&proxy, object_ref, needle).await {
424        return true;
425    }
426
427    let children = proxy.get_children().await.unwrap_or_default();
428    for child_ref in children.into_iter().take(8) {
429        let Ok(child_proxy) = open_accessible(conn, &child_ref).await else {
430            continue;
431        };
432        if proxy_matches(&child_proxy, &child_ref, needle).await {
433            return true;
434        }
435    }
436
437    false
438}
439
440async fn proxy_matches(
441    proxy: &AccessibleProxy<'_>,
442    object_ref: &ObjectRefOwned,
443    needle: &str,
444) -> bool {
445    let name = proxy.name().await.unwrap_or_default();
446    let role = proxy.get_role_name().await.unwrap_or_default();
447    format!("{} {} {}", object_ref_id(object_ref), name, role)
448        .to_ascii_lowercase()
449        .contains(needle)
450}
451
452async fn read_app_summary(
453    proxy: &AccessibleProxy<'_>,
454    object_ref: &ObjectRefOwned,
455    dbus: Option<&DBusProxy<'_>>,
456) -> AccessibleAppSummary {
457    AccessibleAppSummary {
458        object_ref: object_ref_id(object_ref),
459        name: optional_string(proxy.name().await.ok()),
460        pid: object_ref_pid(dbus, object_ref).await,
461        role: role_name(proxy).await,
462        child_count: proxy.child_count().await.unwrap_or_default(),
463        bounds: bounds(proxy).await,
464    }
465}
466
467async fn read_node(
468    proxy: &AccessibleProxy<'_>,
469    object_ref: &ObjectRefOwned,
470    index: u32,
471    parent_index: Option<u32>,
472    depth: u32,
473) -> AccessibilityNode {
474    let proxies = proxy.proxies().await.ok();
475
476    AccessibilityNode {
477        index,
478        parent_index,
479        depth,
480        object_ref: object_ref_id(object_ref),
481        role: role_name(proxy).await,
482        name: optional_string(proxy.name().await.ok()),
483        description: optional_string(proxy.description().await.ok()),
484        child_count: proxy.child_count().await.unwrap_or_default(),
485        bounds: bounds_from_proxies(proxies.as_ref(), proxy).await,
486        states: states_from_proxy(proxy).await,
487        actions: actions_from_proxies(proxies.as_ref()).await,
488        value: value_from_proxies(proxies.as_ref()).await,
489        text: text_from_proxies(proxies.as_ref()).await,
490        supports_editable_text: supports_editable_text(proxies.as_ref()).await,
491    }
492}
493
494async fn role_name(proxy: &AccessibleProxy<'_>) -> String {
495    if let Ok(role) = proxy.get_role_name().await {
496        if !role.trim().is_empty() {
497            return role;
498        }
499    }
500    proxy
501        .get_role()
502        .await
503        .map(|role| format!("{role:?}"))
504        .unwrap_or_else(|_| "unknown".to_string())
505}
506
507async fn bounds(proxy: &AccessibleProxy<'_>) -> Option<Bounds> {
508    bounds_from_proxies(proxy.proxies().await.ok().as_ref(), proxy).await
509}
510
511async fn object_ref_pid(dbus: Option<&DBusProxy<'_>>, object_ref: &ObjectRefOwned) -> Option<u32> {
512    let dbus = dbus?;
513    let bus_name = BusName::try_from(object_ref.name_as_str()?.to_string()).ok()?;
514    dbus.get_connection_unix_process_id(bus_name).await.ok()
515}
516
517async fn bounds_from_proxies(
518    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
519    proxy: &AccessibleProxy<'_>,
520) -> Option<Bounds> {
521    let owned_proxies;
522    let proxies = if let Some(proxies) = proxies {
523        proxies
524    } else {
525        owned_proxies = proxy.proxies().await.ok()?;
526        &owned_proxies
527    };
528    let component = proxies.component().await.ok()?;
529    let (x, y, width, height) = component.get_extents(CoordType::Screen).await.ok()?;
530    normalize_bounds(Bounds {
531        x,
532        y,
533        width,
534        height,
535    })
536}
537
538fn normalize_bounds(bounds: Bounds) -> Option<Bounds> {
539    if bounds.width <= 0 || bounds.height <= 0 {
540        return None;
541    }
542    if bounds.x <= i32::MIN / 2 || bounds.y <= i32::MIN / 2 {
543        return None;
544    }
545    Some(bounds)
546}
547
548async fn actions_from_proxies(
549    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
550) -> Vec<AccessibilityAction> {
551    let Some(proxies) = proxies else {
552        return Vec::new();
553    };
554    let Ok(action_proxy) = proxies.action().await else {
555        return Vec::new();
556    };
557
558    action_proxy
559        .get_actions()
560        .await
561        .unwrap_or_default()
562        .into_iter()
563        .enumerate()
564        .map(|(index, action)| AccessibilityAction {
565            index: index as i32,
566            name: action.name,
567            description: action.description,
568            keybinding: action.keybinding,
569        })
570        .collect()
571}
572
573async fn states_from_proxy(proxy: &AccessibleProxy<'_>) -> Vec<String> {
574    proxy
575        .get_state()
576        .await
577        .map(state_labels)
578        .unwrap_or_default()
579}
580
581async fn value_from_proxies(
582    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
583) -> Option<AccessibilityValue> {
584    let value = proxies?.value().await.ok()?;
585    Some(AccessibilityValue {
586        current: value.current_value().await.ok()?,
587        minimum: value.minimum_value().await.ok()?,
588        maximum: value.maximum_value().await.ok()?,
589        minimum_increment: value.minimum_increment().await.ok()?,
590        text: optional_string(value.text().await.ok()),
591    })
592}
593
594async fn text_from_proxies(
595    proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>,
596) -> Option<AccessibilityText> {
597    let text = proxies?.text().await.ok()?;
598    let character_count = text.character_count().await.ok()?.max(0);
599    let caret_offset = text.caret_offset().await.ok();
600    let capped_count = character_count.min(MAX_TEXT_READBACK_CHARS);
601    let content = if capped_count > 0 {
602        optional_string(text.get_text(0, capped_count).await.ok())
603    } else {
604        None
605    };
606    let selection_count = text
607        .get_nselections()
608        .await
609        .unwrap_or_default()
610        .clamp(0, MAX_TEXT_SELECTIONS);
611    let mut selections = Vec::new();
612    for index in 0..selection_count {
613        if let Ok((start_offset, end_offset)) = text.get_selection(index).await {
614            selections.push(AccessibilityTextSelection {
615                start_offset,
616                end_offset,
617            });
618        }
619    }
620
621    Some(AccessibilityText {
622        character_count,
623        caret_offset,
624        content,
625        truncated: character_count > MAX_TEXT_READBACK_CHARS,
626        selections,
627    })
628}
629
630async fn supports_editable_text(proxies: Option<&atspi::proxy::proxy_ext::Proxies<'_>>) -> bool {
631    let Some(proxies) = proxies else {
632        return false;
633    };
634    proxies.editable_text().await.is_ok()
635}
636
637fn state_labels(state_set: StateSet) -> Vec<String> {
638    state_set.iter().map(|state| state.to_string()).collect()
639}
640
641fn select_action_index(actions: &[atspi::Action], requested_action: Option<&str>) -> Result<i32> {
642    if actions.is_empty() {
643        return Err(anyhow!("element exposes no AT-SPI actions"));
644    }
645
646    if let Some(requested_action) = requested_action
647        .map(str::trim)
648        .filter(|value| !value.is_empty())
649    {
650        let requested_action = requested_action.to_ascii_lowercase();
651        if let Some((index, _)) = actions.iter().enumerate().find(|(_, action)| {
652            action.name.to_ascii_lowercase() == requested_action
653                || action.description.to_ascii_lowercase() == requested_action
654        }) {
655            return Ok(index as i32);
656        }
657
658        if let Ok(index) = requested_action.parse::<usize>() {
659            if index < actions.len() {
660                return Ok(index as i32);
661            }
662        }
663
664        return Err(anyhow!(
665            "requested AT-SPI action was not found; available actions: {}",
666            actions
667                .iter()
668                .map(|action| action.name.as_str())
669                .collect::<Vec<_>>()
670                .join(", ")
671        ));
672    }
673
674    Ok(if actions.len() > 1 { 1 } else { 0 })
675}
676
677fn optional_string(value: Option<String>) -> Option<String> {
678    value
679        .map(|value| value.trim().to_string())
680        .filter(|value| !value.is_empty())
681}
682
683fn object_ref_from_id(object_ref_id: &str) -> Result<ObjectRefOwned> {
684    let (name, path) = split_object_ref_id(object_ref_id)?;
685    let name = UniqueName::try_from(name.to_string())
686        .with_context(|| format!("invalid AT-SPI bus name in object ref {object_ref_id}"))?;
687    let path = ObjectPath::try_from(path.to_string())
688        .with_context(|| format!("invalid AT-SPI object path in object ref {object_ref_id}"))?;
689    Ok(ObjectRef::new_owned(name, path))
690}
691
692fn split_object_ref_id(object_ref_id: &str) -> Result<(&str, &str)> {
693    let Some(path_start) = object_ref_id.find('/') else {
694        return Err(anyhow!(
695            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
696        ));
697    };
698    let (name, path) = object_ref_id.split_at(path_start);
699    if name.is_empty() || path.is_empty() {
700        return Err(anyhow!(
701            "invalid AT-SPI object ref '{object_ref_id}'; expected ':bus/path'"
702        ));
703    }
704    Ok((name, path))
705}
706
707fn object_ref_id(object_ref: &ObjectRefOwned) -> String {
708    format!(
709        "{}{}",
710        object_ref.name_as_str().unwrap_or(""),
711        object_ref.path_as_str()
712    )
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718
719    #[test]
720    fn split_object_ref_id_separates_bus_name_and_path() {
721        let (name, path) = split_object_ref_id(":1.42/org/a11y/atspi/accessible/7").unwrap();
722
723        assert_eq!(name, ":1.42");
724        assert_eq!(path, "/org/a11y/atspi/accessible/7");
725    }
726
727    #[test]
728    fn select_action_index_uses_named_action() {
729        let actions = vec![
730            atspi::Action {
731                name: "click".to_string(),
732                description: "Clicks".to_string(),
733                keybinding: String::new(),
734            },
735            atspi::Action {
736                name: "show-menu".to_string(),
737                description: "Shows menu".to_string(),
738                keybinding: String::new(),
739            },
740        ];
741
742        assert_eq!(select_action_index(&actions, Some("show-menu")).unwrap(), 1);
743    }
744
745    #[test]
746    fn select_action_index_defaults_to_secondary_when_available() {
747        let actions = vec![
748            atspi::Action {
749                name: "click".to_string(),
750                description: String::new(),
751                keybinding: String::new(),
752            },
753            atspi::Action {
754                name: "show-menu".to_string(),
755                description: String::new(),
756                keybinding: String::new(),
757            },
758        ];
759
760        assert_eq!(select_action_index(&actions, None).unwrap(), 1);
761    }
762
763    #[test]
764    fn state_labels_serialize_in_bit_order() {
765        let labels = state_labels(StateSet::new(atspi::State::Focused | atspi::State::Checked));
766
767        assert_eq!(labels, vec!["checked".to_string(), "focused".to_string()]);
768    }
769
770    #[test]
771    fn default_snapshot_limits_cover_deep_gtk4_trees() {
772        // Nautilus 50 places file-list cells below depth 20 and can expose
773        // more than 850 raw nodes. Keep the defaults above that known shape.
774        assert_eq!(snapshot_limits(None, None), (1_000, 32));
775    }
776
777    #[test]
778    fn requested_snapshot_limits_remain_bounded() {
779        assert_eq!(snapshot_limits(Some(0), Some(0)), (1, 0));
780        assert_eq!(snapshot_limits(Some(10_000), Some(128)), (2_000, 64));
781    }
782}