Skip to main content

xa11y_core/
error.rs

1//! Error types for xa11y operations.
2//!
3//! # The diagnosis pattern (tenet 6: errors carry their own diagnosis)
4//!
5//! Failure-path errors that a consumer might need to debug — timeouts and
6//! "not found" lookups — carry an optional structured [`Diagnosis`] alongside
7//! their identifying fields. The diagnosis answers the questions a consumer
8//! would otherwise have to answer by wrapping the call in logging: *what was
9//! the operation waiting for*, *what did it last observe*, and *what was
10//! actually there*.
11//!
12//! Rules for attaching a diagnosis to a new failure path:
13//!
14//! 1. **Enrich at the terminal site, not at construction.** Errors like
15//!    [`Error::SelectorNotMatched`] double as cheap retry signals inside poll
16//!    loops (`Locator::auto_wait`, `App::by_pid_with`). Constructing them must
17//!    stay allocation-light. Attach the diagnosis (via
18//!    [`Error::diagnose`]) only where the error escapes to the caller —
19//!    typically when a poll loop exhausts its timeout or a fail-fast query
20//!    returns.
21//! 2. **Bound the cost.** Diagnosis collection runs only on the failure path
22//!    and must be size-bounded: tree snapshots are depth-limited and
23//!    line-capped, candidate lists are truncated. See the `DIAG_*` constants
24//!    in `locator.rs`.
25//! 3. **Never mask the original error.** If collecting a diagnosis itself
26//!    fails (e.g. the scope dump errors because the app quit), record the
27//!    collection failure *inside* the diagnosis instead of dropping it or
28//!    replacing the original error.
29//! 4. **Keep it structured.** Language bindings expose the diagnosis as
30//!    structured fields (exception attributes in Python, error properties in
31//!    JS) in addition to rendering it into the message. New fields belong on
32//!    [`Diagnosis`], not interpolated into ad-hoc message strings.
33
34use crate::role::Role;
35
36/// Result type alias for xa11y operations.
37pub type Result<T> = std::result::Result<T, Error>;
38
39/// Structured diagnostic context attached to failure-path errors.
40///
41/// See the [module docs](self) for the pattern governing when and how to
42/// attach one. All fields are optional; renderers skip empty fields.
43///
44/// `#[non_exhaustive]`: diagnostic context is expected to grow, and every
45/// growth would otherwise be a breaking change for the platform crates and
46/// bindings that build these. Construct with [`Diagnosis::new`] and the
47/// chained setters below:
48///
49/// ```
50/// # use xa11y_core::Diagnosis;
51/// Diagnosis::new()
52///     .condition("visible")
53///     .last_observed("matched button \"Export\" (visible=false)");
54/// ```
55///
56/// The fields stay public — reading them is how the bindings project a
57/// diagnosis into exception attributes, and a renderer that skips an unknown
58/// field is not a compatibility hazard the way a broken constructor is.
59#[derive(Debug, Clone, Default, PartialEq)]
60#[non_exhaustive]
61pub struct Diagnosis {
62    /// What the operation was waiting for or trying to find, e.g.
63    /// `"visible"`, `"press target actionable (visible && enabled)"`,
64    /// `"event matching predicate"`, `"application with pid 1234"`.
65    pub condition: Option<String>,
66    /// The selector being resolved, when the failing operation had one and
67    /// it is not already part of the error's primary fields.
68    pub selector: Option<String>,
69    /// What was last observed before the failure, e.g.
70    /// `"matched button \"Export\" (visible=false, enabled=true)"`,
71    /// `"selector never matched"`, or
72    /// `"3 event(s) received, none matched"`.
73    pub last_observed: Option<String>,
74    /// Bounded list of near-miss candidates — elements or applications that
75    /// were present in the search scope and partially matched (e.g. same
76    /// role, different name).
77    pub candidates: Vec<String>,
78    /// Depth- and line-bounded rendering of the search scope (an indented
79    /// tree dump or an application list), so the consumer does not need to
80    /// re-run the failure under `print(app.dump())`.
81    pub scope: Option<String>,
82}
83
84impl Diagnosis {
85    /// An empty diagnosis. Fill it in with the chained setters.
86    pub fn new() -> Self {
87        Self::default()
88    }
89
90    /// Set what the operation was waiting for or trying to find.
91    #[must_use]
92    pub fn condition(mut self, condition: impl Into<String>) -> Self {
93        self.condition = Some(condition.into());
94        self
95    }
96
97    /// Set the selector being resolved when the operation failed.
98    #[must_use]
99    pub fn selector(mut self, selector: impl Into<String>) -> Self {
100        self.selector = Some(selector.into());
101        self
102    }
103
104    /// Set what was last observed before the failure.
105    #[must_use]
106    pub fn last_observed(mut self, last_observed: impl Into<String>) -> Self {
107        self.last_observed = Some(last_observed.into());
108        self
109    }
110
111    /// Set the bounded list of near-miss candidates.
112    #[must_use]
113    pub fn candidates(mut self, candidates: impl IntoIterator<Item = String>) -> Self {
114        self.candidates = candidates.into_iter().collect();
115        self
116    }
117
118    /// Set the bounded rendering of the search scope.
119    #[must_use]
120    pub fn scope(mut self, scope: impl Into<String>) -> Self {
121        self.scope = Some(scope.into());
122        self
123    }
124
125    /// True when no field carries any information.
126    pub fn is_empty(&self) -> bool {
127        self.condition.is_none()
128            && self.selector.is_none()
129            && self.last_observed.is_none()
130            && self.candidates.is_empty()
131            && self.scope.is_none()
132    }
133}
134
135impl std::fmt::Display for Diagnosis {
136    /// Renders as `; `-separated clauses appended to an error's primary
137    /// message, with the (potentially multi-line) scope on its own lines:
138    ///
139    /// ```text
140    /// ; waiting for: visible; last observed: selector never matched;
141    /// candidates: button "Back", button "Forward"
142    /// search scope (bounded):
143    ///   window "Main"
144    ///     ...
145    /// ```
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        if let Some(condition) = &self.condition {
148            write!(f, "; waiting for: {condition}")?;
149        }
150        if let Some(selector) = &self.selector {
151            write!(f, "; selector: {selector}")?;
152        }
153        if let Some(last) = &self.last_observed {
154            write!(f, "; last observed: {last}")?;
155        }
156        if !self.candidates.is_empty() {
157            write!(f, "; candidates: {}", self.candidates.join(", "))?;
158        }
159        if let Some(scope) = &self.scope {
160            write!(f, "\nsearch scope (bounded):\n{scope}")?;
161        }
162        Ok(())
163    }
164}
165
166/// Render an optional boxed diagnosis as a message suffix ("" when absent or
167/// empty). Used by the `thiserror` display attributes below.
168fn diagnosis_suffix(diagnosis: &Option<Box<Diagnosis>>) -> String {
169    match diagnosis {
170        Some(d) if !d.is_empty() => d.to_string(),
171        _ => String::new(),
172    }
173}
174
175/// Structured error type for xa11y operations.
176/// Designed to be informative across FFI boundaries.
177///
178/// Construct [`SelectorNotMatched`](Error::SelectorNotMatched) and
179/// [`Timeout`](Error::Timeout) through [`Error::selector_not_matched`] /
180/// [`Error::timeout`] and attach context with [`Error::diagnose`] — see the
181/// [module docs](self) for the diagnosis pattern.
182///
183/// `#[non_exhaustive]`: new failure modes arrive with every backend and
184/// platform quirk, so downstream `match` arms need a `_` fallback. Before
185/// this attribute the bindings' exhaustive matches were what forced a new
186/// variant to be mapped; that guard now lives in
187/// `cargo xtask check-bindings-parity`, which fails when a variant is not
188/// referenced by both binding error mappers (see `[[types.variant_coverage]]`
189/// in `bindings/parity_allowlist.toml`).
190#[derive(Debug, Clone, thiserror::Error)]
191#[non_exhaustive]
192pub enum Error {
193    /// Accessibility permissions not granted.
194    #[error("Permission denied: {instructions}")]
195    PermissionDenied { instructions: String },
196
197    /// The target application advertises an accessibility tree but its
198    /// content is empty because the app's accessibility bridge is disabled
199    /// (Chromium/Electron on Linux without `--force-renderer-accessibility`,
200    /// for example).
201    #[error("Accessibility not enabled for {app}: {instructions}")]
202    AccessibilityNotEnabled { app: String, instructions: String },
203
204    /// No element matched the selector.
205    ///
206    /// Doubles as the "not yet" retry signal inside poll loops, so bare
207    /// construction stays cheap; terminal sites attach a [`Diagnosis`].
208    #[error("No element matched selector: {selector}{}", diagnosis_suffix(.diagnosis))]
209    SelectorNotMatched {
210        selector: String,
211        diagnosis: Option<Box<Diagnosis>>,
212    },
213
214    /// The node's platform handle is stale and re-traversal could not relocate it.
215    #[error("Element stale: could not relocate element for selector: {selector}")]
216    ElementStale { selector: String },
217
218    /// The requested action is not supported by this element.
219    #[error("Action {action} not supported on {role}")]
220    ActionNotSupported { action: String, role: Role },
221
222    /// Text value input is not supported for this element on this platform.
223    #[error("Text value input not supported for this element")]
224    TextValueNotSupported,
225
226    /// A wait operation exceeded its timeout.
227    ///
228    /// Always carries what the wait was for via its [`Diagnosis`] — a bare
229    /// "Timeout after Ns" tells the consumer nothing actionable (tenet 6).
230    #[error("Timeout after {elapsed:.1?}{}", diagnosis_suffix(.diagnosis))]
231    Timeout {
232        elapsed: std::time::Duration,
233        diagnosis: Option<Box<Diagnosis>>,
234    },
235
236    /// The selector string could not be parsed.
237    #[error("Invalid selector '{selector}': {message}")]
238    InvalidSelector { selector: String, message: String },
239
240    /// Invalid argument to an action method.
241    #[error("Invalid action data: {message}")]
242    InvalidActionData { message: String },
243
244    /// Process-wide configuration is invalid (e.g. an unparsable
245    /// `XA11Y_DEFAULT_TIMEOUT` environment variable).
246    #[error("Invalid configuration: {message}")]
247    InvalidConfig { message: String },
248
249    /// The element has no bounds (e.g. an off-screen or virtual node), so a
250    /// screen point can't be computed for input simulation.
251    #[error("Element has no bounds")]
252    NoElementBounds,
253
254    /// The requested operation has no implementation on this platform/session
255    /// (e.g. pointer warp on Wayland without a portal grant).
256    #[error("Unsupported: {feature}")]
257    Unsupported { feature: String },
258
259    /// A platform-specific error occurred.
260    #[error("Platform error ({code}): {message}")]
261    Platform { code: i64, message: String },
262}
263
264impl Error {
265    /// Construct a bare [`Error::SelectorNotMatched`] (no diagnosis).
266    ///
267    /// This is the cheap form for poll-loop retry signals; attach context at
268    /// the terminal site with [`diagnose`](Self::diagnose).
269    pub fn selector_not_matched(selector: impl Into<String>) -> Self {
270        Self::SelectorNotMatched {
271            selector: selector.into(),
272            diagnosis: None,
273        }
274    }
275
276    /// Construct a bare [`Error::Timeout`] (no diagnosis).
277    ///
278    /// Prefer attaching a [`Diagnosis`] with [`diagnose`](Self::diagnose)
279    /// before the error reaches a consumer — see tenet 6.
280    pub fn timeout(elapsed: std::time::Duration) -> Self {
281        Self::Timeout {
282            elapsed,
283            diagnosis: None,
284        }
285    }
286
287    /// Attach (or replace) a [`Diagnosis`] on errors that carry one.
288    ///
289    /// Supported variants: [`SelectorNotMatched`](Self::SelectorNotMatched)
290    /// and [`Timeout`](Self::Timeout). Other variants are returned unchanged —
291    /// their primary fields already identify the failure, and silently
292    /// growing a diagnosis field on them would not be rendered. An empty
293    /// diagnosis is normalized to `None`.
294    #[must_use]
295    pub fn diagnose(mut self, diagnosis: Diagnosis) -> Self {
296        let normalized = if diagnosis.is_empty() {
297            None
298        } else {
299            Some(Box::new(diagnosis))
300        };
301        match &mut self {
302            Self::SelectorNotMatched { diagnosis: d, .. } | Self::Timeout { diagnosis: d, .. } => {
303                *d = normalized;
304            }
305            _ => {}
306        }
307        self
308    }
309
310    /// The attached [`Diagnosis`], if this error carries one. Bindings use
311    /// this to expose structured fields on their exception types.
312    pub fn diagnosis(&self) -> Option<&Diagnosis> {
313        match self {
314            Self::SelectorNotMatched { diagnosis, .. } | Self::Timeout { diagnosis, .. } => {
315                diagnosis.as_deref()
316            }
317            _ => None,
318        }
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use std::time::Duration;
326
327    #[test]
328    fn bare_timeout_renders_duration_only() {
329        let err = Error::timeout(Duration::from_secs(5));
330        assert_eq!(format!("{err}"), "Timeout after 5.0s");
331    }
332
333    #[test]
334    fn diagnosed_timeout_renders_condition_selector_and_last_observed() {
335        let err = Error::timeout(Duration::from_secs(60)).diagnose(Diagnosis {
336            condition: Some("visible".into()),
337            selector: Some(r#"dialog[name^="Submit"]"#.into()),
338            last_observed: Some("selector never matched".into()),
339            ..Diagnosis::default()
340        });
341        let msg = format!("{err}");
342        assert!(msg.contains("Timeout after 60.0s"), "{msg}");
343        assert!(msg.contains("waiting for: visible"), "{msg}");
344        assert!(msg.contains(r#"selector: dialog[name^="Submit"]"#), "{msg}");
345        assert!(
346            msg.contains("last observed: selector never matched"),
347            "{msg}"
348        );
349    }
350
351    #[test]
352    fn diagnosed_not_matched_renders_candidates_and_scope() {
353        let err = Error::selector_not_matched(r#"button[name="Exprot"]"#).diagnose(Diagnosis {
354            candidates: vec![r#"button "Export""#.into(), r#"button "Cancel""#.into()],
355            scope: Some("  window \"Main\"\n    button \"Export\"".into()),
356            ..Diagnosis::default()
357        });
358        let msg = format!("{err}");
359        assert!(msg.contains("No element matched selector"), "{msg}");
360        assert!(
361            msg.contains(r#"candidates: button "Export", button "Cancel""#),
362            "{msg}"
363        );
364        assert!(msg.contains("search scope (bounded):"), "{msg}");
365    }
366
367    #[test]
368    fn empty_diagnosis_is_normalized_away() {
369        let err = Error::timeout(Duration::from_secs(1)).diagnose(Diagnosis::default());
370        assert!(err.diagnosis().is_none());
371        assert_eq!(format!("{err}"), "Timeout after 1.0s");
372    }
373
374    #[test]
375    fn diagnose_on_unsupported_variant_is_a_documented_no_op() {
376        let err = Error::NoElementBounds.diagnose(Diagnosis {
377            condition: Some("anything".into()),
378            ..Diagnosis::default()
379        });
380        assert!(err.diagnosis().is_none());
381    }
382
383    #[test]
384    fn diagnosis_accessor_round_trips() {
385        let err = Error::selector_not_matched("button").diagnose(Diagnosis {
386            last_observed: Some("selector matched 1 element(s); nth(3) requested".into()),
387            ..Diagnosis::default()
388        });
389        let d = err.diagnosis().expect("diagnosis must be attached");
390        assert_eq!(
391            d.last_observed.as_deref(),
392            Some("selector matched 1 element(s); nth(3) requested")
393        );
394    }
395}