Skip to content

Commit

Permalink
Merge pull request #74 from inkling/jeff/reload_accessibility_hierarchy
Browse files Browse the repository at this point in the history
Subliminal reloads the accessibility hierarchy as necessary while matching.
  • Loading branch information
aegolden committed Aug 27, 2013
2 parents 7828c5c + 5a712ac commit e82959b
Show file tree
Hide file tree
Showing 6 changed files with 180 additions and 34 deletions.
9 changes: 9 additions & 0 deletions Integration Tests/Tests/SLElementMatchingTest.m
Original file line number Diff line number Diff line change
Expand Up @@ -402,4 +402,13 @@ - (void)testSubliminalRestoresAccessibilityIdentifiersAfterMatchingEvenIfActionT
@"After being matched, an object's identifier should have been restored.");
}

- (void)testSubliminalReloadsTheAccessibilityHierarchyAsNecessaryWhenMatching {
SLElement *fooLabel = [SLElement elementWithAccessibilityLabel:@"foo"];
SLAssertTrue([[UIAElement(fooLabel) label] isEqualToString:@"foo"], @"Could not match label.");

SLAskApp(invalidateAccessibilityHierarchy);

SLAssertTrue([[UIAElement(fooLabel) label] isEqualToString:@"foo"], @"Could not match label.");
}

@end
118 changes: 88 additions & 30 deletions Integration Tests/Tests/SLElementMatchingTestViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ @interface SLElementMatchingTestViewController : SLTestCaseViewController
@end


#pragma mark - SLElementMatchingTestCell

@interface SLElementMatchingTestCell : UITableViewCell

- (void)configureAccessibility;
Expand Down Expand Up @@ -58,7 +60,7 @@ - (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reus
_weatherTemp.textAlignment = NSTextAlignmentRight;
[self.contentView addSubview:_weatherTemp];
} else {
NSAssert(NO, @"%@ reuse identifier was not of expected format: '%@_<%@ test case>'.",
NSAssert(NO, @"%@ reuse identifier was not of expected format ('%@_<%@ test case>') or was unexpected.",
NSStringFromClass([self class]), NSStringFromClass([self class]), NSStringFromClass([SLElementMatchingTestViewController class]));
}
}
Expand Down Expand Up @@ -105,6 +107,68 @@ - (void)layoutSubviews {
@end


#pragma mark - SLElementMatchingTestHeader

@interface SLElementMatchingTestHeader : UIView

- (instancetype)initWithTestCaseWithSelector:(SEL)testCase;

@end

@implementation SLElementMatchingTestHeader {
UIView *_leftView, *_rightView;
}

- (instancetype)initWithTestCaseWithSelector:(SEL)testCase {
self = [super initWithFrame:CGRectZero];
if (self) {
if (testCase == @selector(testMatchingTableViewHeaderChildElements)) {
UILabel *leftLabel = [[UILabel alloc] initWithFrame:CGRectZero];
leftLabel.textAlignment = NSTextAlignmentLeft;
leftLabel.text = @"left";
_leftView = leftLabel;

UILabel *rightLabel = [[UILabel alloc] initWithFrame:CGRectZero];
rightLabel.textAlignment = NSTextAlignmentRight;
rightLabel.text = @"right";
_rightView = rightLabel;
} else if (testCase == @selector(testSubliminalReloadsTheAccessibilityHierarchyAsNecessaryWhenMatching)) {
UILabel *label = [[UILabel alloc] initWithFrame:CGRectZero];
label.textAlignment = NSTextAlignmentLeft;
label.text = @"foo";
_leftView = label;

UIButton *button = [UIButton buttonWithType:UIButtonTypeRoundedRect];
[button setTitle:@"bar" forState:UIControlStateNormal];
_rightView = button;
} else {
NSAssert(NO, @"Unexpected test case: %@", NSStringFromSelector(testCase));
}
[self addSubview:_leftView];
[self addSubview:_rightView];
}
return self;
}

- (void)layoutSubviews {
[super layoutSubviews];

CGRect contentRect = CGRectInset(self.bounds, 20.0f, 0.0f);
CGRect leftViewFrame, rightViewFrame;
CGRectDivide(contentRect, &leftViewFrame, &rightViewFrame, CGRectGetWidth(contentRect) / 2.0f, CGRectMinXEdge);
_leftView.frame = leftViewFrame;
_rightView.frame = rightViewFrame;
}

- (void)removeRightView {
[_rightView removeFromSuperview];
}

@end


#pragma mark - SLElementMatchingTestViewController

@interface SLElementMatchingTestViewController () <UITableViewDataSource, UITableViewDelegate, UIWebViewDelegate>

// fooButton is purposely strong so that we can hold onto it
Expand All @@ -130,6 +194,8 @@ @implementation SLElementMatchingTestViewController {
UIPopoverController *_popoverController;

UIActionSheet *_actionSheet;

SLElementMatchingTestHeader *_headerView;
}

+ (NSString *)nibNameForTestCase:(SEL)testCase {
Expand All @@ -155,7 +221,8 @@ + (NSString *)nibNameForTestCase:(SEL)testCase {
(testCase == @selector(testCannotMatchIndividualChildLabelsOfTableViewCell)) ||
(testCase == @selector(testMatchingNonLabelTableViewCellChildElement)) ||
(testCase == @selector(testMatchingTableViewHeader)) ||
(testCase == @selector(testMatchingTableViewHeaderChildElements))) {
(testCase == @selector(testMatchingTableViewHeaderChildElements)) ||
(testCase == @selector(testSubliminalReloadsTheAccessibilityHierarchyAsNecessaryWhenMatching))) {
return @"SLTableViewChildElementMatchingTestViewController";
} else {
return nil;
Expand All @@ -176,6 +243,7 @@ - (instancetype)initWithTestCaseWithSelector:(SEL)testCase {
[[SLTestController sharedTestController] registerTarget:self forAction:@selector(showPopoverWithActionSheet)];
[[SLTestController sharedTestController] registerTarget:self forAction:@selector(showActionSheet)];
[[SLTestController sharedTestController] registerTarget:self forAction:@selector(hideActionSheet)];
[[SLTestController sharedTestController] registerTarget:self forAction:@selector(invalidateAccessibilityHierarchy)];
}
return self;
}
Expand Down Expand Up @@ -227,7 +295,8 @@ - (void)viewDidLoad {
if (self.tableView) {
if ((self.testCase == @selector(testMatchingTableViewCellTextLabel)) ||
(self.testCase == @selector(testMatchingTableViewHeader)) ||
(self.testCase == @selector(testMatchingTableViewHeaderChildElements))) {
(self.testCase == @selector(testMatchingTableViewHeaderChildElements)) ||
(self.testCase == @selector(testSubliminalReloadsTheAccessibilityHierarchyAsNecessaryWhenMatching))) {
_testTableViewCellClass = [UITableViewCell class];
} else if ((self.testCase == @selector(testMatchingNonLabelTableViewCellChildElement)) ||
(self.testCase == @selector(testMatchingTableViewCellWithCombinedLabel)) ||
Expand Down Expand Up @@ -265,7 +334,8 @@ - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(N

if ((self.testCase == @selector(testMatchingTableViewCellTextLabel)) ||
(self.testCase == @selector(testMatchingTableViewHeader)) ||
(self.testCase == @selector(testMatchingTableViewHeaderChildElements))) {
(self.testCase == @selector(testMatchingTableViewHeaderChildElements)) ||
(self.testCase == @selector(testSubliminalReloadsTheAccessibilityHierarchyAsNecessaryWhenMatching))) {
cell.textLabel.text = @"fooLabel";
} else {
NSAssert([cell isKindOfClass:[SLElementMatchingTestCell class]],
Expand All @@ -288,32 +358,11 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger
label.text = @"fooHeader";
[label sizeToFit];
headerView = label;
} else if (self.testCase == @selector(testMatchingTableViewHeaderChildElements)) {
CGFloat headerHeight = [self tableView:tableView heightForHeaderInSection:section];
CGRect headerRect = (CGRect){
CGPointZero,
CGSizeMake(CGRectGetWidth(tableView.frame), headerHeight)
};
headerView = [[UIView alloc] initWithFrame:headerRect];
CGRect contentRect = CGRectInset(headerRect, 20.0f, 0.0f);
CGFloat halfWidth = CGRectGetWidth(contentRect) / 2.0;
CGSize halfSize = CGSizeMake(halfWidth, CGRectGetHeight(contentRect));

UILabel *labelLeft = [[UILabel alloc] initWithFrame:(CGRect){
contentRect.origin,
halfSize
}];
labelLeft.textAlignment = NSTextAlignmentLeft;
labelLeft.text = @"left";
[headerView addSubview:labelLeft];

UILabel *labelRight = [[UILabel alloc] initWithFrame:(CGRect){
CGPointMake(CGRectGetMinX(contentRect) + halfWidth, CGRectGetMinY(contentRect)),
halfSize
}];
labelRight.textAlignment = NSTextAlignmentRight;
labelRight.text = @"right";
[headerView addSubview:labelRight];
} else if ((self.testCase == @selector(testMatchingTableViewHeaderChildElements)) ||
(self.testCase == @selector(testSubliminalReloadsTheAccessibilityHierarchyAsNecessaryWhenMatching))) {
_headerView = [[SLElementMatchingTestHeader alloc] initWithTestCaseWithSelector:self.testCase];
NSAssert([self numberOfSectionsInTableView:tableView] == 1, @"We only expect to track one header.");
headerView = _headerView;
}

return headerView;
Expand Down Expand Up @@ -419,4 +468,13 @@ - (void)hideActionSheet {
[_actionSheet dismissWithClickedButtonIndex:0 animated:NO];
}

- (void)invalidateAccessibilityHierarchy {
// Removing a subview of a table view header from the view hierarchy,
// without reloading the header, is one way to invalidate the accessibility hierarchy:
// the accessibility container that mocks the header will retain the accessibility element
// that mocked that subview until that element is queried by the accessibility system,
// at which time all child elements of the container will be replaced.
[_headerView removeRightView];
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,33 @@ - (NSArray *)slChildAccessibilityElementsFavoringSubviews:(BOOL)favoringSubviews
NSMutableArray *children = [NSMutableArray array];
NSInteger count = [self accessibilityElementCount];
if (count != NSNotFound && count > 0) {
for (NSInteger i = 0; i < count; i++) {
[children addObject:[self accessibilityElementAtIndex:i]];
}
// Certain accessibility containers, like those that mock table view headers,
// may contain "stale" accessibility elements: elements which initially carry no information,
// but when queried (for some accessibility property) cause their container to reload
// and replace _all_ of its elements.
// We ensure we return valid children by asking for the accessibility label of each element,
// then checking if the container yet vends that element--if it doesn't, we retrieve all children again.
BOOL shouldReloadChildren, haveReloadedChildren = NO;
do {
shouldReloadChildren = NO;
for (NSInteger i = 0; i < count; i++) {
id element = [self accessibilityElementAtIndex:i];
(void)[element accessibilityLabel];
if (element != [self accessibilityElementAtIndex:i]) {
// Protect against tests entering an infinite loop,
// in case there's any scenario where the hierarchy might not stabilize.
if (haveReloadedChildren) {
SLLogAsync(@"The accessibility hierarchy is unstable: the accessibility children of %@ are likely invalid.", self);
} else {
shouldReloadChildren = YES, haveReloadedChildren = YES;
[children removeAllObjects];
break;
}
}

[children addObject:element];
}
} while (shouldReloadChildren);
}
return children;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ extern const NSTimeInterval SLAlertHandlerDidHandleAlertDelay;
/**
Returns `YES` if Subliminal should log alerts as they are handled.
Logging is disabled by default.
@return `YES` if alert-handling logging is enabled, `NO` otherwise.
@see -setLoggingEnabled:
Expand Down
25 changes: 25 additions & 0 deletions Sources/Classes/UIAutomation/User Interface Elements/SLElement.h
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,28 @@
/// Used with `+[SLElement elementWithAccessibilityLabel:value:traits:]`
/// to match elements with any combination of accessibility traits.
extern UIAccessibilityTraits SLUIAccessibilityTraitAny;


#pragma mark - Debugging Subliminal

/**
The methods in the `SLElement (DebugSettings)` category may be useful in debugging Subliminal.
*/
@interface SLElement (DebugSettings)

/**
Determines whether the specified element should use UIAutomation to confirm that it [is valid](-isValid)
after Subliminal has determined (to the best of its ability) that it is valid.
If Subliminal misidentifies an element to UIAutomation, UIAutomation will not necessarily raise
an exception but instead may silently fail (e.g. it may return `null` from APIs like `UIAElement.hitpoint()`,
causing Subliminal to think that an element isn't tappable when really it's not valid).
Enabling this setting may help in diagnosing such failures.
Validity double-checking is disabled (`NO`) by default, because it is more likely that there is a bug
in a particular test than a bug in Subliminal, and because enabling double-checking will
negatively affect the performance of the tests.
*/
@property (nonatomic) BOOL shouldDoubleCheckValidity;

@end
30 changes: 29 additions & 1 deletion Sources/Classes/UIAutomation/User Interface Elements/SLElement.m
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
@implementation SLElement {
BOOL (^_matchesObject)(NSObject*);
NSString *_description;

BOOL _shouldDoubleCheckValidity;
}

+ (void)load {
Expand Down Expand Up @@ -161,6 +163,14 @@ - (BOOL)canDetermineTappability {
return canDetermineTappability;
}

- (BOOL)shouldDoubleCheckValidity {
return _shouldDoubleCheckValidity;
}

- (void)setShouldDoubleCheckValidity:(BOOL)shouldDoubleCheckValidity {
_shouldDoubleCheckValidity = shouldDoubleCheckValidity;
}

- (SLAccessibilityPath *)accessibilityPathWithTimeout:(NSTimeInterval)timeout {
__block SLAccessibilityPath *accessibilityPath = nil;
NSDate *startDate = [NSDate date];
Expand Down Expand Up @@ -198,6 +208,16 @@ - (void)waitUntilTappable:(BOOL)waitUntilTappable
// catch and rethrow exceptions so that we can unbind the path
@try {
NSString *UIARepresentation = [boundPath UIARepresentation];

if (self.shouldDoubleCheckValidity) {
BOOL uiaIsValid = [[[SLTerminal sharedTerminal] evalWithFormat:@"%@.isValid()", UIARepresentation] boolValue];
if (!uiaIsValid) {
// Subliminal is not properly identifying the element to UIAutomation:
// there is a bug in `SLAccessibilityPath` or `NSObject (SLAccessibilityHierarchy)`
[NSException raise:SLUIAElementInvalidException format:@"Element '%@' does not exist at path '%@'.", self, UIARepresentation];
}
}

// evaluate canDetermineTappability using the current path
// because we can't retrieve another while the element is bound
if (waitUntilTappable && [self canDetermineTappabilityUsingAccessibilityPath:accessibilityPath]) {
Expand Down Expand Up @@ -255,7 +275,15 @@ - (void)examineMatchingObject:(void (^)(NSObject *))block timeout:(NSTimeInterva

- (BOOL)isValid {
// isValid evaluates the current state, no waiting to resolve the element
return ([self accessibilityPathWithTimeout:0.0] != nil);
SLAccessibilityPath *accessibilityPath = [self accessibilityPathWithTimeout:0.0];
__block BOOL isValid = (accessibilityPath != nil);
if (isValid && self.shouldDoubleCheckValidity) {
[accessibilityPath bindPath:^(SLAccessibilityPath *boundPath) {
NSString *UIARepresentation = [boundPath UIARepresentation];
isValid = [[[SLTerminal sharedTerminal] evalWithFormat:@"%@.isValid()", UIARepresentation] boolValue];
}];
}
return isValid;
}

/*
Expand Down

0 comments on commit e82959b

Please sign in to comment.