From 21c98f253561ee3dfc93edfb1c4de6afc3200b50 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 27 Aug 2026 07:30:26 +0200 Subject: [PATCH 01/46] feat: cache the system application resolution for WDA's process lifetime (#1232) --- WebDriverAgentLib/Utilities/FBXCAXClientProxy.m | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/WebDriverAgentLib/Utilities/FBXCAXClientProxy.m b/WebDriverAgentLib/Utilities/FBXCAXClientProxy.m index fc90d43f9..c81b05586 100644 --- a/WebDriverAgentLib/Utilities/FBXCAXClientProxy.m +++ b/WebDriverAgentLib/Utilities/FBXCAXClientProxy.m @@ -20,6 +20,7 @@ @interface FBXCAXClientProxy () @property (nonatomic) NSMutableDictionary *appsCache; +@property (nonatomic, nullable) id cachedSystemApplication; @end @@ -67,7 +68,14 @@ - (BOOL)setAXTimeout:(NSTimeInterval)timeout error:(NSError **)error - (id)systemApplication { - return [FBAXClient systemApplication]; + @synchronized (self) { + if (nil == self.cachedSystemApplication) { + // The system application's identity cannot change without it being killed, + // which takes WDA down with it, so it is safe to cache it forever. + self.cachedSystemApplication = [FBAXClient systemApplication]; + } + return self.cachedSystemApplication; + } } - (NSDictionary *)defaultParameters From 8914883d5dd222e73d3bef98436a048bd732be04 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 27 Aug 2026 05:47:55 +0000 Subject: [PATCH 02/46] chore(release): 16.9.0 [skip ci] ## [16.9.0](https://github.com/appium/WebDriverAgent/compare/v16.8.0...v16.9.0) (2026-08-27) ### Features * cache the system application resolution for WDA's process lifetime ([#1232](https://github.com/appium/WebDriverAgent/issues/1232)) ([21c98f2](https://github.com/appium/WebDriverAgent/commit/21c98f253561ee3dfc93edfb1c4de6afc3200b50)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2fed927..033e7a8d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.9.0](https://github.com/appium/WebDriverAgent/compare/v16.8.0...v16.9.0) (2026-08-27) + +### Features + +* cache the system application resolution for WDA's process lifetime ([#1232](https://github.com/appium/WebDriverAgent/issues/1232)) ([21c98f2](https://github.com/appium/WebDriverAgent/commit/21c98f253561ee3dfc93edfb1c4de6afc3200b50)) + ## [16.8.0](https://github.com/appium/WebDriverAgent/compare/v16.7.3...v16.8.0) (2026-08-24) ### Features diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 185ec8aa5..fbb6333cb 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.8.0 + 16.9.0 CFBundleSignature ???? CFBundleVersion - 16.8.0 + 16.9.0 NSPrincipalClass diff --git a/package.json b/package.json index e5b343488..ff02d8412 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.8.0", + "version": "16.9.0", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From d173fcb8941f99d048d9fe729825de9d5654634c Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 27 Aug 2026 20:26:56 +0200 Subject: [PATCH 03/46] test: make integration test CI/OS/capability skips visible in test reports (#1234) --- Fastlane/Fastfile | 6 +++++- .../xcschemes/IntegrationTests_1.xcscheme | 7 +++++++ .../xcschemes/IntegrationTests_2.xcscheme | 7 +++++++ .../xcschemes/IntegrationTests_3.xcscheme | 7 +++++++ .../xcschemes/IntegrationTests_tvOS.xcscheme | 7 +++++++ .../IntegrationTests_watchOS.xcscheme | 7 +++++++ .../FBAutoAlertsHandlerTests.m | 14 ++++++++++---- .../IntegrationTests/FBConfigurationTests.m | 2 +- .../FBElementAttributeTests.m | 2 +- .../FBElementVisibilityTests.m | 19 ++++++------------- .../IntegrationTests/FBForceTouchTests.m | 6 +++--- .../IntegrationTests/FBIntegrationTestCase.h | 12 +++++++----- .../IntegrationTests/FBIntegrationTestCase.m | 14 ++++++-------- .../IntegrationTests/FBSafariAlertTests.m | 6 +++++- .../IntegrationTests/FBScrollingTests.m | 9 +++------ .../IntegrationTests/FBVideoRecordingTests.m | 3 +-- .../IntegrationTests/FBVoiceOverTests.m | 10 +++++----- .../FBW3CTouchActionsIntegrationTests.m | 2 +- .../IntegrationTests/FBW3CTypeActionsTests.m | 6 +++--- .../XCUIApplicationHelperTests.m | 2 +- .../IntegrationTests/XCUIDeviceHelperTests.m | 10 +++++----- 21 files changed, 98 insertions(+), 60 deletions(-) diff --git a/Fastlane/Fastfile b/Fastlane/Fastfile index 6efd41b07..5e70686bf 100644 --- a/Fastlane/Fastfile +++ b/Fastlane/Fastfile @@ -11,6 +11,10 @@ lane :test do number_of_retries: 3, skip_testing: ENV.fetch('SKIP_TESTING', '') .split(',') - .reject(&:empty?) + .reject(&:empty?), + # Forwarded into the on-simulator test process via each scheme's CI + # LaunchAction environment variable, since xcodebuild does not propagate + # the calling shell's environment to the app under test on its own. + xcargs: "CI=#{ENV['CI']}" ) end diff --git a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationTests_1.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationTests_1.xcscheme index ac0de9653..2b4967b79 100644 --- a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationTests_1.xcscheme +++ b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationTests_1.xcscheme @@ -59,6 +59,13 @@ ReferencedContainer = "container:WebDriverAgent.xcodeproj"> + + + + + + + + + + + + + + + + + + + + + // Device element: Error Domain=XCTDaemonErrorDomain Code=13 "Value for attribute 5017 is an error." + XCTSkip(@"Fails with XCTDaemonErrorDomain Code=13 on CI simulators"); } - [self launchApplication]; - [self goToSpringBoardExtras]; - XCTAssertFalse(self.springboard.icons[@"Extras"].otherElements[@"Contacts"].fb_isVisible); -} -- (void)disabled_testIconsFromSearchDashboard -{ - // This test causes: - // Failure fetching attributes for element Device element: Error Domain=XCTDaemonErrorDomain Code=13 "Value for attribute 5017 is an error." UserInfo={NSLocalizedDescription=Value for attribute 5017 is an error.} [self launchApplication]; [self goToSpringBoardDashboard]; XCTAssertFalse(self.springboard.icons[@"Reminders"].fb_isVisible); diff --git a/WebDriverAgentTests/IntegrationTests/FBForceTouchTests.m b/WebDriverAgentTests/IntegrationTests/FBForceTouchTests.m index 56df1e111..5b094c227 100644 --- a/WebDriverAgentTests/IntegrationTests/FBForceTouchTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBForceTouchTests.m @@ -61,7 +61,7 @@ - (void)testForceTap @"FIXME: Unstable on platform version 27."); if (![XCUIDevice sharedDevice].supportsPressureInteraction) { - return; + XCTSkip(@"Device does not support pressure interaction"); } [self verifyForceTapWithOrientation:UIDeviceOrientationPortrait]; @@ -70,7 +70,7 @@ - (void)testForceTap - (void)testForceTapInLandscapeLeft { if (![XCUIDevice sharedDevice].supportsPressureInteraction) { - return; + XCTSkip(@"Device does not support pressure interaction"); } [self verifyForceTapWithOrientation:UIDeviceOrientationLandscapeLeft]; @@ -79,7 +79,7 @@ - (void)testForceTapInLandscapeLeft - (void)testForceTapInLandscapeRight { if (![XCUIDevice sharedDevice].supportsPressureInteraction) { - return; + XCTSkip(@"Device does not support pressure interaction"); } [self verifyForceTapWithOrientation:UIDeviceOrientationLandscapeRight]; diff --git a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.h b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.h index 1e2e184e8..a2b8faf92 100644 --- a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.h +++ b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.h @@ -28,6 +28,13 @@ extern NSArray *const FBMainViewButtonLabels; @property (nonatomic, strong, readonly) XCUIApplication *testedApplication; @property (nonatomic, strong, readonly) XCUIApplication *springboard; +/** + Whether tests are running under CI, as forwarded into the test process by the + scheme's CI environment variable (see Fastlane/Fastfile). Use to XCTSkip tests + that are known to be too slow/flaky/unsupported for CI. + */ ++ (BOOL)isRunningInCI; + /** Launches application and resets side effects of testing like orientation etc. */ @@ -53,11 +60,6 @@ extern NSArray *const FBMainViewButtonLabels; */ - (void)goToSpringBoardFirstPage; -/** - Navigates to SpringBoard path with Extras folder - */ -- (void)goToSpringBoardExtras; - /** Navigates to SpringBoard's dashboard */ diff --git a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m index 8b1a701fe..e89af5ef6 100644 --- a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m +++ b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m @@ -43,6 +43,12 @@ @interface FBIntegrationTestCase () @implementation FBIntegrationTestCase ++ (BOOL)isRunningInCI +{ + NSString *value = NSProcessInfo.processInfo.environment[@"CI"]; + return nil != value && value.length > 0; +} + - (void)setUp { // Enable it to get extended XCTest logs printed into the console @@ -105,14 +111,6 @@ - (void)goToSpringBoardFirstPage FBAssertWaitTillBecomesTrue(XCUIApplication.fb_systemApplication.icons[@"Calendar"].firstMatch.fb_isVisible); } -- (void)goToSpringBoardExtras -{ - [self goToSpringBoardFirstPage]; - [self.springboard swipeLeft]; - [self.testedApplication fb_waitUntilStable]; - FBAssertWaitTillBecomesTrue(self.springboard.icons[@"Extras"].fb_isVisible); -} - - (void)goToSpringBoardDashboard { [self goToSpringBoardFirstPage]; diff --git a/WebDriverAgentTests/IntegrationTests/FBSafariAlertTests.m b/WebDriverAgentTests/IntegrationTests/FBSafariAlertTests.m index 0e31eb13f..78a954a6a 100644 --- a/WebDriverAgentTests/IntegrationTests/FBSafariAlertTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBSafariAlertTests.m @@ -42,8 +42,12 @@ - (void)tearDown [self.session terminateApplicationWithBundleId:FB_SAFARI_BUNDLE_ID]; } -- (void)disabled_testCanHandleSafariInputPrompt +- (void)testCanHandleSafariInputPrompt { + if (FBIntegrationTestCase.isRunningInCI) { + XCTSkip(@"Depends on an external website (w3schools.com), unreliable on CI"); + } + XCUIElement *urlInput = [[self.safariApp descendantsMatchingType:XCUIElementTypeTextField] matchingPredicate:[ diff --git a/WebDriverAgentTests/IntegrationTests/FBScrollingTests.m b/WebDriverAgentTests/IntegrationTests/FBScrollingTests.m index 915ca7b41..6e95e4543 100644 --- a/WebDriverAgentTests/IntegrationTests/FBScrollingTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBScrollingTests.m @@ -52,8 +52,7 @@ - (void)testCellVisibility - (void)testSimpleScroll { if (SYSTEM_VERSION_LESS_THAN(@"16.0")) { - // This test is unstable in CI env - return; + XCTSkip(@"Requires iOS 16.0+"); } FBAssertVisibleCell(@"0"); @@ -90,8 +89,7 @@ - (void)testFarScrollToVisible - (void)testNativeFarScrollToVisible { if (SYSTEM_VERSION_LESS_THAN(@"16.0")) { - // This test is unstable in CI env - return; + XCTSkip(@"Requires iOS 16.0+"); } NSString *cellName = @"80"; @@ -114,8 +112,7 @@ - (void)testAttributeWithNullScrollToVisible XCTAssertTrue(element.fb_isVisible); if (SYSTEM_VERSION_LESS_THAN(@"16.0")) { - // This test is unstable in CI env - return; + XCTSkip(@"Requires iOS 16.0+"); } [element tap]; diff --git a/WebDriverAgentTests/IntegrationTests/FBVideoRecordingTests.m b/WebDriverAgentTests/IntegrationTests/FBVideoRecordingTests.m index 00688755f..edbfcf004 100644 --- a/WebDriverAgentTests/IntegrationTests/FBVideoRecordingTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBVideoRecordingTests.m @@ -32,9 +32,8 @@ - (void)testStartingAndStoppingVideoRecording { XCTSkip(@"Failed on Azure Pipeline. Local run succeeded."); - // Video recording is only available since iOS 17 if (SYSTEM_VERSION_LESS_THAN(@"17.0")) { - return; + XCTSkip(@"Video recording is only available since iOS 17"); } FBScreenRecordingRequest *recordingRequest = [[FBScreenRecordingRequest alloc] initWithFps:24 diff --git a/WebDriverAgentTests/IntegrationTests/FBVoiceOverTests.m b/WebDriverAgentTests/IntegrationTests/FBVoiceOverTests.m index ba9d10dfd..e70ca0b74 100644 --- a/WebDriverAgentTests/IntegrationTests/FBVoiceOverTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBVoiceOverTests.m @@ -32,7 +32,7 @@ - (void)tearDown - (void)testVoiceOverUnavailableOnOlderSDK { if ([XCUIDevice.sharedDevice fb_isVoiceOverServiceAvailable]) { - return; + XCTSkip(@"Only applicable when the VoiceOver service is unavailable"); } NSError *error = nil; @@ -44,10 +44,10 @@ - (void)testVoiceOverUnavailableOnOlderSDK - (void)testVoiceOverEnableDisableAndNavigation { if (SYSTEM_VERSION_LESS_THAN(@"27.0")) { - return; + XCTSkip(@"Requires iOS 27.0+"); } if (![XCUIDevice.sharedDevice fb_isVoiceOverServiceAvailable]) { - return; + XCTSkip(@"VoiceOver service is unavailable on this device"); } [self launchApplication]; @@ -78,10 +78,10 @@ - (void)testVoiceOverEnableDisableAndNavigation - (void)testVoiceOverMoveBackward { if (SYSTEM_VERSION_LESS_THAN(@"27.0")) { - return; + XCTSkip(@"Requires iOS 27.0+"); } if (![XCUIDevice.sharedDevice fb_isVoiceOverServiceAvailable]) { - return; + XCTSkip(@"VoiceOver service is unavailable on this device"); } [self launchApplication]; diff --git a/WebDriverAgentTests/IntegrationTests/FBW3CTouchActionsIntegrationTests.m b/WebDriverAgentTests/IntegrationTests/FBW3CTouchActionsIntegrationTests.m index 2d8015d9f..6865ece35 100644 --- a/WebDriverAgentTests/IntegrationTests/FBW3CTouchActionsIntegrationTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBW3CTouchActionsIntegrationTests.m @@ -368,7 +368,7 @@ - (void)testLongPress - (void)testForceTap { if (![XCUIDevice.sharedDevice supportsPressureInteraction]) { - return; + XCTSkip(@"Device does not support pressure interaction"); } NSArray *> *gesture = diff --git a/WebDriverAgentTests/IntegrationTests/FBW3CTypeActionsTests.m b/WebDriverAgentTests/IntegrationTests/FBW3CTypeActionsTests.m index 84a5c6c74..3eba1d47f 100644 --- a/WebDriverAgentTests/IntegrationTests/FBW3CTypeActionsTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBW3CTypeActionsTests.m @@ -32,7 +32,7 @@ - (void)setUp - (void)testErroneousGestures { if (![XCPointerEvent.class fb_areKeyEventsSupported]) { - return; + XCTSkip(@"Key events are not supported on this platform"); } NSArray *> *> *invalidGestures = @@ -121,7 +121,7 @@ - (void)testErroneousGestures - (void)testTextTyping { if (![XCPointerEvent.class fb_areKeyEventsSupported]) { - return; + XCTSkip(@"Key events are not supported on this platform"); } XCUIElement *textField = self.testedApplication.textFields[@"aIdentifier"]; @@ -160,7 +160,7 @@ - (void)testTextTyping - (void)testTextTypingWithEmptyActions { if (![XCPointerEvent.class fb_areKeyEventsSupported]) { - return; + XCTSkip(@"Key events are not supported on this platform"); } XCUIElement *textField = self.testedApplication.textFields[@"aIdentifier"]; diff --git a/WebDriverAgentTests/IntegrationTests/XCUIApplicationHelperTests.m b/WebDriverAgentTests/IntegrationTests/XCUIApplicationHelperTests.m index a1850691a..833b963e9 100644 --- a/WebDriverAgentTests/IntegrationTests/XCUIApplicationHelperTests.m +++ b/WebDriverAgentTests/IntegrationTests/XCUIApplicationHelperTests.m @@ -124,7 +124,7 @@ - (void)testTestmanagerdVersion - (void)testAccessbilityAudit { if (SYSTEM_VERSION_LESS_THAN(@"17.0")) { - return; + XCTSkip(@"Requires iOS 17.0+"); } NSError *error; diff --git a/WebDriverAgentTests/IntegrationTests/XCUIDeviceHelperTests.m b/WebDriverAgentTests/IntegrationTests/XCUIDeviceHelperTests.m index 2f1b8a467..89bc92e9a 100644 --- a/WebDriverAgentTests/IntegrationTests/XCUIDeviceHelperTests.m +++ b/WebDriverAgentTests/IntegrationTests/XCUIDeviceHelperTests.m @@ -90,7 +90,7 @@ - (void)testWifiAddress { NSString *adderss = [XCUIDevice sharedDevice].fb_wifiIPAddress; if (!adderss) { - return; + XCTSkip(@"No WiFi IP address available on this device"); } NSRange range = [adderss rangeOfString:@"^([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})" options:NSRegularExpressionSearch]; XCTAssertTrue(range.location != NSNotFound); @@ -119,7 +119,7 @@ - (void)testLockUnlockScreen - (void)testUrlSchemeActivation { if (SYSTEM_VERSION_LESS_THAN(@"16.4")) { - return; + XCTSkip(@"Requires iOS 16.4+"); } NSError *error; @@ -131,7 +131,7 @@ - (void)testUrlSchemeActivation - (void)testUrlSchemeActivationWithApp { if (SYSTEM_VERSION_LESS_THAN(@"16.4")) { - return; + XCTSkip(@"Requires iOS 16.4+"); } NSError *error; @@ -146,7 +146,7 @@ - (void)testUrlSchemeActivationWithApp - (void)testSimulatedLocationSetup { if (SYSTEM_VERSION_LESS_THAN(@"16.4")) { - return; + XCTSkip(@"Requires iOS 16.4+"); } CLLocation *simulatedLocation = [[CLLocation alloc] initWithLatitude:50 longitude:50]; @@ -226,7 +226,7 @@ - (void)testLongPressHomeButton - (void)testAppearance { if (SYSTEM_VERSION_LESS_THAN(@"15.0")) { - return; + XCTSkip(@"Requires iOS 15.0+"); } NSError *error; XCTAssertTrue([XCUIDevice.sharedDevice fb_setAppearance:FBUIInterfaceAppearanceDark error:&error]); From a9e8203d71051db7a0c14c192288b2b5115cf5cf Mon Sep 17 00:00:00 2001 From: Timo <44401485+Timo972@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:13:26 +0200 Subject: [PATCH 04/46] fix: add missing settings to the exported WDASettings/WDACapabilities types (#1230) Co-authored-by: Claude Fable 5 --- lib/types.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/types.ts b/lib/types.ts index e8db0319b..8d3d73c77 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -13,6 +13,16 @@ export interface WDASettings { keyboardAutocorrection?: boolean; keyboardPrediction?: boolean; customSnapshotTimeout?: number; + accessibilityDeadline?: number; + enforceCustomSnapshots?: boolean; + limitXPathContextScope?: boolean; + includeHittableInPageSource?: boolean; + includeNativeFrameInPageSource?: boolean; + includeNativeAccessibilityElementInPageSource?: boolean; + includeMinMaxValueInPageSource?: boolean; + includeCustomActionsInPageSource?: boolean; + respectSystemAlerts?: boolean; + autoClickAlertSelector?: string; snapshotMaxDepth?: number; snapshotMaxChildren?: number; useFirstMatch?: boolean; @@ -50,6 +60,7 @@ export interface WDACapabilities { forceSimulatorSoftwareKeyboardPresence?: boolean; defaultAlertAction?: 'accept' | 'dismiss'; appLaunchStateTimeoutSec?: number; + accessibilityDeadline?: number; } export interface WebDriverAgentArgs { From 57968cc58b69ea9f036d70a694a0c9a5b6f764f7 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 28 Aug 2026 08:16:14 +0200 Subject: [PATCH 05/46] fix: do not trust XCUIElement.lastSnapshot for long-lived elements (#1235) --- .../Categories/XCUIElement+FBClassChain.m | 6 ++--- .../Categories/XCUIElement+FBUtilities.m | 6 +++-- WebDriverAgentLib/Utilities/FBXPath.m | 5 +++-- .../IntegrationTests/XCUIElementFBFindTests.m | 22 +++++++++++++++++++ 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIElement+FBClassChain.m b/WebDriverAgentLib/Categories/XCUIElement+FBClassChain.m index 1be6dc774..1bf7f957d 100644 --- a/WebDriverAgentLib/Categories/XCUIElement+FBClassChain.m +++ b/WebDriverAgentLib/Categories/XCUIElement+FBClassChain.m @@ -134,10 +134,8 @@ - (XCUIElementQuery *)fb_queryWithChainItem:(FBClassChainItem *)item query:(null - (NSArray *)fb_snapshotDescendantsMatchingChainItems:(NSArray *)chainItems shouldReturnAfterFirstMatch:(BOOL)shouldReturnAfterFirstMatch { NSMutableArray *lookupChain = chainItems.mutableCopy; - // Reuse an already-taken snapshot of `self` if one is available (e.g. the - // caller just resolved/inspected this same element) instead of always - // paying for a fresh one. - NSArray> *currentRoots = @[self.lastSnapshot ?: self.fb_cachedSnapshot ?: [self fb_customSnapshot]]; + // self.lastSnapshot may be stale leftover from an unrelated earlier command. + NSArray> *currentRoots = @[self.fb_cachedSnapshot ?: [self fb_customSnapshot]]; FBClassChainItem *chainItem = lookupChain.firstObject; NSArray> *candidates = [self.class fb_snapshotsMatchingItem:chainItem inRoots:currentRoots]; [lookupChain removeObjectAtIndex:0]; diff --git a/WebDriverAgentLib/Categories/XCUIElement+FBUtilities.m b/WebDriverAgentLib/Categories/XCUIElement+FBUtilities.m index 7a616424b..1312cc2f4 100644 --- a/WebDriverAgentLib/Categories/XCUIElement+FBUtilities.m +++ b/WebDriverAgentLib/Categories/XCUIElement+FBUtilities.m @@ -100,9 +100,11 @@ @implementation XCUIElement (FBUtilities) } } NSMutableArray *matchedElements = [NSMutableArray array]; - NSString *uid = nil == self.lastSnapshot + // self.lastSnapshot may be stale leftover from an unrelated earlier command. + id selfSnapshot = self.fb_cachedSnapshot; + NSString *uid = nil == selfSnapshot ? self.fb_uid - : [FBXCElementSnapshotWrapper wdUIDWithSnapshot:self.lastSnapshot]; + : [FBXCElementSnapshotWrapper wdUIDWithSnapshot:selfSnapshot]; if (nil != uid && [matchedIds containsObject:uid]) { XCUIElement *stableSelf = [self fb_stableInstanceWithUid:uid]; if (1 == snapshots.count) { diff --git a/WebDriverAgentLib/Utilities/FBXPath.m b/WebDriverAgentLib/Utilities/FBXPath.m index 09a9aef13..0e97b052b 100644 --- a/WebDriverAgentLib/Utilities/FBXPath.m +++ b/WebDriverAgentLib/Utilities/FBXPath.m @@ -258,10 +258,11 @@ + (nullable NSString *)xmlStringWithRootElement:(id)root if ([root isKindOfClass:XCUIElement.class]) { lookupScopeSnapshot = [self snapshotWithRoot:[(XCUIElement *)root application] useNative:useNativeSnapshot]; + // root.lastSnapshot may be stale leftover from an unrelated earlier command. contextRootSnapshot = [root isKindOfClass:XCUIApplication.class] ? nil - : ([(XCUIElement *)root lastSnapshot] ?: [self snapshotWithRoot:(XCUIElement *)root - useNative:useNativeSnapshot]); + : ([(XCUIElement *)root fb_cachedSnapshot] ?: [self snapshotWithRoot:(XCUIElement *)root + useNative:useNativeSnapshot]); } else { lookupScopeSnapshot = (id)root; contextRootSnapshot = nil == lookupScopeSnapshot.parent ? nil : (id)root; diff --git a/WebDriverAgentTests/IntegrationTests/XCUIElementFBFindTests.m b/WebDriverAgentTests/IntegrationTests/XCUIElementFBFindTests.m index 2137ff5fc..f03f6a62d 100644 --- a/WebDriverAgentTests/IntegrationTests/XCUIElementFBFindTests.m +++ b/WebDriverAgentTests/IntegrationTests/XCUIElementFBFindTests.m @@ -21,6 +21,7 @@ #import "XCUIElement+FBResolve.h" #import "FBXPath.h" #import "FBXCodeCompatibility.h" +#import "XCUIElement+FBUtilities.h" @interface XCUIElementFBFindTests : FBIntegrationTestCase @property (nonatomic, strong) XCUIElement *testedView; @@ -536,3 +537,24 @@ - (void)testPerformanceOfClassChainLookupOnDeepHierarchy } @end + +@interface XCUIElementFBFindTests_StaleAppSnapshot : FBIntegrationTestCase +@end +@implementation XCUIElementFBFindTests_StaleAppSnapshot + +// Regression test for https://github.com/appium/appium/issues/22672. +- (void)testClassChainWithIntermediatePositionAfterStaleAppSnapshot +{ + [self launchApplication]; + // Simulates a stale snapshot cached by an earlier, unrelated command (e.g. GET /source). + [self.testedApplication fb_customSnapshot]; + [self goToDeepHierarchyPage]; + + NSString *query = @"**/XCUIElementTypeOther[`label == \"View 10\"`][1]/**/XCUIElementTypeOther[`label BEGINSWITH \"View 19\"`]"; + NSArray *matches = [self.testedApplication fb_descendantsMatchingClassChain:query + shouldReturnAfterFirstMatch:NO]; + XCTAssertEqual(matches.count, 1); + XCTAssertEqualObjects(matches.firstObject.label, @"View 19"); +} + +@end From 58a09adec451477d0bd5a051d8d7040b52a4a474 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 28 Aug 2026 08:16:19 +0000 Subject: [PATCH 06/46] chore(release): 16.9.1 [skip ci] ## [16.9.1](https://github.com/appium/WebDriverAgent/compare/v16.9.0...v16.9.1) (2026-08-28) ### Bug Fixes * add missing settings to the exported WDASettings/WDACapabilities types ([#1230](https://github.com/appium/WebDriverAgent/issues/1230)) ([a9e8203](https://github.com/appium/WebDriverAgent/commit/a9e8203d71051db7a0c14c192288b2b5115cf5cf)) * do not trust XCUIElement.lastSnapshot for long-lived elements ([#1235](https://github.com/appium/WebDriverAgent/issues/1235)) ([57968cc](https://github.com/appium/WebDriverAgent/commit/57968cc58b69ea9f036d70a694a0c9a5b6f764f7)) --- CHANGELOG.md | 7 +++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 033e7a8d2..8902db92d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [16.9.1](https://github.com/appium/WebDriverAgent/compare/v16.9.0...v16.9.1) (2026-08-28) + +### Bug Fixes + +* add missing settings to the exported WDASettings/WDACapabilities types ([#1230](https://github.com/appium/WebDriverAgent/issues/1230)) ([a9e8203](https://github.com/appium/WebDriverAgent/commit/a9e8203d71051db7a0c14c192288b2b5115cf5cf)) +* do not trust XCUIElement.lastSnapshot for long-lived elements ([#1235](https://github.com/appium/WebDriverAgent/issues/1235)) ([57968cc](https://github.com/appium/WebDriverAgent/commit/57968cc58b69ea9f036d70a694a0c9a5b6f764f7)) + ## [16.9.0](https://github.com/appium/WebDriverAgent/compare/v16.8.0...v16.9.0) (2026-08-27) ### Features diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index fbb6333cb..f762e58e7 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.9.0 + 16.9.1 CFBundleSignature ???? CFBundleVersion - 16.9.0 + 16.9.1 NSPrincipalClass diff --git a/package.json b/package.json index ff02d8412..b5bfc7fab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.9.0", + "version": "16.9.1", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From f3d8e0ce42488b6e95a74f1c59e0f20a500d1a54 Mon Sep 17 00:00:00 2001 From: Timo <44401485+Timo972@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:13:35 +0200 Subject: [PATCH 07/46] fix: cache the testmanagerd protocol version fallback on timeout (#1228) --- WebDriverAgentLib/Utilities/FBXCodeCompatibility.m | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m b/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m index 26cd2dc6e..2a9c5a845 100644 --- a/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m +++ b/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m @@ -81,8 +81,9 @@ + (BOOL)fb_areKeyEventsSupported NSInteger FBTestmanagerdVersion(void) { - // Not dispatch_once: that would permanently cache the timeout fallback below if the first call's - // reply merely arrived late. -1 means "not yet determined"; a timeout isn't cached, so it retries. + // -1 means "not yet determined". The timeout fallback is cached like any other outcome: the + // value is diagnostic-only, and retrying would stall every later /status for the full timeout + // against a daemon that never answers. static NSInteger cachedVersion = -1; static dispatch_queue_t syncQueue; static dispatch_once_t onceToken; @@ -108,12 +109,11 @@ NSInteger FBTestmanagerdVersion(void) }]; int64_t timeoutNs = (int64_t)(TESTMANAGERD_VERSION_TIMEOUT_SEC * NSEC_PER_SEC); if (0 != dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, timeoutNs))) { - // Assume newest/full-featured on timeout, but don't cache it - retry on the next call. [FBLogger logFmt:@"Did not receive a testmanagerd protocol version reply within %d seconds; assuming the newest/full-featured protocol", TESTMANAGERD_VERSION_TIMEOUT_SEC]; result = 0xFFFF; - return; + } else { + result = receivedVersion; } - result = receivedVersion; } else { // Modern testmanagerd (Xcode 15+) negotiates named XCTCapabilities instead of a scalar // version; there's no direct integer equivalent, so just confirm capabilities negotiated. From 03db844ee0cb408bb323d0d77241ba55188c47f3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:13:56 +0200 Subject: [PATCH 08/46] chore(deps): bump @appium/strongbox from 1.1.3 to 2.0.0 (#1237) Bumps [@appium/strongbox](https://github.com/appium/appium/tree/HEAD/packages/strongbox) from 1.1.3 to 2.0.0. - [Release notes](https://github.com/appium/appium/releases) - [Changelog](https://github.com/appium/appium/blob/master/packages/strongbox/CHANGELOG.md) - [Commits](https://github.com/appium/appium/commits/@appium/strongbox@2.0.0/packages/strongbox) --- updated-dependencies: - dependency-name: "@appium/strongbox" dependency-version: 2.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b5bfc7fab..4ea1e70dd 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ }, "dependencies": { "@appium/base-driver": "^10.3.0", - "@appium/strongbox": "^1.0.0-rc.1", + "@appium/strongbox": "^2.0.0", "@appium/support": "^7.2.1", "appium-ios-simulator": "^9.0.0", "async-lock": "^1.0.0", From fa6a2503222ac2ffd039cb73a96689467774d7f2 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 28 Aug 2026 15:14:27 +0200 Subject: [PATCH 09/46] fix: sanitize non-UTF-8-encodable strings before JSON response serialization (#1236) --- WebDriverAgent.xcodeproj/project.pbxproj | 4 + .../NSDictionary+FBUtf8SafeDictionary.m | 72 +++++++++++------- .../Routing/FBResponseJSONPayload.m | 5 +- .../UnitTests/FBResponseJSONPayloadTests.m | 76 +++++++++++++++++++ .../UnitTests/NSDictionaryFBUtf8SafeTests.m | 43 +++++++++++ 5 files changed, 170 insertions(+), 30 deletions(-) create mode 100644 WebDriverAgentTests/UnitTests/FBResponseJSONPayloadTests.m diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index cbb9c438b..0c4c8727c 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -604,6 +604,7 @@ 716F0DA12A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.h in Headers */ = {isa = PBXBuildFile; fileRef = 716F0D9F2A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.h */; }; 716F0DA32A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.m in Sources */ = {isa = PBXBuildFile; fileRef = 716F0DA02A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.m */; }; 716F0DA62A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 716F0DA52A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m */; }; + E82332D32C4E06280CBE3F4C /* FBResponseJSONPayloadTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3F5C37472190A686297D3534 /* FBResponseJSONPayloadTests.m */; }; 7182A87F3CAA27F71B624AD2 /* XCTRunnerAutomationSession-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 01AF8E73DD47455B4854E470 /* XCTRunnerAutomationSession-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; 718F49C8230844330045FE8B /* FBProtocolHelpersTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 718F49C7230844330045FE8B /* FBProtocolHelpersTests.m */; }; 718F49C923087ACF0045FE8B /* FBProtocolHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = 71B155DD23080CA600646AFB /* FBProtocolHelpers.h */; }; @@ -1595,6 +1596,7 @@ 716F0D9F2A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSDictionary+FBUtf8SafeDictionary.h"; sourceTree = ""; }; 716F0DA02A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSDictionary+FBUtf8SafeDictionary.m"; sourceTree = ""; }; 716F0DA52A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NSDictionaryFBUtf8SafeTests.m; sourceTree = ""; }; + 3F5C37472190A686297D3534 /* FBResponseJSONPayloadTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBResponseJSONPayloadTests.m; sourceTree = ""; }; 717C0D702518ED2800CAA6EC /* TVOSSettings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TVOSSettings.xcconfig; sourceTree = ""; }; 717C0D862518ED7000CAA6EC /* TVOSTestSettings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TVOSTestSettings.xcconfig; sourceTree = ""; }; 7183E8C2B556594311CB8898 /* XCUIRemoteSiriInterface-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIRemoteSiriInterface-Protocol.h"; sourceTree = ""; }; @@ -2682,6 +2684,7 @@ 712A0C841DA3E459007D02E5 /* FBXPathTests.m */, EE9B76581CF7987300275851 /* Info.plist */, 716F0DA52A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m */, + 3F5C37472190A686297D3534 /* FBResponseJSONPayloadTests.m */, 7139145B1DF01A12005896C2 /* NSExpressionFBFormatTests.m */, 71A224E71DE326C500844D55 /* NSPredicateFBFormatTests.m */, 713914591DF01989005896C2 /* XCUIElementHelpersTests.m */, @@ -4811,6 +4814,7 @@ 71A224E81DE326C500844D55 /* NSPredicateFBFormatTests.m in Sources */, EE6A892B1D0B25820083E92B /* XCUIApplicationDouble.m in Sources */, 716F0DA62A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m in Sources */, + E82332D32C4E06280CBE3F4C /* FBResponseJSONPayloadTests.m in Sources */, EE6A892D1D0B2AF40083E92B /* FBErrorBuilderTests.m in Sources */, 712A0C851DA3E459007D02E5 /* FBXPathTests.m in Sources */, ADBC39981D07842800327304 /* XCUIElementDouble.m in Sources */, diff --git a/WebDriverAgentLib/Categories/NSDictionary+FBUtf8SafeDictionary.m b/WebDriverAgentLib/Categories/NSDictionary+FBUtf8SafeDictionary.m index 0a5905daf..c826a1461 100644 --- a/WebDriverAgentLib/Categories/NSDictionary+FBUtf8SafeDictionary.m +++ b/WebDriverAgentLib/Categories/NSDictionary+FBUtf8SafeDictionary.m @@ -14,32 +14,42 @@ @implementation NSString (FBUtf8SafeString) - (instancetype)fb_utf8SafeStringWithReplacement:(unichar)replacement { - if ([self canBeConvertedToEncoding:NSUTF8StringEncoding]) { - return self; - } - - NSData *data = [self dataUsingEncoding:NSUTF8StringEncoding allowLossyConversion:YES]; - NSString *convertedString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - NSMutableString *result = [NSMutableString string]; - NSString *replacementStr = [NSString stringWithCharacters:&replacement length:1]; - NSUInteger originalIdx = 0; - NSUInteger convertedIdx = 0; - while (originalIdx < [self length] && convertedIdx < [convertedString length]) { - unichar originalChar = [self characterAtIndex:originalIdx]; - unichar convertedChar = [convertedString characterAtIndex:convertedIdx]; - - if (originalChar == convertedChar) { - [result appendString:[NSString stringWithCharacters:&originalChar length:1]]; - originalIdx++; - convertedIdx++; + // -canBeConvertedToEncoding: and -dataUsingEncoding:allowLossyConversion: + // both misreport strings containing unpaired UTF-16 surrogates, so the + // code units are validated manually instead of relying on them. + NSUInteger length = self.length; + NSMutableString *result = nil; + NSString *replacementStr = nil; + NSUInteger copiedIdx = 0; + NSUInteger idx = 0; + while (idx < length) { + unichar c = [self characterAtIndex:idx]; + if (c >= 0xD800 && c <= 0xDBFF && idx + 1 < length) { + unichar next = [self characterAtIndex:idx + 1]; + if (next >= 0xDC00 && next <= 0xDFFF) { + idx += 2; + continue; + } + } + if (c < 0xD800 || c > 0xDFFF) { + idx += 1; continue; } - - while (originalChar != convertedChar && originalIdx < [self length]) { - [result appendString:replacementStr]; - originalChar = [self characterAtIndex:++originalIdx]; + // Unpaired surrogate found. Lazily allocate the result and copy over + // the valid run preceding it, so strings without any are returned as-is. + if (nil == result) { + result = [NSMutableString stringWithCapacity:length]; + replacementStr = [NSString stringWithCharacters:&replacement length:1]; } + [result appendString:[self substringWithRange:NSMakeRange(copiedIdx, idx - copiedIdx)]]; + [result appendString:replacementStr]; + idx += 1; + copiedIdx = idx; + } + if (nil == result) { + return self; } + [result appendString:[self substringWithRange:NSMakeRange(copiedIdx, length - copiedIdx)]]; return result.copy; } @@ -70,16 +80,24 @@ @implementation NSDictionary (FBUtf8SafeDictionary) - (instancetype)fb_utf8SafeDictionary { - NSMutableDictionary *result = [self mutableCopy]; + NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:self.count]; for (id key in self) { - id value = result[key]; + id value = self[key]; + id safeValue = value; if ([value isKindOfClass:NSString.class]) { - result[key] = [(NSString *)value fb_utf8SafeStringWithReplacement:REPLACER]; + safeValue = [(NSString *)value fb_utf8SafeStringWithReplacement:REPLACER]; } else if ([value isKindOfClass:NSArray.class]) { - result[key] = [(NSArray *)value fb_utf8SafeArray]; + safeValue = [(NSArray *)value fb_utf8SafeArray]; } else if ([value isKindOfClass:NSDictionary.class]) { - result[key] = [(NSDictionary *)value fb_utf8SafeDictionary]; + safeValue = [(NSDictionary *)value fb_utf8SafeDictionary]; } + // Sanitized keys could theoretically collide (e.g. two distinct invalid + // keys both reducing to the same replacement string); the later one + // wins, same as any other NSDictionary literal with duplicate keys. + id safeKey = [key isKindOfClass:NSString.class] + ? [(NSString *)key fb_utf8SafeStringWithReplacement:REPLACER] + : key; + result[safeKey] = safeValue; } return result.copy; } diff --git a/WebDriverAgentLib/Routing/FBResponseJSONPayload.m b/WebDriverAgentLib/Routing/FBResponseJSONPayload.m index 8782b8e9e..4deb397f7 100644 --- a/WebDriverAgentLib/Routing/FBResponseJSONPayload.m +++ b/WebDriverAgentLib/Routing/FBResponseJSONPayload.m @@ -43,9 +43,8 @@ - (void)dispatchWithResponse:(RouteResponse *)response NSData *jsonData = [NSJSONSerialization dataWithJSONObject:self.dictionary options:NSJSONWritingPrettyPrinted error:&error]; - NSCAssert(jsonData, @"Valid JSON must be responded, error of %@", error); - if (nil == [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]) { - [FBLogger log:@"The incoming data cannot be encoded to UTF-8 JSON. Applying lossy conversion as a workaround."]; + if (nil == jsonData || nil == [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]) { + [FBLogger log:@"JSON serialization failed or produced non-UTF-8 data. Applying lossy conversion as a workaround."]; jsonData = [NSJSONSerialization dataWithJSONObject:[self.dictionary fb_utf8SafeDictionary] options:NSJSONWritingPrettyPrinted error:&error]; diff --git a/WebDriverAgentTests/UnitTests/FBResponseJSONPayloadTests.m b/WebDriverAgentTests/UnitTests/FBResponseJSONPayloadTests.m new file mode 100644 index 000000000..710325319 --- /dev/null +++ b/WebDriverAgentTests/UnitTests/FBResponseJSONPayloadTests.m @@ -0,0 +1,76 @@ +/** + * 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 "FBResponseJSONPayload.h" +#import "RouteResponse.h" + +@interface FBResponseJSONPayloadTests : XCTestCase +@end + +@implementation FBResponseJSONPayloadTests + +// https://github.com/appium/appium/issues/22673 +- (void)testDispatchSanitizesNonUtf8EncodableStrings +{ + unichar chars[] = {'a', 'b', 'c', 0xD800, 'd', 'e', 'f'}; + NSString *unsafe = [NSString stringWithCharacters:chars length:sizeof(chars) / sizeof(unichar)]; + NSDictionary *dictionary = @{@"value": unsafe}; + FBResponseJSONPayload *payload = [[FBResponseJSONPayload alloc] initWithDictionary:dictionary + httpStatusCode:kHTTPStatusCodeOK]; + RouteResponse *response = [RouteResponse new]; + + XCTAssertNoThrow([payload dispatchWithResponse:response]); + XCTAssertNotNil(response.responseData); + + NSError *error = nil; + NSDictionary *parsed = [NSJSONSerialization JSONObjectWithData:response.responseData + options:0 + error:&error]; + XCTAssertNil(error); + XCTAssertEqualObjects(parsed[@"value"], @"abc�def"); +} + +// Dictionary keys must be sanitized too, not just values +- (void)testDispatchSanitizesNonUtf8EncodableKeys +{ + unichar chars[] = {'k', 0xD800, 'y'}; + NSString *unsafeKey = [NSString stringWithCharacters:chars length:sizeof(chars) / sizeof(unichar)]; + NSDictionary *dictionary = @{unsafeKey: @"value"}; + FBResponseJSONPayload *payload = [[FBResponseJSONPayload alloc] initWithDictionary:dictionary + httpStatusCode:kHTTPStatusCodeOK]; + RouteResponse *response = [RouteResponse new]; + + XCTAssertNoThrow([payload dispatchWithResponse:response]); + XCTAssertNotNil(response.responseData); + + NSError *error = nil; + NSDictionary *parsed = [NSJSONSerialization JSONObjectWithData:response.responseData + options:0 + error:&error]; + XCTAssertNil(error); + XCTAssertEqualObjects(parsed[@"k�y"], @"value"); +} + +- (void)testDispatchWithRegularDictionary +{ + NSDictionary *dictionary = @{@"value": @"regular string"}; + FBResponseJSONPayload *payload = [[FBResponseJSONPayload alloc] initWithDictionary:dictionary + httpStatusCode:kHTTPStatusCodeOK]; + RouteResponse *response = [RouteResponse new]; + + [payload dispatchWithResponse:response]; + + NSDictionary *parsed = [NSJSONSerialization JSONObjectWithData:response.responseData + options:0 + error:nil]; + XCTAssertEqualObjects(parsed, dictionary); +} + +@end diff --git a/WebDriverAgentTests/UnitTests/NSDictionaryFBUtf8SafeTests.m b/WebDriverAgentTests/UnitTests/NSDictionaryFBUtf8SafeTests.m index 6662852c1..f4d96bd99 100644 --- a/WebDriverAgentTests/UnitTests/NSDictionaryFBUtf8SafeTests.m +++ b/WebDriverAgentTests/UnitTests/NSDictionaryFBUtf8SafeTests.m @@ -31,4 +31,47 @@ - (void)testNonEmptySafeDictConversion XCTAssertEqualObjects(d, d.fb_utf8SafeDictionary); } +- (void)testUnpairedSurrogateSanitization +{ + unichar chars[] = {'a', 'b', 'c', 0xD800, 'd', 'e', 'f'}; + NSString *unsafe = [NSString stringWithCharacters:chars length:sizeof(chars) / sizeof(unichar)]; + NSDictionary *d = @{ + @"key": unsafe, + @"nested": @{@"value": @[unsafe]}, + }; + NSDictionary *safe = d.fb_utf8SafeDictionary; + + NSString *expected = @"abc�def"; + XCTAssertEqualObjects(safe[@"key"], expected); + XCTAssertEqualObjects(safe[@"nested"][@"value"][0], expected); + + NSError *error = nil; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:safe + options:0 + error:&error]; + XCTAssertNotNil(jsonData, @"JSON serialization of the sanitized dictionary unexpectedly failed: %@", error); +} + +- (void)testUnpairedSurrogateKeySanitization +{ + unichar chars[] = {'k', 0xD800, 'y'}; + NSString *unsafeKey = [NSString stringWithCharacters:chars length:sizeof(chars) / sizeof(unichar)]; + NSDictionary *d = @{unsafeKey: @"value"}; + NSDictionary *safe = d.fb_utf8SafeDictionary; + + XCTAssertEqualObjects(safe[@"k�y"], @"value"); + + NSError *error = nil; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:safe + options:0 + error:&error]; + XCTAssertNotNil(jsonData, @"JSON serialization of the sanitized dictionary unexpectedly failed: %@", error); +} + +- (void)testValidSurrogatePairIsPreserved +{ + NSString *emoji = @"a😀b"; + XCTAssertEqualObjects([emoji fb_utf8SafeStringWithReplacement:0xfffd], emoji); +} + @end From 33e21aad9325a56d3a7c4b4af38fd8f236f234c5 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 28 Aug 2026 22:01:13 +0000 Subject: [PATCH 10/46] chore(release): 16.9.2 [skip ci] ## [16.9.2](https://github.com/appium/WebDriverAgent/compare/v16.9.1...v16.9.2) (2026-08-28) ### Bug Fixes * cache the testmanagerd protocol version fallback on timeout ([#1228](https://github.com/appium/WebDriverAgent/issues/1228)) ([f3d8e0c](https://github.com/appium/WebDriverAgent/commit/f3d8e0ce42488b6e95a74f1c59e0f20a500d1a54)) * sanitize non-UTF-8-encodable strings before JSON response serialization ([#1236](https://github.com/appium/WebDriverAgent/issues/1236)) ([fa6a250](https://github.com/appium/WebDriverAgent/commit/fa6a2503222ac2ffd039cb73a96689467774d7f2)) ### Miscellaneous Chores * **deps:** bump @appium/strongbox from 1.1.3 to 2.0.0 ([#1237](https://github.com/appium/WebDriverAgent/issues/1237)) ([03db844](https://github.com/appium/WebDriverAgent/commit/03db844ee0cb408bb323d0d77241ba55188c47f3)) --- CHANGELOG.md | 11 +++++++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8902db92d..691cb9758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [16.9.2](https://github.com/appium/WebDriverAgent/compare/v16.9.1...v16.9.2) (2026-08-28) + +### Bug Fixes + +* cache the testmanagerd protocol version fallback on timeout ([#1228](https://github.com/appium/WebDriverAgent/issues/1228)) ([f3d8e0c](https://github.com/appium/WebDriverAgent/commit/f3d8e0ce42488b6e95a74f1c59e0f20a500d1a54)) +* sanitize non-UTF-8-encodable strings before JSON response serialization ([#1236](https://github.com/appium/WebDriverAgent/issues/1236)) ([fa6a250](https://github.com/appium/WebDriverAgent/commit/fa6a2503222ac2ffd039cb73a96689467774d7f2)) + +### Miscellaneous Chores + +* **deps:** bump @appium/strongbox from 1.1.3 to 2.0.0 ([#1237](https://github.com/appium/WebDriverAgent/issues/1237)) ([03db844](https://github.com/appium/WebDriverAgent/commit/03db844ee0cb408bb323d0d77241ba55188c47f3)) + ## [16.9.1](https://github.com/appium/WebDriverAgent/compare/v16.9.0...v16.9.1) (2026-08-28) ### Bug Fixes diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index f762e58e7..e0b66590a 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.9.1 + 16.9.2 CFBundleSignature ???? CFBundleVersion - 16.9.1 + 16.9.2 NSPrincipalClass diff --git a/package.json b/package.json index 4ea1e70dd..a5cc9a0b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.9.1", + "version": "16.9.2", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From 60c5fc461d91611778b8bb0745a21c80451d6ccf Mon Sep 17 00:00:00 2001 From: Timo <44401485+Timo972@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:54:04 +0200 Subject: [PATCH 11/46] fix: reject malformed Content-Length values and bound request header buffering (#1226) * fix: reject malformed Content-Length values and bound request header buffering Co-Authored-By: Claude Fable 5 * fix: bound completed header blocks too, not just incomplete ones Co-Authored-By: Claude Fable 5 * fix: close connections that never deliver a complete request Co-Authored-By: Claude Fable 5 * fix: treat the incomplete-request timeout as an idle bound during the body phase Co-Authored-By: Claude Fable 5 * fix: add response backpressure and reject whitespace before header colons Co-Authored-By: Claude Fable 5 * fix: reject duplicate framing headers and stop pipelining on send failure Co-Authored-By: Claude Fable 5 * fix: reject malformed header lines instead of skipping them Co-Authored-By: Claude Fable 5 * fix: address review feedback on framing hardening - Bound the parsed Content-Length by NSUIntegerMax explicitly: the 15-digit cap alone is not enough on watchOS (arm64_32), where NSUInteger is 32-bit and truncation would resurrect the framing desync this parser exists to prevent. - Clear pendingRequestHeaders in -closeClient: too, so a connection the reaper drops doesn't retain its parsed header until (or unless) the disconnect callback runs. - Refresh the incomplete-request timestamp when the parser transitions a request into its body phase: -client:didReceiveData: samples that phase before parsing, so the receive completing a slow header block (and carrying the first body bytes) previously left the connection on its header-phase deadline. - Tests: import for close(2), send the payload in a loop since send(2) may write only part of it, keep reading past recv timeouts unless the response is a keep-alive success so didClose is reliable, and assert didClose in both oversized-header tests. Co-Authored-By: Claude Fable 5 * fix: resume parsing atomically when lifting the reaper exemption Co-Authored-By: Claude Fable 5 * chore: make comments more concise * fix: lower the request header cap to 16 KiB Matches node's default --max-http-header-size, and is still far above anything a real WDA request sends. The oversized-header tests flood 96 KiB, so they keep exercising the over-the-cap path unchanged. Co-Authored-By: Claude Opus 5 (1M context) * fix: bound Content-Length by overflow rather than a digit count The 15-digit cap was a second, unrelated bound on a value the caller already bounds by httpRequestBodySizeLimit. Only the arithmetic needs guarding, so reject on the overflow itself instead. NSUInteger is 32-bit on watchOS (arm64_32), so this keeps rejecting what would truncate, and strict digit-only parsing is unchanged. Co-Authored-By: Claude Opus 5 (1M context) * refactor: split -processBufferForClient: into focused helpers Pure refactor, no behaviour change: header-block extraction and size check, header-line parsing, framing-header validation plus body-length resolution, and the body-extent/dispatch step each move to their own method. Every malformed case keeps its existing 400-and-close outcome. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Fable 5 --- WebDriverAgent.xcodeproj/project.pbxproj | 4 + WebDriverAgentLib/Routing/FBHTTPServer.m | 375 +++++++++++++++--- WebDriverAgentLib/Routing/FBTCPSocket.h | 6 +- WebDriverAgentLib/Routing/FBTCPSocket.m | 7 +- .../UnitTests/FBHTTPServerTests.m | 264 ++++++++++++ 5 files changed, 588 insertions(+), 68 deletions(-) create mode 100644 WebDriverAgentTests/UnitTests/FBHTTPServerTests.m diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index 0c4c8727c..a26fa9010 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -1190,6 +1190,7 @@ EE8DDD7F20C5733C004D4925 /* XCUIElement+FBForceTouch.h in Headers */ = {isa = PBXBuildFile; fileRef = EE8DDD7D20C5733C004D4925 /* XCUIElement+FBForceTouch.h */; settings = {ATTRIBUTES = (Public, ); }; }; EE9AB8011CAEE048008C271F /* UITestingUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9AB7FD1CAEE048008C271F /* UITestingUITests.m */; }; EE9B76591CF7987800275851 /* FBRouteTests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76571CF7987300275851 /* FBRouteTests.m */; }; + DF82F4A8758B79DD91FA20CC /* FBHTTPServerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 76B7399DDF52C85A21433C1D /* FBHTTPServerTests.m */; }; EE9B768E1CF7997600275851 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76831CF7997600275851 /* AppDelegate.m */; }; EE9B768F1CF7997600275851 /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76851CF7997600275851 /* ViewController.m */; }; EE9B76911CF7997600275851 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76871CF7997600275851 /* main.m */; }; @@ -1905,6 +1906,7 @@ EE9B75D41CF7956C00275851 /* IntegrationApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IntegrationApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; EE9B75EC1CF7956C00275851 /* IntegrationTests_1.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntegrationTests_1.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EE9B76571CF7987300275851 /* FBRouteTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBRouteTests.m; sourceTree = ""; }; + 76B7399DDF52C85A21433C1D /* FBHTTPServerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBHTTPServerTests.m; sourceTree = ""; }; EE9B76581CF7987300275851 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; EE9B76821CF7997600275851 /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; EE9B76831CF7997600275851 /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -2675,6 +2677,7 @@ 713352FC26CEF31D00523CBC /* FBLRUCacheTests.m */, EE18883C1DA663EB00307AA8 /* FBMathUtilsTests.m */, 718F49C7230844330045FE8B /* FBProtocolHelpersTests.m */, + 76B7399DDF52C85A21433C1D /* FBHTTPServerTests.m */, EE9B76571CF7987300275851 /* FBRouteTests.m */, EE3F8CFD1D08AA17006F02CE /* FBRunLoopSpinnerTests.m */, ADEF63AE1D09DEBE0070A7E3 /* FBRuntimeUtilsTests.m */, @@ -4809,6 +4812,7 @@ 719FF5B91DAD21F5008E0099 /* FBElementUtilitiesTests.m in Sources */, 716E0BD11E917F260087A825 /* FBXMLSafeStringTests.m in Sources */, ADEF63AF1D09DEBE0070A7E3 /* FBRuntimeUtilsTests.m in Sources */, + DF82F4A8758B79DD91FA20CC /* FBHTTPServerTests.m in Sources */, EE9B76591CF7987800275851 /* FBRouteTests.m in Sources */, 7139145C1DF01A12005896C2 /* NSExpressionFBFormatTests.m in Sources */, 71A224E81DE326C500844D55 /* NSPredicateFBFormatTests.m in Sources */, diff --git a/WebDriverAgentLib/Routing/FBHTTPServer.m b/WebDriverAgentLib/Routing/FBHTTPServer.m index 1532086a9..ce824031b 100644 --- a/WebDriverAgentLib/Routing/FBHTTPServer.m +++ b/WebDriverAgentLib/Routing/FBHTTPServer.m @@ -10,6 +10,7 @@ #import "FBCommandStatus.h" #import "FBConfiguration.h" +#import "FBLogger.h" #import "FBResponsePayload.h" #import "FBTCPSocket.h" @@ -30,6 +31,35 @@ return (NSData * _Nonnull)[string dataUsingEncoding:NSUTF8StringEncoding]; } +// Caps a request's header block, so a connection that never completes one cannot grow its buffer +// without limit. Matches node's default --max-http-header-size. +static const NSUInteger FBMaxRequestHeaderSize = 16 * 1024; + +// ASCII decimal digits only. -integerValue must not be used here: it maps garbage silently +// ("bogus" -> 0, "12abc" -> 12), desyncing the framing of every later request on the connection. +static BOOL FBParseContentLength(NSString *value, NSUInteger *outLength) +{ + if (value.length < 1) { + return NO; + } + NSUInteger result = 0; + for (NSUInteger i = 0; i < value.length; i++) { + unichar c = [value characterAtIndex:i]; + if (c < '0' || c > '9') { + return NO; + } + NSUInteger digit = (NSUInteger)(c - '0'); + // NSUInteger is 32-bit on watchOS (arm64_32), so this bounds truncation as well as overflow. + // Anything smaller is left to the caller's httpRequestBodySizeLimit check. + if (result > (NSUIntegerMax - digit) / 10) { + return NO; + } + result = result * 10 + digit; + } + *outLength = result; + return YES; +} + @interface FBHTTPRoute : NSObject @property (nonatomic, copy) NSString *verb; @property (nonatomic, strong) NSRegularExpression *regex; @@ -101,9 +131,19 @@ @interface FBHTTPServer () // standalone or not (except DELETE /session itself - see -dispatchMethod:). See // -abandonPendingRequestsForSessionID:. Guarded by @synchronized(self.pendingSessionRequests). @property (nonatomic, strong) NSMutableDictionary *> *pendingSessionRequests; +// When each connection started waiting for its current request. The reaper closes connections +// whose entry outlives FBIncompleteRequestTimeout; idle keep-alive connections have no entry and +// are exempt. Guarded by @synchronized(self.connectionBuffers). +@property (nonatomic, strong) NSMapTable *incompleteRequestStarts; +@property (nonatomic, nullable) dispatch_source_t staleConnectionReaper; @end +// How long a connection may take to deliver a complete request, matching the header read timeout +// the previous CocoaHTTPServer stack enforced. +static const NSTimeInterval FBIncompleteRequestTimeout = 30.0; +static const int64_t FBStaleConnectionSweepIntervalSec = 10; + @implementation FBHTTPServer - (instancetype)init @@ -119,6 +159,8 @@ - (instancetype)init _connectionsAwaitingResponse = [NSMutableSet set]; _standaloneWaiters = [NSMutableDictionary dictionary]; _pendingSessionRequests = [NSMutableDictionary dictionary]; + _incompleteRequestStarts = [NSMapTable mapTableWithKeyOptions:(NSPointerFunctionsOptions)(NSMapTableObjectPointerPersonality | NSMapTableStrongMemory) + valueOptions:(NSPointerFunctionsOptions)NSMapTableStrongMemory]; } return self; } @@ -226,18 +268,56 @@ - (BOOL)start:(NSError **)error return NO; } self.socket = socket; + dispatch_source_t reaper = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, self.bufferProcessingQueue); + dispatch_source_set_timer(reaper, + dispatch_time(DISPATCH_TIME_NOW, FBStaleConnectionSweepIntervalSec * NSEC_PER_SEC), + (uint64_t)FBStaleConnectionSweepIntervalSec * NSEC_PER_SEC, + NSEC_PER_SEC); + __weak typeof(self) weakSelf = self; + dispatch_source_set_event_handler(reaper, ^{ + [weakSelf reapStaleConnections]; + }); + dispatch_resume(reaper); + self.staleConnectionReaper = reaper; _isRunning = YES; return YES; } +- (void)reapStaleConnections +{ + NSMutableArray *staleConnections = [NSMutableArray array]; + @synchronized (self.connectionBuffers) { + for (id connection in self.incompleteRequestStarts) { + // Waiting on the handler, not the peer - never reap, however long the handler takes. + if ([self.connectionsAwaitingResponse containsObject:connection]) { + continue; + } + NSDate *start = [self.incompleteRequestStarts objectForKey:connection]; + if (nil != start && -start.timeIntervalSinceNow > FBIncompleteRequestTimeout) { + [staleConnections addObject:connection]; + } + } + } + for (id connection in staleConnections) { + [FBLogger logFmt:@"Closing a connection that did not deliver a complete request within %@ seconds", @(FBIncompleteRequestTimeout)]; + [self closeClient:(nw_connection_t)connection]; + } +} + - (void)stop:(BOOL)immediately { + dispatch_source_t reaper = self.staleConnectionReaper; + if (nil != reaper) { + dispatch_source_cancel(reaper); + self.staleConnectionReaper = nil; + } [self.socket stop]; self.socket = nil; @synchronized (self.connectionBuffers) { [self.connectionBuffers removeAllObjects]; [self.pendingRequestHeaders removeAllObjects]; [self.connectionsAwaitingResponse removeAllObjects]; + [self.incompleteRequestStarts removeAllObjects]; } _isRunning = NO; } @@ -248,6 +328,8 @@ - (void)didClientConnect:(nw_connection_t)newClient { @synchronized (self.connectionBuffers) { [self.connectionBuffers setObject:[NSMutableData data] forKey:newClient]; + // Starts at connect, so a peer that connects and then sends nothing is reaped too. + [self.incompleteRequestStarts setObject:[NSDate date] forKey:newClient]; } } @@ -257,6 +339,7 @@ - (void)didClientDisconnect:(nw_connection_t)client [self.connectionBuffers removeObjectForKey:client]; [self.pendingRequestHeaders removeObjectForKey:client]; [self.connectionsAwaitingResponse removeObject:client]; + [self.incompleteRequestStarts removeObjectForKey:client]; } } @@ -271,12 +354,34 @@ - (void)client:(nw_connection_t)client didReceiveData:(NSData *)data if (nil == strongSelf) { return; } + BOOL isOverBufferCap = NO; @synchronized (strongSelf.connectionBuffers) { NSMutableData *buffer = [strongSelf.connectionBuffers objectForKey:client]; if (nil == buffer) { return; } [buffer appendData:data]; + // One maximal header block plus one maximal body, plus headroom for a pipelined follow-up. + // The per-request checks don't run while a request is executing, so without this cap a + // client could pump data unboundedly for as long as its previous request takes. + uint64_t bufferCap = FBConfiguration.sharedInstance.httpRequestBodySizeLimit + 2 * (uint64_t)FBMaxRequestHeaderSize; + if (bufferCap < FBConfiguration.sharedInstance.httpRequestBodySizeLimit) { + bufferCap = UINT64_MAX; + } + isOverBufferCap = buffer.length > bufferCap; + // In the body phase the timeout is an idle bound, refreshed on progress: a declared body + // may legitimately be slow and its size is already capped by Content-Length. In the header + // phase the clock is only started, never refreshed, so drip-fed headers cannot outlive it. + BOOL isBodyPhase = nil != [strongSelf.pendingRequestHeaders objectForKey:client]; + if (isBodyPhase || nil == [strongSelf.incompleteRequestStarts objectForKey:client]) { + [strongSelf.incompleteRequestStarts setObject:[NSDate date] forKey:client]; + } + } + if (isOverBufferCap) { + // No response owed: a peer this far past any legitimate size is not reading anyway. + [FBLogger log:@"Closing a connection that overflowed its request buffer"]; + [strongSelf closeClient:client]; + return; } [strongSelf processBufferForClient:client]; }); @@ -302,78 +407,183 @@ - (void)processBufferForClient:(nw_connection_t)client } if (nil == pending) { - NSRange headerEndRange = [buffer rangeOfData:FBCRLFCRLFData() options:(NSDataSearchOptions)0 range:NSMakeRange(0, buffer.length)]; - if (NSNotFound == headerEndRange.location) { - // Wait for the rest of the header block to arrive. + pending = [self parsedRequestHeaderFromBuffer:buffer forClient:client]; + if (nil == pending) { + // Either the header block is still incomplete, or it was rejected and answered already. return; } + @synchronized (self.connectionBuffers) { + [self.pendingRequestHeaders setObject:pending forKey:client]; + } + } + + [self dispatchBufferedRequestWithHeader:pending fromBuffer:buffer forClient:client]; +} - NSData *headerData = [buffer subdataWithRange:NSMakeRange(0, headerEndRange.location)]; - NSString *headerString = [[NSString alloc] initWithData:headerData encoding:NSUTF8StringEncoding]; - NSArray *lines = [headerString componentsSeparatedByString:@"\r\n"]; - if (lines.count < 1) { +// Locates the CRLFCRLF that ends the buffered header block and bounds the block's size. Returns +// NO when nothing can be parsed yet - either because more bytes are needed or because the block +// was rejected, in which case the 400 has already been written. +- (BOOL)findHeaderBlockEnd:(out NSRange *)outHeaderEndRange + inBuffer:(NSMutableData *)buffer + forClient:(nw_connection_t)client +{ + NSRange headerEndRange = [buffer rangeOfData:FBCRLFCRLFData() options:(NSDataSearchOptions)0 range:NSMakeRange(0, buffer.length)]; + if (NSNotFound == headerEndRange.location) { + if (buffer.length > FBMaxRequestHeaderSize) { + // Past any legitimate header block and still unterminated - stop buffering. [self respondBadRequestToClient:client]; - return; } + // Otherwise wait for the rest of the header block to arrive. + return NO; + } + if (headerEndRange.location > FBMaxRequestHeaderSize) { + // The check above only fires while the terminator is missing; one large receive can deliver + // an oversized block with it, so bound the completed block too before parsing it. + [self respondBadRequestToClient:client]; + return NO; + } + *outHeaderEndRange = headerEndRange; + return YES; +} - NSArray *requestLineParts = [lines.firstObject componentsSeparatedByString:@" "]; - if (requestLineParts.count < 2) { +// Turns the header lines that follow the request line into a lowercase-keyed dictionary. +// Returns nil for the malformed and ambiguous shapes, having written the 400 already. +- (nullable NSDictionary *)parsedHeaderFieldsFromLines:(NSArray *)lines + forClient:(nw_connection_t)client +{ + NSMutableDictionary *requestHeaders = [NSMutableDictionary dictionary]; + for (NSUInteger i = 1; i < lines.count; i++) { + NSString *line = lines[i]; + NSRange colonRange = [line rangeOfString:@":"]; + if (0 == line.length) { + continue; + } + if (NSNotFound == colonRange.location) { + // Malformed. Skipping it would drop what it meant to say: "Content-Length 5" would + // dispatch with an empty body, leaving its bytes to be parsed as another request. [self respondBadRequestToClient:client]; - return; + return nil; } - - NSMutableDictionary *requestHeaders = [NSMutableDictionary dictionary]; - for (NSUInteger i = 1; i < lines.count; i++) { - NSString *line = lines[i]; - NSRange colonRange = [line rangeOfString:@":"]; - if (NSNotFound == colonRange.location) { - continue; - } - NSString *name = [line substringToIndex:colonRange.location]; - NSString *value = [[line substringFromIndex:colonRange.location + 1] - stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet]; - requestHeaders[name.lowercaseString] = value; + NSString *name = [line substringToIndex:colonRange.location]; + // RFC 7230 (3.2.4): whitespace before the colon MUST be rejected. Storing "content-length " + // as its own key would drop the real header and desync the framing. + if (0 == name.length + || NSNotFound != [name rangeOfCharacterFromSet:NSCharacterSet.whitespaceAndNewlineCharacterSet].location) { + [self respondBadRequestToClient:client]; + return nil; } - - NSString *transferEncoding = requestHeaders[@"transfer-encoding"]; - if (transferEncoding.length > 0) { - // No transfer decoder is implemented at all, so any encoding (chunked or otherwise - - // including a value only introduced by a duplicate header overwriting "chunked" above) - // is rejected rather than risking the body being misread as empty and desyncing the rest - // of the connection's request stream. - RouteResponse *notImplemented = [RouteResponse new]; - id notImplementedPayload = FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"Transfer-Encoding is not supported" - traceback:nil]); - [notImplementedPayload dispatchWithResponse:notImplemented]; - [self failClient:client withResponse:notImplemented]; - return; + NSString *value = [[line substringFromIndex:colonRange.location + 1] + stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceCharacterSet]; + NSString *normalizedName = name.lowercaseString; + // RFC 7230 (3.3.3): repeated framing fields are unrecoverable. Last-wins would let an empty + // "Transfer-Encoding:" mask an earlier "chunked", and the last Content-Length drive parsing. + if (([normalizedName isEqualToString:@"content-length"] || [normalizedName isEqualToString:@"transfer-encoding"]) + && nil != requestHeaders[normalizedName]) { + [self respondBadRequestToClient:client]; + return nil; } + requestHeaders[normalizedName] = value; + } + return requestHeaders; +} - NSUInteger contentLength = (NSUInteger)requestHeaders[@"content-length"].integerValue; - if (contentLength > FBConfiguration.sharedInstance.httpRequestBodySizeLimit) { - // Closes the connection after responding, since the rest of the oversized body is still incoming. - RouteResponse *tooLarge = [RouteResponse new]; - id tooLargePayload = FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"The request body exceeds the configured size limit" - traceback:nil]); - [tooLargePayload dispatchWithResponse:tooLarge]; - [self failClient:client withResponse:tooLarge]; - return; - } +// Rejects framing this server cannot honour and resolves the declared body length from the +// remaining framing headers. Returns NO having written the closing error response already. +- (BOOL)resolveBodyLength:(out NSUInteger *)outBodyLength + fromHeaderFields:(NSDictionary *)requestHeaders + forClient:(nw_connection_t)client +{ + NSString *transferEncoding = requestHeaders[@"transfer-encoding"]; + if (nil != transferEncoding) { + // No transfer decoder exists, so mere presence is rejected - including an empty value, + // which is not a valid encoding list and would let the body be misread as empty. + RouteResponse *notImplemented = [RouteResponse new]; + id notImplementedPayload = FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"Transfer-Encoding is not supported" + traceback:nil]); + [notImplementedPayload dispatchWithResponse:notImplemented]; + [self failClient:client withResponse:notImplemented]; + return NO; + } - pending = [FBPendingHTTPRequestHeader new]; - pending.method = requestLineParts[0].uppercaseString; - pending.pathAndQuery = requestLineParts[1]; - pending.bodyStart = headerEndRange.location + headerEndRange.length; - pending.contentLength = contentLength; - @synchronized (self.connectionBuffers) { - [self.pendingRequestHeaders setObject:pending forKey:client]; - } + NSString *contentLengthValue = requestHeaders[@"content-length"]; + NSUInteger contentLength = 0; + if (nil != contentLengthValue && !FBParseContentLength(contentLengthValue, &contentLength)) { + // The body's extent is unknowable, so the connection cannot be resynced - reject and close. + [self respondBadRequestToClient:client]; + return NO; + } + if (contentLength > FBConfiguration.sharedInstance.httpRequestBodySizeLimit) { + // Closes the connection after responding, since the rest of the oversized body is still incoming. + RouteResponse *tooLarge = [RouteResponse new]; + id tooLargePayload = FBResponseWithStatus([FBCommandStatus invalidArgumentErrorWithMessage:@"The request body exceeds the configured size limit" + traceback:nil]); + [tooLargePayload dispatchWithResponse:tooLarge]; + [self failClient:client withResponse:tooLarge]; + return NO; + } + *outBodyLength = contentLength; + return YES; +} + +// Parses the request line and headers of the request at the head of the buffer. Returns nil +// while the header block is still incomplete, and for a rejected one, which is answered here. +- (nullable FBPendingHTTPRequestHeader *)parsedRequestHeaderFromBuffer:(NSMutableData *)buffer + forClient:(nw_connection_t)client +{ + NSRange headerEndRange; + if (![self findHeaderBlockEnd:&headerEndRange inBuffer:buffer forClient:client]) { + return nil; + } + + NSData *headerData = [buffer subdataWithRange:NSMakeRange(0, headerEndRange.location)]; + NSString *headerString = [[NSString alloc] initWithData:headerData encoding:NSUTF8StringEncoding]; + NSArray *lines = [headerString componentsSeparatedByString:@"\r\n"]; + if (lines.count < 1) { + [self respondBadRequestToClient:client]; + return nil; + } + + NSArray *requestLineParts = [lines.firstObject componentsSeparatedByString:@" "]; + if (requestLineParts.count < 2) { + [self respondBadRequestToClient:client]; + return nil; + } + + NSDictionary *requestHeaders = [self parsedHeaderFieldsFromLines:lines forClient:client]; + if (nil == requestHeaders) { + return nil; + } + NSUInteger contentLength = 0; + if (![self resolveBodyLength:&contentLength fromHeaderFields:requestHeaders forClient:client]) { + return nil; } + FBPendingHTTPRequestHeader *pending = [FBPendingHTTPRequestHeader new]; + pending.method = requestLineParts[0].uppercaseString; + pending.pathAndQuery = requestLineParts[1]; + pending.bodyStart = headerEndRange.location + headerEndRange.length; + pending.contentLength = contentLength; + return pending; +} + +// Consumes the already-parsed request from the head of the buffer and dispatches it, once its +// whole body has arrived. Returns with the cached header left in place while it hasn't. +- (void)dispatchBufferedRequestWithHeader:(FBPendingHTTPRequestHeader *)pending + fromBuffer:(NSMutableData *)buffer + forClient:(nw_connection_t)client +{ NSUInteger totalRequestLength = pending.bodyStart + pending.contentLength; if (buffer.length < totalRequestLength) { - // Wait for the rest of the body to arrive - the parsed header stays cached above, so this + // Wait for the rest of the body to arrive - the parsed header stays cached, so this // doesn't re-scan/re-parse the header block on every subsequently arriving chunk. + @synchronized (self.connectionBuffers) { + // The request is now in its body phase, which is idle-bounded rather than hard-bounded. + // -client:didReceiveData: samples that phase before this parse runs, so the receive that + // completed a slowly-delivered header (and carried the first body bytes) would otherwise + // leave the connection on its header-phase timestamp and let the sweep close it despite + // the body having just made progress. + [self.incompleteRequestStarts setObject:[NSDate date] forKey:client]; + } return; } @@ -383,6 +593,14 @@ - (void)processBufferForClient:(nw_connection_t)client [buffer replaceBytesInRange:NSMakeRange(0, totalRequestLength) withBytes:NULL length:0]; [self.pendingRequestHeaders removeObjectForKey:client]; [self.connectionsAwaitingResponse addObject:client]; + if (0 == buffer.length) { + // A complete request was delivered and nothing further is buffered: the connection is a + // healthy keep-alive and must not be reaped while idle. + [self.incompleteRequestStarts removeObjectForKey:client]; + } else { + // Pipelined bytes of the next request are already buffered - restart its clock. + [self.incompleteRequestStarts setObject:[NSDate date] forKey:client]; + } } [self dispatchMethod:pending.method pathAndQuery:pending.pathAndQuery body:body client:client]; @@ -612,19 +830,46 @@ - (void)writeResponse:(RouteResponse *)response toClient:(nw_connection_t)client if (shouldClose) { __weak typeof(self) weakSelf = self; - [self.socket writeData:payload toClient:client completion:^{ + [self.socket writeData:payload toClient:client completion:^(BOOL didSucceed) { [weakSelf closeClient:client]; }]; } else { - // Sent before unblocking the next pipelined request, so responses can't reach the wire out of order. - [self.socket writeData:payload toClient:client]; - @synchronized (self.connectionBuffers) { - [self.connectionsAwaitingResponse removeObject:client]; - } + // Unblocked from the send's completion, not before it: ordering is preserved either way + // (nw_connection_send is FIFO per connection), but unblocking early lets a client that + // pipelines without reading responses pile up rendered responses inside Network.framework. __weak typeof(self) weakSelf = self; - dispatch_async(self.bufferProcessingQueue, ^{ - [weakSelf processBufferForClient:client]; - }); + [self.socket writeData:payload toClient:client completion:^(BOOL didSucceed) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (nil == strongSelf) { + return; + } + if (!didSucceed) { + // The response never reached the peer, so running its next pipelined request - possibly + // a mutating one - would change device state for a client that can no longer be answered. + [FBLogger log:@"Failed to write a response; dropping the connection and its pending requests"]; + [strongSelf closeClient:client]; + return; + } + // Lifting the exemption and resuming parsing happen in one step on bufferProcessingQueue, + // the queue the reaper also runs on: doing it out here exposes the connection to a sweep + // queued ahead of the parse, which would judge a buffered request by the previous one's + // timestamp. + dispatch_async(strongSelf.bufferProcessingQueue, ^{ + __strong typeof(weakSelf) queuedSelf = weakSelf; + if (nil == queuedSelf) { + return; + } + @synchronized (queuedSelf.connectionBuffers) { + [queuedSelf.connectionsAwaitingResponse removeObject:client]; + // Mid-request connections get their window from when parsing could resume, not from the + // previous request. Absent entries stay absent, so idle keep-alives remain exempt. + if (nil != [queuedSelf.incompleteRequestStarts objectForKey:client]) { + [queuedSelf.incompleteRequestStarts setObject:[NSDate date] forKey:client]; + } + } + [queuedSelf processBufferForClient:client]; + }); + }]; } } @@ -632,7 +877,9 @@ - (void)closeClient:(nw_connection_t)client { @synchronized (self.connectionBuffers) { [self.connectionBuffers removeObjectForKey:client]; + [self.pendingRequestHeaders removeObjectForKey:client]; [self.connectionsAwaitingResponse removeObject:client]; + [self.incompleteRequestStarts removeObjectForKey:client]; } nw_connection_cancel(client); } diff --git a/WebDriverAgentLib/Routing/FBTCPSocket.h b/WebDriverAgentLib/Routing/FBTCPSocket.h index 9613330e5..cceefbb00 100644 --- a/WebDriverAgentLib/Routing/FBTCPSocket.h +++ b/WebDriverAgentLib/Routing/FBTCPSocket.h @@ -102,9 +102,11 @@ NS_ASSUME_NONNULL_BEGIN @param data The data to send @param client The destination client - @param completion Called once the send attempt finishes + @param completion Called once the send attempt finishes. `didSucceed` is NO if the send failed + (e.g. the peer went away mid-write), in which case nothing was delivered and the caller + must not treat the connection as usable. */ -- (void)writeData:(NSData *)data toClient:(nw_connection_t)client completion:(nullable void (^)(void))completion; +- (void)writeData:(NSData *)data toClient:(nw_connection_t)client completion:(nullable void (^)(BOOL didSucceed))completion; @end diff --git a/WebDriverAgentLib/Routing/FBTCPSocket.m b/WebDriverAgentLib/Routing/FBTCPSocket.m index 769add707..70ed961a5 100644 --- a/WebDriverAgentLib/Routing/FBTCPSocket.m +++ b/WebDriverAgentLib/Routing/FBTCPSocket.m @@ -195,12 +195,15 @@ - (void)writeData:(NSData *)data toClient:(nw_connection_t)client [self writeData:data toClient:client completion:nil]; } -- (void)writeData:(NSData *)data toClient:(nw_connection_t)client completion:(nullable void (^)(void))completion +- (void)writeData:(NSData *)data toClient:(nw_connection_t)client completion:(nullable void (^)(BOOL didSucceed))completion { dispatch_data_t dispatchData = dispatch_data_create(data.bytes, data.length, self.socketQueue, DISPATCH_DATA_DESTRUCTOR_DEFAULT); nw_connection_send(client, dispatchData, NW_CONNECTION_DEFAULT_STREAM_CONTEXT, false, ^(nw_error_t _Nullable sendError) { if (completion) { - completion(); + // The send error must reach the caller: a failed write means the response never reached + // the peer, and treating that as success would e.g. let the next pipelined request run + // against a connection that can no longer answer it. + completion(nil == sendError); } }); } diff --git a/WebDriverAgentTests/UnitTests/FBHTTPServerTests.m b/WebDriverAgentTests/UnitTests/FBHTTPServerTests.m new file mode 100644 index 000000000..4c693c46d --- /dev/null +++ b/WebDriverAgentTests/UnitTests/FBHTTPServerTests.m @@ -0,0 +1,264 @@ +/** + * 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 +#import +#import +#import + +#import "FBHTTPServer.h" + +static atomic_int gFramingProbeHits; + +// Exercises FBHTTPServer's HTTP framing defenses with raw socket data that URL-loading APIs +// cannot produce: malformed Content-Length values and header blocks that never terminate. +@interface FBHTTPServerTests : XCTestCase +@property (nonatomic, strong) FBHTTPServer *server; +@property (nonatomic, assign) uint16_t port; +@end + +@implementation FBHTTPServerTests + +- (void)setUp +{ + [super setUp]; + atomic_store(&gFramingProbeHits, 0); + self.server = [FBHTTPServer new]; + [self.server handleMethod:@"POST" withPath:@"/framing/probe" block:^(RouteRequest *request, RouteResponse *response) { + atomic_fetch_add(&gFramingProbeHits, 1); + [response respondWithString:@"probe-ok"]; + }]; + [self.server get:@"/framing/ping" withBlock:^(RouteRequest *request, RouteResponse *response) { + [response respondWithString:@"pong"]; + }]; + self.server.port = 0; + NSError *error; + XCTAssertTrue([self.server start:&error], @"%@", error); + self.port = [[self.server valueForKeyPath:@"socket.port"] unsignedShortValue]; +} + +- (void)tearDown +{ + [self.server stop:NO]; + self.server = nil; + [super tearDown]; +} + +// Sends `payload` as-is and reads until the server closes the connection or `timeout` elapses. +// Returns everything received (nil on connect failure); *didClose reports whether EOF was seen. +- (NSString *)responseForRawPayload:(NSData *)payload timeout:(NSTimeInterval)timeout didClose:(BOOL *)didClose +{ + *didClose = NO; + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + return nil; + } + int noSigpipe = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigpipe, sizeof(noSigpipe)); + struct timeval tv = { .tv_sec = (long)timeout, .tv_usec = 0 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(self.port) }; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (0 != connect(fd, (struct sockaddr *)&addr, sizeof(addr))) { + close(fd); + return nil; + } + // send(2) may write only part of the payload, which would truncate the multi-KiB flood + // payloads into something the server answers differently. Errors stay ignored on purpose: + // those same tests expect the server to close the connection mid-send. + const uint8_t *bytes = payload.bytes; + size_t remaining = payload.length; + while (remaining > 0) { + ssize_t sent = send(fd, bytes, remaining, 0); + if (sent <= 0) { + break; + } + bytes += sent; + remaining -= (size_t)sent; + } + NSMutableData *received = [NSMutableData data]; + char chunk[4096]; + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeout]; + while (deadline.timeIntervalSinceNow > 0) { + ssize_t n = recv(fd, chunk, sizeof(chunk), 0); + if (n == 0) { + *didClose = YES; + break; + } + if (n < 0) { + // A read timeout. Only stop waiting once the response is a keep-alive success, where no + // EOF is ever coming; every other response precedes a close, and giving up here would + // report didClose = NO for a connection the server is about to drop. + NSString *soFar = [[NSString alloc] initWithData:received encoding:NSUTF8StringEncoding] ?: @""; + if ([soFar containsString:@"HTTP/1.1 200"]) { + break; + } + continue; + } + [received appendBytes:chunk length:(NSUInteger)n]; + // The response has started arriving; poll in short slices from here so a keep-alive success + // doesn't sit out the whole timeout waiting for an EOF that never comes. + struct timeval drainTv = { .tv_sec = 0, .tv_usec = 200000 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &drainTv, sizeof(drainTv)); + } + close(fd); + return [[NSString alloc] initWithData:received encoding:NSUTF8StringEncoding] ?: @""; +} + +- (void)testWellFormedRequestStillSucceeds +{ + BOOL didClose; + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[@"GET /framing/ping HTTP/1.1\r\n\r\n" dataUsingEncoding:NSUTF8StringEncoding] + timeout:5.0 + didClose:&didClose]; + XCTAssertTrue([response containsString:@"200"], @"%@", response); + XCTAssertTrue([response containsString:@"pong"], @"%@", response); +} + +- (void)testNonNumericContentLengthIsRejected +{ + // Under -integerValue's lenient parsing "bogus" became 0: the probe route would run with an + // empty body and the smuggled GET below would be answered as a second pipelined request. + NSString *payload = @"POST /framing/probe HTTP/1.1\r\nContent-Length: bogus\r\n\r\nGET /framing/ping HTTP/1.1\r\n\r\n"; + BOOL didClose; + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[payload dataUsingEncoding:NSUTF8StringEncoding] + timeout:5.0 + didClose:&didClose]; + XCTAssertTrue([response containsString:@"400"], @"%@", response); + XCTAssertFalse([response containsString:@"pong"], @"the smuggled request must not be answered: %@", response); + XCTAssertTrue(didClose, @"the connection must be closed after unparseable framing"); + XCTAssertEqual(atomic_load(&gFramingProbeHits), 0, @"the route must not be dispatched with unknown body extent"); +} + +- (void)testWhitespaceBeforeHeaderColonIsRejected +{ + // RFC 7230 (3.2.4): whitespace between a field name and its colon MUST be rejected with a 400. + // Tolerating it stores "content-length " as a distinct key, dispatches the request with a + // zero-length body, and re-parses the declared body as a smuggled pipelined request. + NSString *payload = @"POST /framing/probe HTTP/1.1\r\nContent-Length : 5\r\n\r\nhello"; + BOOL didClose; + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[payload dataUsingEncoding:NSUTF8StringEncoding] + timeout:5.0 + didClose:&didClose]; + XCTAssertTrue([response containsString:@"400"], @"%@", response); + XCTAssertTrue(didClose); + XCTAssertEqual(atomic_load(&gFramingProbeHits), 0); +} + +- (void)testHeaderLineWithoutColonIsRejected +{ + // Silently skipping the malformed line made this dispatch with an empty body while "hello" + // stayed in the buffer to be parsed as the next request. + NSString *payload = @"POST /framing/probe HTTP/1.1\r\nContent-Length 5\r\n\r\nhello"; + BOOL didClose; + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[payload dataUsingEncoding:NSUTF8StringEncoding] + timeout:5.0 + didClose:&didClose]; + XCTAssertTrue([response containsString:@"400"], @"%@", response); + XCTAssertTrue(didClose); + XCTAssertEqual(atomic_load(&gFramingProbeHits), 0); +} + +- (void)testDuplicateContentLengthIsRejected +{ + // RFC 7230 (3.3.3): repeated framing fields are unrecoverable. Last-wins assignment would let + // the second value drive parsing while an intermediary used the first - a smuggling primitive. + NSString *payload = @"POST /framing/probe HTTP/1.1\r\nContent-Length: 5\r\nContent-Length: 0\r\n\r\nhello"; + BOOL didClose; + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[payload dataUsingEncoding:NSUTF8StringEncoding] + timeout:5.0 + didClose:&didClose]; + XCTAssertTrue([response containsString:@"400"], @"%@", response); + XCTAssertTrue(didClose); + XCTAssertEqual(atomic_load(&gFramingProbeHits), 0); +} + +- (void)testEmptyTransferEncodingIsRejected +{ + // "chunked" followed by an empty value: with last-wins assignment plus a non-empty presence + // check, the empty value used to make the header look absent, so the chunked body was parsed + // as a zero-length body and its bytes re-read as smuggled requests. + NSString *payload = @"POST /framing/probe HTTP/1.1\r\nTransfer-Encoding: chunked\r\nTransfer-Encoding: \r\n\r\n0\r\n\r\n"; + BOOL didClose; + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[payload dataUsingEncoding:NSUTF8StringEncoding] + timeout:5.0 + didClose:&didClose]; + XCTAssertTrue([response containsString:@"400"] || [response containsString:@"501"], @"%@", response); + XCTAssertTrue(didClose); + XCTAssertEqual(atomic_load(&gFramingProbeHits), 0); +} + +- (void)testPipelinedRequestsAreServedInOrder +{ + // Two requests in one payload: both must be answered on the same connection. Guards the + // response backpressure logic - the next pipelined request is only processed once the + // previous response's send completed, which must not stall or reorder the pipeline. + NSString *payload = @"GET /framing/ping HTTP/1.1\r\n\r\nGET /framing/ping HTTP/1.1\r\n\r\n"; + BOOL didClose; + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[payload dataUsingEncoding:NSUTF8StringEncoding] + timeout:5.0 + didClose:&didClose]; + NSUInteger pongCount = [response componentsSeparatedByString:@"pong"].count - 1; + XCTAssertEqual(pongCount, 2, @"both pipelined requests must be answered: %@", response); +} + +- (void)testPartiallyNumericContentLengthIsRejected +{ + NSString *payload = @"POST /framing/probe HTTP/1.1\r\nContent-Length: 5abc\r\n\r\nhello"; + BOOL didClose; + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[payload dataUsingEncoding:NSUTF8StringEncoding] + timeout:5.0 + didClose:&didClose]; + XCTAssertTrue([response containsString:@"400"], @"%@", response); + XCTAssertTrue(didClose); + XCTAssertEqual(atomic_load(&gFramingProbeHits), 0); +} + +- (void)testOversizedHeaderBlockIsRejected +{ + // A header block that never terminates: 96 KiB of header lines with no \r\n\r\n. The server + // must stop buffering and close the connection instead of growing the buffer indefinitely. + NSMutableString *payload = [NSMutableString stringWithString:@"GET /framing/ping HTTP/1.1\r\n"]; + NSString *filler = [@"X-Filler: " stringByAppendingString:[@"" stringByPaddingToLength:1013 withString:@"a" startingAtIndex:0]]; + while (payload.length < 96 * 1024) { + [payload appendString:filler]; + [payload appendString:@"\r\n"]; + } + BOOL didClose; + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[payload dataUsingEncoding:NSUTF8StringEncoding] + timeout:10.0 + didClose:&didClose]; + XCTAssertTrue([response containsString:@"400"], @"%@", response); + XCTAssertTrue(didClose, @"the connection must be closed rather than left buffering"); +} + +- (void)testOversizedCompletedHeaderBlockIsRejected +{ + // Same flood, but properly terminated with \r\n\r\n. Depending on how the bytes coalesce, the + // terminator can arrive in the same receive callback as the bulk of the block, in which case + // the incomplete-header cap never fires - the completed block must be rejected too instead of + // being copied and parsed. + NSMutableString *payload = [NSMutableString stringWithString:@"GET /framing/ping HTTP/1.1\r\n"]; + NSString *filler = [@"X-Filler: " stringByAppendingString:[@"" stringByPaddingToLength:1013 withString:@"a" startingAtIndex:0]]; + while (payload.length < 96 * 1024) { + [payload appendString:filler]; + [payload appendString:@"\r\n"]; + } + [payload appendString:@"\r\n"]; + BOOL didClose; + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[payload dataUsingEncoding:NSUTF8StringEncoding] + timeout:10.0 + didClose:&didClose]; + XCTAssertTrue([response containsString:@"400"], @"%@", response); + XCTAssertFalse([response containsString:@"pong"], @"the oversized request must not be served: %@", response); + XCTAssertTrue(didClose, @"the connection must be closed rather than left buffering"); +} + +@end From 54fc1a254632911fdc48e2b6fbcca2481d27d223 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 29 Aug 2026 03:57:41 +0000 Subject: [PATCH 12/46] chore(release): 16.9.3 [skip ci] ## [16.9.3](https://github.com/appium/WebDriverAgent/compare/v16.9.2...v16.9.3) (2026-08-29) ### Bug Fixes * reject malformed Content-Length values and bound request header buffering ([#1226](https://github.com/appium/WebDriverAgent/issues/1226)) ([60c5fc4](https://github.com/appium/WebDriverAgent/commit/60c5fc461d91611778b8bb0745a21c80451d6ccf)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 691cb9758..129c3f21c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.9.3](https://github.com/appium/WebDriverAgent/compare/v16.9.2...v16.9.3) (2026-08-29) + +### Bug Fixes + +* reject malformed Content-Length values and bound request header buffering ([#1226](https://github.com/appium/WebDriverAgent/issues/1226)) ([60c5fc4](https://github.com/appium/WebDriverAgent/commit/60c5fc461d91611778b8bb0745a21c80451d6ccf)) + ## [16.9.2](https://github.com/appium/WebDriverAgent/compare/v16.9.1...v16.9.2) (2026-08-28) ### Bug Fixes diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index e0b66590a..41024d9be 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.9.2 + 16.9.3 CFBundleSignature ???? CFBundleVersion - 16.9.2 + 16.9.3 NSPrincipalClass diff --git a/package.json b/package.json index a5cc9a0b7..eecbfd41e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.9.2", + "version": "16.9.3", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From 00cb6b612a5a1a1d00a386e23de01c4eca811df8 Mon Sep 17 00:00:00 2001 From: Timo <44401485+Timo972@users.noreply.github.com> Date: Sat, 29 Aug 2026 06:11:06 +0200 Subject: [PATCH 13/46] fix: drop MJPEG frames for clients that stop draining their socket (#1227) * fix: drop MJPEG frames for clients that stop draining their socket Co-Authored-By: Claude Fable 5 * chore: make comments more concise --------- Co-authored-by: Claude Fable 5 Co-authored-by: Kazuaki Matsuo --- WebDriverAgentLib/Utilities/FBMjpegServer.m | 37 +++++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBMjpegServer.m b/WebDriverAgentLib/Utilities/FBMjpegServer.m index 8bb2b958c..d7170579f 100644 --- a/WebDriverAgentLib/Utilities/FBMjpegServer.m +++ b/WebDriverAgentLib/Utilities/FBMjpegServer.m @@ -22,6 +22,9 @@ static const NSUInteger MAX_FPS = 60; static const NSTimeInterval FRAME_TIMEOUT = 1.; +// nw_connection_send buffers without backpressure, so a client that stops reading would retain +// every generated frame. Frames past this cap are dropped instead of queued. +static const NSUInteger MAX_PENDING_FRAMES_PER_CLIENT = 4; static const NSTimeInterval FAILURE_BACKOFF_MIN = 1.0; static const NSTimeInterval FAILURE_BACKOFF_MAX = 10.0; @@ -44,6 +47,9 @@ @interface FBMjpegServer() @property (atomic, assign) BOOL isStreaming; @property (nonatomic, assign) NSUInteger sentFramesCount; @property (nonatomic, assign) NSUInteger sentBytesCount; +@property (nonatomic, assign) NSUInteger droppedFramesCount; +// Frames submitted but not sent yet, per client. Guarded by @synchronized (self.listeningClients). +@property (nonatomic, readonly) NSMapTable *pendingFrameCounts; @end @@ -58,6 +64,8 @@ - (instancetype)init _sentFramesCount = 0; _sentBytesCount = 0; _listeningClients = [NSMutableArray array]; + _pendingFrameCounts = [NSMapTable mapTableWithKeyOptions:(NSPointerFunctionsOptions)(NSMapTableObjectPointerPersonality | NSMapTableStrongMemory) + valueOptions:(NSPointerFunctionsOptions)NSMapTableStrongMemory]; _imageProcessor = [[FBImageProcessor alloc] init]; _mainScreenID = [XCUIScreen.mainScreen displayID]; dispatch_queue_attr_t queueAttributes = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_UTILITY, 0); @@ -149,17 +157,38 @@ - (void)sendScreenshot:(NSData *)screenshotData { return; } NSUInteger clientCount = self.listeningClients.count; + __weak typeof(self) weakSelf = self; for (nw_connection_t client in self.listeningClients) { - [self.socket writeData:chunk toClient:client]; + NSUInteger pendingFrames = [self.pendingFrameCounts objectForKey:client].unsignedIntegerValue; + if (pendingFrames >= MAX_PENDING_FRAMES_PER_CLIENT) { + self.droppedFramesCount++; + continue; + } + [self.pendingFrameCounts setObject:@(pendingFrames + 1) forKey:client]; + [self.socket writeData:chunk toClient:client completion:^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (nil == strongSelf) { + return; + } + @synchronized (strongSelf.listeningClients) { + NSUInteger stillPending = [strongSelf.pendingFrameCounts objectForKey:client].unsignedIntegerValue; + if (stillPending > 1) { + [strongSelf.pendingFrameCounts setObject:@(stillPending - 1) forKey:client]; + } else { + [strongSelf.pendingFrameCounts removeObjectForKey:client]; + } + } + }]; } self.sentFramesCount++; self.sentBytesCount += chunk.length * clientCount; NSUInteger framerate = FBNormalizedMjpegFramerate(FBConfiguration.sharedInstance.mjpegServerFramerate); if (0 == self.sentFramesCount % framerate) { - [FBLogger verboseLog:[NSString stringWithFormat:@"MJPEG stats: clients=%@ sentFrames=%@ sentBytes=%@", + [FBLogger verboseLog:[NSString stringWithFormat:@"MJPEG stats: clients=%@ sentFrames=%@ sentBytes=%@ droppedFrames=%@", @(clientCount), @(self.sentFramesCount), - @(self.sentBytesCount)]]; + @(self.sentBytesCount), + @(self.droppedFramesCount)]]; } } } @@ -190,6 +219,7 @@ - (void)didClientDisconnect:(nw_connection_t)client { @synchronized (self.listeningClients) { [self.listeningClients removeObject:client]; + [self.pendingFrameCounts removeObjectForKey:client]; } [FBLogger log:@"Disconnected a client from screenshots broadcast"]; } @@ -200,6 +230,7 @@ - (void)stopStreaming @synchronized (self.listeningClients) { NSArray *clients = self.listeningClients.copy; [self.listeningClients removeAllObjects]; + [self.pendingFrameCounts removeAllObjects]; for (nw_connection_t client in clients) { nw_connection_cancel(client); } From c798d7750e19ae0c06c4b82c7acba76f8f2cef25 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 29 Aug 2026 04:31:16 +0000 Subject: [PATCH 14/46] chore(release): 16.9.4 [skip ci] ## [16.9.4](https://github.com/appium/WebDriverAgent/compare/v16.9.3...v16.9.4) (2026-08-29) ### Bug Fixes * drop MJPEG frames for clients that stop draining their socket ([#1227](https://github.com/appium/WebDriverAgent/issues/1227)) ([00cb6b6](https://github.com/appium/WebDriverAgent/commit/00cb6b612a5a1a1d00a386e23de01c4eca811df8)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 129c3f21c..26b4b0dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.9.4](https://github.com/appium/WebDriverAgent/compare/v16.9.3...v16.9.4) (2026-08-29) + +### Bug Fixes + +* drop MJPEG frames for clients that stop draining their socket ([#1227](https://github.com/appium/WebDriverAgent/issues/1227)) ([00cb6b6](https://github.com/appium/WebDriverAgent/commit/00cb6b612a5a1a1d00a386e23de01c4eca811df8)) + ## [16.9.3](https://github.com/appium/WebDriverAgent/compare/v16.9.2...v16.9.3) (2026-08-29) ### Bug Fixes diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 41024d9be..ef2fbab78 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.9.3 + 16.9.4 CFBundleSignature ???? CFBundleVersion - 16.9.3 + 16.9.4 NSPrincipalClass diff --git a/package.json b/package.json index eecbfd41e..e04c9d05e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.9.3", + "version": "16.9.4", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From bfbe6b00b196647f87267827627a5ff675496c30 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Sat, 29 Aug 2026 14:27:25 +0900 Subject: [PATCH 15/46] feat: add displayId in the wda/screen (#1238) --- WebDriverAgentLib/Commands/FBCustomCommands.m | 1 + WebDriverAgentLib/Utilities/FBScreen.h | 5 +++++ WebDriverAgentLib/Utilities/FBScreen.m | 5 +++++ WebDriverAgentTests/IntegrationTests/FBScreenTests.m | 6 +++++- 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/WebDriverAgentLib/Commands/FBCustomCommands.m b/WebDriverAgentLib/Commands/FBCustomCommands.m index 91a58fc2a..8c50d8cae 100644 --- a/WebDriverAgentLib/Commands/FBCustomCommands.m +++ b/WebDriverAgentLib/Commands/FBCustomCommands.m @@ -174,6 +174,7 @@ + (NSArray *)routes @"statusBarSize": @{@"width": @(statusBarSize.width), @"height": @(statusBarSize.height), }, + @"displayId": @([FBScreen displayID]), @"scale": @([FBScreen scale]), }); } diff --git a/WebDriverAgentLib/Utilities/FBScreen.h b/WebDriverAgentLib/Utilities/FBScreen.h index 87c22c7cb..ebc92c1bb 100644 --- a/WebDriverAgentLib/Utilities/FBScreen.h +++ b/WebDriverAgentLib/Utilities/FBScreen.h @@ -12,6 +12,11 @@ NS_ASSUME_NONNULL_BEGIN @interface FBScreen : NSObject +/** + The identifier of the main device's display + */ ++ (long long)displayID; + /** The scale factor of the main device's screen */ diff --git a/WebDriverAgentLib/Utilities/FBScreen.m b/WebDriverAgentLib/Utilities/FBScreen.m index dc6536408..4348ec15d 100644 --- a/WebDriverAgentLib/Utilities/FBScreen.m +++ b/WebDriverAgentLib/Utilities/FBScreen.m @@ -13,6 +13,11 @@ @implementation FBScreen ++ (long long)displayID +{ + return XCUIScreen.mainScreen.displayID; +} + + (double)scale { return [XCUIScreen.mainScreen scale]; diff --git a/WebDriverAgentTests/IntegrationTests/FBScreenTests.m b/WebDriverAgentTests/IntegrationTests/FBScreenTests.m index 1300141e1..6be0bedcd 100644 --- a/WebDriverAgentTests/IntegrationTests/FBScreenTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBScreenTests.m @@ -22,10 +22,14 @@ - (void)setUp [self launchApplication]; } +- (void)testDisplayID +{ + XCTAssertGreaterThanOrEqual([FBScreen displayID], 0LL); +} + - (void)testScreenScale { XCTAssertTrue([FBScreen scale] >= 2); } @end - From 47f5e19f63af7d94104f6c24b9417175d55803ee Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 29 Aug 2026 06:00:09 +0000 Subject: [PATCH 16/46] chore(release): 16.10.0 [skip ci] ## [16.10.0](https://github.com/appium/WebDriverAgent/compare/v16.9.4...v16.10.0) (2026-08-29) ### Features * add displayId in the wda/screen ([#1238](https://github.com/appium/WebDriverAgent/issues/1238)) ([bfbe6b0](https://github.com/appium/WebDriverAgent/commit/bfbe6b00b196647f87267827627a5ff675496c30)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26b4b0dd5..d6dcaa019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.10.0](https://github.com/appium/WebDriverAgent/compare/v16.9.4...v16.10.0) (2026-08-29) + +### Features + +* add displayId in the wda/screen ([#1238](https://github.com/appium/WebDriverAgent/issues/1238)) ([bfbe6b0](https://github.com/appium/WebDriverAgent/commit/bfbe6b00b196647f87267827627a5ff675496c30)) + ## [16.9.4](https://github.com/appium/WebDriverAgent/compare/v16.9.3...v16.9.4) (2026-08-29) ### Bug Fixes diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index ef2fbab78..2041d6572 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.9.4 + 16.10.0 CFBundleSignature ???? CFBundleVersion - 16.9.4 + 16.10.0 NSPrincipalClass diff --git a/package.json b/package.json index e04c9d05e..c69e420bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.9.4", + "version": "16.10.0", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From d5c85718c32bc495a0f73518d3e40eee624b3582 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Sat, 29 Aug 2026 08:34:10 +0200 Subject: [PATCH 17/46] feat: expose AXTimeout and XCTest XPC request timeout wrappers (#1233) --- PrivateHeaders/XCTest/CDStructures.h | 2 + WebDriverAgent.xcodeproj/project.pbxproj | 4 + .../XCUIApplicationProcess+FBQuiescence.m | 18 +-- .../Commands/FBSessionCommands.m | 48 +++---- .../Utilities/FBXCAXClientProxy.h | 117 +++++++++++++++++- .../Utilities/FBXCAXClientProxy.m | 109 +++++++++++++++- .../UnitTests/FBXCAXClientProxyTests.m | 90 ++++++++++++++ 7 files changed, 349 insertions(+), 39 deletions(-) create mode 100644 WebDriverAgentTests/UnitTests/FBXCAXClientProxyTests.m diff --git a/PrivateHeaders/XCTest/CDStructures.h b/PrivateHeaders/XCTest/CDStructures.h index 27a5eaacb..a79d7d8a1 100644 --- a/PrivateHeaders/XCTest/CDStructures.h +++ b/PrivateHeaders/XCTest/CDStructures.h @@ -2,4 +2,6 @@ #pragma clang diagnostic ignored "-Wreserved-identifier" int _XCTSetApplicationStateTimeout(double timeout); double _XCTApplicationStateTimeout(void); +void _XCTSetXPCRequestTimeout(double timeout); +double _XCTXPCRequestTimeout(void); #pragma clang diagnostic pop diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index a26fa9010..62618ccfd 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -1158,6 +1158,7 @@ EE3A18661CDE734B00DE4205 /* FBKeyboard.h in Headers */ = {isa = PBXBuildFile; fileRef = EE3A18641CDE734B00DE4205 /* FBKeyboard.h */; settings = {ATTRIBUTES = (Public, ); }; }; EE3A18671CDE734B00DE4205 /* FBKeyboard.m in Sources */ = {isa = PBXBuildFile; fileRef = EE3A18651CDE734B00DE4205 /* FBKeyboard.m */; }; EE3F8CFE1D08AA17006F02CE /* FBRunLoopSpinnerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE3F8CFD1D08AA17006F02CE /* FBRunLoopSpinnerTests.m */; }; + A09D847635CA4C155583B967 /* FBXCAXClientProxyTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6231A764C3FAAB29B87CD987 /* FBXCAXClientProxyTests.m */; }; EE3F8D001D08B05F006F02CE /* FBElementTypeTransformerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE3F8CFF1D08B05F006F02CE /* FBElementTypeTransformerTests.m */; }; EE5095E51EBCC9090028E2FE /* FBTypingTest.m in Sources */ = {isa = PBXBuildFile; fileRef = AD76723F1D6B826F00610457 /* FBTypingTest.m */; }; EE5095EB1EBCC9090028E2FE /* XCElementSnapshotHitPointTests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE006EB21EBA1C7B006900A4 /* XCElementSnapshotHitPointTests.m */; }; @@ -1827,6 +1828,7 @@ EE3A18641CDE734B00DE4205 /* FBKeyboard.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = FBKeyboard.h; path = WebDriverAgentLib/Utilities/FBKeyboard.h; sourceTree = SOURCE_ROOT; }; EE3A18651CDE734B00DE4205 /* FBKeyboard.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FBKeyboard.m; path = WebDriverAgentLib/Utilities/FBKeyboard.m; sourceTree = SOURCE_ROOT; }; EE3F8CFD1D08AA17006F02CE /* FBRunLoopSpinnerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBRunLoopSpinnerTests.m; sourceTree = ""; }; + 6231A764C3FAAB29B87CD987 /* FBXCAXClientProxyTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBXCAXClientProxyTests.m; sourceTree = ""; }; EE3F8CFF1D08B05F006F02CE /* FBElementTypeTransformerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBElementTypeTransformerTests.m; sourceTree = ""; }; EE5095FE1EBCC9090028E2FE /* IntegrationTests_2.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntegrationTests_2.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EE55B3221D1D5388003AAAEC /* FBTableDataSource.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTableDataSource.h; sourceTree = ""; }; @@ -2683,6 +2685,7 @@ ADEF63AE1D09DEBE0070A7E3 /* FBRuntimeUtilsTests.m */, 714801D01FA9D9FA00DC5997 /* FBSDKVersionTests.m */, EE6A89251D0B19E60083E92B /* FBSessionTests.m */, + 6231A764C3FAAB29B87CD987 /* FBXCAXClientProxyTests.m */, 716E0BD01E917F260087A825 /* FBXMLSafeStringTests.m */, 712A0C841DA3E459007D02E5 /* FBXPathTests.m */, EE9B76581CF7987300275851 /* Info.plist */, @@ -4802,6 +4805,7 @@ files = ( 713352FD26CEF31D00523CBC /* FBLRUCacheTests.m in Sources */, EE3F8CFE1D08AA17006F02CE /* FBRunLoopSpinnerTests.m in Sources */, + A09D847635CA4C155583B967 /* FBXCAXClientProxyTests.m in Sources */, 714801D11FA9D9FA00DC5997 /* FBSDKVersionTests.m in Sources */, EE3F8D001D08B05F006F02CE /* FBElementTypeTransformerTests.m in Sources */, 13FFF2F2287DBEE600E561E4 /* XCElementSnapshotDouble.m in Sources */, diff --git a/WebDriverAgentLib/Categories/XCUIApplicationProcess+FBQuiescence.m b/WebDriverAgentLib/Categories/XCUIApplicationProcess+FBQuiescence.m index 74d971637..3088911e3 100644 --- a/WebDriverAgentLib/Categories/XCUIApplicationProcess+FBQuiescence.m +++ b/WebDriverAgentLib/Categories/XCUIApplicationProcess+FBQuiescence.m @@ -10,11 +10,11 @@ #import -#import "CDStructures.h" #import "FBConfiguration.h" #import "FBExceptions.h" #import "FBLogger.h" #import "FBSettings.h" +#import "FBXCAXClientProxy.h" static void (*original_waitForQuiescenceIncludingAnimationsIdle)(id, SEL, BOOL); static void (*original_waitForQuiescenceIncludingAnimationsIdlePreEvent)(id, SEL, BOOL, BOOL); @@ -29,15 +29,11 @@ static void swizzledWaitForQuiescenceIncludingAnimationsIdle(id self, SEL _cmd, } NSTimeInterval desiredTimeout = FBConfiguration.sharedInstance.waitForIdleTimeout; - NSTimeInterval previousTimeout = _XCTApplicationStateTimeout(); - _XCTSetApplicationStateTimeout(desiredTimeout); [FBLogger logFmt:@"Waiting up to %@s until %@ is in idle state (%@ animations)", @(desiredTimeout), bundleId, includingAnimations ? @"including" : @"excluding"]; - @try { + [FBXCAXClientProxy withApplicationStateTimeout:desiredTimeout do:^{ original_waitForQuiescenceIncludingAnimationsIdle(self, _cmd, includingAnimations); - } @finally { - _XCTSetApplicationStateTimeout(previousTimeout); - } + }]; } static void swizzledWaitForQuiescenceIncludingAnimationsIdlePreEvent(id self, SEL _cmd, BOOL includingAnimations, BOOL isPreEvent) @@ -50,15 +46,11 @@ static void swizzledWaitForQuiescenceIncludingAnimationsIdlePreEvent(id self, SE } NSTimeInterval desiredTimeout = FBConfiguration.sharedInstance.waitForIdleTimeout; - NSTimeInterval previousTimeout = _XCTApplicationStateTimeout(); - _XCTSetApplicationStateTimeout(desiredTimeout); [FBLogger logFmt:@"Waiting up to %@s until %@ is in idle state (%@ animations)", @(desiredTimeout), bundleId, includingAnimations ? @"including" : @"excluding"]; - @try { + [FBXCAXClientProxy withApplicationStateTimeout:desiredTimeout do:^{ original_waitForQuiescenceIncludingAnimationsIdlePreEvent(self, _cmd, includingAnimations, isPreEvent); - } @finally { - _XCTSetApplicationStateTimeout(previousTimeout); - } + }]; } @implementation XCUIApplicationProcess (FBQuiescence) diff --git a/WebDriverAgentLib/Commands/FBSessionCommands.m b/WebDriverAgentLib/Commands/FBSessionCommands.m index 98675ed1c..d5f516aea 100644 --- a/WebDriverAgentLib/Commands/FBSessionCommands.m +++ b/WebDriverAgentLib/Commands/FBSessionCommands.m @@ -22,6 +22,7 @@ #import "FBSettings.h" #import "FBSettingsHandler.h" #import "FBRuntimeUtils.h" +#import "FBXCAXClientProxy.h" #import "FBXCodeCompatibility.h" #import "XCUIApplication+FBHelpers.h" #import "XCUIApplication+FBQuiescence.h" @@ -376,18 +377,22 @@ + (void)applyConfigurationFromCapabilities:(NSDictionary *)capab return errorResponse; } } else { - NSTimeInterval defaultTimeout = _XCTApplicationStateTimeout(); + __block id launchErrorResponse; + void (^launchBlock)(void) = ^{ + @try { + [app launch]; + } @catch (NSException *e) { + launchErrorResponse = FBResponseWithStatus([FBCommandStatus sessionNotCreatedError:e.reason traceback:nil]); + } + }; if (nil != capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC]) { - _XCTSetApplicationStateTimeout([capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC] doubleValue]); + [FBXCAXClientProxy withApplicationStateTimeout:[capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC] doubleValue] + do:launchBlock]; + } else { + launchBlock(); } - @try { - [app launch]; - } @catch (NSException *e) { - return FBResponseWithStatus([FBCommandStatus sessionNotCreatedError:e.reason traceback:nil]); - } @finally { - if (nil != capabilities[FB_CAP_APP_LAUNCH_STATE_TIMEOUT_SEC]) { - _XCTSetApplicationStateTimeout(defaultTimeout); - } + if (nil != launchErrorResponse) { + return launchErrorResponse; } } @@ -486,12 +491,9 @@ + (NSDictionary *)currentCapabilities withApplication:(nullable NSString *)bundleID timeout:(nullable NSNumber *)timeout { - NSError *openError; - NSTimeInterval defaultTimeout = _XCTApplicationStateTimeout(); - if (nil != timeout) { - _XCTSetApplicationStateTimeout([timeout doubleValue]); - } - @try { + __block id response; + void (^openBlock)(void) = ^{ + NSError *openError; BOOL result = nil == bundleID ? [XCUIDevice.sharedDevice fb_openUrl:initialUrl error:&openError] @@ -499,16 +501,18 @@ + (NSDictionary *)currentCapabilities withApplication:(id)bundleID error:&openError]; if (result) { - return nil; + return; } NSString *errorMsg = [NSString stringWithFormat:@"Cannot open the URL %@ with the %@ application. Original error: %@", initialUrl, bundleID ?: @"default", openError.localizedDescription]; - return FBResponseWithStatus([FBCommandStatus sessionNotCreatedError:errorMsg traceback:nil]); - } @finally { - if (nil != timeout) { - _XCTSetApplicationStateTimeout(defaultTimeout); - } + response = FBResponseWithStatus([FBCommandStatus sessionNotCreatedError:errorMsg traceback:nil]); + }; + if (nil != timeout) { + [FBXCAXClientProxy withApplicationStateTimeout:[timeout doubleValue] do:openBlock]; + } else { + openBlock(); } + return response; } @end diff --git a/WebDriverAgentLib/Utilities/FBXCAXClientProxy.h b/WebDriverAgentLib/Utilities/FBXCAXClientProxy.h index 208672af7..2dbb959b8 100644 --- a/WebDriverAgentLib/Utilities/FBXCAXClientProxy.h +++ b/WebDriverAgentLib/Utilities/FBXCAXClientProxy.h @@ -22,8 +22,6 @@ NS_ASSUME_NONNULL_BEGIN + (instancetype)sharedClient; -- (BOOL)setAXTimeout:(NSTimeInterval)timeout error:(NSError **)error; - - (nullable id)snapshotForElement:(id)element attributes:(nullable NSArray *)attributes inDepth:(BOOL)inDepth @@ -53,6 +51,121 @@ NS_ASSUME_NONNULL_BEGIN - (nullable XCUIApplication *)monitoredApplicationWithProcessIdentifier:(int)pid; +/** + Runs `block` synchronously with AXTimeout (the "AXTimeout" property of + XCAXClient_iOS/XCUIAccessibilityInterface, backed by the private `_XCTAXIPCTimeout` + global) temporarily set to `timeout`, restoring the previous value once `block` + returns (even if it throws). Bounds how long a single accessibility (AX) request + issued by this process is allowed to wait for a reply from the AX server. Every + AX-backed call funneled through this proxy - systemApplication, activeApplications, + snapshotForElement:..., attributesForElement:... - is an in-process call into + XCAXClient_iOS and is bounded by this single, process-wide value; there is no + per-call override. Defaults to 60 seconds. + + These XCAXClient_iOS calls are themselves wrapped in + `+[XCTFuture futureWithTimeout:description:block:]`, but that wrapper's own timeout + is `_XCTAXClientWrapperTimeout()` - AXTimeout plus a fixed 5-second margin, not + +withXPCRequestTimeout:do:'s `_XCTXPCRequestTimeout`. That margin exists so the AX + layer's own timeout has a chance to fire and produce a clean error before XCTFuture's + wrapper would cut if off anyway; it is not independently tunable. AXTimeout is + therefore the only knob that matters for every call this proxy wraps. + + Important: this only bounds how long the CALLING thread waits for a reply. The AX + server itself is not told to cancel the request when this timeout elapses - the + request keeps running/queued on the AX side regardless of whether this process gave + up waiting on it. All AX requests from this process share one serial channel to the + AX server, so if the target app's UI is genuinely unresponsive, lowering this value + does not reduce the amount of queued work or make the server itself more responsive - + it only makes each individual caller give up sooner, while requests already abandoned + by their callers keep occupying the channel and can still delay whatever is queued + behind them by their original, un-shortened duration. + + The whole scope is serialized behind a dedicated lock, so overlapping calls from + different threads nest/queue instead of racing to restore the global. + + If installing `timeout` fails, `block` is not run, this returns NO, and `error` (if + given) is populated with the underlying failure. A failure while restoring the + previous value is logged and reported via `error` independently of the return value, + which always reflects `block`'s completion, sampled immediately after it returns and + before the restore attempt. + */ +- (BOOL)withAXTimeout:(NSTimeInterval)timeout do:(void (^)(void))block error:(NSError **)error; + +/** + Runs `block` synchronously with the XCTest automation-session XPC request timeout + (the private `_XCTXPCRequestTimeout`/`_XCTSetXPCRequestTimeout` globals) temporarily + set to `timeout`, restoring the previous value once `block` returns (even if it + throws). Defaults to 30 seconds. + + This does NOT bound anything else in this proxy - despite the similar shape, it is + not the XCTest-level analog of -withAXTimeout:do: for the calls above. AXTimeout and + the XPC request timeout gate two structurally different, non-nested call paths: + XCAXClient_iOS (what this proxy wraps) makes its AX-server round trips in-process, + bounded solely by AXTimeout (see -withAXTimeout:do:); XCTRunnerAutomationSession - + a separate class WDA does not go through here - makes its calls over a real + NSXPCConnection (`remoteObjectProxyWithErrorHandler:`) to another process, bounded by + this timeout instead. `-[XCTRunnerAutomationSession matchesForQuery:error:]` (the + primitive behind XCUIElementQuery/most element-finding lookups) and that class's own, + identically-named `attributesForElement:attributes:error:` are the calls this timeout + actually bounds - not -[XCAXClient_iOS attributesForElement:attributes:error:] above. + + `futureWithTimeout:description:block:` is a synchronous wait wrapper: it starts the + real (asynchronous) XPC request and blocks the calling thread until either the reply + arrives or this timeout elapses, then returns either way - but elapsing the timeout + does NOT cancel the underlying XPC request. It keeps running to completion on the + same serial channel regardless of whether anyone is still waiting on it. + + Practical consequence: all XPC-bounded requests from this process share one queue to + the automation session. If the target app's main thread/run loop is genuinely stuck, + lowering this timeout does not shrink the backlog or make the target more responsive + - it only makes the CALLER give up sooner. A request issued right after an earlier + one "times out" still has to wait behind that earlier request's real completion (which + keeps consuming the channel in the background), so it can take just as long, or longer, + to be serviced - repeatedly retrying after a timeout adds more queued work rather than + freeing up the channel, and can never be used to reliably bound end-to-end latency + while the target is unresponsive. + + A class-level method: it only touches the XPC request timeout global, never the AX + client, so calling it does not trigger AX subsystem initialization. The scope is + serialized behind a dedicated lock, so overlapping calls nest/queue instead of racing + to restore the global. The returned completion result is sampled immediately after + `block` returns and before the previous value is restored. + + Returns YES if `block` returned within `timeout`, NO otherwise. + */ ++ (BOOL)withXPCRequestTimeout:(NSTimeInterval)timeout do:(void (^)(void))block; + +/** + Runs `block` synchronously with the XCTest application-state timeout (the private + `_XCTApplicationStateTimeout`/`_XCTSetApplicationStateTimeout` globals) temporarily + set to `timeout`, restoring the previous value once `block` returns (even if it + throws). Defaults to 60 seconds, though it may be pre-seeded once from a + NSUserDefaults override the first time it is read, before any of this process's own + +withApplicationStateTimeout:do: calls run. + + Unlike AXTimeout/the XPC request timeout above, this one is not scoped to a single + request/response round trip - it bounds `[XCTWaiter waitForExpectations:timeout:]` + inside `-[XCUIApplicationProcess waitForQuiescenceIncludingAnimationsIdle:...]`, + which WDA's own XCUIApplicationProcess+FBQuiescence.m swizzle already targets for the + per-tap pre/post-event quiescence wait. That wait is gated by a *compound OR* + expectation over two independently-notified flags - `eventLoopHasIdled` and (when + requested) `animationsHaveFinished` - so it can return as soon as either one changes, + not necessarily both; this timeout only bounds how long that race is allowed to run + before giving up on both. The same global also bounds XCTest's app-launch/foreground + state-transition waits (see -[FBSessionCommands launchApplication:...], + +[FBSessionCommands openDeepLink:withApplication:timeout:]), which are a different, + non-quiescence consumer of this same timeout. + + A class-level method: it only touches the application-state timeout global, never the + AX client, so calling it does not trigger AX subsystem initialization. The scope is + serialized behind a dedicated lock, so overlapping calls nest/queue instead of racing + to restore the global. The returned completion result is sampled immediately after + `block` returns and before the previous value is restored. + + Returns YES if `block` returned within `timeout`, NO otherwise. + */ ++ (BOOL)withApplicationStateTimeout:(NSTimeInterval)timeout do:(void (^)(void))block; + @end NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBXCAXClientProxy.m b/WebDriverAgentLib/Utilities/FBXCAXClientProxy.m index c81b05586..1c9e3466f 100644 --- a/WebDriverAgentLib/Utilities/FBXCAXClientProxy.m +++ b/WebDriverAgentLib/Utilities/FBXCAXClientProxy.m @@ -8,6 +8,7 @@ #import "FBXCAXClientProxy.h" +#import "CDStructures.h" #import "FBXCAccessibilityElement.h" #import "FBLogger.h" #import "FBMacros.h" @@ -17,6 +18,39 @@ static id FBAXClient = nil; +// Guards -withAXTimeout:do:'s save/set/restore of the process-wide AXTimeout global. +static NSRecursiveLock *FBAXTimeoutLock(void) +{ + static NSRecursiveLock *lock; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + lock = [NSRecursiveLock new]; + }); + return lock; +} + +// Guards +withXPCRequestTimeout:do:'s save/set/restore of the process-wide XPC request timeout global. +static NSRecursiveLock *FBXPCRequestTimeoutLock(void) +{ + static NSRecursiveLock *lock; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + lock = [NSRecursiveLock new]; + }); + return lock; +} + +// Guards +withApplicationStateTimeout:do:'s save/set/restore of the process-wide application-state timeout global. +static NSRecursiveLock *FBApplicationStateTimeoutLock(void) +{ + static NSRecursiveLock *lock; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + lock = [NSRecursiveLock new]; + }); + return lock; +} + @interface FBXCAXClientProxy () @property (nonatomic) NSMutableDictionary *appsCache; @@ -38,9 +72,80 @@ + (instancetype)sharedClient return instance; } -- (BOOL)setAXTimeout:(NSTimeInterval)timeout error:(NSError **)error +- (BOOL)withAXTimeout:(NSTimeInterval)timeout do:(void (^)(void))block error:(NSError **)error +{ + NSRecursiveLock *lock = FBAXTimeoutLock(); + [lock lock]; + @try { + NSTimeInterval previousTimeout = [FBAXClient AXTimeout]; + NSError *setError; + if (![FBAXClient _setAXTimeout:timeout error:&setError]) { + [FBLogger logFmt:@"Failed to set AXTimeout to %@: %@", @(timeout), setError]; + if (nil != error) { + *error = setError; + } + return NO; + } + NSTimeInterval startTime = NSProcessInfo.processInfo.systemUptime; + BOOL completedInTime = NO; + @try { + block(); + completedInTime = (NSProcessInfo.processInfo.systemUptime - startTime) < timeout; + } @finally { + NSError *restoreError; + if (![FBAXClient _setAXTimeout:previousTimeout error:&restoreError]) { + [FBLogger logFmt:@"Failed to restore AXTimeout to %@: %@", @(previousTimeout), restoreError]; + if (nil != error) { + *error = restoreError; + } + } + } + return completedInTime; + } @finally { + [lock unlock]; + } +} + ++ (BOOL)withXPCRequestTimeout:(NSTimeInterval)timeout do:(void (^)(void))block +{ + NSRecursiveLock *lock = FBXPCRequestTimeoutLock(); + [lock lock]; + @try { + NSTimeInterval previousTimeout = _XCTXPCRequestTimeout(); + _XCTSetXPCRequestTimeout(timeout); + NSTimeInterval startTime = NSProcessInfo.processInfo.systemUptime; + BOOL completedInTime = NO; + @try { + block(); + completedInTime = (NSProcessInfo.processInfo.systemUptime - startTime) < timeout; + } @finally { + _XCTSetXPCRequestTimeout(previousTimeout); + } + return completedInTime; + } @finally { + [lock unlock]; + } +} + ++ (BOOL)withApplicationStateTimeout:(NSTimeInterval)timeout do:(void (^)(void))block { - return [FBAXClient _setAXTimeout:timeout error:error]; + NSRecursiveLock *lock = FBApplicationStateTimeoutLock(); + [lock lock]; + @try { + NSTimeInterval previousTimeout = _XCTApplicationStateTimeout(); + _XCTSetApplicationStateTimeout(timeout); + NSTimeInterval startTime = NSProcessInfo.processInfo.systemUptime; + BOOL completedInTime = NO; + @try { + block(); + completedInTime = (NSProcessInfo.processInfo.systemUptime - startTime) < timeout; + } @finally { + _XCTSetApplicationStateTimeout(previousTimeout); + } + return completedInTime; + } @finally { + [lock unlock]; + } } - (id)snapshotForElement:(id)element diff --git a/WebDriverAgentTests/UnitTests/FBXCAXClientProxyTests.m b/WebDriverAgentTests/UnitTests/FBXCAXClientProxyTests.m new file mode 100644 index 000000000..073d2abef --- /dev/null +++ b/WebDriverAgentTests/UnitTests/FBXCAXClientProxyTests.m @@ -0,0 +1,90 @@ +/** + * 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 "CDStructures.h" +#import "FBXCAXClientProxy.h" + +@interface FBXCAXClientProxyTests : XCTestCase +@end + +@implementation FBXCAXClientProxyTests + +- (void)testApplicationStateTimeoutSetsValueDuringBlockAndRestoresAfter +{ + double original = _XCTApplicationStateTimeout(); + __block double observed = -1; + BOOL completed = [FBXCAXClientProxy withApplicationStateTimeout:12.5 do:^{ + observed = _XCTApplicationStateTimeout(); + }]; + XCTAssertTrue(completed); + XCTAssertEqual(observed, 12.5); + XCTAssertEqual(_XCTApplicationStateTimeout(), original); +} + +- (void)testApplicationStateTimeoutNestedCallsRestoreCorrectly +{ + double original = _XCTApplicationStateTimeout(); + [FBXCAXClientProxy withApplicationStateTimeout:20 do:^{ + XCTAssertEqual(_XCTApplicationStateTimeout(), 20); + [FBXCAXClientProxy withApplicationStateTimeout:5 do:^{ + XCTAssertEqual(_XCTApplicationStateTimeout(), 5); + }]; + XCTAssertEqual(_XCTApplicationStateTimeout(), 20); + }]; + XCTAssertEqual(_XCTApplicationStateTimeout(), original); +} + +- (void)testApplicationStateTimeoutOverlappingCallsRestoreOriginalValue +{ + double original = _XCTApplicationStateTimeout(); + NSInteger iterations = 50; + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0); + for (NSInteger i = 0; i < iterations; i++) { + dispatch_group_async(group, queue, ^{ + [FBXCAXClientProxy withApplicationStateTimeout:10 + (double)i do:^{ + usleep(500); + }]; + }); + } + dispatch_group_wait(group, DISPATCH_TIME_FOREVER); + XCTAssertEqual(_XCTApplicationStateTimeout(), original); +} + +- (void)testXPCRequestTimeoutSetsValueDuringBlockAndRestoresAfter +{ + double original = _XCTXPCRequestTimeout(); + __block double observed = -1; + BOOL completed = [FBXCAXClientProxy withXPCRequestTimeout:7.5 do:^{ + observed = _XCTXPCRequestTimeout(); + }]; + XCTAssertTrue(completed); + XCTAssertEqual(observed, 7.5); + XCTAssertEqual(_XCTXPCRequestTimeout(), original); +} + +- (void)testXPCRequestTimeoutOverlappingCallsRestoreOriginalValue +{ + double original = _XCTXPCRequestTimeout(); + NSInteger iterations = 50; + dispatch_group_t group = dispatch_group_create(); + dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0); + for (NSInteger i = 0; i < iterations; i++) { + dispatch_group_async(group, queue, ^{ + [FBXCAXClientProxy withXPCRequestTimeout:10 + (double)i do:^{ + usleep(500); + }]; + }); + } + dispatch_group_wait(group, DISPATCH_TIME_FOREVER); + XCTAssertEqual(_XCTXPCRequestTimeout(), original); +} + +@end From 23c0ad081d9d0f3ad44a54938f78fe1f3a747daf Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 29 Aug 2026 06:52:16 +0000 Subject: [PATCH 18/46] chore(release): 16.11.0 [skip ci] ## [16.11.0](https://github.com/appium/WebDriverAgent/compare/v16.10.0...v16.11.0) (2026-08-29) ### Features * expose AXTimeout and XCTest XPC request timeout wrappers ([#1233](https://github.com/appium/WebDriverAgent/issues/1233)) ([d5c8571](https://github.com/appium/WebDriverAgent/commit/d5c85718c32bc495a0f73518d3e40eee624b3582)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6dcaa019..0a0bdd3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.11.0](https://github.com/appium/WebDriverAgent/compare/v16.10.0...v16.11.0) (2026-08-29) + +### Features + +* expose AXTimeout and XCTest XPC request timeout wrappers ([#1233](https://github.com/appium/WebDriverAgent/issues/1233)) ([d5c8571](https://github.com/appium/WebDriverAgent/commit/d5c85718c32bc495a0f73518d3e40eee624b3582)) + ## [16.10.0](https://github.com/appium/WebDriverAgent/compare/v16.9.4...v16.10.0) (2026-08-29) ### Features diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 2041d6572..4be717b88 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.10.0 + 16.11.0 CFBundleSignature ???? CFBundleVersion - 16.10.0 + 16.11.0 NSPrincipalClass diff --git a/package.json b/package.json index c69e420bc..f46621c89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.10.0", + "version": "16.11.0", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From a3b8650670b9b30c5fc0cd5aa4834a6774d4a828 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Sun, 30 Aug 2026 00:14:33 +0900 Subject: [PATCH 19/46] fix: build of FBMjpegServer (#1239) --- WebDriverAgentLib/Utilities/FBMjpegServer.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebDriverAgentLib/Utilities/FBMjpegServer.m b/WebDriverAgentLib/Utilities/FBMjpegServer.m index d7170579f..01f931321 100644 --- a/WebDriverAgentLib/Utilities/FBMjpegServer.m +++ b/WebDriverAgentLib/Utilities/FBMjpegServer.m @@ -165,7 +165,7 @@ - (void)sendScreenshot:(NSData *)screenshotData { continue; } [self.pendingFrameCounts setObject:@(pendingFrames + 1) forKey:client]; - [self.socket writeData:chunk toClient:client completion:^{ + [self.socket writeData:chunk toClient:client completion:^(BOOL didSucceed) { __strong typeof(weakSelf) strongSelf = weakSelf; if (nil == strongSelf) { return; From 2096cb841e3f2151d534fd3dfc718f879567c670 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sat, 29 Aug 2026 15:35:58 +0000 Subject: [PATCH 20/46] chore(release): 16.11.1 [skip ci] ## [16.11.1](https://github.com/appium/WebDriverAgent/compare/v16.11.0...v16.11.1) (2026-08-29) ### Bug Fixes * build of FBMjpegServer ([#1239](https://github.com/appium/WebDriverAgent/issues/1239)) ([a3b8650](https://github.com/appium/WebDriverAgent/commit/a3b8650670b9b30c5fc0cd5aa4834a6774d4a828)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a0bdd3cd..e9b4e07ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.11.1](https://github.com/appium/WebDriverAgent/compare/v16.11.0...v16.11.1) (2026-08-29) + +### Bug Fixes + +* build of FBMjpegServer ([#1239](https://github.com/appium/WebDriverAgent/issues/1239)) ([a3b8650](https://github.com/appium/WebDriverAgent/commit/a3b8650670b9b30c5fc0cd5aa4834a6774d4a828)) + ## [16.11.0](https://github.com/appium/WebDriverAgent/compare/v16.10.0...v16.11.0) (2026-08-29) ### Features diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 4be717b88..2e200ff50 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.11.0 + 16.11.1 CFBundleSignature ???? CFBundleVersion - 16.11.0 + 16.11.1 NSPrincipalClass diff --git a/package.json b/package.json index f46621c89..093bf4608 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.11.0", + "version": "16.11.1", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From 8bcf451e853d0f94b89b52ec478c6b24a9fc516b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= <37242620+eglitise@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:37:05 +0300 Subject: [PATCH 21/46] ci: update iPad simulator name on xcode-27 image (#1241) Co-authored-by: Kazuaki Matsuo --- .github/workflows/wda-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wda-tests.yml b/.github/workflows/wda-tests.yml index 9c13ebe35..2b6e1747b 100644 --- a/.github/workflows/wda-tests.yml +++ b/.github/workflows/wda-tests.yml @@ -24,7 +24,7 @@ env: MAX_WATCH_DEVICE_NAME: "Apple Watch Series 11 (46mm)" MAX_IPHONE_DEVICE_NAME: "iPhone 17" MAX_TV_DEVICE_NAME: "Apple TV 4K (3rd generation)" - MAX_IPAD_DEVICE_NAME: "iPad Air 11-inch (M2)" + MAX_IPAD_DEVICE_NAME: "iPad Air 11-inch (M4)" jobs: build_matrix: From d6b4862ea113083959a80e87c488fd555cbc8061 Mon Sep 17 00:00:00 2001 From: Timo <44401485+Timo972@users.noreply.github.com> Date: Sun, 30 Aug 2026 05:53:16 +0200 Subject: [PATCH 22/46] fix: reject requests admitted after their session was abandoned (#1229) * fix: reject requests admitted after their session was abandoned Co-Authored-By: Claude Fable 5 * chore: make comments more concise * fix: keep abandoned session ids for the server lifetime Evicting a tombstone let a later stale request for that session be treated as live and queued on a possibly wedged route queue again - the hang this rejection exists to prevent. Session ids are UUIDs, so retained ids can never match a live session. * chore: shorten the -trackPendingRequest: comment --------- Co-authored-by: Claude Fable 5 Co-authored-by: Kazuaki Matsuo --- WebDriverAgent.xcodeproj/project.pbxproj | 4 + WebDriverAgentLib/Routing/FBHTTPServer.m | 28 +++- .../UnitTests/FBHTTPServerSessionTests.m | 133 ++++++++++++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 WebDriverAgentTests/UnitTests/FBHTTPServerSessionTests.m diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index 62618ccfd..b8b7c03c5 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -1191,6 +1191,7 @@ EE8DDD7F20C5733C004D4925 /* XCUIElement+FBForceTouch.h in Headers */ = {isa = PBXBuildFile; fileRef = EE8DDD7D20C5733C004D4925 /* XCUIElement+FBForceTouch.h */; settings = {ATTRIBUTES = (Public, ); }; }; EE9AB8011CAEE048008C271F /* UITestingUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9AB7FD1CAEE048008C271F /* UITestingUITests.m */; }; EE9B76591CF7987800275851 /* FBRouteTests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76571CF7987300275851 /* FBRouteTests.m */; }; + C312172CE7EE9B10B7A3034B /* FBHTTPServerSessionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FB741D78562232A78902B374 /* FBHTTPServerSessionTests.m */; }; DF82F4A8758B79DD91FA20CC /* FBHTTPServerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 76B7399DDF52C85A21433C1D /* FBHTTPServerTests.m */; }; EE9B768E1CF7997600275851 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76831CF7997600275851 /* AppDelegate.m */; }; EE9B768F1CF7997600275851 /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76851CF7997600275851 /* ViewController.m */; }; @@ -1908,6 +1909,7 @@ EE9B75D41CF7956C00275851 /* IntegrationApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IntegrationApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; EE9B75EC1CF7956C00275851 /* IntegrationTests_1.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntegrationTests_1.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EE9B76571CF7987300275851 /* FBRouteTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBRouteTests.m; sourceTree = ""; }; + FB741D78562232A78902B374 /* FBHTTPServerSessionTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBHTTPServerSessionTests.m; sourceTree = ""; }; 76B7399DDF52C85A21433C1D /* FBHTTPServerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBHTTPServerTests.m; sourceTree = ""; }; EE9B76581CF7987300275851 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; EE9B76821CF7997600275851 /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; @@ -2679,6 +2681,7 @@ 713352FC26CEF31D00523CBC /* FBLRUCacheTests.m */, EE18883C1DA663EB00307AA8 /* FBMathUtilsTests.m */, 718F49C7230844330045FE8B /* FBProtocolHelpersTests.m */, + FB741D78562232A78902B374 /* FBHTTPServerSessionTests.m */, 76B7399DDF52C85A21433C1D /* FBHTTPServerTests.m */, EE9B76571CF7987300275851 /* FBRouteTests.m */, EE3F8CFD1D08AA17006F02CE /* FBRunLoopSpinnerTests.m */, @@ -4816,6 +4819,7 @@ 719FF5B91DAD21F5008E0099 /* FBElementUtilitiesTests.m in Sources */, 716E0BD11E917F260087A825 /* FBXMLSafeStringTests.m in Sources */, ADEF63AF1D09DEBE0070A7E3 /* FBRuntimeUtilsTests.m in Sources */, + C312172CE7EE9B10B7A3034B /* FBHTTPServerSessionTests.m in Sources */, DF82F4A8758B79DD91FA20CC /* FBHTTPServerTests.m in Sources */, EE9B76591CF7987800275851 /* FBRouteTests.m in Sources */, 7139145C1DF01A12005896C2 /* NSExpressionFBFormatTests.m in Sources */, diff --git a/WebDriverAgentLib/Routing/FBHTTPServer.m b/WebDriverAgentLib/Routing/FBHTTPServer.m index ce824031b..175b8f9b4 100644 --- a/WebDriverAgentLib/Routing/FBHTTPServer.m +++ b/WebDriverAgentLib/Routing/FBHTTPServer.m @@ -131,6 +131,10 @@ @interface FBHTTPServer () // standalone or not (except DELETE /session itself - see -dispatchMethod:). See // -abandonPendingRequestsForSessionID:. Guarded by @synchronized(self.pendingSessionRequests). @property (nonatomic, strong) NSMutableDictionary *> *pendingSessionRequests; +// Already-abandoned sessions mapped to the response they were abandoned with, so a request parsed +// after that point is answered at once instead of queueing for a session that is gone. Kept for +// the server's lifetime; ids are UUIDs. Guarded by @synchronized(self.pendingSessionRequests). +@property (nonatomic, strong) NSMutableDictionary *abandonedSessionResponses; // When each connection started waiting for its current request. The reaper closes connections // whose entry outlives FBIncompleteRequestTimeout; idle keep-alive connections have no entry and // are exempt. Guarded by @synchronized(self.connectionBuffers). @@ -159,6 +163,7 @@ - (instancetype)init _connectionsAwaitingResponse = [NSMutableSet set]; _standaloneWaiters = [NSMutableDictionary dictionary]; _pendingSessionRequests = [NSMutableDictionary dictionary]; + _abandonedSessionResponses = [NSMutableDictionary dictionary]; _incompleteRequestStarts = [NSMapTable mapTableWithKeyOptions:(NSPointerFunctionsOptions)(NSMapTableObjectPointerPersonality | NSMapTableStrongMemory) valueOptions:(NSPointerFunctionsOptions)NSMapTableStrongMemory]; } @@ -678,7 +683,11 @@ - (void)dispatchMethod:(NSString *)method pathAndQuery:(NSString *)pathAndQuery FBPendingRequest *pendingRequest = nil; if (nil != sessionID) { pendingRequest = [[FBPendingRequest alloc] initWithClient:client]; - [self trackPendingRequest:pendingRequest forSessionID:sessionID]; + RouteResponse *abandonedResponse = [self trackPendingRequest:pendingRequest forSessionID:sessionID]; + if (nil != abandonedResponse) { + [self writeResponse:abandonedResponse toClient:client]; + return; + } } void (^invoke)(void) = ^{ @@ -709,15 +718,22 @@ - (void)dispatchMethod:(NSString *)method pathAndQuery:(NSString *)pathAndQuery #pragma mark - Session-scoped request cancellation -- (void)trackPendingRequest:(FBPendingRequest *)pendingRequest forSessionID:(NSString *)sessionID +// Returns nil once `pendingRequest` is tracked, or the response an already-abandoned session was +// abandoned with, which the caller must deliver instead of dispatching. +- (nullable RouteResponse *)trackPendingRequest:(FBPendingRequest *)pendingRequest forSessionID:(NSString *)sessionID { @synchronized (self.pendingSessionRequests) { + RouteResponse *abandonedResponse = self.abandonedSessionResponses[sessionID]; + if (nil != abandonedResponse) { + return abandonedResponse; + } NSMutableSet *pendingRequests = self.pendingSessionRequests[sessionID]; if (nil == pendingRequests) { pendingRequests = [NSMutableSet set]; self.pendingSessionRequests[sessionID] = pendingRequests; } [pendingRequests addObject:pendingRequest]; + return nil; } } @@ -744,6 +760,8 @@ - (void)abandonPendingRequestsForSessionID:(NSString *)sessionID withResponse:(R @synchronized (self.pendingSessionRequests) { pendingRequests = [self.pendingSessionRequests[sessionID] copy]; [self.pendingSessionRequests removeObjectForKey:sessionID]; + // Recorded before the lock is dropped, so requests admitted from here on are rejected. + self.abandonedSessionResponses[sessionID] = response; } for (FBPendingRequest *pendingRequest in pendingRequests) { [self writeResponse:response toClient:pendingRequest.client]; @@ -764,7 +782,11 @@ - (void)dispatchStandaloneRoute:(FBHTTPRoute *)route NSString *key = [NSString stringWithFormat:@"%@ %@", method, pathAndQuery]; FBPendingRequest *waiter = [[FBPendingRequest alloc] initWithClient:client]; if (nil != sessionID) { - [self trackPendingRequest:waiter forSessionID:sessionID]; + RouteResponse *abandonedResponse = [self trackPendingRequest:waiter forSessionID:sessionID]; + if (nil != abandonedResponse) { + [self writeResponse:abandonedResponse toClient:client]; + return; + } } BOOL isInFlight = NO; diff --git a/WebDriverAgentTests/UnitTests/FBHTTPServerSessionTests.m b/WebDriverAgentTests/UnitTests/FBHTTPServerSessionTests.m new file mode 100644 index 000000000..9e0bb1c46 --- /dev/null +++ b/WebDriverAgentTests/UnitTests/FBHTTPServerSessionTests.m @@ -0,0 +1,133 @@ +/** + * 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 +#import +#import + +#import "FBHTTPServer.h" + +static atomic_int gSessionProbeHits; + +@interface FBHTTPServerSessionTests : XCTestCase +@property (nonatomic, strong) FBHTTPServer *server; +@property (nonatomic, assign) uint16_t port; +@end + +@implementation FBHTTPServerSessionTests + +- (void)setUp +{ + [super setUp]; + atomic_store(&gSessionProbeHits, 0); + self.server = [FBHTTPServer new]; + [self.server get:@"/session/:sessionID/probe" withBlock:^(RouteRequest *request, RouteResponse *response) { + atomic_fetch_add(&gSessionProbeHits, 1); + [response respondWithString:@"session-probe-ok"]; + }]; + self.server.port = 0; + NSError *error; + XCTAssertTrue([self.server start:&error], @"%@", error); + self.port = [[self.server valueForKeyPath:@"socket.port"] unsignedShortValue]; +} + +- (void)tearDown +{ + [self.server stop:NO]; + self.server = nil; + [super tearDown]; +} + +- (NSString *)responseForRawPayload:(NSData *)payload timeout:(NSTimeInterval)timeout +{ + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + return nil; + } + int noSigpipe = 1; + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigpipe, sizeof(noSigpipe)); + struct timeval tv = { .tv_sec = (long)timeout, .tv_usec = 0 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(self.port) }; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (0 != connect(fd, (struct sockaddr *)&addr, sizeof(addr))) { + close(fd); + return nil; + } + send(fd, payload.bytes, payload.length, 0); + NSMutableData *received = [NSMutableData data]; + char chunk[4096]; + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeout]; + while (deadline.timeIntervalSinceNow > 0) { + ssize_t n = recv(fd, chunk, sizeof(chunk), 0); + if (n > 0) { + [received appendBytes:chunk length:(NSUInteger)n]; + // The server keeps the connection open after a success, so don't wait the full timeout + // for an EOF that never comes. + struct timeval drainTv = { .tv_sec = 0, .tv_usec = 200000 }; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &drainTv, sizeof(drainTv)); + } else { + break; + } + } + close(fd); + return [[NSString alloc] initWithData:received encoding:NSUTF8StringEncoding] ?: @""; +} + +- (void)testRequestForAlreadyAbandonedSessionIsRejectedImmediately +{ + // A request parsed *after* DELETE /session tore the session down never receives an abandonment + // notification of its own, so before this was tracked it queued on the route queue - + // potentially forever, if that queue is wedged behind the very request that made the client + // delete the session in the first place. + RouteResponse *abandonedResponse = [RouteResponse new]; + [abandonedResponse respondWithString:@"session-was-deleted"]; + [self.server abandonPendingRequestsForSessionID:@"dead-session" withResponse:abandonedResponse]; + + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[@"GET /session/dead-session/probe HTTP/1.1\r\n\r\n" dataUsingEncoding:NSUTF8StringEncoding] + timeout:5.0]; + XCTAssertTrue([response containsString:@"session-was-deleted"], @"%@", response); + XCTAssertEqual(atomic_load(&gSessionProbeHits), 0, @"the route must not run for a deleted session"); +} + +- (void)testAbandonedSessionIsRememberedAfterManyLaterAbandonments +{ + // Abandoned ids are kept for the server's lifetime; evicting them would let a stale request + // queue on a possibly wedged route queue again, which is the hang this rejection prevents. + RouteResponse *abandonedResponse = [RouteResponse new]; + [abandonedResponse respondWithString:@"session-was-deleted"]; + [self.server abandonPendingRequestsForSessionID:@"dead-session" withResponse:abandonedResponse]; + for (NSUInteger index = 0; index < 64; ++index) { + RouteResponse *otherResponse = [RouteResponse new]; + [otherResponse respondWithString:@"other-session-was-deleted"]; + [self.server abandonPendingRequestsForSessionID:[NSString stringWithFormat:@"other-session-%lu", (unsigned long)index] + withResponse:otherResponse]; + } + + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[@"GET /session/dead-session/probe HTTP/1.1\r\n\r\n" dataUsingEncoding:NSUTF8StringEncoding] + timeout:5.0]; + XCTAssertTrue([response containsString:@"session-was-deleted"], @"%@", response); + XCTAssertEqual(atomic_load(&gSessionProbeHits), 0, @"the route must not run for a deleted session"); +} + +- (void)testRequestForLiveSessionIsStillServed +{ + // The rejection above must be scoped to the abandoned identifier only. + RouteResponse *abandonedResponse = [RouteResponse new]; + [abandonedResponse respondWithString:@"session-was-deleted"]; + [self.server abandonPendingRequestsForSessionID:@"dead-session" withResponse:abandonedResponse]; + + NSString *response = [self responseForRawPayload:(NSData * _Nonnull)[@"GET /session/live-session/probe HTTP/1.1\r\n\r\n" dataUsingEncoding:NSUTF8StringEncoding] + timeout:5.0]; + XCTAssertTrue([response containsString:@"session-probe-ok"], @"%@", response); + XCTAssertEqual(atomic_load(&gSessionProbeHits), 1); +} + +@end From f933ed406e3ef117b88d5946d9c1c47444222a4b Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 30 Aug 2026 03:57:03 +0000 Subject: [PATCH 23/46] chore(release): 16.11.2 [skip ci] ## [16.11.2](https://github.com/appium/WebDriverAgent/compare/v16.11.1...v16.11.2) (2026-08-30) ### Bug Fixes * reject requests admitted after their session was abandoned ([#1229](https://github.com/appium/WebDriverAgent/issues/1229)) ([d6b4862](https://github.com/appium/WebDriverAgent/commit/d6b4862ea113083959a80e87c488fd555cbc8061)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9b4e07ed..be1340b61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.11.2](https://github.com/appium/WebDriverAgent/compare/v16.11.1...v16.11.2) (2026-08-30) + +### Bug Fixes + +* reject requests admitted after their session was abandoned ([#1229](https://github.com/appium/WebDriverAgent/issues/1229)) ([d6b4862](https://github.com/appium/WebDriverAgent/commit/d6b4862ea113083959a80e87c488fd555cbc8061)) + ## [16.11.1](https://github.com/appium/WebDriverAgent/compare/v16.11.0...v16.11.1) (2026-08-29) ### Bug Fixes diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 2e200ff50..6ea5335e7 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.11.1 + 16.11.2 CFBundleSignature ???? CFBundleVersion - 16.11.1 + 16.11.2 NSPrincipalClass diff --git a/package.json b/package.json index 093bf4608..44362bfe6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.11.1", + "version": "16.11.2", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From b8ee694adacb8b7a835becdbc36bdee1c5a6f684 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= <37242620+eglitise@users.noreply.github.com> Date: Sun, 30 Aug 2026 07:05:40 +0300 Subject: [PATCH 24/46] chore: bump base-driver & support (#1240) * chore: bump base-driver * chore: bump support --------- Co-authored-by: Kazuaki Matsuo --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 44362bfe6..f775a88b0 100644 --- a/package.json +++ b/package.json @@ -64,9 +64,9 @@ "sync-wda-version": "node ./Scripts/update-wda-version.mjs --package-version=${npm_package_version} && git add WebDriverAgentLib/Info.plist" }, "dependencies": { - "@appium/base-driver": "^10.3.0", + "@appium/base-driver": "^10.8.0", "@appium/strongbox": "^2.0.0", - "@appium/support": "^7.2.1", + "@appium/support": "^7.2.6", "appium-ios-simulator": "^9.0.0", "async-lock": "^1.0.0", "asyncbox": "^6.1.0", From 050be1e9eb01b39d082d2922938e72c63bdfbeb1 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 30 Aug 2026 04:38:03 +0000 Subject: [PATCH 25/46] chore(release): 16.11.3 [skip ci] ## [16.11.3](https://github.com/appium/WebDriverAgent/compare/v16.11.2...v16.11.3) (2026-08-30) ### Miscellaneous Chores * bump base-driver & support ([#1240](https://github.com/appium/WebDriverAgent/issues/1240)) ([b8ee694](https://github.com/appium/WebDriverAgent/commit/b8ee694adacb8b7a835becdbc36bdee1c5a6f684)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be1340b61..b70ea2513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.11.3](https://github.com/appium/WebDriverAgent/compare/v16.11.2...v16.11.3) (2026-08-30) + +### Miscellaneous Chores + +* bump base-driver & support ([#1240](https://github.com/appium/WebDriverAgent/issues/1240)) ([b8ee694](https://github.com/appium/WebDriverAgent/commit/b8ee694adacb8b7a835becdbc36bdee1c5a6f684)) + ## [16.11.2](https://github.com/appium/WebDriverAgent/compare/v16.11.1...v16.11.2) (2026-08-30) ### Bug Fixes diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 6ea5335e7..32e8ef370 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.11.2 + 16.11.3 CFBundleSignature ???? CFBundleVersion - 16.11.2 + 16.11.3 NSPrincipalClass diff --git a/package.json b/package.json index f775a88b0..0577c934f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.11.2", + "version": "16.11.3", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From 83642a11218fd2b2801af0a04d3b253125246bd7 Mon Sep 17 00:00:00 2001 From: Timo <44401485+Timo972@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:31:02 +0200 Subject: [PATCH 26/46] fix: prevent a stale teardown from affecting a newer session generation (#1231) --- .../Routing/FBScreenRecordingContainer.h | 9 ++ .../Routing/FBScreenRecordingContainer.m | 40 +++-- WebDriverAgentLib/Routing/FBSession.h | 3 + WebDriverAgentLib/Routing/FBSession.m | 137 ++++++++++++++---- 4 files changed, 149 insertions(+), 40 deletions(-) diff --git a/WebDriverAgentLib/Routing/FBScreenRecordingContainer.h b/WebDriverAgentLib/Routing/FBScreenRecordingContainer.h index ce655dd7c..eb1c51216 100644 --- a/WebDriverAgentLib/Routing/FBScreenRecordingContainer.h +++ b/WebDriverAgentLib/Routing/FBScreenRecordingContainer.h @@ -44,6 +44,15 @@ NS_ASSUME_NONNULL_BEGIN */ - (void)reset; +/** + Resets the container, but only if it still keeps the given promise. The comparison and the + reset are performed atomically, so a promise stored concurrently is never dropped. + + @param screenRecordingPromise the promise the caller expects to be still active + @return YES if the container has been reset + */ +- (BOOL)resetIfPromiseIs:(FBScreenRecordingPromise *)screenRecordingPromise; + /** Transforms the container content to a dictionary. diff --git a/WebDriverAgentLib/Routing/FBScreenRecordingContainer.m b/WebDriverAgentLib/Routing/FBScreenRecordingContainer.m index 608d7bfdf..31bf9fc15 100644 --- a/WebDriverAgentLib/Routing/FBScreenRecordingContainer.m +++ b/WebDriverAgentLib/Routing/FBScreenRecordingContainer.m @@ -36,23 +36,39 @@ - (void)storeScreenRecordingPromise:(FBScreenRecordingPromise *)screenRecordingP fps:(NSUInteger)fps codec:(long long)codec; { - self.fps = fps; - self.codec = codec; - self.screenRecordingPromise = screenRecordingPromise; - self.startedAt = @([NSDate.date timeIntervalSince1970]); + @synchronized (self) { + self.fps = fps; + self.codec = codec; + self.screenRecordingPromise = screenRecordingPromise; + self.startedAt = @([NSDate.date timeIntervalSince1970]); + } } - (void)reset; { - self.fps = 0; - self.codec = 0; - if (nil != self.screenRecordingPromise) { - [XCTContext runActivityNamed:@"Video Cleanup" block:^(id activity){ - [activity addAttachment:(XCTAttachment *)self.screenRecordingPromise.nativePromise]; - }]; - self.screenRecordingPromise = nil; + @synchronized (self) { + self.fps = 0; + self.codec = 0; + if (nil != self.screenRecordingPromise) { + [XCTContext runActivityNamed:@"Video Cleanup" block:^(id activity){ + [activity addAttachment:(XCTAttachment *)self.screenRecordingPromise.nativePromise]; + }]; + self.screenRecordingPromise = nil; + } + self.startedAt = nil; + } +} + +- (BOOL)resetIfPromiseIs:(FBScreenRecordingPromise *)screenRecordingPromise +{ + // @synchronized is recursive, so -reset may take the very same lock again below. + @synchronized (self) { + if (self.screenRecordingPromise != screenRecordingPromise) { + return NO; + } + [self reset]; + return YES; } - self.startedAt = nil; } - (nullable NSDictionary *)toDictionary diff --git a/WebDriverAgentLib/Routing/FBSession.h b/WebDriverAgentLib/Routing/FBSession.h index 821891ea3..406ebfc10 100644 --- a/WebDriverAgentLib/Routing/FBSession.h +++ b/WebDriverAgentLib/Routing/FBSession.h @@ -54,6 +54,9 @@ extern NSString* const FBSessionWasKilledNotification; Kills the active session, if any, and blocks until its teardown - including one already started by a concurrent caller - is fully finished. Call this before preparing/launching a replacement application, so it can't race a still-in-progress termination of the outgoing one. + + @throws FBSessionCreationException if the outgoing application's termination is still in flight + after the wait, since a replacement must never start while that termination can still land. */ + (void)killActiveSessionAndWaitForTeardown; diff --git a/WebDriverAgentLib/Routing/FBSession.m b/WebDriverAgentLib/Routing/FBSession.m index 34a80db0e..1eac8bcc7 100644 --- a/WebDriverAgentLib/Routing/FBSession.m +++ b/WebDriverAgentLib/Routing/FBSession.m @@ -50,7 +50,7 @@ @interface FBSession () @property (nonatomic, readwrite) NSMutableDictionary *> *elementsVisibilityCache; - (BOOL)fb_isTestedApplicationSameAsSystemAppWithTimeout:(NSTimeInterval)timeout; -- (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout; +- (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout generation:(NSUInteger)generation; @end @interface FBSession (FBAlertsMonitorDelegate) @@ -100,11 +100,20 @@ - (void)didDetectAlert:(FBAlert *)alert @implementation FBSession +// Guarded, together with the two counters below, by +teardownCondition. static FBSession *_activeSession = nil; // Class-level, not per-instance: a caller that finds _activeSession already nil (a concurrent // -kill beat it there) still needs to know whether that -kill's teardown is done, since it cleared // the pointer before running it. See +waitForActiveTeardownWithTimeout:. -static BOOL _isTeardownInProgress = NO; +// A count, not a flag: the bounded wait below lets teardowns overlap, so one of them finishing +// must not wake waiters while another still runs. +static NSUInteger _activeTeardownCount = 0; +// Bumped once a caller owns the device, before it launches anything; a teardown still running past +// the bounded wait re-checks it before touching process-wide state. +static NSUInteger _sessionGeneration = 0; +// Teardowns that have claimed the current generation and are committed to terminating the app. +// The generation bump waits for these to drain, so a claim and a bump can never interleave. +static NSUInteger _committedTerminationCount = 0; + (NSCondition *)teardownCondition { @@ -116,25 +125,29 @@ + (NSCondition *)teardownCondition return condition; } -// Waits (bounded) for any -kill teardown currently in progress to finish. +// Waits (bounded) for every -kill teardown currently in progress to finish. + (void)waitForActiveTeardownWithTimeout:(NSTimeInterval)timeout { NSCondition *condition = self.teardownCondition; [condition lock]; NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:timeout]; - while (_isTeardownInProgress && [condition waitUntilDate:deadline]) { + while (_activeTeardownCount > 0 && [condition waitUntilDate:deadline]) { } [condition unlock]; } + (instancetype)activeSession { - return _activeSession; + NSCondition *condition = self.teardownCondition; + [condition lock]; + FBSession *session = _activeSession; + [condition unlock]; + return session; } + (void)killActiveSessionAndWaitForTeardown { - FBSession *session = _activeSession; + FBSession *session = self.activeSession; if (nil != session) { // Runs the real teardown synchronously if this call wins the race in -kill, or waits for // whoever did to finish if it lost - either way, blocks until torn down. @@ -144,12 +157,69 @@ + (void)killActiveSessionAndWaitForTeardown // be mid-teardown - wait for it, so we don't launch a replacement app too early. [self waitForActiveTeardownWithTimeout:FB_KILL_WAIT_TIMEOUT_SEC]; } + // Claimed before the caller launches its replacement app: if the bounded wait expired with a + // teardown still running, that teardown must be stale by the time the new app exists. + NSCondition *condition = self.teardownCondition; + [condition lock]; + // A committed -terminate cannot be revoked, so the next generation must never be handed out + // while one is in flight - give up on the new session instead of racing it. + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:FB_APP_TERMINATE_TIMEOUT_SEC]; + while (_committedTerminationCount > 0 && [condition waitUntilDate:deadline]) { + } + BOOL isTerminationPending = _committedTerminationCount > 0; + if (!isTerminationPending) { + _sessionGeneration++; + } + [condition unlock]; + if (isTerminationPending) { + NSString *reason = [NSString stringWithFormat:@"The termination of a previous session's application is still in progress after %@ seconds. Please retry the session creation later", @(FB_APP_TERMINATE_TIMEOUT_SEC)]; + @throw [NSException exceptionWithName:FBSessionCreationException reason:reason userInfo:nil]; + } } + (void)markSessionActive:(FBSession *)session { [self killActiveSessionAndWaitForTeardown]; + NSCondition *condition = self.teardownCondition; + [condition lock]; _activeSession = session; + [condition unlock]; +} + +// Validates and claims `generation` in one critical section, so no replacement can be handed the +// next generation until the matching +endTermination. NO means this teardown is already stale. ++ (BOOL)beginTerminationForGeneration:(NSUInteger)generation +{ + NSCondition *condition = self.teardownCondition; + [condition lock]; + BOOL isCurrent = generation == _sessionGeneration; + if (isCurrent) { + _committedTerminationCount++; + } + [condition unlock]; + return isCurrent; +} + ++ (void)endTermination +{ + NSCondition *condition = self.teardownCondition; + [condition lock]; + _committedTerminationCount--; + [condition broadcast]; + [condition unlock]; +} + +// Read in the same critical section that validates the generation: a replacement would have bumped +// the generation before storing anything, so a promise captured here can never be its. ++ (FBScreenRecordingPromise *)activeScreenRecordingForGeneration:(NSUInteger)generation +{ + NSCondition *condition = self.teardownCondition; + [condition lock]; + FBScreenRecordingPromise *promise = generation == _sessionGeneration + ? FBScreenRecordingContainer.sharedInstance.screenRecordingPromise + : nil; + [condition unlock]; + return promise; } + (instancetype)sessionWithIdentifier:(NSString *)identifier @@ -157,10 +227,9 @@ + (instancetype)sessionWithIdentifier:(NSString *)identifier if (!identifier) { return nil; } - if (![identifier isEqualToString:_activeSession.identifier]) { - return nil; - } - return _activeSession; + // A single snapshot: reading the global twice could validate one session and return another. + FBSession *session = self.activeSession; + return [identifier isEqualToString:session.identifier] ? session : nil; } + (instancetype)initWithApplication:(XCUIApplication *)application @@ -220,13 +289,20 @@ - (void)kill // DELETE /session and session creation can now run concurrently, so a session already // superseded by a newer one can still reach here via a stale reference. Check-and-clear must be // atomic, else a belated -kill could null out the new session's pointer instead of its own. + NSCondition *teardownCondition = self.class.teardownCondition; BOOL wasActive; - @synchronized (self.class) { - wasActive = (self == _activeSession); - if (wasActive) { - _activeSession = nil; - } + NSUInteger generation; + [teardownCondition lock]; + wasActive = (self == _activeSession); + // Captured so the teardown steps below can tell whether a replacement has claimed the device. + generation = _sessionGeneration; + if (wasActive) { + _activeSession = nil; + // Registered in the same critical section as the clear above, else a concurrent session + // creation could observe neither an active session nor a teardown and skip its wait. + _activeTeardownCount++; } + [teardownCondition unlock]; if (!wasActive) { // Someone else is already tearing this session down - wait for that to finish (bounded), so // we don't act as if it's gone (e.g. launch a new app) while its -terminate is still in flight. @@ -234,24 +310,23 @@ - (void)kill return; } - NSCondition *teardownCondition = self.class.teardownCondition; - [teardownCondition lock]; - _isTeardownInProgress = YES; - [teardownCondition unlock]; - @try { // Posted before teardown so pending HTTP requests for this session can stop waiting sooner. [NSNotificationCenter.defaultCenter postNotificationName:FBSessionWasKilledNotification object:self]; [self disableAlertsMonitor]; - FBScreenRecordingPromise *activeScreenRecording = FBScreenRecordingContainer.sharedInstance.screenRecordingPromise; + // The container is process-wide, so only act on a promise captured while this teardown still + // owned the generation - nil here means it is stale and must leave the recording alone. + FBScreenRecordingPromise *activeScreenRecording = [self.class activeScreenRecordingForGeneration:generation]; if (nil != activeScreenRecording) { NSError *error; if (![FBXCTestDaemonsProxy stopScreenRecordingWithUUID:activeScreenRecording.identifier error:&error]) { [FBLogger logFmt:@"%@", error]; } - [FBScreenRecordingContainer.sharedInstance reset]; + // Identity, not generation: the stop above may outlast a replacement storing its own promise. + // Compare-and-reset, so that replacement's store cannot land between the check and the reset. + [FBScreenRecordingContainer.sharedInstance resetIfPromiseIs:activeScreenRecording]; } if (nil != self.testedApplication @@ -259,13 +334,14 @@ - (void)kill && self.testedApplication.running && ![self fb_isTestedApplicationSameAsSystemAppWithTimeout:FB_IS_SYSTEM_APP_CHECK_TIMEOUT_SEC]) { // Blocks until the app is either actually terminated or durably given up on (never left - // pending) - see -fb_terminateTestedApplicationWithTimeout: - so it's safe to report this - // teardown as finished as soon as this returns. - [self fb_terminateTestedApplicationWithTimeout:FB_APP_TERMINATE_TIMEOUT_SEC]; + // pending) - see -fb_terminateTestedApplicationWithTimeout:generation: - so it's safe to + // report this teardown as finished as soon as this returns. + [self fb_terminateTestedApplicationWithTimeout:FB_APP_TERMINATE_TIMEOUT_SEC generation:generation]; } } @finally { [teardownCondition lock]; - _isTeardownInProgress = NO; + _activeTeardownCount--; + // Unconditional: waiters re-check the count, so a wake-up mid-teardown just puts them back. [teardownCondition broadcast]; [teardownCondition unlock]; } @@ -394,7 +470,7 @@ - (BOOL)fb_isTestedApplicationSameAsSystemAppWithTimeout:(NSTimeInterval)timeout // `timeout` - but a "given up on" call must never still terminate whatever's running by the time // main gets to it (e.g. a replacement session's app), so cancellation and the actual terminate // call share a lock: whichever gets there first - the dispatched block, or the timeout - wins. -- (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout +- (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout generation:(NSUInteger)generation { XCUIApplication *application = self.testedApplication; NSObject *lock = [NSObject new]; @@ -402,11 +478,16 @@ - (void)fb_terminateTestedApplicationWithTimeout:(NSTimeInterval)timeout dispatch_semaphore_t sem = dispatch_semaphore_create(0); dispatch_async(dispatch_get_main_queue(), ^{ @synchronized (lock) { - if (isAllowedToTerminate) { + // Re-checked here, not before dispatching: this block can sit on a busy main queue past the + // teardown wait, and a replacement usually runs the same bundle ID as the app to terminate. + // The claim is held across -terminate, so no replacement can take the next generation mid-call. + if (isAllowedToTerminate && [self.class beginTerminationForGeneration:generation]) { @try { [application terminate]; } @catch (NSException *e) { [FBLogger logFmt:@"%@", e.description]; + } @finally { + [self.class endTermination]; } } } From b06271fc0d73241f7a577af98b3f95d8a7ee2f7b Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 30 Aug 2026 07:36:29 +0000 Subject: [PATCH 27/46] chore(release): 16.11.4 [skip ci] ## [16.11.4](https://github.com/appium/WebDriverAgent/compare/v16.11.3...v16.11.4) (2026-08-30) ### Bug Fixes * prevent a stale teardown from affecting a newer session generation ([#1231](https://github.com/appium/WebDriverAgent/issues/1231)) ([83642a1](https://github.com/appium/WebDriverAgent/commit/83642a11218fd2b2801af0a04d3b253125246bd7)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b70ea2513..f2e47bad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.11.4](https://github.com/appium/WebDriverAgent/compare/v16.11.3...v16.11.4) (2026-08-30) + +### Bug Fixes + +* prevent a stale teardown from affecting a newer session generation ([#1231](https://github.com/appium/WebDriverAgent/issues/1231)) ([83642a1](https://github.com/appium/WebDriverAgent/commit/83642a11218fd2b2801af0a04d3b253125246bd7)) + ## [16.11.3](https://github.com/appium/WebDriverAgent/compare/v16.11.2...v16.11.3) (2026-08-30) ### Miscellaneous Chores diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 32e8ef370..996820584 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.11.3 + 16.11.4 CFBundleSignature ???? CFBundleVersion - 16.11.3 + 16.11.4 NSPrincipalClass diff --git a/package.json b/package.json index 0577c934f..700613f1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.11.3", + "version": "16.11.4", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From 55808de21df801c69991cd0de66ee7eeaac3407e Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Tue, 1 Sep 2026 15:01:22 +0900 Subject: [PATCH 28/46] feat: add get screens endpoint (#1242) * feat: add /wda/screens to return multiple screens * add standalone --- WebDriverAgentLib/Commands/FBCustomCommands.m | 12 +++++++++ WebDriverAgentLib/Utilities/FBScreen.h | 5 ++++ WebDriverAgentLib/Utilities/FBScreen.m | 27 +++++++++++++++++++ .../IntegrationTests/FBScreenTests.m | 23 ++++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/WebDriverAgentLib/Commands/FBCustomCommands.m b/WebDriverAgentLib/Commands/FBCustomCommands.m index 8c50d8cae..585e72b90 100644 --- a/WebDriverAgentLib/Commands/FBCustomCommands.m +++ b/WebDriverAgentLib/Commands/FBCustomCommands.m @@ -53,6 +53,8 @@ + (NSArray *)routes [[FBRoute GET:@"/wda/locked"] respondWithTarget:self action:@selector(handleIsLocked:)], [[FBRoute GET:@"/wda/screen"] respondWithTarget:self action:@selector(handleGetScreen:)], [[FBRoute GET:@"/wda/screen"].withoutSession respondWithTarget:self action:@selector(handleGetScreen:)], + [[FBRoute GET:@"/wda/screens"].standalone respondWithTarget:self action:@selector(handleGetScreens:)], + [[FBRoute GET:@"/wda/screens"].withoutSession.standalone respondWithTarget:self action:@selector(handleGetScreens:)], [[FBRoute GET:@"/wda/activeAppInfo"] respondWithTarget:self action:@selector(handleActiveAppInfo:)], [[FBRoute GET:@"/wda/activeAppInfo"].withoutSession respondWithTarget:self action:@selector(handleActiveAppInfo:)], #if !TARGET_OS_TV && !TARGET_OS_WATCH // tvOS/watchOS do not provide relevant APIs @@ -179,6 +181,16 @@ + (NSArray *)routes }); } ++ (id)handleGetScreens:(FBRouteRequest *)request +{ + NSError *error; + NSArray *> *screens = [FBScreen screensWithError:&error]; + if (nil == screens) { + return FBResponseWithUnknownError(error); + } + return FBResponseWithObject(screens); +} + + (id)handleLock:(FBRouteRequest *)request { NSError *error; diff --git a/WebDriverAgentLib/Utilities/FBScreen.h b/WebDriverAgentLib/Utilities/FBScreen.h index ebc92c1bb..74e88013e 100644 --- a/WebDriverAgentLib/Utilities/FBScreen.h +++ b/WebDriverAgentLib/Utilities/FBScreen.h @@ -12,6 +12,11 @@ NS_ASSUME_NONNULL_BEGIN @interface FBScreen : NSObject +/** + Information about all displays available to the device + */ ++ (nullable NSArray *> *)screensWithError:(NSError **)error; + /** The identifier of the main device's display */ diff --git a/WebDriverAgentLib/Utilities/FBScreen.m b/WebDriverAgentLib/Utilities/FBScreen.m index 4348ec15d..f2963002a 100644 --- a/WebDriverAgentLib/Utilities/FBScreen.m +++ b/WebDriverAgentLib/Utilities/FBScreen.m @@ -9,10 +9,37 @@ #import "FBScreen.h" #import "XCUIElement+FBIsVisible.h" #import "FBXCodeCompatibility.h" +#import "XCUIDevice.h" #import "XCUIScreen.h" @implementation FBScreen ++ (nullable NSArray *> *)screensWithError:(NSError **)error +{ + NSArray *screens = [XCUIDevice.sharedDevice screensOrError:error]; + if (nil == screens) { + return nil; + } + + NSMutableArray *> *result = [NSMutableArray arrayWithCapacity:screens.count]; + for (XCUIScreen *screen in screens) { + CGRect bounds = screen.bounds; + [result addObject:@{ + @"displayId": @(screen.displayID), + @"isMain": @(screen.isMainScreen), + @"scale": @(screen.scale), + @"bounds": @{ + @"x": @(bounds.origin.x), + @"y": @(bounds.origin.y), + @"width": @(bounds.size.width), + @"height": @(bounds.size.height), + }, + @"traits": @(screen.traits), + }]; + } + return result.copy; +} + + (long long)displayID { return XCUIScreen.mainScreen.displayID; diff --git a/WebDriverAgentTests/IntegrationTests/FBScreenTests.m b/WebDriverAgentTests/IntegrationTests/FBScreenTests.m index 6be0bedcd..a2b6437b7 100644 --- a/WebDriverAgentTests/IntegrationTests/FBScreenTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBScreenTests.m @@ -27,6 +27,29 @@ - (void)testDisplayID XCTAssertGreaterThanOrEqual([FBScreen displayID], 0LL); } +- (void)testScreens +{ + NSError *error = nil; + NSArray *> *screens = [FBScreen screensWithError:&error]; + + XCTAssertNotNil(screens); + XCTAssertNil(error); + XCTAssertGreaterThan(screens.count, 0UL); + + NSDictionary *mainScreen = nil; + for (NSDictionary *screen in screens) { + if ([screen[@"isMain"] boolValue]) { + mainScreen = screen; + break; + } + } + XCTAssertNotNil(mainScreen); + XCTAssertEqualObjects(mainScreen[@"displayId"], @([FBScreen displayID])); + XCTAssertNotNil(mainScreen[@"scale"]); + XCTAssertNotNil(mainScreen[@"bounds"]); + XCTAssertNotNil(mainScreen[@"traits"]); +} + - (void)testScreenScale { XCTAssertTrue([FBScreen scale] >= 2); From 988f3097876f627a48b38a242958c755c429db95 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Tue, 1 Sep 2026 08:47:16 +0200 Subject: [PATCH 29/46] fix: scope xcodebuild process kill to this package's own processes (#1244) --- lib/constants.ts | 9 ++++ lib/utils/processes.ts | 74 +++++++++++++++++++-------- lib/xcodebuild.ts | 6 ++- test/unit/processes.spec.ts | 99 +++++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 test/unit/processes.spec.ts diff --git a/lib/constants.ts b/lib/constants.ts index 3bab4cf41..44561c17a 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -14,3 +14,12 @@ export const PLATFORM_NAME_IOS = 'iOS'; export const SDK_DEVICE = 'iphoneos'; export const WDA_UPGRADE_TIMESTAMP_PATH = path.join('.appium', 'webdriveragent', 'upgrade.time'); + +/** + * Harmless unused build setting override appended to every xcodebuild invocation + * this package starts. It has no effect on the build itself, but shows up verbatim + * in the process' command line, letting us tell our own xcodebuild processes apart + * from unrelated ones (e.g. other WDA-based test runners) that happen to target the + * same device udid. + */ +export const XCODEBUILD_PROCESS_MARKER = 'APPIUM_XCODEBUILD_WDA_MARKER=1'; diff --git a/lib/utils/processes.ts b/lib/utils/processes.ts index 8f931f622..13be6e32c 100644 --- a/lib/utils/processes.ts +++ b/lib/utils/processes.ts @@ -1,15 +1,21 @@ import {waitForCondition} from 'asyncbox'; import {exec} from 'teen_process'; +import {XCODEBUILD_PROCESS_MARKER} from '../constants.js'; import {log} from '../logger.js'; /** * Find and terminate all processes matching the given pgrep pattern. + * + * @param pgrepPattern - Pattern used to find candidate processes. + * @param cmdlineIncludes - If given, a candidate is only killed if its full + * command line also contains this substring. Used to narrow a broad pgrep + * match (e.g. by device udid) down to processes this package actually started. */ -export async function killAppUsingPattern(pgrepPattern: string): Promise { +export async function killAppUsingPattern(pgrepPattern: string, cmdlineIncludes?: string): Promise { const signals = [2, 15, 9]; for (const signal of signals) { - const matchedPids = await getPIDsUsingPattern(pgrepPattern); + const matchedPids = await getPIDsUsingPattern(pgrepPattern, cmdlineIncludes); if (matchedPids.length === 0) { return; } @@ -52,6 +58,12 @@ export async function killAppUsingPattern(pgrepPattern: string): Promise { /** * Kills running XCTest processes for the particular device. + * + * The `xcodebuild` pattern is additionally scoped to processes this package started + * (see {@link XCODEBUILD_PROCESS_MARKER}), so other XCTest-based tools targeting the + * same udid (e.g. a separately managed WebDriverAgent instance) are left alone. + * The XCTRunner/xctest patterns below cannot be scoped the same way, since those + * processes do not inherit xcodebuild's command line. */ export async function resetTestProcesses(udid: string, isSimulator: boolean): Promise { const processPatterns = [`xcodebuild.*${udid}`]; @@ -61,7 +73,37 @@ export async function resetTestProcesses(udid: string, isSimulator: boolean): Pr processPatterns.push(`xctest.*${udid}`); } log.debug(`Killing running processes '${processPatterns.join(', ')}' for the device ${udid}...`); - await Promise.all(processPatterns.map(killAppUsingPattern)); + await Promise.all( + processPatterns.map((pattern) => + killAppUsingPattern(pattern, pattern.startsWith('xcodebuild') ? XCODEBUILD_PROCESS_MARKER : undefined), + ), + ); +} + +/** + * Filters a list of PIDs down to those whose full command line satisfies the + * given lambda. PIDs that have already exited are silently dropped. + */ +async function filterPIDsByCommandLine( + pids: string[], + filteringFunc: (cmdline: string) => boolean | Promise, +): Promise { + const filtered = await Promise.all( + pids.map(async (pid) => { + let stdout: string; + try { + ({stdout} = await exec('ps', ['-p', pid, '-o', 'command'])); + } catch (e: any) { + if (e.code === 1) { + // The process does not exist anymore, there's nothing to filter + return null; + } + throw e; + } + return (await filteringFunc(stdout)) ? pid : null; + }), + ); + return filtered.filter((pid): pid is string => Boolean(pid)); } /** @@ -97,32 +139,18 @@ export async function getPIDsListeningOnPort( if (typeof filteringFunc !== 'function') { return result; } - const filtered = await Promise.all( - result.map(async (pid) => { - let stdout: string; - try { - ({stdout} = await exec('ps', ['-p', pid, '-o', 'command'])); - } catch (e: any) { - if (e.code === 1) { - // The process does not exist anymore, there's nothing to filter - return null; - } - throw e; - } - return (await filteringFunc(stdout)) ? pid : null; - }), - ); - return filtered.filter((pid): pid is string => Boolean(pid)); + return await filterPIDsByCommandLine(result, filteringFunc); } -async function getPIDsUsingPattern(pattern: string): Promise { +async function getPIDsUsingPattern(pattern: string, cmdlineIncludes?: string): Promise { const args = [ '-if', // case insensitive, full cmdline match pattern, ]; + let pids: string[]; try { const {stdout} = await exec('pgrep', args); - return stdout + pids = stdout .split(/\s+/) .map((x) => parseInt(x, 10)) .filter(Number.isInteger) @@ -131,4 +159,8 @@ async function getPIDsUsingPattern(pattern: string): Promise { log.debug(`'pgrep ${args.join(' ')}' didn't detect any matching processes. Return code: ${err.code}`); return []; } + if (!cmdlineIncludes || pids.length === 0) { + return pids; + } + return await filterPIDsByCommandLine(pids, (cmdline) => cmdline.includes(cmdlineIncludes)); } diff --git a/lib/xcodebuild.ts b/lib/xcodebuild.ts index 34753beca..5b3784798 100644 --- a/lib/xcodebuild.ts +++ b/lib/xcodebuild.ts @@ -5,7 +5,7 @@ import type {AppiumLogger, StringRecord} from '@appium/types'; import {retryInterval} from 'asyncbox'; import {SubProcess, exec} from 'teen_process'; -import {WDA_RUNNER_BUNDLE_ID} from './constants.js'; +import {WDA_RUNNER_BUNDLE_ID, XCODEBUILD_PROCESS_MARKER} from './constants.js'; import {log as defaultLogger} from './logger.js'; import type {NoSessionProxy} from './no-session-proxy.js'; import type { @@ -431,6 +431,10 @@ export class XcodeBuild { // with preventing to generate `/Index/DataStore` which is used by development args.push('COMPILER_INDEX_STORE_ENABLE=NO'); + // Tags this process so resetTestProcesses() can kill only xcodebuild instances + // this package started, not unrelated ones sharing the same device udid. + args.push(XCODEBUILD_PROCESS_MARKER); + return {cmd, args}; } diff --git a/test/unit/processes.spec.ts b/test/unit/processes.spec.ts new file mode 100644 index 000000000..a2ce9ac36 --- /dev/null +++ b/test/unit/processes.spec.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import {describe, beforeEach, it, mock} from 'node:test'; + +interface ExecCall { + cmd: string; + args: string[]; +} + +let pgrepStdout = ''; +let cmdlineByPid: Record = {}; +let killedPids: string[] = []; +const execCalls: ExecCall[] = []; + +async function fakeExec(cmd: string, args: string[] = []): Promise<{stdout: string}> { + execCalls.push({cmd, args}); + if (cmd === 'pgrep') { + return {stdout: pgrepStdout}; + } + if (cmd === 'ps') { + const pid = args[args.indexOf('-p') + 1]; + return {stdout: cmdlineByPid[pid] ?? ''}; + } + if (cmd === 'kill') { + if (args[0] === '-0') { + // Report the process as already gone, so killAppUsingPattern does not + // wait out the full polling window on every signal. + throw Object.assign(new Error('No such process'), {code: 1}); + } + killedPids.push(...args.filter((a) => !a.startsWith('-'))); + return {stdout: ''}; + } + throw new Error(`Unexpected exec call: ${cmd} ${args.join(' ')}`); +} + +mock.module('teen_process', { + namedExports: { + exec: (...args: [string, string[]?]) => fakeExec(...args), + }, +}); + +const {killAppUsingPattern, resetTestProcesses} = await import('../../lib/utils/processes.js'); +const {XCODEBUILD_PROCESS_MARKER} = await import('../../lib/constants.js'); + +describe('processes', function () { + beforeEach(function () { + pgrepStdout = ''; + cmdlineByPid = {}; + killedPids = []; + execCalls.length = 0; + }); + + describe('#killAppUsingPattern', function () { + it('kills every matched pid when no cmdline filter is given', async function () { + pgrepStdout = '111 222'; + await killAppUsingPattern('xcodebuild.*some-udid'); + assert.deepStrictEqual(killedPids.sort(), ['111', '222']); + }); + + it('only kills pids whose full command line contains the given substring', async function () { + pgrepStdout = '111 222'; + cmdlineByPid = { + 111: `xcodebuild -destination id=some-udid ${XCODEBUILD_PROCESS_MARKER}`, + 222: 'xcodebuild -destination id=some-udid', // unrelated xcodebuild instance, no marker + }; + await killAppUsingPattern('xcodebuild.*some-udid', XCODEBUILD_PROCESS_MARKER); + assert.deepStrictEqual(killedPids, ['111']); + }); + + it('kills nothing when no matched pid contains the required substring', async function () { + pgrepStdout = '222'; + cmdlineByPid = { + 222: 'xcodebuild -destination id=some-udid', + }; + await killAppUsingPattern('xcodebuild.*some-udid', XCODEBUILD_PROCESS_MARKER); + assert.deepStrictEqual(killedPids, []); + }); + }); + + describe('#resetTestProcesses', function () { + it('scopes the xcodebuild pattern to this package own processes on a real device', async function () { + pgrepStdout = '111 222'; + cmdlineByPid = { + 111: `xcodebuild -destination id=some-udid ${XCODEBUILD_PROCESS_MARKER}`, + 222: 'xcodebuild -destination id=some-udid', // e.g. a separately managed WDA instance + }; + await resetTestProcesses('some-udid', false); + assert.deepStrictEqual(killedPids, ['111']); + }); + + it('does not apply the marker filter to the simulator XCTRunner/xctest patterns', async function () { + pgrepStdout = '333'; + cmdlineByPid = { + 333: 'some-path/XCTRunner some-udid', // no marker present, unlike the xcodebuild process + }; + await resetTestProcesses('some-udid', true); + assert.ok(killedPids.includes('333')); + }); + }); +}); From a9e3f53f45a7fc4e36528951b4297cc54519137a Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 1 Sep 2026 08:18:23 +0000 Subject: [PATCH 30/46] chore(release): 16.12.0 [skip ci] ## [16.12.0](https://github.com/appium/WebDriverAgent/compare/v16.11.4...v16.12.0) (2026-09-01) ### Features * add get screens endpoint ([#1242](https://github.com/appium/WebDriverAgent/issues/1242)) ([55808de](https://github.com/appium/WebDriverAgent/commit/55808de21df801c69991cd0de66ee7eeaac3407e)) ### Bug Fixes * scope xcodebuild process kill to this package's own processes ([#1244](https://github.com/appium/WebDriverAgent/issues/1244)) ([988f309](https://github.com/appium/WebDriverAgent/commit/988f3097876f627a48b38a242958c755c429db95)) --- CHANGELOG.md | 10 ++++++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e47bad0..70eba0438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## [16.12.0](https://github.com/appium/WebDriverAgent/compare/v16.11.4...v16.12.0) (2026-09-01) + +### Features + +* add get screens endpoint ([#1242](https://github.com/appium/WebDriverAgent/issues/1242)) ([55808de](https://github.com/appium/WebDriverAgent/commit/55808de21df801c69991cd0de66ee7eeaac3407e)) + +### Bug Fixes + +* scope xcodebuild process kill to this package's own processes ([#1244](https://github.com/appium/WebDriverAgent/issues/1244)) ([988f309](https://github.com/appium/WebDriverAgent/commit/988f3097876f627a48b38a242958c755c429db95)) + ## [16.11.4](https://github.com/appium/WebDriverAgent/compare/v16.11.3...v16.11.4) (2026-08-30) ### Bug Fixes diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 996820584..190a592e3 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.11.4 + 16.12.0 CFBundleSignature ???? CFBundleVersion - 16.11.4 + 16.12.0 NSPrincipalClass diff --git a/package.json b/package.json index 700613f1b..51eff5cbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.11.4", + "version": "16.12.0", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From 3ff08a64b2428bbc81aef1dece492f49e273d702 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Egl=C4=ABtis?= <37242620+eglitise@users.noreply.github.com> Date: Wed, 2 Sep 2026 00:20:30 +0300 Subject: [PATCH 31/46] chore: bump support-related dependencies (#1245) --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 51eff5cbd..62418ce11 100644 --- a/package.json +++ b/package.json @@ -67,11 +67,11 @@ "@appium/base-driver": "^10.8.0", "@appium/strongbox": "^2.0.0", "@appium/support": "^7.2.6", - "appium-ios-simulator": "^9.0.0", + "appium-ios-simulator": "^9.1.3", "async-lock": "^1.0.0", - "asyncbox": "^6.1.0", - "axios": "^1.16.0", - "teen_process": "^4.0.7" + "asyncbox": "^6.4.3", + "axios": "^1.18.0", + "teen_process": "^4.2.0" }, "devDependencies": { "@appium/oxc-config": "^1.1.0", @@ -81,7 +81,7 @@ "@types/async-lock": "^1.4.2", "@types/node": "^26.0.0", "@types/sinon": "^22.0.0", - "appium-xcode": "^7.0.0", + "appium-xcode": "^7.1.1", "node-simctl": "^9.0.0", "semver": "^7.3.7", "sinon": "^22.0.0" From 6e2b5c026f728aed9098481e756b458db87d1272 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 1 Sep 2026 21:25:47 +0000 Subject: [PATCH 32/46] chore(release): 16.12.1 [skip ci] ## [16.12.1](https://github.com/appium/WebDriverAgent/compare/v16.12.0...v16.12.1) (2026-09-01) ### Miscellaneous Chores * bump support-related dependencies ([#1245](https://github.com/appium/WebDriverAgent/issues/1245)) ([3ff08a6](https://github.com/appium/WebDriverAgent/commit/3ff08a64b2428bbc81aef1dece492f49e273d702)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70eba0438..333218a71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.12.1](https://github.com/appium/WebDriverAgent/compare/v16.12.0...v16.12.1) (2026-09-01) + +### Miscellaneous Chores + +* bump support-related dependencies ([#1245](https://github.com/appium/WebDriverAgent/issues/1245)) ([3ff08a6](https://github.com/appium/WebDriverAgent/commit/3ff08a64b2428bbc81aef1dece492f49e273d702)) + ## [16.12.0](https://github.com/appium/WebDriverAgent/compare/v16.11.4...v16.12.0) (2026-09-01) ### Features diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 190a592e3..87d1ff4a6 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.12.0 + 16.12.1 CFBundleSignature ???? CFBundleVersion - 16.12.0 + 16.12.1 NSPrincipalClass diff --git a/package.json b/package.json index 62418ce11..d9d4917e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.12.0", + "version": "16.12.1", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From 282478a2e631501fd904b0675717123c779d9b84 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 3 Sep 2026 20:01:10 +0200 Subject: [PATCH 33/46] fix: allow pause action items to appear before any pointer movement (#1246) --- .../Utilities/FBW3CActionsSynthesizer.m | 28 ++++- .../FBW3CMultiTouchActionsIntegrationTests.m | 35 ++++++ .../FBW3CTouchActionsIntegrationTests.m | 107 ++++++++++++++++-- 3 files changed, 153 insertions(+), 17 deletions(-) diff --git a/WebDriverAgentLib/Utilities/FBW3CActionsSynthesizer.m b/WebDriverAgentLib/Utilities/FBW3CActionsSynthesizer.m index ad5cbd3c2..8323c4a3b 100644 --- a/WebDriverAgentLib/Utilities/FBW3CActionsSynthesizer.m +++ b/WebDriverAgentLib/Utilities/FBW3CActionsSynthesizer.m @@ -133,7 +133,8 @@ - (nullable instancetype)initWithActionItem:(NSDictionary *)acti } self.duration = durationObj.doubleValue; XCUICoordinate *position = [self positionWithError:error]; - if (nil == position) { + // A pause may legally have no position yet (nil position, no error set) + if (nil == position && error && nil != *error) { return nil; } self.atPosition = position; @@ -143,7 +144,7 @@ - (nullable instancetype)initWithActionItem:(NSDictionary *)acti - (nullable XCUICoordinate *)positionWithError:(NSError **)error { - if (nil == self.previousItem) { + if (nil == self.previousItem || nil == self.previousItem.atPosition) { NSString *errorDescription = [NSString stringWithFormat:@"The '%@' action item must be preceded by %@ item", self.actionItem, FB_ACTION_ITEM_TYPE_POINTER_MOVE]; if (error) { *error = [[FBErrorBuilder.builder withDescription:errorDescription] build]; @@ -205,9 +206,19 @@ + (NSString *)actionName currentItemIndex:(NSUInteger)currentItemIndex error:(NSError **)error { - if (nil != eventPath && currentItemIndex == 1) { + if (nil != eventPath && currentItemIndex >= 1) { FBW3CGestureItem *preceedingItem = [allItems objectAtIndex:currentItemIndex - 1]; - if ([preceedingItem isKindOfClass:FBPointerMoveItem.class]) { + // Only skip creating a new touch if the preceding pointerMove is the one that + // implicitly opened this touch, i.e. nothing but (possibly zero-duration) pauses + // came before it. Pauses never create an event path themselves. + BOOL isPreceedingMoveTheFirstRealItem = YES; + for (NSInteger index = (NSInteger)currentItemIndex - 2; index >= 0; index--) { + if (![[allItems objectAtIndex:index] isKindOfClass:FBPointerPauseItem.class]) { + isPreceedingMoveTheFirstRealItem = NO; + break; + } + } + if ([preceedingItem isKindOfClass:FBPointerMoveItem.class] && isPreceedingMoveTheFirstRealItem) { return @[]; } } @@ -280,7 +291,7 @@ - (nullable XCUICoordinate *)positionWithError:(NSError **)error } // origin == FB_ORIGIN_TYPE_POINTER - if (nil == self.previousItem) { + if (nil == self.previousItem || nil == self.previousItem.atPosition) { NSString *errorDescription = [NSString stringWithFormat:@"There is no previous item for '%@' action item, however %@ is set to '%@'", self.actionItem, FB_ACTION_ITEM_KEY_ORIGIN, FB_ORIGIN_TYPE_POINTER]; if (error) { *error = [[FBErrorBuilder.builder withDescription:errorDescription] build]; @@ -320,6 +331,13 @@ + (NSString *)actionName return FB_ACTION_ITEM_TYPE_PAUSE; } +- (nullable XCUICoordinate *)positionWithError:(NSError **)error +{ + // A pause has no position of its own; proxy whatever real move preceded + // it, or nil (not a fabricated point) if none has run yet + return self.previousItem.atPosition; +} + - (NSArray *)addToEventPath:(XCPointerEventPath *)eventPath allItems:(NSArray *)allItems currentItemIndex:(NSUInteger)currentItemIndex diff --git a/WebDriverAgentTests/IntegrationTests/FBW3CMultiTouchActionsIntegrationTests.m b/WebDriverAgentTests/IntegrationTests/FBW3CMultiTouchActionsIntegrationTests.m index 016c8701d..9a92d98bc 100644 --- a/WebDriverAgentTests/IntegrationTests/FBW3CMultiTouchActionsIntegrationTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBW3CMultiTouchActionsIntegrationTests.m @@ -120,5 +120,40 @@ - (void)testSymmetricTwoFingersTap [self verifyGesture:gesture orientation:UIDeviceOrientationPortrait]; } +- (void)testTwoFingersTapWithLeadingZeroDurationPause +{ + // Selenium clients pad shorter action sequences with a zero-duration pause + // so that all pointers/devices end up with the same number of ticks + XCUIElement *element = self.testedApplication.buttons[FBShowAlertButtonName]; + NSArray *> *gesture = + @[ + @{ + @"type": @"pointer", + @"id": @"finger1", + @"parameters": @{@"pointerType": @"touch"}, + @"actions": @[ + @{@"type": @"pointerMove", @"duration": @0, @"origin": element, @"x": @0, @"y": @0}, + @{@"type": @"pointerDown"}, + @{@"type": @"pause", @"duration": @100}, + @{@"type": @"pointerUp"}, + ], + }, + @{ + @"type": @"pointer", + @"id": @"finger2", + @"parameters": @{@"pointerType": @"touch"}, + @"actions": @[ + @{@"type": @"pause", @"duration": @0}, + @{@"type": @"pointerMove", @"duration": @0, @"origin": element, @"x": @0, @"y": @0}, + @{@"type": @"pointerDown"}, + @{@"type": @"pause", @"duration": @100}, + @{@"type": @"pointerUp"}, + ], + }, + ]; + + [self verifyGesture:gesture orientation:UIDeviceOrientationPortrait]; +} + @end diff --git a/WebDriverAgentTests/IntegrationTests/FBW3CTouchActionsIntegrationTests.m b/WebDriverAgentTests/IntegrationTests/FBW3CTouchActionsIntegrationTests.m index 6865ece35..8a41983c4 100644 --- a/WebDriverAgentTests/IntegrationTests/FBW3CTouchActionsIntegrationTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBW3CTouchActionsIntegrationTests.m @@ -17,6 +17,10 @@ #import "XCUIDevice+FBRotation.h" #import "FBRunLoopSpinner.h" #import "FBXCodeCompatibility.h" +#import "FBW3CActionsSynthesizer.h" +#import "XCSynthesizedEventRecord.h" +#import "XCPointerEventPath.h" +#import "XCPointerEvent.h" @interface FBW3CTouchActionsIntegrationTestsPart1 : FBIntegrationTestCase @end @@ -185,14 +189,13 @@ - (void)testErroneousGestures }, ], - // Chain element where action items start with an incorrect item + // Chain element where pointerMove action item does not contain coordinates @[@{ @"type": @"pointer", @"id": @"finger1", @"parameters": @{@"pointerType": @"touch"}, @"actions": @[ - @{@"type": @"pause", @"duration": @100}, - @{@"type": @"pointerMove", @"duration": @0, @"x": @1, @"y": @1}, + @{@"type": @"pointerMove", @"duration": @0}, @{@"type": @"pointerDown"}, @{@"type": @"pause", @"duration": @100}, @{@"type": @"pointerUp"}, @@ -200,13 +203,13 @@ - (void)testErroneousGestures }, ], - // Chain element where pointerMove action item does not contain coordinates + // Chain element where pointerMove action item cannot use coordinates of the previous item @[@{ @"type": @"pointer", @"id": @"finger1", @"parameters": @{@"pointerType": @"touch"}, @"actions": @[ - @{@"type": @"pointerMove", @"duration": @0}, + @{@"type": @"pointerMove", @"duration": @0, @"origin": @"pointer"}, @{@"type": @"pointerDown"}, @{@"type": @"pause", @"duration": @100}, @{@"type": @"pointerUp"}, @@ -214,34 +217,51 @@ - (void)testErroneousGestures }, ], - // Chain element where pointerMove action item cannot use coordinates of the previous item + // Chain element where action items contains negative duration @[@{ @"type": @"pointer", @"id": @"finger1", @"parameters": @{@"pointerType": @"touch"}, @"actions": @[ - @{@"type": @"pointerMove", @"duration": @0, @"origin": @"pointer"}, + @{@"type": @"pointerMove", @"duration": @0, @"x": @1, @"y": @1}, @{@"type": @"pointerDown"}, - @{@"type": @"pause", @"duration": @100}, + @{@"type": @"pause", @"duration": @-100}, @{@"type": @"pointerUp"}, ], }, ], - // Chain element where action items contains negative duration + // Chain element where a leading pause is followed directly by pointerDown, + // with no real pointerMove ever establishing a position @[@{ @"type": @"pointer", @"id": @"finger1", @"parameters": @{@"pointerType": @"touch"}, @"actions": @[ - @{@"type": @"pointerMove", @"duration": @0, @"x": @1, @"y": @1}, + @{@"type": @"pause", @"duration": @0}, @{@"type": @"pointerDown"}, - @{@"type": @"pause", @"duration": @-100}, + @{@"type": @"pause", @"duration": @100}, @{@"type": @"pointerUp"}, ], }, ], - + + // Chain element where a leading pause is followed directly by a relative + // pointerMove, with no real preceding position to be relative to + @[@{ + @"type": @"pointer", + @"id": @"finger1", + @"parameters": @{@"pointerType": @"touch"}, + @"actions": @[ + @{@"type": @"pause", @"duration": @0}, + @{@"type": @"pointerMove", @"duration": @0, @"origin": @"pointer"}, + @{@"type": @"pointerDown"}, + @{@"type": @"pause", @"duration": @100}, + @{@"type": @"pointerUp"}, + ], + }, + ], + // Chain element where action items start with an incorrect one, because the correct one is canceled @[@{ @"type": @"pointer", @@ -299,6 +319,69 @@ - (void)testTap [self verifyGesture:gesture orientation:UIDeviceOrientationPortrait]; } +- (void)testLeadingZeroDurationPauseDoesNotAddExtraTouch +{ + // A leading pause must not defeat the down-after-move dedup logic in + // FBPointerDownItem and make WDA synthesize a second, separate touch-down + // for the same finger. Inspect the actual synthesized XCTest event stream + // (without dispatching it) rather than only checking the gesture's visible + // side effect, since a duplicate touch at the same point may still produce + // the same visible outcome. + XCUIElement *element = self.testedApplication.buttons[FBShowAlertButtonName]; + NSDictionary *(^sequenceWithLeadingPause)(BOOL) = ^NSDictionary *(BOOL withLeadingPause) { + NSMutableArray *> *actions = [NSMutableArray array]; + if (withLeadingPause) { + [actions addObject:@{@"type": @"pause", @"duration": @0}]; + } + [actions addObjectsFromArray:@[ + @{@"type": @"pointerMove", @"duration": @0, @"origin": element, @"x": @0, @"y": @0}, + @{@"type": @"pointerDown"}, + @{@"type": @"pause", @"duration": @100}, + @{@"type": @"pointerUp"}, + ]]; + return @{ + @"type": @"pointer", + @"id": @"finger1", + @"parameters": @{@"pointerType": @"touch"}, + @"actions": actions.copy, + }; + }; + + NSError *error; + FBW3CActionsSynthesizer *baselineSynthesizer = + [[FBW3CActionsSynthesizer alloc] initWithActions:@[sequenceWithLeadingPause(NO)] + forApplication:self.testedApplication + elementCache:nil + error:&error]; + XCTAssertNotNil(baselineSynthesizer); + XCSynthesizedEventRecord *baselineRecord = [baselineSynthesizer synthesizeWithError:&error]; + XCTAssertNotNil(baselineRecord, @"%@", error); + + FBW3CActionsSynthesizer *pausedSynthesizer = + [[FBW3CActionsSynthesizer alloc] initWithActions:@[sequenceWithLeadingPause(YES)] + forApplication:self.testedApplication + elementCache:nil + error:&error]; + XCTAssertNotNil(pausedSynthesizer); + XCSynthesizedEventRecord *pausedRecord = [pausedSynthesizer synthesizeWithError:&error]; + XCTAssertNotNil(pausedRecord, @"%@", error); + + XCTAssertEqual(baselineRecord.eventPaths.count, (NSUInteger)1); + XCTAssertEqual(pausedRecord.eventPaths.count, baselineRecord.eventPaths.count); + + XCPointerEventPath *baselinePath = baselineRecord.eventPaths.firstObject; + XCPointerEventPath *pausedPath = pausedRecord.eventPaths.firstObject; + XCTAssertEqual(pausedPath.pointerEvents.count, baselinePath.pointerEvents.count); + for (NSUInteger i = 0; i < baselinePath.pointerEvents.count; i++) { + XCPointerEvent *baselineEvent = baselinePath.pointerEvents[i]; + XCPointerEvent *pausedEvent = pausedPath.pointerEvents[i]; + XCTAssertEqual(pausedEvent.eventType, baselineEvent.eventType); + XCTAssertEqualWithAccuracy(pausedEvent.offset, baselineEvent.offset, 0.001); + XCTAssertEqualWithAccuracy(pausedEvent.coordinate.x, baselineEvent.coordinate.x, 0.001); + XCTAssertEqualWithAccuracy(pausedEvent.coordinate.y, baselineEvent.coordinate.y, 0.001); + } +} + - (void)testDoubleTap { NSArray *> *gesture = From ef5a04a0dacbc3943359cc646ff255b282560c3d Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 3 Sep 2026 18:05:32 +0000 Subject: [PATCH 34/46] chore(release): 16.12.2 [skip ci] ## [16.12.2](https://github.com/appium/WebDriverAgent/compare/v16.12.1...v16.12.2) (2026-09-03) ### Bug Fixes * allow pause action items to appear before any pointer movement ([#1246](https://github.com/appium/WebDriverAgent/issues/1246)) ([282478a](https://github.com/appium/WebDriverAgent/commit/282478a2e631501fd904b0675717123c779d9b84)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 333218a71..6089d672e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.12.2](https://github.com/appium/WebDriverAgent/compare/v16.12.1...v16.12.2) (2026-09-03) + +### Bug Fixes + +* allow pause action items to appear before any pointer movement ([#1246](https://github.com/appium/WebDriverAgent/issues/1246)) ([282478a](https://github.com/appium/WebDriverAgent/commit/282478a2e631501fd904b0675717123c779d9b84)) + ## [16.12.1](https://github.com/appium/WebDriverAgent/compare/v16.12.0...v16.12.1) (2026-09-01) ### Miscellaneous Chores diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 87d1ff4a6..800f1f9b8 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.12.1 + 16.12.2 CFBundleSignature ???? CFBundleVersion - 16.12.1 + 16.12.2 NSPrincipalClass diff --git a/package.json b/package.json index d9d4917e6..2fe7fd6ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.12.1", + "version": "16.12.2", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From f40bac6ea77f6220883fe4437ee63e51fe44d74f Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Fri, 4 Sep 2026 07:17:41 +0200 Subject: [PATCH 35/46] fix: resolve key name lookup for dictionary-form keyboardInput keys (#1247) --- WebDriverAgent.xcodeproj/project.pbxproj | 20 +++-- WebDriverAgentLib/Commands/FBCustomCommands.m | 2 +- .../UnitTests/Doubles/XCUIElementDouble.h | 3 + .../UnitTests/Doubles/XCUIElementDouble.m | 9 ++ .../UnitTests/FBCustomCommandsTests.m | 83 +++++++++++++++++++ 5 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 WebDriverAgentTests/UnitTests/FBCustomCommandsTests.m diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index b8b7c03c5..882034b73 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -118,6 +118,7 @@ 205E75731851D363B53A61DE /* UITestingUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9AB7FD1CAEE048008C271F /* UITestingUITests.m */; }; 208F3FB46E41B0A1C87B8800 /* FBScreenshot.m in Sources */ = {isa = PBXBuildFile; fileRef = 71C9EAAB25E8415A00470CD8 /* FBScreenshot.m */; }; 2112EC67BDFFA4A0B2CF24EB /* RouteRequest.h in Headers */ = {isa = PBXBuildFile; fileRef = D585660F7A04651223F29B07 /* RouteRequest.h */; }; + 21C0068C990412E75A135DD4 /* FBCustomCommandsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = B817F776DE7106D3B7B1C049 /* FBCustomCommandsTests.m */; }; 2238B4FC5C7DCD4B4215444D /* XCTMessagingRole_ProtectedResourceAuthorization-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = CCCBEAE654102DBB1C8C22CD /* XCTMessagingRole_ProtectedResourceAuthorization-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; 227E3F36FAA2C3881F89FD81 /* WDAClickIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C878996F07A9B26E66FC4EAC /* WDAClickIntegrationTests.swift */; }; 229F9F5B85C1255447495847 /* XCUIApplicationAutomationSessionProviding-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 603D97F0F9A5B9D4E6442BF2 /* XCUIApplicationAutomationSessionProviding-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -604,7 +605,6 @@ 716F0DA12A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.h in Headers */ = {isa = PBXBuildFile; fileRef = 716F0D9F2A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.h */; }; 716F0DA32A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.m in Sources */ = {isa = PBXBuildFile; fileRef = 716F0DA02A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.m */; }; 716F0DA62A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 716F0DA52A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m */; }; - E82332D32C4E06280CBE3F4C /* FBResponseJSONPayloadTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3F5C37472190A686297D3534 /* FBResponseJSONPayloadTests.m */; }; 7182A87F3CAA27F71B624AD2 /* XCTRunnerAutomationSession-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 01AF8E73DD47455B4854E470 /* XCTRunnerAutomationSession-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; 718F49C8230844330045FE8B /* FBProtocolHelpersTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 718F49C7230844330045FE8B /* FBProtocolHelpersTests.m */; }; 718F49C923087ACF0045FE8B /* FBProtocolHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = 71B155DD23080CA600646AFB /* FBProtocolHelpers.h */; }; @@ -840,6 +840,7 @@ 9E914062BD3388A4F3B8100B /* FBXPathExtensions.m in Sources */ = {isa = PBXBuildFile; fileRef = 71B2E0022733FB970074B002 /* FBXPathExtensions.m */; }; 9E97481489FB2345F690578E /* XCTMessagingRole_HIDEventRecording-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 94D7F0C1E30FBDB5D2583908 /* XCTMessagingRole_HIDEventRecording-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; 9F9218F11A1D06F8DCC8308B /* FBXCElementSnapshotWrapper+Helpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 13DE7A5A287CA444003243C6 /* FBXCElementSnapshotWrapper+Helpers.m */; }; + A09D847635CA4C155583B967 /* FBXCAXClientProxyTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6231A764C3FAAB29B87CD987 /* FBXCAXClientProxyTests.m */; }; A0A7AB48F1A3AEEC70AF92D7 /* XCTMessagingRole_ForcePressureSupportQuerying-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = AA2351C66A4616534FB81AE4 /* XCTMessagingRole_ForcePressureSupportQuerying-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; A14687C1A2FC70A2356B7839 /* XCUIApplicationImplReporter-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 221BC403F42F61DDB1F11DD0 /* XCUIApplicationImplReporter-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; A14CB090646BCB0B50F213CF /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 5D7DD32943CAC7838F942ED5 /* ViewController.m */; }; @@ -940,6 +941,7 @@ C22CA4AEE1C7395AE82B3BD3 /* XCTMessagingRole_PerformanceMeasurementReporting-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 2B650ADFEEA2369A10F6C1F1 /* XCTMessagingRole_PerformanceMeasurementReporting-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; C2F6BB3D8A49F762E5172768 /* libxml2.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 7155B419224D5B460042A993 /* libxml2.tbd */; }; C309FAE89D050C90496FB87B /* XCTRemoteSignpostListenerProxy-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 8E47AA1A44A4A3C6EDCB6804 /* XCTRemoteSignpostListenerProxy-Protocol.h */; }; + C312172CE7EE9B10B7A3034B /* FBHTTPServerSessionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FB741D78562232A78902B374 /* FBHTTPServerSessionTests.m */; }; C3A3578B56BF260A77EB1ECF /* XCUIAlertMonitoring-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = DB5797DE5A0B4E7EE3D166F7 /* XCUIAlertMonitoring-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3E6942879518EC529341A25 /* XCTRemoteSignpostListenerProxy-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 8E47AA1A44A4A3C6EDCB6804 /* XCTRemoteSignpostListenerProxy-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; C3F7218DAB6B07E34AFDDF44 /* WatchSpikeApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE921136A147FD01D630869 /* WatchSpikeApp.swift */; }; @@ -1017,6 +1019,7 @@ DE2D708340ED70EBF9244D0F /* NSDictionary+FBUtf8SafeDictionary.h in Headers */ = {isa = PBXBuildFile; fileRef = 716F0D9F2A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.h */; }; DE75F01A64FBC6A58012E6B4 /* FBXCElementSnapshotDouble.m in Sources */ = {isa = PBXBuildFile; fileRef = F46F78706C5157469122F730 /* FBXCElementSnapshotDouble.m */; }; DF0FF8388E4C4CFDD1746BF7 /* XCTRunnerIDESessionDelegate-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 8A5EC96F8F1C3888B9E24FAA /* XCTRunnerIDESessionDelegate-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DF82F4A8758B79DD91FA20CC /* FBHTTPServerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 76B7399DDF52C85A21433C1D /* FBHTTPServerTests.m */; }; DFB9638AF3480053CA39AB3C /* XCUIElement+FBVisibleFrame.h in Headers */ = {isa = PBXBuildFile; fileRef = 71AE3CF52D38EE8E0039FC36 /* XCUIElement+FBVisibleFrame.h */; }; DFC01347FC6A5308D5C6765D /* XCTMessagingRole_MemoryTesting-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 13D863EAE7F6F8B7E42D99B9 /* XCTMessagingRole_MemoryTesting-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; E046D4602540FA7272DCBCB4 /* XCUIElement+FBWebDriverAttributes.m in Sources */ = {isa = PBXBuildFile; fileRef = EEE376481D59FAE900ED88DD /* XCUIElement+FBWebDriverAttributes.m */; }; @@ -1039,6 +1042,7 @@ E66195839119DC5A8BB7A5D9 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DD1ABD1093739852B52C472B /* Foundation.framework */; }; E6B214145FE46BBFD824FAE7 /* FBMathUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = EE1888381DA661C400307AA8 /* FBMathUtils.h */; }; E78F581F8E7530B24AC31A8B /* XCTMessagingRole_UIAutomation-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 92182E4007B37665AB8CD88E /* XCTMessagingRole_UIAutomation-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; + E82332D32C4E06280CBE3F4C /* FBResponseJSONPayloadTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3F5C37472190A686297D3534 /* FBResponseJSONPayloadTests.m */; }; E8E894CD81C41AC4467A4F4C /* XCTCapabilities.h in Headers */ = {isa = PBXBuildFile; fileRef = DB5400B170F08C71779CCD0E /* XCTCapabilities.h */; settings = {ATTRIBUTES = (Public, ); }; }; E8E917CF8B3ADB4F24CD0B96 /* _TtC10XCTestCore19XCTReportingContext.h in Headers */ = {isa = PBXBuildFile; fileRef = 2DFEF3D0F3F006AC53F9E525 /* _TtC10XCTestCore19XCTReportingContext.h */; settings = {ATTRIBUTES = (Public, ); }; }; E8FE8448E0118775E7C789CC /* FBXMLGenerationOptions.h in Headers */ = {isa = PBXBuildFile; fileRef = 714D88CA2733FB970074A925 /* FBXMLGenerationOptions.h */; }; @@ -1158,7 +1162,6 @@ EE3A18661CDE734B00DE4205 /* FBKeyboard.h in Headers */ = {isa = PBXBuildFile; fileRef = EE3A18641CDE734B00DE4205 /* FBKeyboard.h */; settings = {ATTRIBUTES = (Public, ); }; }; EE3A18671CDE734B00DE4205 /* FBKeyboard.m in Sources */ = {isa = PBXBuildFile; fileRef = EE3A18651CDE734B00DE4205 /* FBKeyboard.m */; }; EE3F8CFE1D08AA17006F02CE /* FBRunLoopSpinnerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE3F8CFD1D08AA17006F02CE /* FBRunLoopSpinnerTests.m */; }; - A09D847635CA4C155583B967 /* FBXCAXClientProxyTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6231A764C3FAAB29B87CD987 /* FBXCAXClientProxyTests.m */; }; EE3F8D001D08B05F006F02CE /* FBElementTypeTransformerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE3F8CFF1D08B05F006F02CE /* FBElementTypeTransformerTests.m */; }; EE5095E51EBCC9090028E2FE /* FBTypingTest.m in Sources */ = {isa = PBXBuildFile; fileRef = AD76723F1D6B826F00610457 /* FBTypingTest.m */; }; EE5095EB1EBCC9090028E2FE /* XCElementSnapshotHitPointTests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE006EB21EBA1C7B006900A4 /* XCElementSnapshotHitPointTests.m */; }; @@ -1191,8 +1194,6 @@ EE8DDD7F20C5733C004D4925 /* XCUIElement+FBForceTouch.h in Headers */ = {isa = PBXBuildFile; fileRef = EE8DDD7D20C5733C004D4925 /* XCUIElement+FBForceTouch.h */; settings = {ATTRIBUTES = (Public, ); }; }; EE9AB8011CAEE048008C271F /* UITestingUITests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9AB7FD1CAEE048008C271F /* UITestingUITests.m */; }; EE9B76591CF7987800275851 /* FBRouteTests.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76571CF7987300275851 /* FBRouteTests.m */; }; - C312172CE7EE9B10B7A3034B /* FBHTTPServerSessionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = FB741D78562232A78902B374 /* FBHTTPServerSessionTests.m */; }; - DF82F4A8758B79DD91FA20CC /* FBHTTPServerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 76B7399DDF52C85A21433C1D /* FBHTTPServerTests.m */; }; EE9B768E1CF7997600275851 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76831CF7997600275851 /* AppDelegate.m */; }; EE9B768F1CF7997600275851 /* ViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76851CF7997600275851 /* ViewController.m */; }; EE9B76911CF7997600275851 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = EE9B76871CF7997600275851 /* main.m */; }; @@ -1478,6 +1479,7 @@ 3BA71BCF1FDA482419CA8596 /* XCUIPlatformApplicationServicesProviding-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIPlatformApplicationServicesProviding-Protocol.h"; sourceTree = ""; }; 3C710FE3AB9E9C22BD2A9E84 /* XCUIApplicationProcessManaging-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIApplicationProcessManaging-Protocol.h"; sourceTree = ""; }; 3DD2F42015E89D253EA57F63 /* XCUIRemoteAccessibilityInterface-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIRemoteAccessibilityInterface-Protocol.h"; sourceTree = ""; }; + 3F5C37472190A686297D3534 /* FBResponseJSONPayloadTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBResponseJSONPayloadTests.m; sourceTree = ""; }; 42D2B5A0C490D9698C2A87A9 /* XCTMacCatalystStatusProviding-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCTMacCatalystStatusProviding-Protocol.h"; sourceTree = ""; }; 42F7AB68482BA168011C52EA /* XCTTestSelection.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = XCTTestSelection.h; sourceTree = ""; }; 43FCB89739438814F43BCA24 /* XCUIApplicationProcessDelegate-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIApplicationProcessDelegate-Protocol.h"; sourceTree = ""; }; @@ -1498,6 +1500,7 @@ 5CF2F53B94CC13C628FC7760 /* XCUIDeviceAutomationModeInterface-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIDeviceAutomationModeInterface-Protocol.h"; sourceTree = ""; }; 5D7DD32943CAC7838F942ED5 /* ViewController.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = ViewController.m; sourceTree = ""; }; 603D97F0F9A5B9D4E6442BF2 /* XCUIApplicationAutomationSessionProviding-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIApplicationAutomationSessionProviding-Protocol.h"; sourceTree = ""; }; + 6231A764C3FAAB29B87CD987 /* FBXCAXClientProxyTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBXCAXClientProxyTests.m; sourceTree = ""; }; 62A682C55077710D1D90ABC3 /* XCTMessagingRole_CapabilityExchange-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCTMessagingRole_CapabilityExchange-Protocol.h"; sourceTree = ""; }; 631B523421F6174300625362 /* FBImageProcessorTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBImageProcessorTests.m; sourceTree = ""; }; 633E904A220DEE7F007CADF9 /* XCUIApplicationProcessDelay.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = XCUIApplicationProcessDelay.h; sourceTree = ""; }; @@ -1599,7 +1602,6 @@ 716F0D9F2A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSDictionary+FBUtf8SafeDictionary.h"; sourceTree = ""; }; 716F0DA02A16CA1000CDD977 /* NSDictionary+FBUtf8SafeDictionary.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSDictionary+FBUtf8SafeDictionary.m"; sourceTree = ""; }; 716F0DA52A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = NSDictionaryFBUtf8SafeTests.m; sourceTree = ""; }; - 3F5C37472190A686297D3534 /* FBResponseJSONPayloadTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBResponseJSONPayloadTests.m; sourceTree = ""; }; 717C0D702518ED2800CAA6EC /* TVOSSettings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TVOSSettings.xcconfig; sourceTree = ""; }; 717C0D862518ED7000CAA6EC /* TVOSTestSettings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = TVOSTestSettings.xcconfig; sourceTree = ""; }; 7183E8C2B556594311CB8898 /* XCUIRemoteSiriInterface-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIRemoteSiriInterface-Protocol.h"; sourceTree = ""; }; @@ -1676,6 +1678,7 @@ 75438FC693C39052C82C27DB /* XCUIApplicationRegistry.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = XCUIApplicationRegistry.h; sourceTree = ""; }; 758FC0D745C185A2C28BEDA1 /* IntegrationApp_watchOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IntegrationApp_watchOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 75F677B5B7737E7E1F321C20 /* WDAScreenshotAndSourceIntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WDAScreenshotAndSourceIntegrationTests.swift; sourceTree = ""; }; + 76B7399DDF52C85A21433C1D /* FBHTTPServerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBHTTPServerTests.m; sourceTree = ""; }; 7AA21CEBA6E92AAC73FB6A48 /* XCTAggregateSuiteRunStatistics.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = XCTAggregateSuiteRunStatistics.h; sourceTree = ""; }; 7CBAA574F7786985E01D0B6F /* XCTHarnessEventReporting-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCTHarnessEventReporting-Protocol.h"; sourceTree = ""; }; 7E079E00FE148476F94BC42F /* XCTTestIdentifierSet.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = XCTTestIdentifierSet.h; sourceTree = ""; }; @@ -1741,6 +1744,7 @@ B38AC76FAF9275974F272DE9 /* XCUIEventRecording-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCUIEventRecording-Protocol.h"; sourceTree = ""; }; B3FDA51EB36F03592BF48762 /* XCTMeasureOptions.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = XCTMeasureOptions.h; sourceTree = ""; }; B43D656732067585371ADD31 /* XCTMessagingRole_ProcessMonitoring-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCTMessagingRole_ProcessMonitoring-Protocol.h"; sourceTree = ""; }; + B817F776DE7106D3B7B1C049 /* FBCustomCommandsTests.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; path = FBCustomCommandsTests.m; sourceTree = ""; }; B8A163261EFA440E42CA6AC1 /* XCTRunnerDaemonSessionUIAutomationDelegate-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCTRunnerDaemonSessionUIAutomationDelegate-Protocol.h"; sourceTree = ""; }; B98A9F937EF98D6359FCCC7A /* XCTRuntimeIssueDetectionPolicy.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = XCTRuntimeIssueDetectionPolicy.h; sourceTree = ""; }; BCED63DFD03326F6351165FE /* WDAFindIntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WDAFindIntegrationTests.swift; sourceTree = ""; }; @@ -1829,7 +1833,6 @@ EE3A18641CDE734B00DE4205 /* FBKeyboard.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = FBKeyboard.h; path = WebDriverAgentLib/Utilities/FBKeyboard.h; sourceTree = SOURCE_ROOT; }; EE3A18651CDE734B00DE4205 /* FBKeyboard.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FBKeyboard.m; path = WebDriverAgentLib/Utilities/FBKeyboard.m; sourceTree = SOURCE_ROOT; }; EE3F8CFD1D08AA17006F02CE /* FBRunLoopSpinnerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBRunLoopSpinnerTests.m; sourceTree = ""; }; - 6231A764C3FAAB29B87CD987 /* FBXCAXClientProxyTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBXCAXClientProxyTests.m; sourceTree = ""; }; EE3F8CFF1D08B05F006F02CE /* FBElementTypeTransformerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FBElementTypeTransformerTests.m; sourceTree = ""; }; EE5095FE1EBCC9090028E2FE /* IntegrationTests_2.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntegrationTests_2.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EE55B3221D1D5388003AAAEC /* FBTableDataSource.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FBTableDataSource.h; sourceTree = ""; }; @@ -1909,8 +1912,6 @@ EE9B75D41CF7956C00275851 /* IntegrationApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IntegrationApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; EE9B75EC1CF7956C00275851 /* IntegrationTests_1.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntegrationTests_1.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EE9B76571CF7987300275851 /* FBRouteTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBRouteTests.m; sourceTree = ""; }; - FB741D78562232A78902B374 /* FBHTTPServerSessionTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBHTTPServerSessionTests.m; sourceTree = ""; }; - 76B7399DDF52C85A21433C1D /* FBHTTPServerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBHTTPServerTests.m; sourceTree = ""; }; EE9B76581CF7987300275851 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; EE9B76821CF7997600275851 /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; EE9B76831CF7997600275851 /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -1954,6 +1955,7 @@ F46F78706C5157469122F730 /* FBXCElementSnapshotDouble.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBXCElementSnapshotDouble.m; sourceTree = ""; }; F59CD6D22EF16E5E00F91287 /* XCUIElement+FBCustomActions.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "XCUIElement+FBCustomActions.h"; sourceTree = ""; }; F59CD6D32EF16E5E00F91287 /* XCUIElement+FBCustomActions.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "XCUIElement+FBCustomActions.m"; sourceTree = ""; }; + FB741D78562232A78902B374 /* FBHTTPServerSessionTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBHTTPServerSessionTests.m; sourceTree = ""; }; 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 = ""; }; @@ -2697,6 +2699,7 @@ 7139145B1DF01A12005896C2 /* NSExpressionFBFormatTests.m */, 71A224E71DE326C500844D55 /* NSPredicateFBFormatTests.m */, 713914591DF01989005896C2 /* XCUIElementHelpersTests.m */, + B817F776DE7106D3B7B1C049 /* FBCustomCommandsTests.m */, ); path = UnitTests; sourceTree = ""; @@ -4834,6 +4837,7 @@ EE6A89261D0B19E60083E92B /* FBSessionTests.m in Sources */, 71A7EAFC1E229302001DA4F2 /* FBClassChainTests.m in Sources */, EE18883D1DA663EB00307AA8 /* FBMathUtilsTests.m in Sources */, + 21C0068C990412E75A135DD4 /* FBCustomCommandsTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/WebDriverAgentLib/Commands/FBCustomCommands.m b/WebDriverAgentLib/Commands/FBCustomCommands.m index 585e72b90..c981d1b33 100644 --- a/WebDriverAgentLib/Commands/FBCustomCommands.m +++ b/WebDriverAgentLib/Commands/FBCustomCommands.m @@ -676,7 +676,7 @@ + (NSString *)timeZone if ([modifiers isKindOfClass:NSNumber.class]) { modifierFlags = [(NSNumber *)modifiers unsignedIntValue]; } - NSString *keyValue = [FBKeyboard keyValueForName:item] ?: key; + NSString *keyValue = [FBKeyboard keyValueForName:key] ?: key; [destination typeKey:keyValue modifierFlags:(XCUIKeyModifierFlags)modifierFlags]; } else { NSString *message = @"All items of the 'keys' array must be either dictionaries or strings"; diff --git a/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.h b/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.h index 1e3797e37..17bd0cb18 100644 --- a/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.h +++ b/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.h @@ -45,8 +45,11 @@ - (id _Nonnull)fb_standardSnapshot; - (id _Nonnull)fb_customSnapshot; - (nullable id)query; +- (void)typeKey:(nonnull NSString *)key modifierFlags:(NSUInteger)modifierFlags; // Checks @property (nonatomic, assign, readonly) BOOL didResolve; +@property (nonatomic, copy, readonly, nonnull) NSArray *typedKeys; +@property (nonatomic, assign, readonly) NSUInteger lastTypedModifierFlags; @end diff --git a/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.m b/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.m index e51dfdbb2..e6aafb3ea 100644 --- a/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.m +++ b/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.m @@ -10,6 +10,8 @@ @interface XCUIElementDouble () @property (nonatomic, assign, readwrite) BOOL didResolve; +@property (nonatomic, copy, readwrite, nonnull) NSArray *typedKeys; +@property (nonatomic, assign, readwrite) NSUInteger lastTypedModifierFlags; @end @implementation XCUIElementDouble @@ -48,10 +50,17 @@ - (id)init self.wdType = @"XCUIElementTypeOther"; self.wdUID = @"0"; self.lastSnapshot = nil; + self.typedKeys = @[]; } return self; } +- (void)typeKey:(NSString *)key modifierFlags:(NSUInteger)modifierFlags +{ + self.typedKeys = [self.typedKeys arrayByAddingObject:key]; + self.lastTypedModifierFlags = modifierFlags; +} + - (id)fb_valueForWDAttributeName:(NSString *)name { return @"test"; diff --git a/WebDriverAgentTests/UnitTests/FBCustomCommandsTests.m b/WebDriverAgentTests/UnitTests/FBCustomCommandsTests.m new file mode 100644 index 000000000..e8c106a6d --- /dev/null +++ b/WebDriverAgentTests/UnitTests/FBCustomCommandsTests.m @@ -0,0 +1,83 @@ +/** + * 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 "FBCustomCommands.h" +#import "FBElementCache.h" +#import "FBRouteRequest-Private.h" +#import "FBSession.h" +#import "Doubles/XCUIElementDouble.h" + +#if !TARGET_OS_TV && __clang_major__ >= 15 + +@interface FBCustomCommands (FBWDATestable) ++ (id)handleKeyboardInput:(FBRouteRequest *)request; +@end + +@interface FBCustomCommandsTests : XCTestCase +@property (nonatomic, strong) FBSession *session; +@end + +@implementation FBCustomCommandsTests + +- (void)setUp +{ + [super setUp]; + self.session = [FBSession initWithApplication:nil]; +} + +- (void)tearDown +{ + [self.session kill]; + [super tearDown]; +} + +- (FBRouteRequest *)requestWithElement:(XCUIElementDouble *)element keys:(NSArray *)keys +{ + // uuid "0" is reserved by handleKeyboardInput: to mean "no element" (use the active application). + element.wdUID = @"1"; + NSString *uuid = [self.session.elementCache storeElement:(XCUIElement *)element]; + FBRouteRequest *request = [FBRouteRequest routeRequestWithURL:[NSURL URLWithString:@"http://localhost:8100/"] + parameters:@{@"uuid": uuid} + arguments:@{@"keys": keys}]; + request.session = self.session; + return request; +} + +- (void)testDictionaryKeyWithConstantNameIsResolved +{ + XCUIElementDouble *element = XCUIElementDouble.new; + FBRouteRequest *request = [self requestWithElement:element + keys:@[@{@"key": @"XCUIKeyboardKeyTab"}]]; + [FBCustomCommands handleKeyboardInput:request]; + XCTAssertEqualObjects(element.typedKeys, @[XCUIKeyboardKeyTab]); +} + +- (void)testDictionaryKeyWithLiteralCharacterIsPassedThrough +{ + XCUIElementDouble *element = XCUIElementDouble.new; + FBRouteRequest *request = [self requestWithElement:element + keys:@[@{@"key": @"a"}]]; + [FBCustomCommands handleKeyboardInput:request]; + XCTAssertEqualObjects(element.typedKeys, @[@"a"]); +} + +- (void)testDictionaryKeyWithConstantNameAndModifierFlagsIsResolved +{ + XCUIElementDouble *element = XCUIElementDouble.new; + FBRouteRequest *request = [self requestWithElement:element + keys:@[@{@"key": @"XCUIKeyboardKeyTab", @"modifierFlags": @2}]]; + [FBCustomCommands handleKeyboardInput:request]; + XCTAssertEqualObjects(element.typedKeys, @[XCUIKeyboardKeyTab]); + XCTAssertEqual(element.lastTypedModifierFlags, 2); +} + +@end + +#endif From 2c97da55d180dbfb7a7e57b0a18307c71dbdaf2b Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Fri, 4 Sep 2026 05:25:10 +0000 Subject: [PATCH 36/46] chore(release): 16.12.3 [skip ci] ## [16.12.3](https://github.com/appium/WebDriverAgent/compare/v16.12.2...v16.12.3) (2026-09-04) ### Bug Fixes * resolve key name lookup for dictionary-form keyboardInput keys ([#1247](https://github.com/appium/WebDriverAgent/issues/1247)) ([f40bac6](https://github.com/appium/WebDriverAgent/commit/f40bac6ea77f6220883fe4437ee63e51fe44d74f)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6089d672e..dc0f0b9ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.12.3](https://github.com/appium/WebDriverAgent/compare/v16.12.2...v16.12.3) (2026-09-04) + +### Bug Fixes + +* resolve key name lookup for dictionary-form keyboardInput keys ([#1247](https://github.com/appium/WebDriverAgent/issues/1247)) ([f40bac6](https://github.com/appium/WebDriverAgent/commit/f40bac6ea77f6220883fe4437ee63e51fe44d74f)) + ## [16.12.2](https://github.com/appium/WebDriverAgent/compare/v16.12.1...v16.12.2) (2026-09-03) ### Bug Fixes diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 800f1f9b8..9235faff6 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.12.2 + 16.12.3 CFBundleSignature ???? CFBundleVersion - 16.12.2 + 16.12.3 NSPrincipalClass diff --git a/package.json b/package.json index 2fe7fd6ea..87134824d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.12.2", + "version": "16.12.3", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From de6acac7ebbaf70915f0c09093e3a12623efcea2 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Mon, 7 Sep 2026 05:26:22 +0900 Subject: [PATCH 37/46] chore: add coordinate screen in the integration app (#1251) * chore: add coordinate screen in the integration app * move the place * tweak * fix tests * follow system color --- WebDriverAgent.xcodeproj/project.pbxproj | 6 + .../xcschemes/IntegrationApp_watchOS.xcscheme | 2 + .../IntegrationApp/COORDINATE_PROBE.md | 50 +++++ .../Classes/FBCoordinateProbeViewController.h | 4 + .../Classes/FBCoordinateProbeViewController.m | 176 ++++++++++++++++++ .../IntegrationApp/Classes/ViewController.m | 10 +- WebDriverAgentTests/IntegrationApp/Info.plist | 4 +- .../Resources/Base.lproj/Main.storyboard | 38 ++-- .../IntegrationTests/FBConfigurationTests.m | 9 +- .../IntegrationTests/FBIntegrationTestCase.m | 1 + 10 files changed, 280 insertions(+), 20 deletions(-) create mode 100644 WebDriverAgentTests/IntegrationApp/COORDINATE_PROBE.md create mode 100644 WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.h create mode 100644 WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.m diff --git a/WebDriverAgent.xcodeproj/project.pbxproj b/WebDriverAgent.xcodeproj/project.pbxproj index 882034b73..909b17a1c 100644 --- a/WebDriverAgent.xcodeproj/project.pbxproj +++ b/WebDriverAgent.xcodeproj/project.pbxproj @@ -932,6 +932,7 @@ BEA2FA45BED17FE03010FECD /* FBFindElementCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = EE9AB7581CAEDF0C008C271F /* FBFindElementCommands.h */; }; BF9B191D841681A571BAFED1 /* _XCTestObservationPrivate-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = C840A8703A7C8D48897E158A /* _XCTestObservationPrivate-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; BFF5D9B9446DD542E5D6017D /* XCTTagSelection.h in Headers */ = {isa = PBXBuildFile; fileRef = 8E2092D85A7C691F157B1971 /* XCTTagSelection.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C01249000000000000000003 /* FBCoordinateProbeViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C01249000000000000000002 /* FBCoordinateProbeViewController.m */; }; C040AE5F2E3B934E8EEAEE0F /* XCUIApplicationProcessDelegate-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 43FCB89739438814F43BCA24 /* XCUIApplicationProcessDelegate-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; C07F140AF96143A0A5CAA2B0 /* XCTestCaseDiscoveryUIAutomationDelegate-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = E29888B75A756D1CCD21604C /* XCTestCaseDiscoveryUIAutomationDelegate-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; C1414C29C836902466C4D6DB /* XCTestCastMethodNamesUIAutomationDelegate-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = A80A1C12FA9899367D316E8C /* XCTestCastMethodNamesUIAutomationDelegate-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -1748,6 +1749,8 @@ B8A163261EFA440E42CA6AC1 /* XCTRunnerDaemonSessionUIAutomationDelegate-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "XCTRunnerDaemonSessionUIAutomationDelegate-Protocol.h"; sourceTree = ""; }; B98A9F937EF98D6359FCCC7A /* XCTRuntimeIssueDetectionPolicy.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = XCTRuntimeIssueDetectionPolicy.h; sourceTree = ""; }; BCED63DFD03326F6351165FE /* WDAFindIntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WDAFindIntegrationTests.swift; sourceTree = ""; }; + C01249000000000000000001 /* FBCoordinateProbeViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBCoordinateProbeViewController.h; sourceTree = ""; }; + C01249000000000000000002 /* FBCoordinateProbeViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBCoordinateProbeViewController.m; sourceTree = ""; }; C840A8703A7C8D48897E158A /* _XCTestObservationPrivate-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "_XCTestObservationPrivate-Protocol.h"; sourceTree = ""; }; C878996F07A9B26E66FC4EAC /* WDAClickIntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WDAClickIntegrationTests.swift; sourceTree = ""; }; C8FB547322D3949C00B69954 /* LSApplicationWorkspace.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = LSApplicationWorkspace.h; sourceTree = ""; }; @@ -2732,6 +2735,8 @@ EE55B3231D1D5388003AAAEC /* FBTableDataSource.m */, EE9B76841CF7997600275851 /* ViewController.h */, EE9B76851CF7997600275851 /* ViewController.m */, + C01249000000000000000001 /* FBCoordinateProbeViewController.h */, + C01249000000000000000002 /* FBCoordinateProbeViewController.m */, 315A14FF2518CB8700A3A064 /* TouchableView.h */, 315A15002518CB8700A3A064 /* TouchableView.m */, 315A15052518CC2800A3A064 /* TouchSpotView.h */, @@ -4851,6 +4856,7 @@ 315A15072518CC2800A3A064 /* TouchSpotView.m in Sources */, EE9B76911CF7997600275851 /* main.m in Sources */, EE9B768F1CF7997600275851 /* ViewController.m in Sources */, + C01249000000000000000003 /* FBCoordinateProbeViewController.m in Sources */, 315A15012518CB8700A3A064 /* TouchableView.m in Sources */, 315A150A2518D6F400A3A064 /* TouchViewController.m in Sources */, ADDA07241D6BB2BF001700AC /* FBScrollViewController.m in Sources */, diff --git a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme index 6dbf148f3..8f1d50331 100644 --- a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme +++ b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme @@ -46,6 +46,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "9D6B02D8FC050BAF7159284F" BuildableName = "IntegrationApp_watchOS.app" + BlueprintName = "IntegrationApp_watchOS" ReferencedContainer = "container:WebDriverAgent.xcodeproj"> @@ -62,6 +63,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "9D6B02D8FC050BAF7159284F" BuildableName = "IntegrationApp_watchOS.app" + BlueprintName = "IntegrationApp_watchOS" ReferencedContainer = "container:WebDriverAgent.xcodeproj"> diff --git a/WebDriverAgentTests/IntegrationApp/COORDINATE_PROBE.md b/WebDriverAgentTests/IntegrationApp/COORDINATE_PROBE.md new file mode 100644 index 000000000..820485fa7 --- /dev/null +++ b/WebDriverAgentTests/IntegrationApp/COORDINATE_PROBE.md @@ -0,0 +1,50 @@ +# Coordinate probe + +Open **Coordinate Probe** below **DeepHierarchy** on the IntegrationApp home screen. +Use the standard Back button to return to the existing fixtures. Opening the probe +creates a fresh measurement session. + +The **Touch** canvas records actual `UITouch.locationInView:` coordinates rather +than assuming that a gesture succeeded because it hit a large button. **Scroll** +shows 80 numbered rows, each 44 points high. + +## Automation identifiers + +| Identifier | Purpose | +| --- | --- | +| `coordinate-probe` | Home screen button that opens the probe | +| `probe-status` | Label containing the measurement JSON | +| `coordinate-canvas` | Touch target | +| `probe-mode` | Touch/Scroll segmented control | +| `probe-table` | Scrollable table | +| `probe-row-0` through `probe-row-79` | Individual table rows | + +The `probe-status` label exposes `start`, `last`, `phase`, and `count` after a +touch. `start` and `last` are **canvas-local app points**. It also exposes +`canvasBounds`, `canvasWindowRect`, `windowSize`, `screenSize`, and `scrollY`. +Geometry is refreshed when the view lays out, including after rotation or window +resizing. Touch-related fields are absent before the first touch. + +For example, locate `coordinate-probe` by accessibility ID and click it. Then +locate `coordinate-canvas`, send an element-relative action, and read the JSON +from `probe-status`. Check that `count` increased and compare `start`/`last` with +the expected canvas-local point within a suitable tolerance. Derive test offsets +from the current element rectangle rather than hard-coding a device size. + +## Native and compatibility builds + +The screen uses the same source for both configurations. Keep the IntegrationApp +scheme and set the build's `TARGETED_DEVICE_FAMILY`: + +- `1,2`: native support for iPhone and iPad (the existing default). +- `1`: iPhone-only; install on iPad to exercise iPhone compatibility mode. + +These are build settings, not a switch within the running app. Verify the actual +window and element frames before interpreting results. An iPad-native app can +also run in a resized window; maximize it when testing a full-screen native case. +For the compatibility case, compare app and SpringBoard window sizes and the +reported element rectangles with the recorded probe geometry. + +If keeping both builds installed at once, give them distinct bundle IDs. This +change does not add another app target or alter the existing bundle ID or signing +configuration. diff --git a/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.h b/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.h new file mode 100644 index 000000000..b51dfd399 --- /dev/null +++ b/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.h @@ -0,0 +1,4 @@ +#import + +@interface FBCoordinateProbeViewController : UIViewController +@end diff --git a/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.m b/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.m new file mode 100644 index 000000000..dc72e6946 --- /dev/null +++ b/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.m @@ -0,0 +1,176 @@ +#import "FBCoordinateProbeViewController.h" + +@interface FBCoordinateProbeCanvas : UIView +@property (nonatomic, copy) void (^onTouch)(NSString *phase, CGPoint point); +@end + +@implementation FBCoordinateProbeCanvas + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event +{ + self.onTouch(@"began", [touches.anyObject locationInView:self]); +} + +- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event +{ + self.onTouch(@"moved", [touches.anyObject locationInView:self]); +} + +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event +{ + self.onTouch(@"ended", [touches.anyObject locationInView:self]); +} + +- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event +{ + self.onTouch(@"cancelled", [touches.anyObject locationInView:self]); +} + +- (void)drawRect:(CGRect)rect +{ + CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSetStrokeColorWithColor(context, UIColor.whiteColor.CGColor); + for (NSInteger i = 1; i < 4; i++) { + CGFloat x = CGRectGetWidth(self.bounds) * i / 4.0; + CGFloat y = CGRectGetHeight(self.bounds) * i / 4.0; + CGContextMoveToPoint(context, x, 0); + CGContextAddLineToPoint(context, x, CGRectGetHeight(self.bounds)); + CGContextMoveToPoint(context, 0, y); + CGContextAddLineToPoint(context, CGRectGetWidth(self.bounds), y); + } + CGContextStrokePath(context); +} + +@end + +@interface FBCoordinateProbeViewController () +@property (nonatomic, strong) UILabel *statusLabel; +@property (nonatomic, strong) UISegmentedControl *modeControl; +@property (nonatomic, strong) FBCoordinateProbeCanvas *canvas; +@property (nonatomic, strong) UITableView *table; +@property (nonatomic, strong) NSMutableDictionary *measurement; +@property (nonatomic) NSUInteger touchCount; +@end + +@implementation FBCoordinateProbeViewController + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.title = @"Coordinate Probe"; + self.view.backgroundColor = UIColor.systemBackgroundColor; + self.measurement = [NSMutableDictionary dictionary]; + + self.statusLabel = [UILabel new]; + self.statusLabel.accessibilityIdentifier = @"probe-status"; + self.statusLabel.font = [UIFont monospacedSystemFontOfSize:10 weight:UIFontWeightRegular]; + self.statusLabel.numberOfLines = 0; + [self.view addSubview:self.statusLabel]; + + self.modeControl = [[UISegmentedControl alloc] initWithItems:@[@"Touch", @"Scroll"]]; + self.modeControl.accessibilityIdentifier = @"probe-mode"; + self.modeControl.selectedSegmentIndex = 0; + [self.modeControl addTarget:self action:@selector(modeChanged) forControlEvents:UIControlEventValueChanged]; + [self.view addSubview:self.modeControl]; + + self.canvas = [FBCoordinateProbeCanvas new]; + self.canvas.backgroundColor = UIColor.systemBlueColor; + self.canvas.isAccessibilityElement = YES; + self.canvas.accessibilityIdentifier = @"coordinate-canvas"; + self.canvas.accessibilityLabel = @"Coordinate canvas"; + self.canvas.accessibilityTraits = UIAccessibilityTraitAllowsDirectInteraction; + self.canvas.contentMode = UIViewContentModeRedraw; + __weak typeof(self) weakSelf = self; + self.canvas.onTouch = ^(NSString *phase, CGPoint point) { + FBCoordinateProbeViewController *controller = weakSelf; + if (nil == controller) { + return; + } + if ([phase isEqualToString:@"began"]) { + controller.touchCount++; + controller.measurement[@"start"] = @[@(point.x), @(point.y)]; + } + controller.measurement[@"last"] = @[@(point.x), @(point.y)]; + controller.measurement[@"phase"] = phase; + controller.measurement[@"count"] = @(controller.touchCount); + [controller publishMeasurement]; + }; + [self.view addSubview:self.canvas]; + + self.table = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain]; + self.table.accessibilityIdentifier = @"probe-table"; + self.table.rowHeight = 44; + self.table.dataSource = self; + self.table.delegate = self; + self.table.hidden = YES; + [self.view addSubview:self.table]; +} + +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + // Use the app's actual available area, including navigation bars and resized iPad windows. + CGRect safeFrame = self.view.safeAreaLayoutGuide.layoutFrame; + CGFloat width = MAX(0, CGRectGetWidth(safeFrame) - 40); + CGFloat left = CGRectGetMinX(safeFrame) + 20; + CGFloat top = CGRectGetMinY(safeFrame) + 8; + self.statusLabel.frame = CGRectMake(left, top, width, 96); + self.modeControl.frame = CGRectMake(left, top + 104, width, 30); + CGRect contentFrame = CGRectMake(left, top + 146, width, + MAX(0, CGRectGetMaxY(safeFrame) - top - 166)); + self.canvas.frame = contentFrame; + self.table.frame = contentFrame; + [self publishMeasurement]; +} + +- (void)publishMeasurement +{ + UIWindow *window = self.view.window; + if (nil == window) { + return; + } + CGRect bounds = self.canvas.bounds; + CGRect frame = [self.canvas convertRect:bounds toView:window]; + // Touch coordinates are local to the canvas. Window geometry lets tests compare + // those measurements with WDA's element rectangles without assuming a screen size. + self.measurement[@"canvasBounds"] = @[@(bounds.size.width), @(bounds.size.height)]; + self.measurement[@"canvasWindowRect"] = @[@(frame.origin.x), @(frame.origin.y), + @(frame.size.width), @(frame.size.height)]; + self.measurement[@"windowSize"] = @[@(window.bounds.size.width), @(window.bounds.size.height)]; + self.measurement[@"screenSize"] = @[@(window.screen.bounds.size.width), @(window.screen.bounds.size.height)]; + self.measurement[@"scrollY"] = @(self.table.contentOffset.y); + NSData *data = [NSJSONSerialization dataWithJSONObject:self.measurement + options:NSJSONWritingSortedKeys + error:nil]; + self.statusLabel.text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; +} + +- (void)modeChanged +{ + self.canvas.hidden = self.modeControl.selectedSegmentIndex == 1; + self.table.hidden = !self.canvas.hidden; + [self publishMeasurement]; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return 80; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"probe-row"]; + if (nil == cell) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"probe-row"]; + } + cell.textLabel.text = [NSString stringWithFormat:@"Probe row %ld", (long)indexPath.row]; + cell.accessibilityIdentifier = [NSString stringWithFormat:@"probe-row-%ld", (long)indexPath.row]; + return cell; +} + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView +{ + [self publishMeasurement]; +} + +@end diff --git a/WebDriverAgentTests/IntegrationApp/Classes/ViewController.m b/WebDriverAgentTests/IntegrationApp/Classes/ViewController.m index f63a91561..923ce1f98 100644 --- a/WebDriverAgentTests/IntegrationApp/Classes/ViewController.m +++ b/WebDriverAgentTests/IntegrationApp/Classes/ViewController.m @@ -7,6 +7,7 @@ */ #import "ViewController.h" +#import "FBCoordinateProbeViewController.h" @interface ViewController () @property (weak, nonatomic) IBOutlet UILabel *orentationLabel; @@ -30,6 +31,11 @@ - (void)viewDidLoad self.button.accessibilityCustomActions = @[action1, action2]; } +- (IBAction)showCoordinateProbe:(id)sender +{ + [self.navigationController pushViewController:[FBCoordinateProbeViewController new] animated:NO]; +} + - (BOOL)handleCustomAction:(UIAccessibilityCustomAction *)action { // Custom action handler - just return YES to indicate success @@ -57,13 +63,13 @@ - (IBAction)goToDeepHierarchy:(id)sender // lay out. This page exists purely as a fixture for exercising element // lookups (e.g. class chain locators) against a deep accessibility tree. UIViewController *deepHierarchyViewController = [UIViewController new]; - deepHierarchyViewController.view.backgroundColor = UIColor.whiteColor; + deepHierarchyViewController.view.backgroundColor = UIColor.systemBackgroundColor; deepHierarchyViewController.view.accessibilityIdentifier = @"DeepHierarchyPage"; NSInteger depth = 70; // A plain UILabel sibling, not part of the nested chain below, so the // fixture stays recognizable to a human glancing at the simulator instead - // of showing a blank white screen. + // of showing a blank screen. UILabel *titleLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, 60, CGRectGetWidth(UIScreen.mainScreen.bounds) - 40, 60)]; titleLabel.text = [NSString stringWithFormat:@"Deep Hierarchy\n%ld nested elements", (long)depth]; titleLabel.numberOfLines = 2; diff --git a/WebDriverAgentTests/IntegrationApp/Info.plist b/WebDriverAgentTests/IntegrationApp/Info.plist index 5dc963a62..557b7660c 100644 --- a/WebDriverAgentTests/IntegrationApp/Info.plist +++ b/WebDriverAgentTests/IntegrationApp/Info.plist @@ -30,8 +30,6 @@ Yo Yo NSPhotoLibraryUsageDescription Yo Yo - UILaunchStoryboardName - LaunchScreen UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -49,6 +47,8 @@ + UILaunchStoryboardName + LaunchScreen UIRequiredDeviceCapabilities armv7 diff --git a/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard b/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard index 7a9156769..6c10661ba 100644 --- a/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard +++ b/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard @@ -83,8 +83,19 @@ + - + @@ -101,6 +112,8 @@ + + @@ -237,7 +250,7 @@ - + @@ -282,7 +295,7 @@ - + - + @@ -527,7 +540,7 @@ - + @@ -548,7 +561,7 @@ - + @@ -561,7 +574,7 @@ - + @@ -603,7 +616,7 @@ - + @@ -624,6 +637,9 @@ + + + diff --git a/WebDriverAgentTests/IntegrationTests/FBConfigurationTests.m b/WebDriverAgentTests/IntegrationTests/FBConfigurationTests.m index 96d5dc7a0..ed44aa878 100644 --- a/WebDriverAgentTests/IntegrationTests/FBConfigurationTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBConfigurationTests.m @@ -21,14 +21,10 @@ @interface FBConfigurationTests : FBIntegrationTestCase @implementation FBConfigurationTests -- (void)setUp +- (void)testReduceMotion { - [super setUp]; [self launchApplication]; -} -- (void)testReduceMotion -{ BOOL defaultReduceMotionEnabled = FBConfiguration.sharedInstance.reduceMotionEnabled; FBConfiguration.sharedInstance.reduceMotionEnabled = YES; @@ -44,6 +40,9 @@ - (void)testAccessibilityDeadlineAbortsSnapshotRequestForDeadlockedApp XCTSkip(@"Deliberately freezes the app for several seconds, too slow/flaky for CI"); } + // Launch only after the CI skip so a skipped test cannot time out in app startup. + [self launchApplication]; + NSTimeInterval previousDeadline = FBConfiguration.sharedInstance.accessibilityDeadline; // Also bounds any snapshot-based wait -tap itself may perform once the app is stuck. FBConfiguration.sharedInstance.accessibilityDeadline = 3.0; diff --git a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m index e89af5ef6..cd661f0d5 100644 --- a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m +++ b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m @@ -34,6 +34,7 @@ @"Scrolling", @"Touch", @"DeepHierarchy", + @"Coordinate Probe", ]; @interface FBIntegrationTestCase () From 4fd551c39d08bdd86a2e8a447f4133cc6d8741ec Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Sun, 6 Sep 2026 20:30:43 +0000 Subject: [PATCH 38/46] chore(release): 16.12.4 [skip ci] ## [16.12.4](https://github.com/appium/WebDriverAgent/compare/v16.12.3...v16.12.4) (2026-09-06) ### Miscellaneous Chores * add coordinate screen in the integration app ([#1251](https://github.com/appium/WebDriverAgent/issues/1251)) ([de6acac](https://github.com/appium/WebDriverAgent/commit/de6acac7ebbaf70915f0c09093e3a12623efcea2)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc0f0b9ee..18923f068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.12.4](https://github.com/appium/WebDriverAgent/compare/v16.12.3...v16.12.4) (2026-09-06) + +### Miscellaneous Chores + +* add coordinate screen in the integration app ([#1251](https://github.com/appium/WebDriverAgent/issues/1251)) ([de6acac](https://github.com/appium/WebDriverAgent/commit/de6acac7ebbaf70915f0c09093e3a12623efcea2)) + ## [16.12.3](https://github.com/appium/WebDriverAgent/compare/v16.12.2...v16.12.3) (2026-09-04) ### Bug Fixes diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 9235faff6..2f76bf85e 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.12.3 + 16.12.4 CFBundleSignature ???? CFBundleVersion - 16.12.3 + 16.12.4 NSPrincipalClass diff --git a/package.json b/package.json index 87134824d..09d3c7725 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.12.3", + "version": "16.12.4", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From 6f8c0d19b6027ef4a2fa6a5a5e176d8182439323 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Mon, 7 Sep 2026 23:34:43 +0900 Subject: [PATCH 39/46] test: tune the test of testSpringBoardIcons for ios 27 (#1252) --- .../IntegrationTests/FBElementVisibilityTests.m | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/WebDriverAgentTests/IntegrationTests/FBElementVisibilityTests.m b/WebDriverAgentTests/IntegrationTests/FBElementVisibilityTests.m index 3f0dfa7e5..5f0c5f305 100644 --- a/WebDriverAgentTests/IntegrationTests/FBElementVisibilityTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBElementVisibilityTests.m @@ -13,6 +13,7 @@ #import "FBTestMacros.h" #import "FBXCodeCompatibility.h" #import "XCUIElement+FBIsVisible.h" +#import "XCUIElement+FBUtilities.h" @interface FBElementVisibilityTests : FBIntegrationTestCase @end @@ -26,14 +27,15 @@ - (void)testSpringBoardIcons } [self launchApplication]; [self goToSpringBoardFirstPage]; + [self.springboard fb_waitUntilStable]; - // Check Icons on first screen - // Note: Calender app exits 2 (an app icon + a widget) exist on the home screen - // on iOS 15+. The firstMatch is for it. - XCTAssertTrue(self.springboard.icons[@"Calendar"].firstMatch.fb_isVisible); - XCTAssertTrue(self.springboard.icons[@"Reminders"].fb_isVisible); + // Calendar can match both its app icon and a widget on iOS 15+. + FBAssertWaitTillBecomesTrue(self.springboard.icons[@"Calendar"].firstMatch.fb_isVisible); + // Safari is in the dock; Reminders is not reliably visible on the first page + // of CI simulators. Wait for the Home transition before checking visibility. + FBAssertWaitTillBecomesTrue(self.springboard.icons[@"Safari"].firstMatch.fb_isVisible); - // Check Icons on second screen screen + // Check the fixture icon on another page. XCTAssertFalse(self.springboard.icons[@"IntegrationApp"].firstMatch.fb_isVisible); } From 34859e9b443c184505206576b3b531d85d2afbcf Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Tue, 8 Sep 2026 04:54:55 +0900 Subject: [PATCH 40/46] fix: touch and scroll related view issue in IntegrationApp (#1253) --- 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 909b17a1c..886b043a6 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 a8fe79773..64fc1540e 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 7125306a3..dbe77e164 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 53d0c1836..7a4c227e3 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 9e7412ab7..0b406dab6 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 6c10661ba..cf45fb42b 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 000000000..cf0eb0c0f --- /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 000000000..3bf48f52f --- /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 From ec73b05961f4963ac2aa426c36e5b4eaf0e19af2 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 7 Sep 2026 19:59:02 +0000 Subject: [PATCH 41/46] chore(release): 16.12.5 [skip ci] ## [16.12.5](https://github.com/appium/WebDriverAgent/compare/v16.12.4...v16.12.5) (2026-09-07) ### Bug Fixes * touch and scroll related view issue in IntegrationApp ([#1253](https://github.com/appium/WebDriverAgent/issues/1253)) ([34859e9](https://github.com/appium/WebDriverAgent/commit/34859e9b443c184505206576b3b531d85d2afbcf)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18923f068..06a85f414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.12.5](https://github.com/appium/WebDriverAgent/compare/v16.12.4...v16.12.5) (2026-09-07) + +### Bug Fixes + +* touch and scroll related view issue in IntegrationApp ([#1253](https://github.com/appium/WebDriverAgent/issues/1253)) ([34859e9](https://github.com/appium/WebDriverAgent/commit/34859e9b443c184505206576b3b531d85d2afbcf)) + ## [16.12.4](https://github.com/appium/WebDriverAgent/compare/v16.12.3...v16.12.4) (2026-09-06) ### Miscellaneous Chores diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index 2f76bf85e..a06ac6056 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.12.4 + 16.12.5 CFBundleSignature ???? CFBundleVersion - 16.12.4 + 16.12.5 NSPrincipalClass diff --git a/package.json b/package.json index 09d3c7725..90f65e827 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.12.4", + "version": "16.12.5", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From 789b2c507936019ce0f5247a177411ecca6f367a Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Tue, 8 Sep 2026 13:03:04 +0900 Subject: [PATCH 42/46] test: improve the rotation stability a bit (#1254) * fix: touch and scroll related view issue in IntegrationApp * test: tweak the running * remove * improve * Revert "improve" This reverts commit 689083428b9a68ddf04ed4e88f2ed048f28f1a86. --- .../IntegrationTests/FBElementSwipingTests.m | 8 ++++---- .../IntegrationTests/FBIntegrationAppTests.m | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/WebDriverAgentTests/IntegrationTests/FBElementSwipingTests.m b/WebDriverAgentTests/IntegrationTests/FBElementSwipingTests.m index f652c81b4..7f560e033 100644 --- a/WebDriverAgentTests/IntegrationTests/FBElementSwipingTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBElementSwipingTests.m @@ -93,10 +93,10 @@ - (void)openScrollView - (void)setUp { [super setUp]; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - [self openScrollView]; - }); + // Each test (and retry) must start at row zero, not at the previous test's + // scroll offset. A velocity-based swipe need not undo an earlier swipe. + [self resetOrientation]; + [self openScrollView]; } - (void)testSwipeUp diff --git a/WebDriverAgentTests/IntegrationTests/FBIntegrationAppTests.m b/WebDriverAgentTests/IntegrationTests/FBIntegrationAppTests.m index cf0eb0c0f..32cfc5bd9 100644 --- a/WebDriverAgentTests/IntegrationTests/FBIntegrationAppTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBIntegrationAppTests.m @@ -47,10 +47,19 @@ - (void)testTouchControlsRemainOnScreenAfterRotation NSUInteger count = 0; for (NSNumber *orientation in orientations) { [self rotateTo:orientation.integerValue]; + // XCTest can report idle before the rotation animation finishes. Re-read + // the geometry until the controls have reached their on-screen layout. + FBAssertWaitTillBecomesTrue( + CGRectContainsRect(self.testedApplication.frame, canvas.frame) + && CGRectContainsRect(self.testedApplication.frame, taps.frame) + && CGRectContainsRect(self.testedApplication.frame, touches.frame)); CGRect screen = self.testedApplication.frame; - XCTAssertTrue(CGRectContainsRect(screen, canvas.frame)); - XCTAssertTrue(CGRectContainsRect(screen, taps.frame)); - XCTAssertTrue(CGRectContainsRect(screen, touches.frame)); + XCTAssertTrue(CGRectContainsRect(screen, canvas.frame), @"Screen: %@; canvas: %@", + NSStringFromCGRect(screen), NSStringFromCGRect(canvas.frame)); + XCTAssertTrue(CGRectContainsRect(screen, taps.frame), @"Screen: %@; taps: %@", + NSStringFromCGRect(screen), NSStringFromCGRect(taps.frame)); + XCTAssertTrue(CGRectContainsRect(screen, touches.frame), @"Screen: %@; touches: %@", + NSStringFromCGRect(screen), NSStringFromCGRect(touches.frame)); XCTAssertGreaterThan(CGRectGetHeight(canvas.frame), 0); [canvas tap]; NSString *expectedTaps = [NSString stringWithFormat:@"%lu", (unsigned long)++count]; From 25fcd9558314c367c755b047cd0db0949654e90b Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Wed, 9 Sep 2026 12:49:08 +0200 Subject: [PATCH 43/46] fix: rescale gesture coordinates for compatibility-mode window mismatches (#1249) --- .../Categories/XCUIElement+FBForceTouch.m | 7 +- .../Categories/XCUIElement+FBPickerWheel.m | 5 +- .../Categories/XCUIElement+FBScrolling.m | 159 +++++++++++++----- .../Commands/FBElementCommands.m | 38 ++++- .../Utilities/FBBaseActionsSynthesizer.m | 4 +- WebDriverAgentLib/Utilities/FBMathUtils.h | 28 +++ WebDriverAgentLib/Utilities/FBMathUtils.m | 27 ++- .../Utilities/FBW3CActionsSynthesizer.m | 6 +- .../IntegrationApp/Classes/TouchableView.m | 8 +- .../IntegrationTests/FBIntegrationTestCase.h | 7 + .../IntegrationTests/FBIntegrationTestCase.m | 10 ++ .../IntegrationTests/FBTapTest.m | 83 +++++++++ 12 files changed, 316 insertions(+), 66 deletions(-) diff --git a/WebDriverAgentLib/Categories/XCUIElement+FBForceTouch.m b/WebDriverAgentLib/Categories/XCUIElement+FBForceTouch.m index bd5d0bdd3..5d9bd9bdf 100644 --- a/WebDriverAgentLib/Categories/XCUIElement+FBForceTouch.m +++ b/WebDriverAgentLib/Categories/XCUIElement+FBForceTouch.m @@ -11,6 +11,7 @@ #if !TARGET_OS_TV #import "FBErrorBuilder.h" +#import "FBMathUtils.h" #import "XCUICoordinate.h" #import "XCUIDevice.h" @@ -36,8 +37,10 @@ - (BOOL)fb_forceTouchCoordinate:(NSValue *)relativeCoordinate } else { CGVector offset = CGVectorMake(relativeCoordinate.CGPointValue.x, relativeCoordinate.CGPointValue.y); - XCUICoordinate *hitPoint = [[self coordinateWithNormalizedOffset:CGVectorMake(0, 0)] - coordinateWithOffset:offset]; + XCUICoordinate *hitPoint = FBCoordinateWithAnchorOffset(self, CGVectorMake(0, 0), offset, error); + if (nil == hitPoint) { + return NO; + } if (nil == pressure || nil == duration) { [hitPoint forcePress]; } else { diff --git a/WebDriverAgentLib/Categories/XCUIElement+FBPickerWheel.m b/WebDriverAgentLib/Categories/XCUIElement+FBPickerWheel.m index 3b361e569..c49bf2559 100644 --- a/WebDriverAgentLib/Categories/XCUIElement+FBPickerWheel.m +++ b/WebDriverAgentLib/Categories/XCUIElement+FBPickerWheel.m @@ -25,8 +25,9 @@ - (BOOL)fb_scrollWithOffset:(CGFloat)relativeHeightOffset error:(NSError **)erro { id snapshot = [self fb_standardSnapshot]; NSString *previousValue = snapshot.value; - XCUICoordinate *startCoord = [self coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5)]; - XCUICoordinate *endCoord = [startCoord coordinateWithOffset:CGVectorMake(0.0, relativeHeightOffset * snapshot.frame.size.height)]; + // Stay in normalized offsets end-to-end: XCTest never rescales a composed raw + // coordinateWithOffset: for compatibility-mode windows (appium/appium#16185). + XCUICoordinate *endCoord = [self coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5 + relativeHeightOffset)]; // If picker value is reflected in its accessiblity id // then fetching of the next snapshot may fail with StaleElementReferenceError // because we bound elements by their accessbility ids by default. diff --git a/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m b/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m index 5f470a3e3..6760e4fa1 100644 --- a/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m +++ b/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m @@ -16,6 +16,8 @@ #import "FBXCElementSnapshotWrapper.h" #import "FBXCElementSnapshotWrapper+Helpers.h" #import "XCUIElement+FBCaching.h" +#import "XCUIElement+FBResolve.h" +#import "XCUIElement+FBUID.h" #import "XCUIApplication.h" #import "XCUICoordinate.h" #import "XCUIElement+FBIsVisible.h" @@ -35,15 +37,37 @@ @interface FBXCElementSnapshotWrapper (FBScrolling) -- (void)fb_scrollUpByNormalizedDistance:(CGFloat)distance inApplication:(XCUIApplication *)application; -- (void)fb_scrollDownByNormalizedDistance:(CGFloat)distance inApplication:(XCUIApplication *)application; -- (void)fb_scrollLeftByNormalizedDistance:(CGFloat)distance inApplication:(XCUIApplication *)application; -- (void)fb_scrollRightByNormalizedDistance:(CGFloat)distance inApplication:(XCUIApplication *)application; -- (BOOL)fb_scrollByNormalizedVector:(CGVector)normalizedScrollVector inApplication:(XCUIApplication *)application; -- (BOOL)fb_scrollByVector:(CGVector)vector inApplication:(XCUIApplication *)application error:(NSError **)error; +- (BOOL)fb_scrollUpByNormalizedDistance:(CGFloat)distance anchorElement:(XCUIElement *)anchorElement; +- (BOOL)fb_scrollDownByNormalizedDistance:(CGFloat)distance anchorElement:(XCUIElement *)anchorElement; +- (BOOL)fb_scrollLeftByNormalizedDistance:(CGFloat)distance anchorElement:(XCUIElement *)anchorElement; +- (BOOL)fb_scrollRightByNormalizedDistance:(CGFloat)distance anchorElement:(XCUIElement *)anchorElement; +- (BOOL)fb_scrollByNormalizedVector:(CGVector)normalizedScrollVector anchorElement:(XCUIElement *)anchorElement; +- (BOOL)fb_scrollByVector:(CGVector)vector anchorElement:(XCUIElement *)anchorElement error:(NSError **)error; @end +/** + Resolves a live element for the given snapshot, so gesture coordinates can be anchored + to it (its frame gets rescaled by XCTest for compatibility-mode windows; a raw + XCUIApplication anchor never does - see appium/appium#16185). Returns nil, rather than + falling back to the application, if the snapshot can no longer be located: anchoring to + the application would silently reproduce the very bug this is fixing. + */ +static XCUIElement *FBLiveElementForSnapshot(id snapshot, XCUIApplication *application) +{ + NSString *uid = [FBXCElementSnapshotWrapper wdUIDWithSnapshot:snapshot]; + if (nil == uid) { + return nil; + } + XCUIElement *result; + @autoreleasepool { + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"%K = %@", FBStringify(FBXCElementSnapshotWrapper, fb_uid), uid]; + result = [[application.fb_query descendantsMatchingType:XCUIElementTypeAny] matchingPredicate:predicate].allElementsBoundByIndex.firstObject; + } + result.fb_isResolvedNatively = @NO; + return result; +} + @implementation XCUIElement (FBScrolling) - (BOOL)fb_nativeScrollToVisibleWithError:(NSError **)error @@ -61,28 +85,28 @@ - (void)fb_scrollUpByNormalizedDistance:(CGFloat)distance { id snapshot = [self fb_customSnapshot]; [[FBXCElementSnapshotWrapper ensureWrapped:snapshot] fb_scrollUpByNormalizedDistance:distance - inApplication:self.application]; + anchorElement:self]; } - (void)fb_scrollDownByNormalizedDistance:(CGFloat)distance { id snapshot = [self fb_customSnapshot]; [[FBXCElementSnapshotWrapper ensureWrapped:snapshot] fb_scrollDownByNormalizedDistance:distance - inApplication:self.application]; + anchorElement:self]; } - (void)fb_scrollLeftByNormalizedDistance:(CGFloat)distance { id snapshot = [self fb_customSnapshot]; [[FBXCElementSnapshotWrapper ensureWrapped:snapshot] fb_scrollLeftByNormalizedDistance:distance - inApplication:self.application]; + anchorElement:self]; } - (void)fb_scrollRightByNormalizedDistance:(CGFloat)distance { id snapshot = [self fb_customSnapshot]; [[FBXCElementSnapshotWrapper ensureWrapped:snapshot] fb_scrollRightByNormalizedDistance:distance - inApplication:self.application]; + anchorElement:self]; } - (BOOL)fb_scrollToVisibleWithError:(NSError **)error @@ -176,30 +200,52 @@ - (BOOL)fb_scrollToVisibleWithNormalizedScrollDistance:(CGFloat)normalizedScroll } } + // The scroll view's own identity is stable across scroll steps, so it only needs + // to be resolved to a live element once, up front; its frame does not, since it can + // change across scroll steps (rotation, keyboard, dynamic layout). + XCUIElement *scrollViewElement = FBLiveElementForSnapshot(scrollView, self.application); + if (nil == scrollViewElement) { + return + [[[FBErrorBuilder builder] + withDescriptionFormat:@"Failed to resolve a live element for the scrollable parent of '%@'", self.description] + buildError:error]; + } + const NSUInteger maxScrollCount = 25; NSUInteger scrollCount = 0; - FBXCElementSnapshotWrapper *scrollViewWrapped = [FBXCElementSnapshotWrapper ensureWrapped:scrollView]; + FBXCElementSnapshotWrapper *scrollViewWrapped; // Scrolling till cell is visible and get current value of frames while (![self fb_isEquivalentElementSnapshotVisible:prescrollSnapshot] && scrollCount < maxScrollCount) { + BOOL didScroll; @autoreleasepool { + // Re-snapshotting the scroll view every step keeps its frame from drifting too far + // out of sync with the live anchor element's frame used to resolve touch points. + scrollViewWrapped = [FBXCElementSnapshotWrapper ensureWrapped:[scrollViewElement fb_customSnapshot]]; if (targetCellIndex < visibleCellIndex) { - scrollDirection == FBXCUIElementScrollDirectionVertical ? + didScroll = scrollDirection == FBXCUIElementScrollDirectionVertical ? [scrollViewWrapped fb_scrollUpByNormalizedDistance:normalizedScrollDistance - inApplication:self.application] : + anchorElement:scrollViewElement] : [scrollViewWrapped fb_scrollLeftByNormalizedDistance:normalizedScrollDistance - inApplication:self.application]; + anchorElement:scrollViewElement]; } else { - scrollDirection == FBXCUIElementScrollDirectionVertical ? + didScroll = scrollDirection == FBXCUIElementScrollDirectionVertical ? [scrollViewWrapped fb_scrollDownByNormalizedDistance:normalizedScrollDistance - inApplication:self.application] : + anchorElement:scrollViewElement] : [scrollViewWrapped fb_scrollRightByNormalizedDistance:normalizedScrollDistance - inApplication:self.application]; + anchorElement:scrollViewElement]; } scrollCount++; // Wait for scroll animation [self fb_waitUntilStableWithTimeout:FBConfiguration.sharedInstance.animationCoolOffTimeout]; } + // The `error` out-param must not be written from inside the autorelease pool above. + if (!didScroll) { + return + [[[FBErrorBuilder builder] + withDescriptionFormat:@"Failed to scroll '%@': its frame is empty", self.description] + buildError:error]; + } } if (scrollCount >= maxScrollCount) { @@ -215,12 +261,13 @@ - (BOOL)fb_scrollToVisibleWithNormalizedScrollDistance:(CGFloat)normalizedScroll FBXCElementSnapshotWrapper *targetCellSnapshotWrapped = [FBXCElementSnapshotWrapper ensureWrapped:[self fb_customSnapshot]]; targetCellSnapshot = [targetCellSnapshotWrapped fb_parentCellSnapshot]; CGRect visibleFrame = [FBXCElementSnapshotWrapper ensureWrapped:targetCellSnapshot].fb_visibleFrame; - + CGVector scrollVector = CGVectorMake(visibleFrame.size.width - targetCellSnapshot.frame.size.width, visibleFrame.size.height - targetCellSnapshot.frame.size.height ); + scrollViewWrapped = [FBXCElementSnapshotWrapper ensureWrapped:[scrollViewElement fb_customSnapshot]]; return [scrollViewWrapped fb_scrollByVector:scrollVector - inApplication:self.application + anchorElement:scrollViewElement error:error]; } @@ -254,42 +301,42 @@ - (CGRect)scrollingFrame return self.visibleFrame; } -- (void)fb_scrollUpByNormalizedDistance:(CGFloat)distance - inApplication:(XCUIApplication *)application +- (BOOL)fb_scrollUpByNormalizedDistance:(CGFloat)distance + anchorElement:(XCUIElement *)anchorElement { - [self fb_scrollByNormalizedVector:CGVectorMake(0.0, distance) inApplication:application]; + return [self fb_scrollByNormalizedVector:CGVectorMake(0.0, distance) anchorElement:anchorElement]; } -- (void)fb_scrollDownByNormalizedDistance:(CGFloat)distance - inApplication:(XCUIApplication *)application +- (BOOL)fb_scrollDownByNormalizedDistance:(CGFloat)distance + anchorElement:(XCUIElement *)anchorElement { - [self fb_scrollByNormalizedVector:CGVectorMake(0.0, -distance) inApplication:application]; + return [self fb_scrollByNormalizedVector:CGVectorMake(0.0, -distance) anchorElement:anchorElement]; } -- (void)fb_scrollLeftByNormalizedDistance:(CGFloat)distance - inApplication:(XCUIApplication *)application +- (BOOL)fb_scrollLeftByNormalizedDistance:(CGFloat)distance + anchorElement:(XCUIElement *)anchorElement { - [self fb_scrollByNormalizedVector:CGVectorMake(distance, 0.0) inApplication:application]; + return [self fb_scrollByNormalizedVector:CGVectorMake(distance, 0.0) anchorElement:anchorElement]; } -- (void)fb_scrollRightByNormalizedDistance:(CGFloat)distance - inApplication:(XCUIApplication *)application +- (BOOL)fb_scrollRightByNormalizedDistance:(CGFloat)distance + anchorElement:(XCUIElement *)anchorElement { - [self fb_scrollByNormalizedVector:CGVectorMake(-distance, 0.0) inApplication:application]; + return [self fb_scrollByNormalizedVector:CGVectorMake(-distance, 0.0) anchorElement:anchorElement]; } - (BOOL)fb_scrollByNormalizedVector:(CGVector)normalizedScrollVector - inApplication:(XCUIApplication *)application + anchorElement:(XCUIElement *)anchorElement { CGVector scrollVector = CGVectorMake(CGRectGetWidth(self.scrollingFrame) * normalizedScrollVector.dx, CGRectGetHeight(self.scrollingFrame) * normalizedScrollVector.dy ); - return [self fb_scrollByVector:scrollVector inApplication:application error:nil]; + return [self fb_scrollByVector:scrollVector anchorElement:anchorElement error:nil]; } - (BOOL)fb_scrollByVector:(CGVector)vector - inApplication:(XCUIApplication *)application - error:(NSError **)error + anchorElement:(XCUIElement *)anchorElement + error:(NSError **)error { CGVector scrollBoundingVector = CGVectorMake( CGRectGetWidth(self.scrollingFrame) * FBScrollTouchProportion, @@ -306,29 +353,49 @@ - (BOOL)fb_scrollByVector:(CGVector)vector fabs(vector.dy) > fabs(scrollBoundingVector.dy) ? scrollBoundingVector.dy : vector.dy); vector = CGVectorMake(vector.dx - scrollVector.dx, vector.dy - scrollVector.dy); shouldFinishScrolling = FBVectorFuzzyEqualToVector(vector, CGZeroVector, 1) || --preciseScrollAttemptsCount <= 0; - if (![self fb_scrollAncestorScrollViewByVectorWithinScrollViewFrame:scrollVector inApplication:application error:error]){ + if (![self fb_scrollAncestorScrollViewByVectorWithinScrollViewFrame:scrollVector anchorElement:anchorElement error:error]){ return NO; } } return YES; } -- (CGVector)fb_hitPointOffsetForScrollingVector:(CGVector)scrollingVector +// Normalized (0.0-1.0) touch-down offset within the scrolling frame, for the given +// scroll vector's direction. +- (CGVector)fb_normalizedHitPointOffsetForScrollingVector:(CGVector)scrollingVector { - CGFloat x = CGRectGetMinX(self.scrollingFrame) + CGRectGetWidth(self.scrollingFrame) * (scrollingVector.dx < 0.0f ? FBScrollTouchProportion : (1 - FBScrollTouchProportion)); - CGFloat y = CGRectGetMinY(self.scrollingFrame) + CGRectGetHeight(self.scrollingFrame) * (scrollingVector.dy < 0.0f ? FBScrollTouchProportion : (1 - FBScrollTouchProportion)); - return CGVectorMake((CGFloat)floor(x), (CGFloat)floor(y)); + CGFloat x = scrollingVector.dx < 0.0f ? FBScrollTouchProportion : (1 - FBScrollTouchProportion); + CGFloat y = scrollingVector.dy < 0.0f ? FBScrollTouchProportion : (1 - FBScrollTouchProportion); + return CGVectorMake(x, y); } - (BOOL)fb_scrollAncestorScrollViewByVectorWithinScrollViewFrame:(CGVector)vector - inApplication:(XCUIApplication *)application - error:(NSError **)error + anchorElement:(XCUIElement *)anchorElement + error:(NSError **)error { - CGVector hitpointOffset = [self fb_hitPointOffsetForScrollingVector:vector]; + CGRect scrollingFrame = self.scrollingFrame; + CGRect anchorFrame = anchorElement.frame; + if (CGRectIsEmpty(scrollingFrame) || CGRectIsEmpty(anchorFrame)) { + return [[[FBErrorBuilder builder] + withDescriptionFormat:@"Cannot compute a scroll gesture for '%@': its frame is empty", self.fb_description] + buildError:error]; + } - XCUICoordinate *appCoordinate = [[XCUICoordinate alloc] initWithElement:application normalizedOffset:CGVectorMake(0.0, 0.0)]; - XCUICoordinate *startCoordinate = [[XCUICoordinate alloc] initWithCoordinate:appCoordinate pointsOffset:hitpointOffset]; - XCUICoordinate *endCoordinate = [[XCUICoordinate alloc] initWithCoordinate:startCoordinate pointsOffset:vector]; + // Compute the touch-down/up points within the (possibly clipped) scrolling frame as + // before, then express them as fractions of the anchor element's own frame instead of + // raw points, which XCTest never rescales for compatibility-mode windows + // (appium/appium#16185). When scrollingFrame == anchorFrame this resolves to the exact + // same absolute point as before; it only differs once XCTest itself rescales anchorFrame. + CGVector proportion = [self fb_normalizedHitPointOffsetForScrollingVector:vector]; + CGPoint startPoint = CGPointMake((CGFloat)floor(scrollingFrame.origin.x + scrollingFrame.size.width * proportion.dx), + (CGFloat)floor(scrollingFrame.origin.y + scrollingFrame.size.height * proportion.dy)); + CGPoint endPoint = CGPointMake((CGFloat)floor(startPoint.x + vector.dx), (CGFloat)floor(startPoint.y + vector.dy)); + CGVector startOffset = CGVectorMake((startPoint.x - anchorFrame.origin.x) / anchorFrame.size.width, + (startPoint.y - anchorFrame.origin.y) / anchorFrame.size.height); + CGVector endOffset = CGVectorMake((endPoint.x - anchorFrame.origin.x) / anchorFrame.size.width, + (endPoint.y - anchorFrame.origin.y) / anchorFrame.size.height); + XCUICoordinate *startCoordinate = [anchorElement coordinateWithNormalizedOffset:startOffset]; + XCUICoordinate *endCoordinate = [anchorElement coordinateWithNormalizedOffset:endOffset]; if (FBPointFuzzyEqualToPoint(startCoordinate.screenPoint, endCoordinate.screenPoint, FBFuzzyPointThreshold)) { return YES; diff --git a/WebDriverAgentLib/Commands/FBElementCommands.m b/WebDriverAgentLib/Commands/FBElementCommands.m index 2594eed94..a46958bce 100644 --- a/WebDriverAgentLib/Commands/FBElementCommands.m +++ b/WebDriverAgentLib/Commands/FBElementCommands.m @@ -353,14 +353,23 @@ + (NSArray *)routes + (id)handlePressAndDragCoordinateWithVelocity:(FBRouteRequest *)request { XCUIApplication *application = request.session.activeApplication; + NSError *error; CGVector startOffset = CGVectorMake((CGFloat)[request.arguments[@"fromX"] doubleValue], (CGFloat)[request.arguments[@"fromY"] doubleValue]); XCUICoordinate *startCoordinate = [self.class gestureCoordinateWithOffset:startOffset - element:application]; + element:application + error:&error]; + if (nil == startCoordinate) { + return FBResponseWithStatus([FBCommandStatus invalidElementStateErrorWithMessage:error.description traceback:nil]); + } CGVector endOffset = CGVectorMake((CGFloat)[request.arguments[@"toX"] doubleValue], (CGFloat)[request.arguments[@"toY"] doubleValue]); XCUICoordinate *endCoordinate = [self.class gestureCoordinateWithOffset:endOffset - element:application]; + element:application + error:&error]; + if (nil == endCoordinate) { + return FBResponseWithStatus([FBCommandStatus invalidElementStateErrorWithMessage:error.description traceback:nil]); + } [startCoordinate pressForDuration:[request.arguments[@"pressDuration"] doubleValue] thenDragToCoordinate:endCoordinate withVelocity:[request.arguments[@"velocity"] doubleValue] @@ -433,12 +442,19 @@ + (NSArray *)routes + (id)handleDrag:(FBRouteRequest *)request { XCUIElement *target = [self targetFromRequest:request]; + NSError *error; CGVector startOffset = CGVectorMake([request.arguments[@"fromX"] doubleValue], [request.arguments[@"fromY"] doubleValue]); - XCUICoordinate *startCoordinate = [self.class gestureCoordinateWithOffset:startOffset element:target]; + XCUICoordinate *startCoordinate = [self.class gestureCoordinateWithOffset:startOffset element:target error:&error]; + if (nil == startCoordinate) { + return FBResponseWithStatus([FBCommandStatus invalidElementStateErrorWithMessage:error.description traceback:nil]); + } CGVector endOffset = CGVectorMake([request.arguments[@"toX"] doubleValue], [request.arguments[@"toY"] doubleValue]); - XCUICoordinate *endCoordinate = [self.class gestureCoordinateWithOffset:endOffset element:target]; + XCUICoordinate *endCoordinate = [self.class gestureCoordinateWithOffset:endOffset element:target error:&error]; + if (nil == endCoordinate) { + return FBResponseWithStatus([FBCommandStatus invalidElementStateErrorWithMessage:error.description traceback:nil]); + } NSTimeInterval duration = [request.arguments[@"duration"] doubleValue]; [startCoordinate pressForDuration:duration thenDragToCoordinate:endCoordinate]; return FBResponseWithOK(); @@ -659,12 +675,15 @@ + (NSArray *)routes @param offset absolute screen offset for the given application @param element the element instance to perform the gesture on - @return translated gesture coordinates ready to be passed to XCUICoordinate methods + @param error Error instance if any + @return translated gesture coordinates ready to be passed to XCUICoordinate methods, or + nil if the element is not visible on the screen */ -+ (XCUICoordinate *)gestureCoordinateWithOffset:(CGVector)offset - element:(XCUIElement *)element ++ (nullable XCUICoordinate *)gestureCoordinateWithOffset:(CGVector)offset + element:(XCUIElement *)element + error:(NSError **)error { - return [[element coordinateWithNormalizedOffset:CGVectorMake(0, 0)] coordinateWithOffset:offset]; + return FBCoordinateWithAnchorOffset(element, CGVectorMake(0, 0), offset, error); } /** @@ -688,7 +707,8 @@ + (nullable id)targetWithXyCoordinatesFromRequest:(FBRouteRequest *)request erro return nil; } return [self gestureCoordinateWithOffset:CGVectorMake(x.doubleValue, y.doubleValue) - element:[self targetFromRequest:request]]; + element:[self targetFromRequest:request] + error:error]; } /** diff --git a/WebDriverAgentLib/Utilities/FBBaseActionsSynthesizer.m b/WebDriverAgentLib/Utilities/FBBaseActionsSynthesizer.m index a5cd5f07f..ef8604ce6 100644 --- a/WebDriverAgentLib/Utilities/FBBaseActionsSynthesizer.m +++ b/WebDriverAgentLib/Utilities/FBBaseActionsSynthesizer.m @@ -74,9 +74,9 @@ - (nullable XCUICoordinate *)hitpointWithElement:(nullable XCUIElement *)element return [element coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5)]; } - CGVector offset = CGVectorMake(positionOffset.CGPointValue.x, positionOffset.CGPointValue.y); // TODO: Shall we throw an exception if hitPoint is out of the element frame? - return [[element coordinateWithNormalizedOffset:CGVectorMake(0, 0)] coordinateWithOffset:offset]; + CGVector offset = CGVectorMake(positionOffset.CGPointValue.x, positionOffset.CGPointValue.y); + return FBCoordinateWithAnchorOffset((XCUIElement *)element, CGVectorMake(0, 0), offset, error); } @end diff --git a/WebDriverAgentLib/Utilities/FBMathUtils.h b/WebDriverAgentLib/Utilities/FBMathUtils.h index df9a11696..d0e1939ef 100644 --- a/WebDriverAgentLib/Utilities/FBMathUtils.h +++ b/WebDriverAgentLib/Utilities/FBMathUtils.h @@ -9,6 +9,10 @@ #import @class XCUIApplication; +@class XCUICoordinate; +@class XCUIElement; + +NS_ASSUME_NONNULL_BEGIN extern CGFloat FBDefaultFrameFuzzyThreshold; @@ -34,3 +38,27 @@ BOOL FBRectFuzzyEqualToRect(CGRect rect1, CGRect rect2, CGFloat threshold); /*! Inverts size if necessary to match current screen orientation */ CGSize FBAdjustDimensionsForApplication(CGSize actualSize, UIInterfaceOrientation orientation); #endif + +#if !TARGET_OS_TV +/*! + Builds a coordinate for the given element from a raw points offset measured from a + normalized anchor point within the element's own frame - e.g. (0, 0) for an offset + relative to the top-left corner, (0.5, 0.5) for one relative to the center, as W3C + actions use. The offset is normalized against the element's wdFrame (the same + WDA-reported coordinate space pointsOffset itself is measured in) instead of being + passed through as a raw points offset, which XCTest never rescales for + compatibility-mode windows (see appium/appium#16185). + + @param element the element to anchor the coordinate to + @param anchorOffset normalized offset of the anchor point within the element's wdFrame + @param pointsOffset raw points offset from the anchor point, in wdFrame's coordinate space + @param error populated if the element's frame is empty (not visible on the screen) + @return the resulting coordinate, or nil if the element's frame is empty + */ +XCUICoordinate * _Nullable FBCoordinateWithAnchorOffset(XCUIElement *element, + CGVector anchorOffset, + CGVector pointsOffset, + NSError **error); +#endif + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBMathUtils.m b/WebDriverAgentLib/Utilities/FBMathUtils.m index 807f34905..dacdc54aa 100644 --- a/WebDriverAgentLib/Utilities/FBMathUtils.m +++ b/WebDriverAgentLib/Utilities/FBMathUtils.m @@ -8,7 +8,11 @@ #import "FBMathUtils.h" +#import "FBErrorBuilder.h" #import "FBMacros.h" +#import "XCUICoordinate.h" +#import "XCUIElement.h" +#import "XCUIElement+FBWebDriverAttributes.h" CGFloat FBDefaultFrameFuzzyThreshold = 2.0; @@ -51,7 +55,7 @@ CGSize FBAdjustDimensionsForApplication(CGSize actualSize, UIInterfaceOrientatio if (orientation == UIInterfaceOrientationLandscapeLeft || orientation == UIInterfaceOrientationLandscapeRight) { /* There is an XCTest bug that application.frame property returns exchanged dimensions for landscape mode. - This verification is just to make sure the bug is still there (since height is never greater than width in landscape) + This verification is just to make sure the bug is still there (since height is never greater than width in landscape) and to make it still working properly after XCTest itself starts to respect landscape mode. */ if (actualSize.height > actualSize.width) { @@ -61,3 +65,24 @@ This verification is just to make sure the bug is still there (since height is n return actualSize; } #endif + +#if !TARGET_OS_TV +XCUICoordinate *FBCoordinateWithAnchorOffset(XCUIElement *element, + CGVector anchorOffset, + CGVector pointsOffset, + NSError **error) +{ + // wdFrame matches the coordinate space pointsOffset was measured in; element.frame alone + // can already be pre-scaled for a compatibility-mode window mismatch, double-applying it. + CGRect frame = element.wdFrame; + if (CGRectIsEmpty(frame)) { + [[[FBErrorBuilder builder] + withDescriptionFormat:@"The element '%@' is not visible on the screen and thus is not interactable", element.description] + buildError:error]; + return nil; + } + CGVector normalizedOffset = CGVectorMake(anchorOffset.dx + pointsOffset.dx / frame.size.width, + anchorOffset.dy + pointsOffset.dy / frame.size.height); + return [element coordinateWithNormalizedOffset:normalizedOffset]; +} +#endif diff --git a/WebDriverAgentLib/Utilities/FBW3CActionsSynthesizer.m b/WebDriverAgentLib/Utilities/FBW3CActionsSynthesizer.m index 8323c4a3b..c37f2a443 100644 --- a/WebDriverAgentLib/Utilities/FBW3CActionsSynthesizer.m +++ b/WebDriverAgentLib/Utilities/FBW3CActionsSynthesizer.m @@ -162,7 +162,7 @@ - (nullable XCUICoordinate *)hitpointWithElement:(nullable XCUIElement *)element return [super hitpointWithElement:element positionOffset:positionOffset error:error]; } - // An offset relative to the element is defined + // An offset relative to the element is defined. if (CGRectIsEmpty(element.frame)) { [FBLogger log:self.application.fb_descriptionRepresentation]; NSString *description = [NSString stringWithFormat:@"The element '%@' is not visible on the screen and thus is not interactable", @@ -174,9 +174,9 @@ - (nullable XCUICoordinate *)hitpointWithElement:(nullable XCUIElement *)element } // W3C standard requires that relative element coordinates start at the center of the element's rectangle - CGVector offset = CGVectorMake(positionOffset.CGPointValue.x, positionOffset.CGPointValue.y); // TODO: Shall we throw an exception if hitPoint is out of the element frame? - return [[element coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5)] coordinateWithOffset:offset]; + CGVector offset = CGVectorMake(positionOffset.CGPointValue.x, positionOffset.CGPointValue.y); + return FBCoordinateWithAnchorOffset((XCUIElement *)element, CGVectorMake(0.5, 0.5), offset, error); } @end diff --git a/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.m b/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.m index 0b406dab6..51be35ae6 100644 --- a/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.m +++ b/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.m @@ -78,9 +78,15 @@ - (void)createViewForTouch:(UITouch *)touch { if (touch) { + CGPoint location = [touch locationInView:self]; + // Exposes the last touch-down location, in this view's own bounds coordinate + // space, for tests to assert on regardless of any window-level scaling. + self.isAccessibilityElement = YES; + self.accessibilityValue = [NSString stringWithFormat:@"%.2f,%.2f", location.x, location.y]; + TouchSpotView *newView = [[TouchSpotView alloc] init]; newView.bounds = CGRectMake(0, 0, 1, 1); - newView.center = [touch locationInView:self]; + newView.center = location; [self addSubview:newView]; [UIView animateWithDuration:0.2 animations:^{ newView.bounds = CGRectMake(0, 0, 100, 100); diff --git a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.h b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.h index a2b8faf92..a9a09a5b8 100644 --- a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.h +++ b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.h @@ -91,4 +91,11 @@ extern NSArray *const FBMainViewButtonLabels; */ - (void)resetOrientation; +/** + appium/appium#16185: skips the current test unless the app's window size actually + differs from SpringBoard's, e.g. an iPhone-only app on iPad (built with + TARGETED_DEVICE_FAMILY=1). + */ +- (void)skipUnlessWindowSizeMismatchesDevice; + @end diff --git a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m index cd661f0d5..a02d78980 100644 --- a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m +++ b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m @@ -163,4 +163,14 @@ - (void)clearAlert FBAssertWaitTillBecomesTrue(self.testedApplication.alerts.count == 0); } +- (void)skipUnlessWindowSizeMismatchesDevice +{ + CGSize appSize = self.testedApplication.frame.size; + CGSize deviceSize = self.springboard.frame.size; + if (fabs(appSize.width - deviceSize.width) < 1 && fabs(appSize.height - deviceSize.height) < 1) { + XCTSkip(@"App window size matches SpringBoard's on this build/device, so it does not " + "reproduce the compatibility-mode mismatch from appium/appium#16185"); + } +} + @end diff --git a/WebDriverAgentTests/IntegrationTests/FBTapTest.m b/WebDriverAgentTests/IntegrationTests/FBTapTest.m index 2d3d2ba8b..96f05f9a2 100644 --- a/WebDriverAgentTests/IntegrationTests/FBTapTest.m +++ b/WebDriverAgentTests/IntegrationTests/FBTapTest.m @@ -11,9 +11,13 @@ #import "FBIntegrationTestCase.h" #import "FBElementCache.h" +#import "FBMathUtils.h" #import "FBTestMacros.h" +#import "XCUIApplication+FBTouchAction.h" +#import "XCUICoordinate.h" #import "XCUIDevice+FBRotation.h" #import "XCUIElement+FBIsVisible.h" +#import "XCUIElement+FBWebDriverAttributes.h" @interface FBTapTest : FBIntegrationTestCase @end @@ -100,4 +104,83 @@ - (void)testTapCoordinatesInPortraitUpsideDown [self verifyTapByCoordinatesWithOrientation:UIDeviceOrientationPortraitUpsideDown]; } +// Element-less absolute offsets are never rescaled by XCTest's XCUICoordinate (verified by +// disassembling XCUIAutomation.framework), so this still fails under a window-size mismatch. +- (void)testTapAtElementRectCenterUnderWindowSizeMismatch +{ + [self skipUnlessWindowSizeMismatchesDevice]; + + XCUIElement *dstButton = self.testedApplication.buttons[FBShowAlertButtonName]; + CGRect rect = dstButton.wdFrame; + CGPoint center = CGPointMake(CGRectGetMidX(rect), CGRectGetMidY(rect)); + + // Mirrors FBBaseActionsSynthesizer's hitpointWithElement:positionOffset: + // for an absolute (x, y) offset, as used by touch/perform and W3C actions. + XCUICoordinate *appOrigin = [self.testedApplication coordinateWithNormalizedOffset:CGVectorMake(0, 0)]; + XCUICoordinate *tapPoint = [appOrigin coordinateWithOffset:CGVectorMake(center.x, center.y)]; + [tapPoint tap]; + + XCTExpectFailureInBlock(@"element-less absolute offsets are never rescaled by XCTest for a " + "compatibility-mode window (appium/appium#16185); starts failing " + "loudly here the moment XCTest fixes this itself", ^{ + FBAssertWaitTillBecomesTrue(self.testedApplication.alerts.count > 0); + }); +} + +@end + +// The Touch page's touchable view records each touch-down's location, in its own bounds +// coordinate space, as its accessibility value - a ground truth unaffected by any +// window-level scaling, letting these tests assert on exact landing position rather than +// just on whether a tap happened to land inside some (possibly large) target. +@interface FBElementOffsetTapTest : FBIntegrationTestCase +@end + +@implementation FBElementOffsetTapTest + +- (void)setUp +{ + [super setUp]; + [self launchApplication]; + [self goToTouchPage]; +} + +- (CGPoint)lastTouchLocationOf:(XCUIElement *)touchable +{ + NSString *value = touchable.value; + NSArray *components = [value componentsSeparatedByString:@","]; + return CGPointMake(components.firstObject.doubleValue, components.lastObject.doubleValue); +} + +// FBW3CActionsSynthesizer normalizes element-relative offsets against the element's own +// frame, so this keeps landing at the intended point under a window-size mismatch +// (appium/appium#16185), unlike an element-less absolute offset. +- (void)testTapWithElementOffsetUnderWindowSizeMismatch +{ + [self skipUnlessWindowSizeMismatchesDevice]; + + XCUIElement *touchable = self.testedApplication.otherElements[@"touchableView"]; + CGSize size = touchable.wdFrame.size; + CGVector offset = CGVectorMake(size.width / 4, -size.height / 4); + CGPoint expectedLocation = CGPointMake(size.width / 2 + offset.dx, size.height / 2 + offset.dy); + + NSArray *> *gesture = + @[@{ + @"type": @"pointer", + @"id": @"finger1", + @"parameters": @{@"pointerType": @"touch"}, + @"actions": @[ + @{@"type": @"pointerMove", @"duration": @0, @"origin": touchable, @"x": @(offset.dx), @"y": @(offset.dy)}, + @{@"type": @"pointerDown"}, + @{@"type": @"pause", @"duration": @50}, + @{@"type": @"pointerUp"}, + ], + }, + ]; + NSError *error; + XCTAssertTrue([self.testedApplication fb_performW3CActions:gesture elementCache:nil error:&error]); + + FBAssertWaitTillBecomesTrue(FBPointFuzzyEqualToPoint([self lastTouchLocationOf:touchable], expectedLocation, 5.0)); +} + @end From 23b864e7cc2cd1e94bbbc83b3a32e501f1f2b484 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 9 Sep 2026 10:53:58 +0000 Subject: [PATCH 44/46] chore(release): 16.12.6 [skip ci] ## [16.12.6](https://github.com/appium/WebDriverAgent/compare/v16.12.5...v16.12.6) (2026-09-09) ### Bug Fixes * rescale gesture coordinates for compatibility-mode window mismatches ([#1249](https://github.com/appium/WebDriverAgent/issues/1249)) ([25fcd95](https://github.com/appium/WebDriverAgent/commit/25fcd9558314c367c755b047cd0db0949654e90b)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06a85f414..15e68e36a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.12.6](https://github.com/appium/WebDriverAgent/compare/v16.12.5...v16.12.6) (2026-09-09) + +### Bug Fixes + +* rescale gesture coordinates for compatibility-mode window mismatches ([#1249](https://github.com/appium/WebDriverAgent/issues/1249)) ([25fcd95](https://github.com/appium/WebDriverAgent/commit/25fcd9558314c367c755b047cd0db0949654e90b)) + ## [16.12.5](https://github.com/appium/WebDriverAgent/compare/v16.12.4...v16.12.5) (2026-09-07) ### Bug Fixes diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index a06ac6056..a6b549a8c 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.12.5 + 16.12.6 CFBundleSignature ???? CFBundleVersion - 16.12.5 + 16.12.6 NSPrincipalClass diff --git a/package.json b/package.json index 90f65e827..50ed77727 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.12.5", + "version": "16.12.6", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", From f5f70041e463777d7604e84fd8d27bf11cb268b9 Mon Sep 17 00:00:00 2001 From: Kazuaki Matsuo Date: Thu, 10 Sep 2026 13:37:18 +0900 Subject: [PATCH 45/46] fix: remaining strong box file (#1255) * fix: remaining strong box file * leave the todo * leave exported const as deprecated --- lib/constants.ts | 3 + lib/webdriveragent.ts | 39 +++------- test/unit/wda-cleanup.spec.ts | 136 ++++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 28 deletions(-) create mode 100644 test/unit/wda-cleanup.spec.ts diff --git a/lib/constants.ts b/lib/constants.ts index 44561c17a..98235a2d8 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -13,6 +13,9 @@ export const PLATFORM_NAME_IOS = 'iOS'; export const SDK_DEVICE = 'iphoneos'; +/** + * @deprecated The WDA upgrade timestamp path is no longer used. + */ export const WDA_UPGRADE_TIMESTAMP_PATH = path.join('.appium', 'webdriveragent', 'upgrade.time'); /** diff --git a/lib/webdriveragent.ts b/lib/webdriveragent.ts index 736b3fdf8..b55a48167 100644 --- a/lib/webdriveragent.ts +++ b/lib/webdriveragent.ts @@ -7,12 +7,7 @@ import type {AppiumLogger, StringRecord} from '@appium/types'; import AsyncLock from 'async-lock'; import {waitForCondition} from 'asyncbox'; -import { - WDA_RUNNER_BUNDLE_ID, - WDA_BASE_URL, - WDA_UPGRADE_TIMESTAMP_PATH, - DEFAULT_TEST_BUNDLE_SUFFIX, -} from './constants.js'; +import {WDA_RUNNER_BUNDLE_ID, WDA_BASE_URL, DEFAULT_TEST_BUNDLE_SUFFIX} from './constants.js'; import {log as defaultLogger} from './logger.js'; import {NoSessionProxy} from './no-session-proxy.js'; import type { @@ -584,31 +579,19 @@ export class WebDriverAgent { const packageInfo = JSON.parse(await fs.readFile(path.join(BOOTSTRAP_PATH, 'package.json'), 'utf8')); const box = strongbox(packageInfo.name); - let boxItem = box.getItem(RECENT_MODULE_VERSION_ITEM_NAME); - if (!boxItem) { - const timestampPath = path.resolve(process.env.HOME ?? '', WDA_UPGRADE_TIMESTAMP_PATH); - if (await fs.exists(timestampPath)) { - // TODO: It is probably a bit ugly to hardcode the recent version string, - // TODO: hovewer it should do the job as a temporary transition trick - // TODO: to switch from a hardcoded file path to the strongbox usage. - try { - boxItem = await box.createItemWithValue(RECENT_MODULE_VERSION_ITEM_NAME, '5.0.0'); - } catch (e: any) { - this.log.warn(`The actual module version cannot be persisted: ${e.message}`); - return; - } - } else { - this.log.info('There is no need to perform the project cleanup. A fresh install has been detected'); - try { - await box.createItemWithValue(RECENT_MODULE_VERSION_ITEM_NAME, packageInfo.version); - } catch (e: any) { - this.log.warn(`The actual module version cannot be persisted: ${e.message}`); - } - return; + // Each Strongbox instance starts with an empty item map. Load the persisted value from disk. + const boxItem = await box.createItem(RECENT_MODULE_VERSION_ITEM_NAME); + let recentModuleVersion = boxItem.value; + if (recentModuleVersion === undefined) { + this.log.info('There is no need to perform the project cleanup. A fresh install has been detected'); + try { + await boxItem.write(packageInfo.version); + } catch (e: any) { + this.log.warn(`The actual module version cannot be persisted: ${e.message}`); } + return; } - let recentModuleVersion = await boxItem.read(); try { recentModuleVersion = util.coerceVersion(recentModuleVersion, true); } catch (e: any) { diff --git a/test/unit/wda-cleanup.spec.ts b/test/unit/wda-cleanup.spec.ts new file mode 100644 index 000000000..ed1489800 --- /dev/null +++ b/test/unit/wda-cleanup.spec.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import {mkdtemp, readFile, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {afterEach, beforeEach, describe, it, mock} from 'node:test'; + +import {strongbox} from '@appium/strongbox'; +import {fs} from '@appium/support'; +import sinon from 'sinon'; + +import {BOOTSTRAP_PATH} from '../../lib/utils/index.js'; + +let container: string; +function isolatedStrongbox(name: string) { + const box = strongbox(name); + // Preserve the temporary path verbatim instead of Strongbox's container slugification. + Object.defineProperty(box, 'container', {value: container}); + return box; +} +mock.module('@appium/strongbox', { + namedExports: { + strongbox: isolatedStrongbox, + }, +}); +const {WebDriverAgent} = await import('../../lib/webdriveragent.js'); +const packageInfo = JSON.parse(await readFile(path.join(BOOTSTRAP_PATH, 'package.json'), 'utf8')); +const itemName = 'recentWdaModuleVersion'; + +describe('WDA project cleanup persistence', function () { + let sandbox: sinon.SinonSandbox; + let legacyMarker: sinon.SinonStub; + + beforeEach(async function () { + container = await mkdtemp(path.join(tmpdir(), 'wda-cleanup-')); + sandbox = sinon.createSandbox(); + legacyMarker = sandbox + .stub(fs, 'exists') + .withArgs(path.resolve(process.env.HOME ?? '', '.appium', 'webdriveragent', 'upgrade.time')) + .resolves(false); + }); + + afterEach(async function () { + sandbox.restore(); + await rm(container, {recursive: true, force: true}); + }); + + function newAgent() { + const agent = new WebDriverAgent({device: {udid: 'test-udid'}, platformVersion: '17.2'}); + const clean = sandbox.stub(agent.xcodebuild, 'cleanProject').resolves(); + return {clean, run: async () => await (agent as any)._cleanupProjectIfFresh()}; + } + + async function persist(version: string) { + await isolatedStrongbox(packageInfo.name).createItemWithValue(itemName, version); + } + + async function persistedVersion() { + return (await isolatedStrongbox(packageInfo.name).createItem(itemName)).value; + } + + it('reuses the persisted version despite a legacy marker across new agents', async function () { + legacyMarker.resolves(true); + await persist(packageInfo.version); + for (let i = 0; i < 2; i++) { + const agent = newAgent(); + await agent.run(); + sandbox.assert.notCalled(agent.clean); + } + sandbox.assert.notCalled(legacyMarker); + assert.equal(await persistedVersion(), packageInfo.version); + }); + + it('cleans an older persisted version once without a legacy marker', async function () { + await persist('5.0.0'); + const first = newAgent(); + await first.run(); + sandbox.assert.calledOnce(first.clean); + assert.equal(await persistedVersion(), packageInfo.version); + const second = newAgent(); + await second.run(); + sandbox.assert.notCalled(second.clean); + sandbox.assert.notCalled(legacyMarker); + }); + + it('initializes missing version state without consulting a legacy marker', async function () { + legacyMarker.resolves(true); + const first = newAgent(); + await first.run(); + sandbox.assert.notCalled(first.clean); + assert.equal(await persistedVersion(), packageInfo.version); + const second = newAgent(); + await second.run(); + sandbox.assert.notCalled(second.clean); + sandbox.assert.notCalled(legacyMarker); + }); + + it('initializes a fresh installation without cleaning', async function () { + for (let i = 0; i < 2; i++) { + const agent = newAgent(); + await agent.run(); + sandbox.assert.notCalled(agent.clean); + } + sandbox.assert.notCalled(legacyMarker); + assert.equal(await persistedVersion(), packageInfo.version); + }); + + it('preserves a newer persisted version', async function () { + await persist('999.0.0'); + const agent = newAgent(); + await agent.run(); + sandbox.assert.notCalled(agent.clean); + assert.equal(await persistedVersion(), '999.0.0'); + }); + + it('repairs a damaged persisted version without treating it as legacy state', async function () { + legacyMarker.resolves(true); + await persist('not-a-version'); + const agent = newAgent(); + await agent.run(); + sandbox.assert.notCalled(agent.clean); + sandbox.assert.notCalled(legacyMarker); + assert.equal(await persistedVersion(), packageInfo.version); + }); + + it('retries cleanup after a failure without recording a successful upgrade', async function () { + await persist('5.0.0'); + const first = newAgent(); + first.clean.rejects(new Error('cleanup failed')); + await first.run(); + assert.equal(await persistedVersion(), '5.0.0'); + const second = newAgent(); + await second.run(); + sandbox.assert.calledOnce(second.clean); + assert.equal(await persistedVersion(), packageInfo.version); + }); +}); From 6a2ae0565a5f783eaeac6afdc8d1c3067cbfc8a6 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Thu, 10 Sep 2026 04:42:24 +0000 Subject: [PATCH 46/46] chore(release): 16.12.7 [skip ci] ## [16.12.7](https://github.com/appium/WebDriverAgent/compare/v16.12.6...v16.12.7) (2026-09-10) ### Bug Fixes * remaining strong box file ([#1255](https://github.com/appium/WebDriverAgent/issues/1255)) ([f5f7004](https://github.com/appium/WebDriverAgent/commit/f5f70041e463777d7604e84fd8d27bf11cb268b9)) --- CHANGELOG.md | 6 ++++++ WebDriverAgentLib/Info.plist | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e68e36a..bf95805d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [16.12.7](https://github.com/appium/WebDriverAgent/compare/v16.12.6...v16.12.7) (2026-09-10) + +### Bug Fixes + +* remaining strong box file ([#1255](https://github.com/appium/WebDriverAgent/issues/1255)) ([f5f7004](https://github.com/appium/WebDriverAgent/commit/f5f70041e463777d7604e84fd8d27bf11cb268b9)) + ## [16.12.6](https://github.com/appium/WebDriverAgent/compare/v16.12.5...v16.12.6) (2026-09-09) ### Bug Fixes diff --git a/WebDriverAgentLib/Info.plist b/WebDriverAgentLib/Info.plist index a6b549a8c..f7c20b132 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.12.6 + 16.12.7 CFBundleSignature ???? CFBundleVersion - 16.12.6 + 16.12.7 NSPrincipalClass diff --git a/package.json b/package.json index 50ed77727..33c9e6fe9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.12.6", + "version": "16.12.7", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium",