Raw
1 import Foundation
2
3 /// The Apple platform represented by a ControlKit runner.
4 public enum XCRSControlKitPlatform: String, Codable, CaseIterable, Sendable {
5 case iOS
6 case iPadOS
7 case tvOS
8 case watchOS
9 case visionOS
10 case macOS
11 }
12
13 /// An operation supported by a ControlKit runner.
14 public enum XCRSControlKitCapability: String, Codable, CaseIterable, Sendable {
15 case accessibility
16 case screenshot
17 case appLifecycle
18 case touch
19 case textInput
20 case swipe
21 case orientation
22 case remoteButtons
23 case keyboard
24 case pointer
25 case spatialInteraction
26 case pairedDevice
27 }
28
29 /// A simulator or device controlled by a runner.
30 public struct XCRSControlKitTarget: Codable, Equatable, Sendable {
31 // MARK: - Public
32
33 public let platform: XCRSControlKitPlatform
34 public let identifier: String
35 public let name: String?
36
37 public init(
38 platform: XCRSControlKitPlatform,
39 identifier: String,
40 name: String? = nil
41 ) {
42 self.platform = platform
43 self.identifier = identifier
44 self.name = name
45 }
46 }
47
48 /// The capabilities advertised by a runner.
49 public struct XCRSControlKitCapabilities: Codable, Equatable, Sendable {
50 // MARK: - Public
51
52 public let platform: XCRSControlKitPlatform
53 public let values: Set<XCRSControlKitCapability>
54
55 public init(
56 platform: XCRSControlKitPlatform,
57 values: Set<XCRSControlKitCapability>
58 ) {
59 self.platform = platform
60 self.values = values
61 }
62
63 public func supports(_ capability: XCRSControlKitCapability) -> Bool {
64 values.contains(capability)
65 }
66
67 public static func standard(
68 for platform: XCRSControlKitPlatform
69 ) -> Self {
70 let values: Set<XCRSControlKitCapability>
71
72 switch platform {
73 case .iOS, .iPadOS:
74 values = [
75 .accessibility,
76 .screenshot,
77 .appLifecycle,
78 .touch,
79 .textInput,
80 .swipe,
81 .orientation
82 ]
83 case .tvOS:
84 values = [
85 .accessibility,
86 .screenshot,
87 .appLifecycle,
88 .remoteButtons
89 ]
90 case .watchOS:
91 values = [
92 .accessibility,
93 .screenshot,
94 .appLifecycle,
95 .pairedDevice
96 ]
97 case .visionOS:
98 values = [
99 .accessibility,
100 .screenshot,
101 .appLifecycle,
102 .spatialInteraction
103 ]
104 case .macOS:
105 values = [
106 .accessibility,
107 .screenshot,
108 .appLifecycle,
109 .keyboard,
110 .pointer
111 ]
112 }
113
114 return Self(platform: platform, values: values)
115 }
116 }
117
118 /// A screen-space rectangle reported by the accessibility hierarchy.
119 public struct XCRSControlKitRect: Codable, Equatable, Sendable {
120 // MARK: - Public
121
122 public let x: Double
123 public let y: Double
124 public let width: Double
125 public let height: Double
126
127 public init(x: Double, y: Double, width: Double, height: Double) {
128 self.x = x
129 self.y = y
130 self.width = width
131 self.height = height
132 }
133 }
134
135 /// A transport-stable accessibility element returned by `device.dump.ui`.
136 public struct XCRSControlKitAccessibilityNode: Codable, Equatable, Sendable {
137 // MARK: - Public
138
139 public let type: String
140 public let label: String?
141 public let name: String?
142 public let value: String?
143 public let placeholderValue: String?
144 public let rawIdentifier: String?
145 public let rect: XCRSControlKitRect
146 public let depth: Int?
147 public let enabled: Bool?
148 public let selected: Bool?
149 public let focused: Bool?
150 public let hittable: Bool?
151 public let children: [XCRSControlKitAccessibilityNode]
152
153 public init(
154 type: String,
155 label: String? = nil,
156 name: String? = nil,
157 value: String? = nil,
158 placeholderValue: String? = nil,
159 rawIdentifier: String? = nil,
160 rect: XCRSControlKitRect,
161 depth: Int? = nil,
162 enabled: Bool? = nil,
163 selected: Bool? = nil,
164 focused: Bool? = nil,
165 hittable: Bool? = nil,
166 children: [XCRSControlKitAccessibilityNode] = []
167 ) {
168 self.type = type
169 self.label = label
170 self.name = name
171 self.value = value
172 self.placeholderValue = placeholderValue
173 self.rawIdentifier = rawIdentifier
174 self.rect = rect
175 self.depth = depth
176 self.enabled = enabled
177 self.selected = selected
178 self.focused = focused
179 self.hittable = hittable
180 self.children = children
181 }
182 }
183
184 /// Converts XCTest's single-snapshot hierarchy into ControlKit nodes.
185 public enum XCRSControlKitAccessibilityParser {
186 // MARK: - Public
187
188 public static func parse(
189 debugDescription: String
190 ) throws -> XCRSControlKitAccessibilityNode {
191 let lines = debugDescription.components(separatedBy: .newlines)
192 guard
193 let subtreeStart = lines.firstIndex(of: "Element subtree:"),
194 let subtreeEnd = lines[subtreeStart...].firstIndex(of: "Path to element:")
195 else {
196 throw XCRSControlKitError.invalidRequest(
197 "XCTest did not return an accessibility subtree"
198 )
199 }
200
201 let nodes = lines[lines.index(after: subtreeStart)..<subtreeEnd]
202 .compactMap(parseLine)
203 guard !nodes.isEmpty else {
204 throw XCRSControlKitError.invalidRequest(
205 "XCTest returned an empty accessibility subtree"
206 )
207 }
208
209 var index = 0
210 let root = tree(from: nodes, at: &index, depth: 0)
211 guard index == nodes.endIndex else {
212 throw XCRSControlKitError.invalidRequest(
213 "XCTest returned multiple root accessibility elements"
214 )
215 }
216 return root
217 }
218
219 // MARK: - Private
220
221 private struct ParsedNode {
222 let node: XCRSControlKitAccessibilityNode
223 let indentation: Int
224 }
225
226 private static func parseLine(
227 _ line: String
228 ) -> ParsedNode? {
229 let indentation = line.prefix(while: { $0 == " " }).count
230 var content = line.trimmingCharacters(in: .whitespaces)
231 if content.first == "→" {
232 content.removeFirst()
233 }
234 guard let typeEnd = content.firstIndex(of: ",") else {
235 return nil
236 }
237
238 let type = String(content[..<typeEnd])
239 let identifier = quotedAttribute("identifier", in: content)
240 let rect = rectangle(in: content) ?? XCRSControlKitRect(
241 x: 0,
242 y: 0,
243 width: 0,
244 height: 0
245 )
246 return ParsedNode(
247 node: XCRSControlKitAccessibilityNode(
248 type: type,
249 label: quotedAttribute("label", in: content),
250 name: identifier,
251 value: quotedAttribute("value", in: content),
252 placeholderValue: quotedAttribute("placeholderValue", in: content),
253 rawIdentifier: identifier,
254 rect: rect,
255 enabled: !hasStandaloneAttribute("Disabled", in: content),
256 selected: hasStandaloneAttribute("Selected", in: content),
257 focused: hasStandaloneAttribute("Focused", in: content)
258 ),
259 indentation: indentation
260 )
261 }
262
263 private static func tree(
264 from nodes: [ParsedNode],
265 at index: inout Int,
266 depth: Int
267 ) -> XCRSControlKitAccessibilityNode {
268 let parsedNode = nodes[index]
269 index += 1
270
271 var children: [XCRSControlKitAccessibilityNode] = []
272 while index < nodes.count, nodes[index].indentation > parsedNode.indentation {
273 children.append(tree(from: nodes, at: &index, depth: depth + 1))
274 }
275
276 let node = parsedNode.node
277 return XCRSControlKitAccessibilityNode(
278 type: node.type,
279 label: node.label,
280 name: node.name,
281 value: node.value,
282 placeholderValue: node.placeholderValue,
283 rawIdentifier: node.rawIdentifier,
284 rect: node.rect,
285 depth: depth,
286 enabled: node.enabled,
287 selected: node.selected,
288 focused: node.focused,
289 hittable: node.hittable,
290 children: children
291 )
292 }
293
294 private static func rectangle(in line: String) -> XCRSControlKitRect? {
295 guard
296 let rectangleStart = line.range(of: "{{")?.upperBound,
297 let rectangleEnd = line[rectangleStart...].range(of: "}}")?.lowerBound
298 else {
299 return nil
300 }
301
302 let components = line[rectangleStart..<rectangleEnd]
303 .split(whereSeparator: { character in
304 character == "{" || character == "}" || character == ","
305 })
306 .map { $0.trimmingCharacters(in: .whitespaces) }
307 .filter { !$0.isEmpty }
308 guard
309 components.count == 4,
310 let x = Double(components[0]),
311 let y = Double(components[1]),
312 let width = Double(components[2]),
313 let height = Double(components[3])
314 else {
315 return nil
316 }
317 return XCRSControlKitRect(
318 x: x,
319 y: y,
320 width: width,
321 height: height
322 )
323 }
324
325 private static func quotedAttribute(
326 _ name: String,
327 in line: String
328 ) -> String? {
329 let prefix = "\(name): '"
330 guard let valueStart = line.range(of: prefix)?.upperBound else {
331 return nil
332 }
333
334 var index = valueStart
335 while index < line.endIndex {
336 let character = line[index]
337 if character == "'", isClosingQuote(at: index, in: line) {
338 let value = String(line[valueStart..<index])
339 return value.isEmpty ? nil : value
340 }
341 index = line.index(after: index)
342 }
343
344 return nil
345 }
346
347 private static func hasStandaloneAttribute(
348 _ attribute: String,
349 in line: String
350 ) -> Bool {
351 var segmentStart = line.startIndex
352 var index = line.startIndex
353 var quoted = false
354
355 while index < line.endIndex {
356 let character = line[index]
357 if character == "'" {
358 if quoted {
359 if isClosingQuote(at: index, in: line) {
360 quoted = false
361 }
362 } else {
363 quoted = true
364 }
365 } else if character == ",", !quoted {
366 if line[segmentStart..<index].trimmingCharacters(in: .whitespaces) == attribute {
367 return true
368 }
369 segmentStart = line.index(after: index)
370 }
371 index = line.index(after: index)
372 }
373
374 return line[segmentStart...].trimmingCharacters(in: .whitespaces) == attribute
375 }
376
377 private static func isClosingQuote(
378 at quoteIndex: String.Index,
379 in line: String
380 ) -> Bool {
381 var backslashCount = 0
382 var index = quoteIndex
383 while index > line.startIndex {
384 let previousIndex = line.index(before: index)
385 guard line[previousIndex] == "\\" else {
386 break
387 }
388 backslashCount += 1
389 index = previousIndex
390 }
391 guard backslashCount.isMultiple(of: 2) else {
392 return false
393 }
394
395 index = line.index(after: quoteIndex)
396 while index < line.endIndex, line[index].isWhitespace {
397 index = line.index(after: index)
398 }
399 return index == line.endIndex || line[index] == "," || line[index] == "}"
400 }
401 }
402
403 /// A transport-neutral command sent to a runner.
404 public struct XCRSControlKitRequest: Codable, Equatable, Sendable {
405 // MARK: - Public
406
407 public let id: String
408 public let method: String
409 public let parameters: [String: String]
410
411 public init(
412 id: String = UUID().uuidString,
413 method: String,
414 parameters: [String: String] = [:]
415 ) {
416 self.id = id
417 self.method = method
418 self.parameters = parameters
419 }
420 }
421
422 /// Errors shared by package consumers and platform runners.
423 public enum XCRSControlKitError: Error, Equatable, Sendable {
424 case unsupportedPlatform(XCRSControlKitPlatform)
425 case unsupportedCapability(XCRSControlKitCapability)
426 case invalidRequest(String)
427 case transport(String)
428 }