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