feat(companion): serve accessibility snapshots over the wire protocol

This commit is contained in:
pj committed 2026-06-07 15:41:12 +05:30
1 parent 42fde86b9c
commit cd2865bbd5
2 files changed
+155

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
import XCTest
// Maps XCUIElement.ElementType to the capitalized conventional type names the
// Go mapper consumes. textView is deliberately "TextArea": the Go side treats
// "TextArea" and "TextField" as the editable types.
func elementTypeName(_ elementType: XCUIElement.ElementType) -> String {
switch elementType {
case .application: return "Application"
case .group: return "Group"
case .window: return "Window"
case .sheet: return "Sheet"
case .drawer: return "Drawer"
case .alert: return "Alert"
case .dialog: return "Dialog"
case .button: return "Button"
case .radioButton: return "RadioButton"
case .radioGroup: return "RadioGroup"
case .checkBox: return "CheckBox"
case .disclosureTriangle: return "DisclosureTriangle"
case .popUpButton: return "PopUpButton"
case .comboBox: return "ComboBox"
case .menuButton: return "MenuButton"
case .toolbarButton: return "ToolbarButton"
case .popover: return "Popover"
case .keyboard: return "Keyboard"
case .key: return "Key"
case .navigationBar: return "NavigationBar"
case .tabBar: return "TabBar"
case .tabGroup: return "TabGroup"
case .toolbar: return "Toolbar"
case .statusBar: return "StatusBar"
case .table: return "Table"
case .tableRow: return "TableRow"
case .tableColumn: return "TableColumn"
case .outline: return "Outline"
case .outlineRow: return "OutlineRow"
case .browser: return "Browser"
case .collectionView: return "CollectionView"
case .slider: return "Slider"
case .pageIndicator: return "PageIndicator"
case .progressIndicator: return "ProgressIndicator"
case .activityIndicator: return "ActivityIndicator"
case .segmentedControl: return "SegmentedControl"
case .picker: return "Picker"
case .pickerWheel: return "PickerWheel"
case .switch: return "Switch"
case .toggle: return "Toggle"
case .link: return "Link"
case .image: return "Image"
case .icon: return "Icon"
case .searchField: return "SearchField"
case .scrollView: return "ScrollView"
case .scrollBar: return "ScrollBar"
case .staticText: return "StaticText"
case .textField: return "TextField"
case .secureTextField: return "SecureTextField"
case .datePicker: return "DatePicker"
case .textView: return "TextArea"
case .menu: return "Menu"
case .menuItem: return "MenuItem"
case .menuBar: return "MenuBar"
case .menuBarItem: return "MenuBarItem"
case .map: return "Map"
case .webView: return "WebView"
case .incrementArrow: return "IncrementArrow"
case .decrementArrow: return "DecrementArrow"
case .timeline: return "Timeline"
case .ratingIndicator: return "RatingIndicator"
case .valueIndicator: return "ValueIndicator"
case .splitGroup: return "SplitGroup"
case .splitter: return "Splitter"
case .relevanceIndicator: return "RelevanceIndicator"
case .colorWell: return "ColorWell"
case .helpTag: return "HelpTag"
case .matte: return "Matte"
case .dockItem: return "DockItem"
case .ruler: return "Ruler"
case .rulerMarker: return "RulerMarker"
case .grid: return "Grid"
case .levelIndicator: return "LevelIndicator"
case .cell: return "Cell"
case .layoutArea: return "LayoutArea"
case .layoutItem: return "LayoutItem"
case .handle: return "Handle"
case .stepper: return "Stepper"
case .tab: return "Tab"
case .touchBar: return "TouchBar"
case .statusItem: return "StatusItem"
case .any: return "Any"
case .other: return "Other"
@unknown default: return "Other"
}
}
+62
View File
@@ -0,0 +1,62 @@
import XCTest
// Flattens an accessibility snapshot tree into the legacy flat element array.
enum Snapshot {
// Produces the flat element array for the given bundle identifier. Falls back
// to springboard if the requested app is not running or its snapshot throws.
static func elements(bundleIdentifier: String) -> [[String: Any]] {
if let captured = try? capture(bundleIdentifier: bundleIdentifier) {
return captured
}
if let fallback = try? capture(bundleIdentifier: "com.apple.springboard") {
return fallback
}
return []
}
private static func capture(bundleIdentifier: String) throws -> [[String: Any]] {
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
let root = try application.snapshot()
var result: [[String: Any]] = []
walk(root, into: &result)
return result
}
private static func walk(_ node: XCUIElementSnapshot, into result: inout [[String: Any]]) {
result.append(serialize(node))
for child in node.children {
walk(child, into: &result)
}
}
private static func serialize(_ node: XCUIElementSnapshot) -> [String: Any] {
let frame = node.frame
var element: [String: Any] = [
"type": elementTypeName(node.elementType),
"frame": [
"x": Double(frame.origin.x),
"y": Double(frame.origin.y),
"width": Double(frame.size.width),
"height": Double(frame.size.height),
],
"enabled": node.isEnabled,
"AXLabel": nullableString(node.label),
"AXValue": nullableString(stringifyValue(node.value)),
"AXUniqueId": nullableString(node.identifier),
]
// Keep the dictionary keys deterministic for readers; values stay as set.
_ = element
return element
}
private static func nullableString(_ value: String?) -> Any {
guard let value = value, !value.isEmpty else { return NSNull() }
return value
}
private static func stringifyValue(_ value: Any?) -> String? {
guard let value = value else { return nil }
if let text = value as? String { return text }
return String(describing: value)
}
}