Skip to content

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.

xa11y is a Rust workspace with a platform-independent core, three platform backends, and the language bindings on top of them.

  • xa11y-core holds platform-independent types, traits, and the selector engine. Anything that does not need OS-specific FFI lives here.
  • xa11y-linux is the AT-SPI2 backend over zbus, plus a libxkbcommon + uinput input backend.
  • xa11y-macos is the AXUIElement backend with an ObjC exception-safety layer (exception_safe.m).
  • xa11y-windows is the UI Automation backend.
  • xa11y is the umbrella crate that picks the right backend at compile time and exposes the public Rust API.
  • xa11y-python (PyO3 / maturin) and xa11y-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.

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.

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 a Result whose outcome matters.
  • .ok() used to coerce Result → Option and 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.

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).

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 to Error::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.

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.

These are firm defaults. A real exception has to clear three bars:

  1. It needs explicit human approval, so agents must pause and ask.
  2. 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.
  3. It is greppable (rg 'TENET-BREAK'), so the full set of exceptions stays visible and reviewable.

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.

  • 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 via cargo 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 LinuxInputProvider through its public API on the runner and reads events back via libevdev, verifying wire-level codes/values for the input-simulation surface.
  • 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.yaml is machine-readable. tests/matrix_check.py runs 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-parity keeps the Rust, Python, and JS public surfaces aligned so a feature added to one binding cannot quietly miss the others.

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 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.

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.

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.

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.

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.

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.