Architecture & Design
This page is for contributors and for consumers who want a clearer picture of what xa11y is built on, how design decisions are made, and how the library is hardened. If you are looking for usage docs, start at How xa11y works. For how the library is verified (the test suites, the toolkit fleet, and CI), see How xa11y Is Tested.
Architecture at a glance
Section titled “Architecture at a glance”xa11y is a Rust workspace with a platform-independent core, three platform backends, and the language bindings on top of them.
xa11y-coreholds platform-independent types, traits, and the selector engine. Anything that does not need OS-specific FFI lives here.xa11y-linuxis the AT-SPI2 backend overzbus, plus alibxkbcommon+uinputinput backend.xa11y-macosis theAXUIElementbackend with an ObjC exception-safety layer (exception_safe.m).xa11y-windowsis the UI Automation backend.xa11yis the umbrella crate that picks the right backend at compile time and exposes the public Rust API.xa11y-python(PyO3 / maturin) andxa11y-js(napi-rs) are the language bindings layered on top of the umbrella crate.
The public surface (App, Element, Locator, Subscription, Selector) is platform-agnostic. Each backend fills in a small set of provider traits, and xa11y-core is responsible for everything that can be cross-platform: the selector parser, the locator engine, retry/wait loops, error mapping, and screenshot encoding.
Actions (press, set_value, type_text, …) are exposed on both Locator and Element. Locator is the auto-waiting wrapper: it re-resolves its selector and waits for visible+enabled before dispatching. Element calls go straight through to the provider handle captured at fetch time, skipping re-resolution and the wait, and surface ElementStale if the handle is gone. Both shapes share a single underlying provider call, so action fidelity (tenet 3) is enforced in one place.
Design tenets
Section titled “Design tenets”The tenets below are the firm defaults for every new piece of provider or binding code. They are restated verbatim in CLAUDE.md so reviewers and AI agents apply them consistently.
1. No silent fallbacks
Section titled “1. No silent fallbacks”If an operation fails, return the error rather than silently trying a different mechanism. Fallbacks hide bugs and make behaviour unpredictable for consumers. Surface failures clearly so callers can handle them.
Anti-patterns this rules out:
let _ = some_call();swallowing aResultwhose outcome matters..ok()used to coerceResult → Optionand discard the error reason.if let Ok(x) = ... { ... }that treats a real error as “no match”.- Try-A-then-B-then-C fallback chains where each step hides the original failure.
If multiple mechanisms really do need to be tried, do it explicitly with logged reasoning, not silent fall-through.
2. Only expose what accessibility APIs support
Section titled “2. Only expose what accessibility APIs support”If a platform has no accessibility interface for an operation, don’t paper over it with input simulation. Leave the action out of the element’s actions list. Input simulation lives in its own surface (InputSim) and is never reached from locator.press() automatically.
3. Action fidelity
Section titled “3. Action fidelity”If an element advertises an action name in its actions list, calling that action must invoke the original platform action, never a substitute. The verbs press, toggle, focus, select, expand, collapse are semantic and may legitimately dispatch to different platform APIs (e.g. press on Windows can resolve to UIA Invoke, Toggle, SelectionItem.Select, or ExpandCollapse based on the element’s primary-activation pattern). What is forbidden is advertising one verb and calling something that does not carry out its semantics (e.g. simulating a click instead of calling the API).
4. Fail surfaceably, not fatally
Section titled “4. Fail surfaceably, not fatally”Prefer Result over .unwrap() / .expect() in provider and binding code.
- Locks:
.lock().unwrap()on caches becomes.lock().unwrap_or_else(|e| e.into_inner()), because poisoning in a memoization cache is recoverable. - Platform FFI returns: never
.unwrap()a CF / AX / UIA / AT-SPI2 return. Map toError::Platform. - Tests may use
.expect("...")with a descriptive message when the failure indicates a broken fixture. - Any new
.unwrap()must have an invariant one line above that proves it can’t panic.
5. Blocking calls release the host runtime’s lock
Section titled “5. Blocking calls release the host runtime’s lock”In language bindings, any call that can block, sleep, or poll (waits, auto-waiting actions, attach/discovery loops, event receives) must release the host runtime’s global lock for the duration of the block. That means Python’s GIL, or the platform equivalent. A wait that holds the GIL freezes every other thread in the consumer’s process for up to the full timeout, and pushes consumers into architectural workarounds such as moving an in-process mock server out into a separate process.
The one legitimate reason to hold the lock is calling back into the host language, such as a Python predicate in wait_until or App.find. Reacquire it per callback rather than for the whole loop. A missing allow_threads on a blocking path is a correctness bug rather than a missed optimisation.
Enforced for Python by xa11y-python/tests/test_gil_release.py, which asserts that a background thread keeps making progress while a native wait blocks.
6. Errors carry their own diagnosis
Section titled “6. Errors carry their own diagnosis”An error must contain enough context to understand the failure without re-running it under extra logging. If a consumer would need to wrap a call in try/except to print which selector, what condition, and what the tree looked like, that state belongs in the error itself.
- A timeout that reports only its duration is a bug. It must name what it was waiting for and what it last observed.
- “Not found” errors should describe where they searched and what they did find, including near-miss candidates.
- Bindings expose the context as structured fields rather than prose in a message string, so harnesses can act on it programmatically.
- Collection is bounded and runs on the failure path alone. Diagnosis must never slow the success path or emit megabyte messages, and an error that a poll loop uses as a retry signal must stay cheap.
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. Bound every payload. Never let diagnosis collection mask the original error. What this produces for a consumer is described in Errors & Diagnosis.
Breaking a tenet
Section titled “Breaking a tenet”These are firm defaults. A real exception has to clear three bars:
- It needs explicit human approval, so agents must pause and ask.
- It is documented at the call site with a
// TENET-BREAK(<N>):comment explaining why the break is justified and what the alternative would cost. - It is greppable (
rg 'TENET-BREAK'), so the full set of exceptions stays visible and reviewable.
Testing strategy
Section titled “Testing strategy”The testing philosophy has two pillars: integration tests against real, live applications and layered coverage so that platform-specific bugs are caught at the layer that owns them.
Layers
Section titled “Layers”- Unit tests (
cargo xtask test) cover selector parsing, locator/wait loops, error mapping, and per-backend logic that doesn’t need a live AX tree. Run on every push on Linux, macOS, and Windows. - Rust core integration suite (
xa11y/tests/integ/, run viacargo xtask test-integ) is the fast-path validation suite for the core API surface. All tests are#[ignore]and target the AccessKit + winit test app. Event subscription is split per-platform (events_linux.rs,events_macos.rs,events_windows.rs). - Per-app compatibility suites (
tests/<framework>/) are Python integration tests that drive each major UI framework end-to-end through xa11y. They confirm that real-world AX trees, roles, and actions behave correctly with frameworks other than AccessKit. JS suites add a partial second-binding check. - Fuzz targets (
xa11y/fuzz/for libFuzzer;xa11y-fuzz/for live-app stress) put randomised stress on the public API, the selector engine, tree operations, and serde round-trips. - Linux Wayland uinput e2e drives
LinuxInputProviderthrough its public API on the runner and reads events back vialibevdev, verifying wire-level codes/values for the input-simulation surface.
Cross-cutting choices
Section titled “Cross-cutting choices”- Live targets only. Every integration test runs against a real running application, never a recorded fixture. Live tests catch backend bugs that recorded ones miss.
- One coverage vehicle per concern. Input simulation and screenshot capture exercise platform input/screenshot APIs, not AX-framework compatibility, so they are tested once per platform (against the Tauri app, which runs on all three) rather than against every framework.
- Test-app-first. When an integration test needs a widget the test app doesn’t expose, the test app gets the widget before the test is added. This keeps tests deterministic and prevents the suite from drifting away from a stable target surface.
- Coverage matrix as a CI gate.
tests/matrix.yamlis machine-readable.tests/matrix_check.pyruns in CI and fails the build when a row drifts away from what’s documented. Gaps must be declared, not silently introduced. - Bindings parity check.
cargo xtask check-bindings-paritykeeps the Rust, Python, and JS public surfaces aligned so a feature added to one binding cannot quietly miss the others.
Test-suite map
Section titled “Test-suite map”Which app is exercised on which platform, by which suite, and for which
features is tabulated once, in
How xa11y Is Tested. The
authoritative index behind that table is
tests/matrix.yaml,
which is where a new app, platform, or feature gets declared.
The declarations there are machine-checked. tests/matrix_check.py parses the integ matrix out of ci.yml and fails the build when an app’s declared platforms: doesn’t match the cells that exist, so a platform an app claims but never runs on cannot go unnoticed the way the missing Tauri-on-Windows cell did.
CI matrix
Section titled “CI matrix”CI runs every push and pull request, with RUSTFLAGS: -Dwarnings so warnings fail the build. The main jobs:
| Job | Runner | What it does |
|---|---|---|
| Linux | ubuntu-latest |
Workspace unit tests + AccessKit Rust integ suite under Xvfb + dbus. |
| Linux Wayland (uinput) | ubuntu-latest |
uinput e2e for the Wayland input backend; reads back via libevdev. |
| macOS | macos-latest |
Workspace unit tests. (TCC-bound integ tests run locally. See below.) |
| Windows | windows-latest |
Workspace unit tests + AccessKit Rust integ suite via UIA. |
| Windows (SendInput) | windows-latest |
Wire-level tests for the SendInput backend, read back via low-level hooks. |
| Integ (per app × OS) | matrix | One app × one OS × Python+JS+CLI suites via tests/harness/launch.py. |
| Lint & Format | ubuntu-latest |
cargo fmt, clippy -Dwarnings, README sync, bindings parity, matrix check. |
| Python bindings | ubuntu-latest |
maturin build, ruff, pytest for the Python wheel. |
| JS bindings | matrix (3 OS) | napi build, type-check, unit tests on Linux + macOS + Windows. |
| Docs build | ubuntu-latest |
Generates Python/JS API docs and builds the Starlight site. |
| Fuzz | ubuntu-latest |
10 s per fuzz target on every push for cheap regression coverage. |
| License check | ubuntu-latest |
cargo deny check licenses. |
| Cross-compile | ubuntu-latest |
cargo check -p xa11y-core for x86_64 and aarch64 across Linux/macOS. |
macOS Rust integ tests for the AccessKit app are not wired into CI: they require kTCCServiceAccessibility to be granted to a binary whose path is cargo-hashed, which has proven fragile on hosted runners. They run locally through ./scripts/run_integ_tests_macos.sh and are #[ignore]d so cargo test --workspace skips them.
Pre-PR checklist
Section titled “Pre-PR checklist”Before opening a PR, run cargo xtask check. It chains formatting (fmt --check), lint (clippy + ruff + Python Rust check), unit tests, and Python bindings. For provider or test-app changes, also run cargo xtask test-integ. The same checks run in CI, so fixing them locally first keeps review tight.
Platform notes
Section titled “Platform notes”macOS: ObjC exception safety
Section titled “macOS: ObjC exception safety”All raw CoreFoundation / AX FFI calls in xa11y-macos/src/ax.rs go through wrappers in exception_safe.m. A misbehaving AX value’s -release or -getTypeID can throw an NSException that unwinds through extern "C" and aborts the process; the wrappers contain those throws inside @try / @catch. New CF/AX calls add a safe_* wrapper if one doesn’t exist. The rule is enforced by cargo xtask check-macos-ffi, which fails the build if a raw CF/AX symbol is referenced outside a comment in ax.rs.
Linux: input subsystem
Section titled “Linux: input subsystem”Wayland input simulation goes through uinput. The Wayland uinput e2e tests run directly on the runner rather than in a container, because Docker’s /dev tmpfs isolation hides the udev-minted /dev/input/eventN node for the virtual device, even with --device /dev/uinput and a bind mount. The container path is still useful for non-Linux developer hosts.
Windows: UI Automation
Section titled “Windows: UI Automation”Hosted windows-latest runners do have an interactive desktop session, so UIA event subscriptions work in CI. The Rust integ harness launches the AccessKit test app, waits for it to register with UIA, runs the #[ignore]d tests with --test-threads=1, and tears the app down.
Windows: input subsystem
Section titled “Windows: input subsystem”Input simulation goes through SendInput. The wire-level tests in xa11y-windows/tests/send_input_wire.rs install WH_KEYBOARD_LL and WH_MOUSE_LL hooks on a dedicated pumping thread, drive the backend, and assert the virtual-key codes, extended and injected flags, wheel deltas, and absolute pointer coordinates that reached the wire. The hooks swallow the injected events and pass real ones through, so the suite observes what SendInput emitted without any of it landing on a window, and running it locally does not take over the machine. This is the Windows counterpart of the libevdev readback described above, and it covers the backend with no app in the loop, keeping a WebView2 quirk in the Tauri cell distinguishable from a backend bug.