fix: address accessibility hierarchy review feedback test: remove product-specific accessibility fixture label
fix: address accessibility hierarchy review feedback test: remove product-specific accessibility fixture label
Seto Elkahfi committed
Aug 25, 2026 at 09:54 UTC
007e8c9c1adfd07f023d62d0c0449101e52af027
3 files changed
+162
-51
Runners/Shared/ControlKitRPCServer.swift
+6
@@ -108,6 +108,9 @@ final class ControlKitRPCServer {
108
case "device.dump.ui":
109
let bundleIdentifier = try stringParameter("bundleId", request.params)
110
let application = XCUIApplication(bundleIdentifier: bundleIdentifier)
111
+ guard application.state != .notRunning else {
112
+ throw RunnerError.applicationNotRunning(bundleIdentifier)
113
+ }
114
if request.params["format"] as? String == "debug" {
115
return try response(
116
result: ["description": application.debugDescription],
@@ -331,6 +334,7 @@ private final class RPCHandler: ChannelInboundHandler {
334
}
335
336
private enum RunnerError: LocalizedError {
337
+ case applicationNotRunning(String)
338
case invalidAccessibilityHierarchy
339
case invalidParameter(String)
340
case unsupportedButton(String)
@@ -338,6 +342,8 @@ private enum RunnerError: LocalizedError {
342
343
var errorDescription: String? {
344
switch self {
345
+ case .applicationNotRunning(let bundleIdentifier):
346
+ return "Application is not running: \(bundleIdentifier)"
347
case .invalidAccessibilityHierarchy:
348
return "Could not serialize the accessibility hierarchy"
349
case .invalidParameter(let name):
Sources/XCRSControlKit/XCRSControlKit.swift
+120
-40
@@ -138,6 +138,7 @@ public struct XCRSControlKitAccessibilityNode: Codable, Equatable, Sendable {
138
public let depth: Int?
139
public let enabled: Bool?
140
public let selected: Bool?
141
+ public let focused: Bool?
142
public let hittable: Bool?
143
public let children: [XCRSControlKitAccessibilityNode]
144
@@ -152,6 +153,7 @@ public struct XCRSControlKitAccessibilityNode: Codable, Equatable, Sendable {
153
depth: Int? = nil,
154
enabled: Bool? = nil,
155
selected: Bool? = nil,
156
+ focused: Bool? = nil,
157
hittable: Bool? = nil,
158
children: [XCRSControlKitAccessibilityNode] = []
159
) {
@@ -165,6 +167,7 @@ public struct XCRSControlKitAccessibilityNode: Codable, Equatable, Sendable {
167
self.depth = depth
168
self.enabled = enabled
169
self.selected = selected
170
+ self.focused = focused
171
self.hittable = hittable
172
self.children = children
173
}
@@ -172,6 +175,15 @@ public struct XCRSControlKitAccessibilityNode: Codable, Equatable, Sendable {
175
176
/// Converts XCTest's single-snapshot hierarchy into ControlKit nodes.
177
public enum XCRSControlKitAccessibilityParser {
178
+ private struct ParsedNode {
179
+ let node: XCRSControlKitAccessibilityNode
180
+ let depth: Int
181
+ }
182
+
183
+ private static let rectangleExpression = try? NSRegularExpression(
184
+ pattern: #"\{\{(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\},\s*\{(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\}\}"#
185
+ )
186
+
187
public static func parse(
188
debugDescription: String
189
) throws -> XCRSControlKitAccessibilityNode {
@@ -187,31 +199,19 @@ public enum XCRSControlKitAccessibilityParser {
199
200
let nodes = lines[lines.index(after: subtreeStart)..<subtreeEnd]
201
.compactMap(parseLine)
190
- guard let root = nodes.first else {
202
+ guard !nodes.isEmpty else {
203
throw XCRSControlKitError.invalidRequest(
204
"XCTest returned an empty accessibility subtree"
205
)
206
}
207
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
- )
208
+ var index = 0
209
+ return tree(from: nodes, at: &index)
210
}
211
212
private static func parseLine(
213
_ line: String
214
- ) -> XCRSControlKitAccessibilityNode? {
214
+ ) -> ParsedNode? {
215
let indentation = line.prefix(while: { $0 == " " }).count
216
var content = line.trimmingCharacters(in: .whitespaces)
217
if content.first == "→" {
@@ -229,24 +229,58 @@ public enum XCRSControlKitAccessibilityParser {
229
width: 0,
230
height: 0
231
)
232
+ let depth = indentation / 2
233
+ return ParsedNode(
234
+ node: XCRSControlKitAccessibilityNode(
235
+ type: type,
236
+ label: quotedAttribute("label", in: content),
237
+ name: identifier,
238
+ value: quotedAttribute("value", in: content),
239
+ placeholderValue: quotedAttribute("placeholderValue", in: content),
240
+ rawIdentifier: identifier,
241
+ rect: rect,
242
+ depth: depth,
243
+ enabled: !hasStandaloneAttribute("Disabled", in: content),
244
+ selected: hasStandaloneAttribute("Selected", in: content),
245
+ focused: hasStandaloneAttribute("Focused", in: content)
246
+ ),
247
+ depth: depth
248
+ )
249
+ }
250
+
251
+ private static func tree(
252
+ from nodes: [ParsedNode],
253
+ at index: inout Int
254
+ ) -> XCRSControlKitAccessibilityNode {
255
+ let parsedNode = nodes[index]
256
+ index += 1
257
+
258
+ var children: [XCRSControlKitAccessibilityNode] = []
259
+ while index < nodes.count, nodes[index].depth > parsedNode.depth {
260
+ children.append(tree(from: nodes, at: &index))
261
+ }
262
+
263
+ let node = parsedNode.node
264
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")
265
+ type: node.type,
266
+ label: node.label,
267
+ name: node.name,
268
+ value: node.value,
269
+ placeholderValue: node.placeholderValue,
270
+ rawIdentifier: node.rawIdentifier,
271
+ rect: node.rect,
272
+ depth: node.depth,
273
+ enabled: node.enabled,
274
+ selected: node.selected,
275
+ focused: node.focused,
276
+ hittable: node.hittable,
277
+ children: children
278
)
279
}
280
281
private static func rectangle(in line: String) -> XCRSControlKitRect? {
247
- let pattern = #"\{\{(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\},\s*\{(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\}\}"#
282
guard
249
- let expression = try? NSRegularExpression(pattern: pattern),
283
+ let expression = rectangleExpression,
284
let match = expression.firstMatch(
285
in: line,
286
range: NSRange(line.startIndex..., in: line)
@@ -277,20 +311,66 @@ public enum XCRSControlKitAccessibilityParser {
311
_ name: String,
312
in line: String
313
) -> 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 {
314
+ let prefix = "\(name): '"
315
+ guard let valueStart = line.range(of: prefix)?.upperBound else {
316
return nil
317
}
292
- let value = String(line[valueRange])
293
- return value.isEmpty ? nil : value
318
+
319
+ var index = valueStart
320
+ var escaped = false
321
+ while index < line.endIndex {
322
+ let character = line[index]
323
+ if character == "'", !escaped {
324
+ let nextIndex = line.index(after: index)
325
+ if nextIndex == line.endIndex || line[nextIndex] == "," {
326
+ let value = String(line[valueStart..<index])
327
+ return value.isEmpty ? nil : value
328
+ }
329
+ }
330
+ escaped = character == "\\" && !escaped
331
+ if character != "\\" {
332
+ escaped = false
333
+ }
334
+ index = line.index(after: index)
335
+ }
336
+
337
+ return nil
338
+ }
339
+
340
+ private static func hasStandaloneAttribute(
341
+ _ attribute: String,
342
+ in line: String
343
+ ) -> Bool {
344
+ var segmentStart = line.startIndex
345
+ var index = line.startIndex
346
+ var quoted = false
347
+ var escaped = false
348
+
349
+ while index < line.endIndex {
350
+ let character = line[index]
351
+ if character == "'", !escaped {
352
+ if quoted {
353
+ let nextIndex = line.index(after: index)
354
+ if nextIndex == line.endIndex || line[nextIndex] == "," {
355
+ quoted = false
356
+ }
357
+ } else {
358
+ quoted = true
359
+ }
360
+ } else if character == ",", !quoted {
361
+ if line[segmentStart..<index].trimmingCharacters(in: .whitespaces) == attribute {
362
+ return true
363
+ }
364
+ segmentStart = line.index(after: index)
365
+ }
366
+ escaped = character == "\\" && !escaped
367
+ if character != "\\" {
368
+ escaped = false
369
+ }
370
+ index = line.index(after: index)
371
+ }
372
+
373
+ return line[segmentStart...].trimmingCharacters(in: .whitespaces) == attribute
374
}
375
}
376
Tests/XCRSControlKitTests/XCRSControlKitTests.swift
+36
-11
@@ -51,6 +51,7 @@ final class XCRSControlKitTests: XCTestCase {
51
rect: XCRSControlKitRect(x: 100, y: 200, width: 80, height: 40),
52
enabled: true,
53
selected: false,
54
+ focused: false,
55
hittable: true
56
)
57
@@ -69,13 +70,13 @@ final class XCRSControlKitTests: XCTestCase {
70
71
func testAccessibilityParserReadsSingleXCTestSnapshot() throws {
72
let hierarchy = """
72
- Attributes: Application, 0x1, pid: 42, label: 'Viaplay'
73
+ Attributes: Application, 0x1, pid: 42, label: 'Sample App'
74
Element subtree:
74
- →Application, 0x1, pid: 42, label: 'Viaplay'
75
+ →Application, 0x1, pid: 42, label: 'Sample App'
76
Window (Main), 0x2, {{0.0, 0.0}, {1920.0, 1080.0}}
77
Button, 0x3, {{576.0, 64.0}, {63.0, 32.0}}, identifier: 'Start', label: 'Hem', Focused
78
Path to element:
78
- →Application, 0x1, pid: 42, label: 'Viaplay'
79
+ →Application, 0x1, pid: 42, label: 'Sample App'
80
"""
81
82
let root = try XCRSControlKitAccessibilityParser.parse(
@@ -83,13 +84,37 @@ final class XCRSControlKitTests: XCTestCase {
84
)
85
86
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)
87
+ XCTAssertEqual(root.label, "Sample App")
88
+ XCTAssertEqual(root.children.count, 1)
89
+ let window = try XCTUnwrap(root.children.first)
90
+ XCTAssertEqual(window.type, "Window (Main)")
91
+ let button = try XCTUnwrap(window.children.first)
92
+ XCTAssertEqual(button.type, "Button")
93
+ XCTAssertEqual(button.label, "Hem")
94
+ XCTAssertEqual(button.rawIdentifier, "Start")
95
+ XCTAssertEqual(button.rect.x, 576)
96
+ XCTAssertGreaterThan(button.depth ?? 0, root.depth ?? 0)
97
+ XCTAssertEqual(button.selected, false)
98
+ XCTAssertEqual(button.focused, true)
99
+ }
100
+
101
+ func testAccessibilityParserReadsQuotedAttributesAndStates() throws {
102
+ let hierarchy = """
103
+ Element subtree:
104
+ →Application, 0x1, label: 'Disabled, Selected, Focused'
105
+ Button, 0x2, label: 'O'Brien', Disabled, Selected, Focused
106
+ Path to element:
107
+ """
108
+
109
+ let root = try XCRSControlKitAccessibilityParser.parse(
110
+ debugDescription: hierarchy
111
+ )
112
+
113
+ XCTAssertEqual(root.label, "Disabled, Selected, Focused")
114
+ let button = try XCTUnwrap(root.children.first)
115
+ XCTAssertEqual(button.label, "O'Brien")
116
+ XCTAssertEqual(button.enabled, false)
117
+ XCTAssertEqual(button.selected, true)
118
+ XCTAssertEqual(button.focused, true)
119
}
120
}