From 21c98f253561ee3dfc93edfb1c4de6afc3200b50 Mon Sep 17 00:00:00 2001 From: Mykola Mokhnach Date: Thu, 27 Aug 2026 07:30:26 +0200 Subject: [PATCH 01/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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];