Skip to content

Desktop Testing

xa11y’s Locator pattern makes it natural to write integration tests for desktop apps — find elements, interact with them, wait for changes, 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() — element appears or disappears
  • wait_enabled() / wait_disabled() — element becomes interactive or not
  • wait_attached() / wait_detached() — element exists in the tree or not
  • wait_focused() / wait_unfocused() — element gains or loses focus
  • wait_until(predicate) — arbitrary condition on the resolved element

Always use waits instead of sleeping — they’re faster (resolve as soon as the condition is met) and more reliable (won’t flake on slow machines).

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: an explicit per-call timeout= always wins, then the programmatic set_default_timeout() value, then the environment variable, then 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 — 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 is: launch the app as a subprocess, then attach 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)

Two situations need more than a PID:

  • The UI registers 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 — 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 ships 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 itself ships on macOS and Windows too — separate Rust integration tests cover those platforms; the published examples just lock in their Linux behavior automatically.)

Each file covers the same flow: launch a subprocess, poll the accessibility API until the OS registers it, dump the tree to discover selectors, exercise the locator + wait + action surface, subscribe to events, and tear 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 — 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. Instead of sleeping then asserting, combine the wait and the condition — the test passes as soon as the UI is ready.
  • Keep timeouts generous in CI. CI machines are slower. Use 5–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.