use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::atspi as atspi_client;
use crate::atspi::ElementInfo;
use crate::error::{Error, Result};
use crate::session::Session;
macro_rules! state_method_pair {
(
$(#[$is_meta:meta])*
($state:literal, $title:literal) => $is_fn:ident, $wait_fn:ident
) => {
$(#[$is_meta])*
pub async fn $is_fn(&self) -> Result<bool> {
self.has_state($state).await
}
#[doc = concat!(
"Poll until the element has the AT-SPI `State::",
$title,
"` state."
)]
pub async fn $wait_fn(&self) -> Result<()> {
self.wait_until(|hits| single_has_state(hits, $state))
.await
.map(|_| ())
}
};
}
const INITIAL_POLL_DELAY: Duration = Duration::from_millis(50);
const MAX_POLL_DELAY: Duration = Duration::from_millis(500);
const DOUBLE_CLICK_GAP: Duration = Duration::from_millis(40);
const DRAG_INTERMEDIATE_STEPS: u32 = 3;
#[derive(Debug, Clone, Copy)]
pub enum SelectBy<'a> {
Label(&'a str),
Index(usize),
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum FillMode {
#[default]
CaretNav,
SelectAll,
}
#[derive(Clone)]
pub struct Locator {
session: Arc<Session>,
xpath: String,
timeout: Option<Duration>,
}
impl Locator {
pub(crate) fn new(session: Arc<Session>, xpath: String) -> Self {
Self {
session,
xpath,
timeout: None,
}
}
pub fn xpath(&self) -> &str {
&self.xpath
}
pub fn with_timeout(&self, timeout: Duration) -> Locator {
Locator {
session: self.session.clone(),
xpath: self.xpath.clone(),
timeout: Some(timeout),
}
}
pub fn locate(&self, sub: &str) -> Locator {
let trimmed = sub.trim();
let new_xpath = if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("({})//{}", self.xpath, trimmed)
};
self.with_xpath(new_xpath)
}
pub fn nth(&self, n: usize) -> Locator {
self.with_xpath(format!("({})[{}]", self.xpath, n + 1))
}
pub fn first(&self) -> Locator {
self.nth(0)
}
pub fn last(&self) -> Locator {
self.with_xpath(format!("({})[last()]", self.xpath))
}
pub fn parent(&self) -> Locator {
self.with_xpath(format!("({})/..", self.xpath))
}
fn with_xpath(&self, xpath: String) -> Locator {
Locator {
session: self.session.clone(),
xpath,
timeout: self.timeout,
}
}
pub async fn count(&self) -> Result<usize> {
Ok(self.resolve_all_once().await?.len())
}
pub async fn all(&self) -> Result<Vec<Locator>> {
let n = self.count().await?;
Ok((0..n).map(|i| self.nth(i)).collect())
}
pub async fn inspect_all(&self) -> Result<Vec<ElementInfo>> {
let a11y = self.a11y()?;
let xml =
atspi_client::snapshot_tree(a11y, &self.session.app_bus_name, &self.session.app_path)
.await?;
atspi_client::evaluate_xpath_detailed(&xml, &self.xpath)
}
pub async fn name(&self) -> Result<Option<String>> {
Ok(self.wait_for_existing().await?.name)
}
pub async fn role(&self) -> Result<String> {
let info = self.wait_for_existing().await?;
Ok(info.role_raw.unwrap_or(info.role))
}
pub async fn attribute(&self, key: &str) -> Result<Option<String>> {
Ok(self.wait_for_existing().await?.attributes.remove(key))
}
pub async fn attributes(&self) -> Result<HashMap<String, String>> {
Ok(self.wait_for_existing().await?.attributes)
}
pub async fn is_showing(&self) -> Result<bool> {
self.has_state("showing").await
}
pub async fn is_enabled(&self) -> Result<bool> {
let info = self.wait_for_existing().await?;
Ok(is_enabled_in(&info.states))
}
state_method_pair! {
("checked", "Checked") => is_checked, wait_for_checked
}
state_method_pair! {
("focused", "Focused") => is_focused, wait_for_focused
}
state_method_pair! {
("expanded", "Expanded") => is_expanded, wait_for_expanded
}
state_method_pair! {
("editable", "Editable") => is_editable, wait_for_editable
}
state_method_pair! {
("selected", "Selected") => is_selected, wait_for_selected
}
state_method_pair! {
("pressed", "Pressed") => is_pressed, wait_for_pressed
}
state_method_pair! {
("modal", "Modal") => is_modal, wait_for_modal
}
pub async fn bounds(&self) -> Result<crate::atspi::Rect> {
let info = self.wait_for_existing().await?;
info.bounds.ok_or_else(|| {
Error::atspi(format!(
"no bounds available for {} — element doesn't implement Component or isn't laid out",
self.xpath
))
})
}
pub async fn text(&self) -> Result<String> {
let info = self.wait_for_existing().await?;
let a11y = self.a11y()?;
let (bus, path) = info.ref_;
atspi_client::read_text_on(a11y, &self.xpath, &bus, &path).await
}
pub async fn click(&self) -> Result<()> {
let info = self.wait_for_actionable().await?;
let (bus, path) = info.ref_;
let a11y = self.a11y()?;
atspi_client::do_action_on(a11y, &self.xpath, &bus, &path).await
}
pub async fn set_text(&self, text: &str) -> Result<()> {
let info = self.wait_for_actionable().await?;
let (bus, path) = info.ref_;
let a11y = self.a11y()?;
atspi_client::set_text_on(a11y, &self.xpath, &bus, &path, text).await
}
pub async fn fill(&self, text: &str) -> Result<()> {
self.fill_with_opts(text, FillMode::default()).await
}
pub async fn fill_with_opts(&self, text: &str, mode: FillMode) -> Result<()> {
let info = self.wait_for_focusable().await?;
let (bus, path) = info.ref_.clone();
let a11y = self.a11y()?;
match atspi_client::try_grab_focus_on(a11y, &self.xpath, &bus, &path).await? {
atspi_client::FocusOutcome::Granted => {}
atspi_client::FocusOutcome::Rejected => {
return Err(Error::atspi(format!(
"grab_focus returned false on {bus}{path} — element not focusable"
)));
}
atspi_client::FocusOutcome::NotSupported => {
let bounds = info.bounds.ok_or_else(|| {
Error::atspi(format!(
"fill: target {} does not expose Component::grab_focus and has no \
bounds to fall back on a pointer click. Pre-focus the widget \
(pointer click, Tab, app-level grab_focus) and use \
fill_assume_focused.",
self.xpath
))
})?;
tracing::debug!(
xpath = %self.xpath, %bus, %path,
cx = bounds.center_x(), cy = bounds.center_y(),
"fill: Component::grab_focus not supported; falling back to pointer click"
);
self.session
.pointer_motion_absolute(bounds.center_x() as f64, bounds.center_y() as f64)
.await?;
self.session
.pointer_button(crate::backend::PointerButton::Left)
.await?;
}
}
self.clear_and_type(text, mode).await
}
pub async fn fill_assume_focused(&self, text: &str, mode: FillMode) -> Result<()> {
self.wait_for_actionable().await?;
self.clear_and_type(text, mode).await
}
async fn clear_and_type(&self, text: &str, mode: FillMode) -> Result<()> {
match mode {
FillMode::CaretNav => {
self.session.press_chord("Ctrl+Home").await?;
self.session.press_chord("Ctrl+Shift+End").await?;
}
FillMode::SelectAll => {
self.session.press_chord("Ctrl+A").await?;
}
}
let delete =
crate::keysym::key_name_to_keysym("delete").expect("'delete' is a known key name");
self.session.press_keysym(delete).await?;
self.session.type_text(text).await?;
Ok(())
}
pub async fn select_option(&self, by: SelectBy<'_>) -> Result<()> {
let info = self.wait_for_actionable().await?;
let (bus, path) = info.ref_.clone();
let a11y = self.a11y()?;
let index = match by {
SelectBy::Index(i) => i,
SelectBy::Label(label) => {
let xml = self.snapshot().await?;
let children_xpath = format!("({})/*", self.xpath);
let children = atspi_client::evaluate_xpath_detailed(&xml, &children_xpath)?;
child_index_for_label(&children, label, &self.xpath)?
}
};
let index_i32 = i32::try_from(index).map_err(|_| {
Error::atspi(format!(
"select_option: index {index} too large to fit AT-SPI's i32 child index"
))
})?;
atspi_client::select_child_on(a11y, &self.xpath, &bus, &path, index_i32).await
}
pub async fn focus(&self) -> Result<()> {
let info = self.wait_for_focusable().await?;
let (bus, path) = info.ref_;
let a11y = self.a11y()?;
atspi_client::grab_focus_on(a11y, &self.xpath, &bus, &path).await
}
pub async fn scroll_into_view(&self) -> Result<()> {
const MAX_WHEEL_TICKS: i32 = 20;
const POST_SCROLL_SETTLE: Duration = Duration::from_millis(80);
let info = self.wait_for_existing().await?;
let Some(elem_bounds) = info.bounds else {
return Err(Error::atspi(format!(
"no bounds available for {} — can't scroll without Component extents",
self.xpath
)));
};
let Some(scrollable) = self.find_scrollable_ancestor().await? else {
return Err(Error::atspi(format!(
"no scrollable ancestor for {} — element isn't inside a ScrollPane/Viewport",
self.xpath
)));
};
let Some(scroll_bounds) = scrollable.bounds else {
return Err(Error::atspi(format!(
"scrollable ancestor for {} has no bounds — toolkit doesn't expose Component on it",
self.xpath
)));
};
tracing::debug!(
xpath = %self.xpath,
?elem_bounds,
?scroll_bounds,
scrollable_role = %scrollable.role,
"scroll_into_view: resolved target and scrollable ancestor",
);
if elem_bounds.is_inside(&scroll_bounds) {
tracing::debug!(xpath = %self.xpath, "scroll_into_view: already in viewport");
return Ok(());
}
let a11y = self.a11y()?;
let (bus, path) = info.ref_.clone();
for st in [
atspi::ScrollType::Anywhere,
atspi::ScrollType::TopLeft,
atspi::ScrollType::TopEdge,
] {
if atspi_client::scroll_to_on(a11y, &bus, &path, st)
.await
.unwrap_or(false)
{
break;
}
}
let _ = atspi_client::scroll_to_point_on(
a11y,
&bus,
&path,
atspi::CoordType::Window,
scroll_bounds.x,
scroll_bounds.y,
)
.await;
tokio::time::sleep(POST_SCROLL_SETTLE).await;
if self.is_in_viewport(&scrollable).await? {
return Ok(());
}
if info.states.iter().any(|s| s == "focusable") {
match atspi_client::grab_focus_on(a11y, &self.xpath, &bus, &path).await {
Ok(()) => {
tokio::time::sleep(POST_SCROLL_SETTLE).await;
if self.is_in_viewport(&scrollable).await? {
return Ok(());
}
}
Err(e) => {
tracing::debug!(error = %e, "scroll_into_view: grab_focus fallback failed")
}
}
}
self.session
.pointer_motion_relative(-10_000.0, -10_000.0)
.await?;
self.session
.pointer_motion_relative(
scroll_bounds.center_x() as f64,
scroll_bounds.center_y() as f64,
)
.await?;
for _ in 0..MAX_WHEEL_TICKS {
let direction = wheel_direction(&elem_bounds, &scroll_bounds);
if direction == 0 {
break;
}
self.session
.pointer_axis_discrete(crate::backend::PointerAxis::Vertical, direction)
.await?;
tokio::time::sleep(POST_SCROLL_SETTLE).await;
match self.resolve_once_info().await {
Ok(fresh) => {
if let Some(b) = fresh.bounds {
if b.is_inside(&scroll_bounds) {
return Ok(());
}
}
}
Err(Error::ElementNotFound { .. }) => return Ok(()),
Err(e) => return Err(e),
}
}
Err(Error::atspi(format!(
"scroll_into_view exhausted {MAX_WHEEL_TICKS} wheel ticks for {} — toolkit \
likely ignored synthesized axis events",
self.xpath
)))
}
pub async fn hover(&self) -> Result<()> {
let (x, y) = self.wait_and_center().await?;
self.session.pointer_motion_absolute(x, y).await
}
pub async fn double_click(&self) -> Result<()> {
let (x, y) = self.wait_and_center().await?;
self.session.pointer_motion_absolute(x, y).await?;
self.session
.pointer_button(crate::backend::PointerButton::Left)
.await?;
tokio::time::sleep(DOUBLE_CLICK_GAP).await;
self.session
.pointer_button(crate::backend::PointerButton::Left)
.await
}
pub async fn right_click(&self) -> Result<()> {
let (x, y) = self.wait_and_center().await?;
self.session.pointer_motion_absolute(x, y).await?;
self.session
.pointer_button(crate::backend::PointerButton::Right)
.await
}
pub async fn drag_to(&self, target: &Locator) -> Result<()> {
let (sx, sy) = self.wait_and_center().await?;
let (tx, ty) = target.wait_and_center().await?;
self.session.pointer_motion_absolute(sx, sy).await?;
self.session
.pointer_button_down(crate::backend::PointerButton::Left)
.await?;
let result = async {
for i in 1..=DRAG_INTERMEDIATE_STEPS {
let t = i as f64 / DRAG_INTERMEDIATE_STEPS as f64;
let x = sx + (tx - sx) * t;
let y = sy + (ty - sy) * t;
self.session.pointer_motion_absolute(x, y).await?;
}
Ok::<(), Error>(())
}
.await;
let up = self
.session
.pointer_button_up(crate::backend::PointerButton::Left)
.await;
result.and(up)
}
async fn wait_and_center(&self) -> Result<(f64, f64)> {
let info = self.wait_for_actionable().await?;
let bounds = info.bounds.ok_or_else(|| {
Error::atspi(format!(
"no bounds for {} — pointer actions need Component extents",
self.xpath
))
})?;
Ok((bounds.center_x() as f64, bounds.center_y() as f64))
}
async fn find_scrollable_ancestor(&self) -> Result<Option<ElementInfo>> {
let xml = self.snapshot().await?;
let ancestors_xpath = format!("({xp})/ancestor::*", xp = self.xpath);
let mut ancestors = atspi_client::evaluate_xpath_detailed(&xml, &ancestors_xpath)?;
ancestors.reverse();
let target = atspi_client::evaluate_xpath_detailed(&xml, &self.xpath)?
.into_iter()
.next()
.ok_or_else(|| Error::ElementNotFound {
xpath: self.xpath.clone(),
})?;
let mut prev_bounds = target.bounds;
for ancestor in ancestors {
if let (Some(prev), Some(this)) = (prev_bounds, ancestor.bounds) {
if prev.width > this.width || prev.height > this.height {
return Ok(Some(ancestor));
}
}
prev_bounds = ancestor.bounds;
}
Ok(None)
}
async fn is_in_viewport(&self, scrollable: &ElementInfo) -> Result<bool> {
let Some(scroll_bounds) = scrollable.bounds else {
return Ok(false);
};
match self.resolve_once_info().await {
Ok(fresh) => Ok(fresh.bounds.is_some_and(|b| b.is_inside(&scroll_bounds))),
Err(Error::ElementNotFound { .. }) => Ok(false),
Err(e) => Err(e),
}
}
pub async fn wait_for<T, F, Fut>(&self, pred: F) -> Result<T>
where
F: Fn(Vec<ElementInfo>) -> Fut,
Fut: Future<Output = Result<Option<T>>>,
{
let xpath = self.xpath.clone();
poll_with_retry(
self.effective_timeout(),
&xpath,
self.session.cancellation_token(),
|| async { pred(self.inspect_all().await?).await },
)
.await
}
pub async fn wait_until<F>(&self, pred: F) -> Result<Vec<ElementInfo>>
where
F: Fn(&[ElementInfo]) -> bool,
{
self.wait_for(|hits| {
let matched = pred(&hits);
std::future::ready(Ok(matched.then_some(hits)))
})
.await
}
pub async fn wait_until_async<F, Fut>(&self, pred: F) -> Result<Vec<ElementInfo>>
where
F: Fn(Vec<ElementInfo>) -> Fut,
Fut: Future<Output = bool>,
{
self.wait_for(|hits| {
let hits_return = hits.clone();
let fut = pred(hits);
async move { Ok(fut.await.then_some(hits_return)) }
})
.await
}
pub async fn wait_for_visible(&self) -> Result<()> {
self.wait_until(|hits| single_has_state(hits, "showing"))
.await
.map(|_| ())
}
pub async fn wait_for_hidden(&self) -> Result<()> {
self.wait_until(|hits| hits.is_empty() || !hits[0].states.iter().any(|s| s == "showing"))
.await
.map(|_| ())
}
pub async fn wait_for_enabled(&self) -> Result<()> {
self.wait_until(|hits| hits.len() == 1 && is_enabled_in(&hits[0].states))
.await
.map(|_| ())
}
pub async fn wait_for_count(&self, n: usize) -> Result<()> {
self.wait_until(|hits| hits.len() == n).await.map(|_| ())
}
pub async fn wait_for_text<F>(&self, pred: F) -> Result<String>
where
F: Fn(&str) -> bool,
{
let pred = &pred;
self.wait_for(move |hits| async move {
if hits.len() != 1 {
return Ok(None);
}
let (bus, path) = hits[0].ref_.clone();
let a11y = self.a11y()?;
let text = atspi_client::read_text_on(a11y, &self.xpath, &bus, &path).await?;
Ok(pred(&text).then_some(text))
})
.await
}
async fn has_state(&self, state: &str) -> Result<bool> {
Ok(self
.wait_for_existing()
.await?
.states
.iter()
.any(|s| s == state))
}
fn a11y(&self) -> Result<&zbus::Connection> {
self.session
.a11y_connection
.as_ref()
.ok_or_else(|| Error::atspi("session has no AT-SPI connection"))
}
fn effective_timeout(&self) -> Duration {
self.timeout
.unwrap_or_else(|| self.session.default_timeout())
}
async fn snapshot(&self) -> Result<String> {
let a11y = self.a11y()?;
atspi_client::snapshot_tree(a11y, &self.session.app_bus_name, &self.session.app_path).await
}
async fn resolve_all_once(&self) -> Result<Vec<(String, String)>> {
let xml = self.snapshot().await?;
atspi_client::evaluate_xpath(&xml, &self.xpath)
}
async fn resolve_once_info(&self) -> Result<ElementInfo> {
let xml = self.snapshot().await?;
let mut hits = atspi_client::evaluate_xpath_detailed(&xml, &self.xpath)?;
select_exactly_one(&self.xpath, hits.len())?;
Ok(hits.pop().unwrap())
}
async fn wait_for_existing(&self) -> Result<ElementInfo> {
let xpath = self.xpath.clone();
poll_with_retry(
self.effective_timeout(),
&xpath,
self.session.cancellation_token(),
|| async { Ok(Some(self.resolve_once_info().await?)) },
)
.await
}
async fn wait_for_actionable(&self) -> Result<ElementInfo> {
let xpath = self.xpath.clone();
poll_with_retry(
self.effective_timeout(),
&xpath,
self.session.cancellation_token(),
|| async {
let info = self.resolve_once_info().await?;
let showing = info.states.iter().any(|s| s == "showing");
if showing && is_enabled_in(&info.states) {
Ok(Some(info))
} else {
Ok(None)
}
},
)
.await
}
async fn wait_for_focusable(&self) -> Result<ElementInfo> {
let xpath = self.xpath.clone();
poll_with_retry(
self.effective_timeout(),
&xpath,
self.session.cancellation_token(),
|| async {
let info = self.resolve_once_info().await?;
let showing = info.states.iter().any(|s| s == "showing");
let focusable = info.states.iter().any(|s| s == "focusable");
if showing && focusable {
Ok(Some(info))
} else {
Ok(None)
}
},
)
.await
}
}
fn wheel_direction(elem: &crate::atspi::Rect, scrollable: &crate::atspi::Rect) -> i32 {
if elem.y < scrollable.y {
-1
} else if elem.bottom() > scrollable.bottom() {
1
} else {
0
}
}
fn is_enabled_in(states: &[String]) -> bool {
states.iter().any(|s| s == "enabled" || s == "sensitive")
}
fn child_index_for_label(children: &[ElementInfo], label: &str, xpath: &str) -> Result<usize> {
let mut hits = children
.iter()
.enumerate()
.filter(|(_, c)| c.name.as_deref() == Some(label));
let Some((first_idx, _)) = hits.next() else {
return Err(Error::atspi(format!(
"select_option: no child with accessible name {label:?} under {xpath}"
)));
};
let extra = hits.count();
if extra > 0 {
return Err(Error::AmbiguousSelector {
xpath: format!("{xpath} options named {label:?}"),
count: extra + 1,
});
}
Ok(first_idx)
}
fn single_has_state(hits: &[ElementInfo], state: &str) -> bool {
hits.len() == 1 && hits[0].states.iter().any(|s| s == state)
}
fn select_exactly_one(xpath: &str, count: usize) -> Result<()> {
match count {
0 => Err(Error::ElementNotFound {
xpath: xpath.to_string(),
}),
1 => Ok(()),
n => Err(Error::AmbiguousSelector {
xpath: xpath.to_string(),
count: n,
}),
}
}
pub(crate) async fn poll_with_retry<T, F, Fut>(
timeout: Duration,
xpath: &str,
cancel: &tokio_util::sync::CancellationToken,
mut f: F,
) -> Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<Option<T>>>,
{
let deadline = Instant::now() + timeout;
let mut delay = INITIAL_POLL_DELAY;
#[allow(unused_assignments)]
let mut last_err: Option<Error> = None;
let mut attempts: u32 = 0;
loop {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
attempts += 1;
match f().await {
Ok(Some(v)) => return Ok(v),
Ok(None) => {
last_err = None;
}
Err(e) if is_retriable(&e) => {
last_err = Some(e);
}
Err(e) => return Err(e),
}
if Instant::now() >= deadline {
return Err(last_err.unwrap_or_else(|| {
Error::Timeout(format!(
"wait for '{xpath}' timed out after {attempts} attempt(s) \
({}ms budget)",
timeout.as_millis()
))
}));
}
tokio::select! {
_ = cancel.cancelled() => return Err(Error::Cancelled),
_ = tokio::time::sleep(delay) => {}
}
delay = (delay * 2).min(MAX_POLL_DELAY);
}
}
fn is_retriable(e: &Error) -> bool {
matches!(
e,
Error::ElementNotFound { .. } | Error::ElementStale { .. }
)
}
#[cfg(test)]
mod tests {
use super::{
is_retriable, poll_with_retry, select_exactly_one, single_has_state, ElementInfo, Error,
HashMap,
};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
fn compose_locate(outer: &str, sub: &str) -> String {
let trimmed = sub.trim();
if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("({outer})//{trimmed}")
}
}
fn compose_nth(outer: &str, n: usize) -> String {
format!("({outer})[{}]", n + 1)
}
fn compose_parent(outer: &str) -> String {
format!("({outer})/..")
}
#[test]
fn locate_relative_scopes() {
assert_eq!(
compose_locate("//Dialog[@name='X']", "PushButton"),
"(//Dialog[@name='X'])//PushButton"
);
}
#[test]
fn locate_absolute_replaces() {
assert_eq!(compose_locate("//Dialog", "//Menu"), "//Menu");
}
#[test]
fn nth_is_one_indexed_in_xpath() {
assert_eq!(compose_nth("//PushButton", 0), "(//PushButton)[1]");
assert_eq!(compose_nth("//PushButton", 4), "(//PushButton)[5]");
}
#[test]
fn parent_appends_dot_dot() {
assert_eq!(
compose_parent("//PushButton[@name='OK']"),
"(//PushButton[@name='OK'])/.."
);
}
#[test]
fn select_exactly_one_zero_is_not_found() {
let err = select_exactly_one("//Missing", 0).unwrap_err();
assert!(matches!(err, Error::ElementNotFound { .. }));
assert!(err.to_string().contains("//Missing"));
}
#[test]
fn select_exactly_one_one_is_ok() {
assert!(select_exactly_one("//PushButton[@name='OK']", 1).is_ok());
}
#[test]
fn select_exactly_one_many_is_ambiguous_with_count() {
let err = select_exactly_one("//PushButton", 7).unwrap_err();
match err {
Error::AmbiguousSelector { count, xpath } => {
assert_eq!(count, 7);
assert_eq!(xpath, "//PushButton");
}
other => panic!("expected AmbiguousSelector, got {other:?}"),
}
}
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use crate::backend::{CaptureBackend, CompositorRuntime, InputBackend, PipeWireStream};
use crate::error::Result as WdResult;
use crate::session::Session;
struct StubCompositor;
#[async_trait]
impl CompositorRuntime for StubCompositor {
async fn start(&mut self, _resolution: Option<&str>) -> WdResult<()> {
Ok(())
}
async fn stop(&mut self) -> WdResult<()> {
Ok(())
}
fn id(&self) -> &str {
"stub"
}
fn wayland_display(&self) -> &str {
"wayland-stub"
}
fn runtime_dir(&self) -> &Path {
Path::new("/tmp")
}
}
struct StubInput;
#[async_trait]
impl InputBackend for StubInput {
async fn press_keysym(&self, _keysym: u32, _: &CancellationToken) -> WdResult<()> {
Ok(())
}
async fn key_down(&self, _keysym: u32, _: &CancellationToken) -> WdResult<()> {
Ok(())
}
async fn key_up(&self, _keysym: u32, _: &CancellationToken) -> WdResult<()> {
Ok(())
}
async fn pointer_motion_relative(
&self,
_dx: f64,
_dy: f64,
_: &CancellationToken,
) -> WdResult<()> {
Ok(())
}
async fn pointer_motion_absolute(
&self,
_x: f64,
_y: f64,
_: &CancellationToken,
) -> WdResult<()> {
Ok(())
}
async fn pointer_button_down(
&self,
_button: crate::backend::PointerButton,
_: &CancellationToken,
) -> WdResult<()> {
Ok(())
}
async fn pointer_button_up(
&self,
_button: crate::backend::PointerButton,
_: &CancellationToken,
) -> WdResult<()> {
Ok(())
}
async fn pointer_axis_discrete(
&self,
_axis: crate::backend::PointerAxis,
_steps: i32,
_: &CancellationToken,
) -> WdResult<()> {
Ok(())
}
}
struct StubCapture;
#[async_trait]
impl CaptureBackend for StubCapture {
async fn start_stream(&self) -> WdResult<PipeWireStream> {
unimplemented!("not used in composition tests")
}
async fn stop_stream(&self, _stream: PipeWireStream) -> WdResult<()> {
Ok(())
}
fn pipewire_socket(&self) -> PathBuf {
PathBuf::from("/tmp/stub")
}
}
fn test_session() -> Arc<Session> {
Arc::new(Session::new_for_test(
"stub".into(),
"app".into(),
Box::new(StubInput),
Box::new(StubCapture),
Box::new(StubCompositor),
))
}
#[tokio::test]
async fn session_locate_carries_xpath_verbatim() {
let s = test_session();
let loc = s.locate("//PushButton[@name='OK']");
assert_eq!(loc.xpath(), "//PushButton[@name='OK']");
}
#[tokio::test]
async fn session_root_locator_uses_wildcard() {
let s = test_session();
assert_eq!(s.root().xpath(), "/*");
}
#[tokio::test]
async fn session_find_by_id_composes_xpath() {
let s = test_session();
assert_eq!(s.find_by_id("submit").xpath(), "//*[@id='submit']");
}
#[tokio::test]
async fn session_find_by_name_composes_xpath() {
let s = test_session();
assert_eq!(s.find_by_name("OK").xpath(), "//*[@name='OK']");
}
#[tokio::test]
async fn session_find_by_role_name_composes_xpath() {
let s = test_session();
assert_eq!(
s.find_by_role_name("PushButton", "OK").xpath(),
"//PushButton[@name='OK']"
);
}
#[tokio::test]
async fn locator_locate_appends_descendant_when_relative() {
let s = test_session();
let dialog = s.locate("//Dialog[@name='Confirm']");
let inner = dialog.locate("PushButton");
assert_eq!(inner.xpath(), "(//Dialog[@name='Confirm'])//PushButton");
}
#[tokio::test]
async fn locator_locate_absolute_replaces_scope() {
let s = test_session();
let dialog = s.locate("//Dialog");
assert_eq!(dialog.locate("//Menu").xpath(), "//Menu");
}
#[tokio::test]
async fn locator_nth_wraps_with_one_indexed_predicate() {
let s = test_session();
let loc = s.locate("//PushButton").nth(2);
assert_eq!(loc.xpath(), "(//PushButton)[3]");
}
#[tokio::test]
async fn locator_first_is_nth_zero() {
let s = test_session();
let loc = s.locate("//PushButton").first();
assert_eq!(loc.xpath(), "(//PushButton)[1]");
}
#[tokio::test]
async fn locator_last_uses_last_function() {
let s = test_session();
let loc = s.locate("//PushButton").last();
assert_eq!(loc.xpath(), "(//PushButton)[last()]");
}
#[tokio::test]
async fn locator_parent_appends_dot_dot() {
let s = test_session();
let loc = s.locate("//PushButton[@name='OK']").parent();
assert_eq!(loc.xpath(), "(//PushButton[@name='OK'])/..");
}
#[tokio::test]
async fn locator_composition_chains() {
let s = test_session();
let loc = s
.locate("//Dialog[@name='Confirm']")
.locate("PushButton")
.nth(1);
assert_eq!(loc.xpath(), "((//Dialog[@name='Confirm'])//PushButton)[2]");
}
#[tokio::test]
async fn locator_clone_preserves_xpath() {
let s = test_session();
let loc = s.locate("//PushButton");
let cloned = loc.clone();
assert_eq!(cloned.xpath(), "//PushButton");
}
#[tokio::test]
async fn locator_click_on_session_without_a11y_errors_cleanly() {
let s = test_session();
let err = s.locate("//PushButton").click().await.unwrap_err();
assert!(matches!(err, Error::Atspi { .. }));
assert!(err.to_string().contains("no AT-SPI connection"));
}
#[test]
fn single_has_state_requires_exactly_one_match() {
fn info_with_states(states: &[&str]) -> ElementInfo {
ElementInfo {
ref_: ("b".into(), "/p".into()),
role: "Node".into(),
role_raw: None,
name: None,
attributes: HashMap::new(),
states: states.iter().map(|s| (*s).into()).collect(),
bounds: None,
}
}
assert!(!single_has_state(&[], "checked"));
let a = info_with_states(&["showing", "checked"]);
assert!(single_has_state(std::slice::from_ref(&a), "checked"));
let b = info_with_states(&["showing"]);
assert!(!single_has_state(std::slice::from_ref(&b), "checked"));
assert!(!single_has_state(&[a.clone(), a.clone()], "checked"));
}
#[tokio::test]
async fn wait_until_surfaces_missing_a11y_as_atspi_error() {
let s = test_session();
let err = s
.locate("//PushButton")
.with_timeout(Duration::from_millis(10))
.wait_until(|_| true)
.await
.unwrap_err();
assert!(matches!(err, Error::Atspi { .. }));
assert!(err.to_string().contains("no AT-SPI connection"));
}
#[tokio::test]
async fn wait_until_async_surfaces_missing_a11y_as_atspi_error() {
let s = test_session();
let err = s
.locate("//PushButton")
.with_timeout(Duration::from_millis(10))
.wait_until_async(|_| async { true })
.await
.unwrap_err();
assert!(matches!(err, Error::Atspi { .. }));
}
#[tokio::test]
async fn wait_for_surfaces_missing_a11y_as_atspi_error() {
let s = test_session();
let err = s
.locate("//PushButton")
.with_timeout(Duration::from_millis(10))
.wait_for(|_| async { Ok(Some(42)) })
.await
.unwrap_err();
assert!(matches!(err, Error::Atspi { .. }));
}
#[tokio::test]
async fn wait_for_non_retriable_predicate_error_aborts_immediately() {
let s = test_session();
let result: WdResult<&'static str> = s
.locate("//X")
.with_timeout(Duration::from_millis(10))
.wait_for(|_| async { Ok::<Option<&'static str>, Error>(Some("sentinel")) })
.await;
assert!(matches!(result.unwrap_err(), Error::Atspi { .. }));
}
#[tokio::test]
async fn session_dump_tree_without_a11y_errors_cleanly() {
let s = test_session();
let err = s.dump_tree().await.unwrap_err();
assert!(matches!(err, Error::Atspi { .. }));
assert!(err.to_string().contains("no AT-SPI connection"));
}
#[tokio::test]
async fn with_timeout_overrides_session_default() {
let s = test_session();
let base = s.locate("//PushButton");
let quick = base.with_timeout(Duration::from_millis(100));
assert_eq!(quick.xpath(), base.xpath());
}
fn noncancel() -> tokio_util::sync::CancellationToken {
tokio_util::sync::CancellationToken::new()
}
#[tokio::test]
async fn poll_returns_cancelled_when_token_tripped_before_first_attempt() {
let tok = tokio_util::sync::CancellationToken::new();
tok.cancel();
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_cloned = attempts.clone();
let result: Result<i32, Error> =
poll_with_retry(Duration::from_secs(5), "//X", &tok, move || {
let a = attempts_cloned.clone();
async move {
a.fetch_add(1, Ordering::SeqCst);
Ok(Some(42))
}
})
.await;
assert!(matches!(result, Err(Error::Cancelled)));
assert_eq!(
attempts.load(Ordering::SeqCst),
0,
"predicate must not run after cancellation"
);
}
#[tokio::test]
async fn poll_returns_cancelled_when_token_trips_during_backoff_sleep() {
let tok = tokio_util::sync::CancellationToken::new();
let tok_for_spawn = tok.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(20)).await;
tok_for_spawn.cancel();
});
let start = std::time::Instant::now();
let result: Result<i32, Error> =
poll_with_retry(Duration::from_secs(5), "//X", &tok, || async {
Err::<Option<i32>, _>(Error::ElementNotFound {
xpath: "//X".into(),
})
})
.await;
let elapsed = start.elapsed();
assert!(matches!(result, Err(Error::Cancelled)), "got {result:?}");
assert!(
elapsed < Duration::from_millis(500),
"cancel should wake the sleep promptly; elapsed = {elapsed:?}"
);
}
#[tokio::test]
async fn poll_returns_value_on_first_try() {
let tok = noncancel();
let result: Result<i32, Error> =
poll_with_retry(Duration::from_secs(5), "x", &tok, || async { Ok(Some(42)) }).await;
assert_eq!(result.unwrap(), 42);
}
#[tokio::test]
async fn poll_succeeds_after_retries() {
let tok = noncancel();
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_cloned = attempts.clone();
let result: Result<&'static str, Error> =
poll_with_retry(Duration::from_secs(5), "x", &tok, move || {
let a = attempts_cloned.clone();
async move {
let n = a.fetch_add(1, Ordering::SeqCst);
if n < 2 {
Err(Error::ElementNotFound { xpath: "x".into() })
} else {
Ok(Some("found"))
}
}
})
.await;
assert_eq!(result.unwrap(), "found");
assert_eq!(attempts.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn poll_surfaces_last_retriable_error_on_timeout() {
let tok = noncancel();
let result: Result<&'static str, Error> =
poll_with_retry(Duration::from_millis(50), "//Missing", &tok, || async {
Err::<Option<&'static str>, _>(Error::ElementNotFound {
xpath: "//Missing".into(),
})
})
.await;
let err = result.unwrap_err();
assert!(
matches!(err, Error::ElementNotFound { .. }),
"expected ElementNotFound, got {err}"
);
}
#[tokio::test]
async fn poll_returns_timeout_when_predicate_keeps_saying_none() {
let tok = noncancel();
let result: Result<i32, Error> =
poll_with_retry(Duration::from_millis(50), "//Pending", &tok, || async {
Ok::<Option<i32>, Error>(None)
})
.await;
let err = result.unwrap_err();
match err {
Error::Timeout(msg) => assert!(
msg.contains("//Pending"),
"timeout message should include the xpath: {msg}"
),
other => panic!("expected Timeout, got {other:?}"),
}
}
#[tokio::test]
async fn poll_bails_immediately_on_non_retriable_error() {
let tok = noncancel();
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_cloned = attempts.clone();
let result: Result<&'static str, Error> =
poll_with_retry(Duration::from_secs(5), "//Bad", &tok, move || {
let a = attempts_cloned.clone();
async move {
a.fetch_add(1, Ordering::SeqCst);
Err(Error::InvalidSelector {
xpath: "//Bad".into(),
reason: "oops".into(),
})
}
})
.await;
let err = result.unwrap_err();
assert!(matches!(err, Error::InvalidSelector { .. }));
assert_eq!(attempts.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn poll_ambiguous_selector_is_not_retriable() {
let tok = noncancel();
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_cloned = attempts.clone();
let result: Result<&'static str, Error> =
poll_with_retry(Duration::from_secs(5), "//PushButton", &tok, move || {
let a = attempts_cloned.clone();
async move {
a.fetch_add(1, Ordering::SeqCst);
Err(Error::AmbiguousSelector {
xpath: "//PushButton".into(),
count: 3,
})
}
})
.await;
assert!(matches!(
result.unwrap_err(),
Error::AmbiguousSelector { count: 3, .. }
));
assert_eq!(attempts.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn poll_zero_timeout_is_single_shot() {
let tok = noncancel();
let attempts = Arc::new(AtomicUsize::new(0));
let attempts_cloned = attempts.clone();
let start = std::time::Instant::now();
let _: Result<i32, Error> = poll_with_retry(Duration::ZERO, "//X", &tok, move || {
let a = attempts_cloned.clone();
async move {
a.fetch_add(1, Ordering::SeqCst);
Err(Error::ElementNotFound {
xpath: "//X".into(),
})
}
})
.await;
assert_eq!(attempts.load(Ordering::SeqCst), 1);
assert!(
start.elapsed() < Duration::from_millis(100),
"zero-timeout poll should not sleep, took {:?}",
start.elapsed()
);
}
use crate::atspi::evaluate_xpath_detailed;
fn states_for(xml: &str, xpath: &str) -> Vec<String> {
let mut hits = evaluate_xpath_detailed(xml, xpath).unwrap();
assert_eq!(hits.len(), 1, "fixture should match exactly one element");
hits.pop().unwrap().states
}
#[test]
fn snapshot_exposes_checked_state() {
let xml = r#"<?xml version="1.0"?>
<Application _ref="b|/r"><CheckBox name="Accept" checked="true" _ref="b|/c"/></Application>"#;
let states = states_for(xml, "//CheckBox");
assert!(states.iter().any(|s| s == "checked"));
}
#[test]
fn snapshot_exposes_focused_state() {
let xml = r#"<?xml version="1.0"?>
<Application _ref="b|/r"><Entry focused="true" _ref="b|/e"/></Application>"#;
let states = states_for(xml, "//Entry");
assert!(states.iter().any(|s| s == "focused"));
}
#[test]
fn snapshot_exposes_expanded_state() {
let xml = r#"<?xml version="1.0"?>
<Application _ref="b|/r"><TreeItem expanded="true" _ref="b|/t"/></Application>"#;
let states = states_for(xml, "//TreeItem");
assert!(states.iter().any(|s| s == "expanded"));
}
#[test]
fn snapshot_exposes_editable_state() {
let xml = r#"<?xml version="1.0"?>
<Application _ref="b|/r"><Entry editable="true" _ref="b|/e"/></Application>"#;
let states = states_for(xml, "//Entry");
assert!(states.iter().any(|s| s == "editable"));
}
#[test]
fn snapshot_exposes_selected_state() {
let xml = r#"<?xml version="1.0"?>
<Application _ref="b|/r"><ListItem selected="true" _ref="b|/l"/></Application>"#;
let states = states_for(xml, "//ListItem");
assert!(states.iter().any(|s| s == "selected"));
}
#[test]
fn snapshot_exposes_pressed_state() {
let xml = r#"<?xml version="1.0"?>
<Application _ref="b|/r"><ToggleButton pressed="true" _ref="b|/t"/></Application>"#;
let states = states_for(xml, "//ToggleButton");
assert!(states.iter().any(|s| s == "pressed"));
}
#[test]
fn snapshot_exposes_modal_state() {
let xml = r#"<?xml version="1.0"?>
<Application _ref="b|/r"><Dialog modal="true" _ref="b|/d"/></Application>"#;
let states = states_for(xml, "//Dialog");
assert!(states.iter().any(|s| s == "modal"));
}
#[test]
fn snapshot_state_absence_is_also_detectable() {
let xml = r#"<?xml version="1.0"?>
<Application _ref="b|/r"><CheckBox _ref="b|/c"/></Application>"#;
let states = states_for(xml, "//CheckBox");
assert!(!states.iter().any(|s| s == "checked"));
}
fn children_from(xml: &str, parent_xpath: &str) -> Vec<crate::atspi::ElementInfo> {
let children_xpath = format!("({parent_xpath})/*");
evaluate_xpath_detailed(xml, &children_xpath).unwrap()
}
const COMBO_XML: &str = r#"<?xml version="1.0"?>
<Application _ref="b|/r">
<ComboBox name="size" _ref="b|/c">
<MenuItem name="Small" _ref="b|/c/0"/>
<MenuItem name="Medium" _ref="b|/c/1"/>
<MenuItem name="Large" _ref="b|/c/2"/>
</ComboBox>
</Application>"#;
#[test]
fn child_index_for_label_finds_unique_match() {
let children = children_from(COMBO_XML, "//ComboBox");
assert_eq!(
super::child_index_for_label(&children, "Medium", "//ComboBox").unwrap(),
1
);
}
#[test]
fn child_index_for_label_surfaces_absent_label() {
let children = children_from(COMBO_XML, "//ComboBox");
let err = super::child_index_for_label(&children, "Jumbo", "//ComboBox").unwrap_err();
match err {
Error::Atspi { message, .. } => {
assert!(
message.contains("Jumbo"),
"error should name the label: {message}"
);
assert!(
message.contains("//ComboBox"),
"error should name the container: {message}"
);
}
other => panic!("expected Atspi error, got {other:?}"),
}
}
#[test]
fn child_index_for_label_flags_ambiguity() {
let xml = r#"<?xml version="1.0"?>
<Application _ref="b|/r">
<ComboBox _ref="b|/c">
<MenuItem name="Red" _ref="b|/c/0"/>
<MenuItem name="Red" _ref="b|/c/1"/>
</ComboBox>
</Application>"#;
let children = children_from(xml, "//ComboBox");
let err = super::child_index_for_label(&children, "Red", "//ComboBox").unwrap_err();
match err {
Error::AmbiguousSelector { count, xpath } => {
assert_eq!(count, 2);
assert!(
xpath.contains("Red"),
"synthetic xpath should include the label: {xpath}"
);
}
other => panic!("expected AmbiguousSelector, got {other:?}"),
}
}
#[test]
fn child_index_for_label_empty_children_is_not_found() {
let xml = r#"<?xml version="1.0"?>
<Application _ref="b|/r"><ComboBox _ref="b|/c"/></Application>"#;
let children = children_from(xml, "//ComboBox");
assert!(children.is_empty());
let err = super::child_index_for_label(&children, "anything", "//ComboBox").unwrap_err();
assert!(matches!(err, Error::Atspi { .. }));
}
#[test]
fn is_retriable_matches_expected_errors() {
assert!(is_retriable(&Error::ElementNotFound { xpath: "x".into() }));
assert!(is_retriable(&Error::ElementStale {
xpath: "x".into(),
bus: "b".into(),
path: "/p".into(),
}));
assert!(!is_retriable(&Error::AmbiguousSelector {
xpath: "x".into(),
count: 2,
}));
assert!(!is_retriable(&Error::InvalidSelector {
xpath: "x".into(),
reason: "r".into(),
}));
assert!(!is_retriable(&Error::atspi("boom")));
assert!(!is_retriable(&Error::Timeout("nope".into())));
}
use crate::atspi::Rect;
#[test]
fn wheel_direction_above_returns_negative() {
let elem = Rect {
x: 0,
y: -100,
width: 50,
height: 20,
};
let viewport = Rect {
x: 0,
y: 0,
width: 100,
height: 100,
};
assert_eq!(super::wheel_direction(&elem, &viewport), -1);
}
#[test]
fn wheel_direction_below_returns_positive() {
let elem = Rect {
x: 0,
y: 200,
width: 50,
height: 20,
};
let viewport = Rect {
x: 0,
y: 0,
width: 100,
height: 100,
};
assert_eq!(super::wheel_direction(&elem, &viewport), 1);
}
#[test]
fn wheel_direction_already_inside_returns_zero() {
let elem = Rect {
x: 10,
y: 30,
width: 20,
height: 10,
};
let viewport = Rect {
x: 0,
y: 0,
width: 100,
height: 100,
};
assert_eq!(super::wheel_direction(&elem, &viewport), 0);
}
#[test]
fn wheel_direction_partially_below_returns_positive() {
let elem = Rect {
x: 0,
y: 90,
width: 20,
height: 30, };
let viewport = Rect {
x: 0,
y: 0,
width: 100,
height: 100,
};
assert_eq!(super::wheel_direction(&elem, &viewport), 1);
}
}