Testing in CI
xa11y drives the real accessibility tree of a running application, so the hard part of CI isn’t xa11y. It’s standing up an environment where an accessibility API is actually available. A bare CI runner usually has no display, no accessibility bus, and (on macOS) no permission to read other apps’ trees. This guide covers what each platform needs.
The requirements themselves (a display, a D-Bus session, the AT-SPI bridge,
the macOS Accessibility permission) are generic to any CI system (GitLab
CI, CircleCI, Jenkins, a plain container, or your laptop). Only the
setup-a11y composite action and the
YAML snippets are specific to GitHub Actions: they’re a convenience wrapper
so you don’t have to wire the generic steps up by hand. If you’re not on
GitHub Actions, skip to Doing it without the
action for the same setup as plain
shell commands.
The examples below run tests with pytest, but xa11y exposes the same API
from Python, JavaScript, Rust, and the xa11y CLI, so substitute whichever
test runner your suite uses. Nothing in the CI setup is tied to a particular
language.
Why it’s tricky
Section titled “Why it’s tricky”This section is CI-agnostic, describing the underlying platform requirements rather than anything specific to GitHub Actions. Each platform exposes accessibility differently, and each has a precondition that fails silently if you miss it:
| Platform | API | What CI is missing by default |
|---|---|---|
| Linux | AT-SPI2 (D-Bus) | A display, a D-Bus session, and the AT-SPI bridge all have to be started |
| macOS | AXUIElement | The test process isn’t granted the Accessibility (TCC) permission |
| Windows | UI Automation | Nothing. UIA works on hosted runners out of the box |
The failure mode is the same on Linux and macOS: queries return an empty tree rather than an error, so it looks like your app has no UI. The sections below show how to satisfy each precondition.
The setup-a11y action (GitHub Actions)
Section titled “The setup-a11y action (GitHub Actions)”This section is specific to GitHub Actions. The quickest path on GitHub is
the xa11y/setup-a11y action. It
installs the Linux system libraries, brings up a headless display + D-Bus +
AT-SPI, and (on macOS) grants the Accessibility permission, then exports the
environment so every later step in the job inherits it.
jobs: a11y-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: python-version: "3.12"
- name: Set up accessibility environment uses: xa11y/setup-a11y@v1 with: package-groups: base # add 'gtk' / 'electron' for those toolkits # extra-packages: my-app-runtime-deps
- run: pip install xa11y pytest - run: pytest tests/ # DISPLAY, DBUS, and AT-SPI are live hereInputs
Section titled “Inputs”| Input | Default | Description |
|---|---|---|
package-groups |
base |
Space-separated apt groups: base, gtk, electron. |
extra-packages |
"" |
Extra apt packages your app needs. |
install-deps |
true |
Set false to skip apt entirely. |
setup-display |
true |
Start Xvfb and export DISPLAY (Linux). |
setup-atspi |
true |
Start D-Bus + AT-SPI and export their env (Linux). |
setup-window-manager |
false |
Start fluxbox, needed by toolkits (e.g. egui) that only publish their tree once a window is mapped/focused. |
display |
:99 |
X display number for Xvfb. |
macos-tcc-client |
"" |
Path to the binary to grant Accessibility (TCC) on macOS, e.g. your python interpreter. When empty, the action warns instead. |
The Linux package set is defined once, in
apt-packages.txt,
grouped by purpose. Pick the groups your test app’s toolkit needs:
- base covers the headless display and AT-SPI stack. Always include this.
- gtk covers GTK3/4 and WebKitGTK (GTK apps, Tauri).
- electron covers the Chromium/Electron runtime libraries.
macOS won’t let one process read another’s accessibility tree unless it holds
the Accessibility TCC permission. That is a macOS requirement, not a
GitHub Actions one. On a hosted runner you grant it to whichever process runs
your tests: the Python or Node interpreter, the xa11y CLI binary, or your
own test harness. The GitHub Actions example below grants it to the Python
interpreter:
- uses: actions/setup-python@v6 with: python-version: "3.12"- name: Resolve python path id: py run: echo "path=$(python -c 'import sys; print(sys.executable)')" >> "$GITHUB_OUTPUT"
- uses: xa11y/setup-a11y@v1 with: macos-tcc-client: ${{ steps.py.outputs.path }}If you skip this, queries return an empty tree and the action prints a
warning. Granting TCC writes to the system TCC database (allowed on hosted
runners because SIP only protects /System) and restarts tccd so the grant
takes effect immediately.
Granting TCC without the action
Section titled “Granting TCC without the action”Outside GitHub Actions, on a different CI system or a local headless run, use
the standalone helper scripts/grant_macos_tcc.sh.
Pass it the resolved interpreter path (TCC matches the real on-disk
binary, not a venv symlink):
scripts/grant_macos_tcc.sh "$(.venv/bin/python -c 'import sys; print(sys.executable)')"Windows
Section titled “Windows”UI Automation is available on hosted windows-latest runners, which have an
interactive desktop session, so just run your tests.
Doing it without the action (any CI)
Section titled “Doing it without the action (any CI)”If you’re not on GitHub Actions, or can’t use the composite action, here’s the underlying setup for Linux as plain shell commands. This is exactly what the action automates, and it works on any CI system or a local machine:
# 1. Headless displayexport DISPLAY=:99Xvfb :99 -screen 0 1280x1024x24 -ac &sleep 1
# 2. Run everything inside a D-Bus sessiondbus-run-session -- bash -c ' # 3. Start + enable the AT-SPI bridge /usr/libexec/at-spi-bus-launcher --launch-immediately & sleep 1 /usr/libexec/at-spi2-registryd & sleep 1 dbus-send --session --dest=org.a11y.Bus /org/a11y/bus \ org.freedesktop.DBus.Properties.Set \ string:org.a11y.Status string:IsEnabled variant:boolean:true
# 4. Run your tests pytest tests/'Running a pytest-xa11y suite
Section titled “Running a pytest-xa11y suite”If your suite uses pytest-xa11y, install it alongside
xa11y and give the run the flags CI needs and a laptop does not:
- run: pip install xa11y pytest pytest-xa11y - run: | pytest tests/ \ --xa11y-startup-timeout=90 \ --xa11y-artifacts=artifacts/ - uses: actions/upload-artifact@v4 if: failure() with: name: xa11y-artifacts path: artifacts/--xa11y-startup-timeout covers a cold runner, where a first launch pays for
page cache misses and an accessibility bridge that is still coming up. The
30-second default is tuned for a warm machine.
--xa11y-artifacts writes a screenshot of every live application on each
failing test and names the path in the failure report. Upload the directory so
the screenshots outlive the runner. The accessibility tree, the app’s output,
and xa11y’s own error diagnosis are already in the report, so a failure is
usually readable without them.
On macOS and Windows runners without the input grant, declare it:
pytest tests/ --xa11y-skip=input_simNeither platform can be probed for it. CGEventPost returns void, so synthetic
events are discarded silently and every layer reports success. Without the
flag, tests marked @pytest.mark.xa11y_requires("input_sim") run and assert
against a UI that never received the input. XA11Y_SKIP_INPUT_SIM=1 does the
same job for a runner-wide setting.
One thing to know before reaching for pytest-xdist: tests needing
input_sim, and tests marked xa11y_frontmost, skip when more than one worker
is running. Input synthesis and the macOS frontmost slot are process-global,
and parallel workers take them from each other. Split those tests into a
serial job rather than losing them to a silent skip.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
| Empty tree on Linux | AT-SPI bridge not running or org.a11y.Status.IsEnabled is false. Confirm setup-atspi ran. |
| Empty tree on macOS | The test process wasn’t granted Accessibility (TCC). Set macos-tcc-client. |
| App not found by name on Linux | The app was launched via cargo run, which changes the AT-SPI process name. Run the built binary directly. |
| Window never appears in the tree | The toolkit needs a window manager. Set setup-window-manager: true. |
Xvfb fails to start |
The display number is taken. Set a different display (e.g. :98). |
| Tests hang or flake on macOS | An onboarding window (Setup Assistant) holds front-app focus. Dismiss it before running input-driven tests. |
| Tests skip with “needs exclusive use of the desktop session” | pytest-xdist is running more than one worker. Run the input and frontmost tests with -p no:xdist or -n0. |