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