Skip to content

Desktop Testing

xa11y’s Locator pattern makes it natural to write integration tests for desktop apps. Find elements, act on them, wait for the UI to settle, and assert on the result. If you’ve used Playwright for web testing, the workflow is familiar.

Desktop UIs update asynchronously. After pressing a button, the result might not appear for a few frames. Locator wait_*() methods poll until a condition is met or a timeout expires:

  • wait_visible() / wait_hidden() for an element appearing or disappearing
  • wait_enabled() / wait_disabled() for an element becoming interactive
  • wait_attached() / wait_detached() for an element entering or leaving the tree
  • wait_focused() / wait_unfocused() for an element gaining or losing focus
  • wait_until(predicate) for an arbitrary condition on the resolved element

Always use waits instead of sleeping. They resolve as soon as the condition is met, and they won’t flake on a slow machine.

Auto-waiting action methods (press(), set_value(), …), the wait_*() family, and app lookup (App.by_name / App.by_pid) all share one process-wide default timeout: 5 seconds. When that’s too tight (cold CI runners are the classic case), raise it once instead of threading timeout= through every call site:

xa11y.set_default_timeout(30.0) # Python (seconds)
setDefaultTimeout(30); // JavaScript (seconds)
xa11y::set_default_timeout(Duration::from_secs(30)); // Rust

Or set the XA11Y_DEFAULT_TIMEOUT environment variable (seconds, read once at startup). Resolution order runs from most to least specific. An explicit per-call timeout= always wins. After that comes the programmatic set_default_timeout() value, the environment variable, and finally the built-in 5 seconds. A timeout of 0 means a single attempt with no polling.

Because Locator actions (press(), set_value(), toggle(), …) already auto-wait for the target to be visible and enabled, a short wait_visible() immediately before an action is redundant. Pass a per-call timeout= when one step needs a longer window, or raise the default once when the whole suite does.

The same rule applies to finding things: every entry point that can race app startup has a polling form built in, so a hand-rolled while/sleep loop is never needed. Built-in polling also releases the host language’s runtime lock while it waits (Python threads keep running during a 60s wait_visible) and produces a structured diagnosis on timeout, covering what it waited for, what it last observed, and near-miss candidates (see Errors & Diagnosis). A manual loop gets none of that.

The usual test flow launches the app as a subprocess, then attaches by PID. App.by_pid polls until the OS registers the process with the accessibility API:

proc = subprocess.Popen([app_path])
app = xa11y.App.by_pid(proc.pid, timeout=60.0)

A PID is not always enough on its own.

  • The UI may register as more than one accessibility app. Some toolkits register top-level windows as separate accessibility applications sharing the host PID. On Windows UIA, a Qt dialog can appear as its own app rather than under the main window’s tree (see Accessibility API Quirks). Match it with a predicate instead of polling App.list() by hand:

    dialog_app = xa11y.App.find(
    lambda a: a.pid == proc.pid and a.name.startswith("My Dialog"),
    timeout=60.0,
    )
  • The element belongs to an app you can’t predict. Message boxes and system popups are sometimes hosted by a different app than the one showing them. A locator created with the module-level xa11y.locator() searches from the system root, across every running app, so you can wait for the popup without knowing its owner:

    popup_text = xa11y.locator("static_text[name^='Saved the submission']")
    popup = popup_text.wait_visible(timeout=10.0)
    # scope follow-up actions to whichever app the popup landed in
    xa11y.App.by_pid(popup.pid).locator("button[name='OK']").press()

    System-root searches visit every app’s tree, so keep the selector specific and prefer app-scoped locators everywhere else.

This test opens Calculator, performs 7 × 8, and asserts the result is 56.

use xa11y::*;
use std::time::Duration;
#[test]
fn calculator_multiplication() -> Result<()> {
let calc = App::by_name("Calculator", std::time::Duration::from_secs(5))?;
let timeout = Duration::from_secs(5);
// Wait for the app to be ready
calc.locator("window").wait_visible(timeout)?;
// Press 7 × 8 =
calc.locator("button[name='7']").press()?;
calc.locator("button[name='×']").press()?;
calc.locator("button[name='8']").press()?;
calc.locator("button[name='=']").press()?;
// Wait for the result to update, then assert
let result = calc.locator("static_text[name='Result']");
result.wait_until(
|element| element.and_then(|e| e.value.as_deref()) == Some("56"),
timeout,
)?;
assert_eq!(result.element()?.value.as_deref(), Some("56"));
Ok(())
}

The repo carries an end-to-end example for each binding that drives the in-tree AccessKit test app (test-apps/accesskit) from launch to teardown. CI runs all four files on Linux against the AccessKit test app, so they can’t rot. (The library supports macOS and Windows too. Separate Rust integration tests cover those platforms, and the published examples lock in their Linux behavior automatically.)

Each file covers the same flow. It launches a subprocess and polls the accessibility API until the OS registers it. It dumps the tree to discover selectors, exercises the locator, wait, and action surface, subscribes to events, then tears the subprocess down cleanly.

To run from a fresh checkout:

Terminal window
cargo build -p xa11y-test-app -p xa11y
# pick your language:
cargo run -p xa11y-example-end-to-end # Rust
python examples/python/end_to_end.py # Python (after `pip install -e xa11y-python`)
node examples/js/end_to_end.mjs # JavaScript (after `cd xa11y-js && npm install && npm run build:debug`)
bash examples/cli/end_to_end.sh # CLI (xa11y binary)

Full source: examples/rust/src/main.rs

//! End-to-end xa11y example: drive the AccessKit test app from launch to teardown.
//!
//! This binary is a complete, copy-pasteable starting point for writing your
//! first xa11y program in Rust. It targets the AccessKit test app shipped
//! with this repo (`test-apps/accesskit`) so it runs identically on Linux,
//! macOS, and Windows.
//!
//! What it demonstrates:
//!
//! * Launching a test app and polling the accessibility API until the OS
//! registers it (`App::by_pid` with a `Duration` timeout).
//! * Dumping the tree (`App::dump`) to discover the role and name of every
//! element before writing selectors.
//! * The `Locator` pattern with auto-waiting actions (`press`, `set_value`).
//! * Wait helpers: `wait_visible`, `wait_until`.
//! * Reading element fields (`role`, `name`, `actions`, `states.checked`).
//! * Subscribing to events with `App::subscribe` and `Subscription::wait_for`.
//! * Tearing the child process down cleanly with a panic-safe guard.
//!
//! Run from the repo root, after building the test app:
//!
//! ```bash
//! cargo build -p xa11y-test-app
//! cargo run -p xa11y-example-end-to-end
//! ```
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use xa11y::{App, AppExt, Error};
const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
fn binary_path() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let mut p = PathBuf::from(manifest_dir);
p.pop(); // examples/
p.pop(); // repo root
p.push("target");
p.push("debug");
p.push(if cfg!(windows) {
"xa11y-test-app.exe"
} else {
"xa11y-test-app"
});
p
}
/// Panic-safe child guard: kills the child when dropped.
struct ChildGuard(Child);
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn wait_for_registration(pid: u32) -> Result<App, Error> {
let deadline = Instant::now() + STARTUP_TIMEOUT;
let mut last = None;
while Instant::now() < deadline {
match App::by_pid(pid, Duration::from_secs(1)) {
Ok(app) => return Ok(app),
Err(err) => {
last = Some(err);
thread::sleep(Duration::from_millis(200));
}
}
}
Err(last.unwrap_or_else(|| Error::timeout(STARTUP_TIMEOUT)))
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let binary = binary_path();
if !binary.exists() {
return Err(format!(
"Build the test app first: cargo build -p xa11y-test-app (looked at {})",
binary.display()
)
.into());
}
// 1. Launch the test app. We wrap the child in a guard so a panic later
// in the example still terminates the subprocess.
let child = Command::new(&binary)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
let pid = child.id();
let _guard = ChildGuard(child);
// 2. Poll the accessibility API until the OS registers the new process.
let app = wait_for_registration(pid)?;
println!("App registered: {} (pid={:?})", app.name, app.pid);
// 3. Dump the tree once to discover the role/name of every element. Copy
// selectors out of this output instead of guessing.
println!("\n--- Tree (depth 4) ---");
println!("{}", app.dump(Some(4))?);
// 4. Locators auto-wait and re-resolve on every call, so they stay
// correct even if the UI mutates between operations.
let submit = app.locator(r#"button[name="Submit"]"#);
submit.wait_visible(Duration::from_secs(5))?;
// 5. Read element fields via `.element()`.
let button = submit.element()?;
assert_eq!(button.role, xa11y::Role::Button);
assert!(button.states.enabled, "Submit should be enabled at startup");
assert!(button.actions.iter().any(|a| a == "press"));
// 6. Press the primary button.
submit.press()?;
// 7. Drive a text input. `wait_until` polls until the predicate is
// true — preferable to a fixed `thread::sleep`.
//
// Some platform providers don't implement editable-text writes for
// every widget (e.g. Linux AT-SPI's AccessKit bridge doesn't expose
// `EditableText`). Real apps usually expose it via Qt/GTK; the test
// app here is pure AccessKit, so we tolerate the error explicitly
// rather than swallowing it silently.
let name_field = app.locator(r#"text_field[name="Name"]"#);
match name_field.set_value("Ada Lovelace") {
Ok(()) => {
let wait_result = name_field.wait_until(
|el| el.and_then(|d| d.value.as_deref()) == Some("Ada Lovelace"),
Duration::from_secs(2),
);
if let Err(Error::Timeout { .. }) = wait_result {
println!("note: text value not echoed back via accessibility (adapter quirk)");
} else {
wait_result?;
}
}
Err(Error::TextValueNotSupported) => {
println!(
"note: set_value not supported by this provider (e.g. Linux AT-SPI on AccessKit)"
);
}
Err(e) => return Err(e.into()),
}
// 8. Toggle the checkbox via the `press` semantic verb and confirm the
// new state with `wait_until`.
let checkbox = app.locator(r#"check_box[name="I agree to terms"]"#);
let before = checkbox.element()?.states.checked;
checkbox.press()?;
checkbox.wait_until(
|el| el.map(|d| d.states.checked) != Some(before),
Duration::from_secs(2),
)?;
let after = checkbox.element()?.states.checked;
println!("checkbox toggled: {:?} -> {:?}", before, after);
assert_ne!(before, after);
// 9. Iterate matching elements with `.elements()`.
let buttons = app.locator("button").elements()?;
println!("discovered {} buttons total", buttons.len());
assert!(buttons.len() >= 2);
// 10. Subscribe to events, trigger a press, and wait for the next event.
// In real code you'd filter the predicate by `e.kind` (FocusChanged,
// ValueChanged, StateChanged, ...) and/or by `e.target` fields. Here
// we just demonstrate the API by waiting for any event — pressing
// Submit on the test app mutates `status_text`, so an event is
// guaranteed to fire shortly after.
let sub = app.subscribe()?;
submit.press()?;
let event = sub.wait_for(|_| true, Duration::from_secs(5))?;
println!(
"observed event: {:?} on {:?}",
event.kind,
event.target.as_ref().and_then(|t| t.name.as_deref())
);
println!("\nOK — example completed successfully.");
Ok(())
}

The xa11y CLI is helpful when writing and debugging tests. Use it to explore the accessibility tree of your app and find the right selectors before putting them in test code:

Terminal window
# See what's in the tree
xa11y tree --app MyApp
# Test a selector interactively
xa11y find "button[name='Submit']" --app MyApp
# Try an action manually
xa11y action press "button[name='Submit']" --app MyApp
# Watch events while interacting with the app
xa11y events --app MyApp

See the Overview for the full CLI reference.

  • Be specific with selectors. button[name='7'] is better than button, because it won’t break when the UI adds more buttons.
  • Use locators, not elements, for interactions. Locators re-resolve their selector on every operation, making them resilient to UI changes.
  • Prefer wait_until for complex assertions. Combine the wait and the condition instead of sleeping then asserting, and the test passes as soon as the UI is ready.
  • Keep timeouts generous in CI. CI machines are slower. Use 5 to 10 seconds for waits even if your local machine resolves in milliseconds, or raise the process-wide default once with set_default_timeout() / XA11Y_DEFAULT_TIMEOUT instead of touching every call site.