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 disappearswait_enabled()/wait_disabled()— element becomes interactive or notwait_attached()/wait_detached()— element exists in the tree or notwait_focused()/wait_unfocused()— element gains or loses focuswait_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).
The default timeout
Section titled “The default timeout”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)); // RustOr 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.
Attaching to the app under test
Section titled “Attaching to the app under test”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 inxa11y.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.
Example: testing a calculator
Section titled “Example: testing a calculator”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(())}import xa11yimport subprocessimport pytest
@pytest.fixturedef calculator(): """Launch Calculator and return an App handle.""" proc = subprocess.Popen(["open", "-a", "Calculator"]) yield xa11y.App.by_name("Calculator") proc.terminate()
def test_calculator_multiplication(calculator): calc = calculator
# Wait for the app to be ready calc.locator("window").wait_visible(timeout=5.0)
# 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 result = calc.locator("static_text[name='Result']") result.wait_until(lambda element: element and element.value == "56", timeout=5.0) assert result.element().value == "56"import { App } from '@crowecawcaw/xa11y';import { test } from 'node:test';import assert from 'node:assert/strict';
test('calculator multiplication', async () => { const calc = await App.byName('Calculator');
// Wait for the app to be ready await calc.locator('window').waitVisible(5);
// Press 7 × 8 = await calc.locator("button[name='7']").press(); await calc.locator("button[name='×']").press(); await calc.locator("button[name='8']").press(); await calc.locator("button[name='=']").press();
// Wait for the result to update, then assert const result = calc.locator("static_text[name='Result']"); await result.waitUntil((el) => el?.value === '56', { timeout: 5000 }); assert.equal((await result.element()).value, '56');});Full runnable example
Section titled “Full runnable example”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:
cargo build -p xa11y-test-app -p xa11y# pick your language:cargo run -p xa11y-example-end-to-end # Rustpython 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(())}Full source: examples/python/end_to_end.py
"""End-to-end xa11y example: drive the AccessKit test app from launch to teardown.
This script is a complete, copy-pasteable starting point for writing your firstxa11y test in Python. 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.* 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``, ``checked``).* Subscribing to accessibility events with ``app.subscribe`` and the ``Subscription.wait_for`` helper.* Tearing the subprocess down cleanly.
Run from the repo root:
cargo build -p xa11y-test-app python examples/python/end_to_end.py
Prerequisites: ``xa11y`` installed (``pip install -e xa11y-python``). On macOS,the Python interpreter needs accessibility permission. On Linux, an X serverand an AT-SPI registry must be running."""
from __future__ import annotations
import subprocessimport sysfrom pathlib import Path
import xa11y
REPO_ROOT = Path(__file__).resolve().parents[2]BINARY = REPO_ROOT / "target" / "debug" / ("xa11y-test-app.exe" if sys.platform == "win32" else "xa11y-test-app")STARTUP_TIMEOUT = 30.0
def main() -> int: if not BINARY.exists(): sys.exit(f"Build the test app first: cargo build -p xa11y-test-app (looked at {BINARY})")
# 1. Launch the test app as a subprocess. The example owns its lifecycle so # that a CI run never leaks processes between attempts. proc = subprocess.Popen([str(BINARY)]) try: # 2. Wait for the accessibility API to expose the app. ``by_pid`` polls # internally until the timeout elapses; ``App.by_name`` is the # alternative for apps you didn't launch yourself. app = xa11y.App.by_pid(proc.pid, timeout=STARTUP_TIMEOUT) print(f"App registered: {app.name} (pid={app.pid})")
# 3. Dump the tree once to discover the role/name of every element. # Copy a selector out of this output instead of guessing. print("\n--- Tree (depth 4) ---") print(app.dump(max_depth=4))
# 4. Locators auto-wait and re-resolve on every operation, so they # stay correct even if the UI mutates between calls. submit = app.locator('button[name="Submit"]') submit.wait_visible(timeout=5.0)
# 5. Read element properties. Locators with a single match expose the # underlying ``Element`` via ``.element()``. button = submit.element() assert button.role == "button", button.role assert button.enabled, "Submit should be enabled at startup" assert "press" in button.actions, button.actions
# 6. Press the primary button. submit.press()
# 7. Drive a text input. ``wait_until`` polls until the predicate is # true — preferable to a fixed ``time.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`` — surfaced as ``ActionNotSupportedError``). # 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. name_field = app.locator('text_field[name="Name"]') try: name_field.set_value("Ada Lovelace") except xa11y.ActionNotSupportedError: print("note: set_value not supported by this provider (e.g. Linux AT-SPI on AccessKit)") else: try: name_field.wait_until( lambda el: el is not None and el.value == "Ada Lovelace", timeout=2.0, ) except xa11y.TimeoutError: # Some providers accept set_value but don't echo it back # through the tree. The call still went through. print("note: text value not echoed back via accessibility (adapter quirk)")
# 8. Toggle the checkbox via the ``press`` semantic verb and confirm # the new state with ``wait_until``. checkbox = app.locator('check_box[name="I agree to terms"]') before = checkbox.element().checked checkbox.press() checkbox.wait_until(lambda el: el is not None and el.checked != before, timeout=2.0) print(f"checkbox toggled: {before!r} -> {checkbox.element().checked!r}")
# 9. Iterate matching elements. ``.elements()`` returns all matches. buttons = app.locator("button").elements() print(f"discovered {len(buttons)} buttons total") assert len(buttons) >= 2
# 10. Subscribe to events, trigger a press, and wait for the next # event. In real code you would filter the predicate by # ``e.event_type`` and/or ``e.target`` fields. Here we just # demonstrate the API — pressing Submit mutates ``status_text`` # on the test app so an event is guaranteed to fire shortly after. with app.subscribe() as sub: submit.press() event = sub.wait_for(lambda _e: True, timeout=5.0) target_name = event.target.name if event.target else None print(f"observed event: {event.event_type} on {target_name!r}")
print("\nOK — example completed successfully.") return 0 finally: proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=5)
if __name__ == "__main__": sys.exit(main())Full source: examples/js/end_to_end.mjs
// End-to-end xa11y example: drive the AccessKit test app from launch to teardown.//// This script is a complete, copy-pasteable starting point for writing your// first xa11y test in JavaScript. 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.byPid` with a `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`, `setValue`).// * Wait helpers: `waitVisible` (seconds) and `waitUntil` (milliseconds).// * Reading element fields (`role`, `name`, `actions`, `checked`).// * Subscribing to events with `app.subscribe()` and the// `Subscription.waitFor` helper.// * Tearing the subprocess down cleanly.//// Run from the repo root, after building the test app and JS bindings://// cargo build -p xa11y-test-app// (cd xa11y-js && npm install && npm run build:debug)// node examples/js/end_to_end.mjs
import { spawn } from 'node:child_process';import { existsSync } from 'node:fs';import { createRequire } from 'node:module';import { dirname, resolve } from 'node:path';import { setTimeout as sleep } from 'node:timers/promises';import { fileURLToPath } from 'node:url';import assert from 'node:assert/strict';
const __dirname = dirname(fileURLToPath(import.meta.url));const REPO_ROOT = resolve(__dirname, '..', '..');
const isWindows = process.platform === 'win32';const BINARY = resolve( REPO_ROOT, 'target', 'debug', isWindows ? 'xa11y-test-app.exe' : 'xa11y-test-app',);
// Load the locally-built CJS JS bindings via createRequire. Dynamic `import()`// on a CJS module exposes named exports through Node's static-analysis// heuristic, which can miss some class exports; createRequire returns the// full `module.exports` object so destructuring is reliable.const require = createRequire(import.meta.url);const xa11y = require(resolve(REPO_ROOT, 'xa11y-js', 'index.js'));const { App, SelectorNotMatchedError, PlatformError, TimeoutError, ActionNotSupportedError } = xa11y;
const STARTUP_TIMEOUT_MS = 30_000;
async function waitForRegistration(pid) { const deadline = Date.now() + STARTUP_TIMEOUT_MS; let lastErr; while (Date.now() < deadline) { try { return await App.byPid(pid, { timeout: 1000 }); } catch (err) { if (err instanceof SelectorNotMatchedError || err instanceof PlatformError) { lastErr = err; await sleep(200); continue; } throw err; } } throw new Error(`Test app (pid=${pid}) did not register within ${STARTUP_TIMEOUT_MS}ms: ${lastErr}`);}
async function main() { if (!existsSync(BINARY)) { console.error(`Build the test app first: cargo build -p xa11y-test-app (looked at ${BINARY})`); process.exit(1); }
// 1. Launch the test app. The example owns its subprocess lifecycle so a CI // run never leaks processes between attempts. const proc = spawn(BINARY, [], { stdio: 'ignore' }); try { // 2. Poll the accessibility API until the OS registers the new process. const app = await waitForRegistration(proc.pid); console.log(`App registered: ${app.name} (pid=${app.pid})`);
// 3. Dump the tree once to discover the role/name of every element. Copy // selectors out of this output instead of guessing. console.log('\n--- Tree (depth 4) ---'); console.log(await app.dump(4));
// 4. Locators auto-wait and re-resolve on every call, so they stay correct // even if the UI mutates between operations. const submit = app.locator('button[name="Submit"]'); await submit.waitVisible(5); // timeout in seconds for native wait helpers
// 5. Read element fields via .element(). const button = await submit.element(); assert.equal(button.role, 'button'); assert.equal(button.enabled, true); assert.ok(button.actions.includes('press'));
// 6. Press the primary button. await submit.press();
// 7. Drive a text input. `waitUntil` polls until the predicate is true — // preferable to a fixed sleep. Timeout is milliseconds, mirroring // other Node APIs. // // Some platform providers don't implement editable-text writes for // every widget (e.g. Linux AT-SPI's AccessKit bridge doesn't expose // `EditableText` — surfaced as `ActionNotSupportedError`). 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. const nameField = app.locator('text_field[name="Name"]'); try { await nameField.setValue('Ada Lovelace'); try { await nameField.waitUntil((el) => el !== undefined && el.value === 'Ada Lovelace', { timeout: 2000, }); } catch (err) { if (!(err instanceof TimeoutError)) throw err; // Some providers accept setValue but don't echo it back through the // tree; the call still went through. console.log('note: text value not echoed back via accessibility (adapter quirk)'); } } catch (err) { if (!(err instanceof ActionNotSupportedError)) throw err; console.log('note: setValue not supported by this provider (e.g. Linux AT-SPI on AccessKit)'); }
// 8. Toggle the checkbox via the `press` semantic verb and confirm the // new state with `waitUntil`. const checkbox = app.locator('check_box[name="I agree to terms"]'); const before = (await checkbox.element()).checked; await checkbox.press(); await checkbox.waitUntil((el) => el !== undefined && el.checked !== before, { timeout: 2000, }); const after = (await checkbox.element()).checked; console.log(`checkbox toggled: ${JSON.stringify(before)} -> ${JSON.stringify(after)}`);
// 9. Iterate matching elements with `.elements()`. const buttons = await app.locator('button').elements(); console.log(`discovered ${buttons.length} buttons total`); assert.ok(buttons.length >= 2);
// 10. Subscribe to events, trigger a press, and wait for the next event. // In real code you'd filter the predicate by `event.eventType` and/or // by `event.target` fields. Here we just demonstrate the API — // pressing Submit mutates `status_text` on the test app so an event // is guaranteed to fire shortly after. The Subscription is an // EventEmitter; `waitFor` is the convenience wrapper for one-shot // waits. const sub = await app.subscribe(); try { const evPromise = sub.waitFor(() => true, { timeout: 5000 }); await submit.press(); const event = await evPromise; const targetName = event.target ? event.target.name : null; console.log(`observed event: ${event.eventType} on ${JSON.stringify(targetName)}`); } finally { sub.close(); }
console.log('\nOK — example completed successfully.'); } finally { proc.kill('SIGTERM'); await new Promise((r) => proc.once('exit', r)); }}
await main();Full source: examples/cli/end_to_end.sh
#!/usr/bin/env bash# End-to-end xa11y CLI example: drive the AccessKit test app from the shell.## This script is a complete, copy-pasteable starting point for using the# `xa11y` CLI as a debugging or scripting tool. It targets the AccessKit# test app shipped with this repo (`test-apps/accesskit`) so it runs# identically on Linux, macOS, and Windows (Git Bash).## What it demonstrates:## * Listing accessible apps with `xa11y apps`.# * Dumping the tree with `xa11y tree` to discover selectors.# * Finding elements with `xa11y find` (pretty / bounds / center output).# * Dispatching actions with `xa11y action` (press, set_value).## Run from the repo root, after building:## cargo build -p xa11y-test-app -p xa11y# bash examples/cli/end_to_end.sh
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# Locate the test-app binary (handle Windows .exe).if [ -x "$REPO_ROOT/target/debug/xa11y-test-app" ]; then APP_BIN="$REPO_ROOT/target/debug/xa11y-test-app"elif [ -x "$REPO_ROOT/target/debug/xa11y-test-app.exe" ]; then APP_BIN="$REPO_ROOT/target/debug/xa11y-test-app.exe"else echo "Build the test app first: cargo build -p xa11y-test-app" >&2 exit 1fi
# Locate the xa11y CLI binary.if [ -x "$REPO_ROOT/target/debug/xa11y" ]; then CLI="$REPO_ROOT/target/debug/xa11y"elif [ -x "$REPO_ROOT/target/debug/xa11y.exe" ]; then CLI="$REPO_ROOT/target/debug/xa11y.exe"else echo "Build the CLI first: cargo build -p xa11y" >&2 exit 1fi
# 1. Launch the test app in the background. We capture $! as a best-effort# cleanup target, but on Git Bash for Windows that's the subshell pid, not# the .exe's pid — so we re-discover the real pid via `xa11y apps` below."$APP_BIN" >/dev/null 2>&1 &LAUNCHER_PID=$!APP_PID=""cleanup() { [ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true kill "$LAUNCHER_PID" 2>/dev/null || true wait "$LAUNCHER_PID" 2>/dev/null || true}trap cleanup EXIT
# 2. Poll `xa11y apps` for the test app by name. The Linux/macOS process name# is "xa11y-test-app"; the Windows UIA window title is "xa11y Test App".DEADLINE=$(( $(date +%s) + 30 ))while :; do APP_PID=$("$CLI" apps 2>/dev/null | awk -F'\t' ' $2 == "xa11y-test-app" || $2 == "xa11y Test App" { print $1; exit } ') if [ -n "$APP_PID" ]; then break fi if [ "$(date +%s)" -ge "$DEADLINE" ]; then echo "Test app did not register within 30s" >&2 echo "--- xa11y apps ---" >&2 "$CLI" apps >&2 || true exit 1 fi sleep 0.2done
echo "App registered (pid=$APP_PID)"
# 3. Dump the tree once for discovery. In a real session you'd inspect it# visually to find the role and name of the element you want to drive.echoecho "--- xa11y tree --pid $APP_PID (first 30 lines) ---""$CLI" tree --pid "$APP_PID" | sed -n '1,30p'
# 4. Find: list every button. -o pretty (default) prints role/name; -o bounds# prints `X,Y,W,H` rectangles ready to pass to `xa11y click --at`.echoecho "--- xa11y find 'button' --pid $APP_PID ---""$CLI" find 'button' --pid "$APP_PID"
# 5. Action: press the Submit button. Asserts a clean exit (the test app# rebuilds its tree on action — the action result lands in subsequent# queries)."$CLI" action press 'button[name="Submit"]' --pid "$APP_PID"echo "press OK"
# 6. Action: set the value of a text field. --value supplies the new content.# Some platform providers (e.g. Linux AT-SPI on AccessKit) don't implement# EditableText and return ActionNotSupported. Surface it explicitly rather# than failing the example.if "$CLI" action set-value 'text_field[name="Name"]' --pid "$APP_PID" --value "Ada Lovelace"; then echo "set-value OK"else echo "note: set-value not supported by this provider (e.g. Linux AT-SPI on AccessKit)"fi
# 7. Action: toggle the checkbox via the `press` semantic verb. (Toggle on# web/AT-SPI checkboxes is exposed as press on this widget.)"$CLI" action press 'check_box[name="I agree to terms"]' --pid "$APP_PID"echo "toggle OK"
# 8. Find again — Submit's bounds in screen pixels, in the format the input# simulation subcommands (`xa11y click`) accept.echoecho "--- bounds of Submit ---""$CLI" find 'button[name="Submit"]' --pid "$APP_PID" -o bounds
echoecho "OK — example completed successfully."Debugging with the CLI
Section titled “Debugging with the CLI”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:
# See what's in the treexa11y tree --app MyApp
# Test a selector interactivelyxa11y find "button[name='Submit']" --app MyApp
# Try an action manuallyxa11y action press "button[name='Submit']" --app MyApp
# Watch events while interacting with the appxa11y events --app MyAppSee the Overview for the full CLI reference.
- Be specific with selectors.
button[name='7']is better thanbutton— 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_untilfor 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_TIMEOUTinstead of touching every call site.