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