TeraAccessibilityAudit.swift (15748B)
1 import XCTest 2 3 @MainActor 4 final class TeraAccessibilityAudit { 5 private let test: XCTestCase 6 private var findings: [String] = [] 7 8 init(test: XCTestCase) { 9 self.test = test 10 } 11 12 func run(_ app: XCUIApplication, dynamicType: Bool = false, includeContrast: Bool = true) throws -> [String] { 13 // Native scans can move focus and scroll position. Audit clipping before 14 // those scans and retain every finding, including unidentified ones. 15 var types: XCUIAccessibilityAuditType = [.elementDetection, .hitRegion, 16 .sufficientElementDescription, .trait] 17 if dynamicType { 18 types.insert(.dynamicType) 19 } 20 let before = XCTAttachment(string: app.debugDescription) 21 before.name = "Accessibility tree before audit" 22 before.lifetime = .keepAlways 23 test.add(before) 24 let screenshot = XCTAttachment(screenshot: app.screenshot()) 25 screenshot.name = "Screen before audit" 26 screenshot.lifetime = .keepAlways 27 test.add(screenshot) 28 try scan(app, types: .textClipped) 29 if includeContrast { 30 try auditContrast(app) 31 } 32 try scan(app, types: types) 33 return findings 34 } 35 36 private func scan(_ app: XCUIApplication, types: XCUIAccessibilityAuditType) throws { 37 try app.performAccessibilityAudit(for: types) { issue in 38 let detail = XCTAttachment(string: "Audit type: \(issue.auditType.rawValue)\nAudit: \(issue.detailedDescription)\nElement: \(issue.element?.debugDescription ?? "unidentified")") 39 detail.lifetime = .keepAlways 40 self.test.add(detail) 41 let tree = XCTAttachment(string: app.debugDescription) 42 tree.name = "Audit finding accessibility tree" 43 tree.lifetime = .keepAlways 44 self.test.add(tree) 45 let screen = XCTAttachment(screenshot: app.screenshot()) 46 screen.name = "Audit finding screen" 47 screen.lifetime = .keepAlways 48 self.test.add(screen) 49 if let element = issue.element { 50 let fresh = TeraAuditTarget(element, app: app).resolve(in: app) 51 let geometry = XCTAttachment(string: "Current target: \(fresh.debugDescription)") 52 geometry.lifetime = .keepAlways 53 self.test.add(geometry) 54 } 55 return self.retainFinding(issue) 56 } 57 } 58 } 59 60 @MainActor 61 enum TeraAccessibilityNavigation { 62 static func viewport(_ app: XCUIApplication) -> (top: CGFloat, bottom: CGFloat) { 63 let navigation = app.navigationBars.firstMatch 64 let tabs = app.tabBars.firstMatch 65 // A presented account/import sheet covers the underlying tab bar. Its 66 // foreground content must use the sheet viewport, not the covered tabs. 67 let top = navigation.exists ? navigation.frame.maxY : 64 68 let bottom = tabs.exists && tabs.isHittable ? tabs.frame.minY : app.frame.maxY - 40 69 return (top, bottom) 70 } 71 72 static func scroll(_ app: XCUIApplication, to element: XCUIElement, up: Bool = true, 73 requiresHit: Bool = true, test: XCTestCase) 74 { 75 var forward = up 76 var previousViewport: [String] = [] 77 var stationaryDrags = 0 78 for _ in 0 ..< 28 { 79 let position = scrollPosition(app) 80 let frame = element.exists ? String(describing: element.frame) : "not instantiated" 81 XCTContext.runActivity(named: "Accessibility scroll: position \(position ?? "unknown"), target \(frame)") { _ in } 82 if isVisible(element, app: app, requiresHit: requiresHit) { 83 return 84 } 85 // Lazy forms revise their estimated content height while revealing rows. 86 // A repeated percentage is not an edge. Reverse only after actual 87 // visible content has stopped moving across two completed drags. 88 let viewport = visibleContent(app) 89 stationaryDrags = !viewport.isEmpty && viewport == previousViewport ? stationaryDrags + 1 : 0 90 if stationaryDrags == 2 { 91 forward.toggle() 92 stationaryDrags = 0 93 } 94 previousViewport = viewport 95 if element.exists, element.frame.width > 0, element.frame.height > 0 { 96 let (top, bottom) = TeraAccessibilityNavigation.viewport(app) 97 let frame = element.frame 98 let distance = bottom - top - 40 99 let delta = max(-distance, min(distance, frame.midY - (top + bottom) / 2)) 100 if abs(delta) > 8 { 101 drag(app, amount: -delta, top: top, bottom: bottom) 102 continue 103 } 104 } 105 // Audit scans can leave a lazy form on either side of the target. 106 // Reverse only when scrolling has stopped making progress, so a long 107 // maximum-text form can use the whole bound to reach the opposite end. 108 let (top, bottom) = TeraAccessibilityNavigation.viewport(app) 109 let delta = (bottom - top - 40) * (forward ? -0.7 : 0.7) 110 drag(app, amount: delta, top: top, bottom: bottom) 111 } 112 // The final drag can expose a lazy element. Check its settled frame before 113 // declaring the bounded search unsuccessful. 114 if isVisible(element, app: app, requiresHit: requiresHit) { 115 return 116 } 117 let screenshot = XCTAttachment(screenshot: app.screenshot()) 118 screenshot.lifetime = .keepAlways 119 test.add(screenshot) 120 let (top, bottom) = TeraAccessibilityNavigation.viewport(app) 121 let exists = element.exists 122 let frame = exists ? element.frame : .zero 123 let detail = exists ? element.debugDescription : "Target is not instantiated" 124 let geometry = String(format: "minY=%.17g maxY=%.17g top=%.17g bottom=%.17g", frame.minY, frame.maxY, top, bottom) 125 XCTFail("Required accessibility element is unreachable: exists=\(exists) hittable=\(exists && element.isHittable) \(geometry). \(detail).\n\(app.debugDescription)") 126 } 127 128 private static func isVisible(_ element: XCUIElement, app: XCUIApplication, requiresHit: Bool) -> Bool { 129 guard element.exists, !requiresHit || element.isHittable else { return false } 130 let (top, bottom) = TeraAccessibilityNavigation.viewport(app) 131 let frame = element.frame 132 return contains(frame, top: top, bottom: bottom) 133 } 134 135 static func contains(_ frame: CGRect, top: CGFloat, bottom: CGFloat) -> Bool { 136 // UIKit's scaled geometry can differ by a few floating-point units at the 137 // same boundary (observed 189 versus 189.00000000000011). Eight ulps are 138 // far below a screen pixel; actual clipping still fails the native audits. 139 frame.width > 0 && frame.height > 0 140 && frame.minY >= top - top.ulp * 8 141 && frame.maxY <= bottom + bottom.ulp * 8 142 } 143 144 private static func scrollPosition(_ app: XCUIApplication) -> String? { 145 // Text editors have their own scrollbars. Progress belongs to the outer 146 // form, whose visible scrollbar is farther right than those nested editors. 147 let bars = app.otherElements.matching(NSPredicate(format: "label BEGINSWITH %@", "Vertical scroll bar")) 148 .allElementsBoundByIndex.filter { $0.frame.height > 0 && $0.frame.intersects(app.frame) } 149 let outer = bars.max { lhs, rhs in 150 lhs.frame.maxX == rhs.frame.maxX ? lhs.frame.height < rhs.frame.height : lhs.frame.maxX < rhs.frame.maxX 151 } 152 return outer?.value as? String 153 } 154 155 private static func visibleContent(_ app: XCUIApplication) -> [String] { 156 let (top, bottom) = TeraAccessibilityNavigation.viewport(app) 157 return app.staticTexts.allElementsBoundByIndex.compactMap { text in 158 let frame = text.frame 159 guard frame.height > 0, frame.maxY > top, frame.minY < bottom else { return nil } 160 return "\(text.identifier):\(text.label):\(frame.minY.rounded()):\(frame.maxY.rounded())" 161 } 162 } 163 164 private static func drag(_ app: XCUIApplication, amount: CGFloat, top: CGFloat, bottom: CGFloat) { 165 // Application coordinates can resolve to a zero-width origin. The actual 166 // window gives a stable point in the form margin, outside nested editors. 167 let window = app.windows.firstMatch 168 let origin = window.coordinate(withNormalizedOffset: .zero) 169 // Start and end in the middle half of the scrollable area. The system 170 // bars can intercept gestures near the nominal safe-area boundaries. 171 let span = bottom - top 172 let delta = max(-span / 2, min(span / 2, amount)) 173 let startY = (delta < 0 ? bottom - span / 4 : top + span / 4) - window.frame.minY 174 let start = origin.withOffset(CGVector(dx: 20, dy: startY)) 175 // A second withOffset replaces the previous offset. Calculate both ends 176 // from the same origin so the destination stays inside the viewport. 177 let finish = origin.withOffset(CGVector(dx: 20, dy: startY + delta)) 178 // Hold at the destination to stop momentum before reading the next frame. 179 start.press(forDuration: 0.05, thenDragTo: finish, 180 withVelocity: .slow, thenHoldForDuration: 0.3) 181 } 182 } 183 184 /// Contrast sampling under translucent navigation/tab bars can report a 185 /// partially occluded element. Every such finding must pass a new contrast 186 /// audit with its entire frame exposed. Unidentified findings remain fatal. 187 private extension TeraAccessibilityAudit { 188 func auditContrast(_ app: XCUIApplication) throws { 189 var pending: [String: TeraAuditTarget] = [:] 190 var verified = Set<String>() 191 var requested: TeraAuditTarget? 192 for _ in 0 ..< 20 { 193 let target = requested?.resolve(in: app) 194 let targetKey = requested?.key 195 if let target { 196 // A menu's static label has no separate tap action. It still must be 197 // fully exposed and pass the actual native contrast re-audit below. 198 TeraAccessibilityNavigation.scroll(app, to: target, up: requested?.scrollUp ?? true, 199 requiresHit: requested?.type != .staticText, test: test) 200 } 201 let findingsBefore = findings.count 202 try app.performAccessibilityAudit(for: .contrast) { issue in 203 let observation = XCTAttachment(string: "Contrast observation: \(issue.detailedDescription)\n\(issue.element?.debugDescription ?? "unidentified")") 204 observation.lifetime = .keepAlways 205 self.test.add(observation) 206 guard let element = issue.element else { return self.retainFinding(issue) } 207 // SC 1.4.3 exempts inactive controls. Keep this bound to the actual 208 // disabled camera control; no active text or clipping finding is exempt. 209 if element.label == "Camera", !element.isEnabled, 210 app.buttons["Camera"].exists, !app.buttons["Camera"].isEnabled { 211 return true 212 } 213 let key = self.contrastKey(element) 214 guard key != targetKey, self.isPartlyOccluded(element, app: app) else { return self.retainFinding(issue) } 215 if !verified.contains(key) { 216 pending[key] = TeraAuditTarget(element, app: app) 217 } 218 let evidence = XCTAttachment(string: "Contrast requires unobscured recheck: \(element.debugDescription)") 219 evidence.lifetime = .keepAlways 220 self.test.add(evidence) 221 return true 222 } 223 if let target, let targetKey { 224 let (top, bottom) = TeraAccessibilityNavigation.viewport(app) 225 XCTAssertTrue((requested?.type == .staticText || target.isHittable) 226 && TeraAccessibilityNavigation.contains(target.frame, top: top, bottom: bottom), 227 "Contrast recheck moved outside the viewport") 228 if findings.count == findingsBefore { 229 verified.insert(targetKey) 230 } 231 } 232 guard let next = pending.keys.sorted().first else { return } 233 requested = pending.removeValue(forKey: next) 234 } 235 XCTFail("Not all partially obscured contrast findings passed an unobscured recheck") 236 } 237 238 func retainFinding(_ issue: XCUIAccessibilityAuditIssue) -> Bool { 239 findings.append("\(issue.compactDescription): \(issue.element?.label ?? "unidentified")") 240 // Return every finding to the calling test for a mandatory assertion. 241 // Continuing this scan does not waive the finding. 242 return true 243 } 244 245 func contrastKey(_ element: XCUIElement) -> String { 246 "\(element.elementType.rawValue):\(element.identifier):\(element.label)" 247 } 248 249 func isPartlyOccluded(_ element: XCUIElement, app: XCUIApplication) -> Bool { 250 let frame = element.frame 251 let (top, bottom) = TeraAccessibilityNavigation.viewport(app) 252 guard frame.height > 0, frame.height < bottom - top - 16, 253 frame.minX >= app.frame.minX, frame.maxX <= app.frame.maxX else { return false } 254 return frame.minY < app.frame.maxY && frame.maxY > app.frame.minY 255 && !TeraAccessibilityNavigation.contains(frame, top: top, bottom: bottom) 256 } 257 } 258 259 private struct TeraAuditTarget { 260 let type: XCUIElement.ElementType 261 let identifier: String 262 let label: String 263 let scrollUp: Bool 264 265 var key: String { 266 "\(type.rawValue):\(identifier):\(label)" 267 } 268 269 @MainActor init(_ element: XCUIElement, app: XCUIApplication) { 270 type = element.elementType 271 identifier = element.identifier 272 label = element.label 273 scrollUp = element.frame.midY > app.frame.midY 274 } 275 276 @MainActor func resolve(in app: XCUIApplication) -> XCUIElement { 277 let predicate = identifier.isEmpty ? NSPredicate(format: "label == %@", label) 278 : NSPredicate(format: "identifier == %@ AND label == %@", identifier, label) 279 // UIKit-backed SwiftUI section headings expose different legacy and modern 280 // automation types. Match the stable identity and label across both trees. 281 return app.descendants(matching: .any).matching(predicate).firstMatch 282 } 283 } 284 285 @MainActor 286 enum TeraAccessibilitySettings { 287 static func setSystemReduceMotion(_ enabled: Bool, restoring test: XCTestCase? = nil) -> Bool { 288 let settings = XCUIApplication(bundleIdentifier: "com.apple.Preferences") 289 // This helper operates Settings, not the app under qualification. Keep 290 // its navigation stable while Tera uses the real maximum system size. 291 settings.launchArguments = ["-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryL"] 292 settings.launch() 293 if settings.navigationBars["Settings"].exists { 294 for _ in 0 ..< 3 { 295 settings.swipeDown() 296 } 297 } 298 for _ in 0 ..< 8 { 299 if settings.switches["Reduce Motion"].exists { 300 break 301 } 302 let motion = settings.buttons["Motion"].exists ? settings.buttons["Motion"] : settings.cells["Motion"] 303 let accessibility = settings.buttons["Accessibility"].exists ? settings.buttons["Accessibility"] : settings.cells["Accessibility"] 304 if motion.exists, motion.isHittable { 305 motion.tap() 306 } else if accessibility.exists, accessibility.isHittable { 307 accessibility.tap() 308 } else if settings.navigationBars.buttons.firstMatch.exists { 309 settings.navigationBars.buttons.firstMatch.tap() 310 } else { 311 settings.swipeUp() 312 } 313 } 314 let toggle = settings.switches["Reduce Motion"] 315 XCTAssertTrue(toggle.waitForExistence(timeout: 5), settings.debugDescription) 316 let original = toggle.value as? String == "1" 317 // Register restoration before changing the setting, including on failure. 318 if let test { 319 let originalSetting = XCTAttachment(string: "Original system Reduce Motion: \(original)") 320 originalSetting.lifetime = .keepAlways 321 test.add(originalSetting) 322 test.addTeardownBlock { 323 await MainActor.run { 324 _ = setSystemReduceMotion(original) 325 } 326 } 327 } 328 if original != enabled { 329 // Settings exposes the entire row as the switch accessibility element; 330 // its label does not toggle the visual control on this OS version. 331 toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5)).tap() 332 } 333 let settled = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@", enabled ? "1" : "0"), object: toggle) 334 XCTAssertEqual(XCTWaiter.wait(for: [settled], timeout: 5), .completed, settings.debugDescription) 335 settings.terminate() 336 return original 337 } 338 }