From e7bf93607fcbb35146ff2f333ab7bfafeac38955 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Mon, 7 Sep 2026 10:39:10 -0700 Subject: [PATCH] fix: touch and scroll related view issue in IntegrationApp --- WebDriverAgent.xcodeproj/project.pbxproj | 12 ++ .../Classes/FBScrollViewController.m | 17 ++- .../Classes/TouchViewController.h | 2 +- .../IntegrationApp/Classes/TouchableView.h | 3 +- .../IntegrationApp/Classes/TouchableView.m | 9 +- .../Resources/Base.lproj/Main.storyboard | 23 ++-- .../IntegrationTests/FBIntegrationAppTests.m | 78 +++++++++++ .../UnitTests/FBTouchableViewTests.m | 121 ++++++++++++++++++ 8 files changed, 249 insertions(+), 16 deletions(-) create mode 100644 WebDriverAgentTests/IntegrationTests/FBIntegrationAppTests.m create mode 100644 WebDriverAgentTests/UnitTests/FBTouchableViewTests.m diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index 909b17a1c2..886b043a60 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -1270,6 +1270,10 @@ FEC3A97A4929115A192F947A /* XCUIDeviceEventAndStateInterface-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 003AAAB9CB5FB38E45E05F6F /* XCUIDeviceEventAndStateInterface-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; FFA7672B731B57AB9283DD40 /* FBXCElementSnapshotWrapper.h in Headers */ = {isa = PBXBuildFile; fileRef = 13DE7A53287CA1EC003243C6 /* FBXCElementSnapshotWrapper.h */; }; FFD70914E6D8CE5D4FED4B69 /* XCUIAXNotificationHandling-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = EA96C2FEDE73CB5148BA4949 /* XCUIAXNotificationHandling-Protocol.h */; }; + 6C07E44E139D41D6BA092F39 /* FBTouchableViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 1D04760558C64B8EB69380E0 /* FBTouchableViewTests.m */; }; + C15E49E6B6924191BAAD9FDF /* FBIntegrationAppTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 27B518E261C14E29B943AB9B /* FBIntegrationAppTests.m */; }; + 81E462B8A75F497CBFC6FD8B /* TouchableView.m in Sources */ = {isa = PBXBuildFile; fileRef = 315A15002518CB8700A3A064 /* TouchableView.m */; }; + B6DE70BFF27148BDAC72F80C /* TouchSpotView.m in Sources */ = {isa = PBXBuildFile; fileRef = 315A15062518CC2800A3A064 /* TouchSpotView.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1962,6 +1966,8 @@ FCD1815F2BF21CA0936B04E1 /* XCTMessagingRole_SiriAutomation-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCTMessagingRole_SiriAutomation-Protocol.h"; sourceTree = ""; }; FDB15E393EA0850C004D26B2 /* XCTScreenCapturePolicy.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = XCTScreenCapturePolicy.h; sourceTree = ""; }; FF8E3B470FC9639D5F18E2EA /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 1D04760558C64B8EB69380E0 /* FBTouchableViewTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBTouchableViewTests.m; sourceTree = ""; }; + 27B518E261C14E29B943AB9B /* FBIntegrationAppTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBIntegrationAppTests.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2632,6 +2638,7 @@ EE9B76541CF7987300275851 /* IntegrationTests */ = { isa = PBXGroup; children = ( + 27B518E261C14E29B943AB9B /* FBIntegrationAppTests.m */, EE9B76991CF799F400275851 /* FBAlertTests.m */, 719CD8FE2126C90200C7D0C2 /* FBAutoAlertsHandlerTests.m */, EE26409C1D0EBA25009BE6B0 /* FBElementAttributeTests.m */, @@ -2675,6 +2682,7 @@ EE9B76561CF7987300275851 /* UnitTests */ = { isa = PBXGroup; children = ( + 1D04760558C64B8EB69380E0 /* FBTouchableViewTests.m */, ADBC39951D07840300327304 /* Doubles */, 71A7EAFB1E229302001DA4F2 /* FBClassChainTests.m */, EEE16E961D33A25500172525 /* FBConfigurationTests.m */, @@ -4814,6 +4822,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B6DE70BFF27148BDAC72F80C /* TouchSpotView.m in Sources */, + 81E462B8A75F497CBFC6FD8B /* TouchableView.m in Sources */, + 6C07E44E139D41D6BA092F39 /* FBTouchableViewTests.m in Sources */, 713352FD26CEF31D00523CBC /* FBLRUCacheTests.m in Sources */, EE3F8CFE1D08AA17006F02CE /* FBRunLoopSpinnerTests.m in Sources */, A09D847635CA4C155583B967 /* FBXCAXClientProxyTests.m in Sources */, @@ -4869,6 +4880,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C15E49E6B6924191BAAD9FDF /* FBIntegrationAppTests.m in Sources */, EE26409D1D0EBA25009BE6B0 /* FBElementAttributeTests.m in Sources */, 7119E1EC1E891F8600D0B125 /* FBPickerWheelSelectTests.m in Sources */, 71ACF5B8242F2FDC00F0AAD4 /* FBSafariAlertTests.m in Sources */, diff --git a/WebDriverAgentTests/IntegrationApp/Classes/FBScrollViewController.m b/WebDriverAgentTests/IntegrationApp/Classes/FBScrollViewController.m index a8fe797739..64fc1540e9 100644 --- a/WebDriverAgentTests/IntegrationApp/Classes/FBScrollViewController.m +++ b/WebDriverAgentTests/IntegrationApp/Classes/FBScrollViewController.m @@ -15,6 +15,7 @@ @interface FBScrollViewController () @property (nonatomic, weak) IBOutlet UIScrollView *scrollView; @property (nonatomic, strong) IBOutlet FBTableDataSource *dataSource; +@property (nonatomic, copy) NSArray *rowLabels; @end @implementation FBScrollViewController @@ -22,18 +23,30 @@ @implementation FBScrollViewController - (void)viewDidLoad { [super viewDidLoad]; [self setupLabelViews]; - self.scrollView.contentSize = CGSizeMake(CGRectGetWidth(self.view.frame), self.dataSource.count * FBSubviewHeight); +} + +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + CGFloat width = CGRectGetWidth(self.scrollView.bounds); + [self.rowLabels enumerateObjectsUsingBlock:^(UILabel *label, NSUInteger index, BOOL *stop) { + label.frame = CGRectMake(0, index * FBSubviewHeight, width, FBSubviewHeight); + }]; + self.scrollView.contentSize = CGSizeMake(width, self.rowLabels.count * FBSubviewHeight); } - (void)setupLabelViews { NSUInteger count = self.dataSource.count; + NSMutableArray *labels = [NSMutableArray arrayWithCapacity:count]; for (NSInteger i = 0 ; i < count ; i++) { - UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(0, i * FBSubviewHeight, CGRectGetWidth(self.view.frame), FBSubviewHeight)]; + UILabel *label = [UILabel new]; label.text = [self.dataSource textForElementAtIndex:i]; label.textAlignment = NSTextAlignmentCenter; [self.scrollView addSubview:label]; + [labels addObject:label]; } + self.rowLabels = labels; } @end diff --git a/WebDriverAgentTests/IntegrationApp/Classes/TouchViewController.h b/WebDriverAgentTests/IntegrationApp/Classes/TouchViewController.h index 7125306a3a..dbe77e164c 100644 --- a/WebDriverAgentTests/IntegrationApp/Classes/TouchViewController.h +++ b/WebDriverAgentTests/IntegrationApp/Classes/TouchViewController.h @@ -11,7 +11,7 @@ NS_ASSUME_NONNULL_BEGIN -@interface TouchViewController : UIViewController +@interface TouchViewController : UIViewController @property (weak, nonatomic) IBOutlet TouchableView *touchable; @property (weak, nonatomic) IBOutlet UILabel *numberOfTapsLabel; diff --git a/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.h b/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.h index 53d0c1836e..7a4c227e32 100644 --- a/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.h +++ b/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.h @@ -21,8 +21,9 @@ NS_ASSUME_NONNULL_BEGIN @interface TouchableView : UIView @property (nonatomic) NSMutableDictionary *touchViews; +// Cumulative completed contacts; cancelled contacts are excluded. @property (nonatomic) int numberOFTaps; -@property (nonatomic) id delegate; +@property (nonatomic, weak, nullable) id delegate; @end diff --git a/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.m b/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.m index 9e7412ab7b..0b406dab67 100644 --- a/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.m +++ b/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.m @@ -34,12 +34,11 @@ - (instancetype)initWithCoder:(NSCoder *)coder - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { - self.numberOFTaps += 1; - [self.delegate shouldHandleTouchesNumber:(int)touches.count]; for (UITouch *touch in touches) { [self createViewForTouch:touch]; } + [self.delegate shouldHandleTouchesNumber:(int)self.touchViews.count]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event @@ -56,8 +55,13 @@ - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { for (UITouch *touch in touches) { + // Count completed contacts, independently of how UIKit batches callbacks. + if ([self viewForTouch:touch] != nil) { + self.numberOFTaps += 1; + } [self removeViewForTouch:touch]; } + [self.delegate shouldHandleTouchesNumber:(int)self.touchViews.count]; [self.delegate shouldHandleTapsNumber:self.numberOFTaps]; } @@ -67,6 +71,7 @@ - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { [self removeViewForTouch:touch]; } + [self.delegate shouldHandleTouchesNumber:(int)self.touchViews.count]; } - (void)createViewForTouch:(UITouch *)touch diff --git a/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard b/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard index 6c10661bac..cf45fb42b1 100644 --- a/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard +++ b/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard @@ -4,6 +4,7 @@ + @@ -142,7 +143,7 @@ - + + - - - - - - - - - + + + + + + + + + + diff --git a/WebDriverAgentTests/IntegrationTests/FBIntegrationAppTests.m b/WebDriverAgentTests/IntegrationTests/FBIntegrationAppTests.m new file mode 100644 index 0000000000..cf0eb0c0fc --- /dev/null +++ b/WebDriverAgentTests/IntegrationTests/FBIntegrationAppTests.m @@ -0,0 +1,78 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "FBIntegrationTestCase.h" +#import "FBTestMacros.h" +#import "XCUIDevice+FBRotation.h" +#import "XCUIElement+FBUtilities.h" + +@interface FBIntegrationAppTests : FBIntegrationTestCase +@end + +@implementation FBIntegrationAppTests + +- (void)setUp +{ + [super setUp]; + [self resetOrientation]; + [self launchApplication]; +} + +- (void)tearDown +{ + [self resetOrientation]; + [super tearDown]; +} + +- (void)rotateTo:(UIDeviceOrientation)orientation +{ + XCTAssertTrue([[XCUIDevice sharedDevice] fb_setDeviceInterfaceOrientation:orientation]); + [self.testedApplication fb_waitUntilStable]; +} + +- (void)testTouchControlsRemainOnScreenAfterRotation +{ + [self goToTouchPage]; + XCUIElement *canvas = [self.testedApplication descendantsMatchingType:XCUIElementTypeAny][@"touchableView"]; + XCUIElement *taps = self.testedApplication.staticTexts[FBTapsCountLabelIdentifier]; + XCUIElement *touches = self.testedApplication.staticTexts[FBTouchesCountLabelIdentifier]; + NSArray *orientations = @[@(UIDeviceOrientationLandscapeLeft), + @(UIDeviceOrientationLandscapeRight), + @(UIDeviceOrientationPortrait)]; + NSUInteger count = 0; + for (NSNumber *orientation in orientations) { + [self rotateTo:orientation.integerValue]; + CGRect screen = self.testedApplication.frame; + XCTAssertTrue(CGRectContainsRect(screen, canvas.frame)); + XCTAssertTrue(CGRectContainsRect(screen, taps.frame)); + XCTAssertTrue(CGRectContainsRect(screen, touches.frame)); + XCTAssertGreaterThan(CGRectGetHeight(canvas.frame), 0); + [canvas tap]; + NSString *expectedTaps = [NSString stringWithFormat:@"%lu", (unsigned long)++count]; + FBAssertWaitTillBecomesTrue([taps.label isEqualToString:expectedTaps]); + XCTAssertEqualObjects(touches.label, @"0"); + } +} + +- (void)testScrollRowsResizeWhenRotatingInBothDirections +{ + [self rotateTo:UIDeviceOrientationLandscapeLeft]; + [self goToScrollPageWithCells:NO]; + XCUIElement *scroll = self.testedApplication.scrollViews[@"scrollView"]; + XCUIElement *row = scroll.staticTexts[@"3"]; + NSArray *orientations = @[@(UIDeviceOrientationPortrait), + @(UIDeviceOrientationLandscapeRight)]; + for (NSNumber *orientation in orientations) { + [self rotateTo:orientation.integerValue]; + XCTAssertEqualWithAccuracy(CGRectGetWidth(row.frame), CGRectGetWidth(scroll.frame), 1); + XCTAssertEqualWithAccuracy(CGRectGetMidX(row.frame), CGRectGetMidX(scroll.frame), 1); + XCTAssertTrue(row.hittable); + } +} + +@end diff --git a/WebDriverAgentTests/UnitTests/FBTouchableViewTests.m b/WebDriverAgentTests/UnitTests/FBTouchableViewTests.m new file mode 100644 index 0000000000..3bf48f52f2 --- /dev/null +++ b/WebDriverAgentTests/UnitTests/FBTouchableViewTests.m @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import "../IntegrationApp/Classes/TouchableView.h" + +// Only the location and identity are needed to replay UIKit's touch callbacks. +@interface FBFixtureTouchDouble : NSObject +@end + +@implementation FBFixtureTouchDouble +- (CGPoint)locationInView:(UIView *)view +{ + return CGPointMake(50, 50); +} +@end + +@interface FBTouchableViewTests : XCTestCase +@property (nonatomic, strong) TouchableView *touchable; +@property (nonatomic) int reportedTouches; +@property (nonatomic) int reportedTaps; +@end + +@implementation FBTouchableViewTests + +- (void)setUp +{ + [super setUp]; + self.reportedTouches = 0; + self.reportedTaps = 0; + self.touchable = [[TouchableView alloc] initWithFrame:CGRectMake(0, 0, 200, 200)]; + self.touchable.delegate = self; +} + +- (void)tearDown +{ + self.touchable.delegate = nil; + self.touchable = nil; + [super tearDown]; +} + +- (void)shouldHandleTouchesNumber:(int)touchesCount +{ + self.reportedTouches = touchesCount; +} + +- (void)shouldHandleTapsNumber:(int)numberOfTaps +{ + self.reportedTaps = numberOfTaps; +} + +- (UITouch *)newTouch +{ + return (UITouch *)[FBFixtureTouchDouble new]; +} + +- (void)testStaggeredTouchesTrackActiveFingers +{ + NSSet *first = [NSSet setWithObject:[self newTouch]]; + NSSet *second = [NSSet setWithObject:[self newTouch]]; + [self.touchable touchesBegan:first withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 1); + [self.touchable touchesBegan:second withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 2); + XCTAssertEqual(self.reportedTaps, 0); + + [self.touchable touchesEnded:first withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 1); + XCTAssertEqual(self.reportedTaps, 1); + [self.touchable touchesEnded:second withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 0); + XCTAssertEqual(self.reportedTaps, 2); +} + +- (void)testSimultaneousContactsCountIndependentlyOfCallbackBatching +{ + NSSet *touches = [NSSet setWithObjects:[self newTouch], [self newTouch], nil]; + [self.touchable touchesBegan:touches withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 2); + [self.touchable touchesEnded:touches withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 0); + XCTAssertEqual(self.reportedTaps, 2); +} + +- (void)testCancelledContactsDoNotCarryOverIntoNextTap +{ + NSSet *first = [NSSet setWithObject:[self newTouch]]; + NSSet *second = [NSSet setWithObject:[self newTouch]]; + [self.touchable touchesBegan:first withEvent:nil]; + [self.touchable touchesBegan:second withEvent:nil]; + [self.touchable touchesCancelled:[first setByAddingObjectsFromSet:second] withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 0); + XCTAssertEqual(self.reportedTaps, 0); + + NSSet *next = [NSSet setWithObject:[self newTouch]]; + [self.touchable touchesBegan:next withEvent:nil]; + [self.touchable touchesEnded:next withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 0); + XCTAssertEqual(self.reportedTaps, 1); +} + +- (void)testCancellingOneFingerPreservesTheOtherContact +{ + NSSet *first = [NSSet setWithObject:[self newTouch]]; + NSSet *second = [NSSet setWithObject:[self newTouch]]; + [self.touchable touchesBegan:[first setByAddingObjectsFromSet:second] withEvent:nil]; + [self.touchable touchesCancelled:first withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 1); + XCTAssertEqual(self.reportedTaps, 0); + [self.touchable touchesEnded:second withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 0); + XCTAssertEqual(self.reportedTaps, 1); +} + +@end