blob: 11fb538547dbec51ced739210ab30a9e1a4c5aca [file] [log] [blame]
// 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>
#include "ios/chrome/test/earl_grey/accessibility_util.h"
#if !defined(__has_feature) || !__has_feature(objc_arc)
#error "This file requires ARC support."
#endif
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