Skip to content

Errors & Diagnosis

xa11y errors are designed so you never have to re-run a failure under extra logging to understand it. A timeout tells you what it was waiting for and what it last saw; a selector miss tells you what was actually there. The same information is available two ways:

  • Rendered into the message. The exception text alone is enough to diagnose a CI failure from the log.
  • As structured fields. Exception attributes in Python, error properties in JavaScript, and the Diagnosis struct in Rust, so harnesses can act on them programmatically instead of parsing prose.

A failed wait_visible(timeout=60) renders like this:

xa11y.TimeoutError: Timeout after 60.0s; waiting for: visible; selector:
dialog[name^="Submit to AWS Deadline Cloud"]; last observed: selector never
matched; candidates: window "Untitled — Cinema 4D"
search scope (bounded):
application "Cinema 4D" (pid=4242)
application "Finder" (pid=512)

Reading it back:

  • waiting for names the wait condition. The wait_* methods report visible, attached, enabled, and so on. An auto-waiting action reports press target actionable (visible && enabled), an event wait reports event matching predicate, and wait_until reports custom predicate.
  • selector is the full selector the locator was resolving.
  • last observed is the final poll’s observation, which is what separates the two failure modes when debugging:
    • selector never matched means the element was never there (wrong selector, wrong window, app not ready).
    • matched button "Export" (visible=false, enabled=true, focused=false) means the element was there in the wrong state. No candidate or scope sweep is attached in this case. The states already tell the story.
    • selector matched 2 element(s); nth(5) requested means an nth() index out of range.
  • candidates lists near-misses: elements with the selector’s target role but different attributes. A typo like button[name="Exprot"] lists button "Export" right in the error.
  • search scope (bounded) is a depth-limited, line-capped snapshot of where the search happened: a tree dump for scoped locators, the running application list for rootless ones and app lookups. This replaces the print(app.dump()) wrapper you’d otherwise write around the failing call.

Selector misses from fail-fast calls (element(), tree(), dump()) carry the same context on SelectorNotMatchedError.

Diagnosis collection is bounded and failure-path-only: candidate lists and snapshots are truncated (a miss against a huge tree cannot produce a megabyte message), and nothing is collected unless the operation has already failed.

import xa11y
try:
dialog.wait_visible(timeout=60)
except xa11y.TimeoutError as e:
e.condition # "visible"
e.selector # 'dialog[name^="Submit"]'
e.last_observed # 'selector never matched'
e.candidates # ['window "Untitled — Cinema 4D"', ...]
e.scope # bounded tree dump / app list (str)
e.elapsed # 60.0 (seconds, float)

Every attribute is always present (None / [] when not applicable), so no hasattr guards are needed. SelectorNotMatchedError exposes the same fields (elapsed is always None there).

App.by_pid / App.by_name / App.find timeouts report a bounded list of the apps that were running, so an attach failure on an opaque CI runner is diagnosable from the message:

xa11y.SelectorNotMatchedError: No element matched selector:
application[pid=4242]; waiting for: application discovery; last observed:
enumerated 12 running apps, 1 without a resolvable pid; candidates:
"Finder" (pid=512), "Terminal" (pid=890), …

Subscription.wait_for timeouts report how many non-matching events arrived, and distinguish a quiet stream from a disconnected one:

Timeout after 5.0s; waiting for: event matching predicate; last observed:
event source disconnected after 3 non-matching event(s) — no further
events will arrive

This behavior is governed by design tenet 6 (errors carry their own diagnosis) in AGENTS.md. The structured carrier is the Diagnosis struct in xa11y-core/src/error.rs. Its module docs describe the pattern: enrich at the terminal failure site, keep poll-loop retry signals cheap, bound every payload, and never let diagnosis collection mask the original error. New failure paths should attach a Diagnosis rather than interpolating context into ad-hoc message strings.