diff --git a/.github/workflows/wda-tests.yml b/.github/workflows/wda-tests.yml index 9c13ebe357..2b6e1747bc 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: diff --git a/CHANGELOG.md b/CHANGELOG.md index ae2fed9271..dc0f0b9eed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,103 @@ +## [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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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/Fastlane/Fastfile b/Fastlane/Fastfile index 6efd41b078..5e70686bfa 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/PrivateHeaders/XCTest/CDStructures.h b/PrivateHeaders/XCTest/CDStructures.h index 27a5eaacb7..a79d7d8a14 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 cbb9c438b1..882034b733 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, ); }; }; @@ -839,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 */; }; @@ -939,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 */; }; @@ -1016,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 */; }; @@ -1038,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 */; }; @@ -1474,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 = ""; }; @@ -1494,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 = ""; }; @@ -1671,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 = ""; }; @@ -1736,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 = ""; }; @@ -1946,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 = ""; }; @@ -2673,18 +2683,23 @@ 713352FC26CEF31D00523CBC /* FBLRUCacheTests.m */, EE18883C1DA663EB00307AA8 /* FBMathUtilsTests.m */, 718F49C7230844330045FE8B /* FBProtocolHelpersTests.m */, + FB741D78562232A78902B374 /* FBHTTPServerSessionTests.m */, + 76B7399DDF52C85A21433C1D /* FBHTTPServerTests.m */, EE9B76571CF7987300275851 /* FBRouteTests.m */, EE3F8CFD1D08AA17006F02CE /* FBRunLoopSpinnerTests.m */, ADEF63AE1D09DEBE0070A7E3 /* FBRuntimeUtilsTests.m */, 714801D01FA9D9FA00DC5997 /* FBSDKVersionTests.m */, EE6A89251D0B19E60083E92B /* FBSessionTests.m */, + 6231A764C3FAAB29B87CD987 /* FBXCAXClientProxyTests.m */, 716E0BD01E917F260087A825 /* FBXMLSafeStringTests.m */, 712A0C841DA3E459007D02E5 /* FBXPathTests.m */, EE9B76581CF7987300275851 /* Info.plist */, 716F0DA52A17323300CDD977 /* NSDictionaryFBUtf8SafeTests.m */, + 3F5C37472190A686297D3534 /* FBResponseJSONPayloadTests.m */, 7139145B1DF01A12005896C2 /* NSExpressionFBFormatTests.m */, 71A224E71DE326C500844D55 /* NSPredicateFBFormatTests.m */, 713914591DF01989005896C2 /* XCUIElementHelpersTests.m */, + B817F776DE7106D3B7B1C049 /* FBCustomCommandsTests.m */, ); path = UnitTests; sourceTree = ""; @@ -4796,6 +4811,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 */, @@ -4806,11 +4822,14 @@ 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 */, 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 */, @@ -4818,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/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationTests_1.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationTests_1.xcscheme index ac0de96534..2b4967b79d 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"> + + + + + + + + + + + + + + + + + + + + = 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/Categories/XCUIApplicationProcess+FBQuiescence.m b/WebDriverAgentLib/Categories/XCUIApplicationProcess+FBQuiescence.m index 74d9716373..3088911e3d 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/Categories/XCUIElement+FBClassChain.m b/WebDriverAgentLib/Categories/XCUIElement+FBClassChain.m index 1be6dc7744..1bf7f957d4 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 7a616424ba..1312cc2f40 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/Commands/FBCustomCommands.m b/WebDriverAgentLib/Commands/FBCustomCommands.m index 91a58fc2ab..c981d1b33a 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 @@ -174,10 +176,21 @@ + (NSArray *)routes @"statusBarSize": @{@"width": @(statusBarSize.width), @"height": @(statusBarSize.height), }, + @"displayId": @([FBScreen displayID]), @"scale": @([FBScreen scale]), }); } ++ (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; @@ -663,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/WebDriverAgentLib/Commands/FBSessionCommands.m b/WebDriverAgentLib/Commands/FBSessionCommands.m index 98675ed1c8..d5f516aead 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/Info.plist b/WebDriverAgentLib/Info.plist index 185ec8aa5b..9235faff66 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.8.0 + 16.12.3 CFBundleSignature ???? CFBundleVersion - 16.8.0 + 16.12.3 NSPrincipalClass diff --git a/WebDriverAgentLib/Routing/FBHTTPServer.m b/WebDriverAgentLib/Routing/FBHTTPServer.m index 1532086a9c..175b8f9b45 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,23 @@ @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). +@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 +163,9 @@ - (instancetype)init _connectionsAwaitingResponse = [NSMutableSet set]; _standaloneWaiters = [NSMutableDictionary dictionary]; _pendingSessionRequests = [NSMutableDictionary dictionary]; + _abandonedSessionResponses = [NSMutableDictionary dictionary]; + _incompleteRequestStarts = [NSMapTable mapTableWithKeyOptions:(NSPointerFunctionsOptions)(NSMapTableObjectPointerPersonality | NSMapTableStrongMemory) + valueOptions:(NSPointerFunctionsOptions)NSMapTableStrongMemory]; } return self; } @@ -226,18 +273,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 +333,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 +344,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 +359,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 +412,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 *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 *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; } - - 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; + 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; +} - 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]; - } +// 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; } + 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 +598,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]; @@ -460,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) = ^{ @@ -491,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; } } @@ -526,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]; @@ -546,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; @@ -612,19 +852,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 +899,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/FBResponseJSONPayload.m b/WebDriverAgentLib/Routing/FBResponseJSONPayload.m index 8782b8e9e2..4deb397f7d 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/WebDriverAgentLib/Routing/FBScreenRecordingContainer.h b/WebDriverAgentLib/Routing/FBScreenRecordingContainer.h index ce655dd7c0..eb1c51216a 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 608d7bfdfc..31bf9fc150 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 821891ea36..406ebfc109 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 34a80db0ea..1eac8bcc7d 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]; } } } diff --git a/WebDriverAgentLib/Routing/FBTCPSocket.h b/WebDriverAgentLib/Routing/FBTCPSocket.h index 9613330e58..cceefbb008 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 769add707a..70ed961a59 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/WebDriverAgentLib/Utilities/FBMjpegServer.m b/WebDriverAgentLib/Utilities/FBMjpegServer.m index 8bb2b958c8..01f931321d 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:^(BOOL didSucceed) { + __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); } diff --git a/WebDriverAgentLib/Utilities/FBScreen.h b/WebDriverAgentLib/Utilities/FBScreen.h index 87c22c7cb4..74e88013e2 100644 --- a/WebDriverAgentLib/Utilities/FBScreen.h +++ b/WebDriverAgentLib/Utilities/FBScreen.h @@ -12,6 +12,16 @@ 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 + */ ++ (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 dc6536408a..f2963002a7 100644 --- a/WebDriverAgentLib/Utilities/FBScreen.m +++ b/WebDriverAgentLib/Utilities/FBScreen.m @@ -9,10 +9,42 @@ #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; +} + + (double)scale { return [XCUIScreen.mainScreen scale]; diff --git a/WebDriverAgentLib/Utilities/FBW3CActionsSynthesizer.m b/WebDriverAgentLib/Utilities/FBW3CActionsSynthesizer.m index ad5cbd3c23..8323c4a3b2 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/WebDriverAgentLib/Utilities/FBXCAXClientProxy.h b/WebDriverAgentLib/Utilities/FBXCAXClientProxy.h index 208672af75..2dbb959b8e 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 fc90d43f92..1c9e3466f5 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,9 +18,43 @@ 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; +@property (nonatomic, nullable) id cachedSystemApplication; @end @@ -37,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 { - return [FBAXClient _setAXTimeout:timeout error: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 +{ + 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 @@ -67,7 +173,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 diff --git a/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m b/WebDriverAgentLib/Utilities/FBXCodeCompatibility.m index 26cd2dc6e6..2a9c5a8454 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. diff --git a/WebDriverAgentLib/Utilities/FBXPath.m b/WebDriverAgentLib/Utilities/FBXPath.m index 09a9aef13d..0e97b052bc 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/FBAutoAlertsHandlerTests.m b/WebDriverAgentTests/IntegrationTests/FBAutoAlertsHandlerTests.m index d086e35777..06069c9426 100644 --- a/WebDriverAgentTests/IntegrationTests/FBAutoAlertsHandlerTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBAutoAlertsHandlerTests.m @@ -44,9 +44,12 @@ - (void)tearDown [super tearDown]; } -// The test is flaky on slow Travis CI -- (void)disabled_testAutoAcceptingOfAlerts +- (void)testAutoAcceptingOfAlerts { + if (FBIntegrationTestCase.isRunningInCI) { + XCTSkip(@"Flaky on slow CI machines"); + } + self.session = [FBSession initWithApplication:XCUIApplication.fb_activeApplication defaultAlertAction:@"accept"]; @@ -57,9 +60,12 @@ - (void)disabled_testAutoAcceptingOfAlerts } } -// The test is flaky on slow Travis CI -- (void)disabled_testAutoDismissingOfAlerts +- (void)testAutoDismissingOfAlerts { + if (FBIntegrationTestCase.isRunningInCI) { + XCTSkip(@"Flaky on slow CI machines"); + } + self.session = [FBSession initWithApplication:XCUIApplication.fb_activeApplication defaultAlertAction:@"dismiss"]; diff --git a/WebDriverAgentTests/IntegrationTests/FBConfigurationTests.m b/WebDriverAgentTests/IntegrationTests/FBConfigurationTests.m index fb863dc131..96d5dc7a08 100644 --- a/WebDriverAgentTests/IntegrationTests/FBConfigurationTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBConfigurationTests.m @@ -40,7 +40,7 @@ - (void)testReduceMotion - (void)testAccessibilityDeadlineAbortsSnapshotRequestForDeadlockedApp { - if (nil != NSProcessInfo.processInfo.environment[@"CI"]) { + if (FBIntegrationTestCase.isRunningInCI) { XCTSkip(@"Deliberately freezes the app for several seconds, too slow/flaky for CI"); } diff --git a/WebDriverAgentTests/IntegrationTests/FBElementAttributeTests.m b/WebDriverAgentTests/IntegrationTests/FBElementAttributeTests.m index 1012b67227..2389748266 100644 --- a/WebDriverAgentTests/IntegrationTests/FBElementAttributeTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBElementAttributeTests.m @@ -66,7 +66,7 @@ - (void)testIgnoredAccessibilityAttributes // Images are neither accessibility elements nor contain them, so both checks should fail XCUIElement *imageElement = self.testedApplication.images.allElementsBoundByIndex.firstObject; if (nil == imageElement) { - return; + XCTSkip(@"No image element available to test"); } XCTAssertTrue(imageElement.exists); diff --git a/WebDriverAgentTests/IntegrationTests/FBElementVisibilityTests.m b/WebDriverAgentTests/IntegrationTests/FBElementVisibilityTests.m index 87a9094924..3f0dfa7e50 100644 --- a/WebDriverAgentTests/IntegrationTests/FBElementVisibilityTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBElementVisibilityTests.m @@ -22,7 +22,7 @@ @implementation FBElementVisibilityTests - (void)testSpringBoardIcons { if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad) { - return; + XCTSkip(@"Not applicable to iPad"); } [self launchApplication]; [self goToSpringBoardFirstPage]; @@ -37,21 +37,14 @@ - (void)testSpringBoardIcons XCTAssertFalse(self.springboard.icons[@"IntegrationApp"].firstMatch.fb_isVisible); } -- (void)testSpringBoardSubfolder +- (void)testIconsFromSearchDashboard { - if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad - || SYSTEM_VERSION_GREATER_THAN(@"12.0")) { - return; + if (FBIntegrationTestCase.isRunningInCI) { + // Causes: Failure fetching attributes for element + // 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 56df1e1117..5b094c2275 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 1e2e184e85..a2b8faf92c 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 8b1a701fe3..e89af5ef68 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 0e31eb13f7..78a954a6aa 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/FBScreenTests.m b/WebDriverAgentTests/IntegrationTests/FBScreenTests.m index 1300141e1d..a2b6437b75 100644 --- a/WebDriverAgentTests/IntegrationTests/FBScreenTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBScreenTests.m @@ -22,10 +22,37 @@ - (void)setUp [self launchApplication]; } +- (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); } @end - diff --git a/WebDriverAgentTests/IntegrationTests/FBScrollingTests.m b/WebDriverAgentTests/IntegrationTests/FBScrollingTests.m index 915ca7b41d..6e95e45435 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 00688755f9..edbfcf0047 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 ba9d10dfd3..e70ca0b749 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/FBW3CMultiTouchActionsIntegrationTests.m b/WebDriverAgentTests/IntegrationTests/FBW3CMultiTouchActionsIntegrationTests.m index 016c8701d3..9a92d98bc0 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 2d8015d9f9..8a41983c42 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 = @@ -368,7 +451,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 84a5c6c748..3eba1d47f7 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 a1850691a3..833b963e91 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 2f1b8a4674..89bc92e9ac 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]); diff --git a/WebDriverAgentTests/IntegrationTests/XCUIElementFBFindTests.m b/WebDriverAgentTests/IntegrationTests/XCUIElementFBFindTests.m index 2137ff5fc8..f03f6a62dd 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 diff --git a/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.h b/WebDriverAgentTests/UnitTests/Doubles/XCUIElementDouble.h index 1e3797e375..17bd0cb187 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 e51dfdbb2d..e6aafb3ea7 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 0000000000..e8c106a6dc --- /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 diff --git a/WebDriverAgentTests/UnitTests/FBHTTPServerSessionTests.m b/WebDriverAgentTests/UnitTests/FBHTTPServerSessionTests.m new file mode 100644 index 0000000000..9e0bb1c46b --- /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 diff --git a/WebDriverAgentTests/UnitTests/FBHTTPServerTests.m b/WebDriverAgentTests/UnitTests/FBHTTPServerTests.m new file mode 100644 index 0000000000..4c693c46da --- /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 diff --git a/WebDriverAgentTests/UnitTests/FBResponseJSONPayloadTests.m b/WebDriverAgentTests/UnitTests/FBResponseJSONPayloadTests.m new file mode 100644 index 0000000000..7103253191 --- /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/FBXCAXClientProxyTests.m b/WebDriverAgentTests/UnitTests/FBXCAXClientProxyTests.m new file mode 100644 index 0000000000..073d2abefe --- /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 diff --git a/WebDriverAgentTests/UnitTests/NSDictionaryFBUtf8SafeTests.m b/WebDriverAgentTests/UnitTests/NSDictionaryFBUtf8SafeTests.m index 6662852c1f..f4d96bd99f 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 diff --git a/lib/constants.ts b/lib/constants.ts index 3bab4cf41d..44561c17ac 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/types.ts b/lib/types.ts index e8db0319b3..8d3d73c77c 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 { diff --git a/lib/utils/processes.ts b/lib/utils/processes.ts index 8f931f6225..13be6e32c4 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 34753beca4..5b37847981 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/package.json b/package.json index e5b3434885..87134824d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.8.0", + "version": "16.12.3", "description": "Package bundling WebDriverAgent", "keywords": [ "Appium", @@ -64,14 +64,14 @@ "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/strongbox": "^1.0.0-rc.1", - "@appium/support": "^7.2.1", - "appium-ios-simulator": "^9.0.0", + "@appium/base-driver": "^10.8.0", + "@appium/strongbox": "^2.0.0", + "@appium/support": "^7.2.6", + "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" diff --git a/test/unit/processes.spec.ts b/test/unit/processes.spec.ts new file mode 100644 index 0000000000..a2ce9ac366 --- /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')); + }); + }); +});