OLD | NEW |
(Empty) | |
| 1 // Copyright 2016 The Chromium Authors. All rights reserved. |
| 2 // Use of this source code is governed by a BSD-style license that can be |
| 3 // found in the LICENSE file. |
| 4 |
| 5 #import <EarlGrey/EarlGrey.h> |
| 6 #import <XCTest/XCTest.h> |
| 7 |
| 8 #import "ios/chrome/test/earl_grey/accessibility_util.h" |
| 9 |
| 10 namespace { |
| 11 |
| 12 // Returns whether a UIView is hidden from the screen, based on the alpha |
| 13 // and hidden properties of the UIView. |
| 14 bool ViewIsHidden(UIView* view) { |
| 15 return view.alpha == 0 || view.hidden; |
| 16 } |
| 17 |
| 18 // Returns whether a view should be accessible. A view should be accessible if |
| 19 // either it is a whitelisted type or its isAccessibilityElement property is |
| 20 // enabled. |
| 21 bool ViewShouldBeAccessible(UIView* view) { |
| 22 NSArray* classList = @[ |
| 23 [UILabel class], [UIControl class], [UITableView class], |
| 24 [UICollectionViewCell class] |
| 25 ]; |
| 26 bool viewIsAccessibleType = false; |
| 27 for (Class accessibleClass in classList) { |
| 28 if ([view isKindOfClass:accessibleClass]) { |
| 29 viewIsAccessibleType = true; |
| 30 break; |
| 31 } |
| 32 } |
| 33 return (viewIsAccessibleType || view.isAccessibilityElement) && |
| 34 !ViewIsHidden(view); |
| 35 } |
| 36 |
| 37 // Returns true if |element| or descendant has property accessibilityLabel |
| 38 // that is not an empty string. If |element| has isAccessibilityElement set to |
| 39 // true, then the |element| itself must have a label. |
| 40 bool ViewOrDescendantHasAccessibilityLabel(UIView* element) { |
| 41 if ([element.accessibilityLabel length]) |
| 42 return true; |
| 43 if (element.isAccessibilityElement) |
| 44 return false; |
| 45 for (UIView* view in element.subviews) { |
| 46 if (ViewOrDescendantHasAccessibilityLabel(view)) |
| 47 return true; |
| 48 } |
| 49 return false; |
| 50 } |
| 51 |
| 52 // Returns true if |element|'s accessibilityLabel is a not a bad default value, |
| 53 // particularly that it does not match the name of an image in the bundle, since |
| 54 // UIKit can set the accessibilityLabel to an associated image name if no other |
| 55 // data is available. |
| 56 bool ViewHasNonDefaultAccessibilityLabel(UIView* element) { |
| 57 if (element.accessibilityLabel) { |
| 58 // Replace all spaces with underscores because when UIKit converts an |
| 59 // image name to an accessibility label by default, it replaces underscores |
| 60 // with spaces. |
| 61 NSString* fileName = |
| 62 [element.accessibilityLabel stringByReplacingOccurrencesOfString:@" " |
| 63 withString:@"_"]; |
| 64 if ([fileName rangeOfString:@"/"].location != NSNotFound) |
| 65 return true; // "/" is not a valid character in a file name |
| 66 if ([UIImage imageNamed:fileName]) |
| 67 return false; |
| 68 if ([UIImage imageNamed:[fileName stringByAppendingString:@".jpg"]]) |
| 69 return false; |
| 70 if ([UIImage imageNamed:[fileName stringByAppendingString:@".gif"]]) |
| 71 return false; |
| 72 } |
| 73 return true; |
| 74 } |
| 75 |
| 76 // Returns an array of elements that should be accessible. |
| 77 // Helper method for accessibilityElementsStartingFromView, so that |
| 78 // |ancestorString|, which handles internal bookkeeping, is hidden from the top |
| 79 // level API. |ancestorString| is the description of the most recent ancestor |
| 80 // with isAccessibilityElement set to true, and thus when the method is first |
| 81 // called, it should always be set to nil. |
| 82 NSArray* AccessibilityElementsHelperStartingFromView(UIView* view, |
| 83 NSString* ancestorString, |
| 84 NSError** error) { |
| 85 NSMutableArray* results = [NSMutableArray array]; |
| 86 // Add |view| to |results| if it should be accessible and is not hidden. |
| 87 if (ViewShouldBeAccessible(view)) { |
| 88 // UILabels have an extra check since some labels are dynamically set, and |
| 89 // may not have text or an accessibilityLabel at a given point of |
| 90 // execution. |
| 91 if ([view isKindOfClass:[UILabel class]]) { |
| 92 UILabel* label = static_cast<UILabel*>(view); |
| 93 if ([label.text length]) |
| 94 [results addObject:label]; |
| 95 } else { |
| 96 [results addObject:view]; |
| 97 if ([ancestorString length]) { |
| 98 if (error != nil && !*error) |
| 99 *error = [NSError errorWithDomain:@"Ancestor blocks VoiceOver" |
| 100 code:1 |
| 101 userInfo:nil]; |
| 102 // The most recent ancestor with Accessibility Element set to true |
| 103 // blocks VoiceOver for Element ancestorString |
| 104 } |
| 105 } |
| 106 } |
| 107 if (view.isAccessibilityElement && ![view isKindOfClass:[UILabel class]]) { |
| 108 ancestorString = [view description]; |
| 109 } |
| 110 if (![view isKindOfClass:[UITableView class]]) { |
| 111 // Do not recurse below views which are accessible but may have children |
| 112 // that default to being accessible. Also, do not recurse below views which |
| 113 // implement the UIAccessibilityContainer informal protocol, as these views |
| 114 // have taken ownership of accessibility behavior of descendents. |
| 115 if ([view isKindOfClass:[UISwitch class]] || |
| 116 [view respondsToSelector:@selector(accessibilityElements)]) |
| 117 return results; |
| 118 for (UIView* subView in [view subviews]) { |
| 119 [results addObjectsFromArray:AccessibilityElementsHelperStartingFromView( |
| 120 subView, ancestorString, error)]; |
| 121 } |
| 122 } |
| 123 return results; |
| 124 } |
| 125 |
| 126 // Recursively traverses the UIView tree and returns all views that should be |
| 127 // accessible. Views should be accessible if they are of type UIControl, |
| 128 // UILabel, UITableViewCell, or UICollectionViewCell or if their |
| 129 // isAccessibilityElement property is set to true. Also, it prints errors when |
| 130 // an ancestor of an element which should be accessible has |
| 131 // isAccessibilityElement set to true. It notifies the calling method of this |
| 132 // error by assigning |error| to a new NSError object. By passing in an NSError |
| 133 // object set to nil, it can be used to determine if there was an error. |
| 134 NSArray* AccessibilityElementsStartingFromView(UIView* view, NSError** error) { |
| 135 return AccessibilityElementsHelperStartingFromView(view, nil, error); |
| 136 } |
| 137 |
| 138 // Starting from |view|, verifies that no view masks its descendants by having |
| 139 // isAccessibilityElement set to true. |ancestorString| is the description of |
| 140 // the most recent ancestor with isAccessibilityElement set to true, and thus |
| 141 // when the method is first called, it should always be set to nil. |
| 142 bool ViewAndDescendantsDoNotBlockVoiceOver(UIView* view, |
| 143 NSString* ancestorString) { |
| 144 if (![view isKindOfClass:[UILabel class]] && ViewShouldBeAccessible(view)) { |
| 145 if ([ancestorString length]) { |
| 146 // Ancestor String masks Descendant because the ancestor's |
| 147 // isAccessibilityElement is set to true. |
| 148 return false; |
| 149 } |
| 150 if (view.isAccessibilityElement) { |
| 151 ancestorString = [view description]; |
| 152 } |
| 153 } |
| 154 // Do not recurse through hidden elements, as their descendants are also |
| 155 // hidden. |
| 156 if (ViewIsHidden(view)) |
| 157 return true; |
| 158 // Do not recurse below views which are accessible but may have children |
| 159 // that default to being accessible. Also, do not recurse below views which |
| 160 // implement the UIAccessibilityContainer informal protocol, as these views |
| 161 // have taken ownership of accessibility behavior of descendents. |
| 162 if ([view isKindOfClass:[UISwitch class]] || |
| 163 [view respondsToSelector:@selector(accessibilityElements)]) |
| 164 return true; |
| 165 bool ancestorMasksDescendant = true; |
| 166 for (UIView* subView in [view subviews]) { |
| 167 if (!ViewAndDescendantsDoNotBlockVoiceOver(subView, ancestorString)) { |
| 168 ancestorMasksDescendant = false; |
| 169 } |
| 170 } |
| 171 return ancestorMasksDescendant; |
| 172 } |
| 173 |
| 174 // Run accessibilityLabel tests on |view|. This method is used for cases where |
| 175 // tests are grouped by element, instead of the typical case where elements are |
| 176 // grouped by test. |
| 177 bool VerifyElementAccessibilityLabel(UIView* view) { |
| 178 if (view && !ViewIsHidden(view)) { |
| 179 if (!ViewOrDescendantHasAccessibilityLabel(view)) { |
| 180 // TODO: (crbug.com/650800) Add more verbose fail case logging. |
| 181 return false; |
| 182 } |
| 183 if (!ViewHasNonDefaultAccessibilityLabel(view)) { |
| 184 // TODO: (crbug.com/650800) Add more verbose fail case logging. |
| 185 return false; |
| 186 } |
| 187 } |
| 188 return true; |
| 189 } |
| 190 |
| 191 // Verifies |tableView|'s accessibility by scrolling through to ensure that |
| 192 // accessibility tests are run on each cell. Cells which are offscreen may |
| 193 // not be in the UIView hierarchy, so UITableViews must be scrolled in order to |
| 194 // verify all of its cells. The method will scroll through the |tableView| no |
| 195 // more than the number of times specified with the |kMaxTableViewScrolls| |
| 196 // constant so that dynamically updated UITableViews do not scroll infinitely. |
| 197 bool VerifyTableViewAccessibility(UITableView* tableView) { |
| 198 // Reload |tableView| in order to update its representation in the view |
| 199 // hierarchy, which can be stale. |
| 200 [tableView reloadData]; |
| 201 [tableView layoutIfNeeded]; |
| 202 bool hasRows = false; |
| 203 NSInteger numberOfSections = [tableView numberOfSections]; |
| 204 for (NSInteger section = 0; section < numberOfSections; section++) { |
| 205 if ([tableView numberOfRowsInSection:section]) { |
| 206 hasRows = true; |
| 207 break; |
| 208 } |
| 209 } |
| 210 if (!hasRows) { |
| 211 return ViewAndDescendantsDoNotBlockVoiceOver(tableView, nil); |
| 212 } |
| 213 bool tableViewIsAccessible = true; |
| 214 NSIndexPath* prevIndexPath = nil; |
| 215 // Cell index path to scroll to on each iteration. |
| 216 NSIndexPath* nextIndexPath = [NSIndexPath indexPathForRow:0 inSection:0]; |
| 217 bool hasMoreRows = true; |
| 218 // The maximum number of times that the test will scroll through a |
| 219 // UITableView. |
| 220 const NSUInteger kMaxTableViewScrolls = 1000; |
| 221 NSUInteger numberOfScrolls = 0; |
| 222 // Iterate until the tests have run on every cell in |tableView| or max number |
| 223 // of scrolls is reached. |
| 224 while (hasMoreRows && numberOfScrolls < kMaxTableViewScrolls) { |
| 225 [tableView scrollToRowAtIndexPath:nextIndexPath |
| 226 atScrollPosition:UITableViewScrollPositionTop |
| 227 animated:false]; |
| 228 [tableView reloadData]; |
| 229 [tableView layoutIfNeeded]; |
| 230 if (!ViewAndDescendantsDoNotBlockVoiceOver(tableView, nil)) { |
| 231 tableViewIsAccessible = false; |
| 232 // TODO: (crbug.com/650800) Add more verbose fail case logging. |
| 233 } |
| 234 for (UITableViewCell* cell in tableView.visibleCells) { |
| 235 NSError* error = nil; |
| 236 NSArray* accessibleElements = |
| 237 AccessibilityElementsStartingFromView(cell, &error); |
| 238 for (UIView* view in accessibleElements) { |
| 239 if (!VerifyElementAccessibilityLabel(view)) |
| 240 tableViewIsAccessible = false; |
| 241 // TODO: (crbug.com/650800) Add more verbose fail case logging. |
| 242 } |
| 243 } |
| 244 nextIndexPath = |
| 245 [tableView indexPathForCell:[tableView.visibleCells lastObject]]; |
| 246 // If nextIndexPath is nil or it is greater than or equal to the last cell |
| 247 // in the |tableView|, end loop. |
| 248 if (!nextIndexPath || |
| 249 (nextIndexPath.section >= tableView.numberOfSections - 1 && |
| 250 nextIndexPath.row >= |
| 251 [tableView numberOfRowsInSection:nextIndexPath.section] - 1)) { |
| 252 hasMoreRows = false; |
| 253 } |
| 254 // If nextIndexPath is the same value as prev, which can happen if the |
| 255 // scrolling fails, set nextIndexPath to the next cell. |
| 256 if ([nextIndexPath isEqual:prevIndexPath]) { |
| 257 if (nextIndexPath.row == |
| 258 [tableView numberOfRowsInSection:nextIndexPath.section] - 1) { |
| 259 nextIndexPath = |
| 260 [NSIndexPath indexPathForRow:0 inSection:nextIndexPath.section + 1]; |
| 261 } else { |
| 262 nextIndexPath = [NSIndexPath indexPathForRow:nextIndexPath.row + 1 |
| 263 inSection:nextIndexPath.section]; |
| 264 } |
| 265 } |
| 266 prevIndexPath = nextIndexPath; |
| 267 numberOfScrolls++; |
| 268 } |
| 269 return tableViewIsAccessible; |
| 270 } |
| 271 } |
| 272 |
| 273 namespace chrome_test_util { |
| 274 |
| 275 void VerifyAccessibilityForCurrentScreen() { |
| 276 NSMutableArray* accessibilityElements = [NSMutableArray array]; |
| 277 NSError* inaccessibleChildrenError = nil; |
| 278 // Checking for elements that are inaccessible because |
| 279 // they have an ancestor whose isAccessibilityElement is set to |
| 280 // true, blocking VoiceOver from reaching them... |
| 281 for (UIWindow* window in [[UIApplication sharedApplication] windows]) { |
| 282 // If window is UITextEffectsWindow or UIRemoteKeyboardWindow skip |
| 283 // accessibility check as this is likely a native keyboard. |
| 284 if (!([NSStringFromClass([window class]) |
| 285 isEqualToString:@"UITextEffectsWindow"]) && |
| 286 !([NSStringFromClass([window class]) |
| 287 isEqualToString:@"UIRemoteKeyboardWindow"])) { |
| 288 NSArray* windowElements = AccessibilityElementsStartingFromView( |
| 289 window, &inaccessibleChildrenError); |
| 290 [accessibilityElements addObjectsFromArray:windowElements]; |
| 291 } |
| 292 } |
| 293 |
| 294 // Special case UITableViews. Some elements on UITableViews are not in |
| 295 // the view tree, so we must scroll to ensure that each row in the |
| 296 // UITableView is visible when the accessibility tests are run. Also |
| 297 // removes all UITableViews from accessibilityElements to stop other |
| 298 // tests from running on the table. |
| 299 bool tableViewError = false; |
| 300 NSMutableIndexSet* tableViewsToBeRemoved = [NSMutableIndexSet indexSet]; |
| 301 NSUInteger accessibilityIndex = 0; |
| 302 // Checking for TableView errors... |
| 303 for (UIView* view in accessibilityElements) { |
| 304 if ([view isKindOfClass:[UITableView class]]) { |
| 305 UITableView* table_view = static_cast<UITableView*>(view); |
| 306 if (!VerifyTableViewAccessibility(table_view)) { |
| 307 tableViewError = true; |
| 308 } |
| 309 [tableViewsToBeRemoved addIndex:accessibilityIndex]; |
| 310 } |
| 311 accessibilityIndex++; |
| 312 } |
| 313 [accessibilityElements removeObjectsAtIndexes:tableViewsToBeRemoved]; |
| 314 // Find all elements without labels and generate associated error |
| 315 // messages. |
| 316 bool noLabels = false; |
| 317 NSString* noLabelElementDesc = @""; |
| 318 // Checking for elements without labels... |
| 319 for (UIView* view in accessibilityElements) { |
| 320 if (!ViewOrDescendantHasAccessibilityLabel(view)) { |
| 321 [noLabelElementDesc |
| 322 stringByAppendingString:[NSString |
| 323 stringWithFormat:@"\n'%@'", |
| 324 [view description]]]; |
| 325 noLabels = true; |
| 326 } |
| 327 } |
| 328 // Find all elements which have set their accessibility labels to the |
| 329 // name of an associated image, and generate associated error |
| 330 // messages. |
| 331 bool badDefaultLabel = false; |
| 332 NSString* badDefaultLabelDesc = @""; |
| 333 // Checking for labels with default values... |
| 334 for (UIView* view in accessibilityElements) { |
| 335 if (!ViewHasNonDefaultAccessibilityLabel(view)) { |
| 336 [badDefaultLabelDesc |
| 337 stringByAppendingString:[NSString |
| 338 stringWithFormat:@"\n'%@'", |
| 339 [view description]]]; |
| 340 badDefaultLabel = true; |
| 341 } |
| 342 } |
| 343 |
| 344 GREYAssert(!inaccessibleChildrenError, |
| 345 @"The accessibility tests failed: Inaccessible children error"); |
| 346 GREYAssert(!noLabels, @"The accessibility tests failed: No labels error"); |
| 347 GREYAssert(!badDefaultLabel, |
| 348 @"The accessibility tests failed: Bad default labels error"); |
| 349 GREYAssert(!tableViewError, |
| 350 @"The accessibility tests failed: Table view error"); |
| 351 } |
| 352 |
| 353 } // namespace chrome_test_util |
OLD | NEW |