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 }