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