Add accessibility hierarchy RPC support
Co-Authored-By: siGit Code <297239231+sigitc@users.noreply.github.com>
Seto Elkahfi committed
Aug 20, 2026 at 16:43 UTC
ecf4e8143cad55d127c084dd2dde6f5ebc4745f0
6 files changed
+347
-30
README.md
+1
-1
@@ -22,7 +22,7 @@ Add the package URL to an Xcode project or Swift package:
22
23
```swift
24
dependencies: [
25
- .package(url: "https://github.com/ondeinference/xcrs-controlkit.git", from: "0.1.0")
25
+ .package(url: "https://github.com/ondeinference/xcrs-controlkit.git", from: "1.0.0")
26
]
27
```
28
Runners/Shared/ControlKitRPCServer.swift
+67
-16
@@ -13,6 +13,20 @@ private struct JSONRPCRequest {
13
14
@MainActor
15
final class ControlKitRPCServer {
16
+ private static let protocolVersion = 1
17
+ private static let supportedMethods = [
18
+ "device.apps.launch",
19
+ "device.apps.terminate",
20
+ "device.capabilities",
21
+ "device.dump.ui",
22
+ "device.info",
23
+ "device.io.button",
24
+ "device.io.click",
25
+ "device.io.spatial.tap",
26
+ "device.io.tap",
27
+ "device.io.text"
28
+ ]
29
+
30
private let port: UInt16
31
private let host: String
32
@@ -73,31 +87,35 @@ final class ControlKitRPCServer {
87
private func dispatch(_ request: JSONRPCRequest) async throws -> Data {
88
switch request.method {
89
case "device.capabilities":
76
- let platform: XCRSControlKitPlatform = {
77
- #if os(tvOS)
78
- return .tvOS
79
- #elseif os(visionOS)
80
- return .visionOS
81
- #elseif os(macOS)
82
- return .macOS
83
- #elseif os(watchOS)
84
- return .watchOS
85
- #else
86
- return .iOS
87
- #endif
88
- }()
90
return try response(
91
result: [
91
- "platform": platform.rawValue,
92
- "capabilities": XCRSControlKitCapabilities.standard(for: platform).values.map(\.rawValue).sorted()
92
+ "platform": Self.platform.rawValue,
93
+ "capabilities": XCRSControlKitCapabilities.standard(for: Self.platform).values.map(\.rawValue).sorted(),
94
+ "methods": Self.supportedMethods
95
],
96
id: request.id
97
)
98
case "device.info":
99
return try response(
98
- result: ["port": port, "runner": "XCRSControlKit"],
100
+ result: [
101
+ "port": port,
102
+ "runner": "XCRSControlKit",
103
+ "protocolVersion": Self.protocolVersion,
104
+ "methods": Self.supportedMethods
105
+ ],
106
id: request.id
107
)
108
+ case "device.dump.ui":
109
+ let bundleIdentifier = try stringParameter("bundleId", request.params)
110
+ let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
111
+ if request.params["format"] as? String == "debug" {
112
+ return try response(
113
+ result: ["description": application.debugDescription],
114
+ id: request.id
115
+ )
116
+ }
117
+ let hierarchy = try accessibilityHierarchy(application)
118
+ return try response(result: dictionary(hierarchy), id: request.id)
119
case "device.apps.launch":
120
let bundleIdentifier = try stringParameter("bundleId", request.params)
121
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
@@ -170,6 +188,20 @@ final class ControlKitRPCServer {
188
}
189
}
190
191
+ private static var platform: XCRSControlKitPlatform {
192
+ #if os(tvOS)
193
+ return .tvOS
194
+ #elseif os(visionOS)
195
+ return .visionOS
196
+ #elseif os(macOS)
197
+ return .macOS
198
+ #elseif os(watchOS)
199
+ return .watchOS
200
+ #else
201
+ return .iOS
202
+ #endif
203
+ }
204
+
205
private func foregroundApplication(_ params: [String: Any]) -> XCUIApplication {
206
if let bundleIdentifier = params["bundleId"] as? String {
207
return XCUIApplication(bundleIdentifier: bundleIdentifier)
@@ -177,6 +209,22 @@ final class ControlKitRPCServer {
209
return XCUIApplication()
210
}
211
212
+ private func accessibilityHierarchy(
213
+ _ application: XCUIApplication
214
+ ) throws -> XCRSControlKitAccessibilityNode {
215
+ try XCRSControlKitAccessibilityParser.parse(
216
+ debugDescription: application.debugDescription
217
+ )
218
+ }
219
+
220
+ private func dictionary(_ node: XCRSControlKitAccessibilityNode) throws -> [String: Any] {
221
+ let data = try JSONEncoder().encode(node)
222
+ guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
223
+ throw RunnerError.invalidAccessibilityHierarchy
224
+ }
225
+ return object
226
+ }
227
+
228
#if !os(tvOS)
229
private func tap(_ params: [String: Any]) throws {
230
let x = try doubleParameter("x", params)
@@ -283,12 +331,15 @@ private final class RPCHandler: ChannelInboundHandler {
331
}
332
333
private enum RunnerError: LocalizedError {
334
+ case invalidAccessibilityHierarchy
335
case invalidParameter(String)
336
case unsupportedButton(String)
337
case unsupportedInteraction(String)
338
339
var errorDescription: String? {
340
switch self {
341
+ case .invalidAccessibilityHierarchy:
342
+ return "Could not serialize the accessibility hierarchy"
343
case .invalidParameter(let name):
344
return "Missing or invalid parameter: \(name)"
345
case .unsupportedButton(let button):
Runners/XCRSControlKitRunner.xcodeproj/project.pbxproj
+43
-9
@@ -94,24 +94,24 @@
94
95
/* Begin PBXFileReference section */
96
06ABEBF2BC26029BF199977C /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
97
- 08CFC4F37BB1C3444B435EC8 /* ControlKit-tvOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "ControlKit-tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
98
- 0AF2C936A45BEF3E427051E8 /* ControlKit-iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "ControlKit-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
97
+ 08CFC4F37BB1C3444B435EC8 /* ControlKit-tvOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; name = "ControlKit-tvOS.app"; path = ControlKit.app; sourceTree = BUILT_PRODUCTS_DIR; };
98
+ 0AF2C936A45BEF3E427051E8 /* ControlKit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ControlKit.app; sourceTree = BUILT_PRODUCTS_DIR; };
99
1D9522A60419FFC7F405FE88 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
100
243F4A0C8F7567EDEEE21282 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
101
- 2A9F28238A4519F25B302BD1 /* ControlKit-visionOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = "ControlKit-visionOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
101
+ 2A9F28238A4519F25B302BD1 /* ControlKit-visionOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; name = "ControlKit-visionOS.app"; path = ControlKit.app; sourceTree = BUILT_PRODUCTS_DIR; };
102
3686EC34042E55BB49FCE937 /* ControlKit-macOS-UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "ControlKit-macOS-UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
103
3DFF0D230E8E7D8DF4E8AF86 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
104
40A16F9F72DA073E165B0149 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
105
511F38504131E988C4903A29 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
106
5591C7FEF7205CCA88C10507 /* ControlKitRunnerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlKitRunnerApp.swift; sourceTree = "<group>"; };
107
- 5A26B9285A83B3312F79B286 /* ControlKit-macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ControlKit-macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
107
+ 5A26B9285A83B3312F79B286 /* ControlKit-macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = "ControlKit-macOS.app"; path = ControlKit.app; sourceTree = BUILT_PRODUCTS_DIR; };
108
5A89F5048B0CAE4988FB1203 /* ControlKit-visionOS-UITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "ControlKit-visionOS-UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
109
5FB5CFB03469D6F6B4B90338 /* ControlKitRunnerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlKitRunnerApp.swift; sourceTree = "<group>"; };
110
898E84DC2CE45F3463AA7E5D /* ControlKitRunnerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlKitRunnerApp.swift; sourceTree = "<group>"; };
111
8A09FB52496A952CB13C163B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
112
904DF2F8A56B8FA1C5EE1431 /* ControlKit-tvOS-UITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = "ControlKit-tvOS-UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
113
976BFE251DB7DC2248C8D0E7 /* ControlKitRPCServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlKitRPCServer.swift; sourceTree = "<group>"; };
114
- 9D200812648C48867D12D43D /* ControlKit-watchOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "ControlKit-watchOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
114
+ 9D200812648C48867D12D43D /* ControlKit-watchOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; name = "ControlKit-watchOS.app"; path = ControlKit.app; sourceTree = BUILT_PRODUCTS_DIR; };
115
A32F608A3A429B81416D59FC /* ControlKitRunnerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlKitRunnerApp.swift; sourceTree = "<group>"; };
116
B4A7C1166461333A52056112 /* ControlKit-macOS-UITests.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "ControlKit-macOS-UITests.entitlements"; sourceTree = "<group>"; };
117
CD648878AF85C1F1CC002EC1 /* ControlKit-watchOS-UITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "ControlKit-watchOS-UITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -206,7 +206,7 @@
206
isa = PBXGroup;
207
children = (
208
F93B11596E43814F825966AC /* ControlKit-iOS-UITests.xctest */,
209
- 0AF2C936A45BEF3E427051E8 /* ControlKit-iOS.app */,
209
+ 0AF2C936A45BEF3E427051E8 /* ControlKit.app */,
210
3686EC34042E55BB49FCE937 /* ControlKit-macOS-UITests.xctest */,
211
5A26B9285A83B3312F79B286 /* ControlKit-macOS.app */,
212
904DF2F8A56B8FA1C5EE1431 /* ControlKit-tvOS-UITests.xctest */,
@@ -423,7 +423,7 @@
423
packageProductDependencies = (
424
);
425
productName = "ControlKit-iOS";
426
- productReference = 0AF2C936A45BEF3E427051E8 /* ControlKit-iOS.app */;
426
+ productReference = 0AF2C936A45BEF3E427051E8 /* ControlKit.app */;
427
productType = "com.apple.product-type.application";
428
};
429
BBEDFC1F8A411D980AF3432F /* ControlKit-macOS-UITests */ = {
@@ -718,6 +718,10 @@
718
buildSettings = {
719
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
720
CODE_SIGN_IDENTITY = "iPhone Developer";
721
+ CODE_SIGN_STYLE = Manual;
722
+ DEVELOPMENT_TEAM = "";
723
+ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = L83LZMRLR7;
724
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
725
INFOPLIST_KEY_UIRequiresFullScreen = YES;
726
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
727
LD_RUNPATH_SEARCH_PATHS = (
@@ -726,6 +730,8 @@
730
);
731
PRODUCT_BUNDLE_IDENTIFIER = "com.smbcloud.xcrs-controlkit.ios.runner";
732
PRODUCT_NAME = ControlKit;
733
+ PROVISIONING_PROFILE_SPECIFIER = "";
734
+ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Test Runner";
735
SDKROOT = iphoneos;
736
TARGETED_DEVICE_FAMILY = "1,2";
737
};
@@ -753,6 +759,10 @@
759
buildSettings = {
760
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
761
CODE_SIGN_IDENTITY = "iPhone Developer";
762
+ CODE_SIGN_STYLE = Manual;
763
+ DEVELOPMENT_TEAM = "";
764
+ "DEVELOPMENT_TEAM[sdk=iphoneos*]" = L83LZMRLR7;
765
+ INFOPLIST_KEY_UILaunchScreen_Generation = YES;
766
INFOPLIST_KEY_UIRequiresFullScreen = YES;
767
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
768
LD_RUNPATH_SEARCH_PATHS = (
@@ -761,6 +771,8 @@
771
);
772
PRODUCT_BUNDLE_IDENTIFIER = "com.smbcloud.xcrs-controlkit.ios.runner";
773
PRODUCT_NAME = ControlKit;
774
+ PROVISIONING_PROFILE_SPECIFIER = "";
775
+ "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Test Runner";
776
SDKROOT = iphoneos;
777
TARGETED_DEVICE_FAMILY = "1,2";
778
};
@@ -886,7 +898,10 @@
898
GENERATE_INFOPLIST_FILE = YES;
899
INFOPLIST_KEY_CFBundleDisplayName = ControlKit;
900
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
889
- LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
901
+ LD_RUNPATH_SEARCH_PATHS = (
902
+ "$(inherited)",
903
+ "@executable_path/Frameworks",
904
+ );
905
MACOSX_DEPLOYMENT_TARGET = 13.0;
906
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
907
MTL_FAST_MATH = YES;
@@ -937,12 +952,16 @@
952
isa = XCBuildConfiguration;
953
buildSettings = {
954
BUNDLE_LOADER = "$(TEST_HOST)";
955
+ "CODE_SIGN_IDENTITY[sdk=appletvos*]" = "Apple Development";
956
+ CODE_SIGN_STYLE = Manual;
957
+ DEVELOPMENT_TEAM = L83LZMRLR7;
958
LD_RUNPATH_SEARCH_PATHS = (
959
"$(inherited)",
960
"@executable_path/Frameworks",
961
"@loader_path/Frameworks",
962
);
963
PRODUCT_BUNDLE_IDENTIFIER = "com.smbcloud.xcrs-controlkit.tvos.runnerUITests";
964
+ PROVISIONING_PROFILE_SPECIFIER = "Test Runner TV";
965
SDKROOT = appletvos;
966
TARGETED_DEVICE_FAMILY = 3;
967
TEST_TARGET_NAME = "ControlKit-tvOS";
@@ -953,12 +972,16 @@
972
isa = XCBuildConfiguration;
973
buildSettings = {
974
BUNDLE_LOADER = "$(TEST_HOST)";
975
+ "CODE_SIGN_IDENTITY[sdk=appletvos*]" = "Apple Development";
976
+ CODE_SIGN_STYLE = Manual;
977
+ DEVELOPMENT_TEAM = L83LZMRLR7;
978
LD_RUNPATH_SEARCH_PATHS = (
979
"$(inherited)",
980
"@executable_path/Frameworks",
981
"@loader_path/Frameworks",
982
);
983
PRODUCT_BUNDLE_IDENTIFIER = "com.smbcloud.xcrs-controlkit.tvos.runnerUITests";
984
+ PROVISIONING_PROFILE_SPECIFIER = "Test Runner TV";
985
SDKROOT = appletvos;
986
TARGETED_DEVICE_FAMILY = 3;
987
TEST_TARGET_NAME = "ControlKit-tvOS";
@@ -986,12 +1009,16 @@
1009
buildSettings = {
1010
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
1011
ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage;
1012
+ "CODE_SIGN_IDENTITY[sdk=appletvos*]" = "Apple Development";
1013
+ CODE_SIGN_STYLE = Manual;
1014
+ DEVELOPMENT_TEAM = L83LZMRLR7;
1015
LD_RUNPATH_SEARCH_PATHS = (
1016
"$(inherited)",
1017
"@executable_path/Frameworks",
1018
);
1019
PRODUCT_BUNDLE_IDENTIFIER = "com.smbcloud.xcrs-controlkit.tvos.runner";
1020
PRODUCT_NAME = ControlKit;
1021
+ PROVISIONING_PROFILE_SPECIFIER = "Test Runner TV";
1022
SDKROOT = appletvos;
1023
TARGETED_DEVICE_FAMILY = 3;
1024
};
@@ -1043,12 +1070,16 @@
1070
buildSettings = {
1071
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
1072
ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage;
1073
+ "CODE_SIGN_IDENTITY[sdk=appletvos*]" = "Apple Development";
1074
+ CODE_SIGN_STYLE = Manual;
1075
+ DEVELOPMENT_TEAM = L83LZMRLR7;
1076
LD_RUNPATH_SEARCH_PATHS = (
1077
"$(inherited)",
1078
"@executable_path/Frameworks",
1079
);
1080
PRODUCT_BUNDLE_IDENTIFIER = "com.smbcloud.xcrs-controlkit.tvos.runner";
1081
PRODUCT_NAME = ControlKit;
1082
+ PROVISIONING_PROFILE_SPECIFIER = "Test Runner TV";
1083
SDKROOT = appletvos;
1084
TARGETED_DEVICE_FAMILY = 3;
1085
};
@@ -1137,7 +1168,10 @@
1168
GENERATE_INFOPLIST_FILE = YES;
1169
INFOPLIST_KEY_CFBundleDisplayName = ControlKit;
1170
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
1140
- LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
1171
+ LD_RUNPATH_SEARCH_PATHS = (
1172
+ "$(inherited)",
1173
+ "@executable_path/Frameworks",
1174
+ );
1175
MACOSX_DEPLOYMENT_TARGET = 13.0;
1176
MTL_ENABLE_DEBUG_INFO = NO;
1177
MTL_FAST_MATH = YES;
Runners/iOS/ControlKitRunnerApp.swift
+2
-4
@@ -33,10 +33,7 @@ private struct ControlKitShowcaseView: View {
33
.foregroundStyle(Color.controlKitCream.opacity(0.78))
34
}
35
}
36
-
37
- Text("A clean-room runner for reliable black-box testing across Apple platforms.")
38
- .font(.title2.weight(.medium))
39
- .fixedSize(horizontal: false, vertical: true)
36
+ .padding(.top, 32)
37
38
statusCard
39
informationCard
@@ -104,6 +101,7 @@ private struct ControlKitShowcaseView: View {
101
Spacer()
102
Text(value)
103
.fontWeight(.semibold)
104
+ .multilineTextAlignment(.trailing)
105
}
106
}
107
}
Sources/XCRSControlKit/XCRSControlKit.swift
+183
@@ -111,6 +111,189 @@ public struct XCRSControlKitCapabilities: Codable, Equatable, Sendable {
111
}
112
}
113
114
+/// A screen-space rectangle reported by the accessibility hierarchy.
115
+public struct XCRSControlKitRect: Codable, Equatable, Sendable {
116
+ public let x: Double
117
+ public let y: Double
118
+ public let width: Double
119
+ public let height: Double
120
+
121
+ public init(x: Double, y: Double, width: Double, height: Double) {
122
+ self.x = x
123
+ self.y = y
124
+ self.width = width
125
+ self.height = height
126
+ }
127
+}
128
+
129
+/// A transport-stable accessibility element returned by `device.dump.ui`.
130
+public struct XCRSControlKitAccessibilityNode: Codable, Equatable, Sendable {
131
+ public let type: String
132
+ public let label: String?
133
+ public let name: String?
134
+ public let value: String?
135
+ public let placeholderValue: String?
136
+ public let rawIdentifier: String?
137
+ public let rect: XCRSControlKitRect
138
+ public let depth: Int?
139
+ public let enabled: Bool?
140
+ public let selected: Bool?
141
+ public let hittable: Bool?
142
+ public let children: [XCRSControlKitAccessibilityNode]
143
+
144
+ public init(
145
+ type: String,
146
+ label: String? = nil,
147
+ name: String? = nil,
148
+ value: String? = nil,
149
+ placeholderValue: String? = nil,
150
+ rawIdentifier: String? = nil,
151
+ rect: XCRSControlKitRect,
152
+ depth: Int? = nil,
153
+ enabled: Bool? = nil,
154
+ selected: Bool? = nil,
155
+ hittable: Bool? = nil,
156
+ children: [XCRSControlKitAccessibilityNode] = []
157
+ ) {
158
+ self.type = type
159
+ self.label = label
160
+ self.name = name
161
+ self.value = value
162
+ self.placeholderValue = placeholderValue
163
+ self.rawIdentifier = rawIdentifier
164
+ self.rect = rect
165
+ self.depth = depth
166
+ self.enabled = enabled
167
+ self.selected = selected
168
+ self.hittable = hittable
169
+ self.children = children
170
+ }
171
+}
172
+
173
+/// Converts XCTest's single-snapshot hierarchy into ControlKit nodes.
174
+public enum XCRSControlKitAccessibilityParser {
175
+ public static func parse(
176
+ debugDescription: String
177
+ ) throws -> XCRSControlKitAccessibilityNode {
178
+ let lines = debugDescription.components(separatedBy: .newlines)
179
+ guard
180
+ let subtreeStart = lines.firstIndex(of: "Element subtree:"),
181
+ let subtreeEnd = lines[subtreeStart...].firstIndex(of: "Path to element:")
182
+ else {
183
+ throw XCRSControlKitError.invalidRequest(
184
+ "XCTest did not return an accessibility subtree"
185
+ )
186
+ }
187
+
188
+ let nodes = lines[lines.index(after: subtreeStart)..<subtreeEnd]
189
+ .compactMap(parseLine)
190
+ guard let root = nodes.first else {
191
+ throw XCRSControlKitError.invalidRequest(
192
+ "XCTest returned an empty accessibility subtree"
193
+ )
194
+ }
195
+
196
+ return XCRSControlKitAccessibilityNode(
197
+ type: root.type,
198
+ label: root.label,
199
+ name: root.name,
200
+ value: root.value,
201
+ placeholderValue: root.placeholderValue,
202
+ rawIdentifier: root.rawIdentifier,
203
+ rect: root.rect,
204
+ depth: root.depth,
205
+ enabled: root.enabled,
206
+ selected: root.selected,
207
+ hittable: root.hittable,
208
+ children: Array(nodes.dropFirst())
209
+ )
210
+ }
211
+
212
+ private static func parseLine(
213
+ _ line: String
214
+ ) -> XCRSControlKitAccessibilityNode? {
215
+ let indentation = line.prefix(while: { $0 == " " }).count
216
+ var content = line.trimmingCharacters(in: .whitespaces)
217
+ if content.first == "→" {
218
+ content.removeFirst()
219
+ }
220
+ guard let typeEnd = content.firstIndex(of: ",") else {
221
+ return nil
222
+ }
223
+
224
+ let type = String(content[..<typeEnd])
225
+ let identifier = quotedAttribute("identifier", in: content)
226
+ let rect = rectangle(in: content) ?? XCRSControlKitRect(
227
+ x: 0,
228
+ y: 0,
229
+ width: 0,
230
+ height: 0
231
+ )
232
+ return XCRSControlKitAccessibilityNode(
233
+ type: type,
234
+ label: quotedAttribute("label", in: content),
235
+ name: identifier,
236
+ value: quotedAttribute("value", in: content),
237
+ placeholderValue: quotedAttribute("placeholderValue", in: content),
238
+ rawIdentifier: identifier,
239
+ rect: rect,
240
+ depth: indentation / 2,
241
+ enabled: !content.contains(", Disabled"),
242
+ selected: content.contains(", Selected") || content.contains(", Focused")
243
+ )
244
+ }
245
+
246
+ private static func rectangle(in line: String) -> XCRSControlKitRect? {
247
+ let pattern = #"\{\{(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\},\s*\{(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\}\}"#
248
+ guard
249
+ let expression = try? NSRegularExpression(pattern: pattern),
250
+ let match = expression.firstMatch(
251
+ in: line,
252
+ range: NSRange(line.startIndex..., in: line)
253
+ ),
254
+ match.numberOfRanges == 5
255
+ else {
256
+ return nil
257
+ }
258
+
259
+ let values = (1..<5).compactMap { index -> Double? in
260
+ guard let range = Range(match.range(at: index), in: line) else {
261
+ return nil
262
+ }
263
+ return Double(line[range])
264
+ }
265
+ guard values.count == 4 else {
266
+ return nil
267
+ }
268
+ return XCRSControlKitRect(
269
+ x: values[0],
270
+ y: values[1],
271
+ width: values[2],
272
+ height: values[3]
273
+ )
274
+ }
275
+
276
+ private static func quotedAttribute(
277
+ _ name: String,
278
+ in line: String
279
+ ) -> String? {
280
+ let escapedName = NSRegularExpression.escapedPattern(for: name)
281
+ let pattern = "\(escapedName): '(.*?)'(?=,|$)"
282
+ guard
283
+ let expression = try? NSRegularExpression(pattern: pattern),
284
+ let match = expression.firstMatch(
285
+ in: line,
286
+ range: NSRange(line.startIndex..., in: line)
287
+ ),
288
+ let valueRange = Range(match.range(at: 1), in: line)
289
+ else {
290
+ return nil
291
+ }
292
+ let value = String(line[valueRange])
293
+ return value.isEmpty ? nil : value
294
+ }
295
+}
296
+
297
/// A transport-neutral command sent to a runner.
298
public struct XCRSControlKitRequest: Codable, Equatable, Sendable {
299
public let id: String
Tests/XCRSControlKitTests/XCRSControlKitTests.swift
+51
@@ -41,4 +41,55 @@ final class XCRSControlKitTests: XCTestCase {
41
"select"
42
)
43
}
44
+
45
+ func testAccessibilityNodeHasStableTransportShape() throws {
46
+ let node = XCRSControlKitAccessibilityNode(
47
+ type: "Button",
48
+ label: "Play",
49
+ name: "play-button",
50
+ rawIdentifier: "play-button",
51
+ rect: XCRSControlKitRect(x: 100, y: 200, width: 80, height: 40),
52
+ enabled: true,
53
+ selected: false,
54
+ hittable: true
55
+ )
56
+
57
+ let object = try JSONSerialization.jsonObject(
58
+ with: JSONEncoder().encode(node)
59
+ ) as? [String: Any]
60
+ let rect = object?["rect"] as? [String: Any]
61
+
62
+ XCTAssertEqual(object?["type"] as? String, "Button")
63
+ XCTAssertEqual(object?["label"] as? String, "Play")
64
+ XCTAssertEqual(object?["rawIdentifier"] as? String, "play-button")
65
+ XCTAssertEqual(rect?["x"] as? Double, 100)
66
+ XCTAssertEqual(rect?["width"] as? Double, 80)
67
+ XCTAssertEqual((object?["children"] as? [[String: Any]])?.count, 0)
68
+ }
69
+
70
+ func testAccessibilityParserReadsSingleXCTestSnapshot() throws {
71
+ let hierarchy = """
72
+ Attributes: Application, 0x1, pid: 42, label: 'Viaplay'
73
+ Element subtree:
74
+ →Application, 0x1, pid: 42, label: 'Viaplay'
75
+ Window (Main), 0x2, {{0.0, 0.0}, {1920.0, 1080.0}}
76
+ Button, 0x3, {{576.0, 64.0}, {63.0, 32.0}}, identifier: 'Start', label: 'Hem', Focused
77
+ Path to element:
78
+ →Application, 0x1, pid: 42, label: 'Viaplay'
79
+ """
80
+
81
+ let root = try XCRSControlKitAccessibilityParser.parse(
82
+ debugDescription: hierarchy
83
+ )
84
+
85
+ XCTAssertEqual(root.type, "Application")
86
+ XCTAssertEqual(root.label, "Viaplay")
87
+ XCTAssertEqual(root.children.count, 2)
88
+ XCTAssertEqual(root.children[1].type, "Button")
89
+ XCTAssertEqual(root.children[1].label, "Hem")
90
+ XCTAssertEqual(root.children[1].rawIdentifier, "Start")
91
+ XCTAssertEqual(root.children[1].rect.x, 576)
92
+ XCTAssertGreaterThan(root.children[1].depth ?? 0, root.depth ?? 0)
93
+ XCTAssertEqual(root.children[1].selected, true)
94
+ }
95
}