Index: ios/chrome/test/earl_grey/accessibility_util.mm |
diff --git a/ios/chrome/test/earl_grey/accessibility_util.mm b/ios/chrome/test/earl_grey/accessibility_util.mm |
new file mode 100644 |
index 0000000000000000000000000000000000000000..ce17c3aebad7151002491b9e6cd0bfd1c6d2c965 |
--- /dev/null |
+++ b/ios/chrome/test/earl_grey/accessibility_util.mm |
@@ -0,0 +1,353 @@ |
+// Copyright 2016 The Chromium Authors. All rights reserved. |
+// Use of this source code is governed by a BSD-style license that can be |
+// found in the LICENSE file. |
+ |
+#import <EarlGrey/EarlGrey.h> |
+#import <XCTest/XCTest.h> |
+ |
+#import "ios/chrome/test/earl_grey/accessibility_util.h" |
+ |
+namespace { |
+ |
+// Returns whether a UIView is hidden from the screen, based on the alpha |
+// and hidden properties of the UIView. |
+bool ViewIsHidden(UIView* view) { |
+ return view.alpha == 0 || view.hidden; |
+} |
+ |
+// Returns whether a view should be accessible. A view should be accessible if |
+// either it is a whitelisted type or its isAccessibilityElement property is |
+// enabled. |
+bool ViewShouldBeAccessible(UIView* view) { |
+ NSArray* classList = @[ |
+ [UILabel class], [UIControl class], [UITableView class], |
+ [UICollectionViewCell class] |
+ ]; |
+ bool viewIsAccessibleType = false; |
+ for (Class accessibleClass in classList) { |
+ if ([view isKindOfClass:accessibleClass]) { |
+ viewIsAccessibleType = true; |
+ break; |
+ } |
+ } |
+ return (viewIsAccessibleType || view.isAccessibilityElement) && |
+ !ViewIsHidden(view); |
+} |
+ |
+// Returns true if |element| or descendant has property accessibilityLabel |
+// that is not an empty string. If |element| has isAccessibilityElement set to |
+// true, then the |element| itself must have a label. |
+bool ViewOrDescendantHasAccessibilityLabel(UIView* element) { |
+ if ([element.accessibilityLabel length]) |
+ return true; |
+ if (element.isAccessibilityElement) |
+ return false; |
+ for (UIView* view in element.subviews) { |
+ if (ViewOrDescendantHasAccessibilityLabel(view)) |
+ return true; |
+ } |
+ return false; |
+} |
+ |
+// Returns true if |element|'s accessibilityLabel is a not a bad default value, |
+// particularly that it does not match the name of an image in the bundle, since |
+// UIKit can set the accessibilityLabel to an associated image name if no other |
+// data is available. |
+bool ViewHasNonDefaultAccessibilityLabel(UIView* element) { |
+ if (element.accessibilityLabel) { |
+ // Replace all spaces with underscores because when UIKit converts an |
+ // image name to an accessibility label by default, it replaces underscores |
+ // with spaces. |
+ NSString* fileName = |
+ [element.accessibilityLabel stringByReplacingOccurrencesOfString:@" " |
+ withString:@"_"]; |
+ if ([fileName rangeOfString:@"/"].location != NSNotFound) |
+ return true; // "/" is not a valid character in a file name |
+ if ([UIImage imageNamed:fileName]) |
+ return false; |
+ if ([UIImage imageNamed:[fileName stringByAppendingString:@".jpg"]]) |
+ return false; |
+ if ([UIImage imageNamed:[fileName stringByAppendingString:@".gif"]]) |
+ return false; |
+ } |
+ return true; |
+} |
+ |
+// Returns an array of elements that should be accessible. |
+// Helper method for accessibilityElementsStartingFromView, so that |
+// |ancestorString|, which handles internal bookkeeping, is hidden from the top |
+// level API. |ancestorString| is the description of the most recent ancestor |
+// with isAccessibilityElement set to true, and thus when the method is first |
+// called, it should always be set to nil. |
+NSArray* AccessibilityElementsHelperStartingFromView(UIView* view, |
+ NSString* ancestorString, |
+ NSError** error) { |
+ NSMutableArray* results = [NSMutableArray array]; |
+ // Add |view| to |results| if it should be accessible and is not hidden. |
+ if (ViewShouldBeAccessible(view)) { |
+ // UILabels have an extra check since some labels are dynamically set, and |
+ // may not have text or an accessibilityLabel at a given point of |
+ // execution. |
+ if ([view isKindOfClass:[UILabel class]]) { |
+ UILabel* label = static_cast<UILabel*>(view); |
+ if ([label.text length]) |
+ [results addObject:label]; |
+ } else { |
+ [results addObject:view]; |
+ if ([ancestorString length]) { |
+ if (error != nil && !*error) |
+ *error = [NSError errorWithDomain:@"Ancestor blocks VoiceOver" |
+ code:1 |
+ userInfo:nil]; |
+ // The most recent ancestor with Accessibility Element set to true |
+ // blocks VoiceOver for Element ancestorString |
+ } |
+ } |
+ } |
+ if (view.isAccessibilityElement && ![view isKindOfClass:[UILabel class]]) { |
+ ancestorString = [view description]; |
+ } |
+ if (![view isKindOfClass:[UITableView class]]) { |
+ // Do not recurse below views which are accessible but may have children |
+ // that default to being accessible. Also, do not recurse below views which |
+ // implement the UIAccessibilityContainer informal protocol, as these views |
+ // have taken ownership of accessibility behavior of descendents. |
+ if ([view isKindOfClass:[UISwitch class]] || |
+ [view respondsToSelector:@selector(accessibilityElements)]) |
+ return results; |
+ for (UIView* subView in [view subviews]) { |
+ [results addObjectsFromArray:AccessibilityElementsHelperStartingFromView( |
+ subView, ancestorString, error)]; |
+ } |
+ } |
+ return results; |
+} |
+ |
+// Recursively traverses the UIView tree and returns all views that should be |
+// accessible. Views should be accessible if they are of type UIControl, |
+// UILabel, UITableViewCell, or UICollectionViewCell or if their |
+// isAccessibilityElement property is set to true. Also, it prints errors when |
+// an ancestor of an element which should be accessible has |
+// isAccessibilityElement set to true. It notifies the calling method of this |
+// error by assigning |error| to a new NSError object. By passing in an NSError |
+// object set to nil, it can be used to determine if there was an error. |
+NSArray* AccessibilityElementsStartingFromView(UIView* view, NSError** error) { |
+ return AccessibilityElementsHelperStartingFromView(view, nil, error); |
+} |
+ |
+// Starting from |view|, verifies that no view masks its descendants by having |
+// isAccessibilityElement set to true. |ancestorString| is the description of |
+// the most recent ancestor with isAccessibilityElement set to true, and thus |
+// when the method is first called, it should always be set to nil. |
+bool ViewAndDescendantsDoNotBlockVoiceOver(UIView* view, |
+ NSString* ancestorString) { |
+ if (![view isKindOfClass:[UILabel class]] && ViewShouldBeAccessible(view)) { |
+ if ([ancestorString length]) { |
+ // Ancestor String masks Descendant because the ancestor's |
+ // isAccessibilityElement is set to true. |
+ return false; |
+ } |
+ if (view.isAccessibilityElement) { |
+ ancestorString = [view description]; |
+ } |
+ } |
+ // Do not recurse through hidden elements, as their descendants are also |
+ // hidden. |
+ if (ViewIsHidden(view)) |
+ return true; |
+ // Do not recurse below views which are accessible but may have children |
+ // that default to being accessible. Also, do not recurse below views which |
+ // implement the UIAccessibilityContainer informal protocol, as these views |
+ // have taken ownership of accessibility behavior of descendents. |
+ if ([view isKindOfClass:[UISwitch class]] || |
+ [view respondsToSelector:@selector(accessibilityElements)]) |
+ return true; |
+ bool ancestorMasksDescendant = true; |
+ for (UIView* subView in [view subviews]) { |
+ if (!ViewAndDescendantsDoNotBlockVoiceOver(subView, ancestorString)) { |
+ ancestorMasksDescendant = false; |
+ } |
+ } |
+ return ancestorMasksDescendant; |
+} |
+ |
+// Run accessibilityLabel tests on |view|. This method is used for cases where |
+// tests are grouped by element, instead of the typical case where elements are |
+// grouped by test. |
+bool VerifyElementAccessibilityLabel(UIView* view) { |
+ if (view && !ViewIsHidden(view)) { |
+ if (!ViewOrDescendantHasAccessibilityLabel(view)) { |
+ // TODO: (crbug.com/650800) Add more verbose fail case logging. |
+ return false; |
+ } |
+ if (!ViewHasNonDefaultAccessibilityLabel(view)) { |
+ // TODO: (crbug.com/650800) Add more verbose fail case logging. |
+ return false; |
+ } |
+ } |
+ return true; |
+} |
+ |
+// Verifies |tableView|'s accessibility by scrolling through to ensure that |
+// accessibility tests are run on each cell. Cells which are offscreen may |
+// not be in the UIView hierarchy, so UITableViews must be scrolled in order to |
+// verify all of its cells. The method will scroll through the |tableView| no |
+// more than the number of times specified with the |kMaxTableViewScrolls| |
+// constant so that dynamically updated UITableViews do not scroll infinitely. |
+bool VerifyTableViewAccessibility(UITableView* tableView) { |
+ // Reload |tableView| in order to update its representation in the view |
+ // hierarchy, which can be stale. |
+ [tableView reloadData]; |
+ [tableView layoutIfNeeded]; |
+ bool hasRows = false; |
+ NSInteger numberOfSections = [tableView numberOfSections]; |
+ for (NSInteger section = 0; section < numberOfSections; section++) { |
+ if ([tableView numberOfRowsInSection:section]) { |
+ hasRows = true; |
+ break; |
+ } |
+ } |
+ if (!hasRows) { |
+ return ViewAndDescendantsDoNotBlockVoiceOver(tableView, nil); |
+ } |
+ bool tableViewIsAccessible = true; |
+ NSIndexPath* prevIndexPath = nil; |
+ // Cell index path to scroll to on each iteration. |
+ NSIndexPath* nextIndexPath = [NSIndexPath indexPathForRow:0 inSection:0]; |
+ bool hasMoreRows = true; |
+ // The maximum number of times that the test will scroll through a |
+ // UITableView. |
+ const NSUInteger kMaxTableViewScrolls = 1000; |
+ NSUInteger numberOfScrolls = 0; |
+ // Iterate until the tests have run on every cell in |tableView| or max number |
+ // of scrolls is reached. |
+ while (hasMoreRows && numberOfScrolls < kMaxTableViewScrolls) { |
+ [tableView scrollToRowAtIndexPath:nextIndexPath |
+ atScrollPosition:UITableViewScrollPositionTop |
+ animated:false]; |
+ [tableView reloadData]; |
+ [tableView layoutIfNeeded]; |
+ if (!ViewAndDescendantsDoNotBlockVoiceOver(tableView, nil)) { |
+ tableViewIsAccessible = false; |
+ // TODO: (crbug.com/650800) Add more verbose fail case logging. |
+ } |
+ for (UITableViewCell* cell in tableView.visibleCells) { |
+ NSError* error = nil; |
+ NSArray* accessibleElements = |
+ AccessibilityElementsStartingFromView(cell, &error); |
+ for (UIView* view in accessibleElements) { |
+ if (!VerifyElementAccessibilityLabel(view)) |
+ tableViewIsAccessible = false; |
+ // TODO: (crbug.com/650800) Add more verbose fail case logging. |
+ } |
+ } |
+ nextIndexPath = |
+ [tableView indexPathForCell:[tableView.visibleCells lastObject]]; |
+ // If nextIndexPath is nil or it is greater than or equal to the last cell |
+ // in the |tableView|, end loop. |
+ if (!nextIndexPath || |
+ (nextIndexPath.section >= tableView.numberOfSections - 1 && |
+ nextIndexPath.row >= |
+ [tableView numberOfRowsInSection:nextIndexPath.section] - 1)) { |
+ hasMoreRows = false; |
+ } |
+ // If nextIndexPath is the same value as prev, which can happen if the |
+ // scrolling fails, set nextIndexPath to the next cell. |
+ if ([nextIndexPath isEqual:prevIndexPath]) { |
+ if (nextIndexPath.row == |
+ [tableView numberOfRowsInSection:nextIndexPath.section] - 1) { |
+ nextIndexPath = |
+ [NSIndexPath indexPathForRow:0 inSection:nextIndexPath.section + 1]; |
+ } else { |
+ nextIndexPath = [NSIndexPath indexPathForRow:nextIndexPath.row + 1 |
+ inSection:nextIndexPath.section]; |
+ } |
+ } |
+ prevIndexPath = nextIndexPath; |
+ numberOfScrolls++; |
+ } |
+ return tableViewIsAccessible; |
+} |
+} |
+ |
+namespace chrome_test_util { |
+ |
+void VerifyAccessibilityForCurrentScreen() { |
+ NSMutableArray* accessibilityElements = [NSMutableArray array]; |
+ NSError* inaccessibleChildrenError = nil; |
+ // Checking for elements that are inaccessible because |
+ // they have an ancestor whose isAccessibilityElement is set to |
+ // true, blocking VoiceOver from reaching them... |
+ for (UIWindow* window in [[UIApplication sharedApplication] windows]) { |
+ // If window is UITextEffectsWindow or UIRemoteKeyboardWindow skip |
+ // accessibility check as this is likely a native keyboard. |
+ if (!([NSStringFromClass([window class]) |
+ isEqualToString:@"UITextEffectsWindow"]) && |
+ !([NSStringFromClass([window class]) |
+ isEqualToString:@"UIRemoteKeyboardWindow"])) { |
+ NSArray* windowElements = AccessibilityElementsStartingFromView( |
+ window, &inaccessibleChildrenError); |
+ [accessibilityElements addObjectsFromArray:windowElements]; |
+ } |
+ } |
+ |
+ // Special case UITableViews. Some elements on UITableViews are not in |
+ // the view tree, so we must scroll to ensure that each row in the |
+ // UITableView is visible when the accessibility tests are run. Also |
+ // removes all UITableViews from accessibilityElements to stop other |
+ // tests from running on the table. |
+ bool tableViewError = false; |
+ NSMutableIndexSet* tableViewsToBeRemoved = [NSMutableIndexSet indexSet]; |
+ NSUInteger accessibilityIndex = 0; |
+ // Checking for TableView errors... |
+ for (UIView* view in accessibilityElements) { |
+ if ([view isKindOfClass:[UITableView class]]) { |
+ UITableView* table_view = static_cast<UITableView*>(view); |
+ if (!VerifyTableViewAccessibility(table_view)) { |
+ tableViewError = true; |
+ } |
+ [tableViewsToBeRemoved addIndex:accessibilityIndex]; |
+ } |
+ accessibilityIndex++; |
+ } |
+ [accessibilityElements removeObjectsAtIndexes:tableViewsToBeRemoved]; |
+ // Find all elements without labels and generate associated error |
+ // messages. |
+ bool noLabels = false; |
+ NSString* noLabelElementDesc = @""; |
+ // Checking for elements without labels... |
+ for (UIView* view in accessibilityElements) { |
+ if (!ViewOrDescendantHasAccessibilityLabel(view)) { |
+ [noLabelElementDesc |
+ stringByAppendingString:[NSString |
+ stringWithFormat:@"\n'%@'", |
+ [view description]]]; |
+ noLabels = true; |
+ } |
+ } |
+ // Find all elements which have set their accessibility labels to the |
+ // name of an associated image, and generate associated error |
+ // messages. |
+ bool badDefaultLabel = false; |
+ NSString* badDefaultLabelDesc = @""; |
+ // Checking for labels with default values... |
+ for (UIView* view in accessibilityElements) { |
+ if (!ViewHasNonDefaultAccessibilityLabel(view)) { |
+ [badDefaultLabelDesc |
+ stringByAppendingString:[NSString |
+ stringWithFormat:@"\n'%@'", |
+ [view description]]]; |
+ badDefaultLabel = true; |
+ } |
+ } |
+ |
+ GREYAssert(!inaccessibleChildrenError, |
+ @"The accessibility tests failed: Inaccessible children error"); |
+ GREYAssert(!noLabels, @"The accessibility tests failed: No labels error"); |
+ GREYAssert(!badDefaultLabel, |
+ @"The accessibility tests failed: Bad default labels error"); |
+ GREYAssert(!tableViewError, |
+ @"The accessibility tests failed: Table view error"); |
+} |
+ |
+} // namespace chrome_test_util |