use crate::role::Role;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Diagnosis {
pub condition: Option<String>,
pub selector: Option<String>,
pub last_observed: Option<String>,
pub candidates: Vec<String>,
pub scope: Option<String>,
}
impl Diagnosis {
pub fn is_empty(&self) -> bool {
self.condition.is_none()
&& self.selector.is_none()
&& self.last_observed.is_none()
&& self.candidates.is_empty()
&& self.scope.is_none()
}
}
impl std::fmt::Display for Diagnosis {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(condition) = &self.condition {
write!(f, "; waiting for: {condition}")?;
}
if let Some(selector) = &self.selector {
write!(f, "; selector: {selector}")?;
}
if let Some(last) = &self.last_observed {
write!(f, "; last observed: {last}")?;
}
if !self.candidates.is_empty() {
write!(f, "; candidates: {}", self.candidates.join(", "))?;
}
if let Some(scope) = &self.scope {
write!(f, "\nsearch scope (bounded):\n{scope}")?;
}
Ok(())
}
}
fn diagnosis_suffix(diagnosis: &Option<Box<Diagnosis>>) -> String {
match diagnosis {
Some(d) if !d.is_empty() => d.to_string(),
_ => String::new(),
}
}
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Permission denied: {instructions}")]
PermissionDenied { instructions: String },
#[error("Accessibility not enabled for {app}: {instructions}")]
AccessibilityNotEnabled { app: String, instructions: String },
#[error("No element matched selector: {selector}{}", diagnosis_suffix(.diagnosis))]
SelectorNotMatched {
selector: String,
diagnosis: Option<Box<Diagnosis>>,
},
#[error("Element stale: could not relocate element for selector: {selector}")]
ElementStale { selector: String },
#[error("Action {action} not supported on {role}")]
ActionNotSupported { action: String, role: Role },
#[error("Text value input not supported for this element")]
TextValueNotSupported,
#[error("Timeout after {elapsed:.1?}{}", diagnosis_suffix(.diagnosis))]
Timeout {
elapsed: std::time::Duration,
diagnosis: Option<Box<Diagnosis>>,
},
#[error("Invalid selector '{selector}': {message}")]
InvalidSelector { selector: String, message: String },
#[error("Invalid action data: {message}")]
InvalidActionData { message: String },
#[error("Invalid configuration: {message}")]
InvalidConfig { message: String },
#[error("Element has no bounds")]
NoElementBounds,
#[error("Unsupported: {feature}")]
Unsupported { feature: String },
#[error("Platform error ({code}): {message}")]
Platform { code: i64, message: String },
}
impl Error {
pub fn selector_not_matched(selector: impl Into<String>) -> Self {
Self::SelectorNotMatched {
selector: selector.into(),
diagnosis: None,
}
}
pub fn timeout(elapsed: std::time::Duration) -> Self {
Self::Timeout {
elapsed,
diagnosis: None,
}
}
#[must_use]
pub fn diagnose(mut self, diagnosis: Diagnosis) -> Self {
let normalized = if diagnosis.is_empty() {
None
} else {
Some(Box::new(diagnosis))
};
match &mut self {
Self::SelectorNotMatched { diagnosis: d, .. } | Self::Timeout { diagnosis: d, .. } => {
*d = normalized;
}
_ => {}
}
self
}
pub fn diagnosis(&self) -> Option<&Diagnosis> {
match self {
Self::SelectorNotMatched { diagnosis, .. } | Self::Timeout { diagnosis, .. } => {
diagnosis.as_deref()
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn bare_timeout_renders_duration_only() {
let err = Error::timeout(Duration::from_secs(5));
assert_eq!(format!("{err}"), "Timeout after 5.0s");
}
#[test]
fn diagnosed_timeout_renders_condition_selector_and_last_observed() {
let err = Error::timeout(Duration::from_secs(60)).diagnose(Diagnosis {
condition: Some("visible".into()),
selector: Some(r#"dialog[name^="Submit"]"#.into()),
last_observed: Some("selector never matched".into()),
..Diagnosis::default()
});
let msg = format!("{err}");
assert!(msg.contains("Timeout after 60.0s"), "{msg}");
assert!(msg.contains("waiting for: visible"), "{msg}");
assert!(msg.contains(r#"selector: dialog[name^="Submit"]"#), "{msg}");
assert!(
msg.contains("last observed: selector never matched"),
"{msg}"
);
}
#[test]
fn diagnosed_not_matched_renders_candidates_and_scope() {
let err = Error::selector_not_matched(r#"button[name="Exprot"]"#).diagnose(Diagnosis {
candidates: vec![r#"button "Export""#.into(), r#"button "Cancel""#.into()],
scope: Some(" window \"Main\"\n button \"Export\"".into()),
..Diagnosis::default()
});
let msg = format!("{err}");
assert!(msg.contains("No element matched selector"), "{msg}");
assert!(
msg.contains(r#"candidates: button "Export", button "Cancel""#),
"{msg}"
);
assert!(msg.contains("search scope (bounded):"), "{msg}");
}
#[test]
fn empty_diagnosis_is_normalized_away() {
let err = Error::timeout(Duration::from_secs(1)).diagnose(Diagnosis::default());
assert!(err.diagnosis().is_none());
assert_eq!(format!("{err}"), "Timeout after 1.0s");
}
#[test]
fn diagnose_on_unsupported_variant_is_a_documented_no_op() {
let err = Error::NoElementBounds.diagnose(Diagnosis {
condition: Some("anything".into()),
..Diagnosis::default()
});
assert!(err.diagnosis().is_none());
}
#[test]
fn diagnosis_accessor_round_trips() {
let err = Error::selector_not_matched("button").diagnose(Diagnosis {
last_observed: Some("selector matched 1 element(s); nth(3) requested".into()),
..Diagnosis::default()
});
let d = err.diagnosis().expect("diagnosis must be attached");
assert_eq!(
d.last_observed.as_deref(),
Some("selector matched 1 element(s); nth(3) requested")
);
}
}