Skip to content

Commit

Permalink
Merge pull request #1143 from zradke/zradke/first-responder-search
Browse files Browse the repository at this point in the history
Widen `firstResponder` searches to all windows
  • Loading branch information
justinseanmartin authored Mar 27, 2020
2 parents ecfc02d + ded9474 commit bc9266a
Show file tree
Hide file tree
Showing 7 changed files with 250 additions and 169 deletions.
5 changes: 5 additions & 0 deletions Additions/UIApplication-KIFAdditions.h
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ CF_EXPORT SInt32 KIFRunLoopRunInModeRelativeToAnimationSpeed(CFStringRef mode, C
*/
- (NSArray *)windowsWithKeyWindow;

/// @discussion The first responders are ordered in the reverse order of @c -windowsWithKeyWindow
/// to return in order of nearest visually.
/// @returns All first responders in the application.
- (NSArray<UIResponder *> *)firstResponders;

/*!
The current Core Animation speed of the keyWindow's CALayer.
*/
Expand Down
15 changes: 15 additions & 0 deletions Additions/UIApplication-KIFAdditions.m
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// which Square, Inc. licenses this file to you.

#import "UIApplication-KIFAdditions.h"
#import "UIWindow-KIFAdditions.h"
#import "LoadableCategory.h"
#import "UIView-KIFAdditions.h"
#import "NSError-KIFAdditions.h"
Expand Down Expand Up @@ -99,6 +100,20 @@ - (UIWindow *)dimmingViewWindow;
return [self getWindowForSubviewClass:@"UIDimmingView"];
}

- (NSArray<UIResponder *> *)firstResponders;
{
NSMutableArray *responders = [NSMutableArray array];

for (UIWindow *window in [[self windowsWithKeyWindow] reverseObjectEnumerator]) {
UIResponder *responder = window.firstResponder;
if (responder) {
[responders addObject:responder];
}
}

return [responders copy];
}

- (UIWindow *)getWindowForSubviewClass:(NSString*)className;
{
for (UIWindow *window in self.windowsWithKeyWindow) {
Expand Down
4 changes: 2 additions & 2 deletions Classes/KIFUITestActor.h
Original file line number Diff line number Diff line change
Expand Up @@ -678,7 +678,7 @@ typedef NS_ENUM(NSUInteger, KIFPullToRefreshTiming) {
/*!
@abstract Waits until a view or accessibility element is the first responder.
@discussion The first responder is found by searching the view hierarchy of the application's
main window and its accessibility label is compared to the given value. If they match, the
windows and its accessibility label is compared to the given value. If they match, the
step returns success else it will attempt to wait until they do.
@param label The accessibility label of the element to wait for.
*/
Expand All @@ -687,7 +687,7 @@ typedef NS_ENUM(NSUInteger, KIFPullToRefreshTiming) {
/*!
@abstract Waits until a view or accessibility element is the first responder.
@discussion The first responder is found by searching the view hierarchy of the application's
main window and its accessibility label is compared to the given value. If they match, the
windows and its accessibility label is compared to the given value. If they match, the
step returns success else it will attempt to wait until they do.
@param label The accessibility label of the element to wait for.
@param traits The accessibility traits of the element to wait for. Elements that do not include at least these traits are ignored.
Expand Down
176 changes: 111 additions & 65 deletions Classes/KIFUITestActor.m
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ - (void)waitForAnimationsToFinishWithTimeout:(NSTimeInterval)timeout stabilizati
[self waitForTimeInterval:maximumWaitingTimeInterval relativeToAnimationSpeed:YES];
}
} else {

// Wait for the view to stabilize and give them a chance to start animations before we wait for them.
[self waitForTimeInterval:stabilizationTime relativeToAnimationSpeed:YES];
maximumWaitingTimeInterval -= stabilizationTime;
Expand Down Expand Up @@ -468,24 +468,38 @@ - (void)enterTextIntoCurrentFirstResponder:(NSString *)text fallbackView:(UIView
[text enumerateSubstringsInRange:NSMakeRange(0, text.length)
options:NSStringEnumerationByComposedCharacterSequences
usingBlock: ^(NSString *characterString,NSRange substringRange,NSRange enclosingRange,BOOL * stop)
{
{
if (![KIFTypist enterCharacter:characterString]) {
NSLog(@"KIF: Unable to find keyboard key for %@. Will attempt to insert manually.", characterString);

// Attempt to cheat if we couldn't find the character
UIView * fallback = fallbackView;
if (!fallback) {
UIResponder *firstResponder = [[[UIApplication sharedApplication] keyWindow] firstResponder];
NSMutableArray *fallbackViews = [NSMutableArray array];

if ([firstResponder isKindOfClass:[UIView class]]) {
fallback = (UIView *)firstResponder;
if (fallbackView) {
[fallbackViews addObject:fallbackView];
} else {
[fallbackViews addObjectsFromArray:[[UIApplication sharedApplication] firstResponders]];
}

for (id fallback in [fallbackViews copy]) {
if (![fallback isKindOfClass:[UITextField class]] &&
![fallback isKindOfClass:[UITextView class]] &&
![fallback isKindOfClass:[UISearchBar class]]) {
[fallbackViews removeObject:fallback];
}
}

if ([fallback isKindOfClass:[UITextField class]] || [fallback isKindOfClass:[UITextView class]] || [fallback isKindOfClass:[UISearchBar class]]) {
NSLog(@"KIF: Unable to find keyboard key for %@. Inserting manually.", characterString);
[(UITextField *)fallback setText:[[(UITextField *)fallback text] stringByAppendingString:characterString]];
} else {
[self failWithError:[NSError KIFErrorWithFormat:@"Failed to find key for character \"%@\"", characterString] stopTest:YES];
UITextField *fallbackTextView = fallbackViews.firstObject;
if (fallbackTextView) {
if (fallbackViews.count > 1) {
NSLog(@"KIF: Found multiple possible fallback views for entering text: %@. Will use: %@", fallbackViews, fallbackTextView);
}

[fallbackTextView setText:[[fallbackTextView text] stringByAppendingString:characterString]];
return;
}

[self failWithError:[NSError KIFErrorWithFormat:@"Failed to find key for character \"%@\"", characterString] stopTest:YES];
}
}];

Expand Down Expand Up @@ -551,9 +565,17 @@ - (void)expectView:(UIView *)view toContainText:(NSString *)expectedResult
- (void)clearTextFromFirstResponder
{
@autoreleasepool {
UIView *firstResponder = (id)[[[UIApplication sharedApplication] keyWindow] firstResponder];
if ([firstResponder isKindOfClass:[UIView class]]) {
[self clearTextFromElement:(UIAccessibilityElement *)firstResponder inView:firstResponder];
NSArray *firstResponders = [[UIApplication sharedApplication] firstResponders];

for (UIResponder *firstResponder in firstResponders) {
if ([firstResponder isKindOfClass:[UIView class]]) {
if (firstResponders.count > 1) {
NSLog(@"KIF: Found multiple first responders while attempting to clear text: %@. Will use: %@.", firstResponders, firstResponder);
}

[self clearTextFromElement:(UIAccessibilityElement *)firstResponder inView:(UIView *)firstResponder];
break;
}
}
}
}
Expand Down Expand Up @@ -976,7 +998,7 @@ - (void)choosePhotoInAlbum:(NSString *)albumName atRow:(NSInteger)row column:(NS

// Tap the desired photo in the grid
// TODO: This currently only works for the first page of photos. It should scroll appropriately at some point.
UIAccessibilityElement *headerElt = [[UIApplication sharedApplication] accessibilityElementMatchingBlock:^(UIAccessibilityElement *element) {
UIAccessibilityElement *headerElt = [[UIApplication sharedApplication] accessibilityElementMatchingBlock:^(UIAccessibilityElement *element) {
return [NSStringFromClass(element.class) isEqual:@"UINavigationItemButtonView"];
}];
UIView* headerView = [UIAccessibilityElement viewContainingAccessibilityElement:headerElt];
Expand Down Expand Up @@ -1095,56 +1117,56 @@ - (void)swipeViewWithAccessibilityLabel:(NSString *)label value:(NSString *)valu
- (void)swipeAccessibilityElement:(UIAccessibilityElement *)element inView:(UIView *)viewToSwipe inDirection:(KIFSwipeDirection)direction
{
// The original version of this came from http://groups.google.com/group/kif-framework/browse_thread/thread/df3f47eff9f5ac8c

const NSUInteger kNumberOfPointsInSwipePath = 20;

// Within this method, all geometry is done in the coordinate system of the view to swipe.
CGRect elementFrame = [self elementFrameForElement:element andView:viewToSwipe];

CGPoint swipeStart = CGPointCenteredInRect(elementFrame);

KIFDisplacement swipeDisplacement = [self _displacementForSwipingInDirection:direction];

[viewToSwipe dragFromPoint:swipeStart displacement:swipeDisplacement steps:kNumberOfPointsInSwipePath];
}

- (void)pullToRefreshViewWithAccessibilityLabel:(NSString *)label
{
[self pullToRefreshViewWithAccessibilityLabel:label value:nil pullDownDuration:0 traits:UIAccessibilityTraitNone];
[self pullToRefreshViewWithAccessibilityLabel:label value:nil pullDownDuration:0 traits:UIAccessibilityTraitNone];
}

- (void)pullToRefreshViewWithAccessibilityLabel:(NSString *)label pullDownDuration:(KIFPullToRefreshTiming) pullDownDuration
{
[self pullToRefreshViewWithAccessibilityLabel:label value:nil pullDownDuration:pullDownDuration traits:UIAccessibilityTraitNone];
[self pullToRefreshViewWithAccessibilityLabel:label value:nil pullDownDuration:pullDownDuration traits:UIAccessibilityTraitNone];
}

- (void)pullToRefreshViewWithAccessibilityLabel:(NSString *)label value:(NSString *)value
{
[self pullToRefreshViewWithAccessibilityLabel:label value:value pullDownDuration:0 traits:UIAccessibilityTraitNone];
[self pullToRefreshViewWithAccessibilityLabel:label value:value pullDownDuration:0 traits:UIAccessibilityTraitNone];
}

- (void)pullToRefreshViewWithAccessibilityLabel:(NSString *)label value:(NSString *)value pullDownDuration:(KIFPullToRefreshTiming) pullDownDuration traits:(UIAccessibilityTraits)traits
{
UIView *viewToSwipe = nil;
UIAccessibilityElement *element = nil;
UIView *viewToSwipe = nil;
UIAccessibilityElement *element = nil;

[self waitForAccessibilityElement:&element view:&viewToSwipe withLabel:label value:value traits:traits tappable:YES];
[self waitForAccessibilityElement:&element view:&viewToSwipe withLabel:label value:value traits:traits tappable:YES];

[self pullToRefreshAccessibilityElement:element inView:viewToSwipe pullDownDuration:pullDownDuration];
[self pullToRefreshAccessibilityElement:element inView:viewToSwipe pullDownDuration:pullDownDuration];
}

- (void)pullToRefreshAccessibilityElement:(UIAccessibilityElement *)element inView:(UIView *)viewToSwipe pullDownDuration:(KIFPullToRefreshTiming) pullDownDuration
{
//Based on swipeAccessibilityElement
//Based on swipeAccessibilityElement

const NSUInteger kNumberOfPointsInSwipePath = pullDownDuration ? pullDownDuration : KIFPullToRefreshInAboutAHalfSecond;
const NSUInteger kNumberOfPointsInSwipePath = pullDownDuration ? pullDownDuration : KIFPullToRefreshInAboutAHalfSecond;

// Can handle only the touchable space.
CGRect elementFrame = [viewToSwipe convertRect:viewToSwipe.bounds toView:[UIApplication sharedApplication].keyWindow.rootViewController.view];
CGPoint swipeStart = CGPointCenteredInRect(elementFrame);
CGPoint swipeDisplacement = CGPointMake(CGRectGetMidX(elementFrame), CGRectGetMaxY(elementFrame));
CGPoint swipeDisplacement = CGPointMake(CGRectGetMidX(elementFrame), CGRectGetMaxY(elementFrame));

[viewToSwipe dragFromPoint:swipeStart displacement:swipeDisplacement steps:kNumberOfPointsInSwipePath];
[viewToSwipe dragFromPoint:swipeStart displacement:swipeDisplacement steps:kNumberOfPointsInSwipePath];
}

- (void)scrollViewWithAccessibilityLabel:(NSString *)label byFractionOfSizeHorizontal:(CGFloat)horizontalFraction vertical:(CGFloat)verticalFraction
Expand Down Expand Up @@ -1182,13 +1204,24 @@ - (void)scrollAccessibilityElement:(UIAccessibilityElement *)element inView:(UIV
- (void)waitForFirstResponderWithAccessibilityLabel:(NSString *)label
{
[self runBlock:^KIFTestStepResult(NSError **error) {
UIResponder *firstResponder = [[[UIApplication sharedApplication] keyWindow] firstResponder];
if ([firstResponder isKindOfClass:NSClassFromString(@"UISearchBarTextField")]) {
do {
firstResponder = [(UIView *)firstResponder superview];
} while (firstResponder && ![firstResponder isKindOfClass:[UISearchBar class]]);
BOOL didMatch = NO;
NSArray *firstResponders = [[UIApplication sharedApplication] firstResponders];

for (UIResponder *firstResponder in firstResponders) {
UIResponder *foundResponder = firstResponder;
if ([foundResponder isKindOfClass:NSClassFromString(@"UISearchBarTextField")]) {
do {
foundResponder = [(UIView *)foundResponder superview];
} while (foundResponder && ![foundResponder isKindOfClass:[UISearchBar class]]);
}

if (foundResponder.accessibilityLabel == label || [foundResponder.accessibilityLabel isEqualToString:label]) {
didMatch = YES;
break;
}
}
KIFTestWaitCondition([[firstResponder accessibilityLabel] isEqualToString:label], error, @"Expected accessibility label for first responder to be '%@', got '%@'", label, [firstResponder accessibilityLabel]);

KIFTestWaitCondition(didMatch, error, @"Expected to find a first responder with the accessibility label '%@', got: %@", label, firstResponders);

return KIFTestStepResultSuccess;
}];
Expand All @@ -1197,13 +1230,26 @@ - (void)waitForFirstResponderWithAccessibilityLabel:(NSString *)label
- (void)waitForFirstResponderWithAccessibilityLabel:(NSString *)label traits:(UIAccessibilityTraits)traits
{
[self runBlock:^KIFTestStepResult(NSError **error) {
UIResponder *firstResponder = [[[UIApplication sharedApplication] keyWindow] firstResponder];

NSString *foundLabel = firstResponder.accessibilityLabel;
BOOL didMatchLabel = NO;
BOOL didMatchTraits = NO;
NSArray *firstResponders = [[UIApplication sharedApplication] firstResponders];

for (UIResponder *firstResponder in firstResponders) {
if (firstResponder.accessibilityLabel == label || [firstResponder.accessibilityLabel isEqualToString:label]) {
didMatchLabel = YES;
}
if (firstResponder.accessibilityTraits & traits) {
didMatchTraits = YES;
}

if (didMatchLabel && didMatchTraits) {
break;
}
}

// foundLabel == label checks for the case where both are nil.
KIFTestWaitCondition(foundLabel == label || [foundLabel isEqualToString:label], error, @"Expected accessibility label for first responder to be '%@', got '%@'", label, foundLabel);
KIFTestWaitCondition(firstResponder.accessibilityTraits & traits, error, @"Found first responder with accessibility label, but not traits. First responder: %@", firstResponder);
KIFTestWaitCondition(didMatchLabel, error, @"Expected to find a first responder with the accessibility label '%@', got: %@", label, firstResponders);
KIFTestWaitCondition(didMatchTraits, error, @"Expected to find a first responder with accessibility traits, got: %@", firstResponders);

return KIFTestStepResultSuccess;
}];
Expand Down Expand Up @@ -1392,43 +1438,43 @@ - (void)deactivateAppForDuration:(NSTimeInterval)duration {

-(void) tapStepperWithAccessibilityLabel: (NSString *)accessibilityLabel increment: (KIFStepperDirection) stepperDirection
{
@autoreleasepool {
UIView *view = nil;
UIAccessibilityElement *element = nil;
[self waitForAccessibilityElement:&element view:&view withLabel:accessibilityLabel value:nil traits:UIAccessibilityTraitNone tappable:YES];
[self tapStepperWithAccessibilityElement:element increment:stepperDirection inView:view];
}
@autoreleasepool {
UIView *view = nil;
UIAccessibilityElement *element = nil;
[self waitForAccessibilityElement:&element view:&view withLabel:accessibilityLabel value:nil traits:UIAccessibilityTraitNone tappable:YES];
[self tapStepperWithAccessibilityElement:element increment:stepperDirection inView:view];
}
}

//inspired by http://www.raywenderlich.com/61419/ios-ui-testing-with-kif
- (void)tapStepperWithAccessibilityElement:(UIAccessibilityElement *)element increment: (KIFStepperDirection) stepperDirection inView:(UIView *)view
{
[self runBlock:^KIFTestStepResult(NSError **error) {
[self runBlock:^KIFTestStepResult(NSError **error) {

KIFTestWaitCondition(view.isUserInteractionActuallyEnabled, error, @"View is not enabled for interaction: %@", view);
KIFTestWaitCondition(view.isUserInteractionActuallyEnabled, error, @"View is not enabled for interaction: %@", view);

CGPoint stepperPointToTap = [self tappablePointInElement:element andView:view];

switch (stepperDirection)
{
case KIFStepperDirectionIncrement:
stepperPointToTap.x += CGRectGetWidth(view.frame) / 4;
break;
case KIFStepperDirectionDecrement:
stepperPointToTap.x -= CGRectGetWidth(view.frame) / 4;
break;
}
switch (stepperDirection)
{
case KIFStepperDirectionIncrement:
stepperPointToTap.x += CGRectGetWidth(view.frame) / 4;
break;
case KIFStepperDirectionDecrement:
stepperPointToTap.x -= CGRectGetWidth(view.frame) / 4;
break;
}

// This is mostly redundant of the test in _accessibilityElementWithLabel:
KIFTestWaitCondition(!isnan(stepperPointToTap.x), error, @"View is not tappable: %@", view);
[view tapAtPoint:stepperPointToTap];
// This is mostly redundant of the test in _accessibilityElementWithLabel:
KIFTestWaitCondition(!isnan(stepperPointToTap.x), error, @"View is not tappable: %@", view);
[view tapAtPoint:stepperPointToTap];

KIFTestCondition(![view canBecomeFirstResponder] || [view isDescendantOfFirstResponder], error, @"Failed to make the view into the first responder: %@", view);
KIFTestCondition(![view canBecomeFirstResponder] || [view isDescendantOfFirstResponder], error, @"Failed to make the view into the first responder: %@", view);

return KIFTestStepResultSuccess;
}];
return KIFTestStepResultSuccess;
}];

[self waitForAnimationsToFinish];
[self waitForAnimationsToFinish];
}

- (CGRect) elementFrameForElement:(UIAccessibilityElement *)element andView:(UIView *)view
Expand Down
4 changes: 2 additions & 2 deletions Classes/KIFUIViewTestActor.h
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,8 @@ extern NSString *const inputFieldTestString;

/*!
@abstract Waits until a view or accessibility element matching the tester's search predicate is the first responder.
@discussion The first responder is found by searching the view hierarchy of the application's
main window and its accessibility label is compared to the given value. If they match, the
@discussion The first responder is found by searching the view hierarchy of all the application's
windows and its accessibility label is compared to the given value. If they match, the
step returns success else it will attempt to wait until they do.
*/
- (void)waitToBecomeFirstResponder;
Expand Down
12 changes: 10 additions & 2 deletions Classes/KIFUIViewTestActor.m
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,17 @@ - (void)waitToBecomeTappable;
- (void)waitToBecomeFirstResponder;
{
[self runBlock:^KIFTestStepResult(NSError **error) {
UIResponder *firstResponder = [[[UIApplication sharedApplication] keyWindow] firstResponder];
BOOL didMatch = NO;
NSArray *firstResponders = [[UIApplication sharedApplication] firstResponders];

KIFTestWaitCondition([self.predicate evaluateWithObject:firstResponder], error, @"Expected first responder to match '%@', got '%@'", self.predicate, firstResponder);
for (UIResponder *firstResponder in firstResponders) {
if ([self.predicate evaluateWithObject:firstResponder]) {
didMatch = YES;
break;
}
}

KIFTestWaitCondition(didMatch, error, @"Expected to find a first responder matching '%@', got: %@", self.predicate.kifPredicateDescription, firstResponders);
return KIFTestStepResultSuccess;
}];
}
Expand Down
Loading

0 comments on commit bc9266a

Please sign in to comment.