| 1 | # Reading the tree on each platform |
| 2 | |
| 3 | Each recipe prints the tree as one line per node, or drives it, without a |
| 4 | screen reader. Never point one at the owner's desktop: a platform tree needs a |
| 5 | visible window, so run it in a disposable VM or a sandbox. An AccessKit app |
| 6 | can dump its own tree from a hidden window instead. |
| 7 | |
| 8 | ## AccessKit, inside the app |
| 9 | |
| 10 | Feed the `TreeUpdate` you'd send the adapter to `accesskit_consumer`, and print |
| 11 | what the platform would show. `common_filter` drops generic containers, as the |
| 12 | adapters do. Snowbound's version is `write_accessibility` in |
| 13 | `crates/snowbound/src/main.rs`, and its test helper is `snapshot` in |
| 14 | `crates/ui/src/access/tests.rs`. |
| 15 | |
| 16 | ```rust |
| 17 | use accesskit_consumer::{NodeRef, Tree, common_filter}; |
| 18 | |
| 19 | fn write(node: &NodeRef, depth: usize, out: &mut String) { |
| 20 | let data = node.data(); |
| 21 | *out += &format!("{}{:?}", " ".repeat(depth), node.role()); |
| 22 | if let Some(label) = data.label() { *out += &format!(" {label:?}"); } |
| 23 | if let Some(value) = data.value() { *out += &format!(" = {value:?}"); } |
| 24 | if data.is_disabled() { *out += " [disabled]"; } |
| 25 | if node.is_focused() { *out += " [focused]"; } |
| 26 | out.push('\n'); |
| 27 | for child in node.filtered_children(common_filter) { write(&child, depth + 1, out); } |
| 28 | } |
| 29 | |
| 30 | let tree = Tree::new(full_update, true); // a grafted subtree: tree.update_and_process_changes(sub, handler) |
| 31 | let mut out = String::new(); |
| 32 | write(&tree.state().root(), 0, &mut out); |
| 33 | ``` |
| 34 | |
| 35 | Expose it as a replay step that runs after a settle (`accessibility PATH`), |
| 36 | and as a CLI flag if there's no replay harness yet. To drive the tree, hand the |
| 37 | app an `ActionRequest { action, target_tree, target_node, data }` the way the |
| 38 | adapter would. |
| 39 | |
| 40 | ## macOS: AX |
| 41 | |
| 42 | The calling process needs Accessibility permission (`AXIsProcessTrusted()`). |
| 43 | A hidden window exposes only the menu bar. To press a node, call |
| 44 | `AXUIElementPerformAction(element, kAXPressAction as CFString)`. |
| 45 | |
| 46 | ```swift |
| 47 | // swiftc -O ax-dump.swift -o ax-dump && ./ax-dump PID |
| 48 | import ApplicationServices |
| 49 | |
| 50 | func attr(_ e: AXUIElement, _ name: String) -> AnyObject? { |
| 51 | var value: AnyObject? |
| 52 | return AXUIElementCopyAttributeValue(e, name as CFString, &value) == .success ? value : nil |
| 53 | } |
| 54 | |
| 55 | func walk(_ e: AXUIElement, _ depth: Int) { |
| 56 | var line = String(repeating: " ", count: depth) + (attr(e, kAXRoleAttribute) as? String ?? "?") |
| 57 | for name in [kAXTitleAttribute, kAXDescriptionAttribute, kAXValueAttribute] { |
| 58 | if let text = attr(e, name) as? String, !text.isEmpty { line += " \(name)=\(text.debugDescription)" } |
| 59 | } |
| 60 | if attr(e, kAXFocusedAttribute) as? Bool == true { line += " [focused]" } |
| 61 | if attr(e, kAXEnabledAttribute) as? Bool == false { line += " [disabled]" } |
| 62 | var actions: CFArray? |
| 63 | if AXUIElementCopyActionNames(e, &actions) == .success, let names = actions as? [String], !names.isEmpty { |
| 64 | line += " {\(names.joined(separator: " "))}" |
| 65 | } |
| 66 | print(line) |
| 67 | for child in attr(e, kAXChildrenAttribute) as? [AXUIElement] ?? [] { walk(child, depth + 1) } |
| 68 | } |
| 69 | |
| 70 | guard AXIsProcessTrusted() else { fatalError("Grant this terminal Accessibility in Privacy & Security") } |
| 71 | walk(AXUIElementCreateApplication(pid_t(CommandLine.arguments[1])!), 0) |
| 72 | ``` |
| 73 | |
| 74 | ## Windows: UI Automation |
| 75 | |
| 76 | Run this in the VM with `powershell -ExecutionPolicy Bypass -File uia.ps1`. |
| 77 | The script finds the window by its class name. Matching Snowbound's title |
| 78 | ("… · Garden.one") found nothing, but its class worked. winit names its window |
| 79 | class `Window Class`. |
| 80 | Snowbound's Win7 lab tool `win7_ui` reads Win32 controls, which a custom-drawn |
| 81 | app doesn't have, so use UIA there. |
| 82 | |
| 83 | ```powershell |
| 84 | Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes |
| 85 | $root = [Windows.Automation.AutomationElement]::RootElement |
| 86 | $by = New-Object Windows.Automation.PropertyCondition( |
| 87 | [Windows.Automation.AutomationElement]::ClassNameProperty, "Window Class") |
| 88 | $walker = [Windows.Automation.TreeWalker]::ControlViewWalker |
| 89 | function Walk($e, $depth) { |
| 90 | $c = $walker.GetFirstChild($e) |
| 91 | while ($c -ne $null) { |
| 92 | (" " * $depth) + $c.Current.ControlType.ProgrammaticName + " '" + $c.Current.Name + "'" + |
| 93 | $(if ($c.Current.HasKeyboardFocus) { " [focused]" }) + $(if (-not $c.Current.IsEnabled) { " [disabled]" }) |
| 94 | Walk $c ($depth + 1) |
| 95 | $c = $walker.GetNextSibling($c) |
| 96 | } |
| 97 | } |
| 98 | Walk ($root.FindFirst('Children', $by)) 0 |
| 99 | ``` |
| 100 | |
| 101 | To press a button, call |
| 102 | `$e.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern).Invoke()`. |
| 103 | Run the same script against the reference app: OneNote 2010's class is |
| 104 | `Framework::CFrame`. |
| 105 | |
| 106 | ## Linux: AT-SPI |
| 107 | |
| 108 | AccessKit's Unix adapter stays dormant until the accessibility bus reports |
| 109 | itself enabled. Set that in the VM's session: |
| 110 | |
| 111 | ```sh |
| 112 | busctl --user set-property org.a11y.Bus /org/a11y/bus org.a11y.Status IsEnabled b true |
| 113 | ``` |
| 114 | |
| 115 | Then walk the tree with `python3-gi`. Snowbound's `linux_ui` tool in |
| 116 | `tools/w7/linux_desktop.py` does this under Xvfb. |
| 117 | |
| 118 | ```python |
| 119 | import gi; gi.require_version("Atspi", "2.0") |
| 120 | from gi.repository import Atspi |
| 121 | |
| 122 | def walk(node, depth=0): |
| 123 | states = node.get_state_set() |
| 124 | line = " " * depth + f"{node.get_role_name()} {node.get_name()!r}" |
| 125 | if states.contains(Atspi.StateType.FOCUSED): line += " [focused]" |
| 126 | print(line) |
| 127 | for i in range(node.get_child_count()): walk(node.get_child_at_index(i), depth + 1) |
| 128 | |
| 129 | desktop = Atspi.get_desktop(0) |
| 130 | for i in range(desktop.get_child_count()): |
| 131 | app = desktop.get_child_at_index(i) |
| 132 | if app.get_process_id() == PID: walk(app) |
| 133 | ``` |
| 134 | |
| 135 | ## Web |
| 136 | |
| 137 | - **Playwright** gives you the tree two ways: |
| 138 | - `await page.getByRole("toolbar").ariaSnapshot()` prints a subtree as YAML; |
| 139 | - `await expect(locator).toMatchAriaSnapshot(...)` pins it in a test. |
| 140 | |
| 141 | Drive the page with `getByRole(role, { name })`, not CSS selectors, so a |
| 142 | missing name or role fails the test. |
| 143 | - **CDP**: `Accessibility.getFullAXTree` returns Chrome's computed tree from |
| 144 | any CDP session. |
| 145 | - **The in-app browser and Claude in Chrome**: `read_page` returns the |
| 146 | accessibility tree with refs, and `find` searches it. Clicking a ref acts |
| 147 | through it. |
| 148 | - **Rules**: `@axe-core/playwright`'s `new AxeBuilder({ page }).analyze()` |
| 149 | returns violations. Run it in each theme and at a narrow width. |
| 150 | - **Refresh after every action.** Refs and indices go stale after a menu |
| 151 | opens or the focus moves. Agents updating Snowbound's tracker through |
| 152 | Safari's accessibility tree acted on the wrong control until they re-read |
| 153 | the tree after each step. |
| 154 | |
| 155 | ## iOS |
| 156 | |
| 157 | - **XCUITest**: `print(XCUIApplication().debugDescription)` prints the element |
| 158 | tree. Query elements by label or identifier (`app.buttons["Bold"]`) and act |
| 159 | on them. |
| 160 | - **Accessibility Inspector** (Xcode › Open Developer Tool) can target the |
| 161 | simulator without VoiceOver. It's interactive, so use it for spot checks. |
| 162 | - **Custom actions**: log `accessibilityCustomActions` at run time. |
| 163 | Snowbound's reorder actions were verified that way. |
| 164 | |
| 165 | ## Android |
| 166 | |
| 167 | - `adb shell uiautomator dump && adb shell cat /sdcard/window_dump.xml` gives |
| 168 | each node's class, `text`, `content-desc`, `clickable`, `focusable` and |
| 169 | bounds. |
| 170 | - In Espresso tests, `AccessibilityChecks.enable()` fails a test on missing |
| 171 | labels, small targets and low contrast. |