1# Reading the tree on each platform
2
3Each recipe prints the tree as one line per node, or drives it, without a
4screen reader. Never point one at the owner's desktop: a platform tree needs a
5visible window, so run it in a disposable VM or a sandbox. An AccessKit app
6can dump its own tree from a hidden window instead.
7
8## AccessKit, inside the app
9
10Feed the `TreeUpdate` you'd send the adapter to `accesskit_consumer`, and print
11what the platform would show. `common_filter` drops generic containers, as the
12adapters 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
17use accesskit_consumer::{NodeRef, Tree, common_filter};
18
19fn 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
30let tree = Tree::new(full_update, true); // a grafted subtree: tree.update_and_process_changes(sub, handler)
31let mut out = String::new();
32write(&tree.state().root(), 0, &mut out);
33```
34
35Expose it as a replay step that runs after a settle (`accessibility PATH`),
36and as a CLI flag if there's no replay harness yet. To drive the tree, hand the
37app an `ActionRequest { action, target_tree, target_node, data }` the way the
38adapter would.
39
40## macOS: AX
41
42The calling process needs Accessibility permission (`AXIsProcessTrusted()`).
43A 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
48import ApplicationServices
49
50func attr(_ e: AXUIElement, _ name: String) -> AnyObject? {
51 var value: AnyObject?
52 return AXUIElementCopyAttributeValue(e, name as CFString, &value) == .success ? value : nil
53}
54
55func 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
70guard AXIsProcessTrusted() else { fatalError("Grant this terminal Accessibility in Privacy & Security") }
71walk(AXUIElementCreateApplication(pid_t(CommandLine.arguments[1])!), 0)
72```
73
74## Windows: UI Automation
75
76Run this in the VM with `powershell -ExecutionPolicy Bypass -File uia.ps1`.
77The 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
79class `Window Class`.
80Snowbound's Win7 lab tool `win7_ui` reads Win32 controls, which a custom-drawn
81app doesn't have, so use UIA there.
82
83```powershell
84Add-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
89function 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}
98Walk ($root.FindFirst('Children', $by)) 0
99```
100
101To press a button, call
102`$e.GetCurrentPattern([Windows.Automation.InvokePattern]::Pattern).Invoke()`.
103Run the same script against the reference app: OneNote 2010's class is
104`Framework::CFrame`.
105
106## Linux: AT-SPI
107
108AccessKit's Unix adapter stays dormant until the accessibility bus reports
109itself enabled. Set that in the VM's session:
110
111```sh
112busctl --user set-property org.a11y.Bus /org/a11y/bus org.a11y.Status IsEnabled b true
113```
114
115Then 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
119import gi; gi.require_version("Atspi", "2.0")
120from gi.repository import Atspi
121
122def 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
129desktop = Atspi.get_desktop(0)
130for 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.