From 77e72e1d143f1435f8855cf020ac213427914c44 Mon Sep 17 00:00:00 2001 From: Thomas Behner Date: Fri, 17 Jul 2026 17:08:53 -0400 Subject: [PATCH 1/2] Add SwiftUI + Swift 6 strict concurrency-friendly versions of the IPrint... a11y debugger functions --- .../Common/Setup/DebugStepDefinitions.swift | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/Sources/Swerkin/Classes/StepDefinitions/Common/Setup/DebugStepDefinitions.swift b/Sources/Swerkin/Classes/StepDefinitions/Common/Setup/DebugStepDefinitions.swift index a19f038..468dfc1 100644 --- a/Sources/Swerkin/Classes/StepDefinitions/Common/Setup/DebugStepDefinitions.swift +++ b/Sources/Swerkin/Classes/StepDefinitions/Common/Setup/DebugStepDefinitions.swift @@ -41,3 +41,134 @@ extension Steps { } } } + +// MARK: SwiftUI + Swift 6 concurrency support for accessibility traversal + +@MainActor +public extension Steps { + /// Prints all accessibility labels reachable from the top-most view controller + func IPrintAllSwiftUILabels() { + self.performStep { + self.viewTester.wait(forTimeInterval: 2) + let labels = self.topController()?.view.collectSwiftUIAccessibilityLabels() ?? [] + print("========================") + print(labels) + print("========================") + } + } + + /// Prints all accessibility identifiers reachable from the top-most view controller + func IPrintAllSwiftUIIdentifiers() { + self.performStep { + self.viewTester.wait(forTimeInterval: 2) + let ids = self.topController()?.view.collectSwiftUIAccessibilityIdentifiers() ?? [] + print("========================") + print(ids) + print("========================") + } + } + + /// Prints all accessibility (label, identifier) pairs reachable from the top-most view controller + func IPrintAllSwiftUILabelsAndIdentifiers() { + self.performStep { + self.viewTester.wait(forTimeInterval: 2) + let pairs = self.topController()?.view.collectSwiftUIAccessibilityElements() ?? [] + print("========================") + print(pairs) + print("========================") + } + } +} + +// MARK: - SwiftUI-aware accessibility traversal + +@MainActor +private extension UIView { + func collectSwiftUIAccessibilityLabels() -> [String] { + var labels: [String] = [] + SwiftUIAccessibilityWalker.walk(self) { node in + guard let label = node.accessibilityLabel, !label.isEmpty else { return } + labels.append("".appendingFormat("%@", label)) + } + return labels + } + + func collectSwiftUIAccessibilityIdentifiers() -> [String] { + var ids: [String] = [] + SwiftUIAccessibilityWalker.walk(self) { node in + guard let id = node.accessibilityIdentifier, !id.isEmpty else { return } + ids.append(id) + } + return ids + } + + func collectSwiftUIAccessibilityElements() -> [AccessibilityPair] { + var pairs: [AccessibilityPair] = [] + SwiftUIAccessibilityWalker.walk(self) { node in + guard let id = node.accessibilityIdentifier, !id.isEmpty, + let label = node.accessibilityLabel, !label.isEmpty else { return } + pairs.append(AccessibilityPair(label: "".appendingFormat("%@", label), + identifier: id)) + } + return pairs + } +} + +/// A single node in the accessibility traversal - either a real `UIView` or a +/// `UIAccessibilityElement` produced by SwiftUI. +@MainActor +private struct AccessibilityNode { + let accessibilityLabel: String? + let accessibilityIdentifier: String? + + init(view: UIView) { + self.accessibilityLabel = view.accessibilityLabel + self.accessibilityIdentifier = view.accessibilityIdentifier + } + + init(element: NSObject) { + self.accessibilityLabel = element.value(forKey: "accessibilityLabel") as? String + self.accessibilityIdentifier = (element as? UIAccessibilityIdentification)?.accessibilityIdentifier + ?? element.value(forKey: "accessibilityIdentifier") as? String + } +} + +private enum SwiftUIAccessibilityWalker { + /// Depth-first traversal that visits every visible `UIView` in the + /// hierarchy plus every entry in each view's `accessibilityElements` + /// array (the mechanism SwiftUI uses to publish its a11y tree). + @MainActor + static func walk(_ root: UIView, visit: (AccessibilityNode) -> Void) { + guard !root.isHidden else { return } + + visit(AccessibilityNode(view: root)) + + if let elements = root.accessibilityElements as? [NSObject] { + for element in elements { + visitElement(element, visit: visit) + } + } + + for subview in root.subviews { + walk(subview, visit: visit) + } + } + + @MainActor + private static func visitElement(_ element: NSObject, + visit: (AccessibilityNode) -> Void) { + if let view = element as? UIView { + walk(view, visit: visit) + return + } + + visit(AccessibilityNode(element: element)) + + // Some SwiftUI a11y elements are themselves containers. + if let nested = element.value(forKey: "accessibilityElements") as? [NSObject] { + for child in nested { + visitElement(child, visit: visit) + } + } + } +} From 269a0185a728fffe6a09cd79c5d682e242bffaf8 Mon Sep 17 00:00:00 2001 From: Thomas Behner Date: Mon, 20 Jul 2026 10:58:16 -0400 Subject: [PATCH 2/2] add topController helper function from UIAutomation's Steps+ViewAssertion.swift. This correctly resolves the key window via UIWindowsScene. The original functions call UIWindow.topMostController on a new, empty UIWindow, which is a bug that would return nil in real cases. This was addition was needed as this debug code previously drew this helper function from the UIAutomation module where it originally existed. --- .../Common/Setup/DebugStepDefinitions.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Sources/Swerkin/Classes/StepDefinitions/Common/Setup/DebugStepDefinitions.swift b/Sources/Swerkin/Classes/StepDefinitions/Common/Setup/DebugStepDefinitions.swift index 468dfc1..d59dbd8 100644 --- a/Sources/Swerkin/Classes/StepDefinitions/Common/Setup/DebugStepDefinitions.swift +++ b/Sources/Swerkin/Classes/StepDefinitions/Common/Setup/DebugStepDefinitions.swift @@ -78,6 +78,36 @@ public extension Steps { print("========================") } } + + func topController() -> UIViewController? { + guard let keyWindow = UIApplication.shared.connectedSceneActiveWindow else { + return nil + } + if let root = keyWindow.rootViewController as? UINavigationController { + if let topController = root.visibleViewController, + topController as? UIAlertController == nil { + return topController + } + if let topController = root.topViewController { + return topController + } + return nil + } else { + return nil + } + } +} + +public extension UIApplication { + fileprivate var connectedSceneActiveWindow: UIWindow? { + return self.connectedScenes + // Keep only active scenes, onscreen and visible to the user + .first(where: { $0 is UIWindowScene }) + // Get its associated windows + .flatMap({ $0 as? UIWindowScene })?.windows + // Finally, keep only the key window + .first(where: \.isKeyWindow) + } } // MARK: - SwiftUI-aware accessibility traversal