diff --git a/companion/Sources/ElementTypeName.swift b/companion/Sources/ElementTypeName.swift new file mode 100644 index 0000000..d5f517d --- /dev/null +++ b/companion/Sources/ElementTypeName.swift @@ -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" + } +} diff --git a/companion/Sources/Snapshot.swift b/companion/Sources/Snapshot.swift new file mode 100644 index 0000000..fc43dbf --- /dev/null +++ b/companion/Sources/Snapshot.swift @@ -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) + } +}