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}