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..bf95805d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,127 @@ +## [16.12.7](https://github.com/appium/WebDriverAgent/compare/v16.12.6...v16.12.7) (2026-09-10) + +### Bug Fixes + +* remaining strong box file ([#1255](https://github.com/appium/WebDriverAgent/issues/1255)) ([f5f7004](https://github.com/appium/WebDriverAgent/commit/f5f70041e463777d7604e84fd8d27bf11cb268b9)) + +## [16.12.6](https://github.com/appium/WebDriverAgent/compare/v16.12.5...v16.12.6) (2026-09-09) + +### Bug Fixes + +* rescale gesture coordinates for compatibility-mode window mismatches ([#1249](https://github.com/appium/WebDriverAgent/issues/1249)) ([25fcd95](https://github.com/appium/WebDriverAgent/commit/25fcd9558314c367c755b047cd0db0949654e90b)) + +## [16.12.5](https://github.com/appium/WebDriverAgent/compare/v16.12.4...v16.12.5) (2026-09-07) + +### Bug Fixes + +* touch and scroll related view issue in IntegrationApp ([#1253](https://github.com/appium/WebDriverAgent/issues/1253)) ([34859e9](https://github.com/appium/WebDriverAgent/commit/34859e9b443c184505206576b3b531d85d2afbcf)) + +## [16.12.4](https://github.com/appium/WebDriverAgent/compare/v16.12.3...v16.12.4) (2026-09-06) + +### Miscellaneous Chores + +* add coordinate screen in the integration app ([#1251](https://github.com/appium/WebDriverAgent/issues/1251)) ([de6acac](https://github.com/appium/WebDriverAgent/commit/de6acac7ebbaf70915f0c09093e3a12623efcea2)) + +## [16.12.3](https://github.com/appium/WebDriverAgent/compare/v16.12.2...v16.12.3) (2026-09-04) + +### Bug Fixes + +* 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..886b043a60 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 */; }; @@ -930,6 +932,7 @@ BEA2FA45BED17FE03010FECD /* FBFindElementCommands.h in Headers */ = {isa = PBXBuildFile; fileRef = EE9AB7581CAEDF0C008C271F /* FBFindElementCommands.h */; }; BF9B191D841681A571BAFED1 /* _XCTestObservationPrivate-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = C840A8703A7C8D48897E158A /* _XCTestObservationPrivate-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; BFF5D9B9446DD542E5D6017D /* XCTTagSelection.h in Headers */ = {isa = PBXBuildFile; fileRef = 8E2092D85A7C691F157B1971 /* XCTTagSelection.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C01249000000000000000003 /* FBCoordinateProbeViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = C01249000000000000000002 /* FBCoordinateProbeViewController.m */; }; C040AE5F2E3B934E8EEAEE0F /* XCUIApplicationProcessDelegate-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 43FCB89739438814F43BCA24 /* XCUIApplicationProcessDelegate-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; C07F140AF96143A0A5CAA2B0 /* XCTestCaseDiscoveryUIAutomationDelegate-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = E29888B75A756D1CCD21604C /* XCTestCaseDiscoveryUIAutomationDelegate-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; C1414C29C836902466C4D6DB /* XCTestCastMethodNamesUIAutomationDelegate-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = A80A1C12FA9899367D316E8C /* XCTestCastMethodNamesUIAutomationDelegate-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -939,6 +942,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 +1020,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 +1043,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 */; }; @@ -1264,6 +1270,10 @@ FEC3A97A4929115A192F947A /* XCUIDeviceEventAndStateInterface-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = 003AAAB9CB5FB38E45E05F6F /* XCUIDeviceEventAndStateInterface-Protocol.h */; settings = {ATTRIBUTES = (Public, ); }; }; FFA7672B731B57AB9283DD40 /* FBXCElementSnapshotWrapper.h in Headers */ = {isa = PBXBuildFile; fileRef = 13DE7A53287CA1EC003243C6 /* FBXCElementSnapshotWrapper.h */; }; FFD70914E6D8CE5D4FED4B69 /* XCUIAXNotificationHandling-Protocol.h in Headers */ = {isa = PBXBuildFile; fileRef = EA96C2FEDE73CB5148BA4949 /* XCUIAXNotificationHandling-Protocol.h */; }; + 6C07E44E139D41D6BA092F39 /* FBTouchableViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 1D04760558C64B8EB69380E0 /* FBTouchableViewTests.m */; }; + C15E49E6B6924191BAAD9FDF /* FBIntegrationAppTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 27B518E261C14E29B943AB9B /* FBIntegrationAppTests.m */; }; + 81E462B8A75F497CBFC6FD8B /* TouchableView.m in Sources */ = {isa = PBXBuildFile; fileRef = 315A15002518CB8700A3A064 /* TouchableView.m */; }; + B6DE70BFF27148BDAC72F80C /* TouchSpotView.m in Sources */ = {isa = PBXBuildFile; fileRef = 315A15062518CC2800A3A064 /* TouchSpotView.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1474,6 +1484,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 +1505,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 +1683,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,9 +1749,12 @@ 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 = ""; }; + C01249000000000000000001 /* FBCoordinateProbeViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FBCoordinateProbeViewController.h; sourceTree = ""; }; + C01249000000000000000002 /* FBCoordinateProbeViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBCoordinateProbeViewController.m; sourceTree = ""; }; C840A8703A7C8D48897E158A /* _XCTestObservationPrivate-Protocol.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "_XCTestObservationPrivate-Protocol.h"; sourceTree = ""; }; C878996F07A9B26E66FC4EAC /* WDAClickIntegrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WDAClickIntegrationTests.swift; sourceTree = ""; }; C8FB547322D3949C00B69954 /* LSApplicationWorkspace.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = LSApplicationWorkspace.h; sourceTree = ""; }; @@ -1946,9 +1962,12 @@ 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 = ""; }; + 1D04760558C64B8EB69380E0 /* FBTouchableViewTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBTouchableViewTests.m; sourceTree = ""; }; + 27B518E261C14E29B943AB9B /* FBIntegrationAppTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FBIntegrationAppTests.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2619,6 +2638,7 @@ EE9B76541CF7987300275851 /* IntegrationTests */ = { isa = PBXGroup; children = ( + 27B518E261C14E29B943AB9B /* FBIntegrationAppTests.m */, EE9B76991CF799F400275851 /* FBAlertTests.m */, 719CD8FE2126C90200C7D0C2 /* FBAutoAlertsHandlerTests.m */, EE26409C1D0EBA25009BE6B0 /* FBElementAttributeTests.m */, @@ -2662,6 +2682,7 @@ EE9B76561CF7987300275851 /* UnitTests */ = { isa = PBXGroup; children = ( + 1D04760558C64B8EB69380E0 /* FBTouchableViewTests.m */, ADBC39951D07840300327304 /* Doubles */, 71A7EAFB1E229302001DA4F2 /* FBClassChainTests.m */, EEE16E961D33A25500172525 /* FBConfigurationTests.m */, @@ -2673,18 +2694,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 = ""; @@ -2717,6 +2743,8 @@ EE55B3231D1D5388003AAAEC /* FBTableDataSource.m */, EE9B76841CF7997600275851 /* ViewController.h */, EE9B76851CF7997600275851 /* ViewController.m */, + C01249000000000000000001 /* FBCoordinateProbeViewController.h */, + C01249000000000000000002 /* FBCoordinateProbeViewController.m */, 315A14FF2518CB8700A3A064 /* TouchableView.h */, 315A15002518CB8700A3A064 /* TouchableView.m */, 315A15052518CC2800A3A064 /* TouchSpotView.h */, @@ -4794,8 +4822,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B6DE70BFF27148BDAC72F80C /* TouchSpotView.m in Sources */, + 81E462B8A75F497CBFC6FD8B /* TouchableView.m in Sources */, + 6C07E44E139D41D6BA092F39 /* FBTouchableViewTests.m in Sources */, 713352FD26CEF31D00523CBC /* FBLRUCacheTests.m in Sources */, EE3F8CFE1D08AA17006F02CE /* FBRunLoopSpinnerTests.m in Sources */, + A09D847635CA4C155583B967 /* FBXCAXClientProxyTests.m in Sources */, 714801D11FA9D9FA00DC5997 /* FBSDKVersionTests.m in Sources */, EE3F8D001D08B05F006F02CE /* FBElementTypeTransformerTests.m in Sources */, 13FFF2F2287DBEE600E561E4 /* XCElementSnapshotDouble.m in Sources */, @@ -4806,11 +4838,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 +4853,7 @@ EE6A89261D0B19E60083E92B /* FBSessionTests.m in Sources */, 71A7EAFC1E229302001DA4F2 /* FBClassChainTests.m in Sources */, EE18883D1DA663EB00307AA8 /* FBMathUtilsTests.m in Sources */, + 21C0068C990412E75A135DD4 /* FBCustomCommandsTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4831,6 +4867,7 @@ 315A15072518CC2800A3A064 /* TouchSpotView.m in Sources */, EE9B76911CF7997600275851 /* main.m in Sources */, EE9B768F1CF7997600275851 /* ViewController.m in Sources */, + C01249000000000000000003 /* FBCoordinateProbeViewController.m in Sources */, 315A15012518CB8700A3A064 /* TouchableView.m in Sources */, 315A150A2518D6F400A3A064 /* TouchViewController.m in Sources */, ADDA07241D6BB2BF001700AC /* FBScrollViewController.m in Sources */, @@ -4843,6 +4880,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C15E49E6B6924191BAAD9FDF /* FBIntegrationAppTests.m in Sources */, EE26409D1D0EBA25009BE6B0 /* FBElementAttributeTests.m in Sources */, 7119E1EC1E891F8600D0B125 /* FBPickerWheelSelectTests.m in Sources */, 71ACF5B8242F2FDC00F0AAD4 /* FBSafariAlertTests.m in Sources */, diff --git a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme index 6dbf148f35..8f1d503318 100644 --- a/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme +++ b/WebDriverAgent.xcodeproj/xcshareddata/xcschemes/IntegrationApp_watchOS.xcscheme @@ -46,6 +46,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "9D6B02D8FC050BAF7159284F" BuildableName = "IntegrationApp_watchOS.app" + BlueprintName = "IntegrationApp_watchOS" ReferencedContainer = "container:WebDriverAgent.xcodeproj"> @@ -62,6 +63,7 @@ BuildableIdentifier = "primary" BlueprintIdentifier = "9D6B02D8FC050BAF7159284F" BuildableName = "IntegrationApp_watchOS.app" + BlueprintName = "IntegrationApp_watchOS" ReferencedContainer = "container:WebDriverAgent.xcodeproj"> diff --git a/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+FBForceTouch.m b/WebDriverAgentLib/Categories/XCUIElement+FBForceTouch.m index bd5d0bdd37..5d9bd9bdff 100644 --- a/WebDriverAgentLib/Categories/XCUIElement+FBForceTouch.m +++ b/WebDriverAgentLib/Categories/XCUIElement+FBForceTouch.m @@ -11,6 +11,7 @@ #if !TARGET_OS_TV #import "FBErrorBuilder.h" +#import "FBMathUtils.h" #import "XCUICoordinate.h" #import "XCUIDevice.h" @@ -36,8 +37,10 @@ - (BOOL)fb_forceTouchCoordinate:(NSValue *)relativeCoordinate } else { CGVector offset = CGVectorMake(relativeCoordinate.CGPointValue.x, relativeCoordinate.CGPointValue.y); - XCUICoordinate *hitPoint = [[self coordinateWithNormalizedOffset:CGVectorMake(0, 0)] - coordinateWithOffset:offset]; + XCUICoordinate *hitPoint = FBCoordinateWithAnchorOffset(self, CGVectorMake(0, 0), offset, error); + if (nil == hitPoint) { + return NO; + } if (nil == pressure || nil == duration) { [hitPoint forcePress]; } else { diff --git a/WebDriverAgentLib/Categories/XCUIElement+FBPickerWheel.m b/WebDriverAgentLib/Categories/XCUIElement+FBPickerWheel.m index 3b361e569e..c49bf2559d 100644 --- a/WebDriverAgentLib/Categories/XCUIElement+FBPickerWheel.m +++ b/WebDriverAgentLib/Categories/XCUIElement+FBPickerWheel.m @@ -25,8 +25,9 @@ - (BOOL)fb_scrollWithOffset:(CGFloat)relativeHeightOffset error:(NSError **)erro { id snapshot = [self fb_standardSnapshot]; NSString *previousValue = snapshot.value; - XCUICoordinate *startCoord = [self coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5)]; - XCUICoordinate *endCoord = [startCoord coordinateWithOffset:CGVectorMake(0.0, relativeHeightOffset * snapshot.frame.size.height)]; + // Stay in normalized offsets end-to-end: XCTest never rescales a composed raw + // coordinateWithOffset: for compatibility-mode windows (appium/appium#16185). + XCUICoordinate *endCoord = [self coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5 + relativeHeightOffset)]; // If picker value is reflected in its accessiblity id // then fetching of the next snapshot may fail with StaleElementReferenceError // because we bound elements by their accessbility ids by default. diff --git a/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m b/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m index 5f470a3e39..6760e4fa1c 100644 --- a/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m +++ b/WebDriverAgentLib/Categories/XCUIElement+FBScrolling.m @@ -16,6 +16,8 @@ #import "FBXCElementSnapshotWrapper.h" #import "FBXCElementSnapshotWrapper+Helpers.h" #import "XCUIElement+FBCaching.h" +#import "XCUIElement+FBResolve.h" +#import "XCUIElement+FBUID.h" #import "XCUIApplication.h" #import "XCUICoordinate.h" #import "XCUIElement+FBIsVisible.h" @@ -35,15 +37,37 @@ @interface FBXCElementSnapshotWrapper (FBScrolling) -- (void)fb_scrollUpByNormalizedDistance:(CGFloat)distance inApplication:(XCUIApplication *)application; -- (void)fb_scrollDownByNormalizedDistance:(CGFloat)distance inApplication:(XCUIApplication *)application; -- (void)fb_scrollLeftByNormalizedDistance:(CGFloat)distance inApplication:(XCUIApplication *)application; -- (void)fb_scrollRightByNormalizedDistance:(CGFloat)distance inApplication:(XCUIApplication *)application; -- (BOOL)fb_scrollByNormalizedVector:(CGVector)normalizedScrollVector inApplication:(XCUIApplication *)application; -- (BOOL)fb_scrollByVector:(CGVector)vector inApplication:(XCUIApplication *)application error:(NSError **)error; +- (BOOL)fb_scrollUpByNormalizedDistance:(CGFloat)distance anchorElement:(XCUIElement *)anchorElement; +- (BOOL)fb_scrollDownByNormalizedDistance:(CGFloat)distance anchorElement:(XCUIElement *)anchorElement; +- (BOOL)fb_scrollLeftByNormalizedDistance:(CGFloat)distance anchorElement:(XCUIElement *)anchorElement; +- (BOOL)fb_scrollRightByNormalizedDistance:(CGFloat)distance anchorElement:(XCUIElement *)anchorElement; +- (BOOL)fb_scrollByNormalizedVector:(CGVector)normalizedScrollVector anchorElement:(XCUIElement *)anchorElement; +- (BOOL)fb_scrollByVector:(CGVector)vector anchorElement:(XCUIElement *)anchorElement error:(NSError **)error; @end +/** + Resolves a live element for the given snapshot, so gesture coordinates can be anchored + to it (its frame gets rescaled by XCTest for compatibility-mode windows; a raw + XCUIApplication anchor never does - see appium/appium#16185). Returns nil, rather than + falling back to the application, if the snapshot can no longer be located: anchoring to + the application would silently reproduce the very bug this is fixing. + */ +static XCUIElement *FBLiveElementForSnapshot(id snapshot, XCUIApplication *application) +{ + NSString *uid = [FBXCElementSnapshotWrapper wdUIDWithSnapshot:snapshot]; + if (nil == uid) { + return nil; + } + XCUIElement *result; + @autoreleasepool { + NSPredicate *predicate = [NSPredicate predicateWithFormat:@"%K = %@", FBStringify(FBXCElementSnapshotWrapper, fb_uid), uid]; + result = [[application.fb_query descendantsMatchingType:XCUIElementTypeAny] matchingPredicate:predicate].allElementsBoundByIndex.firstObject; + } + result.fb_isResolvedNatively = @NO; + return result; +} + @implementation XCUIElement (FBScrolling) - (BOOL)fb_nativeScrollToVisibleWithError:(NSError **)error @@ -61,28 +85,28 @@ - (void)fb_scrollUpByNormalizedDistance:(CGFloat)distance { id snapshot = [self fb_customSnapshot]; [[FBXCElementSnapshotWrapper ensureWrapped:snapshot] fb_scrollUpByNormalizedDistance:distance - inApplication:self.application]; + anchorElement:self]; } - (void)fb_scrollDownByNormalizedDistance:(CGFloat)distance { id snapshot = [self fb_customSnapshot]; [[FBXCElementSnapshotWrapper ensureWrapped:snapshot] fb_scrollDownByNormalizedDistance:distance - inApplication:self.application]; + anchorElement:self]; } - (void)fb_scrollLeftByNormalizedDistance:(CGFloat)distance { id snapshot = [self fb_customSnapshot]; [[FBXCElementSnapshotWrapper ensureWrapped:snapshot] fb_scrollLeftByNormalizedDistance:distance - inApplication:self.application]; + anchorElement:self]; } - (void)fb_scrollRightByNormalizedDistance:(CGFloat)distance { id snapshot = [self fb_customSnapshot]; [[FBXCElementSnapshotWrapper ensureWrapped:snapshot] fb_scrollRightByNormalizedDistance:distance - inApplication:self.application]; + anchorElement:self]; } - (BOOL)fb_scrollToVisibleWithError:(NSError **)error @@ -176,30 +200,52 @@ - (BOOL)fb_scrollToVisibleWithNormalizedScrollDistance:(CGFloat)normalizedScroll } } + // The scroll view's own identity is stable across scroll steps, so it only needs + // to be resolved to a live element once, up front; its frame does not, since it can + // change across scroll steps (rotation, keyboard, dynamic layout). + XCUIElement *scrollViewElement = FBLiveElementForSnapshot(scrollView, self.application); + if (nil == scrollViewElement) { + return + [[[FBErrorBuilder builder] + withDescriptionFormat:@"Failed to resolve a live element for the scrollable parent of '%@'", self.description] + buildError:error]; + } + const NSUInteger maxScrollCount = 25; NSUInteger scrollCount = 0; - FBXCElementSnapshotWrapper *scrollViewWrapped = [FBXCElementSnapshotWrapper ensureWrapped:scrollView]; + FBXCElementSnapshotWrapper *scrollViewWrapped; // Scrolling till cell is visible and get current value of frames while (![self fb_isEquivalentElementSnapshotVisible:prescrollSnapshot] && scrollCount < maxScrollCount) { + BOOL didScroll; @autoreleasepool { + // Re-snapshotting the scroll view every step keeps its frame from drifting too far + // out of sync with the live anchor element's frame used to resolve touch points. + scrollViewWrapped = [FBXCElementSnapshotWrapper ensureWrapped:[scrollViewElement fb_customSnapshot]]; if (targetCellIndex < visibleCellIndex) { - scrollDirection == FBXCUIElementScrollDirectionVertical ? + didScroll = scrollDirection == FBXCUIElementScrollDirectionVertical ? [scrollViewWrapped fb_scrollUpByNormalizedDistance:normalizedScrollDistance - inApplication:self.application] : + anchorElement:scrollViewElement] : [scrollViewWrapped fb_scrollLeftByNormalizedDistance:normalizedScrollDistance - inApplication:self.application]; + anchorElement:scrollViewElement]; } else { - scrollDirection == FBXCUIElementScrollDirectionVertical ? + didScroll = scrollDirection == FBXCUIElementScrollDirectionVertical ? [scrollViewWrapped fb_scrollDownByNormalizedDistance:normalizedScrollDistance - inApplication:self.application] : + anchorElement:scrollViewElement] : [scrollViewWrapped fb_scrollRightByNormalizedDistance:normalizedScrollDistance - inApplication:self.application]; + anchorElement:scrollViewElement]; } scrollCount++; // Wait for scroll animation [self fb_waitUntilStableWithTimeout:FBConfiguration.sharedInstance.animationCoolOffTimeout]; } + // The `error` out-param must not be written from inside the autorelease pool above. + if (!didScroll) { + return + [[[FBErrorBuilder builder] + withDescriptionFormat:@"Failed to scroll '%@': its frame is empty", self.description] + buildError:error]; + } } if (scrollCount >= maxScrollCount) { @@ -215,12 +261,13 @@ - (BOOL)fb_scrollToVisibleWithNormalizedScrollDistance:(CGFloat)normalizedScroll FBXCElementSnapshotWrapper *targetCellSnapshotWrapped = [FBXCElementSnapshotWrapper ensureWrapped:[self fb_customSnapshot]]; targetCellSnapshot = [targetCellSnapshotWrapped fb_parentCellSnapshot]; CGRect visibleFrame = [FBXCElementSnapshotWrapper ensureWrapped:targetCellSnapshot].fb_visibleFrame; - + CGVector scrollVector = CGVectorMake(visibleFrame.size.width - targetCellSnapshot.frame.size.width, visibleFrame.size.height - targetCellSnapshot.frame.size.height ); + scrollViewWrapped = [FBXCElementSnapshotWrapper ensureWrapped:[scrollViewElement fb_customSnapshot]]; return [scrollViewWrapped fb_scrollByVector:scrollVector - inApplication:self.application + anchorElement:scrollViewElement error:error]; } @@ -254,42 +301,42 @@ - (CGRect)scrollingFrame return self.visibleFrame; } -- (void)fb_scrollUpByNormalizedDistance:(CGFloat)distance - inApplication:(XCUIApplication *)application +- (BOOL)fb_scrollUpByNormalizedDistance:(CGFloat)distance + anchorElement:(XCUIElement *)anchorElement { - [self fb_scrollByNormalizedVector:CGVectorMake(0.0, distance) inApplication:application]; + return [self fb_scrollByNormalizedVector:CGVectorMake(0.0, distance) anchorElement:anchorElement]; } -- (void)fb_scrollDownByNormalizedDistance:(CGFloat)distance - inApplication:(XCUIApplication *)application +- (BOOL)fb_scrollDownByNormalizedDistance:(CGFloat)distance + anchorElement:(XCUIElement *)anchorElement { - [self fb_scrollByNormalizedVector:CGVectorMake(0.0, -distance) inApplication:application]; + return [self fb_scrollByNormalizedVector:CGVectorMake(0.0, -distance) anchorElement:anchorElement]; } -- (void)fb_scrollLeftByNormalizedDistance:(CGFloat)distance - inApplication:(XCUIApplication *)application +- (BOOL)fb_scrollLeftByNormalizedDistance:(CGFloat)distance + anchorElement:(XCUIElement *)anchorElement { - [self fb_scrollByNormalizedVector:CGVectorMake(distance, 0.0) inApplication:application]; + return [self fb_scrollByNormalizedVector:CGVectorMake(distance, 0.0) anchorElement:anchorElement]; } -- (void)fb_scrollRightByNormalizedDistance:(CGFloat)distance - inApplication:(XCUIApplication *)application +- (BOOL)fb_scrollRightByNormalizedDistance:(CGFloat)distance + anchorElement:(XCUIElement *)anchorElement { - [self fb_scrollByNormalizedVector:CGVectorMake(-distance, 0.0) inApplication:application]; + return [self fb_scrollByNormalizedVector:CGVectorMake(-distance, 0.0) anchorElement:anchorElement]; } - (BOOL)fb_scrollByNormalizedVector:(CGVector)normalizedScrollVector - inApplication:(XCUIApplication *)application + anchorElement:(XCUIElement *)anchorElement { CGVector scrollVector = CGVectorMake(CGRectGetWidth(self.scrollingFrame) * normalizedScrollVector.dx, CGRectGetHeight(self.scrollingFrame) * normalizedScrollVector.dy ); - return [self fb_scrollByVector:scrollVector inApplication:application error:nil]; + return [self fb_scrollByVector:scrollVector anchorElement:anchorElement error:nil]; } - (BOOL)fb_scrollByVector:(CGVector)vector - inApplication:(XCUIApplication *)application - error:(NSError **)error + anchorElement:(XCUIElement *)anchorElement + error:(NSError **)error { CGVector scrollBoundingVector = CGVectorMake( CGRectGetWidth(self.scrollingFrame) * FBScrollTouchProportion, @@ -306,29 +353,49 @@ - (BOOL)fb_scrollByVector:(CGVector)vector fabs(vector.dy) > fabs(scrollBoundingVector.dy) ? scrollBoundingVector.dy : vector.dy); vector = CGVectorMake(vector.dx - scrollVector.dx, vector.dy - scrollVector.dy); shouldFinishScrolling = FBVectorFuzzyEqualToVector(vector, CGZeroVector, 1) || --preciseScrollAttemptsCount <= 0; - if (![self fb_scrollAncestorScrollViewByVectorWithinScrollViewFrame:scrollVector inApplication:application error:error]){ + if (![self fb_scrollAncestorScrollViewByVectorWithinScrollViewFrame:scrollVector anchorElement:anchorElement error:error]){ return NO; } } return YES; } -- (CGVector)fb_hitPointOffsetForScrollingVector:(CGVector)scrollingVector +// Normalized (0.0-1.0) touch-down offset within the scrolling frame, for the given +// scroll vector's direction. +- (CGVector)fb_normalizedHitPointOffsetForScrollingVector:(CGVector)scrollingVector { - CGFloat x = CGRectGetMinX(self.scrollingFrame) + CGRectGetWidth(self.scrollingFrame) * (scrollingVector.dx < 0.0f ? FBScrollTouchProportion : (1 - FBScrollTouchProportion)); - CGFloat y = CGRectGetMinY(self.scrollingFrame) + CGRectGetHeight(self.scrollingFrame) * (scrollingVector.dy < 0.0f ? FBScrollTouchProportion : (1 - FBScrollTouchProportion)); - return CGVectorMake((CGFloat)floor(x), (CGFloat)floor(y)); + CGFloat x = scrollingVector.dx < 0.0f ? FBScrollTouchProportion : (1 - FBScrollTouchProportion); + CGFloat y = scrollingVector.dy < 0.0f ? FBScrollTouchProportion : (1 - FBScrollTouchProportion); + return CGVectorMake(x, y); } - (BOOL)fb_scrollAncestorScrollViewByVectorWithinScrollViewFrame:(CGVector)vector - inApplication:(XCUIApplication *)application - error:(NSError **)error + anchorElement:(XCUIElement *)anchorElement + error:(NSError **)error { - CGVector hitpointOffset = [self fb_hitPointOffsetForScrollingVector:vector]; + CGRect scrollingFrame = self.scrollingFrame; + CGRect anchorFrame = anchorElement.frame; + if (CGRectIsEmpty(scrollingFrame) || CGRectIsEmpty(anchorFrame)) { + return [[[FBErrorBuilder builder] + withDescriptionFormat:@"Cannot compute a scroll gesture for '%@': its frame is empty", self.fb_description] + buildError:error]; + } - XCUICoordinate *appCoordinate = [[XCUICoordinate alloc] initWithElement:application normalizedOffset:CGVectorMake(0.0, 0.0)]; - XCUICoordinate *startCoordinate = [[XCUICoordinate alloc] initWithCoordinate:appCoordinate pointsOffset:hitpointOffset]; - XCUICoordinate *endCoordinate = [[XCUICoordinate alloc] initWithCoordinate:startCoordinate pointsOffset:vector]; + // Compute the touch-down/up points within the (possibly clipped) scrolling frame as + // before, then express them as fractions of the anchor element's own frame instead of + // raw points, which XCTest never rescales for compatibility-mode windows + // (appium/appium#16185). When scrollingFrame == anchorFrame this resolves to the exact + // same absolute point as before; it only differs once XCTest itself rescales anchorFrame. + CGVector proportion = [self fb_normalizedHitPointOffsetForScrollingVector:vector]; + CGPoint startPoint = CGPointMake((CGFloat)floor(scrollingFrame.origin.x + scrollingFrame.size.width * proportion.dx), + (CGFloat)floor(scrollingFrame.origin.y + scrollingFrame.size.height * proportion.dy)); + CGPoint endPoint = CGPointMake((CGFloat)floor(startPoint.x + vector.dx), (CGFloat)floor(startPoint.y + vector.dy)); + CGVector startOffset = CGVectorMake((startPoint.x - anchorFrame.origin.x) / anchorFrame.size.width, + (startPoint.y - anchorFrame.origin.y) / anchorFrame.size.height); + CGVector endOffset = CGVectorMake((endPoint.x - anchorFrame.origin.x) / anchorFrame.size.width, + (endPoint.y - anchorFrame.origin.y) / anchorFrame.size.height); + XCUICoordinate *startCoordinate = [anchorElement coordinateWithNormalizedOffset:startOffset]; + XCUICoordinate *endCoordinate = [anchorElement coordinateWithNormalizedOffset:endOffset]; if (FBPointFuzzyEqualToPoint(startCoordinate.screenPoint, endCoordinate.screenPoint, FBFuzzyPointThreshold)) { return YES; diff --git a/WebDriverAgentLib/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/FBElementCommands.m b/WebDriverAgentLib/Commands/FBElementCommands.m index 2594eed940..a46958bce4 100644 --- a/WebDriverAgentLib/Commands/FBElementCommands.m +++ b/WebDriverAgentLib/Commands/FBElementCommands.m @@ -353,14 +353,23 @@ + (NSArray *)routes + (id)handlePressAndDragCoordinateWithVelocity:(FBRouteRequest *)request { XCUIApplication *application = request.session.activeApplication; + NSError *error; CGVector startOffset = CGVectorMake((CGFloat)[request.arguments[@"fromX"] doubleValue], (CGFloat)[request.arguments[@"fromY"] doubleValue]); XCUICoordinate *startCoordinate = [self.class gestureCoordinateWithOffset:startOffset - element:application]; + element:application + error:&error]; + if (nil == startCoordinate) { + return FBResponseWithStatus([FBCommandStatus invalidElementStateErrorWithMessage:error.description traceback:nil]); + } CGVector endOffset = CGVectorMake((CGFloat)[request.arguments[@"toX"] doubleValue], (CGFloat)[request.arguments[@"toY"] doubleValue]); XCUICoordinate *endCoordinate = [self.class gestureCoordinateWithOffset:endOffset - element:application]; + element:application + error:&error]; + if (nil == endCoordinate) { + return FBResponseWithStatus([FBCommandStatus invalidElementStateErrorWithMessage:error.description traceback:nil]); + } [startCoordinate pressForDuration:[request.arguments[@"pressDuration"] doubleValue] thenDragToCoordinate:endCoordinate withVelocity:[request.arguments[@"velocity"] doubleValue] @@ -433,12 +442,19 @@ + (NSArray *)routes + (id)handleDrag:(FBRouteRequest *)request { XCUIElement *target = [self targetFromRequest:request]; + NSError *error; CGVector startOffset = CGVectorMake([request.arguments[@"fromX"] doubleValue], [request.arguments[@"fromY"] doubleValue]); - XCUICoordinate *startCoordinate = [self.class gestureCoordinateWithOffset:startOffset element:target]; + XCUICoordinate *startCoordinate = [self.class gestureCoordinateWithOffset:startOffset element:target error:&error]; + if (nil == startCoordinate) { + return FBResponseWithStatus([FBCommandStatus invalidElementStateErrorWithMessage:error.description traceback:nil]); + } CGVector endOffset = CGVectorMake([request.arguments[@"toX"] doubleValue], [request.arguments[@"toY"] doubleValue]); - XCUICoordinate *endCoordinate = [self.class gestureCoordinateWithOffset:endOffset element:target]; + XCUICoordinate *endCoordinate = [self.class gestureCoordinateWithOffset:endOffset element:target error:&error]; + if (nil == endCoordinate) { + return FBResponseWithStatus([FBCommandStatus invalidElementStateErrorWithMessage:error.description traceback:nil]); + } NSTimeInterval duration = [request.arguments[@"duration"] doubleValue]; [startCoordinate pressForDuration:duration thenDragToCoordinate:endCoordinate]; return FBResponseWithOK(); @@ -659,12 +675,15 @@ + (NSArray *)routes @param offset absolute screen offset for the given application @param element the element instance to perform the gesture on - @return translated gesture coordinates ready to be passed to XCUICoordinate methods + @param error Error instance if any + @return translated gesture coordinates ready to be passed to XCUICoordinate methods, or + nil if the element is not visible on the screen */ -+ (XCUICoordinate *)gestureCoordinateWithOffset:(CGVector)offset - element:(XCUIElement *)element ++ (nullable XCUICoordinate *)gestureCoordinateWithOffset:(CGVector)offset + element:(XCUIElement *)element + error:(NSError **)error { - return [[element coordinateWithNormalizedOffset:CGVectorMake(0, 0)] coordinateWithOffset:offset]; + return FBCoordinateWithAnchorOffset(element, CGVectorMake(0, 0), offset, error); } /** @@ -688,7 +707,8 @@ + (nullable id)targetWithXyCoordinatesFromRequest:(FBRouteRequest *)request erro return nil; } return [self gestureCoordinateWithOffset:CGVectorMake(x.doubleValue, y.doubleValue) - element:[self targetFromRequest:request]]; + element:[self targetFromRequest:request] + error:error]; } /** diff --git a/WebDriverAgentLib/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..f7c20b1323 100644 --- a/WebDriverAgentLib/Info.plist +++ b/WebDriverAgentLib/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 16.8.0 + 16.12.7 CFBundleSignature ???? CFBundleVersion - 16.8.0 + 16.12.7 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/FBBaseActionsSynthesizer.m b/WebDriverAgentLib/Utilities/FBBaseActionsSynthesizer.m index a5cd5f07fe..ef8604ce68 100644 --- a/WebDriverAgentLib/Utilities/FBBaseActionsSynthesizer.m +++ b/WebDriverAgentLib/Utilities/FBBaseActionsSynthesizer.m @@ -74,9 +74,9 @@ - (nullable XCUICoordinate *)hitpointWithElement:(nullable XCUIElement *)element return [element coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5)]; } - CGVector offset = CGVectorMake(positionOffset.CGPointValue.x, positionOffset.CGPointValue.y); // TODO: Shall we throw an exception if hitPoint is out of the element frame? - return [[element coordinateWithNormalizedOffset:CGVectorMake(0, 0)] coordinateWithOffset:offset]; + CGVector offset = CGVectorMake(positionOffset.CGPointValue.x, positionOffset.CGPointValue.y); + return FBCoordinateWithAnchorOffset((XCUIElement *)element, CGVectorMake(0, 0), offset, error); } @end diff --git a/WebDriverAgentLib/Utilities/FBMathUtils.h b/WebDriverAgentLib/Utilities/FBMathUtils.h index df9a116969..d0e1939efc 100644 --- a/WebDriverAgentLib/Utilities/FBMathUtils.h +++ b/WebDriverAgentLib/Utilities/FBMathUtils.h @@ -9,6 +9,10 @@ #import @class XCUIApplication; +@class XCUICoordinate; +@class XCUIElement; + +NS_ASSUME_NONNULL_BEGIN extern CGFloat FBDefaultFrameFuzzyThreshold; @@ -34,3 +38,27 @@ BOOL FBRectFuzzyEqualToRect(CGRect rect1, CGRect rect2, CGFloat threshold); /*! Inverts size if necessary to match current screen orientation */ CGSize FBAdjustDimensionsForApplication(CGSize actualSize, UIInterfaceOrientation orientation); #endif + +#if !TARGET_OS_TV +/*! + Builds a coordinate for the given element from a raw points offset measured from a + normalized anchor point within the element's own frame - e.g. (0, 0) for an offset + relative to the top-left corner, (0.5, 0.5) for one relative to the center, as W3C + actions use. The offset is normalized against the element's wdFrame (the same + WDA-reported coordinate space pointsOffset itself is measured in) instead of being + passed through as a raw points offset, which XCTest never rescales for + compatibility-mode windows (see appium/appium#16185). + + @param element the element to anchor the coordinate to + @param anchorOffset normalized offset of the anchor point within the element's wdFrame + @param pointsOffset raw points offset from the anchor point, in wdFrame's coordinate space + @param error populated if the element's frame is empty (not visible on the screen) + @return the resulting coordinate, or nil if the element's frame is empty + */ +XCUICoordinate * _Nullable FBCoordinateWithAnchorOffset(XCUIElement *element, + CGVector anchorOffset, + CGVector pointsOffset, + NSError **error); +#endif + +NS_ASSUME_NONNULL_END diff --git a/WebDriverAgentLib/Utilities/FBMathUtils.m b/WebDriverAgentLib/Utilities/FBMathUtils.m index 807f349055..dacdc54aaf 100644 --- a/WebDriverAgentLib/Utilities/FBMathUtils.m +++ b/WebDriverAgentLib/Utilities/FBMathUtils.m @@ -8,7 +8,11 @@ #import "FBMathUtils.h" +#import "FBErrorBuilder.h" #import "FBMacros.h" +#import "XCUICoordinate.h" +#import "XCUIElement.h" +#import "XCUIElement+FBWebDriverAttributes.h" CGFloat FBDefaultFrameFuzzyThreshold = 2.0; @@ -51,7 +55,7 @@ CGSize FBAdjustDimensionsForApplication(CGSize actualSize, UIInterfaceOrientatio if (orientation == UIInterfaceOrientationLandscapeLeft || orientation == UIInterfaceOrientationLandscapeRight) { /* There is an XCTest bug that application.frame property returns exchanged dimensions for landscape mode. - This verification is just to make sure the bug is still there (since height is never greater than width in landscape) + This verification is just to make sure the bug is still there (since height is never greater than width in landscape) and to make it still working properly after XCTest itself starts to respect landscape mode. */ if (actualSize.height > actualSize.width) { @@ -61,3 +65,24 @@ This verification is just to make sure the bug is still there (since height is n return actualSize; } #endif + +#if !TARGET_OS_TV +XCUICoordinate *FBCoordinateWithAnchorOffset(XCUIElement *element, + CGVector anchorOffset, + CGVector pointsOffset, + NSError **error) +{ + // wdFrame matches the coordinate space pointsOffset was measured in; element.frame alone + // can already be pre-scaled for a compatibility-mode window mismatch, double-applying it. + CGRect frame = element.wdFrame; + if (CGRectIsEmpty(frame)) { + [[[FBErrorBuilder builder] + withDescriptionFormat:@"The element '%@' is not visible on the screen and thus is not interactable", element.description] + buildError:error]; + return nil; + } + CGVector normalizedOffset = CGVectorMake(anchorOffset.dx + pointsOffset.dx / frame.size.width, + anchorOffset.dy + pointsOffset.dy / frame.size.height); + return [element coordinateWithNormalizedOffset:normalizedOffset]; +} +#endif diff --git a/WebDriverAgentLib/Utilities/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..c37f2a443c 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]; @@ -161,7 +162,7 @@ - (nullable XCUICoordinate *)hitpointWithElement:(nullable XCUIElement *)element return [super hitpointWithElement:element positionOffset:positionOffset error:error]; } - // An offset relative to the element is defined + // An offset relative to the element is defined. if (CGRectIsEmpty(element.frame)) { [FBLogger log:self.application.fb_descriptionRepresentation]; NSString *description = [NSString stringWithFormat:@"The element '%@' is not visible on the screen and thus is not interactable", @@ -173,9 +174,9 @@ - (nullable XCUICoordinate *)hitpointWithElement:(nullable XCUIElement *)element } // W3C standard requires that relative element coordinates start at the center of the element's rectangle - CGVector offset = CGVectorMake(positionOffset.CGPointValue.x, positionOffset.CGPointValue.y); // TODO: Shall we throw an exception if hitPoint is out of the element frame? - return [[element coordinateWithNormalizedOffset:CGVectorMake(0.5, 0.5)] coordinateWithOffset:offset]; + CGVector offset = CGVectorMake(positionOffset.CGPointValue.x, positionOffset.CGPointValue.y); + return FBCoordinateWithAnchorOffset((XCUIElement *)element, CGVectorMake(0.5, 0.5), offset, error); } @end @@ -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/IntegrationApp/COORDINATE_PROBE.md b/WebDriverAgentTests/IntegrationApp/COORDINATE_PROBE.md new file mode 100644 index 0000000000..820485fa7a --- /dev/null +++ b/WebDriverAgentTests/IntegrationApp/COORDINATE_PROBE.md @@ -0,0 +1,50 @@ +# Coordinate probe + +Open **Coordinate Probe** below **DeepHierarchy** on the IntegrationApp home screen. +Use the standard Back button to return to the existing fixtures. Opening the probe +creates a fresh measurement session. + +The **Touch** canvas records actual `UITouch.locationInView:` coordinates rather +than assuming that a gesture succeeded because it hit a large button. **Scroll** +shows 80 numbered rows, each 44 points high. + +## Automation identifiers + +| Identifier | Purpose | +| --- | --- | +| `coordinate-probe` | Home screen button that opens the probe | +| `probe-status` | Label containing the measurement JSON | +| `coordinate-canvas` | Touch target | +| `probe-mode` | Touch/Scroll segmented control | +| `probe-table` | Scrollable table | +| `probe-row-0` through `probe-row-79` | Individual table rows | + +The `probe-status` label exposes `start`, `last`, `phase`, and `count` after a +touch. `start` and `last` are **canvas-local app points**. It also exposes +`canvasBounds`, `canvasWindowRect`, `windowSize`, `screenSize`, and `scrollY`. +Geometry is refreshed when the view lays out, including after rotation or window +resizing. Touch-related fields are absent before the first touch. + +For example, locate `coordinate-probe` by accessibility ID and click it. Then +locate `coordinate-canvas`, send an element-relative action, and read the JSON +from `probe-status`. Check that `count` increased and compare `start`/`last` with +the expected canvas-local point within a suitable tolerance. Derive test offsets +from the current element rectangle rather than hard-coding a device size. + +## Native and compatibility builds + +The screen uses the same source for both configurations. Keep the IntegrationApp +scheme and set the build's `TARGETED_DEVICE_FAMILY`: + +- `1,2`: native support for iPhone and iPad (the existing default). +- `1`: iPhone-only; install on iPad to exercise iPhone compatibility mode. + +These are build settings, not a switch within the running app. Verify the actual +window and element frames before interpreting results. An iPad-native app can +also run in a resized window; maximize it when testing a full-screen native case. +For the compatibility case, compare app and SpringBoard window sizes and the +reported element rectangles with the recorded probe geometry. + +If keeping both builds installed at once, give them distinct bundle IDs. This +change does not add another app target or alter the existing bundle ID or signing +configuration. diff --git a/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.h b/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.h new file mode 100644 index 0000000000..b51dfd399d --- /dev/null +++ b/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.h @@ -0,0 +1,4 @@ +#import + +@interface FBCoordinateProbeViewController : UIViewController +@end diff --git a/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.m b/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.m new file mode 100644 index 0000000000..dc72e69463 --- /dev/null +++ b/WebDriverAgentTests/IntegrationApp/Classes/FBCoordinateProbeViewController.m @@ -0,0 +1,176 @@ +#import "FBCoordinateProbeViewController.h" + +@interface FBCoordinateProbeCanvas : UIView +@property (nonatomic, copy) void (^onTouch)(NSString *phase, CGPoint point); +@end + +@implementation FBCoordinateProbeCanvas + +- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event +{ + self.onTouch(@"began", [touches.anyObject locationInView:self]); +} + +- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event +{ + self.onTouch(@"moved", [touches.anyObject locationInView:self]); +} + +- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event +{ + self.onTouch(@"ended", [touches.anyObject locationInView:self]); +} + +- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event +{ + self.onTouch(@"cancelled", [touches.anyObject locationInView:self]); +} + +- (void)drawRect:(CGRect)rect +{ + CGContextRef context = UIGraphicsGetCurrentContext(); + CGContextSetStrokeColorWithColor(context, UIColor.whiteColor.CGColor); + for (NSInteger i = 1; i < 4; i++) { + CGFloat x = CGRectGetWidth(self.bounds) * i / 4.0; + CGFloat y = CGRectGetHeight(self.bounds) * i / 4.0; + CGContextMoveToPoint(context, x, 0); + CGContextAddLineToPoint(context, x, CGRectGetHeight(self.bounds)); + CGContextMoveToPoint(context, 0, y); + CGContextAddLineToPoint(context, CGRectGetWidth(self.bounds), y); + } + CGContextStrokePath(context); +} + +@end + +@interface FBCoordinateProbeViewController () +@property (nonatomic, strong) UILabel *statusLabel; +@property (nonatomic, strong) UISegmentedControl *modeControl; +@property (nonatomic, strong) FBCoordinateProbeCanvas *canvas; +@property (nonatomic, strong) UITableView *table; +@property (nonatomic, strong) NSMutableDictionary *measurement; +@property (nonatomic) NSUInteger touchCount; +@end + +@implementation FBCoordinateProbeViewController + +- (void)viewDidLoad +{ + [super viewDidLoad]; + self.title = @"Coordinate Probe"; + self.view.backgroundColor = UIColor.systemBackgroundColor; + self.measurement = [NSMutableDictionary dictionary]; + + self.statusLabel = [UILabel new]; + self.statusLabel.accessibilityIdentifier = @"probe-status"; + self.statusLabel.font = [UIFont monospacedSystemFontOfSize:10 weight:UIFontWeightRegular]; + self.statusLabel.numberOfLines = 0; + [self.view addSubview:self.statusLabel]; + + self.modeControl = [[UISegmentedControl alloc] initWithItems:@[@"Touch", @"Scroll"]]; + self.modeControl.accessibilityIdentifier = @"probe-mode"; + self.modeControl.selectedSegmentIndex = 0; + [self.modeControl addTarget:self action:@selector(modeChanged) forControlEvents:UIControlEventValueChanged]; + [self.view addSubview:self.modeControl]; + + self.canvas = [FBCoordinateProbeCanvas new]; + self.canvas.backgroundColor = UIColor.systemBlueColor; + self.canvas.isAccessibilityElement = YES; + self.canvas.accessibilityIdentifier = @"coordinate-canvas"; + self.canvas.accessibilityLabel = @"Coordinate canvas"; + self.canvas.accessibilityTraits = UIAccessibilityTraitAllowsDirectInteraction; + self.canvas.contentMode = UIViewContentModeRedraw; + __weak typeof(self) weakSelf = self; + self.canvas.onTouch = ^(NSString *phase, CGPoint point) { + FBCoordinateProbeViewController *controller = weakSelf; + if (nil == controller) { + return; + } + if ([phase isEqualToString:@"began"]) { + controller.touchCount++; + controller.measurement[@"start"] = @[@(point.x), @(point.y)]; + } + controller.measurement[@"last"] = @[@(point.x), @(point.y)]; + controller.measurement[@"phase"] = phase; + controller.measurement[@"count"] = @(controller.touchCount); + [controller publishMeasurement]; + }; + [self.view addSubview:self.canvas]; + + self.table = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain]; + self.table.accessibilityIdentifier = @"probe-table"; + self.table.rowHeight = 44; + self.table.dataSource = self; + self.table.delegate = self; + self.table.hidden = YES; + [self.view addSubview:self.table]; +} + +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + // Use the app's actual available area, including navigation bars and resized iPad windows. + CGRect safeFrame = self.view.safeAreaLayoutGuide.layoutFrame; + CGFloat width = MAX(0, CGRectGetWidth(safeFrame) - 40); + CGFloat left = CGRectGetMinX(safeFrame) + 20; + CGFloat top = CGRectGetMinY(safeFrame) + 8; + self.statusLabel.frame = CGRectMake(left, top, width, 96); + self.modeControl.frame = CGRectMake(left, top + 104, width, 30); + CGRect contentFrame = CGRectMake(left, top + 146, width, + MAX(0, CGRectGetMaxY(safeFrame) - top - 166)); + self.canvas.frame = contentFrame; + self.table.frame = contentFrame; + [self publishMeasurement]; +} + +- (void)publishMeasurement +{ + UIWindow *window = self.view.window; + if (nil == window) { + return; + } + CGRect bounds = self.canvas.bounds; + CGRect frame = [self.canvas convertRect:bounds toView:window]; + // Touch coordinates are local to the canvas. Window geometry lets tests compare + // those measurements with WDA's element rectangles without assuming a screen size. + self.measurement[@"canvasBounds"] = @[@(bounds.size.width), @(bounds.size.height)]; + self.measurement[@"canvasWindowRect"] = @[@(frame.origin.x), @(frame.origin.y), + @(frame.size.width), @(frame.size.height)]; + self.measurement[@"windowSize"] = @[@(window.bounds.size.width), @(window.bounds.size.height)]; + self.measurement[@"screenSize"] = @[@(window.screen.bounds.size.width), @(window.screen.bounds.size.height)]; + self.measurement[@"scrollY"] = @(self.table.contentOffset.y); + NSData *data = [NSJSONSerialization dataWithJSONObject:self.measurement + options:NSJSONWritingSortedKeys + error:nil]; + self.statusLabel.text = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; +} + +- (void)modeChanged +{ + self.canvas.hidden = self.modeControl.selectedSegmentIndex == 1; + self.table.hidden = !self.canvas.hidden; + [self publishMeasurement]; +} + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section +{ + return 80; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath +{ + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"probe-row"]; + if (nil == cell) { + cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"probe-row"]; + } + cell.textLabel.text = [NSString stringWithFormat:@"Probe row %ld", (long)indexPath.row]; + cell.accessibilityIdentifier = [NSString stringWithFormat:@"probe-row-%ld", (long)indexPath.row]; + return cell; +} + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView +{ + [self publishMeasurement]; +} + +@end diff --git a/WebDriverAgentTests/IntegrationApp/Classes/FBScrollViewController.m b/WebDriverAgentTests/IntegrationApp/Classes/FBScrollViewController.m index a8fe797739..64fc1540e9 100644 --- a/WebDriverAgentTests/IntegrationApp/Classes/FBScrollViewController.m +++ b/WebDriverAgentTests/IntegrationApp/Classes/FBScrollViewController.m @@ -15,6 +15,7 @@ @interface FBScrollViewController () @property (nonatomic, weak) IBOutlet UIScrollView *scrollView; @property (nonatomic, strong) IBOutlet FBTableDataSource *dataSource; +@property (nonatomic, copy) NSArray *rowLabels; @end @implementation FBScrollViewController @@ -22,18 +23,30 @@ @implementation FBScrollViewController - (void)viewDidLoad { [super viewDidLoad]; [self setupLabelViews]; - self.scrollView.contentSize = CGSizeMake(CGRectGetWidth(self.view.frame), self.dataSource.count * FBSubviewHeight); +} + +- (void)viewDidLayoutSubviews +{ + [super viewDidLayoutSubviews]; + CGFloat width = CGRectGetWidth(self.scrollView.bounds); + [self.rowLabels enumerateObjectsUsingBlock:^(UILabel *label, NSUInteger index, BOOL *stop) { + label.frame = CGRectMake(0, index * FBSubviewHeight, width, FBSubviewHeight); + }]; + self.scrollView.contentSize = CGSizeMake(width, self.rowLabels.count * FBSubviewHeight); } - (void)setupLabelViews { NSUInteger count = self.dataSource.count; + NSMutableArray *labels = [NSMutableArray arrayWithCapacity:count]; for (NSInteger i = 0 ; i < count ; i++) { - UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(0, i * FBSubviewHeight, CGRectGetWidth(self.view.frame), FBSubviewHeight)]; + UILabel *label = [UILabel new]; label.text = [self.dataSource textForElementAtIndex:i]; label.textAlignment = NSTextAlignmentCenter; [self.scrollView addSubview:label]; + [labels addObject:label]; } + self.rowLabels = labels; } @end diff --git a/WebDriverAgentTests/IntegrationApp/Classes/TouchViewController.h b/WebDriverAgentTests/IntegrationApp/Classes/TouchViewController.h index 7125306a3a..dbe77e164c 100644 --- a/WebDriverAgentTests/IntegrationApp/Classes/TouchViewController.h +++ b/WebDriverAgentTests/IntegrationApp/Classes/TouchViewController.h @@ -11,7 +11,7 @@ NS_ASSUME_NONNULL_BEGIN -@interface TouchViewController : UIViewController +@interface TouchViewController : UIViewController @property (weak, nonatomic) IBOutlet TouchableView *touchable; @property (weak, nonatomic) IBOutlet UILabel *numberOfTapsLabel; diff --git a/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.h b/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.h index 53d0c1836e..7a4c227e32 100644 --- a/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.h +++ b/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.h @@ -21,8 +21,9 @@ NS_ASSUME_NONNULL_BEGIN @interface TouchableView : UIView @property (nonatomic) NSMutableDictionary *touchViews; +// Cumulative completed contacts; cancelled contacts are excluded. @property (nonatomic) int numberOFTaps; -@property (nonatomic) id delegate; +@property (nonatomic, weak, nullable) id delegate; @end diff --git a/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.m b/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.m index 9e7412ab7b..51be35ae68 100644 --- a/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.m +++ b/WebDriverAgentTests/IntegrationApp/Classes/TouchableView.m @@ -34,12 +34,11 @@ - (instancetype)initWithCoder:(NSCoder *)coder - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { - self.numberOFTaps += 1; - [self.delegate shouldHandleTouchesNumber:(int)touches.count]; for (UITouch *touch in touches) { [self createViewForTouch:touch]; } + [self.delegate shouldHandleTouchesNumber:(int)self.touchViews.count]; } - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event @@ -56,8 +55,13 @@ - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { for (UITouch *touch in touches) { + // Count completed contacts, independently of how UIKit batches callbacks. + if ([self viewForTouch:touch] != nil) { + self.numberOFTaps += 1; + } [self removeViewForTouch:touch]; } + [self.delegate shouldHandleTouchesNumber:(int)self.touchViews.count]; [self.delegate shouldHandleTapsNumber:self.numberOFTaps]; } @@ -67,15 +71,22 @@ - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { [self removeViewForTouch:touch]; } + [self.delegate shouldHandleTouchesNumber:(int)self.touchViews.count]; } - (void)createViewForTouch:(UITouch *)touch { if (touch) { + CGPoint location = [touch locationInView:self]; + // Exposes the last touch-down location, in this view's own bounds coordinate + // space, for tests to assert on regardless of any window-level scaling. + self.isAccessibilityElement = YES; + self.accessibilityValue = [NSString stringWithFormat:@"%.2f,%.2f", location.x, location.y]; + TouchSpotView *newView = [[TouchSpotView alloc] init]; newView.bounds = CGRectMake(0, 0, 1, 1); - newView.center = [touch locationInView:self]; + newView.center = location; [self addSubview:newView]; [UIView animateWithDuration:0.2 animations:^{ newView.bounds = CGRectMake(0, 0, 100, 100); diff --git a/WebDriverAgentTests/IntegrationApp/Classes/ViewController.m b/WebDriverAgentTests/IntegrationApp/Classes/ViewController.m index f63a91561d..923ce1f98d 100644 --- a/WebDriverAgentTests/IntegrationApp/Classes/ViewController.m +++ b/WebDriverAgentTests/IntegrationApp/Classes/ViewController.m @@ -7,6 +7,7 @@ */ #import "ViewController.h" +#import "FBCoordinateProbeViewController.h" @interface ViewController () @property (weak, nonatomic) IBOutlet UILabel *orentationLabel; @@ -30,6 +31,11 @@ - (void)viewDidLoad self.button.accessibilityCustomActions = @[action1, action2]; } +- (IBAction)showCoordinateProbe:(id)sender +{ + [self.navigationController pushViewController:[FBCoordinateProbeViewController new] animated:NO]; +} + - (BOOL)handleCustomAction:(UIAccessibilityCustomAction *)action { // Custom action handler - just return YES to indicate success @@ -57,13 +63,13 @@ - (IBAction)goToDeepHierarchy:(id)sender // lay out. This page exists purely as a fixture for exercising element // lookups (e.g. class chain locators) against a deep accessibility tree. UIViewController *deepHierarchyViewController = [UIViewController new]; - deepHierarchyViewController.view.backgroundColor = UIColor.whiteColor; + deepHierarchyViewController.view.backgroundColor = UIColor.systemBackgroundColor; deepHierarchyViewController.view.accessibilityIdentifier = @"DeepHierarchyPage"; NSInteger depth = 70; // A plain UILabel sibling, not part of the nested chain below, so the // fixture stays recognizable to a human glancing at the simulator instead - // of showing a blank white screen. + // of showing a blank screen. UILabel *titleLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, 60, CGRectGetWidth(UIScreen.mainScreen.bounds) - 40, 60)]; titleLabel.text = [NSString stringWithFormat:@"Deep Hierarchy\n%ld nested elements", (long)depth]; titleLabel.numberOfLines = 2; diff --git a/WebDriverAgentTests/IntegrationApp/Info.plist b/WebDriverAgentTests/IntegrationApp/Info.plist index 5dc963a62d..557b7660c2 100644 --- a/WebDriverAgentTests/IntegrationApp/Info.plist +++ b/WebDriverAgentTests/IntegrationApp/Info.plist @@ -30,8 +30,6 @@ Yo Yo NSPhotoLibraryUsageDescription Yo Yo - UILaunchStoryboardName - LaunchScreen UIApplicationSceneManifest UIApplicationSupportsMultipleScenes @@ -49,6 +47,8 @@ + UILaunchStoryboardName + LaunchScreen UIRequiredDeviceCapabilities armv7 diff --git a/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard b/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard index 7a9156769a..cf45fb42b1 100644 --- a/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard +++ b/WebDriverAgentTests/IntegrationApp/Resources/Base.lproj/Main.storyboard @@ -4,6 +4,7 @@ + @@ -83,8 +84,19 @@ + - + @@ -101,6 +113,8 @@ + + @@ -129,7 +143,7 @@ - + + - - - - - - - - - + + + + + + + + + + @@ -237,7 +253,7 @@ - + @@ -282,7 +298,7 @@ - + - + @@ -527,7 +543,7 @@ - + @@ -548,7 +564,7 @@ - + @@ -561,7 +577,7 @@ - + @@ -603,7 +619,7 @@ - + @@ -624,6 +640,9 @@ + + + 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..ed44aa8782 100644 --- a/WebDriverAgentTests/IntegrationTests/FBConfigurationTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBConfigurationTests.m @@ -21,14 +21,10 @@ @interface FBConfigurationTests : FBIntegrationTestCase @implementation FBConfigurationTests -- (void)setUp +- (void)testReduceMotion { - [super setUp]; [self launchApplication]; -} -- (void)testReduceMotion -{ BOOL defaultReduceMotionEnabled = FBConfiguration.sharedInstance.reduceMotionEnabled; FBConfiguration.sharedInstance.reduceMotionEnabled = YES; @@ -40,10 +36,13 @@ - (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"); } + // Launch only after the CI skip so a skipped test cannot time out in app startup. + [self launchApplication]; + NSTimeInterval previousDeadline = FBConfiguration.sharedInstance.accessibilityDeadline; // Also bounds any snapshot-based wait -tap itself may perform once the app is stuck. FBConfiguration.sharedInstance.accessibilityDeadline = 3.0; diff --git a/WebDriverAgentTests/IntegrationTests/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/FBElementSwipingTests.m b/WebDriverAgentTests/IntegrationTests/FBElementSwipingTests.m index f652c81b42..7f560e0333 100644 --- a/WebDriverAgentTests/IntegrationTests/FBElementSwipingTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBElementSwipingTests.m @@ -93,10 +93,10 @@ - (void)openScrollView - (void)setUp { [super setUp]; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - [self openScrollView]; - }); + // Each test (and retry) must start at row zero, not at the previous test's + // scroll offset. A velocity-based swipe need not undo an earlier swipe. + [self resetOrientation]; + [self openScrollView]; } - (void)testSwipeUp diff --git a/WebDriverAgentTests/IntegrationTests/FBElementVisibilityTests.m b/WebDriverAgentTests/IntegrationTests/FBElementVisibilityTests.m index 87a9094924..5f0c5f3055 100644 --- a/WebDriverAgentTests/IntegrationTests/FBElementVisibilityTests.m +++ b/WebDriverAgentTests/IntegrationTests/FBElementVisibilityTests.m @@ -13,6 +13,7 @@ #import "FBTestMacros.h" #import "FBXCodeCompatibility.h" #import "XCUIElement+FBIsVisible.h" +#import "XCUIElement+FBUtilities.h" @interface FBElementVisibilityTests : FBIntegrationTestCase @end @@ -22,36 +23,30 @@ @implementation FBElementVisibilityTests - (void)testSpringBoardIcons { if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad) { - return; + XCTSkip(@"Not applicable to iPad"); } [self launchApplication]; [self goToSpringBoardFirstPage]; + [self.springboard fb_waitUntilStable]; - // Check Icons on first screen - // Note: Calender app exits 2 (an app icon + a widget) exist on the home screen - // on iOS 15+. The firstMatch is for it. - XCTAssertTrue(self.springboard.icons[@"Calendar"].firstMatch.fb_isVisible); - XCTAssertTrue(self.springboard.icons[@"Reminders"].fb_isVisible); + // Calendar can match both its app icon and a widget on iOS 15+. + FBAssertWaitTillBecomesTrue(self.springboard.icons[@"Calendar"].firstMatch.fb_isVisible); + // Safari is in the dock; Reminders is not reliably visible on the first page + // of CI simulators. Wait for the Home transition before checking visibility. + FBAssertWaitTillBecomesTrue(self.springboard.icons[@"Safari"].firstMatch.fb_isVisible); - // Check Icons on second screen screen + // Check the fixture icon on another page. XCTAssertFalse(self.springboard.icons[@"IntegrationApp"].firstMatch.fb_isVisible); } -- (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/FBIntegrationAppTests.m b/WebDriverAgentTests/IntegrationTests/FBIntegrationAppTests.m new file mode 100644 index 0000000000..32cfc5bd96 --- /dev/null +++ b/WebDriverAgentTests/IntegrationTests/FBIntegrationAppTests.m @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "FBIntegrationTestCase.h" +#import "FBTestMacros.h" +#import "XCUIDevice+FBRotation.h" +#import "XCUIElement+FBUtilities.h" + +@interface FBIntegrationAppTests : FBIntegrationTestCase +@end + +@implementation FBIntegrationAppTests + +- (void)setUp +{ + [super setUp]; + [self resetOrientation]; + [self launchApplication]; +} + +- (void)tearDown +{ + [self resetOrientation]; + [super tearDown]; +} + +- (void)rotateTo:(UIDeviceOrientation)orientation +{ + XCTAssertTrue([[XCUIDevice sharedDevice] fb_setDeviceInterfaceOrientation:orientation]); + [self.testedApplication fb_waitUntilStable]; +} + +- (void)testTouchControlsRemainOnScreenAfterRotation +{ + [self goToTouchPage]; + XCUIElement *canvas = [self.testedApplication descendantsMatchingType:XCUIElementTypeAny][@"touchableView"]; + XCUIElement *taps = self.testedApplication.staticTexts[FBTapsCountLabelIdentifier]; + XCUIElement *touches = self.testedApplication.staticTexts[FBTouchesCountLabelIdentifier]; + NSArray *orientations = @[@(UIDeviceOrientationLandscapeLeft), + @(UIDeviceOrientationLandscapeRight), + @(UIDeviceOrientationPortrait)]; + NSUInteger count = 0; + for (NSNumber *orientation in orientations) { + [self rotateTo:orientation.integerValue]; + // XCTest can report idle before the rotation animation finishes. Re-read + // the geometry until the controls have reached their on-screen layout. + FBAssertWaitTillBecomesTrue( + CGRectContainsRect(self.testedApplication.frame, canvas.frame) + && CGRectContainsRect(self.testedApplication.frame, taps.frame) + && CGRectContainsRect(self.testedApplication.frame, touches.frame)); + CGRect screen = self.testedApplication.frame; + XCTAssertTrue(CGRectContainsRect(screen, canvas.frame), @"Screen: %@; canvas: %@", + NSStringFromCGRect(screen), NSStringFromCGRect(canvas.frame)); + XCTAssertTrue(CGRectContainsRect(screen, taps.frame), @"Screen: %@; taps: %@", + NSStringFromCGRect(screen), NSStringFromCGRect(taps.frame)); + XCTAssertTrue(CGRectContainsRect(screen, touches.frame), @"Screen: %@; touches: %@", + NSStringFromCGRect(screen), NSStringFromCGRect(touches.frame)); + XCTAssertGreaterThan(CGRectGetHeight(canvas.frame), 0); + [canvas tap]; + NSString *expectedTaps = [NSString stringWithFormat:@"%lu", (unsigned long)++count]; + FBAssertWaitTillBecomesTrue([taps.label isEqualToString:expectedTaps]); + XCTAssertEqualObjects(touches.label, @"0"); + } +} + +- (void)testScrollRowsResizeWhenRotatingInBothDirections +{ + [self rotateTo:UIDeviceOrientationLandscapeLeft]; + [self goToScrollPageWithCells:NO]; + XCUIElement *scroll = self.testedApplication.scrollViews[@"scrollView"]; + XCUIElement *row = scroll.staticTexts[@"3"]; + NSArray *orientations = @[@(UIDeviceOrientationPortrait), + @(UIDeviceOrientationLandscapeRight)]; + for (NSNumber *orientation in orientations) { + [self rotateTo:orientation.integerValue]; + XCTAssertEqualWithAccuracy(CGRectGetWidth(row.frame), CGRectGetWidth(scroll.frame), 1); + XCTAssertEqualWithAccuracy(CGRectGetMidX(row.frame), CGRectGetMidX(scroll.frame), 1); + XCTAssertTrue(row.hittable); + } +} + +@end diff --git a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.h b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.h index 1e2e184e85..a9a09a5b8c 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 */ @@ -89,4 +91,11 @@ extern NSArray *const FBMainViewButtonLabels; */ - (void)resetOrientation; +/** + appium/appium#16185: skips the current test unless the app's window size actually + differs from SpringBoard's, e.g. an iPhone-only app on iPad (built with + TARGETED_DEVICE_FAMILY=1). + */ +- (void)skipUnlessWindowSizeMismatchesDevice; + @end diff --git a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m index 8b1a701fe3..a02d789800 100644 --- a/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m +++ b/WebDriverAgentTests/IntegrationTests/FBIntegrationTestCase.m @@ -34,6 +34,7 @@ @"Scrolling", @"Touch", @"DeepHierarchy", + @"Coordinate Probe", ]; @interface FBIntegrationTestCase () @@ -43,6 +44,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 +112,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]; @@ -164,4 +163,14 @@ - (void)clearAlert FBAssertWaitTillBecomesTrue(self.testedApplication.alerts.count == 0); } +- (void)skipUnlessWindowSizeMismatchesDevice +{ + CGSize appSize = self.testedApplication.frame.size; + CGSize deviceSize = self.springboard.frame.size; + if (fabs(appSize.width - deviceSize.width) < 1 && fabs(appSize.height - deviceSize.height) < 1) { + XCTSkip(@"App window size matches SpringBoard's on this build/device, so it does not " + "reproduce the compatibility-mode mismatch from appium/appium#16185"); + } +} + @end diff --git a/WebDriverAgentTests/IntegrationTests/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/FBTapTest.m b/WebDriverAgentTests/IntegrationTests/FBTapTest.m index 2d3d2ba8b6..96f05f9a23 100644 --- a/WebDriverAgentTests/IntegrationTests/FBTapTest.m +++ b/WebDriverAgentTests/IntegrationTests/FBTapTest.m @@ -11,9 +11,13 @@ #import "FBIntegrationTestCase.h" #import "FBElementCache.h" +#import "FBMathUtils.h" #import "FBTestMacros.h" +#import "XCUIApplication+FBTouchAction.h" +#import "XCUICoordinate.h" #import "XCUIDevice+FBRotation.h" #import "XCUIElement+FBIsVisible.h" +#import "XCUIElement+FBWebDriverAttributes.h" @interface FBTapTest : FBIntegrationTestCase @end @@ -100,4 +104,83 @@ - (void)testTapCoordinatesInPortraitUpsideDown [self verifyTapByCoordinatesWithOrientation:UIDeviceOrientationPortraitUpsideDown]; } +// Element-less absolute offsets are never rescaled by XCTest's XCUICoordinate (verified by +// disassembling XCUIAutomation.framework), so this still fails under a window-size mismatch. +- (void)testTapAtElementRectCenterUnderWindowSizeMismatch +{ + [self skipUnlessWindowSizeMismatchesDevice]; + + XCUIElement *dstButton = self.testedApplication.buttons[FBShowAlertButtonName]; + CGRect rect = dstButton.wdFrame; + CGPoint center = CGPointMake(CGRectGetMidX(rect), CGRectGetMidY(rect)); + + // Mirrors FBBaseActionsSynthesizer's hitpointWithElement:positionOffset: + // for an absolute (x, y) offset, as used by touch/perform and W3C actions. + XCUICoordinate *appOrigin = [self.testedApplication coordinateWithNormalizedOffset:CGVectorMake(0, 0)]; + XCUICoordinate *tapPoint = [appOrigin coordinateWithOffset:CGVectorMake(center.x, center.y)]; + [tapPoint tap]; + + XCTExpectFailureInBlock(@"element-less absolute offsets are never rescaled by XCTest for a " + "compatibility-mode window (appium/appium#16185); starts failing " + "loudly here the moment XCTest fixes this itself", ^{ + FBAssertWaitTillBecomesTrue(self.testedApplication.alerts.count > 0); + }); +} + +@end + +// The Touch page's touchable view records each touch-down's location, in its own bounds +// coordinate space, as its accessibility value - a ground truth unaffected by any +// window-level scaling, letting these tests assert on exact landing position rather than +// just on whether a tap happened to land inside some (possibly large) target. +@interface FBElementOffsetTapTest : FBIntegrationTestCase +@end + +@implementation FBElementOffsetTapTest + +- (void)setUp +{ + [super setUp]; + [self launchApplication]; + [self goToTouchPage]; +} + +- (CGPoint)lastTouchLocationOf:(XCUIElement *)touchable +{ + NSString *value = touchable.value; + NSArray *components = [value componentsSeparatedByString:@","]; + return CGPointMake(components.firstObject.doubleValue, components.lastObject.doubleValue); +} + +// FBW3CActionsSynthesizer normalizes element-relative offsets against the element's own +// frame, so this keeps landing at the intended point under a window-size mismatch +// (appium/appium#16185), unlike an element-less absolute offset. +- (void)testTapWithElementOffsetUnderWindowSizeMismatch +{ + [self skipUnlessWindowSizeMismatchesDevice]; + + XCUIElement *touchable = self.testedApplication.otherElements[@"touchableView"]; + CGSize size = touchable.wdFrame.size; + CGVector offset = CGVectorMake(size.width / 4, -size.height / 4); + CGPoint expectedLocation = CGPointMake(size.width / 2 + offset.dx, size.height / 2 + offset.dy); + + NSArray *> *gesture = + @[@{ + @"type": @"pointer", + @"id": @"finger1", + @"parameters": @{@"pointerType": @"touch"}, + @"actions": @[ + @{@"type": @"pointerMove", @"duration": @0, @"origin": touchable, @"x": @(offset.dx), @"y": @(offset.dy)}, + @{@"type": @"pointerDown"}, + @{@"type": @"pause", @"duration": @50}, + @{@"type": @"pointerUp"}, + ], + }, + ]; + NSError *error; + XCTAssertTrue([self.testedApplication fb_performW3CActions:gesture elementCache:nil error:&error]); + + FBAssertWaitTillBecomesTrue(FBPointFuzzyEqualToPoint([self lastTouchLocationOf:touchable], expectedLocation, 5.0)); +} + @end 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/FBTouchableViewTests.m b/WebDriverAgentTests/UnitTests/FBTouchableViewTests.m new file mode 100644 index 0000000000..3bf48f52f2 --- /dev/null +++ b/WebDriverAgentTests/UnitTests/FBTouchableViewTests.m @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2015-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +#import "../IntegrationApp/Classes/TouchableView.h" + +// Only the location and identity are needed to replay UIKit's touch callbacks. +@interface FBFixtureTouchDouble : NSObject +@end + +@implementation FBFixtureTouchDouble +- (CGPoint)locationInView:(UIView *)view +{ + return CGPointMake(50, 50); +} +@end + +@interface FBTouchableViewTests : XCTestCase +@property (nonatomic, strong) TouchableView *touchable; +@property (nonatomic) int reportedTouches; +@property (nonatomic) int reportedTaps; +@end + +@implementation FBTouchableViewTests + +- (void)setUp +{ + [super setUp]; + self.reportedTouches = 0; + self.reportedTaps = 0; + self.touchable = [[TouchableView alloc] initWithFrame:CGRectMake(0, 0, 200, 200)]; + self.touchable.delegate = self; +} + +- (void)tearDown +{ + self.touchable.delegate = nil; + self.touchable = nil; + [super tearDown]; +} + +- (void)shouldHandleTouchesNumber:(int)touchesCount +{ + self.reportedTouches = touchesCount; +} + +- (void)shouldHandleTapsNumber:(int)numberOfTaps +{ + self.reportedTaps = numberOfTaps; +} + +- (UITouch *)newTouch +{ + return (UITouch *)[FBFixtureTouchDouble new]; +} + +- (void)testStaggeredTouchesTrackActiveFingers +{ + NSSet *first = [NSSet setWithObject:[self newTouch]]; + NSSet *second = [NSSet setWithObject:[self newTouch]]; + [self.touchable touchesBegan:first withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 1); + [self.touchable touchesBegan:second withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 2); + XCTAssertEqual(self.reportedTaps, 0); + + [self.touchable touchesEnded:first withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 1); + XCTAssertEqual(self.reportedTaps, 1); + [self.touchable touchesEnded:second withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 0); + XCTAssertEqual(self.reportedTaps, 2); +} + +- (void)testSimultaneousContactsCountIndependentlyOfCallbackBatching +{ + NSSet *touches = [NSSet setWithObjects:[self newTouch], [self newTouch], nil]; + [self.touchable touchesBegan:touches withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 2); + [self.touchable touchesEnded:touches withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 0); + XCTAssertEqual(self.reportedTaps, 2); +} + +- (void)testCancelledContactsDoNotCarryOverIntoNextTap +{ + NSSet *first = [NSSet setWithObject:[self newTouch]]; + NSSet *second = [NSSet setWithObject:[self newTouch]]; + [self.touchable touchesBegan:first withEvent:nil]; + [self.touchable touchesBegan:second withEvent:nil]; + [self.touchable touchesCancelled:[first setByAddingObjectsFromSet:second] withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 0); + XCTAssertEqual(self.reportedTaps, 0); + + NSSet *next = [NSSet setWithObject:[self newTouch]]; + [self.touchable touchesBegan:next withEvent:nil]; + [self.touchable touchesEnded:next withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 0); + XCTAssertEqual(self.reportedTaps, 1); +} + +- (void)testCancellingOneFingerPreservesTheOtherContact +{ + NSSet *first = [NSSet setWithObject:[self newTouch]]; + NSSet *second = [NSSet setWithObject:[self newTouch]]; + [self.touchable touchesBegan:[first setByAddingObjectsFromSet:second] withEvent:nil]; + [self.touchable touchesCancelled:first withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 1); + XCTAssertEqual(self.reportedTaps, 0); + [self.touchable touchesEnded:second withEvent:nil]; + XCTAssertEqual(self.reportedTouches, 0); + XCTAssertEqual(self.reportedTaps, 1); +} + +@end 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..98235a2d89 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -13,4 +13,16 @@ export const PLATFORM_NAME_IOS = 'iOS'; export const SDK_DEVICE = 'iphoneos'; +/** + * @deprecated The WDA upgrade timestamp path is no longer used. + */ export const WDA_UPGRADE_TIMESTAMP_PATH = path.join('.appium', 'webdriveragent', 'upgrade.time'); + +/** + * 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/webdriveragent.ts b/lib/webdriveragent.ts index 736b3fdf80..b55a48167b 100644 --- a/lib/webdriveragent.ts +++ b/lib/webdriveragent.ts @@ -7,12 +7,7 @@ import type {AppiumLogger, StringRecord} from '@appium/types'; import AsyncLock from 'async-lock'; import {waitForCondition} from 'asyncbox'; -import { - WDA_RUNNER_BUNDLE_ID, - WDA_BASE_URL, - WDA_UPGRADE_TIMESTAMP_PATH, - DEFAULT_TEST_BUNDLE_SUFFIX, -} from './constants.js'; +import {WDA_RUNNER_BUNDLE_ID, WDA_BASE_URL, DEFAULT_TEST_BUNDLE_SUFFIX} from './constants.js'; import {log as defaultLogger} from './logger.js'; import {NoSessionProxy} from './no-session-proxy.js'; import type { @@ -584,31 +579,19 @@ export class WebDriverAgent { const packageInfo = JSON.parse(await fs.readFile(path.join(BOOTSTRAP_PATH, 'package.json'), 'utf8')); const box = strongbox(packageInfo.name); - let boxItem = box.getItem(RECENT_MODULE_VERSION_ITEM_NAME); - if (!boxItem) { - const timestampPath = path.resolve(process.env.HOME ?? '', WDA_UPGRADE_TIMESTAMP_PATH); - if (await fs.exists(timestampPath)) { - // TODO: It is probably a bit ugly to hardcode the recent version string, - // TODO: hovewer it should do the job as a temporary transition trick - // TODO: to switch from a hardcoded file path to the strongbox usage. - try { - boxItem = await box.createItemWithValue(RECENT_MODULE_VERSION_ITEM_NAME, '5.0.0'); - } catch (e: any) { - this.log.warn(`The actual module version cannot be persisted: ${e.message}`); - return; - } - } else { - this.log.info('There is no need to perform the project cleanup. A fresh install has been detected'); - try { - await box.createItemWithValue(RECENT_MODULE_VERSION_ITEM_NAME, packageInfo.version); - } catch (e: any) { - this.log.warn(`The actual module version cannot be persisted: ${e.message}`); - } - return; + // Each Strongbox instance starts with an empty item map. Load the persisted value from disk. + const boxItem = await box.createItem(RECENT_MODULE_VERSION_ITEM_NAME); + let recentModuleVersion = boxItem.value; + if (recentModuleVersion === undefined) { + this.log.info('There is no need to perform the project cleanup. A fresh install has been detected'); + try { + await boxItem.write(packageInfo.version); + } catch (e: any) { + this.log.warn(`The actual module version cannot be persisted: ${e.message}`); } + return; } - let recentModuleVersion = await boxItem.read(); try { recentModuleVersion = util.coerceVersion(recentModuleVersion, true); } catch (e: any) { diff --git a/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..33c9e6fe9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appium-webdriveragent", - "version": "16.8.0", + "version": "16.12.7", "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')); + }); + }); +}); diff --git a/test/unit/wda-cleanup.spec.ts b/test/unit/wda-cleanup.spec.ts new file mode 100644 index 0000000000..ed1489800a --- /dev/null +++ b/test/unit/wda-cleanup.spec.ts @@ -0,0 +1,136 @@ +import assert from 'node:assert/strict'; +import {mkdtemp, readFile, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {afterEach, beforeEach, describe, it, mock} from 'node:test'; + +import {strongbox} from '@appium/strongbox'; +import {fs} from '@appium/support'; +import sinon from 'sinon'; + +import {BOOTSTRAP_PATH} from '../../lib/utils/index.js'; + +let container: string; +function isolatedStrongbox(name: string) { + const box = strongbox(name); + // Preserve the temporary path verbatim instead of Strongbox's container slugification. + Object.defineProperty(box, 'container', {value: container}); + return box; +} +mock.module('@appium/strongbox', { + namedExports: { + strongbox: isolatedStrongbox, + }, +}); +const {WebDriverAgent} = await import('../../lib/webdriveragent.js'); +const packageInfo = JSON.parse(await readFile(path.join(BOOTSTRAP_PATH, 'package.json'), 'utf8')); +const itemName = 'recentWdaModuleVersion'; + +describe('WDA project cleanup persistence', function () { + let sandbox: sinon.SinonSandbox; + let legacyMarker: sinon.SinonStub; + + beforeEach(async function () { + container = await mkdtemp(path.join(tmpdir(), 'wda-cleanup-')); + sandbox = sinon.createSandbox(); + legacyMarker = sandbox + .stub(fs, 'exists') + .withArgs(path.resolve(process.env.HOME ?? '', '.appium', 'webdriveragent', 'upgrade.time')) + .resolves(false); + }); + + afterEach(async function () { + sandbox.restore(); + await rm(container, {recursive: true, force: true}); + }); + + function newAgent() { + const agent = new WebDriverAgent({device: {udid: 'test-udid'}, platformVersion: '17.2'}); + const clean = sandbox.stub(agent.xcodebuild, 'cleanProject').resolves(); + return {clean, run: async () => await (agent as any)._cleanupProjectIfFresh()}; + } + + async function persist(version: string) { + await isolatedStrongbox(packageInfo.name).createItemWithValue(itemName, version); + } + + async function persistedVersion() { + return (await isolatedStrongbox(packageInfo.name).createItem(itemName)).value; + } + + it('reuses the persisted version despite a legacy marker across new agents', async function () { + legacyMarker.resolves(true); + await persist(packageInfo.version); + for (let i = 0; i < 2; i++) { + const agent = newAgent(); + await agent.run(); + sandbox.assert.notCalled(agent.clean); + } + sandbox.assert.notCalled(legacyMarker); + assert.equal(await persistedVersion(), packageInfo.version); + }); + + it('cleans an older persisted version once without a legacy marker', async function () { + await persist('5.0.0'); + const first = newAgent(); + await first.run(); + sandbox.assert.calledOnce(first.clean); + assert.equal(await persistedVersion(), packageInfo.version); + const second = newAgent(); + await second.run(); + sandbox.assert.notCalled(second.clean); + sandbox.assert.notCalled(legacyMarker); + }); + + it('initializes missing version state without consulting a legacy marker', async function () { + legacyMarker.resolves(true); + const first = newAgent(); + await first.run(); + sandbox.assert.notCalled(first.clean); + assert.equal(await persistedVersion(), packageInfo.version); + const second = newAgent(); + await second.run(); + sandbox.assert.notCalled(second.clean); + sandbox.assert.notCalled(legacyMarker); + }); + + it('initializes a fresh installation without cleaning', async function () { + for (let i = 0; i < 2; i++) { + const agent = newAgent(); + await agent.run(); + sandbox.assert.notCalled(agent.clean); + } + sandbox.assert.notCalled(legacyMarker); + assert.equal(await persistedVersion(), packageInfo.version); + }); + + it('preserves a newer persisted version', async function () { + await persist('999.0.0'); + const agent = newAgent(); + await agent.run(); + sandbox.assert.notCalled(agent.clean); + assert.equal(await persistedVersion(), '999.0.0'); + }); + + it('repairs a damaged persisted version without treating it as legacy state', async function () { + legacyMarker.resolves(true); + await persist('not-a-version'); + const agent = newAgent(); + await agent.run(); + sandbox.assert.notCalled(agent.clean); + sandbox.assert.notCalled(legacyMarker); + assert.equal(await persistedVersion(), packageInfo.version); + }); + + it('retries cleanup after a failure without recording a successful upgrade', async function () { + await persist('5.0.0'); + const first = newAgent(); + first.clean.rejects(new Error('cleanup failed')); + await first.run(); + assert.equal(await persistedVersion(), '5.0.0'); + const second = newAgent(); + await second.run(); + sandbox.assert.calledOnce(second.clean); + assert.equal(await persistedVersion(), packageInfo.version); + }); +});