Skip to content

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.

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.

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.

.github/workflows/test.yml
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 here
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.

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

Terminal window
scripts/grant_macos_tcc.sh "$(.venv/bin/python -c 'import sys; print(sys.executable)')"

UI Automation is available on hosted windows-latest runners, which have an interactive desktop session, so just run your tests.

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:

Terminal window
# 1. Headless display
export DISPLAY=:99
Xvfb :99 -screen 0 1280x1024x24 -ac &
sleep 1
# 2. Run everything inside a D-Bus session
dbus-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/
'

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:

Terminal window
pytest tests/ --xa11y-skip=input_sim

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

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.