Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Accessibility Inspector Tree Snapshot for Audit

Catching an accessibility regression means comparing what the tree looked like before a change to what it looks like after. Xcode's Accessibility Inspector is excellent for interactive audits but does not emit a structured JSON hierarchy you can diff in CI. This guide pairs the inspector with the open-source xctree CLI for JSON export, then runs a short Python diff that flags every node where a label, role or value moved — each mapped to the WCAG 2.2 success criterion it affects.

What Accessibility Inspector Gives You, And What It Doesn't

Open the inspector from Xcode → Open Developer Tool → Accessibility Inspector. Point it at a running macOS process or an iOS Simulator app and you see the hierarchy VoiceOver consumes, with the attributes that map to WCAG 2.2:

AttributeWCAG 2.2 success criterion
label (accessible name)4.1.2 Name, Role, Value; 2.5.3 Label in Name
role4.1.2 Name, Role, Value
value4.1.2 Name, Role, Value
hint3.3.2 Labels or Instructions
enabled state4.1.2 Name, Role, Value
Structural relationships1.3.1 Info and Relationships

The inspector's Audit tab exports its automated-check findings as a readable report, which is fine for a one-off review but is not structured for scripted diffing between builds. For that you need the raw tree.

JSON Capture With xctree

xctree is a Swift CLI that reads the same public macOS Accessibility APIs the inspector uses, targeting apps in a booted iOS Simulator and emitting either colour-coded text or JSON. The project README lists three prerequisites: macOS 15 (Sequoia) or later, Xcode installed, and a running iOS Simulator instance. The terminal that runs xctree also needs Accessibility permission under System Settings → Privacy & Security → Accessibility. The tool works only against the iOS Simulator, not physical devices — if you need the macOS process tree, use the inspector interactively.

# Install via the project's Homebrew tap
brew tap ldomaradzki/xctree
brew install xctree

# Boot a simulator, launch the app under test, then capture the tree.
# Use a UDID you confirmed with `xcrun simctl list devices available`.
: "${SIMULATOR_UDID:?set SIMULATOR_UDID to the target simulator}"
xcrun simctl boot "$SIMULATOR_UDID" || true
xctree --format json > "accessibility-snapshot-$(date +%Y%m%dT%H%M%S).json"

Per the README each JSON node carries identifier, label, role, and traits, plus children for nested elements. Run xctree --help and inspect one real export from your own app before trusting any specific key — the Python diff below normalises missing keys to None so a future schema addition does not silently hide regressions:

{
  "identifier": "sign-in-button",
  "label": "Sign In",
  "role": "AXButton",
  "traits": ["button"],
  "children": []
}

An empty label on a tappable element is a 4.1.2 problem. A button whose role flips to AXStaticText after a refactor stops announcing as interactive. The diff below catches both.

The Python Diff Script

Run this after each build against the previous baseline. It exits non-zero when a label disappears, a role changes, or an element is dropped from the tree — enough to fail a CI step before merge. The code is a small example, not an executed benchmark: validate it against two real exports from your app before wiring it to a merge gate.

#!/usr/bin/env python3
"""
a11y_diff.py — compare two xctree JSON snapshots and report accessibility changes.
Usage: python3 a11y_diff.py baseline.json current.json
Exit:  0 if no regressions, 1 if one or more regressions, 2 on usage error.
"""

import json
import sys
from pathlib import Path

WCAG_MAP = {
    "label":   ("4.1.2 Name, Role, Value", "2.5.3 Label in Name"),
    "role":    ("4.1.2 Name, Role, Value",),
    "value":   ("4.1.2 Name, Role, Value",),
    "hint":    ("3.3.2 Labels or Instructions",),
    "enabled": ("4.1.2 Name, Role, Value",),
}
ATTRS = list(WCAG_MAP.keys())

def flatten(node, path=""):
    key = node.get("identifier") or (
        f"{path}/{node.get('role', '?')}:{node.get('label', '')}"
    )
    out = {key: {a: node.get(a) for a in ATTRS}}
    for child in node.get("children", []) or []:
        out.update(flatten(child, key))
    return out

def load(path):
    data = json.loads(Path(path).read_text())
    if isinstance(data, list):
        flat = {}
        for n in data:
            flat.update(flatten(n))
        return flat
    return flatten(data)

def main(baseline_path, current_path):
    baseline, current = load(baseline_path), load(current_path)
    removed = set(baseline) - set(current)
    added = set(current) - set(baseline)
    regressions = len(removed)

    for k in sorted(removed):
        print(f"REMOVED  {k}  [WCAG 4.1.2 element missing]")

    for key in sorted(set(baseline) & set(current)):
        for attr in ATTRS:
            bv, cv = baseline[key].get(attr), current[key].get(attr)
            if bv == cv:
                continue
            is_regression = (
                (attr == "label" and bv and not cv)
                or (attr == "role" and bv is not None and bv != cv)
                or (attr == "enabled" and bv is True and cv is False)
            )
            tag = "FAIL" if is_regression else "WARN"
            regressions += int(is_regression)
            wcag = ", ".join(WCAG_MAP[attr])
            print(f"{tag}  {key}  {attr}: {bv!r} -> {cv!r}  [{wcag}]")

    for k in sorted(added):
        print(f"ADDED    {k}  (review — a new unlabelled control is itself a regression)")

    print(f"\n{regressions} regression(s).")
    return 1 if regressions else 0

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print("Usage: python3 a11y_diff.py baseline.json current.json")
        sys.exit(2)
    sys.exit(main(sys.argv[1], sys.argv[2]))

Treat WARN rows (new label text, new value, new hint) as triage items — not every rewording is a regression, but one that breaks "Label in Name" for voice control is. Treat ADDED keys as a review step: a new control appearing without a stable identifier is often itself the bug.

Wiring It To Your Build

A pre-test Xcode run-script phase is the simplest local hook. Keep it defensive so a developer build with no simulator booted does not fail, and use a unique path per run so parallel schemes do not clobber each other:

set -euo pipefail
SNAP_DIR="${BUILT_PRODUCTS_DIR}/accessibility-snapshots"
mkdir -p "$SNAP_DIR"
STAMP="$(date +%Y%m%dT%H%M%S)-$$"
SHA="$(git rev-parse --short HEAD 2>/dev/null || echo nogit)"
OUT="$SNAP_DIR/tree_${SHA}_${STAMP}.json"
if xcrun simctl list devices booted | grep -q '(Booted)'; then
  xctree --format json > "$OUT"
  echo "wrote $OUT"
else
  echo "no booted simulator; skipping accessibility snapshot" >&2
fi

In CI, store the baseline snapshot as a build artefact keyed to the main branch, then invoke a11y_diff.py against it on each pull request. Because xctree needs a booted iOS Simulator, the runner must be a Mac that can boot the matching simulator runtime.

Manual Acceptance Worksheet

Before you trust the gate, walk through this once against your own app:

  1. Boot one simulator, launch your main screen, run xctree --format json > a.json. Close the app, reopen it, run again to produce b.json. The diff between a.json and b.json should report zero FAIL rows. If it does not, your app's identifiers are unstable and need accessibilityIdentifier set before any diff is meaningful.
  2. Delete the label from one test-only control, rebuild, snapshot as c.json. Confirm a11y_diff.py a.json c.json prints a FAIL row for that key with WCAG 4.1.2.
  3. Change that same test control from a button to a static label, snapshot as d.json. Confirm a FAIL row for the role change.
  4. Keep a.json as the branch baseline; keep d.json as a known-bad fixture so you can regression-test the diff script itself.

Running It On A Figment Mac

The inspector GUI needs a desktop session; xctree only needs the simulator and a Terminal. Two Figment plans fit, with different trade-offs — check current terms on the Figment Mac hosting page before buying.

  • Virtual Intel macOS, $29/month — 2 vCPU, 8 GiB RAM, 80 GiB disk, accessed through a browser console. The VM runs real macOS on Linux/KVM with no GPU acceleration and no audio; the install image is Sequoia. You can run xctree from the VM's desktop Terminal against a booted iOS Simulator. Compatible Intel toolchains, including supported Xcode 26 or earlier per Apple's porting guide, build and sign iOS targets and drive the simulator. Simulator rendering without GPU acceleration is slower than on an Apple silicon host — fine for structural tree snapshots, less suited to heavy visual QA. ARM-only execution, Xcode 27, and visionOS targets require an M-series Mac.
  • M-series Mac, from $95/month — for Apple silicon execution, Xcode 27, and GPU-backed simulators. The specific chip, RAM, and disk at the entry price are confirmed in the quote; physical plans collect requests until supply is confirmed, so ask for availability before you schedule a migration date.

For a like-for-like price sanity check: CloudMac publishes a Mac mini M4 / 16 GB / 256 GB at $99.99/month, and its homepage lists an annual option at $999/year (an effective $83.25/month, or $141 less per year than 12 × $95). Figment's $95 is an unspecified M-series entry, not a verified M4/16 GB, so the useful comparison is entry price and included work: Managed Build Mac at $249/month adds CI setup, backups, monitoring, and 30 minutes of monthly support on one M-series Mac; Managed Mac Team at $999/month covers two Macs, two CI runners, team setup, backups, monitoring, and one hour of monthly support. If you need only CI-driven snapshot diffing and no live inspector session, start with the $29 VM and request a quote at /mac-hosting/start. If you need VoiceOver on Apple silicon with a real display, choose M-series.


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms