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 }