From a5d2ddf1518be48fd067b95d171e9f626b3cc7b0 Mon Sep 17 00:00:00 2001 From: Sarah Date: Fri, 20 Dec 2024 11:01:06 +0100 Subject: [PATCH 1/7] Adding QA automation to iOS repo --- .swiftformat | 3 +- .swiftlint.yml | 1 + wire-ios-automation/ios/.gitignore | 5 + wire-ios-automation/ios/.project | 23 + wire-ios-automation/ios/GridDeployment.groovy | 448 +++ .../ios/NodeMaintenance.groovy | 85 + wire-ios-automation/ios/Powercycle.groovy | 56 + wire-ios-automation/ios/README.md | 258 ++ .../ios/Release_Tests.Jenkinsfile.groovy | 281 ++ .../ios/RetrieveIPA.Jenkinsfile.groovy | 153 + .../ios/Tests.Jenkinsfile.groovy | 366 +++ .../ios/Trigger.Jenkinsfile.groovy | 85 + wire-ios-automation/ios/img.png | Bin 0 -> 5545 bytes wire-ios-automation/ios/nbactions.xml | 33 + wire-ios-automation/ios/pom.xml | 255 ++ .../auto/ios/common/ElementState.java | 84 + .../auto/ios/common/IOSDriverBuilder.java | 224 ++ .../auto/ios/common/IOSTestContext.java | 361 +++ .../auto/ios/common/IPAInspector.java | 62 + .../wearezeta/auto/ios/common/Lifecycle.java | 407 +++ .../auto/ios/common/PagesCollection.java | 32 + .../com/wearezeta/auto/ios/common/Pinger.java | 56 + .../wearezeta/auto/ios/common/RealDevice.java | 49 + .../auto/ios/pages/ActionsSheetPage.java | 60 + .../wearezeta/auto/ios/pages/ArchivePage.java | 43 + .../ios/pages/BackupPasswordOverlayPage.java | 27 + .../ios/pages/BottomNavigationBarPage.java | 51 + .../wearezeta/auto/ios/pages/CameraPage.java | 48 + .../auto/ios/pages/CameraRollPage.java | 26 + .../auto/ios/pages/CollectionPage.java | 53 + .../auto/ios/pages/ContactsUiPage.java | 77 + .../auto/ios/pages/ConversationViewPage.java | 966 ++++++ .../auto/ios/pages/ConversationsListPage.java | 310 ++ .../auto/ios/pages/CreateFolderPage.java | 42 + .../pages/CustomBackendRedirectionPage.java | 43 + .../ios/pages/CustomBackendWelcomePage.java | 34 + .../wearezeta/auto/ios/pages/E2EIPage.java | 27 + .../ios/pages/EncryptionAtRestOverlay.java | 37 + .../auto/ios/pages/EnterpriseLoginPage.java | 34 + .../auto/ios/pages/FileInspectionPage.java | 26 + .../auto/ios/pages/FirstTimeOverlay.java | 42 + .../auto/ios/pages/FolderViewPage.java | 256 ++ .../wearezeta/auto/ios/pages/ForwardPage.java | 90 + .../auto/ios/pages/GiphyPreviewPage.java | 49 + .../auto/ios/pages/HistoryBackupPage.java | 26 + .../com/wearezeta/auto/ios/pages/IOSPage.java | 1065 +++++++ .../auto/ios/pages/ImageFullScreenPage.java | 35 + .../auto/ios/pages/KeyboardGalleryPage.java | 42 + .../auto/ios/pages/LegalHoldOverviewPage.java | 62 + .../wearezeta/auto/ios/pages/LoginPage.java | 143 + .../auto/ios/pages/ManageDevicesOverlay.java | 42 + .../wearezeta/auto/ios/pages/MapViewPage.java | 19 + .../auto/ios/pages/MessageDetailsPage.java | 36 + .../ios/pages/MoveToCustomFolderPage.java | 42 + .../wearezeta/auto/ios/pages/PasteDialog.java | 19 + .../auto/ios/pages/PicturePreviewPage.java | 40 + .../auto/ios/pages/PollMessagesPage.java | 63 + .../auto/ios/pages/ReactionsEditMenuPage.java | 170 ++ .../auto/ios/pages/RegistrationPage.java | 133 + .../auto/ios/pages/SearchUIPage.java | 161 + .../auto/ios/pages/ServiceCreationPage.java | 18 + .../auto/ios/pages/ServiceDetailPage.java | 19 + .../auto/ios/pages/SettingsPage.java | 473 +++ .../wearezeta/auto/ios/pages/SketchPage.java | 26 + .../auto/ios/pages/StatusActionSheetPage.java | 14 + .../auto/ios/pages/TeamSearchUIPage.java | 19 + .../auto/ios/pages/TopNavigationBarPage.java | 68 + .../auto/ios/pages/UniqueUsernamePage.java | 33 + .../ios/pages/UniqueUsernameTakeoverPage.java | 25 + .../auto/ios/pages/VideoPlayerPage.java | 33 + .../auto/ios/pages/VoiceFiltersOverlay.java | 76 + .../wearezeta/auto/ios/pages/WelcomePage.java | 66 + .../auto/ios/pages/calling/CallPage.java | 24 + .../calling/VideoCallingOverlayPage.java | 365 +++ .../common/CertificateDetailsPage.java | 28 + .../common/DeviceDetailsPage.java | 75 + .../common/UserDetailsDevicesPage.java | 60 + .../common/UserSettingsDevicesPage.java | 103 + .../group/GroupAddPeoplePage.java | 51 + .../GroupConnectedParticipantProfilePage.java | 140 + .../group/GroupDetailsPage.java | 265 ++ ...dingParticipantIncomingConnectionPage.java | 23 + ...dingParticipantOutgoingConnectionPage.java | 47 + .../group/GroupPeoplePage.java | 67 + .../group/GuestOptionsPage.java | 44 + .../single/ConnectionInboxPage.java | 115 + .../single/SelfProfilePage.java | 110 + .../SingleConnectedUserProfilePage.java | 112 + ...dingUserIncomingConnectionProfilePage.java | 48 + ...nglePendingUserOutgoingConnectionPage.java | 154 + .../single/UserProfilePopupPage.java | 155 + .../external_app/FileChooseDialogPage.java | 81 + .../external_app/FileSavingPopupPage.java | 59 + .../auto/ios/pages/keyboard/IOSKeyboard.java | 62 + .../linear_groupcreation/AddPeoplePage.java | 68 + .../linear_groupcreation/NewGroupPage.java | 135 + .../pages/search/BaseSearchableItemsList.java | 255 ++ .../pages/search/GroupParticipantsList.java | 56 + .../search/GroupParticipantsSearchList.java | 90 + .../pages/search/MentionSuggestionsList.java | 53 + .../auto/ios/pages/search/SearchList.java | 93 + .../pages/team_creation/InvitePeoplePage.java | 27 + .../team_creation/TCVerificationCodePage.java | 35 + .../pages/webview/KeycloakWebViewPage.java | 57 + .../ios/pages/webview/OktaWebViewPage.java | 47 + .../auto/ios/pages/webview/WebViewPage.java | 219 ++ .../main/resources/Configuration.properties | 32 + .../services/javax.script.ScriptEngineFactory | 1 + .../ios/src/main/resources/mixpanel.cer | 22 + .../resources/scripts/export_trace_to_csv.txt | 29 + .../auto/ios/pages/AdvancedSettingsPage.java | 26 + .../auto/ios/steps/AdvancedSettingsSteps.java | 31 + .../auto/ios/steps/ArchivePageSteps.java | 66 + .../ios/steps/BackupPasswordOverlaySteps.java | 39 + .../auto/ios/steps/BiometricAuthSteps.java | 39 + .../ios/steps/BottomNavigationBarSteps.java | 64 + .../auto/ios/steps/CameraPageSteps.java | 53 + .../auto/ios/steps/CameraRollPageSteps.java | 28 + .../auto/ios/steps/CollectionPageSteps.java | 37 + .../auto/ios/steps/CommonBackendSteps.java | 460 +++ .../auto/ios/steps/CommonIOSSteps.java | 627 ++++ .../auto/ios/steps/ContactsUiPageSteps.java | 93 + .../steps/ConversationActionsPageSteps.java | 173 ++ .../ios/steps/ConversationViewPageSteps.java | 1147 ++++++++ .../ios/steps/ConversationsListPageSteps.java | 364 +++ .../auto/ios/steps/CreateFolderSteps.java | 29 + .../CustomBackendRedirectionPageSteps.java | 54 + .../steps/CustomBackendWelcomePageSteps.java | 36 + .../auto/ios/steps/E2EIOverlayPageSteps.java | 28 + .../steps/EncryptionAtRestOverlaySteps.java | 52 + .../ios/steps/EnterpriseLoginPageSteps.java | 50 + .../ios/steps/FileInspectionPageSteps.java | 27 + .../auto/ios/steps/FirstTimeOverlaySteps.java | 42 + .../auto/ios/steps/FolderViewSteps.java | 269 ++ .../auto/ios/steps/ForwardPageSteps.java | 97 + .../auto/ios/steps/GiphyPreviewPageSteps.java | 42 + .../auto/ios/steps/HistoryBackupSteps.java | 47 + .../ios/steps/ImageFullScreenPageSteps.java | 44 + .../ios/steps/KeyboardGalleryPageSteps.java | 48 + .../ios/steps/LegalHoldOverviewPageSteps.java | 59 + .../auto/ios/steps/LoginPageSteps.java | 190 ++ .../wearezeta/auto/ios/steps/LoginSteps.java | 115 + .../ios/steps/ManageDeviceOverlaySteps.java | 54 + .../auto/ios/steps/MapViewPageSteps.java | 22 + .../auto/ios/steps/MentionSteps.java | 103 + .../ios/steps/MessageDetailsPageSteps.java | 34 + .../ios/steps/MoveToCustomFolderSteps.java | 41 + .../auto/ios/steps/PasteDialogSteps.java | 20 + .../ios/steps/PicturePreviewPageSteps.java | 37 + .../auto/ios/steps/PollMessagesSteps.java | 64 + .../ios/steps/ReactionsEditMenuPageSteps.java | 154 + .../auto/ios/steps/RegistrationPageSteps.java | 207 ++ .../auto/ios/steps/SearchUIPageSteps.java | 243 ++ .../auto/ios/steps/SketchPageSteps.java | 32 + .../ios/steps/StatusActionSheetSteps.java | 28 + .../auto/ios/steps/TestServiceSteps.java | 343 +++ .../auto/ios/steps/TopNavigationBarSteps.java | 69 + .../ios/steps/UniqueUsernamePageSteps.java | 52 + .../UniqueUsernameTakeoverPageSteps.java | 28 + .../auto/ios/steps/VideoPlayerPageSteps.java | 37 + .../ios/steps/VoiceFiltersOverlaySteps.java | 49 + .../auto/ios/steps/WelcomePageSteps.java | 62 + .../auto/ios/steps/calling/CallPageSteps.java | 185 ++ .../auto/ios/steps/calling/CallingSteps.java | 221 ++ .../calling/VideoCallingOverlayPageSteps.java | 68 + .../CertificateDetailsPageSteps.java | 34 + .../DeviceDetailsPageSteps.java | 40 + .../UserDetailsDevicesPageSteps.java | 60 + .../group/GroupAddPeoplePageSteps.java | 97 + .../group/GroupDetailsPageSteps.java | 254 ++ ...pParticipantConnectedProfilePageSteps.java | 137 + ...antIncomingPendingConnectionPageSteps.java | 30 + ...antOutgoingPendingConnectionPageSteps.java | 41 + .../group/GroupPeoplePageSteps.java | 59 + .../group/GuestOptionsPageSteps.java | 44 + .../single/ConnectionInboxPageSteps.java | 71 + .../SingleConnectedUserProfilePageSteps.java | 82 + ...serIncomingConnectionProfilePageSteps.java | 47 + ...serOutgoingPendingConnectionPageSteps.java | 103 + .../single/UserProfilePopupPageSteps.java | 158 + .../external_app/FileChooseDialogSteps.java | 56 + .../steps/external_app/FileSavingSteps.java | 61 + .../AddPeoplePageSteps.java | 63 + .../NewGroupPageSteps.java | 124 + .../auto/ios/steps/services/ServiceSteps.java | 37 + .../steps/settings/SelfDevicesPageSteps.java | 107 + .../steps/settings/SelfProfilePageSteps.java | 99 + .../ios/steps/settings/SettingsPageSteps.java | 435 +++ .../team_creation/InvitePeopleSteps.java | 23 + .../TCVerificationCodePageSteps.java | 44 + .../webview/KeycloakWebViewPageSteps.java | 74 + .../steps/webview/OktaWebViewPageSteps.java | 51 + .../auto/ios/steps/webview/WebViewSteps.java | 174 ++ .../auto/ios/2FactorAuthentication.feature | 72 + .../com/wearezeta/auto/ios/Blacklist.feature | 27 + .../com/wearezeta/auto/ios/Calling.feature | 25 + .../auto/ios/Column-Tests/2FA.feature | 98 + .../auto/ios/Column-Tests/CBR.feature | 101 + .../auto/ios/Column-Tests/Col3Tests.feature | 616 ++++ .../E2EE/E2EEDeviceManagement.feature | 23 + .../E2EE/E2EEVerification.feature | 44 + .../ios/Column-Tests/ExchangeMessages.feature | 145 + .../Column-Tests/FederationClassified.feature | 344 +++ .../FederationUnreachable.feature | 497 ++++ .../auto/ios/Column-Tests/FileSharing.feature | 192 ++ .../ios/Column-Tests/GroupCreation.feature | 26 + .../Column-Tests/GuestLinkCreation.feature | 19 + .../ios/Column-Tests/HistoryImport.feature | 76 + .../auto/ios/Column-Tests/Images.feature | 103 + .../auto/ios/Column-Tests/LinkPreview.feature | 28 + .../auto/ios/Column-Tests/Login.feature | 19 + .../auto/ios/Column-Tests/Logout.feature | 31 + .../auto/ios/Column-Tests/MLS/E2EI.feature | 133 + .../auto/ios/Column-Tests/MLS/MLSCol1.feature | 312 ++ .../auto/ios/Column-Tests/MLS/MLSCol3.feature | 96 + .../NonFullyConnectedGraphs.feature | 133 + .../auto/ios/Column-Tests/On-Premises.feature | 60 + .../auto/ios/Column-Tests/Search.feature | 130 + .../auto/ios/Column-Tests/SelfProfile.feature | 82 + .../ios/Column-Tests/TechnicalInfo.feature | 9 + .../auto/ios/Column-Tests/Upgrade.feature | 39 + .../ios/Critical-Flows/FileSending.feature | 56 + .../auto/ios/Critical-Flows/Groups.feature | 171 ++ .../auto/ios/Critical-Flows/NewDevice.feature | 87 + .../NewPersonOnboarding.feature | 68 + .../auto/ios/Critical-Flows/Statuses.feature | 61 + .../auto/ios/Critical-Flows/Upgrade.feature | 41 + .../auto/ios/Critical-Flows/VideoCall.feature | 56 + .../wearezeta/auto/ios/DeleteMessage.feature | 25 + .../auto/ios/LinearGroupCreation.feature | 28 + .../com/wearezeta/auto/ios/Login.feature | 25 + .../com/wearezeta/auto/ios/Logout.feature | 118 + .../wearezeta/auto/ios/Registration.feature | 18 + .../test/resources/junit-platform.properties | 5 + wire-ios-automation/pom.xml | 12 + .../tools/android/getLatestCand.sh | 3 + .../tools/android/getLatestExp.sh | 3 + .../tools/android/historian.py | 1566 ++++++++++ .../android/testing-gallery-qa-release.apk | Bin 0 -> 8541478 bytes .../tools/android_result_aggregator.groovy | 160 + wire-ios-automation/tools/audio/test.m4a | Bin 0 -> 3171878 bytes wire-ios-automation/tools/img/animated.gif | Bin 0 -> 451009 bytes .../tools/img/aqaPictureContact600_800.jpg | Bin 0 -> 75091 bytes ...aPictureContact_osx_userinfo_1920x1080.png | Bin 0 -> 378606 bytes ...aPictureContact_osx_userinfo_2560x1600.png | Bin 0 -> 1272631 bytes wire-ios-automation/tools/img/avatarTest.png | Bin 0 -> 5813 bytes .../tools/img/osx/aqaPictureContact.jpg | Bin 0 -> 71032 bytes ...aPictureContact_osx_userinfo_1920x1080.png | Bin 0 -> 722617 bytes ...aPictureContact_osx_userinfo_2560x1600.png | Bin 0 -> 1272631 bytes wire-ios-automation/tools/img/osx/testing.jpg | Bin 0 -> 246181 bytes .../tools/img/osx/userpicture_landscape.jpg | Bin 0 -> 283301 bytes .../tools/img/osx/userpicture_portrait.jpg | Bin 0 -> 77576 bytes .../tools/img/personalProfilePicture.png | Bin 0 -> 13246 bytes .../img/personalProfilePictureAndroid.png | Bin 0 -> 11207 bytes wire-ios-automation/tools/img/teamLogo.png | Bin 0 -> 5599 bytes .../tools/img/teamLogoAndroid.png | Bin 0 -> 1967 bytes .../tools/img/teamLogoBlue.png | Bin 0 -> 5810 bytes .../tools/img/teamLogoBlueAndroid.png | Bin 0 -> 1762 bytes wire-ios-automation/tools/img/teamLogoRed.png | Bin 0 -> 5860 bytes .../tools/img/teamLogoRedAndroid.png | Bin 0 -> 1739 bytes wire-ios-automation/tools/img/testing.jpg | Bin 0 -> 246181 bytes .../tools/img/userpicture_landscape.jpg | Bin 0 -> 283301 bytes .../tools/img/userpicture_portrait.jpg | Bin 0 -> 77576 bytes .../tools/ios/clickInWindow.py | 116 + .../tools/ios/doubleClickInWindow.py | 107 + .../tools/ios/misc/lessThan8000ch.txt | 23 + .../tools/ios/misc/project.pbxproj | 2598 +++++++++++++++++ wire-ios-automation/tools/ios/misc/zalgo.txt | 1 + .../tools/ios/reconnectIDevice.py | 75 + wire-ios-automation/tools/ios/simshot | Bin 0 -> 24576 bytes .../tools/ios/swipeInWindow.py | 134 + wire-ios-automation/tools/ios/zbackuptool | Bin 0 -> 10266192 bytes wire-ios-automation/tools/timeout | 50 + wire-ios-automation/tools/video/testing.mp4 | Bin 0 -> 386751 bytes 274 files changed, 30363 insertions(+), 1 deletion(-) create mode 100644 wire-ios-automation/ios/.gitignore create mode 100644 wire-ios-automation/ios/.project create mode 100644 wire-ios-automation/ios/GridDeployment.groovy create mode 100644 wire-ios-automation/ios/NodeMaintenance.groovy create mode 100644 wire-ios-automation/ios/Powercycle.groovy create mode 100644 wire-ios-automation/ios/README.md create mode 100644 wire-ios-automation/ios/Release_Tests.Jenkinsfile.groovy create mode 100644 wire-ios-automation/ios/RetrieveIPA.Jenkinsfile.groovy create mode 100644 wire-ios-automation/ios/Tests.Jenkinsfile.groovy create mode 100644 wire-ios-automation/ios/Trigger.Jenkinsfile.groovy create mode 100644 wire-ios-automation/ios/img.png create mode 100644 wire-ios-automation/ios/nbactions.xml create mode 100644 wire-ios-automation/ios/pom.xml create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/ElementState.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/IOSDriverBuilder.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/IOSTestContext.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/IPAInspector.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/Lifecycle.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/PagesCollection.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/Pinger.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/RealDevice.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ActionsSheetPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ArchivePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/BackupPasswordOverlayPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/BottomNavigationBarPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CameraPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CameraRollPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CollectionPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ContactsUiPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ConversationViewPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ConversationsListPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CreateFolderPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CustomBackendRedirectionPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CustomBackendWelcomePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/E2EIPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/EncryptionAtRestOverlay.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/EnterpriseLoginPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/FileInspectionPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/FirstTimeOverlay.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/FolderViewPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ForwardPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/GiphyPreviewPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/HistoryBackupPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/IOSPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ImageFullScreenPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/KeyboardGalleryPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/LegalHoldOverviewPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/LoginPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ManageDevicesOverlay.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/MapViewPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/MessageDetailsPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/MoveToCustomFolderPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/PasteDialog.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/PicturePreviewPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/PollMessagesPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ReactionsEditMenuPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/RegistrationPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/SearchUIPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ServiceCreationPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ServiceDetailPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/SettingsPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/SketchPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/StatusActionSheetPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/TeamSearchUIPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/TopNavigationBarPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/UniqueUsernamePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/UniqueUsernameTakeoverPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/VideoPlayerPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/VoiceFiltersOverlay.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/WelcomePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/calling/CallPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/calling/VideoCallingOverlayPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/CertificateDetailsPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/DeviceDetailsPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/UserDetailsDevicesPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/UserSettingsDevicesPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupAddPeoplePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupConnectedParticipantProfilePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupDetailsPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupPendingParticipantIncomingConnectionPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupPendingParticipantOutgoingConnectionPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupPeoplePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GuestOptionsPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/ConnectionInboxPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SelfProfilePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SingleConnectedUserProfilePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SinglePendingUserIncomingConnectionProfilePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SinglePendingUserOutgoingConnectionPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/UserProfilePopupPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/external_app/FileChooseDialogPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/external_app/FileSavingPopupPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/keyboard/IOSKeyboard.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/linear_groupcreation/AddPeoplePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/linear_groupcreation/NewGroupPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/BaseSearchableItemsList.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/GroupParticipantsList.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/GroupParticipantsSearchList.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/MentionSuggestionsList.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/SearchList.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/team_creation/InvitePeoplePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/team_creation/TCVerificationCodePage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/webview/KeycloakWebViewPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/webview/OktaWebViewPage.java create mode 100644 wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/webview/WebViewPage.java create mode 100644 wire-ios-automation/ios/src/main/resources/Configuration.properties create mode 100644 wire-ios-automation/ios/src/main/resources/META-INF/services/javax.script.ScriptEngineFactory create mode 100644 wire-ios-automation/ios/src/main/resources/mixpanel.cer create mode 100644 wire-ios-automation/ios/src/main/resources/scripts/export_trace_to_csv.txt create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/pages/AdvancedSettingsPage.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/AdvancedSettingsSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ArchivePageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/BackupPasswordOverlaySteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/BiometricAuthSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/BottomNavigationBarSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CameraPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CameraRollPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CollectionPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CommonBackendSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CommonIOSSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ContactsUiPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ConversationActionsPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ConversationViewPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ConversationsListPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CreateFolderSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CustomBackendRedirectionPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CustomBackendWelcomePageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/E2EIOverlayPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/EncryptionAtRestOverlaySteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/EnterpriseLoginPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/FileInspectionPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/FirstTimeOverlaySteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/FolderViewSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ForwardPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/GiphyPreviewPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/HistoryBackupSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ImageFullScreenPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/KeyboardGalleryPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/LegalHoldOverviewPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/LoginPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/LoginSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ManageDeviceOverlaySteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MapViewPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MentionSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MessageDetailsPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MoveToCustomFolderSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/PasteDialogSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/PicturePreviewPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/PollMessagesSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ReactionsEditMenuPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/RegistrationPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/SearchUIPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/SketchPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/StatusActionSheetSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/TestServiceSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/TopNavigationBarSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/UniqueUsernamePageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/UniqueUsernameTakeoverPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/VideoPlayerPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/VoiceFiltersOverlaySteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/WelcomePageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/calling/CallPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/calling/CallingSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/calling/VideoCallingOverlayPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/CertificateDetailsPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/DeviceDetailsPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/UserDetailsDevicesPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupAddPeoplePageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupDetailsPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupParticipantConnectedProfilePageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupParticipantIncomingPendingConnectionPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupParticipantOutgoingPendingConnectionPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupPeoplePageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GuestOptionsPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/ConnectionInboxPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/SingleConnectedUserProfilePageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/SinglePendingUserIncomingConnectionProfilePageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/SingleUserOutgoingPendingConnectionPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/UserProfilePopupPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/external_app/FileChooseDialogSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/external_app/FileSavingSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/linear_groupcreation/AddPeoplePageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/linear_groupcreation/NewGroupPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/services/ServiceSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/settings/SelfDevicesPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/settings/SelfProfilePageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/settings/SettingsPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/team_creation/InvitePeopleSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/team_creation/TCVerificationCodePageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/webview/KeycloakWebViewPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/webview/OktaWebViewPageSteps.java create mode 100644 wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/webview/WebViewSteps.java create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/2FactorAuthentication.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Blacklist.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Calling.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/2FA.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/CBR.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Col3Tests.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/E2EE/E2EEDeviceManagement.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/E2EE/E2EEVerification.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/ExchangeMessages.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/FederationClassified.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/FederationUnreachable.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/FileSharing.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/GroupCreation.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/GuestLinkCreation.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/HistoryImport.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Images.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/LinkPreview.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Login.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Logout.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/MLS/E2EI.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/MLS/MLSCol1.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/MLS/MLSCol3.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/NonFullyConnectedGraphs.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/On-Premises.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Search.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/SelfProfile.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/TechnicalInfo.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Upgrade.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/FileSending.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/Groups.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/NewDevice.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/NewPersonOnboarding.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/Statuses.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/Upgrade.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/VideoCall.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/DeleteMessage.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/LinearGroupCreation.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Login.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Logout.feature create mode 100644 wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Registration.feature create mode 100644 wire-ios-automation/ios/src/test/resources/junit-platform.properties create mode 100644 wire-ios-automation/pom.xml create mode 100755 wire-ios-automation/tools/android/getLatestCand.sh create mode 100644 wire-ios-automation/tools/android/getLatestExp.sh create mode 100644 wire-ios-automation/tools/android/historian.py create mode 100644 wire-ios-automation/tools/android/testing-gallery-qa-release.apk create mode 100644 wire-ios-automation/tools/android_result_aggregator.groovy create mode 100644 wire-ios-automation/tools/audio/test.m4a create mode 100644 wire-ios-automation/tools/img/animated.gif create mode 100644 wire-ios-automation/tools/img/aqaPictureContact600_800.jpg create mode 100644 wire-ios-automation/tools/img/aqaPictureContact_osx_userinfo_1920x1080.png create mode 100644 wire-ios-automation/tools/img/aqaPictureContact_osx_userinfo_2560x1600.png create mode 100644 wire-ios-automation/tools/img/avatarTest.png create mode 100644 wire-ios-automation/tools/img/osx/aqaPictureContact.jpg create mode 100644 wire-ios-automation/tools/img/osx/aqaPictureContact_osx_userinfo_1920x1080.png create mode 100644 wire-ios-automation/tools/img/osx/aqaPictureContact_osx_userinfo_2560x1600.png create mode 100644 wire-ios-automation/tools/img/osx/testing.jpg create mode 100644 wire-ios-automation/tools/img/osx/userpicture_landscape.jpg create mode 100644 wire-ios-automation/tools/img/osx/userpicture_portrait.jpg create mode 100644 wire-ios-automation/tools/img/personalProfilePicture.png create mode 100644 wire-ios-automation/tools/img/personalProfilePictureAndroid.png create mode 100644 wire-ios-automation/tools/img/teamLogo.png create mode 100644 wire-ios-automation/tools/img/teamLogoAndroid.png create mode 100644 wire-ios-automation/tools/img/teamLogoBlue.png create mode 100644 wire-ios-automation/tools/img/teamLogoBlueAndroid.png create mode 100644 wire-ios-automation/tools/img/teamLogoRed.png create mode 100644 wire-ios-automation/tools/img/teamLogoRedAndroid.png create mode 100644 wire-ios-automation/tools/img/testing.jpg create mode 100644 wire-ios-automation/tools/img/userpicture_landscape.jpg create mode 100644 wire-ios-automation/tools/img/userpicture_portrait.jpg create mode 100755 wire-ios-automation/tools/ios/clickInWindow.py create mode 100755 wire-ios-automation/tools/ios/doubleClickInWindow.py create mode 100644 wire-ios-automation/tools/ios/misc/lessThan8000ch.txt create mode 100644 wire-ios-automation/tools/ios/misc/project.pbxproj create mode 100644 wire-ios-automation/tools/ios/misc/zalgo.txt create mode 100644 wire-ios-automation/tools/ios/reconnectIDevice.py create mode 100755 wire-ios-automation/tools/ios/simshot create mode 100755 wire-ios-automation/tools/ios/swipeInWindow.py create mode 100755 wire-ios-automation/tools/ios/zbackuptool create mode 100755 wire-ios-automation/tools/timeout create mode 100644 wire-ios-automation/tools/video/testing.mp4 diff --git a/.swiftformat b/.swiftformat index d18b825642b..486a650d41d 100644 --- a/.swiftformat +++ b/.swiftformat @@ -9,7 +9,8 @@ **AutoMockable**, \ wire-ios-protos/Protos/**, \ wire-ios/Templates/**, \ - WireUI/Sources/WireDesign/Icons/Autogenerated/** + WireUI/Sources/WireDesign/Icons/Autogenerated/**, \ + wire-ios-automation/** # RULES - Explicitly enabled to avoid automatic opt in of new rules diff --git a/.swiftlint.yml b/.swiftlint.yml index 37a83a4f594..d4e08e6175d 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -99,6 +99,7 @@ excluded: - wire-ios/Wire-iOS Share Extension/Generated/Strings+Generated.swift - wire-ios/Wire-iOS/Generated/Assets+Generated.swift - wire-ios/Wire-iOS/Generated/Strings+Generated.swift + - "wire-ios-automation/**" # In order to deal with the huge number of violations, first set # very high limits then iteratively reduce the limits and resolve diff --git a/wire-ios-automation/ios/.gitignore b/wire-ios-automation/ios/.gitignore new file mode 100644 index 00000000000..8f665fbea63 --- /dev/null +++ b/wire-ios-automation/ios/.gitignore @@ -0,0 +1,5 @@ +/target +/bin/ +/tests/ios/.idea/ +.classpath +.settings diff --git a/wire-ios-automation/ios/.project b/wire-ios-automation/ios/.project new file mode 100644 index 00000000000..b9d64a61338 --- /dev/null +++ b/wire-ios-automation/ios/.project @@ -0,0 +1,23 @@ + + + auto-ios + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.m2e.core.maven2Nature + + diff --git a/wire-ios-automation/ios/GridDeployment.groovy b/wire-ios-automation/ios/GridDeployment.groovy new file mode 100644 index 00000000000..71dc39abd4e --- /dev/null +++ b/wire-ios-automation/ios/GridDeployment.groovy @@ -0,0 +1,448 @@ +def gridNodes = [] + +// Default values for appium + node (Override in the next conditional block if needed) +env.NODEVERSION = "node-v18" +env.APPIUMVERSION = "2.5.2" + +if ("${GRID}" == "iOS-realdevice") { + env.HUB = "iOS_node070" + env.HUBHOST = "192.168.2.70" + env.HUBPORT = 4444 + env.WDALOCALPORT = 8101 + env.APPIUMPORT = 4730 + // node cannot be updated higher than xcode 14.0.1 which only comes with iOS 16.0 + gridNodes.add([label: "iOS_node070", GRIDDEVICETYPE: "phone", DEVICE_PER_NODE: 1, IS_SIMULATOR: false, DEVICENAME: "iPhone 11", PLATFORMVERSION: "16.2"]) +} else if ("${GRID}" == "iOS-phones-arm64") { + env.HUB = "iOS_node200" + env.HUBHOST = "192.168.2.200" + env.HUBPORT = 4444 + env.WDALOCALPORT = 8101 + env.APPIUMPORT = 4730 + gridNodes.add([label: "iOS_node200", GRIDDEVICETYPE: "phone", DEVICE_PER_NODE: 5, IS_SIMULATOR: true, DEVICENAME: "iPhone 11", PLATFORMVERSION: "17.5", RUNTIME: "com.apple.CoreSimulator.SimRuntime.iOS-17-5"]) +} else if ("${GRID}" == "iOS-tablets-arm64") { + env.HUB = "iOS_node201" + env.HUBHOST = "192.168.2.201" + env.HUBPORT = 4444 + env.WDALOCALPORT = 8101 + env.APPIUMPORT = 4730 + gridNodes.add([label: "iOS_node201", GRIDDEVICETYPE: "tablet", DEVICE_PER_NODE: 3, IS_SIMULATOR: true, DEVICENAME: "iPad Air (5th generation)", PLATFORMVERSION: "16.4", RUNTIME: "com.apple.CoreSimulator.SimRuntime.iOS-16-4"]) +} else if ("${GRID}" == "iOS-phones-arm64-fast") { + env.HUB = "iOS_node203" + env.HUBHOST = "192.168.2.203" + env.HUBPORT = 4444 + env.WDALOCALPORT = 8101 + env.APPIUMPORT = 4730 + gridNodes.add([label: "iOS_node203", GRIDDEVICETYPE: "phone", DEVICE_PER_NODE: 15, IS_SIMULATOR: true, DEVICENAME: "iPhone 15", PLATFORMVERSION: "17.5", RUNTIME: "com.apple.CoreSimulator.SimRuntime.iOS-17-5"]) +} else { + error("Could not find configuration for ${GRID}") +} + +node(env.HUB) { + + env.NODEPORT = 5555 + env.DEVICEIDS + env.LAUNCH_PATH = "/Library/LaunchDaemons" + + // Print xcode version on node + xcodeVersion = sh returnStdout: true, script: 'xcodebuild -version' + echo(xcodeVersion) + + stage('Hub: Stop and cleanup') { + + echo("Unload former grid hub") + try { + String output = sh label: 'Get plist files of grid hub', returnStdout: true, script: "ls -1 /Library/LaunchDaemons/com.wire.ios.phone.grid.plist 2>/dev/null" + print(output) + for (String nodePlist : output.split("\n")) { + sh label: 'Unload plist', returnStdout: true, script: "sudo launchctl unload -w $nodePlist" + } + } catch (e) { + print("Unload plist of node failed or no former plists found!"); + } + + echo("Delete old plist files and logs") + try { + sh label: 'Delete old plist files', returnStdout: true, script: "sudo rm -f /Library/LaunchDaemons/com.wire.ios.phone.grid.plist" + sh label: 'Delete old grid logs', returnStdout: true, script: "sudo rm -rf /Users/jenkins/selenium/ios-phone-hub.* || true" + } catch (e) { + print("Delete old plist files or logs failed!"); + } + + } + + gridNodes.each { entry -> + env.GRIDDEVICETYPE = entry["GRIDDEVICETYPE"] + node(entry["label"]) { + stage(entry["label"] + ": Stop nodes and cleanup") { + echo("Unload former grid nodes (selenium relay)") + try { + String output = sh label: 'Get plist files of grid nodes', returnStdout: true, script: "ls -1 /Library/LaunchDaemons/com.wire.ios.node.* 2>/dev/null" + print(output) + for (String nodePlist : output.split("\n")) { + sh label: 'Unload plist of node', returnStdout: true, script: "sudo launchctl unload -w $nodePlist" + } + } catch (e) { + print("Unload plist of node failed or no former plists found!"); + } + + echo("Unload former grid nodes (appium)") + try { + String output = sh label: 'Get plist files of grid nodes', returnStdout: true, script: "ls -1 /Library/LaunchDaemons/com.wire.ios.appium.* 2>/dev/null" + print(output) + for (String nodePlist : output.split("\n")) { + sh label: 'Unload plist of node', returnStdout: true, script: "sudo launchctl unload -w $nodePlist" + } + } catch (e) { + print("Unload plist of node failed or no former plists found!"); + } + + echo("Delete old plist files and logs (selenium relay & appium)") + try { + sh label: 'Delete nodeconfig files and logs', returnStdout: true, script: "sudo rm -rf /Users/jenkins/selenium/*.log || true" + sh label: 'Delete serverconfig files and logs', returnStdout: true, script: "sudo rm -rf /Users/jenkins/selenium/*.err || true" + sh label: 'Delete plist files', returnStdout: true, script: "sudo rm -rf /Library/LaunchDaemons/com.wire.ios.appium.* || true" + sh label: 'Delete plist files', returnStdout: true, script: "sudo rm -rf /Library/LaunchDaemons/com.wire.ios.node.* || true" + } catch (e) { + error("Delete old plist files or logs failed!"); + } + } + } + } + + stage('Hub: Prepare needed software') { + // download selenium-standalone-server if needed + try { + sh script: """ + mkdir -p /Users/jenkins/selenium/ + if [ ! -e /Users/jenkins/selenium/selenium-server-4.7.0.jar ] ; then + curl -L https://github.com/SeleniumHQ/selenium/releases/download/selenium-4.7.0/selenium-server-4.7.0.jar -o /Users/jenkins/selenium/selenium-server-4.7.0.jar + fi + """ + } catch (e) { + error("Downloading selenium standalone server failed!"); + } + // download applesimutils if needed + try { + sh script: """ + mkdir -p /Users/jenkins/selenium/ + if [ ! -e /Users/jenkins/selenium/applesimutils/0.9.9/bin/applesimutils ] ; then + rm -rf /Users/jenkins/selenium/applesimutils.tar.gz + ARCH="" + # Set architecture prefix if on M1 machine + if uname -m | grep arm64; then + ARCH="arm64_" + fi + curl -L https://github.com/wix/AppleSimulatorUtils/releases/download/0.9.9/applesimutils-0.9.9.\${ARCH}big_sur.bottle.tar.gz -o /Users/jenkins/selenium/applesimutils.tar.gz + cd /Users/jenkins/selenium/ + tar -zvxf applesimutils.tar.gz + sudo xattr -rd com.apple.quarantine /Users/jenkins/selenium/applesimutils/0.9.9/bin/applesimutils + fi + """ + } catch (e) { + error("Downloading applesimutils failed! " + e); + } + } + + stage('Create grid config file and start') { + // create plist file for grid + sh ''' + GRIDPLIST=com.wire.ios.${GRIDDEVICETYPE}.grid + cat < $GRIDPLIST.plist + + + + + Label + $GRIDPLIST + RunAtLoad + + KeepAlive + + NetworkState + + + ThrottleInterval + 30 + WorkingDirectory + /Users/jenkins/selenium/ + ProgramArguments + + /usr/bin/java + -jar + selenium-server-4.7.0.jar + hub + --host + 0.0.0.0 + --port + $HUBPORT + --session-request-timeout + 240 + + StandardErrorPath + /Users/jenkins/selenium/ios-$GRIDDEVICETYPE-hub.err + StandardOutPath + /dev/null + + +EOF + sudo mv $GRIDPLIST.plist $LAUNCH_PATH/ + sudo chown root:wheel $LAUNCH_PATH/$GRIDPLIST.plist + ''' + + // load grid hub + try { + sh label: 'Load plist of grid', returnStdout: true, script: "sudo launchctl load -w /Library/LaunchDaemons/com.wire.ios.${GRIDDEVICETYPE}.grid.plist" + } catch (e) { + error("Load plist of grid failed!"); + } + } + + gridNodes.each { entry -> + env.LABEL = entry["label"] + env.GRIDDEVICETYPE = entry["GRIDDEVICETYPE"] + env.DEVICE_PER_NODE = entry["DEVICE_PER_NODE"] + env.PLATFORMVERSION = entry["PLATFORMVERSION"] + env.RUNTIME = entry["RUNTIME"] + env.DEVICENAME = entry["DEVICENAME"] + node(entry["label"]) { + stage(entry["label"] + ": Create config files and start nodes") { + + // download selenium-standalone-server if needed + try { + sh script: """ + mkdir -p /Users/jenkins/selenium/ + if [ ! -e /Users/jenkins/selenium/selenium-server-4.7.0.jar ] ; then + curl -L https://github.com/SeleniumHQ/selenium/releases/download/selenium-4.7.0/selenium-server-4.7.0.jar -o /Users/jenkins/selenium/selenium-server-4.7.0.jar + fi + """ + } catch (e) { + error("Downloading selenium standalone server failed!"); + } + + // Install Carthage + sh([script: """ + if [ "`/usr/local/bin/carthage version`" != '0.38.0' ]; then + curl -L https://github.com/Carthage/Carthage/releases/download/0.38.0/Carthage.pkg -o Carthage.pkg + sudo installer -pkg Carthage.pkg -target / + fi + """]) + + nodejs(env.NODEVERSION) { + sh '[ "$(appium -v)" == ${APPIUMVERSION} ] || npm install -g appium@${APPIUMVERSION}' + + // Need to run this once to make appium find the driver + sh returnStatus: true, script: '${NODEJS_HOME}/bin/appium driver install xcuitest' + // Unfortunately it is impossible to pin the version of the driver, we just get latest through update: + sh returnStatus: true, script: '${NODEJS_HOME}/bin/appium driver update xcuitest --unsafe' + + env.PATH = "${env.NODEJS_HOME}/bin:/usr/local/bin:/usr/bin:/Users/jenkins/selenium/applesimutils/0.9.9/bin/:${env.PATH}" + echo "PATH = " + env.PATH + def udids = [] + + if (entry["IS_SIMULATOR"]) { + // Delete all devices with missing runtime etc. + sh "xcrun simctl delete unavailable" + sh "sudo xcrun simctl delete unavailable" + // Get udid of available device with certain device name + def output = sh label: 'Get iOS phone/tablet UDIDs', returnStdout: true, script: 'xcrun simctl list -j devices available' + def json = readJSON text: output + def sims = json["devices"][env.RUNTIME] + for (sim in sims) { + if (sim["name"] == env.DEVICENAME) { + def udid = sim["udid"] + udids.add(udid) + echo("Simulators with udid " + udid) + // If device is not booted then boot it up + if (sim["state"] != "Booted") { + sh "xcrun simctl boot ${udid}" + } + } + } + echo(udids.size() + " simulators found on jenkins node " + entry["label"]) + // TODO: Create simulators if no available ones with the platformVersion and name are existing + while (udids.size() < (env.DEVICE_PER_NODE as Integer)) { + def newUDID = sh returnStdout: true, script: 'xcrun simctl create "${DEVICENAME}" "${DEVICENAME}" "${RUNTIME}"' + udids.add(newUDID) + } + } else { + def output = sh label: 'Get real device iOS phone/tablet UDIDs', returnStdout: true, script: 'system_profiler SPUSBDataType -json' + def json = readJSON text: output + def devices = [] + def dataType = json.SPUSBDataType._items*._items.flatten() + dataType.each { + if (it != null) { + def name = it["_name"] + if (name == "iPhone") { + devices.add(it) + } + } + } + for (device in devices) { + if (device["serial_num"] != null) { + def serial_num = device["serial_num"] + def udid = serial_num.substring(0, 8) + "-" + serial_num.substring(8, serial_num.length()) + udids.add(udid) + echo("Real device with udid " + udid) + } + } + echo(udids.size() + " real devices found on jenkins node " + entry["label"]) + } + env.DEVICEIDS = udids.join(" ") + + print(DEVICEIDS) + + sh 'mkdir -p /Users/jenkins/selenium/' + + // create new grid node config files + sh ''' + DEVICECOUNT=0 + for UDID in $DEVICEIDS ; do + ((DEVICECOUNT++)) + ((WDALOCALPORT++)) + ((APPIUMPORT++)) + ((NODEPORT++)) + + IPADDRESS=`ifconfig en0 inet | tail -1 | cut -d ' ' -f 2` + SERVERCONFIG=/Users/jenkins/selenium/serverconfig-localhost-$GRIDDEVICETYPE${DEVICECOUNT} + NODECONFIG=/Users/jenkins/selenium/nodeconfig-ios-$GRIDDEVICETYPE${DEVICECOUNT} + mkdir -p /Users/jenkins/selenium/WDA/$UDID + + cat < $NODECONFIG.toml +[server] +port = $NODEPORT + +[node] +detect-drivers = false + +[relay] +url = "http://localhost:$APPIUMPORT" +status-endpoint = "/status" +configs = [ + "1", "{\\"platformName\\": \\"ios\\", \\"adbExecTimeout\\": 40000,\\"version\\": \\"$PLATFORMVERSION\\", \\"wdaLocalPort\\": $WDALOCALPORT}" +] + +EOF + +SERVERPLIST=com.wire.ios.appium.${GRIDDEVICETYPE}${DEVICECOUNT}.plist + echo "SERVERPLIST = $SERVERPLIST" + + cat < $SERVERPLIST + + + + + EnvironmentVariables + + PATH + $PATH + HOME + /Users/jenkins/ + + UserName + jenkins + Label + com.wire.ios.node.${GRIDDEVICETYPE}${DEVICECOUNT} + RunAtLoad + + KeepAlive + + ProgramArguments + + ${NODEJS_HOME}/bin/appium + --relaxed-security + -p + $APPIUMPORT + --log-timestamp + --log-no-colors + --default-capabilities + {"appium:udid":"$UDID","appium:wdaLocalPort":$WDALOCALPORT,"appium:derivedDataPath":"/Users/jenkins/selenium/WDA/$UDID"} + + StandardErrorPath + $SERVERCONFIG.err + StandardOutPath + $SERVERCONFIG.log + + + +EOF + + sudo mv $SERVERPLIST $LAUNCH_PATH/ + sudo chown root:wheel $LAUNCH_PATH/$SERVERPLIST + + NODEPLIST=com.wire.ios.node.${GRIDDEVICETYPE}${DEVICECOUNT}.plist + echo "NODEPLIST = $NODEPLIST" + + cat < $NODEPLIST + + + + + Label + $NODEPLIST + RunAtLoad + + KeepAlive + + ThrottleInterval + 30 + WorkingDirectory + /Users/jenkins/selenium/ + UserName + jenkins + ProgramArguments + + /usr/bin/java + -jar + selenium-server-4.7.0.jar + node + --config + $NODECONFIG.toml + --hub + http://$HUBHOST:$HUBPORT + + StandardErrorPath + $NODECONFIG.err + StandardOutPath + $NODECONFIG.log + + + +EOF + + sudo mv $NODEPLIST $LAUNCH_PATH/ + sudo chown root:wheel $LAUNCH_PATH/$NODEPLIST + done + ''' + } + } + + stage('Start new grid and nodes') { + + // load appium server nodes + try { + String output = sh label: 'Get new plist files of appium servers', returnStdout: true, script: 'ls -1 /Library/LaunchDaemons/* | grep "com.wire.ios.appium.${GRIDDEVICETYPE}*"' + print(output) + for (String serverPlist : output.split("\n")) { + sh label: 'Load plist of node', returnStdout: true, script: "sudo launchctl load -w $serverPlist" + } + } catch (e) { + error("Load new plist of node failed!"); + } + + sleep 5 + + // load grid nodes + try { + String output = sh label: 'Get new plist files of nodes', returnStdout: true, script: 'ls -1 /Library/LaunchDaemons/* | grep "com.wire.ios.node.${GRIDDEVICETYPE}*"' + print(output) + for (String nodePlist : output.split("\n")) { + sh label: 'Load plist of node', returnStdout: true, script: "sudo launchctl load -w $nodePlist" + } + } catch (e) { + error("Load new plist of node failed!"); + } + } + } + } +} + diff --git a/wire-ios-automation/ios/NodeMaintenance.groovy b/wire-ios-automation/ios/NodeMaintenance.groovy new file mode 100644 index 00000000000..4009193700f --- /dev/null +++ b/wire-ios-automation/ios/NodeMaintenance.groovy @@ -0,0 +1,85 @@ +node('built-in') { + + env.NODE_LABELS = "ios_tablet || ios" + + def jenkinsbot_secret = "" + withCredentials([string(credentialsId: "JENKINSBOT_IOS", variable: 'JENKINSBOT_SECRET')]) { + jenkinsbot_secret = env.JENKINSBOT_SECRET + } + + nodes = nodesByLabel label: "$NODE_LABELS", offline: true + + stage('Check availability of nodes') { + nodes.each { + echo("Checking if node " + it + " is online...") + if (Jenkins.instance.getNode(it).toComputer().isOnline()) { + echo("Checking if node " + it + " is busy...") + for (slave in hudson.model.Hudson.instance.slaves) { + if (slave.name.equals(it)) { + final comp = slave.getComputer() + if (comp.isOnline()) { + if (comp.countBusy() > 0) { + currentBuild.result = 'ABORTED' + error(slave.getNodeName() + " seems to be busy. Aborting the build...") + } + } + } + } + } else { + echo("Node " + it + " is offline!") + } + } + } + + def offline_nodes = [] + + stage('Do maintenance') { + def jobs = [:] + + for (int i = 0; i < nodes.size(); i++) { + def nodeIndex = i + jobs[nodes[nodeIndex]] = { + if (Jenkins.instance.getNode(nodes[nodeIndex]).toComputer().isOnline()) { + node(nodes[nodeIndex]) { + echo("Reboot OS on node " + nodes[nodeIndex] + "...") + env.JENKINS_NODE_COOKIE = "dontKillMe" + sh '(sleep 5 && sudo shutdown -r now) &' + sleep 5 + } + echo("Wait until node " + nodes[nodeIndex] + " shuts down...") + try { + timeout(1) { + waitUntil { + return !Jenkins.instance.getNode(nodes[nodeIndex]).toComputer().isOnline() + } + } + } catch (e) { + echo("Not restarted?") + } + echo("Wait until node " + nodes[nodeIndex] + " comes back...") + try { + timeout(3) { + waitUntil { + return Jenkins.instance.getNode(nodes[nodeIndex]).toComputer().isOnline() + } + } + } catch (e) { + echo("Node " + nodes[nodeIndex] + " is still offline!") + offline_nodes.add(nodes[nodeIndex]) + } + } else { + // The old script went to the parallels host machine and killed the parallels process + echo("Node " + nodes[nodeIndex] + " is offline!") + offline_nodes.add(nodes[nodeIndex]) + } + } + } + parallel jobs + } + + if (offline_nodes.size() > 0) { + echo("Offline nodes: " + offline_nodes.join(",")) + wireSend secret: "$jenkinsbot_secret", message: "Several iOS nodes are offline: " + offline_nodes.join(", ") + } + +} \ No newline at end of file diff --git a/wire-ios-automation/ios/Powercycle.groovy b/wire-ios-automation/ios/Powercycle.groovy new file mode 100644 index 00000000000..218a40daa13 --- /dev/null +++ b/wire-ios-automation/ios/Powercycle.groovy @@ -0,0 +1,56 @@ +pipeline { + agent { + label 'iOS_simu_node070' + } + + options { + disableConcurrentBuilds() + } + + // NOTE: run every hour + triggers { + pollSCM 'H * * * *' + } + + stages { + stage('Requirements') { + steps { + sh 'pip3 install --user brainstem --upgrade' + writeFile file: 'powercycle.py', text: '''import datetime +import time + +from brainstem import discover +from brainstem.link import Spec +from brainstem.stem import USBHub2x4 + +NOW = datetime.datetime.now() + +if __name__ == \'__main__\': + stem = USBHub2x4() + spec = discover.findFirstModule(Spec.USB) + if spec is None: + raise RuntimeError("No USBHub is connected!") + stem.connectFromSpec(spec) + print(\'Current hour is: {}\'.format(NOW.hour)) + if (NOW.hour < 10) or (NOW.hour > 20): + print(\'Power ON for all USB ports\') + stem.usb.setPowerEnable(0) + stem.usb.setPowerEnable(1) + stem.usb.setPowerEnable(2) + stem.usb.setPowerEnable(3) + else: + stem.usb.setPowerDisable(0) + stem.usb.setPowerDisable(1) + stem.usb.setPowerDisable(2) + stem.usb.setPowerDisable(3) + print(\'Power OFF for all USB ports\') +''' + } + } + stage('Run') { + steps { + sh 'python3 powercycle.py' + } + } + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/README.md b/wire-ios-automation/ios/README.md new file mode 100644 index 00000000000..f6a2276bd07 --- /dev/null +++ b/wire-ios-automation/ios/README.md @@ -0,0 +1,258 @@ +# Setup +## Simulator Automation Setup + +1. Install Xcode from the Appstore. + - Install the additional requirements. You will be prompted when opening Xcode for the first time. +2. In Xcode, navigate to `Settings -> Locations` and set the value of **Command Line Tools** to your current Xcode version. +3. In Xcode, navigate to `Settings -> Platforms` and install **iOS** and matching simulator via **+** button +3. Install Carthage. + + brew install carthage +4. Install NPM with `brew install node` +5. Check [GridDeployment.groovy](GridDeployment.groovy) for currently used versions (APPIUMVERSION, XCUITESTVERSION) +6. Install **Appium** with the version above: `npm install appium@` (Please don't use `-g` option) +7. Install **Appium driver**: `npx appium driver install xcuitest` (Please don't use `-g` option) +8. Install [applesimutils](https://github.com/wix/AppleSimulatorUtils/releases) required for setting permissions in simulator +9. If applesimutils cannot be executed run: `xattr -d com.apple.quarantine applesimutils` +``` +brew tap wix/brew +brew install applesimutils +``` +9. Run appium with: `PATH=$PATH: npx appium` + +# Test execution + +## Simulator Run +* Create new Run configuration `Run -> Edit Configurations -> Add new -> Maven` +* Give the configuration the desired name (e.g. `iOS`) +* Navigate to the Parameters tab (should be open by default) +* Set Working directory to the zautomation root, for example `/Users/USERNAME/code/zautomation/tests/` +* Set Command line to `--also-make --projects ios clean install` + * if you want to execute the iPad testcases with this configuration, this should be `--also-make --projects ios-tablet clean install` +* Click on Modify next to Java Options + ![img.png](img.png) +* Select **Properties** and **Environment Variables** +* Add the following properties: + * `picklejar.tags` @torun or id of desired testcase to run + * `appPath` Path to the wire.ipa for the simulator + * `backendType` To set the default backend + * `deviceName` Use `xcrun simctl list` to find a simulator that Appium will match and reuse. If deviceName is not given then a simulator is newly created and destroyed for every run. + * `Url` Set http://127.0.0.1:4723 for local execution +* Set environment variables for [Credentials](../../README.md#credentials) and [Federation](../../README.md#federation) +* Run appium with: `PATH=$PATH: npx appium` +* Start the created Run Configuration in Intellij + +## Real Device Automation Setup + +1. Launch Xcode, go to Xcode menu option: `Preferences -> Accounts`. Add an account using Apple ID. Use `qa1@wire.com` apple ID. Login credentials for this can be found in the QA.kdbx file in our Git directory. + After loggin in, click on "Manage Certificates..." and download all. +2. Navigate to the Applications folder. Right click on Appium and select "Open Package Contents" + + From there, open the folder `Contents/Resources/app/node_modules/appium/node_modules/appium-webdriveragent` + +3. Open this folder in a terminal window and execute: `bash Scripts/bootstrap.sh -d` + +4. Open the `WebDriverAgent.xcodeproj` Xcode project file in this folder + +5. - Click on the top-level project in the left side-bar, called `WebDriverAgent`. +- Click on *WebDriverAgentLib* in the **TARGETS section** +- Click on **Signing & Capabilities** in the top bar, make sure Automatically manage signing is checked and set Team value to `Zeta Project, Inc. (Ent)` and change `Bundle Identifier` to `com.wire.WebDriverAgentLib` +- Click on *WebDriverAgentRunner* in the **TARGETS** section +- Click on **Build Settings** in the top bar and change the value of `Product Bundle Identifier` to `com.wire.WebDriverAgentRunner` +- Click on **Signing & Capabilities** in the top bar, make sure Automatically manage signing is checked and change the team value to `Zeta Project, Inc. (Ent)` +- Click on *IntegrationApp* in the **TARGETS section** +- Click on **Signing & Capabilities** in the top bar, make sure Automatically manage signing is checked and set Team value to `Zeta Project, Inc. (Ent)` and change `Bundle Identifier` to `com.wire.WebDriverAgentLib` + + Note: If you are running in to signing issues, try out the Troubleshooting section in this document. If this does not fix it, you could try clearing the keychain of old signing certificates, or installing Appium again. + +6. Try to build WebDriverAgentRunner with your real device as a target. It will ask for keychain access a few times during the first run. If this passes, the setup inside Xcode is finished. + +## Real device Run +* Go to `Run -> Edit Configurations` +* Duplicate the iOS Simulator run configuration and give the configuration the desired name (e.g. `iOS Real`) +* Go to **Runner** +* Change the following properties: + * `picklejar.tags` + + @torun or id of desired testcase to run + * `appPath` + + Path to the wire.ipa for the real device + * `platformVersion` + + The installed version of iOS on the device you want to test on + * `deviceName` + + Name of the real device you want to execute the testcase on (Example: iPhone X) + * `isSimulator` + + Should be set to false + + * `UDID` + + UDID of the connected device +* Start Appium: `npx appium` +* Start the created Run Configuration in Intellij + +## Debug runs +* Go to `Run -> Edit Configurations` +* Duplicate the run configuration with which you want to debug and give the configuration the desired name (e.g. `iOS debug`) +* Navigate to the Parameters tab (should be open by default) +* Set Working directory to the ios project root, for example ` /Users/USERNAME/IdeaProjects/zautomation/tests/ios` +* Set Command line to `clean -Dmaven.surefire.debug test` +* Set Profiles to `iOS` +* Add a new Run Configuration by clicking on the `+` in the left upper toolbar and select `Remote` + * The default values are valid here +* Add a breakpoint to the desired line of code that you want to debug +* Start Appium Desktop +* Enter `localhost` for the Host field +* Click on Start Server +* Start the created Debug Run Configuration in Intellij +* Start the Remote run configuration + +# Finding locators +There are two possibilities for finding locators with [Appium-Inspector](https://github.com/appium/appium-inspector/releases): +- By running a testcase with a break point, and attaching the appium server to this session +- by starting an appium session directly through appium inspector + +The benefit of using a break point is that it can take care of setting up the test, so that you don't need to manually create the circumstances in which the locator will show. It can however, be a bit tricky to connect to the session sometimes. + +### By running a test + +* Run a testcase which has a breakpoint in debug mode + + Alternative possibillity: you can add a step `And I wait for 200 seconds` + to make the test execution pause for X seconds, which can be quicker than + starting a debug run. Just make sure to remove this before commiting. +* Open the active Appium Desktop server +* Press **Command + n** +* Click **Attach to session...** +* Click the refresh button on the drop-down bar below until the Session ID shows up +* Click Attach to Session + +### Without running a test + +* Open Appium desktop and start a server +* Press **Command + n** or press the magnifier icon +* Underneath "Desired Capabilities", add the following values: +``` +{ + "platformName": "iOS", + "platformVersion": "14.1", // The platform version of your simulator (usually resambles the xcode version) + "app": "/Users//Downloads/Wire.ipa", // Path to the wire.ipa for the simulator + "deviceName": "iPhone 11", // The device name of your simulator/device + "processArguments": { // These arguments are needed to run the app as the automation would do it (for e.g. using staging backend) + "args": [ + "-use-app-center", + "0", + "--disable-interactive-keyboard-dismissal", + "--disable-call-quality-survey", + "-BackendEnvironmentTypeOverrideKey", + "staging", + "--persist-backend-type", + "--disable-autocorrection", + "--debug-log=Network,SessionManager,event-processing,SyncStatus,OperationStatus,Push,cryptobox,background-activity,ephemeral,Authentication", + "-com.apple.CoreData.ConcurrencyDebug", + "1", + "--disable-push-alert", + "-UseAnalytics", + "0" + ], + "env": { + "ZMLOG_TAGS": "AVS,calling" + } + } +} +``` +* Click on "Save as..." and save the values so that you do not have to re-enter this the next time +* Click on "Start Session" + +# Special testcases + +## Fast Login + +If a test uses fast login the entering of credentials will be skipped by providing +the app with command line parameters `--loginemail=` and `--loginpassword=` + +### Deprecated method + +Fast login can be activated for a test by adding the @fastlogin tag. + +But it only works when test sets the "is me" value on one of the following steps: +* User X is me +* There (?:is|are) (\d+) users? where (.*) is me +* There (?:are|is) (\d+) users? with email address only where (.*) is me +* I prepare Wire to perform fast log in by email as (.*) + +**Warning:** Fast login also skips the First Time Overlay, so it is not needed to use +this step in the test after the login. + +### New method + +Instead of using the @fastlogin tag use the step `I sign in user with fast login` +and remove implicit and explicit setting of "is me" and `Myself`. + +The step will automatically tap on login button on the welcome page. + +You can only use the step if the driver was not created before. + +## Upgrade +### Locally +To test upgrade testcases locally, you need to set the old build you want to upgrade from. You can do this by adding the property `oldAppPath` in the run configuration in IntelliJ. The value of this should lead to the .ipa that you want to upgrade from. + +### Automation Grid +In case you want to perform upgrade tests on our automation grid, you can specify the build number of which version you want to upgrade from in the `oldBuildNumber` parameter. If this is left empty, the job will grab the fourth oldest build. In case of a RC build, it will take the previously released build by default. + +When the `AppBuildNumber` parameter is provided in the Jenkins Job, the `OldBuildNumber` needs to be provided too in case you want to test an upgrade. Otherwise, it will perform a re-install of the same version. + +# Troubleshooting + +#### General +* If you are unable to sign WebDriverAgentRunner with the real device selected as a target (missing certificate iPhone Developer -name-), make sure you are added to the wildcard for our testing devices in the iOS Development team. +* If there is a project file missing in your WebDriverAgent Xcode project, you should navigate to the WebDriverAgent project location in terminal and execute the following command: `bash Scripts/bootstrap.sh -d` +This will fetch the needed dependencies using Carthage. If it only shows "Fetching dependencies" before finsihing, re-install carthage using the commands below and execute the bootstrap command again. + + rm -rf Carthage + brew uninstall carthage + brew install carthage + +#### sudo: a terminal is required to read the password +* The NOPASSWD privilege for the user `jenkins` is needed on the machine. See +[Confluence](https://wearezeta.atlassian.net/wiki/spaces/QA/pages/383058074/Node+Configuration) on how to set this up. + +#### Fail to lock /Users/jenkins/workspace/ios_tablet_pipeline_staging/Wire.ipa +* The job was probably canceled while it was downloading the ipa from S3. The easiest way is to login to the machine and +delete the whole workspace with `rm -rf /Users/jenkins/workspace/ios_tablet_pipeline_staging/` and then disconnect and +reconnect the node in Jenkins node settings. + +#### Error running 'install': An error was encountered processing the command... Failed to load Info.plist from bundle +* The error message should contain the udid of the simulator. Use this udid to find the machine on which the affected +simulator is running. Connect to the machine and make a full reset of the simulator. + +#### Real device: No signing certificate "iOS Development" found: No "iOS Development" signing certificate matching team ID "..." with a private key was found. +* Check the capability called `appium:xcodeOrgId`. It should contain the team id that you can find when logging in with +qa1@wire.com on [developer.apple.com](https://developer.apple.com/) under Membership | Team ID. + +#### Real device: WebDriverAgent xcodebuild exited with code '65' +* WebDriverAgent probably could not be signed. Check if the capability `appium:xcodeOrgId` is used. In the nodeconfig +log you can find the full command for xcodebuild. When you run it on the machine you will get a log with better error +messages. + +#### Real device: WebDriverAgent xcodebuild exited with code '1' +* WebDriverAgent probably could not be build through missing dependencies or something else. In the nodeconfig log you +can find the path to appium-webdriveragent node module. Go into this directory and run `./Scripts/bootstrap.sh` In the +nodeconfig log you can also find the full command for xcodebuild. When you run it on the machine you will get a log with +better error messages. + +#### Real device: Xcode Project does not compile due to missing files +* Try to re-install carthage and execute the `bash Scripts/boostrap.sh -d` command again from step 4 of the real device automation setup guide. + + +#### All tests freeze on simulator on the screen with the Wire shield logo with black background** +* Solution: Restart simulator + +#### Simulator architecture is unsupported by the '.../Wire.app' application +* You probably have tried to run the real device builds on a simulator. Check if `is_simulator = true` for the choosen grid. + +#### MacOS Big Sur: JavaSeekerTest.testGetLoadedClasses:39 NoSuchField classes +* It is possible that the update to MacOS Big Sur has resulted into the JAVA_HOME variable being set incorrectly. Try to run `- export JAVA_HOME=$(/usr/libexec/java_home)` in Terminal and see if that fixes the issue. diff --git a/wire-ios-automation/ios/Release_Tests.Jenkinsfile.groovy b/wire-ios-automation/ios/Release_Tests.Jenkinsfile.groovy new file mode 100644 index 00000000000..a8abfe22a9d --- /dev/null +++ b/wire-ios-automation/ios/Release_Tests.Jenkinsfile.groovy @@ -0,0 +1,281 @@ +/* + +TAGS as String parameter +Grid as Choice parameter +Track as Choice parameter: dev, release, avs, qa-playground +AppBuildNumber as String parameter +CALLING_SERVICE_ENV as Choice parameter with choices: master, dev +TESTINY_RUN_NAME as String parameter +Branch as String parameter main +surefire.rerunFailingTestsCount as String parameter 1 +test as String parameter + + */ +import hudson.tasks.test.AbstractTestResultAction + +@NonCPS +def testStatuses() { + AbstractTestResultAction testResultAction = currentBuild.rawBuild.getAction(AbstractTestResultAction.class) + if (testResultAction != null) { + def total = testResultAction.totalCount + def failed = testResultAction.failCount + def skipped = testResultAction.skipCount + def passed = total - failed - skipped + int percent = (total > 0) ? (passed * 100) / (total - skipped) : 0 + testStatus = "Tests passed: ${passed}, Failed: ${failed} ${testResultAction.failureDiffString}, Skipped: ${skipped}, Total: ${total} (${percent}%)" + } else { + testStatus = "Could not find test results!" + } + return testStatus +} + +@NonCPS +def isTestSuccessful() { + AbstractTestResultAction testResultAction = currentBuild.rawBuild.getAction(AbstractTestResultAction.class) + if (testResultAction != null && testResultAction.failCount > 0) { + return false + } + return true +} + +node("Job_distributor") { + + def aborted = false + def TAGS = "${params.TAGS}" + def filename = "" + + // Select bot credentials for specific Wire conversation based on job name + credentialsId = "JENKINSBOT_IOS_REGRESSION" + + // Select grid + hubUrl = "http://192.168.2.200:4444/wd/hub" + is_simulator = true + deviceName = "iPhone 11" + platformVersion = "16.4" + nodeLabels = "iOS_node200" + MAX_PARALLEL = 5 + + withCredentials([ + file(credentialsId: 'KUBECONFIG_anta', variable: 'KUBECONFIG_anta'), + file(credentialsId: 'KUBECONFIG_bella', variable: 'KUBECONFIG_bella'), + file(credentialsId: 'KUBECONFIG_chala', variable: 'KUBECONFIG_chala'), + file(credentialsId: 'KUBECONFIG_foma', variable: 'KUBECONFIG_foma'), + file(credentialsId: 'KUBECONFIG_gudja_offline_ios', variable: 'KUBECONFIG_gudja_offline_ios'), + file(credentialsId: 'KUBECONFIG_bund_next_column_1', variable: 'KUBECONFIG_bund_next_column_1'), + file(credentialsId: 'KUBECONFIG_bund_qa_column_1', variable: 'KUBECONFIG_bund_qa_column_1'), + string(credentialsId: "${credentialsId}", variable: 'JENKINSBOT_SECRET'), + string(credentialsId: 'BLACKLIST_S3_SECRET', variable: 'BLACKLIST_S3_SECRET'), + string(credentialsId: 'OKTA_API_KEY', variable: 'OKTA_API_KEY'), + string(credentialsId: 'KEYCLOAK_PASSWORD', variable: 'KEYCLOAK_PASSWORD'), + string(credentialsId: 'LH_SERVICE_AUTH_TOKEN', variable: 'LH_SERVICE_AUTH_TOKEN'), + string(credentialsId: "TESTINY_API_KEY_IOS", variable: 'TESTINY_API_KEY'), + string(credentialsId: 'STRIPE_API_KEY', variable: 'STRIPE_API_KEY')]) { + + // Checkout + stage('Checkout & Clean') { + echo("Checkout common") + checkout( + [$class: 'GitSCM', + branches: [[name: '*/${Branch}']], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'RelativeTargetDirectory', relativeTargetDir: 'common'], + [$class: 'SparseCheckoutPaths', sparseCheckoutPaths: [[path: 'tests/common']]], + [$class: 'CheckoutOption', timeout: 30], + [$class: 'CloneOption', depth: 0, noTags: true, reference: '', shallow: true, timeout: 30], + [$class: 'BuildChooserSetting', buildChooser: [$class: 'DefaultBuildChooser']]], + submoduleCfg: [], + userRemoteConfigs: [[credentialsId: 'zautomation', url: 'git@github.com:zinfra/zautomation.git']]]) + + checkout([$class: 'GitSCM', + branches: [[name: '*/${Branch}']], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'RelativeTargetDirectory', relativeTargetDir: 'wire-ios-automation'], + [$class: 'SparseCheckoutPaths', sparseCheckoutPaths: [[path: 'wire-ios-automation/ios'], [path: 'wire-ios-automation/pom.xml'], [path: 'wire-ios-automation/tools']]], + [$class: 'CheckoutOption', timeout: 30], + [$class: 'CloneOption', depth: 0, noTags: true, reference: '', shallow: true, timeout: 30], + [$class: 'BuildChooserSetting', buildChooser: [$class: 'DefaultBuildChooser']]], + submoduleCfg: [], + userRemoteConfigs: [[credentialsId: 'zautomation', url: 'git@github.com:wireapp/wire-ios.git']]]) + + echo("Installing kubectl if not already installed") + kubeCtlSetup = readFile("${WORKSPACE}/common/tests/common/kubectlSetup.sh") + sh kubeCtlSetup + } + + // TODO: Move this back into the test stage when a solution is found that does not override Wire.ipa in a second parallel run + lock("${Grid}") { + + stage('Download build from S3 to grid nodes') { + load('wire-ios-automation/ios/RetrieveIPA.Jenkinsfile.groovy').setIPAFromS3(track, appBuildNumber, is_simulator) + + // Work on selenium grid nodes + nodes = nodesByLabel(nodeLabels) + nodes.each { + node(it) { + // Cleanup the workspace before getting the ipa ready + sh "rm -rf $WORKSPACE/Payload" + sh "rm -rf $WORKSPACE/Wire.ipa" + + // TODO: Find a better place for saving the ipa (maybe containing the grid name) + env.OLD_APP_PATH = "$WORKSPACE/Previous/Payload/Wire.app" + env.APP_PATH = "$WORKSPACE/Payload/Wire.app" + + // Download builds via s3 proxy + sh "curl http://192.168.2.39:8000/z-lohika/${S3AppPath} -o \"$WORKSPACE/Wire.ipa\"" + + // grab wire.app from the ipa + sh "unzip -o $WORKSPACE/Wire.ipa" + + // Gather bundle id from ipa + env.BUNDLE_ID = sh returnStdout: true, script: "plutil -extract CFBundleIdentifier raw Payload/Wire.app/Info.plist -o - | tr -d '\\n'" + echo("BUNDLE_ID: " + env.BUNDLE_ID) + + echo("Delete old version") + sh "rm -rf ${OldS3AppPath}" + + echo("Download old version") + if ("${OldS3AppPath}" == "") { + echo("No OldS3AppPath given.") + } else { + // Download old build via s3 proxy + sh "curl http://192.168.2.39:8000/z-lohika/${OldS3AppPath} -o \"$WORKSPACE/Previous.ipa\"" + sh "unzip -o $WORKSPACE/Previous.ipa -d $WORKSPACE/Previous/" + } + + // See section "Files generated by test runs" on http://appium.io/docs/en/drivers/ios-xcuitest/ + // If it fails we shutdown the simulator first and try deleting them again + sh returnStatus: true, script: 'rm -rf $HOME/Library/Logs/CoreSimulator/* || xcrun simctl shutdown booted && rm -Rf $HOME/Library/Logs/CoreSimulator/*' + + // Delete cached ipa files to prevent installation error "Failed to load Info.plist from bundle at" + sh 'rm -rf /var/folders/* || true' + + // Delete all files used by former history backup tests + sh 'find $HOME/Library/Developer/CoreSimulator/Devices/ -name *.ios_wbu -delete' + } + } + } + + // Set build description + filename = env.S3AppPath.tokenize('/')[-1] + currentBuild.description = filename + "\n" + TAGS + + // Keep build forever when RC is made + if (params.TESTINY_RUN_NAME != "") { + currentBuild.description = params.TESTINY_RUN_NAME + "\n" + currentBuild.description + currentBuild.keepLog = true + } + + withMaven(jdk: 'AdoptiumJDK11', maven: 'M3', mavenOpts: '-Xmx1024m -XX:MaxPermSize=128m', mavenLocalRepo: '.repository', options: [junitPublisher(disabled: true), jacocoPublisher(disabled: true)]) { + + stage('Build common') { + env.RC_TESTS_COMMENT_PATH = "$WORKSPACE/rc_comment.txt" + sh 'echo "${JOB_NAME} ${BUILD_DISPLAY_NAME} - Cucumber Report: ${JENKINS_URL}job/${JOB_NAME}/${BUILD_NUMBER}/cucumber-html-reports/" > $RC_TESTS_COMMENT_PATH' + + sh """ +mvn clean install \\ +-f "$WORKSPACE/common/tests/common/pom.xml" \\ +-DbackendType="${backendType}" \\ +-DtestinyProjectName="Wire iOS" \\ +-DtestinyRunName="$TESTINY_RUN_NAME" \\ +-DrcTestsCommentPath="${RC_TESTS_COMMENT_PATH}" \\ +-DcallingServiceUrl='loadbalanced' \\ +-Dcom.wire.calling.env='${CALLING_SERVICE_ENV}' \\ +-DsyncIsAutomated=true +""" + } + + // This is needed because of https://issues.jenkins-ci.org/browse/JENKINS-7180 + def RERUN = currentBuild.getRawBuild().actions.find { it instanceof ParametersAction }?.parameters.find { + it.name == 'surefire.rerunFailingTestsCount' + }?.value + + // This is needed when we want the tests to only run on a specific device + if (!browserName.isEmpty()) { + env.MAX_PARALLEL = 1 + } + stage('Run tests') { + try { + timeout(time: 6, unit: 'HOURS') { + realtimeJUnit(keepLongStdio: true, testDataPublishers: [[$class: 'JUnitFlakyTestDataPublisher']], testResults: 'wire-ios-automation/ios/target/xml-reports/TEST*.xml') { + sh """ +mvn clean integration-test \\ +-f "$WORKSPACE/wire-ios-automation/ios/pom.xml" \\ +-P isOnGrid \\ +-DUrl='${hubUrl}' \\ +-Dpicklejar.parallelism='${MAX_PARALLEL}' \\ +-DisSimulator=${is_simulator} \\ +-Dpicklejar.tags='$TAGS' \\ +-DappPath="${APP_PATH}" \\ +-DoldAppPath="${OLD_APP_PATH}" \\ +-DdeviceName="${deviceName}" \\ +-DplatformVersion='${platformVersion}' \\ +-DrealBuildNumber='${REAL_BUILD_NUMBER}' \\ +-DbundleId='${BUNDLE_ID}' \\ +-Dsurefire.rerunFailingTestsCount=${RERUN} \\ +-Dtest="${params.test}" \\ +-DbrowserName="${browserName}" +""" + } + } + } catch (e) { + print e + if (e instanceof hudson.AbortException) { + aborted = true + } + } + } + } + + } // End of lock + + stage('Generate test results') { + try { + // Generate Jenkins cucumber HTML reports and archive JSON + archiveArtifacts artifacts: '**/target/*report*.json', followSymlinks: false + cucumber failedFeaturesNumber: -1, failedScenariosNumber: -1, failedStepsNumber: -1, fileIncludePattern: '**/target/*report*.json', jsonReportDirectory: "${WORKSPACE}/wire-ios-automation/ios/", mergeFeaturesById: true, pendingStepsNumber: -1, skippedStepsNumber: -1, sortingMethod: 'ALPHABETICAL', undefinedStepsNumber: -1 + } catch (e) { + print e + if (e instanceof hudson.AbortException) { + aborted = true + } + } + try { + // Generate zip-able files + // Zip and archive cucumber HTML reports + node("built-in") { + def foldername = env.JOB_NAME + def down = "../.." + if (foldername.indexOf("/") > -1) { + foldername = env.JOB_NAME.replace("/", "/jobs/") + down = "../../.." + } else { + foldername = env.JOB_BASE_NAME + } + sh returnStatus: true, script: 'rm -rf cucumber-report*.zip' + zip archive: true, defaultExcludes: false, dir: "${down}/jobs/${foldername}/builds/${BUILD_NUMBER}/cucumber-html-reports/", overwrite: true, zipFile: "cucumber-report_iOS_build_${REAL_BUILD_NUMBER}.zip" + } + + } catch (e) { + print e + if (e instanceof hudson.AbortException) { + aborted = true + } + } + } + + stage('Report test results') { + def testResult = testStatuses() + if (isTestSuccessful()) { + if (!aborted) { + wireSend secret: env.JENKINSBOT_SECRET, message: "✅ **${JOB_NAME} ${BUILD_DISPLAY_NAME}**\n${filename}\n${TAGS}\nSee [JUnit Reports](${BUILD_URL}testReport/) or [Cucumber Reports](${BUILD_URL}cucumber-html-reports)\n${testResult}" + } else { + wireSend secret: env.JENKINSBOT_SECRET, message: "⚠️ **${JOB_NAME} ${BUILD_DISPLAY_NAME} was aborted**\n${filename}\n${TAGS}\nSee [Console log](${BUILD_URL}console)\n${testResult}" + } + } else { + wireSend secret: env.JENKINSBOT_SECRET, message: "❌ **${JOB_NAME} ${BUILD_DISPLAY_NAME}**\n${filename}\n${TAGS}\nSee [JUnit Reports](${BUILD_URL}testReport/) or [Cucumber Reports](${BUILD_URL}cucumber-html-reports)\n${testResult}" + } + } + + } + +} diff --git a/wire-ios-automation/ios/RetrieveIPA.Jenkinsfile.groovy b/wire-ios-automation/ios/RetrieveIPA.Jenkinsfile.groovy new file mode 100644 index 00000000000..09afec23c2b --- /dev/null +++ b/wire-ios-automation/ios/RetrieveIPA.Jenkinsfile.groovy @@ -0,0 +1,153 @@ +/* + +TAGS as String parameter +Grid as Choice parameter +Track as Choice parameter: dev, release, avs, qa-playground +AppBuildNumber as String parameter +CALLING_SERVICE_ENV as Choice parameter with choices: master, dev +TESTINY_RUN_NAME as String parameter +Branch as String parameter main +surefire.rerunFailingTestsCount as String parameter 1 +test as String parameter + */ + +@NonCPS +def sortByModified(list) { + list.sort { + it.getLastModified() + } +} + +def setIPAFromS3(def track, def appBuildNumber, def is_simulator) { + if (track == "Development") { + // Track "Development": AppBuildNumber can be set to build number or "latest" + if (is_simulator) { + S3_FOLDER = "ios/development/simulator/" + } else { + S3_FOLDER = "ios/development/device/debug/" + } + GLOB = 'Wire-development-develop-*/*.ipa' + // Get list of files on S3 + def files = [] + withAWS(region: 'eu-west-1', credentials: "S3_CREDENTIALS") { + files = s3FindFiles bucket: "z-lohika", path: S3_FOLDER, onlyFiles: true, glob: GLOB + } + print("Found:") + files.each { + print(it.path) + } + if (appBuildNumber == "latest") { + print("Get latest develop build...") + files = sortByModified(files) + env.S3AppPath = S3_FOLDER + files[-1].path + } else { + print("Find develop build with matching build number...") + files.each { + if (it.name.contains("${appBuildNumber}")) { + env.S3AppPath = S3_FOLDER + it.path + } + } + if (env.S3AppPath == null) { + error("Could not find develop build in ${S3_FOLDER} with name containing ${appBuildNumber}") + } + } + } else if (track == "S3") { + if ("${appBuildNumber}".endsWith(".ipa") == 0) { + error("AppBuildNumber does needs to end with .ipa: ${appBuildNumber}") + } + if (appBuildNumber.startsWith("ios/") == 0) { + error("AppBuildNumber should look like this: ios/development/simulator/Wire-development-develop-simulator-10310/Wire-development-develop-simulator-10310.ipa") + } + env.S3AppPath = appBuildNumber + } else if (track == "Release") { + print("Trying to get latest release...") + S3_FOLDER = "ios/release/testflight/" + GLOB = 'Wire-beta-release*-simulator-*/*.ipa' + withAWS(region: 'eu-west-1', credentials: "S3_CREDENTIALS") { + files = s3FindFiles bucket: "z-lohika", path: S3_FOLDER, onlyFiles: true, glob: GLOB + } + print("Search complete") + print("Found:") + files.each { + print(it.path) + } + + if (appBuildNumber == "latest") { + print("Get latest release build...") + files = sortByModified(files) + env.S3AppPath = S3_FOLDER + files[-1].path + } else { + print("Find develop build with matching build number...") + files.each { + if (it.name.contains("${appBuildNumber}")) { + env.S3AppPath = S3_FOLDER + it.path + } + } + if (env.S3AppPath == null) { + error("Could not find develop build in ${S3_FOLDER} with name containing ${appBuildNumber}") + } + } + } else if (track.startsWith("Bund-")) { + if (track == "Bund-column1-dev") { + if (is_simulator) { + S3_FOLDER = "ios/custom/bund/column1/development/simulator/" + } else { + S3_FOLDER = "ios/custom/bund/column1/development/device/release/" + } + GLOB = '*.ipa' + } else if (track == "Bund-column1-release") { + if (is_simulator) { + S3_FOLDER = "ios/custom/bund/column1/release/simulator/" + } else { + S3_FOLDER = "ios/custom/bund/column1/release/device/release/" + } + GLOB = 'Wire-rc-release_*/*.ipa' + } else if (track == "Bund-column3-release") { + if (is_simulator) { + S3_FOLDER = "ios/custom/bund/column3/release/simulator/" + } else { + S3_FOLDER = "ios/custom/bund/column3/release/device/release/" + } + GLOB = 'Wire-rc-release_*/*.ipa' + } else { + error("Unknown Track: " + track) + } + // Get list of files on S3 + def files = [] + print(S3_FOLDER) + print(GLOB) + withAWS(region: 'eu-west-1', credentials: s3_credentials) { + files = s3FindFiles bucket: "z-lohika", path: S3_FOLDER, onlyFiles: true, glob: GLOB + } + print("Search complete") + print("Found:") + files.each { + print(it.path) + } + if (appBuildNumber == "latest") { + print("Get latest build...") + files = sortByModified(files) + env.S3AppPath = S3_FOLDER + files[-1].path + } else { + print("Find develop build with matching build number...") + files.each { + if (it.name.contains(appBuildNumber)) { + env.S3AppPath = S3_FOLDER + it.path + } + } + if (env.S3AppPath == null) { + error("Could not find develop build in ${S3_FOLDER} with name containing ${appBuildNumber}") + } + } + } else { + error("Track ${track} is not known. Please use either 'Development', 'S3' or one of the bund tracks.") + } + + echo("S3AppPath: " + env.S3AppPath) + + env.REAL_BUILD_NUMBER = (env.S3AppPath =~ /([0-9]+).ipa/) [0] [1] + echo("REAL_BUILD_NUMBER: " + env.REAL_BUILD_NUMBER) + return env.S3AppPath +} + +return this \ No newline at end of file diff --git a/wire-ios-automation/ios/Tests.Jenkinsfile.groovy b/wire-ios-automation/ios/Tests.Jenkinsfile.groovy new file mode 100644 index 00000000000..0454ac5ad2b --- /dev/null +++ b/wire-ios-automation/ios/Tests.Jenkinsfile.groovy @@ -0,0 +1,366 @@ +/* + +TAGS as String parameter +Grid as Choice parameter +Track as Choice parameter: dev, release, avs, qa-playground +AppBuildNumber as String parameter +CALLING_SERVICE_ENV as Choice parameter with choices: master, dev +TESTINY_RUN_NAME as String parameter +Branch as String parameter main +surefire.rerunFailingTestsCount as String parameter 1 +test as String parameter + + */ +import hudson.tasks.test.AbstractTestResultAction + +@NonCPS +def testStatuses() { + AbstractTestResultAction testResultAction = currentBuild.rawBuild.getAction(AbstractTestResultAction.class) + if (testResultAction != null) { + def total = testResultAction.totalCount + def failed = testResultAction.failCount + def skipped = testResultAction.skipCount + def passed = total - failed - skipped + int percent = (total > 0) ? (passed * 100) / (total - skipped) : 0 + testStatus = "Tests passed: ${passed}, Failed: ${failed} ${testResultAction.failureDiffString}, Skipped: ${skipped}, Total: ${total} (${percent}%)" + } else { + testStatus = "Could not find test results!" + } + return testStatus +} + +@NonCPS +def isTestSuccessful() { + AbstractTestResultAction testResultAction = currentBuild.rawBuild.getAction(AbstractTestResultAction.class) + if (testResultAction != null && testResultAction.failCount > 0) { + return false + } + return true +} + +@NonCPS +def sortByModified(list) { + list.sort { + it.getLastModified() + } +} + +node("Job_distributor") { + + def aborted = false + def TAGS = "${params.TAGS}" + def filename = "" + + // Select bot credentials for specific Wire conversation based on job name + if ("${JOB_NAME}" =~ /pipeline_Regression/) { + credentialsId = "JENKINSBOT_IOS_REGRESSION" + } else if ("${JOB_NAME}" =~ /pipeline_Call/) { + credentialsId = "JENKINSBOT_IOS" + } else if ("${JOB_NAME}" =~ /pipeline_realdevice_Regression/) { + credentialsId = "JENKINSBOT_IOS_REGRESSION" + } else if ("${JOB_NAME}" =~ /pipeline_realdevice_Staging/) { + credentialsId = "JENKINSBOT_IOS_STAGING" + } else if ("${JOB_NAME}" =~ /pipeline_realdevice_exp/) { + credentialsId = "JENKINSBOT_IOS_STAGING" + } else if ("${JOB_NAME}" =~ /exp_pipeline/) { + credentialsId = "JENKINSBOT_IOS_EXP" + } else if ("${JOB_NAME}" =~ /exp2_pipeline/) { + credentialsId = "JENKINSBOT_IOS_EXP" + } else if ("${JOB_NAME}" =~ /knownbug|unstable/) { + credentialsId = "JENKINSBOT_IOS_STAGING" + } else if ("${JOB_NAME}" =~ /pipeline_RC/) { + credentialsId = "JENKINSBOT_IOS_RC" + } else if ("${JOB_NAME}" =~ /pipeline_Smoke/) { + credentialsId = "JENKINSBOT_IOS_SMOKE" + } else if ("${JOB_NAME}" =~ /_Bund_/) { + credentialsId = "JENKINSBOT_BUND" + } else if ("${JOB_NAME}" =~ /pipeline_large_team/) { + credentialsId = "JENKINSBOT_IOS_REGRESSION" + } else if ("${JOB_NAME}" =~ /pipeline_experiment/) { + credentialsId = "JENKINSBOT_IOS_STAGING" + } else if ("${JOB_NAME}" =~ /pipeline_regression_x86/) { + credentialsId = "JENKINSBOT_IOS_REGRESSION" + } else if ("${JOB_NAME}" =~ /iOS_Critical_Flows/) { + credentialsId = "JENKINSBOT_IOS_SMOKE" + } else if ("${JOB_NAME}" =~ /iOS_Navigation_Overhaul/) { + credentialsId = "JENKINSBOT_IOS_SMOKE" + } else { + credentialsId = "JENKINSBOT_IOS_EXP" + } + + // Select grid + if (params.Grid == "iOS-realdevice") { + hubUrl = "http://192.168.2.70:4444/wd/hub" + is_simulator = false + deviceName = "iPhone 11" + platformVersion = "16.3" + nodeLabels = "iOS_node070" + MAX_PARALLEL = nodesByLabel(nodeLabels).size() + } else if (params.Grid == "iOS-phones-arm64") { + hubUrl = "http://192.168.2.200:4444/wd/hub" + is_simulator = true + deviceName = "iPhone 11" + platformVersion = "16.4" + nodeLabels = "iOS_node200" + MAX_PARALLEL = 5 + } else if (params.Grid == "iOS-phones-arm64-fast") { + hubUrl = "http://192.168.2.203:4444/wd/hub" + is_simulator = true + deviceName = "iPhone 11" + platformVersion = "16.4" + nodeLabels = "iOS_node203" + MAX_PARALLEL = 15 + } else { + error("Grid " + params.Grid + " is not supported yet!") + } + + echo("Configure 1Password integration") + def config = [ + serviceAccountCredentialId: '1PasswordServiceAccountToken', + opCLIPath: "/usr/local/bin/" + ] + def secrets = [ + [envVar: 'OKTA_API_KEY', secretRef: 'op://QA automation/OKTA_API_KEY/password'], + [envVar: 'KEYCLOAK_PASSWORD', secretRef: 'op://QA automation/KEYCLOAK_PASSWORD/password'], + [envVar: 'LH_SERVICE_AUTH_TOKEN', secretRef: 'op://QA automation/LH_SERVICE_AUTH_TOKEN/password'], + [envVar: 'STRIPE_API_KEY', secretRef: 'op://QA automation/STRIPE_API_KEY/password'], + [envVar: 'MS_EMAIL', secretRef: 'op://QA automation/MS_CREDENTIALS/username'], + [envVar: 'MS_PASSWORD', secretRef: 'op://QA automation/MS_CREDENTIALS/password'], + [envVar: 'BLACKLIST_S3_SECRET', secretRef: 'op://QA automation/BLACKLIST_S3_SECRET/password'], + [envVar: 'TESTINY_API_KEY', secretRef: 'op://QA automation/TESTINY_API_KEY_IOS/password'], + ] + + // Use 1Password secrets + withSecrets(config: config, secrets: secrets) { + // Use Jenkins credentials + withCredentials([ + string(credentialsId: '1PasswordServiceAccountToken', variable: 'OP_SERVICE_ACCOUNT_TOKEN'), + file(credentialsId: 'KUBECONFIG_anta', variable: 'KUBECONFIG_anta'), + file(credentialsId: 'KUBECONFIG_bella', variable: 'KUBECONFIG_bella'), + file(credentialsId: 'KUBECONFIG_chala', variable: 'KUBECONFIG_chala'), + file(credentialsId: 'KUBECONFIG_foma', variable: 'KUBECONFIG_foma'), + file(credentialsId: 'KUBECONFIG_gudja_offline_ios', variable: 'KUBECONFIG_gudja_offline_ios'), + file(credentialsId: 'KUBECONFIG_bund_next_column_1', variable: 'KUBECONFIG_bund_next_column_1'), + file(credentialsId: 'KUBECONFIG_bund_qa_column_1', variable: 'KUBECONFIG_bund_qa_column_1'), + string(credentialsId: "${credentialsId}", variable: 'JENKINSBOT_SECRET') + ]) { + + // Checkout + stage('Checkout & Clean') { + + echo("Checkout common") + checkout( + [$class: 'GitSCM', + branches: [[name: '*/main']], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'RelativeTargetDirectory', relativeTargetDir: 'common'], + [$class: 'SparseCheckoutPaths', sparseCheckoutPaths: [[path: 'tests/common']]], + [$class: 'CheckoutOption', timeout: 30], + [$class: 'CloneOption', depth: 0, noTags: true, reference: '', shallow: true, timeout: 30], + [$class: 'BuildChooserSetting', buildChooser: [$class: 'DefaultBuildChooser']]], + submoduleCfg: [], + userRemoteConfigs: [[credentialsId: 'zautomation', url: 'git@github.com:zinfra/zautomation.git']]]) + + checkout([$class: 'GitSCM', + branches: [[name: '*/${Branch}']], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'RelativeTargetDirectory', relativeTargetDir: 'wire-ios'], + [$class: 'SparseCheckoutPaths', sparseCheckoutPaths: [[path: 'wire-ios-automation/ios'], [path: 'wire-ios-automation/tools']]], + [$class: 'CheckoutOption', timeout: 30], + [$class: 'CloneOption', depth: 0, noTags: true, reference: '', shallow: true, timeout: 30], + [$class: 'BuildChooserSetting', buildChooser: [$class: 'DefaultBuildChooser']]], + submoduleCfg: [], + userRemoteConfigs: [[credentialsId: 'zautomation', url: 'git@github.com:wireapp/wire-ios.git']]]) + + echo("Installing kubectl if not already installed") + kubeCtlSetup = readFile("${WORKSPACE}/common/tests/common/kubectlSetup.sh") + sh kubeCtlSetup + } + + // TODO: Move this back into the test stage when a solution is found that does not override Wire.ipa in a second parallel run + lock("${Grid}") { + stage('Download build from S3 to grid nodes') { + // Get ipa from S3 depending on track name + load('wire-ios/wire-ios-automation/ios/RetrieveIPA.Jenkinsfile.groovy').setIPAFromS3(env.Track, appBuildNumber, is_simulator) + echo("OldS3AppPath: ${OldS3AppPath}") + + env.REAL_BUILD_NUMBER = (env.S3AppPath =~ /([0-9]+).ipa/)[0][1] + echo("REAL_BUILD_NUMBER: " + env.REAL_BUILD_NUMBER) + + // Work on selenium grid nodes + nodes = nodesByLabel(nodeLabels) + nodes.each { + node(it) { + // Cleanup the workspace before getting the ipa ready + sh "rm -rf $WORKSPACE/Payload" + sh "rm -rf $WORKSPACE/Wire.ipa" + + // TODO: Find a better place for saving the ipa (maybe containing the grid name) + env.OLD_APP_PATH = "$WORKSPACE/Previous/Payload/Wire.app" + env.APP_PATH = "$WORKSPACE/Payload/Wire.app" + + // Download builds via s3 proxy + sh "curl http://192.168.2.39:8000/z-lohika/${S3AppPath} -o \"$WORKSPACE/Wire.ipa\"" + + // grab wire.app from the ipa + sh "unzip -o $WORKSPACE/Wire.ipa" + + // Gather bundle id from ipa + env.BUNDLE_ID = sh returnStdout: true, script: "plutil -extract CFBundleIdentifier raw Payload/Wire.app/Info.plist -o - | tr -d '\\n'" + echo("BUNDLE_ID: " + env.BUNDLE_ID) + + echo("Delete old version") + sh "rm -rf ${OldS3AppPath}" + + echo("Download old version") + if ("${OldS3AppPath}" == "") { + echo("No OldS3AppPath given.") + } else { + // Download old build via s3 proxy + sh "curl http://192.168.2.39:8000/z-lohika/${OldS3AppPath} -o \"$WORKSPACE/Previous.ipa\"" + sh "unzip -o $WORKSPACE/Previous.ipa -d $WORKSPACE/Previous/" + } + + // See section "Files generated by test runs" on http://appium.io/docs/en/drivers/ios-xcuitest/ + // If it fails we shutdown the simulator first and try deleting them again + sh returnStatus: true, script: 'rm -rf $HOME/Library/Logs/CoreSimulator/* || xcrun simctl shutdown booted && rm -Rf $HOME/Library/Logs/CoreSimulator/*' + + // Delete cached ipa files to prevent installation error "Failed to load Info.plist from bundle at" + sh 'rm -rf /var/folders/* || true' + + // Delete all files used by former history backup tests + sh 'find $HOME/Library/Developer/CoreSimulator/Devices/ -name *.ios_wbu -delete' + } + } + } + + // Set build description + filename = env.S3AppPath.tokenize('/')[-1] + currentBuild.description = filename + "\n" + TAGS + + // Keep build forever when RC is made + if (params.TESTINY_RUN_NAME != "") { + currentBuild.description = params.TESTINY_RUN_NAME + "\n" + currentBuild.description + currentBuild.keepLog = true + } + + withMaven(jdk: 'JDK17', maven: 'M3', mavenOpts: '-Xmx1024m', mavenLocalRepo: '.repository', options: [junitPublisher(disabled: true), jacocoPublisher(disabled: true)]) { + + stage('Build common') { + echo("Check 1Password Installation") + sh "sh $WORKSPACE/common/tests/common/src/main/resources/install1PasswordCLIOnNode.sh" + + echo("Get backend connections...") + sh "sh $WORKSPACE/common/tests/common/src/main/resources/backendConnections.sh" + + env.RC_TESTS_COMMENT_PATH = "$WORKSPACE/rc_comment.txt" + sh 'echo "${JOB_NAME} ${BUILD_DISPLAY_NAME} - Cucumber Report: ${JENKINS_URL}job/${JOB_NAME}/${BUILD_NUMBER}/cucumber-html-reports/" > $RC_TESTS_COMMENT_PATH' + + sh """ +mvn clean install \\ +-f "$WORKSPACE/common/tests/common/pom.xml" \\ +-DbackendType="${backendType}" \\ +-DbackendConnections="${WORKSPACE}/backendConnections.json" \\ +-DtestinyProjectName="Wire iOS" \\ +-DtestinyRunName="$TESTINY_RUN_NAME" \\ +-DrcTestsCommentPath="${RC_TESTS_COMMENT_PATH}" \\ +-DcallingServiceUrl='loadbalanced' \\ +-Dcom.wire.calling.env='${CALLING_SERVICE_ENV}' \\ +-DsyncIsAutomated=true +""" + } + + // This is needed because of https://issues.jenkins-ci.org/browse/JENKINS-7180 + def RERUN = currentBuild.getRawBuild().actions.find { it instanceof ParametersAction }?.parameters.find { + it.name == 'surefire.rerunFailingTestsCount' + }?.value + + // This is needed when we want the tests to only run on a specific device + if (!browserName.isEmpty()) { + env.MAX_PARALLEL = 1 + } + + stage('Run tests') { + try { + timeout(time: 6, unit: 'HOURS') { + realtimeJUnit(keepLongStdio: true, testDataPublishers: [[$class: 'JUnitFlakyTestDataPublisher']], testResults: 'wire-ios/wire-ios-automation/ios/target/xml-reports/TEST*.xml') { + sh """ +mvn clean integration-test \\ +-f "$WORKSPACE/wire-ios/wire-ios-automation/ios/pom.xml" \\ +-P isOnGrid \\ +-DUrl='${hubUrl}' \\ +-Dpicklejar.parallelism='${MAX_PARALLEL}' \\ +-DisSimulator=${is_simulator} \\ +-Dpicklejar.tags='$TAGS' \\ +-DappPath="${APP_PATH}" \\ +-DoldAppPath="${OLD_APP_PATH}" \\ +-DdeviceName="${deviceName}" \\ +-DplatformVersion='${platformVersion}' \\ +-DrealBuildNumber='${REAL_BUILD_NUMBER}' \\ +-DbundleId='${BUNDLE_ID}' \\ +-Dsurefire.rerunFailingTestsCount=${RERUN} \\ +-Dtest="${params.test}" \\ +-DbrowserName="${browserName}" +""" + } + } + } catch (e) { + print e + if (e instanceof hudson.AbortException) { + aborted = true + } + } + } + } + + } // End of lock + + stage('Generate test results') { + try { + // Generate Jenkins cucumber HTML reports and archive JSON + archiveArtifacts artifacts: '**/target/*report*.json', followSymlinks: false + cucumber failedFeaturesNumber: -1, failedScenariosNumber: -1, failedStepsNumber: -1, fileIncludePattern: '**/target/*report*.json', jsonReportDirectory: "${WORKSPACE}/wire-ios/wire-ios-automation/ios/", mergeFeaturesById: true, pendingStepsNumber: -1, skippedStepsNumber: -1, sortingMethod: 'ALPHABETICAL', undefinedStepsNumber: -1 + } catch (e) { + print e + if (e instanceof hudson.AbortException) { + aborted = true + } + } + try { + // Generate zip-able files + // Zip and archive cucumber HTML reports + node("built-in") { + def foldername = env.JOB_NAME + def down = "../.." + if (foldername.indexOf("/") > -1) { + foldername = env.JOB_NAME.replace("/", "/jobs/") + down = "../../.." + } else { + foldername = env.JOB_BASE_NAME + } + sh returnStatus: true, script: 'rm -rf cucumber-report*.zip' + zip archive: true, defaultExcludes: false, dir: "${down}/jobs/${foldername}/builds/${BUILD_NUMBER}/cucumber-html-reports/", overwrite: true, zipFile: "cucumber-report_iOS_build_${REAL_BUILD_NUMBER}.zip" + } + } catch (e) { + print e + if (e instanceof hudson.AbortException) { + aborted = true + } + } + } + + stage('Report test results') { + def testResult = testStatuses() + if (isTestSuccessful()) { + if (!aborted) { + wireSend secret: env.JENKINSBOT_SECRET, message: "✅ **${JOB_NAME} ${BUILD_DISPLAY_NAME}**\n${filename}\n${TAGS}\nSee [JUnit Reports](${BUILD_URL}testReport/) or [Cucumber Reports](${BUILD_URL}cucumber-html-reports)\n${testResult}" + } else { + wireSend secret: env.JENKINSBOT_SECRET, message: "⚠️ **${JOB_NAME} ${BUILD_DISPLAY_NAME} was aborted**\n${filename}\n${TAGS}\nSee [Console log](${BUILD_URL}console)\n${testResult}" + } + } else { + wireSend secret: env.JENKINSBOT_SECRET, message: "❌ **${JOB_NAME} ${BUILD_DISPLAY_NAME}**\n${filename}\n${TAGS}\nSee [JUnit Reports](${BUILD_URL}testReport/) or [Cucumber Reports](${BUILD_URL}cucumber-html-reports)\n${testResult}" + } + } + } + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/Trigger.Jenkinsfile.groovy b/wire-ios-automation/ios/Trigger.Jenkinsfile.groovy new file mode 100644 index 00000000000..e36ce193d8a --- /dev/null +++ b/wire-ios-automation/ios/Trigger.Jenkinsfile.groovy @@ -0,0 +1,85 @@ +node('built-in') { + + // Select bot credentials for specific Wire conversation based on job name + credentialsId = "JENKINSBOT_IOS_SMOKE" + + def jenkinsbot_secret = "" + withCredentials([string(credentialsId: "${credentialsId}", variable: 'JENKINSBOT_SECRET')]) { + jenkinsbot_secret = env.JENKINSBOT_SECRET + } + + def commit_hash = "" + def commit_msg = "" + stage('Checkout wire-ios-mono') { + def scmVars = git branch: "$GIT_BRANCH_IOS", url: 'https://github.com/wireapp/wire-ios-mono' + commit_hash = scmVars.GIT_COMMIT + commit_msg = sh returnStdout: true, script: 'git log -n 1 --pretty=format:"%an: %s"' + commit_msg = "[${commit_msg}](https://github.com/wireapp/wire-ios-mono/commit/${commit_hash})" + } + + stage('Check build state') { + try { + withCredentials([usernameColonPassword(credentialsId: 'GITHUB_API_WEBAPP', variable: 'CREDENTIALS')]) { + + timeout(time: 3, unit: 'MINUTES') { + waitUntil { + def output = sh label: 'Get runs', returnStdout: true, script: 'curl -L -u ${CREDENTIALS} https://api.github.com/repos/wireapp/wire-ios-mono/actions/workflows/61594641/runs' + def json = readJSON text: output + if (json['message']) { + echo("Output: " + output) + error("**Trigger script failed:** " + json['message']) + } + def runs = json['workflow_runs'] + echo("Looking for hash " + commit_hash) + for (run in runs) { + if (run['head_sha'] == commit_hash) { + echo("Found hash " + run['head_sha']) + echo("status: " + run['status']) + // status can be queued, in_progress, or completed + if (run['status'] == 'queued' || run['status'] == 'in_progress' || run['status'] == 'completed') { + return true + } + } + } + sleep(20) + return false + } + } + + timeout(time: 30, unit: 'MINUTES') { + waitUntil { + def output = sh label: 'Get runs', returnStdout: true, script: 'curl -L -u ${CREDENTIALS} https://api.github.com/repos/wireapp/wire-ios-mono/actions/workflows/61594641/runs' + def json = readJSON text: output + def runs = json['workflow_runs'] + echo("Looking for hash " + commit_hash) + for (run in runs) { + if (run['head_sha'] == commit_hash) { + echo("Found hash " + run['head_sha']) + echo("status: " + run['status']) + echo("conclusion: " + run['conclusion']) + // conclusion can be: success, failure, neutral, cancelled, skipped, timed_out, or action_required + if (run['conclusion'] == 'success') { + return true + } else if (run['conclusion'] == 'failure') { + error("❌ **Build failed for branch '${GIT_BRANCH_IOS}'** See [Github Actions](" + run['url'] + ")") + } else if (run['conclusion'] == 'cancelled') { + error("⚠️ **Build aborted for branch '${GIT_BRANCH_IOS}'** See [Github Actions](" + run['url'] + ")") + } + } + } + sleep(20) + return false; + } + } + } + } catch(e) { + wireSend secret: "$jenkinsbot_secret", message: "❌ **Github Action issue or took longer than 30 minutes** \n${commit_msg}\nSee https://github.com/wireapp/wire-webapp/branches" + error("$e") + } + wireSend secret: "$jenkinsbot_secret", message: "✅ **Build finished for branch '$GIT_BRANCH_IOS'**\n${commit_msg}" + } + + stage('Trigger test job') { + build job: "$TEST_JOB", parameters: [string(name: 'TAGS', value: "$TAGS"), string(name: 'GIT_BRANCH', value: "$GIT_BRANCH"), string(name: 'Track', value: "Development"), string(name: 'AppBuildNumber', value: "latest")], wait: false + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/img.png b/wire-ios-automation/ios/img.png new file mode 100644 index 0000000000000000000000000000000000000000..1f8df74d42a75204dc72db690b15881774c1f2a8 GIT binary patch literal 5545 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 literal 0 HcmV?d00001 diff --git a/wire-ios-automation/ios/nbactions.xml b/wire-ios-automation/ios/nbactions.xml new file mode 100644 index 00000000000..0e4c7688178 --- /dev/null +++ b/wire-ios-automation/ios/nbactions.xml @@ -0,0 +1,33 @@ + + + + test.single + + * + + + test-compile + surefire:test + + + ${packageClassName} + false + + + iOS + + + + rebuild + + * + + + clean + install + + + iOS + + + diff --git a/wire-ios-automation/ios/pom.xml b/wire-ios-automation/ios/pom.xml new file mode 100644 index 00000000000..a29511ed894 --- /dev/null +++ b/wire-ios-automation/ios/pom.xml @@ -0,0 +1,255 @@ + + 4.0.0 + + com.wearezeta.auto + auto-ios + 1.0-SNAPSHOT + jar + + auto-ios + http://maven.apache.org + + + UTF-8 + + 17 + false + DevRun + http://127.0.0.1:4723 + + + 16.0 + + + Wire + true + false + + 10 + smoketester@wire.com + bsmquxrcdverabbp + smoketester.android@wire.com + esjasabkpuugatao + iPhone 11 + 1 + 11 + staging + {user.home}/Documents/performance.json + false + true + + + 1 + com.wearezeta.auto.ios.steps + com.wearezeta.auto.ios + target/ + report.json + https://wire-account-staging.zinfra.io/ + + 0 + + 0 + + + + + central + Maven Central Repository + https://repo1.maven.org/maven2 + + + + + + com.wire.qa + qa-common + 1.0-SNAPSHOT + jar + + + org.seleniumhq.selenium + selenium-java + + + + + + org.seleniumhq.selenium + selenium-java + 4.7.0 + + + + io.appium + java-client + + 8.5.0 + + + org.seleniumhq.selenium + selenium-api + + + org.seleniumhq.selenium + selenium-remote-driver + + + org.seleniumhq.selenium + selenium-support + + + + + + com.googlecode.plist + dd-plist + 1.18 + + + + + + + src/main/resources + true + + + + src/main/java + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + UTF-8 + 17 + 17 + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + test-jar + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + UTF-8 + + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + + integration-test + + exec + + + + + java + true + false + ${skipTests} + test + + + + --add-opens + java.base/java.lang=ALL-UNNAMED + -classpath + + -DappPath=${appPath} + -DoldAppPath=${oldAppPath} + -DplatformVersion=${platformVersion} + -DrealBuildNumber=${realBuildNumber} + -DbundleId=${bundleId} + -DisSimulator=${isSimulator} + -DenableAppiumOutput=${enableAppiumOutput} + -DbrowserName=${browserName} + -DdeviceName=${deviceName} + -Dpicklejar.tags=${picklejar.tags} + -Djunit.jupiter.execution.parallel.enabled=true + -Djunit.jupiter.execution.parallel.mode.default=concurrent + -Djunit.jupiter.execution.parallel.mode.classes.default=concurrent + -Djunit.jupiter.execution.parallel.config.strategy=fixed + -Djunit.jupiter.execution.parallel.config.strategy=custom + + -Djunit.jupiter.execution.parallel.config.custom.class=com.wire.qa.picklejar.engine.CustomStrategy + + -Dpicklejar.parallelism=${picklejar.parallelism} + -Dmaven.test.failure.ignore=${maven.test.failure.ignore} + + + -Dtest=${test} + -Dsurefire.rerunFailingTestsCount=${surefire.rerunFailingTestsCount} + com.wire.qa.picklejar.launcher.PicklejarLauncher + ${project.build.directory} + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.1.2 + + true + + + + + + + + windows + + + Windows + + + + ;${basedir}\target\test-classes + + + + Unix + + + unix + + + + :${basedir}/target/test-classes + + + + isOnGrid + + http://192.168.2.110:44110/wd/hub + true + + + + + \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/ElementState.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/ElementState.java new file mode 100644 index 00000000000..4971de61032 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/ElementState.java @@ -0,0 +1,84 @@ +package com.wearezeta.auto.ios.common; + +import com.wearezeta.auto.common.ImageUtil; +import com.wearezeta.auto.common.log.ZetaLogger; +import com.wearezeta.auto.common.misc.Timedelta; +import java.util.logging.Logger; +import org.openqa.selenium.StaleElementReferenceException; + +import java.awt.image.BufferedImage; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + + +public class ElementState { + private static final Logger log = ZetaLogger.getLog(ElementState.class.getSimpleName()); + + private static final Timedelta INTERVAL = Timedelta.ofMillis(500); + + private Optional previousScreenshot = Optional.empty(); + private Supplier screenshotFunction; + + public ElementState(Supplier screenshotFunction) { + this.screenshotFunction = screenshotFunction; + } + + public ElementState remember() throws Exception { + final int maxRetries = 3; + int nTry = 0; + Exception savedException; + do { + try { + this.previousScreenshot = Optional.of(screenshotFunction.get()); + return this; + } catch (StaleElementReferenceException e) { + savedException = e; + nTry++; + INTERVAL.sleep(); + } + } while (nTry < maxRetries); + throw savedException; + } + + private boolean checkState(Function checkerFunc, Timedelta timeout) { + return checkState(checkerFunc, timeout, ImageUtil.RESIZE_TEMPLATE_TO_REFERENCE_RESOLUTION); + } + + private boolean checkState(Function checkerFunc, Timedelta timeout, int resizeMode) { + final Timedelta started = Timedelta.now(); + do { + try { + final BufferedImage currentState = screenshotFunction.get(); + final double score = ImageUtil.getOverlapScore( + this.previousScreenshot.orElseThrow( + () -> new IllegalStateException("Please remember the previous element state first")), + currentState, resizeMode); + log.fine(String.format("Actual score: %.4f; Time left: %s", score, + timeout.sum(started).diff(Timedelta.now()).toString())); + if (checkerFunc.apply(score)) { + return true; + } + } catch (StaleElementReferenceException e) { + log.fine(String.format("Actual score: ; Time left: %s", + timeout.sum(started).diff(Timedelta.now()).toString())); + } + INTERVAL.sleep(); + } while (Timedelta.now().isDiffLessOrEqual(started, timeout)); + return false; + } + + public boolean isChanged(Timedelta timeout, double minScore) { + log.fine(String.format( + "Checking if element state has been changed in %s (Min score: %.4f)...", + timeout.toString(), minScore)); + return checkState((x) -> x < minScore, timeout); + } + + public boolean isNotChanged(Timedelta timeout, double minScore) { + log.fine(String.format( + "Checking if element state has NOT been changed in %s (Min score: %.4f)...", + timeout, minScore)); + return checkState((x) -> x >= minScore, timeout); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/IOSDriverBuilder.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/IOSDriverBuilder.java new file mode 100644 index 00000000000..56de846a3ef --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/IOSDriverBuilder.java @@ -0,0 +1,224 @@ +package com.wearezeta.auto.ios.common; + +import com.google.common.collect.ImmutableMap; +import com.wearezeta.auto.common.Config; +import com.wearezeta.auto.common.log.ZetaLogger; +import com.wearezeta.auto.common.usrmgmt.ClientUser; + +import java.time.Duration; +import java.util.logging.Logger; + +import io.appium.java_client.ios.IOSDriver; +import org.json.JSONObject; +import org.openqa.selenium.Capabilities; +import org.openqa.selenium.MutableCapabilities; +import org.openqa.selenium.ScreenOrientation; +import org.openqa.selenium.WebDriver; + +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; + +public class IOSDriverBuilder { + + private static final Logger log = ZetaLogger.getLog(IOSDriverBuilder.class.getSimpleName()); + + private URL hubUrl = null; + private Capabilities capabilities = null; + private MutableCapabilities extraCapabilities = new MutableCapabilities(); + private List processArgs = new ArrayList<>(); + private Consumer logListenerHandler = null; + private Boolean fullReset = false; + private Boolean allowLocation = true; + private Boolean allowTouchID = true; + private Boolean allowMicrophoneAccess = false; + private Boolean allowCameraAccess = false; + private Boolean allowAccessToAllPhotos = false; + private Boolean allowNotifications = false; + private Boolean uninstallAllOtherVersions = false; + private static final String[] bundleIDs = { + "com.wearezeta.zclient.development", + "com.wearezeta.zclient.rc", + "com.wearezeta.zclient.ios-release", + "com.wearezeta.zclient.internal", + "com.wire.ios.bund.columnf.release", + "com.wire.bund.ios.c3", + "com.wearezeta.zclient.ios-playground", + "com.wire.bund.ios.beta.column3", + "com.wearezeta.zclient.alpha", + "com.wire.bund.ios.beta", + "com.wire.bund.ios.column3", + "com.wearezeta.zclient.ios.beta" + }; + + public IOSDriverBuilder withCapabilities(Capabilities capabilities) { + this.capabilities = capabilities; + return this; + } + + public IOSDriverBuilder withProcessArgs(String... args) { + for (String arg : args) { + processArgs.add(arg); + } + return this; + } + + public IOSDriverBuilder withHub(URL url) { + this.hubUrl = url; + return this; + } + + public IOSDriverBuilder withFastLoginUser(ClientUser fastLoginUser) { + if (fastLoginUser != null) { + processArgs.addAll(Arrays.asList( + // https://wearezeta.atlassian.net/browse/ZIOS-6747 + "--loginemail=" + fastLoginUser.getEmail(), + "--loginpassword=" + fastLoginUser.getPassword() + )); + } + return this; + } + + public IOSDriverBuilder withLogListener(Consumer logListenerHandler) { + this.logListenerHandler = logListenerHandler; + return this; + } + + public IOSDriverBuilder withFullReset(boolean fullReset) { + this.fullReset = fullReset; + return this; + } + + public void withMicrophoneAccess() { + allowMicrophoneAccess = true; + } + + public void withCameraAccess() { + allowCameraAccess = true; + } + + public void withAccessToAllPhotos() { + allowAccessToAllPhotos = true; + } + + public void withNotifications() { + allowNotifications = true; + } + + public void withUninstallingAllVersionsOfWire() { + uninstallAllOtherVersions = true; + } + + public WebDriver build() { + if (hubUrl == null) { + throw new RuntimeException("No hub url specified."); + } + + if (capabilities == null) { + throw new RuntimeException("No capabilities specified."); + } + + if (!processArgs.contains("-BackendEnvironmentTypeOverrideKey")) { + String backendType = Config.current().getBackendType(this.getClass()); + processArgs.add("-BackendEnvironmentTypeOverrideKey"); + if (backendType.equals("qa-column-1")) { + // Using staging here because qa-column-1 is configured in the backend bundle for staging + // See: https://github.com/wireapp/wire-ios-build-assets/blob/master/BK-CI-configuration/AppStore/Backend.bundle/staging.json + processArgs.add("staging"); + } else if (backendType.equals("qa-column-3")) { + // See: https://github.com/wireapp/wire-ios-build-assets/blob/master/COLUMN3-CI-configuration/RC/Backend.bundle/staging.json + processArgs.add("staging"); + } else { + processArgs.add(backendType); + } + } + + // TODO: Find a better way for adding this capability + final JSONObject processArguments = new JSONObject(); + processArguments.put("args", processArgs); + // Enable logging for AVS and calling modules by default + processArguments.put("env", ImmutableMap.of("ZMLOG_TAGS", "AVS,calling")); + extraCapabilities.setCapability("appium:processArguments", processArguments.toString()); + + if (fullReset) { + // Set full reset option (app is uninstalled) if needed + extraCapabilities.setCapability("appium:fullReset", true); + // Uninstall app before and after test on real device / Destroy Simulator before and after test + // (this needs to be set because after step is skipped if resetOnSessionStartOnly = true) + extraCapabilities.setCapability("appium:resetOnSessionStartOnly", false); + } else { + // reset app but leave simulator running (might not work. see https://github.com/appium/appium/issues/18814) + extraCapabilities.setCapability("appium:noReset", false); + extraCapabilities.setCapability("appium:fullReset", false); + extraCapabilities.setCapability("appium:resetOnSessionStartOnly", true); + } + + JSONObject permissions = new JSONObject(); + + if (allowLocation) { + permissions.put("location", "yes"); + } + + if (allowMicrophoneAccess) { + permissions.put("microphone", "yes"); + } + + if (allowCameraAccess) { + permissions.put("camera", "yes"); + } + + if (allowAccessToAllPhotos) { + permissions.put("photos", "yes"); + } + + if (allowNotifications) { + permissions.put("notifications", "yes"); + } + + if (allowTouchID) { + permissions.put("faceid", "yes"); + } + + // Use applesimutils for controlling permissions + // See https://github.com/wix/AppleSimulatorUtils#usage for permission names + if (allowLocation || allowMicrophoneAccess || allowCameraAccess || allowAccessToAllPhotos || allowNotifications) { + JSONObject bundles = new JSONObject(); + bundles.put(Lifecycle.getBundleId(), permissions); + extraCapabilities.setCapability("appium:permissions", bundles.toString()); + } + + // merge all extra capabilites into the capabilites + capabilities.merge(extraCapabilities); + + log.info(String.format("Creating webdriver with capabilities: %s", capabilities)); + + final IOSDriver iosDriver = new IOSDriver(hubUrl, capabilities); + + iosDriver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)); + iosDriver.setClipboardText("wire"); + + if (uninstallAllOtherVersions) { + log.info("Remove all version except with bundle id " + Lifecycle.getBundleId()); + for (String bundleID : bundleIDs) { + if (!bundleID.equals(Lifecycle.getBundleId())) { + iosDriver.removeApp(bundleID); + } + } + } + + //start iPad testcases in Landscape by default + if (Config.current().isTablet(getClass())) { + (iosDriver).rotate( + ScreenOrientation.LANDSCAPE); + } + + String udid = iosDriver.getSessionId().toString(); + log.info("sessionDetail: " + udid); + + return iosDriver; + } + +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/IOSTestContext.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/IOSTestContext.java new file mode 100644 index 00000000000..2e411f70f71 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/IOSTestContext.java @@ -0,0 +1,361 @@ +package com.wearezeta.auto.ios.common; + +import com.wearezeta.auto.common.*; +import com.wearezeta.auto.common.backend.BackendConnections; +import com.wearezeta.auto.common.calling2.v1.UiCallingStatTracker; +import com.wearezeta.auto.common.log.LogListener; +import com.wearezeta.auto.common.log.ZetaLogger; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wire.qa.picklejar.engine.gherkin.model.Scenario; +import io.appium.java_client.ios.IOSDriver; +import org.json.JSONObject; +import org.openqa.selenium.WebDriver; + +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Future; +import java.util.function.Supplier; +import java.util.logging.Logger; + +public class IOSTestContext extends CommonTestContext { + + private static final Logger log = ZetaLogger.getLog(IOSTestContext.class.getSimpleName()); + + private final String testname; + private static final String CALLING_STATS = "@call_stats"; + public static final Timedelta NO_EXPIRATION = Timedelta.ofSeconds(0); + private final IOSDriverBuilder driverBuilder; + + private WebDriver driver; + private PagesCollection pagesCollection = null; + private final Pinger pinger; + + private LogListener deviceLogListener; + private UiCallingStatTracker uiCallingStatTracker; + private final Scenario scenario; + private final Map> EPHEMERAL_TIMEOUTS_MAP = new HashMap<>(); + private List additionalScreenshots = new ArrayList<>(); + + // remember states + private ElementState likeIconState = null; + private ElementState profilePictureState = null; + private ElementState colorPickerState = null; + private String currentDeviceId = null; + private Future activationMessage = null; + private Future verificationMessage = null; + private Future accountRemovalConfirmation; + private ClientUser userToRegister = null; + private String rememberedCertificate = null; + + public IOSTestContext(Scenario scenario, boolean useSpecialEmail, IOSDriverBuilder driverBuilder) { + super(useSpecialEmail); + this.uiCallingStatTracker = new UiCallingStatTracker(); + this.scenario = scenario; + this.pinger = new Pinger(this); + this.testname = scenario.getName(); + this.driverBuilder = driverBuilder; + } + + public IOSDriver getDriver() { + if (!isDriverCreated()) { + log.info("Driver is not created yet. Using driver builder..."); + this.driver = driverBuilder.build(); + } + return (IOSDriver) this.driver; + } + + public boolean isDriverCreated() { + return driver != null; + } + + public void doFullReset() { + if (!isDriverCreated()) { + driverBuilder.withFullReset(true); + } else { + throw new RuntimeException("Driver was already created. Cannot add capabilities anymore."); + } + } + + public void startAppOnProductionBackend() { + if (!isDriverCreated()) { + driverBuilder.withProcessArgs("-BackendEnvironmentTypeOverrideKey", "production"); + log.info("Starting app on Production backend"); + } else { + throw new RuntimeException("Driver was already created. Cannot add capabilities anymore."); + } + } + + public void uninstallAllVersionsOfWire() { + if (!isDriverCreated()) { + driverBuilder.withUninstallingAllVersionsOfWire(); + } else { + throw new RuntimeException("Cannot uninstall other versions of Wire after driver was created."); + } + } + + public void enrollSimulatorTouchID() { + getDriver().toggleTouchIDEnrollment(true); + } + + public void setFastLoginUser(ClientUser fastLoginUser) { + if (!isDriverCreated()) { + driverBuilder.withFastLoginUser(fastLoginUser); + } else { + throw new RuntimeException("Driver was already created. Cannot add capabilities anymore."); + } + } + + public void allowMicrophoneAccess() { + if (!isDriverCreated()) { + driverBuilder.withMicrophoneAccess(); + } else { + throw new RuntimeException("Cannot allow permissions after driver was created."); + } + } + + public void allowCameraAccess() { + if (!isDriverCreated()) { + driverBuilder.withCameraAccess(); + } else { + throw new RuntimeException("Cannot allow permissions after driver was created."); + } + } + + public void allowAccessToAllPhotos() { + if (!isDriverCreated()) { + driverBuilder.withAccessToAllPhotos(); + } else { + throw new RuntimeException("Cannot allow permissions after driver was created."); + } + } + + public PagesCollection getPagesCollection() { + if (pagesCollection == null) { + log.info("Pages collection not initialized yet..."); + try { + // The driver needs to be finally initialized here so that the method isDriverCreated() works in the context + getDriver(); + } catch (Exception e) { + log.severe("Driver initialization failed"); + throw new RuntimeException("Driver initialization failed: " + e.getMessage(), e); + } + pagesCollection = new PagesCollection(this.driver); + } + return pagesCollection; + } + + public void reset() { + // noop + } + + public void startPinging() { + if(isDriverCreated()) { + pinger.startPinging(); + } + } + + public void stopPinging() { + if(isDriverCreated()) { + pinger.stopPinging(); + } + } + + /** + * From MobileTestContext + */ + + public boolean isRealDevice() { + return !Config.current().isSimulator(getClass()); + } + + public Scenario getScenario() { + return scenario; + } + + public Optional getLogListener() { + return Optional.ofNullable(deviceLogListener); + } + + public void setLogListener(LogListener logListener) { + this.deviceLogListener = logListener; + } + + public UiCallingStatTracker getUiCallingStatTracker() { + return uiCallingStatTracker; + } + + public boolean isCallingTrackerEnabled() { + return scenario.hasTag(CALLING_STATS); + } + + public void printCallingStatsIfEnabled() { + try { + if (isCallingTrackerEnabled()) { + UiCallingStatTracker tracker = getUiCallingStatTracker(); + log.info(String.join("\n", tracker.getSummary())); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + public Timedelta getSelfDeletingMessageTimeout(String userAlias, String conversationName) { + final ClientUser user = getUsersManager().findUserByNameOrNameAlias(userAlias); + + // only team users support enforced self-deleting messages + if (user.getTeamId() != null) { + JSONObject selfDeletingMessagesSettings = BackendConnections.get(user).getSelfDeletingMessagesSettings(user); + + if (selfDeletingMessagesSettings.getString("status").equals("enabled")) { + int timeoutInSeconds = selfDeletingMessagesSettings.getJSONObject("config").getInt("enforcedTimeoutSeconds"); + if (timeoutInSeconds != 0) { + // timeout value is enforced in team settings + return Timedelta.ofSeconds(timeoutInSeconds); + } + } else { + // timeout is disabled + return NO_EXPIRATION; + } + } + + // Personal user or team user without set enforced self-deleting message setting + + // follow conversation settings if there is any + conversationName = getUsersManager().replaceAliasesOccurrences(conversationName, + ClientUsersManager.FindBy.NAME_ALIAS); + int conversationMessageTimer = getCommonSteps().getConversationMessageTimer(user, conversationName); + if (conversationMessageTimer > 0) { + return Timedelta.ofMillis(conversationMessageTimer); + } + + // otherwise check for local/client-side self-deleting message timeout + return getLocalSelfDeletingMessageTimeout(userAlias, conversationName); + } + + private Timedelta getLocalSelfDeletingMessageTimeout(String userAlias, String conversationName) { + final ClientUser user = getUsersManager().findUserByNameOrNameAlias(userAlias); + conversationName = getUsersManager().replaceAliasesOccurrences(conversationName, + ClientUsersManager.FindBy.NAME_ALIAS); + final String conversationId = BackendConnections.get(user).getConversationByName(user, conversationName).getId(); + if (EPHEMERAL_TIMEOUTS_MAP.containsKey(user) && EPHEMERAL_TIMEOUTS_MAP.get(user).containsKey(conversationId)) { + return EPHEMERAL_TIMEOUTS_MAP.get(user).get(conversationId); + } + return NO_EXPIRATION; + } + + /** + * From TestContext + */ + + public void addAdditionalScreenshots(BufferedImage screenshot) { + additionalScreenshots.add(screenshot); + } + + public void enableFederation() { + if (!isDriverCreated()) { + driverBuilder.withProcessArgs("-FederationEnabled", "1"); + log.info("Starting app with Federation enabled"); + } else { + throw new RuntimeException("Driver was already created. Cannot add federation enabled capabilities anymore."); + } + } + + public void enableApiVersioning(int version) { + if (!isDriverCreated()) { + driverBuilder.withProcessArgs(String.format("--preferred-api-version=%s", version)); + log.info(String.format("Starting app with Api versioning %s enabled", version)); + } else { + throw new RuntimeException("Driver was already created. Cannot add federation enabled capabilities anymore."); + } + } + + public void enableMLSSupport() { + if (!isDriverCreated()) { + driverBuilder.withProcessArgs("--enable-mls-support", "1"); + log.info("Starting app with mls support enabled"); + } else { + throw new RuntimeException("Driver was already created. Cannot add federation enabled capabilities anymore."); + } + } + + public ElementState getLikeIconState() { + return likeIconState; + } + + public void setLikeIconState(Supplier screenshotFunction) throws Exception { + this.likeIconState = new ElementState(screenshotFunction); + likeIconState.remember(); + } + + public ElementState getProfilePictureState() { + return profilePictureState; + } + + public void setProfilePictureState(Supplier screenshotFunction) throws Exception { + this.profilePictureState = new ElementState(screenshotFunction); + this.profilePictureState.remember(); + } + + public ElementState getColorPickerState() { + return colorPickerState; + } + + public void setColorPickerState(Supplier screenshotFunction) throws Exception { + this.colorPickerState = new ElementState(screenshotFunction); + this.colorPickerState.remember(); + } + + public String getCurrentDeviceId() { + return this.currentDeviceId; + } + + public void setCurrentDeviceId(String deviceId) { + this.currentDeviceId = deviceId; + } + + public Future getActivationMessage() { + return activationMessage; + } + + public Future getVerificationMessage() { + return verificationMessage; + } + + public void setActivationMessage(Future message) { + this.activationMessage = message; + } + + public void setVerificationMessage(Future message) { + this.verificationMessage = message; + } + + public Future getAccountRemovalConfirmation() { + return accountRemovalConfirmation; + } + + public void setAccountRemovalConfirmation(Future message) { + this.accountRemovalConfirmation = message; + } + + public ClientUser getUserToRegister() { + return userToRegister; + } + + public void setUserToRegister(ClientUser user) { + this.userToRegister = user; + } + + public String getRememberedCertificate() { + return rememberedCertificate; + } + + public void setRememberedCertificate(String remembered) { + this.rememberedCertificate = remembered; + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/IPAInspector.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/IPAInspector.java new file mode 100644 index 00000000000..b263b6413cd --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/IPAInspector.java @@ -0,0 +1,62 @@ +package com.wearezeta.auto.ios.common; + +import com.dd.plist.NSDictionary; +import com.dd.plist.PropertyListParser; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +public class IPAInspector { + + private byte[] plist; + private String bundleId; + + public IPAInspector(String path) { + final File fPath = new File(path); + if (path.toLowerCase().endsWith(".app")) { + // no need to extract. Just load Info.plist as byte[] + final File plistFile = new File(fPath.getAbsolutePath(), "Info.plist"); + try { + plist = Files.readAllBytes(plistFile.toPath()); + } catch (IOException e) { + throw new RuntimeException("Could not read " + fPath.getAbsolutePath(), e); + } + } else if (path.toLowerCase().endsWith(".ipa")) { + // extract Info.plist from .ipa + try { + ZipFile zipFile = new ZipFile(fPath); + ZipEntry zipEntry = zipFile.getEntry("Payload/Wire.app/Info.plist"); + InputStream is = zipFile.getInputStream(zipEntry); + plist = new byte[is.available()]; + is.read(plist); + is.close(); + } catch (Exception e) { + throw new RuntimeException("Could not extract Info.plist from " + fPath.getAbsolutePath(), e); + } + } else { + throw new IllegalArgumentException( + String.format("Only .ipa and .app packages are supported. %s is given instead", path)); + } + } + + public String getBundleId() { + if (bundleId == null) { + bundleId = parseProperty(plist, "CFBundleIdentifier"); + } + return this.bundleId; + } + + private static String parseProperty(byte[] plist, String propertyName) { + final NSDictionary rootDict; + try { + rootDict = (NSDictionary) PropertyListParser.parse(plist); + } catch (Exception e) { + throw new IllegalStateException(e); + } + return rootDict.objectForKey(propertyName).toString(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/Lifecycle.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/Lifecycle.java new file mode 100644 index 00000000000..d22f65710e3 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/Lifecycle.java @@ -0,0 +1,407 @@ +package com.wearezeta.auto.ios.common; + +import com.wearezeta.auto.common.Config; +import com.wearezeta.auto.common.Platform; +import com.wearezeta.auto.common.TestScreenshotHelper; +import com.wearezeta.auto.common.calling2.v1.UiCallingStatTracker; +import com.wearezeta.auto.common.driver.AppiumLocalServer; +import com.wearezeta.auto.common.log.LogListener; +import com.wearezeta.auto.common.log.LogsConverter; +import com.wearezeta.auto.common.log.ZetaLogger; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.misc.WireBlacklist; +import com.wearezeta.auto.common.testiny.ScenarioResultToTestinyTransformer; +import com.wearezeta.auto.common.testiny.TestinySync; +import com.wearezeta.auto.ios.pages.IOSPage; +import com.wire.qa.picklejar.engine.TestContext; +import com.wire.qa.picklejar.engine.annotations.AfterEachScenario; +import com.wire.qa.picklejar.engine.annotations.AfterEachStep; +import com.wire.qa.picklejar.engine.annotations.BeforeEachScenario; +import com.wire.qa.picklejar.engine.annotations.BeforeEachStep; +import com.wire.qa.picklejar.engine.gherkin.model.Embeddings; +import com.wire.qa.picklejar.engine.gherkin.model.Scenario; +import com.wire.qa.picklejar.engine.gherkin.model.Step; +import org.openqa.selenium.OutputType; +import org.openqa.selenium.logging.LogEntry; +import org.openqa.selenium.remote.DesiredCapabilities; + +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Duration; +import java.util.*; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import static com.wearezeta.auto.common.CommonUtils.isRunningOnJenkinsNode; + +public class Lifecycle { + + private static final Duration DEFAULT_COMMAND_TIMEOUT = Duration.ofMinutes(10); + + private static Logger log = ZetaLogger.getLog(Lifecycle.class.getSimpleName()); + + private static final String TAG_NAME_BLACKLIST = "@blacklist"; + private static final String SSO = "@sso"; + private static final String SCIM = "@scim"; + + private static final Platform CURRENT_PLATFORM = Platform.iOS; + + private final TestScreenshotHelper screenshotHelper = new TestScreenshotHelper(); + + private static final int MAX_SCREENSHOT_WIDTH = 800; + private static final int MAX_SCREENSHOT_HEIGHT = 400; + + private static boolean isOnGrid() { + return Config.current().isOnGrid(Lifecycle.class); + } + + private static String getUrl() { + return Config.current().getAppiumUrl(Lifecycle.class); + } + + public static String getAppPath() { + return Config.current().getIosApplicationPath(Lifecycle.class); + } + + public static String getOldAppPath() { + return Config.current().getOldAppPath(Lifecycle.class); + } + + public static String getBundleId() { + if (isRunningOnJenkinsNode() && isOnGrid()) { + return Config.current().getBundleId(Lifecycle.class); + } else { + return new IPAInspector(getAppPath()).getBundleId(); + } + } + + private static String getAppName() { + return Config.current().getIOSAppName(Lifecycle.class); + } + + @BeforeEachScenario + public TestContext setup(Scenario scenario) throws Exception { + + IOSDriverBuilder driverBuilder = new IOSDriverBuilder(); + + boolean useSpecialEmail = false; + + if (scenario.hasTag("useSpecialEmail")) { + useSpecialEmail = true; + } + + // The appPath is the path to the ipa on the node where the simulator runs + String appPath = getAppPath(); + + // Start appium server if this test is run manually and appium not already running (for e.g. via appium-desktop) + if (!isOnGrid() && !AppiumLocalServer.isRunning()) { + AppiumLocalServer.start(); + } + + // TODO: Can be removed once we migrated to the new grid + if (isRunningOnJenkinsNode() && !isOnGrid()) { + // Add hostname to scenario description (yellow line) + InetAddress ip = InetAddress.getLocalHost(); + String hostname = ip.getHostName(); + scenario.setDescription(hostname + ": "); + } + + final boolean isRealDevice = !Config.current().isSimulator(getClass()); + + final DesiredCapabilities capabilities = new DesiredCapabilities(); + capabilities.setCapability("newCommandTimeout", DEFAULT_COMMAND_TIMEOUT.getSeconds()); + capabilities.setCapability("app", appPath); + // Only set the platformVersion if you have multiple sim runtimes installed + //capabilities.setCapability("appium:platformVersion", getPlatformVersion()); + + String bundleId = getBundleId(); + log.info("bundleId: " + bundleId); + capabilities.setCapability("appium:automationName", "XCUITest"); + capabilities.setCapability("appium:bundleId", bundleId); + capabilities.setCapability("appium:autoLaunch", true); + capabilities.setCapability("appium:clearSystemFiles", true); + capabilities.setCapability("appium:appName", getAppName()); + capabilities.setCapability("appium:language", "en"); + capabilities.setCapability("appium:locale", "en_US"); + + // Increase the default timeout to start up a simulator from 120s to 240s + capabilities.setCapability("appium:simulatorStartupTimeout", 240000); + + //Temporary enable full XCode Log + capabilities.setCapability("appium:showXcodeLog", true); + + if (!isOnGrid()) { + // If deviceName is set appium tries to match the first available simulator/device with the given name + capabilities.setCapability("deviceName", + Config.current().getDeviceName(this.getClass())); + } + + if (isRealDevice && !isOnGrid()) { + capabilities.setCapability("appium:udid", RealDevice.getUDID()); + } + + // Disable update notifications from Hockey + driverBuilder.withProcessArgs("-use-app-center", "0"); + + driverBuilder.withProcessArgs("--disable-interactive-keyboard-dismissal"); + + driverBuilder.withProcessArgs("--disable-call-quality-survey"); + + driverBuilder.withProcessArgs("--persist-backend-type"); + + // https://wearezeta.atlassian.net/browse/ZIOS-5769 + driverBuilder.withProcessArgs("--disable-autocorrection"); + + driverBuilder.withProcessArgs("--debug-log=Network,SessionManager,event-processing,SyncStatus,OperationStatus," + + "Push,cryptobox,background-activity,ephemeral,Authentication"); + + driverBuilder.withProcessArgs("-com.apple.CoreData.ConcurrencyDebug", "1"); + + if (!scenario.hasTag("@notifications")) { + driverBuilder.withProcessArgs("--disable-push-alert"); + } + + driverBuilder.withProcessArgs("-UseAnalytics", "0"); + + String url = getUrl(); + log.info("URL: " + url); + + final URL serverAddress; + try { + serverAddress = new URL(url); + } catch (MalformedURLException e) { + throw new IllegalStateException(e); + } + + driverBuilder.withHub(serverAddress); + + driverBuilder.withCapabilities(capabilities); + + final LogListener logListener = new LogListener(); + driverBuilder.withLogListener(logListener::addLogMessage); + + IOSTestContext context = new IOSTestContext(scenario, useSpecialEmail, driverBuilder); + context.setLogListener(logListener); + return context; + } + + @BeforeEachStep + public void beforeEachStep(IOSTestContext context, Scenario scenario, Step step) { + + } + + @AfterEachStep + public void afterEachStep(IOSTestContext context, Scenario scenario, Step step) { + // Make screenshot + try { + if (context.isDriverCreated() && !"SKIPPED".equalsIgnoreCase(step.getResult().getStatus())) { + byte[] screenshot = context.getDriver().getScreenshotAs(OutputType.BYTES); + // Jenkins 1: screenshotHelper.saveScreenshot(step, scenario, scenario.getCurrentFeatureName(), screenshot); + Embeddings embedding = new Embeddings(screenshotHelper.encodeToBase64(screenshot, MAX_SCREENSHOT_WIDTH, MAX_SCREENSHOT_HEIGHT), "image/jpeg"); + step.addEmbedding(embedding); + } + } catch (Exception e) { + log.warning("Could not make a screenshot: " + e.getMessage()); + } + + // Attach logs + try { + if (context.isDriverCreated() && step.getResult().getErrorMessage() != null && !step.getResult().getErrorMessage().isEmpty()) { + + log.info("Get logs..."); + + IOSPage page = context.getPagesCollection().getPage(IOSPage.class); + + final Map> attachmentLogsData = new LinkedHashMap<>(); + final List applicationLogs = page.getWireLogs(); + final List deviceLogs = context.getLogListener() + .map(LogListener::getLogs) + .orElse(Collections.emptyList()); + final List crashLogs = page.getCrashLogs().getAll() + .stream() + .map(LogEntry::getMessage) + .collect(Collectors.toList()); + final List serverLogs = page.getAppiumLogs().getAll() + .stream() + .map(LogEntry::getMessage) + .collect(Collectors.toList()); + attachmentLogsData.put("crash.log", crashLogs); + attachmentLogsData.put("appium.log", serverLogs); + attachmentLogsData.put("system.txt", deviceLogs); + attachmentLogsData.put("application.txt", applicationLogs); + + if (context.isCallingTrackerEnabled()) { + UiCallingStatTracker tracker = context.getUiCallingStatTracker(); + attachmentLogsData.put("callingStats.log", tracker.getSummary()); + } + + if (!attachmentLogsData.isEmpty()) { + Embeddings embedding = new Embeddings( + LogsConverter.toZipBytearray(attachmentLogsData), + LogsConverter.ZIP_MIME_TYPE); + step.addEmbedding(embedding); + } + } + } catch (Exception e) { + log.warning("Could not attach logs: " + e.getMessage()); + } + } + + @AfterEachScenario + public void tearDown(IOSTestContext context, Scenario scenario) { + try { + TestinySync.syncExecutedScenarioWithTestiny(scenario.getName(), + new ScenarioResultToTestinyTransformer(scenario).transform(), + scenario.getTags()); + } catch (Exception e) { + log.warning(e.getMessage()); + } + + try { + if (context != null && context.isDriverCreated()) { + if (context.isRealDevice()) { + context.getDriver().switchTo().alert().accept(); + } else { + context.getPagesCollection().getPage(IOSPage.class).acceptAlert(Timedelta.ofSeconds(1)); + } + } + } catch (Exception e) { + log.warning(e.getMessage()); + } + + if (scenario.hasTag(SSO) || scenario.hasTag(SCIM)) { + try { + if (context != null && context.isDriverCreated()) { + context.getPagesCollection().getPage(IOSPage.class).setClipboard(" "); + } + } catch (Exception e) { + log.warning("Could not empty clipboard: " + e.getMessage()); + } + } + + try { + if (!scenario.hasTag("maintenance")) { + log.fine("Delete application in okta"); + context.getCommonSteps().cleanUpOkta(); + } + } catch (Exception e) { + log.warning(e.getMessage()); + } + try { + if (scenario.hasTag(TAG_NAME_BLACKLIST)) { + WireBlacklist.uploadDefault(CURRENT_PLATFORM); + } + if (scenario.hasTag("notifications")) { + if (context != null && context.isDriverCreated()) { + context.getPagesCollection().getPage(IOSPage.class).pressHomeButton(); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + // Driver + try { + log.fine("Closing webdriver"); + if (context != null && context.isDriverCreated()) { + context.getDriver().quit(); + } + } catch (Exception e) { + log.warning("Closing webdriver failed: " + e.getMessage()); + e.printStackTrace(); + } + // Federation + try { + for (String fromBackend: context.getCommonSteps().defederatedBackends.keySet()) { + log.info("Repair defederation"); + context.getCommonSteps().federateBackends(fromBackend, + context.getCommonSteps().defederatedBackends.get(fromBackend)); + } + } catch (Exception e) { + log.warning(e.getMessage()); + } + try { + for (String backendName: context.getCommonSteps().touchedFederator) { + log.info("Turn federator back on"); + context.getCommonSteps().turnFederatorInBackendOn(backendName); + } + } catch (Exception e) { + log.warning(e.getMessage()); + } + try { + for (String backendName: context.getCommonSteps().touchedBrig) { + log.info("Turn brig back on"); + context.getCommonSteps().turnBrigInBackendOn(backendName); + } + } catch (Exception e) { + log.warning(e.getMessage()); + } + try { + for (String backendName: context.getCommonSteps().touchedGalley) { + log.info("Turn galley back on"); + context.getCommonSteps().turnGalleyInBackendOn(backendName); + } + } catch (Exception e) { + log.warning(e.getMessage()); + } + try { + for (String backendName: context.getCommonSteps().touchedIngress) { + log.info("Turn ingress back on"); + context.getCommonSteps().turnIngressInBackendOn(backendName); + } + } catch (Exception e) { + log.warning(e.getMessage()); + } + try { + for (String backendName: context.getCommonSteps().touchedSFT) { + log.info("Turn SFT back on"); + context.getCommonSteps().turnSFTInBackendOn(backendName); + } + } catch (Exception e) { + log.warning(e.getMessage()); + } + // Local appium + try { + if (!isOnGrid()) { + log.fine("Stopping appium"); + AppiumLocalServer.stop(); + } + } catch (Exception e) { + log.warning("Stopping appium failed: " + e.getMessage()); + } + + try { + if (!isOnGrid()) { + // TODO: This breaks parallel runs and ends in a loop + context.printCallingStatsIfEnabled(); + } + if (context != null) { + context.reset(); + } + } catch (Exception e) { + log.severe("Could not reset context: " + e.getMessage()); + } + + try { + log.fine("Releasing Testservice instances"); + context.getCommonSteps().cleanUpTestServiceInstances(); + } catch (Exception e) { + log.warning(e.getMessage()); + } + try { + log.fine("Cleaning up calling instances"); + context.getCallingManager().cleanup(); + } catch (Exception e) { + log.warning(e.getMessage()); + } + try { + if (!scenario.hasTag("maintenance")) { + log.fine("Cleanup backends"); + context.getCommonSteps().cleanUpBackends(); + } + } catch (Exception e) { + log.warning(e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/PagesCollection.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/PagesCollection.java new file mode 100644 index 00000000000..3578a3aa7c6 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/PagesCollection.java @@ -0,0 +1,32 @@ +package com.wearezeta.auto.ios.common; + +import com.wearezeta.auto.common.log.ZetaLogger; +import io.appium.java_client.pagefactory.AppiumFieldDecorator; +import java.util.logging.Logger; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.support.PageFactory; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +public class PagesCollection { + private final Logger log = ZetaLogger.getLog(this.getClass().getSimpleName()); + + private final WebDriver driver; + + public PagesCollection(WebDriver driver) { + this.driver = driver; + } + + public T getPage(Class pageClass) { + log.info("Page: " + pageClass.getSimpleName()); + try { + final Constructor constructor = pageClass.getConstructor(WebDriver.class); + T instance = pageClass.cast(constructor.newInstance(this.driver)); + PageFactory.initElements(new AppiumFieldDecorator(this.driver), instance); + return instance; + } catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException("Could not instantiate page " + pageClass, e); + } + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/Pinger.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/Pinger.java new file mode 100644 index 00000000000..e2bc6ece595 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/Pinger.java @@ -0,0 +1,56 @@ +package com.wearezeta.auto.ios.common; + +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +import com.wearezeta.auto.common.log.ZetaLogger; +import java.util.logging.Logger; + +public class Pinger { + + public static final Logger log = ZetaLogger.getLog(Pinger.class.getSimpleName()); + + // Should not be higher than session timeout on selenium grid + private static final int PINGER_POLLING_PERIOD = 30; + + private final ScheduledThreadPoolExecutor PING_EXECUTOR = new ScheduledThreadPoolExecutor(1); + private ScheduledFuture RUNNING_PINGER; + private IOSTestContext context; + private final Runnable PINGER = new Runnable() { + @Override + public void run() { + try { + log.fine("Pinging driver"); + context.getDriver().manage().window().getSize(); + } catch (Exception ex) { + log.warning(String.format("Could not ping driver: %s", ex.getMessage())); + } + } + }; + + public Pinger(IOSTestContext context) { + PING_EXECUTOR.setRemoveOnCancelPolicy(true); + this.context = context; + } + + public void startPinging() { + if (RUNNING_PINGER == null) { + log.info("Scheduling pinger task"); + RUNNING_PINGER = PING_EXECUTOR.scheduleAtFixedRate(PINGER, 0, PINGER_POLLING_PERIOD, TimeUnit.SECONDS); + } else { + log.warning("Driver pinger is already running - Please stop the driver pinger before starting it again"); + } + } + + public void stopPinging() { + if (RUNNING_PINGER != null) { + if (!RUNNING_PINGER.cancel(true)) { + log.warning("Could not stop driver pinger"); + } + RUNNING_PINGER = null; + } else { + log.warning("No pinger to stop"); + } + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/RealDevice.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/RealDevice.java new file mode 100644 index 00000000000..c35aaa2047a --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/common/RealDevice.java @@ -0,0 +1,49 @@ +package com.wearezeta.auto.ios.common; + +import com.wearezeta.auto.common.CommonUtils; +import com.wearezeta.auto.common.Config; + +import java.util.*; + +public class RealDevice { + private static Optional udid = Optional.empty(); + + private static RealDevice instance = null; + + public static synchronized RealDevice getInstance() { + if (instance == null) { + instance = new RealDevice(); + } + return instance; + } + + public static String getUDID() { + if(!udid.isPresent()) { + udid = Config.current().getUDID(RealDevice.class); + } + + if (!udid.isPresent()) { + for (String deviceName : new String[]{"iPhone", "iPad"}) { + final String result; + try { + result = CommonUtils.executeOsXCommandWithOutput(new String[]{ + "/bin/bash", + "-c", + "system_profiler SPUSBDataType | sed -n '/" + + deviceName + + "/,/Serial/p' | grep 'Serial Number:' | awk -F ': ' '{print $2}'"}).trim(); + } catch (Exception e) { + throw new RuntimeException(e); + } + if (result.length() > 0) { + udid = Optional.of(result); + break; + } + } + } + return udid.orElseThrow( + () -> new IllegalStateException("No connected iOS devices can be detected") + ); + } + +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ActionsSheetPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ActionsSheetPage.java new file mode 100644 index 00000000000..80eb9678d87 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ActionsSheetPage.java @@ -0,0 +1,60 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import java.util.function.Function; + +public class ActionsSheetPage extends IOSPage { + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeSheet/**/XCUIElementTypeButton[`visible == 1 AND label != ''`][-1]") + private WebElement declineActionButton; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeButton[`visible == 1 AND label != ''`]") + private WebElement confirmActionButton; + + private static final Function predicateNewActionButtonByName = text -> + MobileBy.iOSNsPredicateString(String.format("type == 'XCUIElementTypeButton' AND label == '%s'", text)); + + private static final Function predicateActionSheetLabelByText = text -> + String.format("type == 'XCUIElementTypeStaticText' AND label CONTAINS '%s'", text); + + public ActionsSheetPage(WebDriver driver) { + super(driver); + } + + private By getButtonLocatorByName(String name) { + return predicateNewActionButtonByName.apply(name); + } + + public void tapMenuItem(String name) { + getDriver().findElement(getButtonLocatorByName(name)).click(); + } + + public boolean isItemVisible(String name) { + return getDriver().findElement(getButtonLocatorByName(name)).isDisplayed(); + } + + public boolean isItemInvisible(String name) { + return isLocatorInvisible(getButtonLocatorByName(name)); + } + + public boolean isActionSheetContainsText(String text) { + return getDriver().findElement(MobileBy.iOSNsPredicateString(predicateActionSheetLabelByText.apply(text))) + .isDisplayed(); + } + + public boolean isActionSheetDoesNotContainsText(String text) { + return isLocatorInvisible(MobileBy.iOSNsPredicateString(predicateActionSheetLabelByText.apply(text))); + } + + public void confirm() { + confirmActionButton.click(); + } + + public void decline() { + declineActionButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ArchivePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ArchivePage.java new file mode 100644 index 00000000000..cac2ea889aa --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ArchivePage.java @@ -0,0 +1,43 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.common.misc.Timedelta; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import java.util.function.Function; + +public class ArchivePage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "archiveCloseButton") + private WebElement closeArchiveButton; + + private static final String strNameConversation = "title"; + + private static final Function predicateStrConversationByLabel = text -> + MobileBy.iOSNsPredicateString(String.format("name == '%s' AND label == '%s'", strNameConversation, text)); + + public ArchivePage(WebDriver driver) { + super(driver); + } + + public void clickCloseArchivePageButton() { + closeArchiveButton.click(); + } + + public boolean isConversationInList(String name) { + final By locator = predicateStrConversationByLabel.apply(name); + return isLocatorDisplayed(locator, Timedelta.ofSeconds(5)); + } + + public boolean isConversationNotInList(String name) { + final By locator = predicateStrConversationByLabel.apply(name); + return isLocatorInvisible(locator, Timedelta.ofSeconds(5)); + } + + public void tapConversationsListItem(String name) { + final By locator = predicateStrConversationByLabel.apply(name); + getElement(locator).click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/BackupPasswordOverlayPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/BackupPasswordOverlayPage.java new file mode 100644 index 00000000000..4ab584ee81e --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/BackupPasswordOverlayPage.java @@ -0,0 +1,27 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class BackupPasswordOverlayPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "password input") + private WebElement passwordInput; + + @iOSXCUITFindBy(accessibility = "Next") + private WebElement nextButton; + + public BackupPasswordOverlayPage(WebDriver driver) { + super(driver); + } + + public void typePassword(String password) { + passwordInput.clear(); + passwordInput.sendKeys(password); + } + + public void tapNextButton() { + nextButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/BottomNavigationBarPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/BottomNavigationBarPage.java new file mode 100644 index 00000000000..fe101741c4b --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/BottomNavigationBarPage.java @@ -0,0 +1,51 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class BottomNavigationBarPage extends IOSPage { + + + @iOSXCUITFindBy(accessibility = "bottomBarRecentListButton") + private WebElement recentConversationsButton; + + /** + @iOSXCUITFindBy(accessibility = "bottomBarFolderListButton") + private WebElement folderButton; +*/ + @iOSXCUITFindBy(accessibility = "bottomBarArchivedButton") + private WebElement openArchiveButton; + + @iOSXCUITFindBy(accessibility = "gearshape") + WebElement settingsButton; + + public BottomNavigationBarPage(WebDriver driver) { + super(driver); + } + + + public void openArchivedConversations() { + tapElementWithRetryIfStillDisplayed(openArchiveButton); + } + + public boolean isArchiveButtonVisible() { + return openArchiveButton.isDisplayed(); + } + + public boolean isArchiveButtonInvisible() { + return isElementInvisible(openArchiveButton); + } + + /** public void tapGroupedConversationsButton() { + folderButton.click(); + } +*/ + public void tapRecentConversationsButton() { + recentConversationsButton.click(); + } + + public void tapSettingsButton() { + settingsButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CameraPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CameraPage.java new file mode 100644 index 00000000000..116aa3aa78d --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CameraPage.java @@ -0,0 +1,48 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class CameraPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Take Photo") + private WebElement takePhotoButton; + + @iOSXCUITFindBy(accessibility = "VideoCapture") + private WebElement takeVideoButton; + + @iOSXCUITFindBy(accessibility = "Use Video") + private WebElement useVideoButton; + + @iOSXCUITFindBy(accessibility = "Choose from Library") + private WebElement chooseFromLibrary; + + public CameraPage(WebDriver driver) { + super(driver); + } + + public void tapTakePhoto() { + takePhotoButton.click(); + } + + public void tapTakeVideo() { + takeVideoButton.click(); + } + + public void tapUseVideo() { + useVideoButton.click(); + } + + public boolean isTakePhotoButtonVisible() { + return takePhotoButton.isDisplayed(); + } + + public boolean isChooseFromLibraryVisible() { + return chooseFromLibrary.isDisplayed(); + } + + public void tapChooseFromLibrary() { + chooseFromLibrary.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CameraRollPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CameraRollPage.java new file mode 100644 index 00000000000..bce6b1ebefd --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CameraRollPage.java @@ -0,0 +1,26 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class CameraRollPage extends IOSPage { + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeScrollView/**/XCUIElementTypeImage[`label BEGINSWITH 'Photo'`][-1]") + private WebElement cameraRollPicture; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeScrollView/**/XCUIElementTypeImage[`label BEGINSWITH 'Photo'`]") + private WebElement firstCameraRollPicture; + + public CameraRollPage(WebDriver driver) { + super(driver); + } + + public void selectPicture() { + cameraRollPicture.click(); + } + + public void selectFirstPicture() { + firstCameraRollPicture.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CollectionPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CollectionPage.java new file mode 100644 index 00000000000..c250b055727 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CollectionPage.java @@ -0,0 +1,53 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import java.util.function.Function; + +public class CollectionPage extends IOSPage { + + @iOSXCUITFindBy(className = "XCUIElementTypeCollectionView") + private WebElement collectionViewRoot; + + @iOSXCUITFindBy(accessibility = "back") + private WebElement backButton; + + @iOSXCUITFindBy(accessibility = "close") + private WebElement closeButton; + + @iOSXCUITFindBy(accessibility = "fullScreenPage") + private WebElement fullScreenPage; + + private static final Function classChainStrPictureCollectionItemByIndex = idx -> + String.format("**/XCUIElementTypeCell[" + + "$type == 'XCUIElementTypeImage' AND name == 'image'$][%s]", idx); + + public CollectionPage(WebDriver driver) { + super(driver); + } + + public void tapPictureItemByIndex(int index, boolean isLongTap) { + final By locator = MobileBy.iOSClassChain(classChainStrPictureCollectionItemByIndex.apply(index)); + final WebElement dstElement = collectionViewRoot.findElement(locator); + if (isLongTap) { + longTapWithScript(dstElement, 50, 50); + } else { + tapAtTheCenterOfElement(dstElement); + } + } + + public boolean isFullScreenImagePreviewVisible() { + return fullScreenPage.isDisplayed(); + } + + public void tapBackButton() { + backButton.click(); + } + + public void tapCloseButton() { + closeButton.click(); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ContactsUiPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ContactsUiPage.java new file mode 100644 index 00000000000..29f755f5011 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ContactsUiPage.java @@ -0,0 +1,77 @@ +package com.wearezeta.auto.ios.pages; + +import java.util.function.Function; +import com.wearezeta.auto.common.misc.Timedelta; +import io.appium.java_client.MobileBy; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import org.openqa.selenium.WebDriver; + +public class ContactsUiPage extends IOSPage { + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'textViewSearch' AND visible == 1") + private WebElement searchInput; + + @iOSXCUITFindBy(accessibility = "Invite Others") + private WebElement inviteOthersButton; + + @iOSXCUITFindBy(accessibility = "Go back to conversation details") + private WebElement backButton; + + @iOSXCUITFindBy(accessibility = "Back") + private WebElement backButtonContactsUI; + + private static final Function classChainStrConvoCellByName = name -> + String.format("**/XCUIElementTypeCell[$type == 'XCUIElementTypeStaticText' AND label == '%s'$]", name); + + private static final Function classChainOpenButtonByConvoName = name -> MobileBy.iOSClassChain( + String.format("%s/XCUIElementTypeButton[`name == 'Open'`]", classChainStrConvoCellByName.apply(name))); + + public ContactsUiPage(WebDriver driver) { + super(driver); + } + + public boolean isSearchInputVisible() { + return searchInput.isDisplayed(); + } + + public void inputTextToSearch(String text) { + searchInput.click(); + searchInput.sendKeys(text); + } + + public boolean isContactVisible(String contact) { + final By locator = MobileBy.iOSClassChain(classChainStrConvoCellByName.apply(contact)); + return getDriver().findElement(locator).isDisplayed(); + } + + public void tapOpenButtonNextToUser(String contact) { + final By locator = classChainOpenButtonByConvoName.apply(contact); + getElement(locator).click(); + // Wait for animation + isLocatorInvisible(locator, Timedelta.ofSeconds(5)); + } + + public boolean isInviteButtonVisible() { + return inviteOthersButton.isDisplayed(); + } + + public void tapBackButton() { + if (isElementVisible(backButton)){ + backButton.click(); + } else { + backButtonContactsUI.click(); + } + } + + public void tapInviteOthersButton() { + inviteOthersButton.click(); + } + + public boolean isContactInvisible(String contact) { + final By locator = MobileBy.iOSClassChain(classChainStrConvoCellByName.apply(contact)); + return isLocatorInvisible(locator); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ConversationViewPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ConversationViewPage.java new file mode 100644 index 00000000000..505741ccbed --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ConversationViewPage.java @@ -0,0 +1,966 @@ +package com.wearezeta.auto.ios.pages; + +import com.wearezeta.auto.common.FilenameHelper; +import com.wearezeta.auto.common.misc.Timedelta; +import io.appium.java_client.MobileBy; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +import java.awt.image.BufferedImage; +import java.util.Optional; +import java.util.function.Function; + +public class ConversationViewPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "ConversationBackButton") + private WebElement conversationBackButton; + + @iOSXCUITFindBy(accessibility = "inputField") + private WebElement conversationInput; + + @iOSXCUITFindBy(accessibility = "Call") + private WebElement startCallButton; + + @iOSXCUITFindBy(accessibility = "audioCallBarButton") + private WebElement audioCallButton; + + @iOSXCUITFindBy(accessibility = "videoCallBarButton") + private WebElement videoCallButton; + + @iOSXCUITFindBy(accessibility = "Call anyway") + private WebElement callAnywayButton; + + @iOSXCUITFindBy(accessibility = "Cancel") + private WebElement cancelButton; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeStaticText[`name == \"Upgrade to Enterprise\"`]") + private WebElement upgradeAlert; + + @iOSXCUITFindBy(accessibility = "ImageCell") + private WebElement imageCell; + + @iOSXCUITFindBy(accessibility = "VideoCell") + private WebElement videoCell; + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'ReplyCell'") + private WebElement replyCell; + + @iOSXCUITFindBy(accessibility = "link-attachment") + private WebElement youtubePreview; + + @iOSXCUITFindBy(accessibility = "mentionButton") + private WebElement mentionButton; + + @iOSXCUITFindBy(accessibility = "sketchButton") + private WebElement sketchButton; + + @iOSXCUITFindBy(accessibility = "photoButton") + private WebElement addPictureButton; + + @iOSXCUITFindBy(accessibility = "gifButton") + private WebElement gifButton; + + @iOSXCUITFindBy(accessibility = "audioButton") + private WebElement audioMessageButton; + + @iOSXCUITFindBy(accessibility = "AudioActionButton") + private WebElement audioMessageActionButton; + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'AudioActionButton' AND value == 'Pause'") + private WebElement audioMessagePauseButton; + + @iOSXCUITFindBy(accessibility = "showOtherRowButton") + private WebElement ellipsisButton; + + @iOSXCUITFindBy(accessibility = "pingButton") + private WebElement pingButton; + + @iOSXCUITFindBy(accessibility = "uploadFileButton") + private WebElement fileTransferButton; + + @iOSXCUITFindBy(accessibility = "locationButton") + private WebElement locationButton; + + @iOSXCUITFindBy(accessibility = "videoButton") + private WebElement videoMessageButton; + + @iOSXCUITFindBy(accessibility = "sendButton") + private WebElement sendButton; + + @iOSXCUITFindBy(accessibility = "VideoActionButton") + private WebElement videoMessageActionButton; + + @iOSXCUITFindBy(accessibility = "audioRecorderSend") + private WebElement audioRecorderSendButton; + + @iOSXCUITFindBy(accessibility = "ephemeralTimeSelectionButton") + private WebElement hourglassButton; + + @iOSXCUITFindBy(accessibility = "collection") + private WebElement collectionButton; + + @iOSXCUITFindBy(accessibility = "linkPreview") + private WebElement linkPreview; + + @iOSXCUITFindBy(accessibility = "linkPreviewImage") + private WebElement linkPreviewImage; + + @iOSXCUITFindBy(accessibility = "confirmButton") + private WebElement confirmEdit; + + @iOSXCUITFindBy(accessibility = "cancelButton") + private WebElement cancelEdit; + + @iOSXCUITFindBy(accessibility = "hasguests") + private WebElement conversationHasGuestsIndicator; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeMap'") + private WebElement sharedLocationContainer; + + @iOSXCUITFindBy(accessibility = "quote.type.image") + private WebElement quotedImageInReply; + + @iOSXCUITFindBy(className = "XCUIElementTypePickerWheel") + private WebElement pickerWheel; + + @iOSXCUITFindBy(accessibility = "likeButton") + private WebElement likeButton; + + @iOSXCUITFindBy(accessibility = "MessageToolbox") + private WebElement recentMessageToolbox; + + @iOSXCUITFindBy(accessibility = "FileTransferBottomLabel") + private WebElement fileTransferBottomLabel; + + @iOSXCUITFindBy(accessibility = "FileTransferTopLabel") + private WebElement fileTransferTopLabel; + + @iOSXCUITFindBy(accessibility = "DeliveryStatus") + private WebElement messageDeliveryStatus; + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'deep link' AND type == 'XCUIElementTypeLink'") + private WebElement deepLinkMessage; + + @iOSXCUITFindBy(accessibility = "Image + MessageRestrictionBottomLabel") + private WebElement placeholderPicture; + + @iOSXCUITFindBy(accessibility = "File + MessageRestrictionBottomLabel") + private WebElement placeholderFile; + + @iOSXCUITFindBy(iOSNsPredicate = "label CONTAINS 'New Device' AND name CONTAINS 'New Device' AND value CONTAINS 'New Device'") + private WebElement degradationAlert; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeNavigationBar'") + private WebElement titleBar; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeNavigationBar/*[`name == 'Name'`]") + private WebElement conversationName; + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'users_list.label'") + private WebElement userListLabel; + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'Learn more'") + private WebElement learnMoreDelayedMessage; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeButton[$name == '80 MB file'$]") + private WebElement optionFor80MBFile; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeButton[$name == 'CountryCodes.plist'$]") + private WebElement optionForCountryCodesFile; + + private static final By classChainAllTextMessages = MobileBy.iOSClassChain("**/XCUIElementTypeCell[$name == 'Message'$]"); + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeTextView[`name == 'Message' OR label == 'Message'`]") + private WebElement lastTextMessage; + + private static final By nameFileTransferActionButton = MobileBy.AccessibilityId("FileTransferActionButton"); + + private static final By nameFileActionsMenu = MobileBy.AccessibilityId("ActivityListView"); + + private static final By nameImageCell = MobileBy.AccessibilityId("ImageCell"); + + private static final By nameVideoCell = MobileBy.AccessibilityId("videoCell"); + + private static final By nameFileTransferCell = MobileBy.AccessibilityId("FileTransferTopLabel"); + + private static final By namePlaceholderImageCell = MobileBy.AccessibilityId("Image + MessageRestrictionBottomLabel"); + + private static final String nameInputPlaceholderStandard = "Type a message"; + + private static final By predicateStandardTextInputPlaceholder = MobileBy.iOSNsPredicateString(String.format("type == 'XCUIElementTypeTextView' AND name == 'inputField' AND value ENDSWITH '%s'", nameInputPlaceholderStandard)); + + private static final Function predicateInputFieldQuoteType = type -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeOther' AND name == 'replyView' AND label CONTAINS '%s'", + type)); + + private static final Function predicateStrQuotedMessageByValue = text -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeTextView' AND name =='quote.type.text' AND value CONTAINS '%s'", text)); + + private static final By classChainConversationViewEntry = MobileBy.iOSClassChain("XCUIElementTypeCell"); + private static final String classChainStrAllEntries = "**/XCUIElementTypeTable/XCUIElementTypeCell"; + private static final By fbClassChainRecentEntry = MobileBy.iOSClassChain(String.format("%s[1]", classChainStrAllEntries)); + private static final By classConversationViewRoot = By.className("XCUIElementTypeTable"); + private static final By predicateMissedCallByYourself = MobileBy.iOSNsPredicateString("value == 'You called'"); + + private static final Function predicateReactionConversationViewByValue = reaction -> MobileBy.iOSNsPredicateString(String.format("type == 'XCUIElementTypeOther' AND name CONTAINS 'value: %s, count:'", reaction)); + + private static final Function classChainMessageToolboxByText = name -> MobileBy.iOSClassChain( + String.format("**/XCUIElementTypeCell[`name == 'MessageToolbox'`]/**/XCUIElementTypeAny[`value CONTAINS '%s' AND label CONTAINS '%s'`]", name, name)); + private static final Function classChainMessageToolboxButtonByText = name -> MobileBy.iOSClassChain( + String.format("**/XCUIElementTypeCell[`name == 'MessageToolbox'`]/**/XCUIElementTypeButton[`label CONTAINS '%s'`]", name)); + + /** + * !!! The actual message order in DOM is reversed relatively to the messages order in the conversation view + */ + private static final Function messageAttributesByTextPartTemplate = text -> + String.format("`name == 'Message' AND visible == 1 AND (value CONTAINS '%s' OR label CONTAINS '%s')`", text, text); + + private static final Function classChainMessageByTextPart = text -> MobileBy.iOSClassChain( + String.format("%s/**/*[%s]", classChainStrAllEntries, messageAttributesByTextPartTemplate.apply(text))); + + private static final Function predicateSystemMessageByText = text -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeCell' AND label BEGINSWITH[c] '%s' OR type == 'XCUIElementTypeLink' AND label BEGINSWITH[c] '%s' OR type == 'XCUIElementTypeLink' AND value BEGINSWITH[c] '%s' OR type == 'XCUIElementTypeStaticText' AND label BEGINSWITH[c] '%s'", text, text, text, text)); + + private static final Function predicatePingMessageByText = text -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeLink' AND value ==[c] '%s'", text)); + + private static final Function xpathReplyCellsByCount = count -> By.xpath( + String.format("//XCUIElementTypeTable[count(XCUIElementTypeCell[@name='ReplyCell'])=%s]", count)); + + private static final String MISSED_CALL_PREFIX = "Missed call from"; + + private static Function classChainStrToolbarByPredicateExpr = predicateExpr -> + String.format("**/XCUIElementTypeNavigationBar/*[`%s`]", predicateExpr); + + private static final By classChainTopBarShield = MobileBy.iOSClassChain(classChainStrToolbarByPredicateExpr.apply( + "name == 'Name' AND label CONTAINS 'Verified'")); + + private static final By classChainTopBarLHIndicator = MobileBy.iOSClassChain(classChainStrToolbarByPredicateExpr.apply( + "name == 'Name' AND label CONTAINS 'Legal hold'")); + + private static final Function predicateTransferTopLabelByFileName = name -> + String.format("type == 'XCUIElementTypeStaticText' AND name == 'FileTransferTopLabel' AND value == '%s'", + name.toUpperCase()); + + private static final Function predicateStrTransferBottomLabelByExpr = expr -> + String.format("type == 'XCUIElementTypeStaticText' AND name == 'FileTransferBottomLabel' AND %s", + expr); + + private static final Function predicateAudioActionButtonByState = value -> + String.format("name == 'AudioActionButton' AND value == '%s'", + value); + + private static final Function predicateStrMessageDeliveryStatusByText = text -> + String.format("name == 'DeliveryStatus' AND value CONTAINS '%s'", text); + + private static final Function predicateStrMessageDetailsByText = text -> + String.format("name == 'Details' AND value CONTAINS '%s'", text); + + private static final Timedelta MAX_APPEARANCE_TIME = Timedelta.ofSeconds(20); + + public ConversationViewPage(WebDriver driver) { + super(driver); + } + + public boolean isInputFieldVisible() { + return conversationInput.isDisplayed(); + } + + public boolean isInputFieldInvisible() { + return isElementInvisible(conversationInput); + } + + public boolean waitUntilTextMessageIsNotVisible(String msg) { + final By locator = classChainMessageByTextPart.apply(msg); + return isLocatorInvisible(locator); + } + + public void returnToConversationsList() { + waitUntilElementClickable(conversationBackButton); + conversationBackButton.click(); + } + + public int getNumberOfMessageEntries() { + final WebElement convoViewRoot = getElement(classConversationViewRoot); + return selectVisibleElements(convoViewRoot, classChainConversationViewEntry).size(); + } + + public boolean waitForCursorInputVisible() { + return isElementVisible(conversationInput, Timedelta.ofSeconds(10)); + } + + public boolean waitForCursorInputInvisible() { + return isElementInvisible(conversationInput); + } + + public void tapTextInput() { + conversationInput.click(); + } + + public void longTapTextInput() throws InterruptedException { + longTapWithActionsAPI(conversationInput); + } + + public void clearTextInput() { + // This is to make sure the input cursor has been put to the tail of the text + this.tapByPercentOfElementSize(conversationInput, 95, 50); + conversationInput.clear(); + } + + public String getLastTextMessage() { + waitUntilElementVisible(lastTextMessage); + return lastTextMessage.getText(); + } + + public boolean waitUntilMessageInConversation() { + return waitUntilElementVisible(lastTextMessage); + } + + public boolean isUpperToolbarContainNames(String expectedNames) { + return titleBar.getAttribute("name").toUpperCase().equals(expectedNames.toUpperCase()); + } + + public void openConversationDetails() { + waitUntilElementClickable(conversationName); + conversationName.click(); + } + + public void typeMessage(String message, boolean shouldSend) { + conversationInput.sendKeys(message); + if (shouldSend) { + tapSendMessageButton(); + } + } + + public void typeMessage(String message) { + typeMessage(message, false); + } + + public boolean isShieldIconVisible() { + return getDriver().findElement(classChainTopBarShield).isDisplayed(); + } + + public boolean isShieldIconInvisible() { + return isLocatorInvisible(classChainTopBarShield); + } + + public boolean areInputToolsVisible() { + return isElementVisible(addPictureButton, Timedelta.ofSeconds(8)) || isElementVisible(ellipsisButton, Timedelta.ofSeconds(8)); + } + + public boolean areInputToolsInvisible() { + return isElementInvisible(addPictureButton) && isElementInvisible(ellipsisButton); + } + + public boolean isSystemMessageVisible(String expectedMsg) { + final By locator = predicateSystemMessageByText.apply(expectedMsg); + return getDriver().findElement(locator).isDisplayed(); + } + + public boolean isSystemMessageInvisible(String expectedMsg) { + final By locator = predicateSystemMessageByText.apply(expectedMsg); + return isLocatorInvisible(locator); + } + + public boolean isPingMessageVisible(String pingMsg) { + final By locator = predicatePingMessageByText.apply(pingMsg); + return getDriver().findElement(locator).isDisplayed(); + } + + public boolean isPingMessageInvisible(String pingMsg) { + final By locator = predicatePingMessageByText.apply(pingMsg); + return isLocatorInvisible(locator); + } + + public boolean isYouCalledMessageVisible() { + return getDriver().findElement(predicateMissedCallByYourself).isDisplayed(); + } + + public Optional getRecentPictureScreenshot() { + return getElementScreenshot(imageCell); + } + + public List getQRCodeFromPicture() { + return waitUntilElementContainsQRCode(imageCell); + } + + public void tapFileTransferOptionFor80MBFile() { + optionFor80MBFile.click(); + } + + public void tapFileTransferOptionForCountryCodesFile() { + optionForCountryCodesFile.click(); + } + + public boolean isFileTransferTopLabelVisible() { + return fileTransferTopLabel.isDisplayed(); + } + + public boolean isFileTransferTopLabelInvisible() { + return isElementInvisible(fileTransferTopLabel); + } + + public boolean isFileTransferBottomLabelVisible() { + return waitUntilElementVisible(fileTransferBottomLabel); + } + + public void tapFileTransferActionButton() { + tapElementWithRetryIfNextElementNotAppears(nameFileTransferActionButton, nameFileActionsMenu, + Timedelta.ofSeconds(3), 5); + } + + public void tapAddPictureButton() { + addPictureButton.click(); + } + + public boolean isAddPictureButtonVisible() { + return addPictureButton.isDisplayed(); + } + + public boolean isAddPictureButtonInvisible() { + return isElementInvisible(addPictureButton); + } + + public boolean isPingButtonVisible() { + return locateCursorToolButton(pingButton).isDisplayed(); + } + + public boolean isPingButtonInvisible() { + return isElementInvisible(pingButton); + } + + public boolean isMentionButtonVisible() { + return locateCursorToolButton(mentionButton).isDisplayed(); + } + + public boolean isMentionButtonInvisible() { + return isElementInvisible(mentionButton); + } + + public boolean isShareLocationButtonVisible() { + return locateCursorToolButton(locationButton).isDisplayed(); + } + + public boolean isShareLocationButtonInvisible() { + return isElementInvisible(locationButton); + } + + public void tapMentionButton() { + mentionButton.click(); + } + + public void tapPingButton() { + pingButton.click(); + } + + public void tapSketchButton() { + sketchButton.click(); + } + + public void tapShareLocationButton() { + locationButton.click(); + } + + public void tapEllipsisButton() { + ellipsisButton.click(); + } + + public void tapFileTransferButton() { + fileTransferButton.click(); + } + + public void tapVideoMessageButton() { + locateCursorToolButton(videoMessageButton).click(); + } + + public void tapAudioMessageButton() { + locateCursorToolButton(audioMessageButton).click(); + } + + public void longTapAudioMessageButton() { + if (waitUntilElementClickable(audioMessageButton)) { + try { + longTapWithActionsAPI(audioMessageButton); + } catch (InterruptedException e) { + throw new RuntimeException("Failed to long tap audio message button", e); + } + } else { + throw new RuntimeException("Audio message button is not clickable"); + } + + } + + public void longTapAudioMessageButtonWithDuration(int seconds) { + try { + longTapWithActionsAPI(locateCursorToolButton(audioMessageButton), Duration.ofSeconds(seconds)); + } catch (InterruptedException e) { + throw new RuntimeException("Could not long tap audio message button", e); + } + } + + public void tapGIFButton() { + gifButton.click(); + } + + public boolean isAudioMessageButtonVisible() { + return audioMessageButton.isDisplayed(); + } + + public boolean isAudioMessageButtonInvisible() { + return isElementInvisible(audioMessageButton); + } + + public boolean isFileTransferButtonVisible() { + return locateCursorToolButton(fileTransferButton).isDisplayed(); + } + + public boolean isSketchButtonVisible() { + return locateCursorToolButton(sketchButton).isDisplayed(); + } + + public boolean isSketchButtonInvisible() { + return isElementInvisible(sketchButton); + } + + public boolean isVideoMessageButtonVisible() { + return locateCursorToolButton(videoMessageButton).isDisplayed(); + } + + public boolean isVideoMessageButtonInvisible() { + return isElementInvisible(videoMessageButton); + } + + public boolean isPhotoGalleryButtonVisible() { + return addPictureButton.isDisplayed(); + } + + public boolean isPhotoGalleryButtonInvisible() { + return isElementInvisible(addPictureButton); + } + + public boolean isGiphyButtonVisible() { + return locateCursorToolButton(gifButton).isDisplayed(); + } + + public boolean isGiphyButtonInvisible() { + return isElementInvisible(gifButton); + } + + public boolean isDegradationAlertVisible() { + return degradationAlert.isEnabled(); + } + + public boolean isDegradationAlertInvisible() { + return isElementInvisible(degradationAlert); + } + + public boolean isFileTransferButtonInvisible() { + return isElementInvisible(fileTransferButton); + } + + public boolean waitUntilDownloadReadyPlaceholderVisible(String filename, String expectedSize, Timedelta timeout) { + final By topLabelLocator = MobileBy.iOSNsPredicateString( + predicateTransferTopLabelByFileName.apply(FilenameHelper.getBaseName(filename).get())); + final By bottomLabelLocator = MobileBy.iOSNsPredicateString(predicateStrTransferBottomLabelByExpr.apply( + String.join(" AND ", + String.format("value BEGINSWITH '%s'", expectedSize.toUpperCase()), + String.format("value CONTAINS '%s'", FilenameHelper.getExtension(filename).get().toUpperCase()) + ) + )); + return isLocatorDisplayed(topLabelLocator, timeout) && + isLocatorDisplayed(bottomLabelLocator, timeout); + } + + public boolean isPlaceholderStandardTextVisible() { + return getDriver().findElement(predicateStandardTextInputPlaceholder).isDisplayed(); + } + + public boolean isPlaceholderStandardTextInvisible() { + return isLocatorInvisible(predicateStandardTextInputPlaceholder); + } + + public void longTapMessageByText(String message) { + final WebElement el = getDriver().findElement(classChainMessageByTextPart.apply(message)); + getDriver().executeScript("mobile: touchAndHold", Map.ofEntries( + Map.entry("elementId", el.getAttribute("UID")), + Map.entry("duration", "2.0") + )); + } + + public void tapMessageByText(String text) { + final WebElement el = getDriver().findElement(classChainMessageByTextPart.apply(text)); + tapAtTheLeftSideOfElement(el); + } + + private WebElement locateCursorToolButton(WebElement toolButton) { + if (isElementVisible(toolButton)) { + return toolButton; + } else { + this.tapAtTheCenterOfElement(ellipsisButton); + return toolButton; + } + } + + public void tapSendRecordControlButton() { + audioRecorderSendButton.click(); + } + + public void tapAudioMessagePlayButton() { + waitUntilElementClickable(audioMessageActionButton); + audioMessageActionButton.click(); + } + + public void longTapPlayAudioMessageButton() { + waitUntilElementClickable(audioMessageActionButton); + longTapWithScript(audioMessageActionButton); + } + + public boolean isPlaceholderAudioMessageButtonState(String buttonState) { + final By locator = MobileBy.iOSNsPredicateString(predicateAudioActionButtonByState.apply(buttonState)); + return getDriver().findElement(locator).isDisplayed(); + } + + public boolean isAudioMessagePauseButtonVisible() { + return waitUntilElementVisible(audioMessagePauseButton); + } + + public boolean isLinkPreviewImageVisible() { + return isElementVisible(linkPreviewImage, MAX_APPEARANCE_TIME); + } + + public boolean isLinkPreviewImageInvisible() { + return isElementInvisible(linkPreviewImage); + } + + public void selectDeleteMenuItem(String name) { + getElement(MobileBy.AccessibilityId(name)).click(); + } + + public void tapConfirmEditControlButton() { + confirmEdit.click(); + } + + public void tapCancelEditControlButton() { + cancelEdit.click(); + } + + public void tapImageInConversation() { + imageCell.click(); + } + + public void longTapImageInConversation() { + longTapWithScript(imageCell); + } + + public void longTapFileTransferPlaceholder() { + longTapWithScript(fileTransferBottomLabel); + } + + public void tapFileTransferPlaceholder() { + fileTransferBottomLabel.click(); + } + + public void tapVideoMessage() { + videoMessageActionButton.click(); + } + + public void longTapVideoMessage() { + longTapWithScript(videoMessageActionButton); + } + + public void tapLocationMap() { + sharedLocationContainer.click(); + } + + public void longTapLinkPreview() { + longTapWithScript(linkPreview); + } + + public void singleTapYoutubePreview() { + youtubePreview.click(); + } + + public boolean isVideoMessageVisible() { + return videoMessageActionButton.isDisplayed(); + } + + public boolean isVideoMessageInvisible() { + return isElementInvisible(videoMessageActionButton); + } + + public boolean isLinkPreviewVisible() { + return linkPreview.isDisplayed(); + } + + public boolean isLinkPreviewInvisible() { + return isElementInvisible(linkPreview); + } + + public boolean isAudioMessageVisible() { + return waitUntilElementVisible(audioMessageActionButton); + } + + public boolean isAudioMessageInvisible() { + return waitUntilElementInvisible(audioMessageActionButton); + } + + public boolean isLocationMapVisible() { + return sharedLocationContainer.isDisplayed(); + } + + public boolean isLocationMapInvisible() { + return isElementInvisible(sharedLocationContainer); + } + + public BufferedImage getLikeIconState() { + return getElementScreenshot(likeButton).orElseThrow( + () -> new IllegalStateException("Cannot take a screenshot of Like/Unlike button") + ); + } + + public void tapLikeIcon() { + likeButton.click(); + } + + public boolean isLikeIconVisible() { + return likeButton.isDisplayed() && recentMessageToolbox.isDisplayed(); + } + + public boolean isLikeIconInvisible() { + return isElementInvisible(likeButton) || isElementInvisible(recentMessageToolbox); + } + + public void tapAtRecentMessage(int pWidth, int pHeight) { + this.tapByPercentOfElementSize(getElement(fbClassChainRecentEntry), pWidth, pHeight); + } + + public void tapRecentMessageToolbox() { + recentMessageToolbox.click(); + } + + public void tapAtDeepLinkMessage() { + deepLinkMessage.click(); + } + + public boolean waitUntilAllTextMessageAreNotVisible() { + return isLocatorInvisible(classChainAllTextMessages); + } + + public int numberOfTextMessagesVisible(int expectedCount) { + return waitUntilNumberOfElementsToBe(classChainAllTextMessages, expectedCount); + } + + public int numberOfSpecificTextMessagesVisible(String s, int expectedCount) { + final By locator = classChainMessageByTextPart.apply(s); + return waitUntilNumberOfElementsToBe(locator, expectedCount); + } + + public boolean areNoImagesVisible() { + return waitUntilElementInvisible(imageCell); + } + + public int numberOfImagesVisible(int expectedCount) { + return waitUntilNumberOfElementsToBe(nameImageCell, expectedCount); + } + + public boolean areNoVideoFilesVisible() { + return waitUntilElementInvisible(videoCell); + } + + public int numberOfVideoFiles(int expectedCount) { + return waitUntilNumberOfElementsToBe(nameVideoCell, expectedCount); + } + + public int areXFilesVisible(int expectedCount) { + return waitUntilNumberOfElementsToBe(nameFileTransferCell, expectedCount); + } + + public String getPlaceholderImageText() { + return placeholderPicture.getText(); + } + + public boolean areNoPlaceholderImagesVisible() { + return isElementInvisible(placeholderPicture); + } + + public boolean isPlaceholderFileVisible() { + return placeholderFile.isDisplayed(); + } + + public boolean isPlaceholderFileInvisible() { + return isElementInvisible(placeholderFile); + } + + public void longTapPlaceholderFile() { + longTapWithScript(placeholderFile); + } + + public int numberOfPlaceholderImages(int expectedCount) { + return waitUntilNumberOfElementsToBe(namePlaceholderImageCell, expectedCount); + } + + public void tapAudioButton() { + audioCallButton.click(); + } + + public void tapStartCallButton() { + startCallButton.click(); + } + + public void tapVideoCallButton() { + tapAtTheCenterOfElement(videoCallButton); + } + + public void tapCancelButton() { + tapAtTheCenterOfElement(cancelButton); + } + + public void tapCallAnywayButton() { + waitUntilElementVisible(callAnywayButton); + tapAtTheCenterOfElement(callAnywayButton); + } + + public void tapSendMessageButton() { + sendButton.click(); + } + + public void tapHourglassButton() { + hourglassButton.click(); + } + + public void tapCollectionButton() { + this.tapElementWithRetryIfStillDisplayed(collectionButton); + } + + public boolean isMessageDeliveryStatusTextVisible(String expectedText) { + final By locator = MobileBy.iOSNsPredicateString(predicateStrMessageDeliveryStatusByText.apply(expectedText)); + return getDriver().findElement(locator).isDisplayed(); + } + + public boolean isMessageToolboxDetailsTextVisible(String expectedText) { + final By locator = MobileBy.iOSNsPredicateString(predicateStrMessageDetailsByText.apply(expectedText)); + return getDriver().findElement(locator).isDisplayed(); + } + + public boolean isMessageToolboxDetailsTextInvisible(String expectedText) { + final By locator = MobileBy.iOSNsPredicateString(predicateStrMessageDetailsByText.apply(expectedText)); + return isLocatorInvisible(locator); + } + + public boolean isMessageToolboxTextVisible(String expectedText) { + By locator = classChainMessageToolboxByText.apply(expectedText); + return waitUntilLocatorVisible(locator); + } + + public boolean isMessageToolboxButtonVisible(String expectedText) { + By locator = classChainMessageToolboxButtonByText.apply(expectedText); + return getDriver().findElement(locator).isDisplayed(); + } + + public boolean isMessageToolboxButtonInvisible(String expectedText) { + By locator = classChainMessageToolboxButtonByText.apply(expectedText); + return isLocatorInvisible(locator, MAX_APPEARANCE_TIME); + } + + public boolean isMessageToolboxTextInvisible(String expectedText) { + final By locator = classChainMessageToolboxByText.apply(expectedText); + return isLocatorInvisible(locator); + } + + public boolean isMessageDeliveryStatusVisible() { + return waitUntilElementVisible(messageDeliveryStatus, MAX_APPEARANCE_TIME.asDuration()); + } + + public boolean isMessageDeliveryStatusInvisible() { + return isElementInvisible(messageDeliveryStatus); + } + + public void setMessageExpirationTimer(String value) { + pickerWheel.sendKeys(value); + } + + private static final int MAX_SCROLLS = 3; + + public void scrollToTheTop() { + for (int i = 0; i < MAX_SCROLLS; i++) { + swipe(getElement(classConversationViewRoot), SwipeDirection.DOWN); + } + } + + public void scrollToTheBottom() { + for (int i = 0; i < MAX_SCROLLS; i++) { + swipe(getElement(classConversationViewRoot), SwipeDirection.UP); + } + } + + public boolean isConversationHasGuestsVisible() { + return conversationHasGuestsIndicator.isDisplayed(); + } + + public boolean isConversationHasGuestsInvisible() { + return isElementInvisible(conversationHasGuestsIndicator); + } + + public boolean isReplyVisible() { + return replyCell.isDisplayed(); + } + + public boolean isReplyInvisible() { + return isElementInvisible(replyCell); + } + + public boolean isInputFieldQuoteOfTypeVisible(String type) { + return isLocatorExist(predicateInputFieldQuoteType.apply(type)); + } + + public boolean isQuotedImageVisible() { + return quotedImageInReply.isDisplayed(); + } + + public boolean isQuotedMessageVisible(String text) { + return getDriver().findElement(predicateStrQuotedMessageByValue.apply(text)).isDisplayed(); + } + + public boolean isNumberOfReplyCellsVisible(int numberOfCells) { + return getDriver().findElement(xpathReplyCellsByCount.apply(numberOfCells)).isDisplayed(); + } + + public boolean isLegalHoldIndicatorVisible() { + return isLocatorDisplayed(classChainTopBarLHIndicator, Timedelta.ofSeconds(10)); + } + + public boolean isLegalHoldIndicatorInvisible() { + return isLocatorInvisible(classChainTopBarLHIndicator); + } + + public boolean isUserWillGetYourMessageLaterVisible(String name) { + return userListLabel.getText().equals(String.format("%s will get your message later. Learn more", name)); + } + + public void iTapOnLearnMoreLinkOnDelayedMessage() { + learnMoreDelayedMessage.click(); + } + + public boolean isReactionVisible(String reaction) { + return getDriver().findElement(predicateReactionConversationViewByValue.apply(reaction)).isDisplayed(); + } + + public boolean isReactionInvisible(String reaction) { + return isLocatorInvisible(predicateReactionConversationViewByValue.apply(reaction)); + } + + public boolean enterpriseUpgradeAlertPresent() { + try { + return upgradeAlert.isDisplayed(); + } catch(Exception e) { + return false; + } + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ConversationsListPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ConversationsListPage.java new file mode 100644 index 00000000000..79c37d894ca --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ConversationsListPage.java @@ -0,0 +1,310 @@ +package com.wearezeta.auto.ios.pages; + +import com.wearezeta.auto.common.CommonUtils; +import com.wearezeta.auto.common.Config; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.common.log.ZetaLogger; +import com.wearezeta.auto.common.misc.Timedelta; +import io.appium.java_client.MobileBy; +import java.util.logging.Logger; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import java.util.function.BiFunction; +import java.util.function.Function; + +public class ConversationsListPage extends IOSPage { + private static final Logger log = ZetaLogger.getLog(ConversationsListPage.class.getSimpleName()); + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'conversation list'") + private WebElement conversationsListRoot; + + @iOSXCUITFindBy(className = "XCUIElementTypeCollectionView") + private WebElement classConversationsListRoot; + + @iOSXCUITFindBy(iOSNsPredicate = "name BEGINSWITH 'Start a conversation'") + private WebElement inviteHelpText; + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'action_button' AND type == 'XCUIElementTypeButton'") + private WebElement joinButton; + + @iOSXCUITFindBy(accessibility = "EVERYTHING ARCHIVED") + private WebElement conversationListPlaceholder; + + @iOSXCUITFindBy(accessibility = "ClassificationBannerClassified") + private WebElement classifiedDomainLabel; + + @iOSXCUITFindBy(accessibility = "ClassificationBannerUnclassified") + private WebElement unClassifiedDomainLabel; + + @iOSXCUITFindBy(accessibility = "contactRequests - conversation_list_cell") + private WebElement conversationPendingRequest; + + @iOSXCUITFindBy(accessibility = "Mark as Read") + private WebElement markAsRead; + + @iOSXCUITFindBy(accessibility = "Mute") + private WebElement mute; + + @iOSXCUITFindBy(accessibility = "Nothing") + private WebElement nothing; + + @iOSXCUITFindBy(accessibility = "Notifications…") + private WebElement notificationsMenu; + + @iOSXCUITFindBy(accessibility = "Archive") + private WebElement archive; + + @iOSXCUITFindBy(accessibility = "Add to Favorites") + private WebElement addToFavorites; + + @iOSXCUITFindBy(accessibility = "Remove from Favorites") + private WebElement removeFromFavorites; + + @iOSXCUITFindBy(accessibility = "Move to…") + private WebElement moveTo; + + @iOSXCUITFindBy(accessibility = "Clear Content…") + private WebElement clearContent; + + @iOSXCUITFindBy(accessibility = "Clear") + private WebElement clearInClearContent; + + @iOSXCUITFindBy(accessibility = "Block…") + private WebElement block; + + @iOSXCUITFindBy(accessibility = "Block") + private WebElement blockConfirm; + + @iOSXCUITFindBy(accessibility = "Leave Group…") + private WebElement leaveGroup; + + @iOSXCUITFindBy(accessibility = "Leave and clear content") + private WebElement leaveAndClearConfirm; + + @iOSXCUITFindBy(accessibility = "Name") + private WebElement name; + + private static final Function predicateStrConversationByLabel = text -> + MobileBy.iOSNsPredicateString(String.format("name == 'title' AND label == '%s'", text)); + + private static final Function xpathStrFirstConversationRelativeEntryByName = name -> + MobileBy.xpath(String.format("//XCUIElementTypeOther[1]/XCUIElementTypeButton[@label='%s']", name)); + + private static final BiFunction predicateStrSecondaryLine = (label, value) -> + MobileBy.iOSNsPredicateString(String.format("name == 'title' AND label == '%s' AND value BEGINSWITH '%s'", label, value)); + + private static final Function classChainStrRelativeConversationStatus = name -> MobileBy.iOSClassChain(String.format( + "XCUIElementTypeCell[$type == 'XCUIElementTypeButton' AND label == '%s' AND `name == 'title' $]", + name)); + + private static final BiFunction predicatedStrRelativeConversationStatusByValue = (label, value) -> + MobileBy.iOSNsPredicateString(String.format("name == 'title' AND label == '%s' AND value ENDSWITH '%s'", label, value)); + + public ConversationsListPage(WebDriver driver) { + super(driver); + } + + public void tapConversationItemRecentList(String name) { + final By locator = predicateStrConversationByLabel.apply(name); + getDriver().findElement(locator).click(); + } + + // TODO: Not sure if I like how handling the context menu at the moment + public void tapMarkAsRead() { + markAsRead.click(); + } + + public void tapNotificationsMenu() { + notificationsMenu.click(); + } + + public void tapNothing() { + nothing.click(); + } + + public void tapArchive() { + archive.click(); + } + + public void tapFavorite() { + addToFavorites.click(); + } + + public void tapRemoveFromFavorite() { + removeFromFavorites.click(); + } + + public void tapMoveTo() { + moveTo.click(); + } + + public void tapClearContent() { + clearContent.click(); + } + + public void tapBlock() { + block.click(); + } + + public void tapLeaveGroup() { + leaveGroup.click(); + } + + public void longTapConversationItemRecentList(String name) { + final By locator = predicateStrConversationByLabel.apply(name); + try { + longTapWithActionsAPI(getDriver().findElement(locator)); + } catch (InterruptedException e) { + throw new RuntimeException("The long tap action was interrupted", e); + } + } + + public boolean isConversationInList(String name) { + return this.isConversationInList(name, + Timedelta.ofSeconds(Integer.parseInt(Config.current().getDriverTimeout(getClass())))); + } + + public boolean isConversationInList(String name, Timedelta timeout) { + final By locator = predicateStrConversationByLabel.apply(name); + return isLocatorDisplayed(locator, timeout); + } + + public void swipeRightOnConversation(String name) { + final By locator = predicateStrConversationByLabel.apply(name); + final WebElement convoListItem = getDriver().findElement(locator); + if (!CommonUtils.waitUntilTrue(Timedelta.ofSeconds(5), Timedelta.ofMillis(1), () -> { + swipe(convoListItem, SwipeDirection.RIGHT); + return isElementInvisible(convoListItem, Timedelta.ofSeconds(1)); + })) { + log.warning(String.format("The conversation item '%s' is still visible after being swiped to the right", name)); + } + } + + public boolean isPendingRequestInContactList() { + return conversationPendingRequest.isDisplayed(); + } + + public boolean pendingRequestInContactListIsNotShown() { + return isElementInvisible(conversationPendingRequest); + } + + public void tapPendingRequest() { + conversationPendingRequest.click(); + } + + public boolean isConversationNotInList(String name, Timedelta timeout) { + final By locator = predicateStrConversationByLabel.apply(name); + return isLocatorInvisible(locator, timeout); + } + + public boolean isConversationNotInList(String name) { + return isConversationNotInList(name, getDefaultLookupTimeout()); + } + + public boolean isFirstConversationName(String convoName) { + return isLocatorDisplayed(classConversationsListRoot, xpathStrFirstConversationRelativeEntryByName.apply(convoName)); + } + + public boolean isConversationsListPlaceholderVisible() { + return conversationListPlaceholder.isDisplayed(); + } + + public boolean isConversationItemWithStatusVisible(String status, String conversation) { + return isLocatorDisplayed(classConversationsListRoot, predicatedStrRelativeConversationStatusByValue.apply(conversation, status)); + } + + public boolean isConversationItemWithStatusInvisible(String status, String conversation) { + return isLocatorInvisible(classConversationsListRoot, predicatedStrRelativeConversationStatusByValue.apply(conversation, status)); + } + + public boolean isConversationItemStatusInvisible(String conversation) { + return isLocatorInvisible(classConversationsListRoot, classChainStrRelativeConversationStatus.apply(conversation)); + } + + public boolean isConversationItemStatusVisible(String conversation) { + return isLocatorDisplayed(classConversationsListRoot, classChainStrRelativeConversationStatus.apply(conversation)); + } + + public boolean isSecondaryLineVisible(String conversation, String secondaryLine) { + final By locator = predicateStrSecondaryLine.apply(conversation, secondaryLine); + return isLocatorDisplayed(locator, Timedelta.ofSeconds(3)); + } + + public boolean isVisible() { + return isElementVisible(conversationsListRoot, Timedelta.ofSeconds(10)); + } + + public boolean isInvisible() { + return isElementInvisible(conversationsListRoot); + } + + public void tapJoinButtonNextTo(String name) { + isLocatorDisplayed(classConversationsListRoot, predicatedStrRelativeConversationStatusByValue.apply(name, "JOIN")); + joinButton.click(); + } + + public boolean isClassifiedLabelVisible() { + return classifiedDomainLabel.isDisplayed(); + } + + public boolean isClassifiedLabelInvisible() { + return isElementInvisible(classifiedDomainLabel); + } + + public boolean isNotClassifiedLabelVisible() { + return unClassifiedDomainLabel.isDisplayed(); + } + + public boolean isNotClassifiedLabelInvisible() { + return isElementInvisible(classifiedDomainLabel); + } + + public boolean isClassifiedLabelVisibleConvo() { + return classifiedDomainLabel.isDisplayed(); + } + + public boolean isClassifiedLabelInvisibleConvo() { + return isElementInvisible(classifiedDomainLabel); + } + + public boolean isNotClassifiedLabelVisibleConvo() { + return unClassifiedDomainLabel.isDisplayed(); + } + + public boolean isNotClassifiedLabelInvisibleConvo() { + return isElementInvisible(unClassifiedDomainLabel); + } + + public boolean isClassifiedLabelVisibleUserProfile() { + return classifiedDomainLabel.isDisplayed(); + } + + public boolean isClassifiedLabelInvisibleUserProfile() { + return isElementInvisible(classifiedDomainLabel); + } + + public boolean isNotClassifiedLabelVisibleUserProfile() { + return unClassifiedDomainLabel.isDisplayed(); + } + + public boolean isNotClassifiedLabelInvisibleUserProfile() { + return isElementInvisible(unClassifiedDomainLabel); + } + + public void tapClearInClearContent() { + clearInClearContent.click(); + } + + public void tapBlockConfirm() { + blockConfirm.click(); + } + + public void tapLeaveAndClearConfirm() { + leaveAndClearConfirm.click(); + } + + public boolean isCertified() { + return name.getAttribute("label").contains("all your devices have a valid end-to-end identity certificate"); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CreateFolderPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CreateFolderPage.java new file mode 100644 index 00000000000..def0526d606 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CreateFolderPage.java @@ -0,0 +1,42 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.common.log.ZetaLogger; +import java.util.logging.Logger; +import org.openqa.selenium.WebElement; + +public class CreateFolderPage extends IOSPage { + public CreateFolderPage(WebDriver driver) { + super(driver); + } + + @iOSXCUITFindBy(accessibility = "Create New Folder") + private WebElement createNewFolderTitle; + + @iOSXCUITFindBy(accessibility = "textfield.newfolder.name") + private WebElement folderNameTextField; + + @iOSXCUITFindBy(accessibility = "button.newfolder.create") + private WebElement createButton; + + @iOSXCUITFindBy(accessibility = "back") + private WebElement backButton; + + public boolean isVisible() { + return isElementVisible(createNewFolderTitle); + } + + public void tapBackButton() { + backButton.click(); + } + + public void tapCreateButton() { + createButton.click(); + } + + public void enterFolderName(String groupName) { + folderNameTextField.clear(); + folderNameTextField.sendKeys(groupName); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CustomBackendRedirectionPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CustomBackendRedirectionPage.java new file mode 100644 index 00000000000..bfbc20361f0 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CustomBackendRedirectionPage.java @@ -0,0 +1,43 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.AppiumBy; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class CustomBackendRedirectionPage extends IOSPage { + + public CustomBackendRedirectionPage(WebDriver driver) { + super(driver); + } + + @iOSXCUITFindBy(accessibility = "Redirecting...") + private WebElement leavingWire; + + @iOSXCUITFindBy(accessibility = "ProgressView.Timer") + private WebElement wireLogo; + + @iOSXCUITFindBy(accessibility = "Proceed") + private WebElement proceedButton; + + @iOSXCUITFindBy(accessibility = "Redirect to an on-premises backend?") + private WebElement redirectionTitle; + + public boolean isVisible() { + return isElementVisible(wireLogo) && leavingWire.isDisplayed(); + } + + public void tapProceedButton() { + proceedButton.click(); + } + + public boolean isRedirectionTitleVisible() { + return redirectionTitle.isDisplayed(); + } + + public boolean isTextVisible(String text) { + By locator = AppiumBy.accessibilityId(text); + return waitUntilLocatorVisible(locator); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CustomBackendWelcomePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CustomBackendWelcomePage.java new file mode 100644 index 00000000000..7c1fbb122c7 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/CustomBackendWelcomePage.java @@ -0,0 +1,34 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class CustomBackendWelcomePage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Login") + private WebElement emailLoginButton; + + private static final Function predicateWelcomeToBackend = backendName -> MobileBy.iOSNsPredicateString(String.format("label CONTAINS '%s'", backendName)); + + public CustomBackendWelcomePage(WebDriver driver) { + super(driver); + } + + public boolean isTextVisible(String backendName) { + return isLocatorDisplayed(predicateWelcomeToBackend.apply(backendName)); + } + + public boolean isConnectionMessageVisible(String backendName) { + return isLocatorDisplayed(predicateWelcomeToBackend.apply(backendName)); + } + + public void tapOnLoginWithEmailButton() { + waitUntilElementClickable(emailLoginButton); + emailLoginButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/E2EIPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/E2EIPage.java new file mode 100644 index 00000000000..320046d0c87 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/E2EIPage.java @@ -0,0 +1,27 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class E2EIPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Get Certificate") + private WebElement getCertificateBtn; + + @iOSXCUITFindBy(accessibility = "confirmationButton") + private WebElement OkBtn; + + public E2EIPage(WebDriver driver) { + super(driver); + } + + public void tapGetCertificateButton() { + waitUntilElementClickable(getCertificateBtn); + getCertificateBtn.click(); + } + + public void tapOkButton() { + OkBtn.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/EncryptionAtRestOverlay.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/EncryptionAtRestOverlay.java new file mode 100644 index 00000000000..9e8520be530 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/EncryptionAtRestOverlay.java @@ -0,0 +1,37 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.Keys; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class EncryptionAtRestOverlay extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Wire Bund") + private WebElement application; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeImage' AND name == 'wire-logo-shield'") + private WebElement wireShieldLogo; + + public EncryptionAtRestOverlay(WebDriver driver) { + super(driver); + } + + public boolean isPasscodeOverlayVisible() { + return wireShieldLogo.isDisplayed(); + } + + public boolean isPasscodeOverlayInvisible() { + // currently not possible as on iOS 17 it is not possible to interact with the passcode overlay + // return isElementInvisible(passcodeField); + return true; + } + + public void typePasscode(String passcode) { + application.sendKeys(passcode); + } + + public void pressEnter() { + application.sendKeys(Keys.ENTER); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/EnterpriseLoginPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/EnterpriseLoginPage.java new file mode 100644 index 00000000000..ff67fca5d7d --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/EnterpriseLoginPage.java @@ -0,0 +1,34 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class EnterpriseLoginPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "textfield.sso.code") + private WebElement emailSSOCodeTextField; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeAlert' AND name == 'Enterprise Login'") + private WebElement predicateEnterpriseLogInPopup; + + public EnterpriseLoginPage(WebDriver driver) { + super(driver); + } + + public void typeCodeIntoEmailSSOField(String code) { + emailSSOCodeTextField.sendKeys(code); + } + + public boolean isEnterpriseLoginBoxVisible() { + return predicateEnterpriseLogInPopup.isDisplayed(); + } + + public boolean isEnterpriseLoginBoxInvisible() { + return isElementInvisible(predicateEnterpriseLogInPopup); + } + + public boolean isCancelOptionVisible() { + return isAlertButtonVisible("Cancel"); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/FileInspectionPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/FileInspectionPage.java new file mode 100644 index 00000000000..db03a3fa595 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/FileInspectionPage.java @@ -0,0 +1,26 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class FileInspectionPage extends IOSPage { + + public FileInspectionPage(WebDriver driver) { + super(driver); + } + + @iOSXCUITFindBy(accessibility = "QLOverlayDefaultActionButtonAccessibilityIdentifier") + private WebElement shareButton; + + @iOSXCUITFindBy(accessibility = "QLOverlayDoneButtonAccessibilityIdentifier") + private WebElement doneButton; + + public void tapShareButton() { + shareButton.click(); + } + + public void tapDoneButton() { + doneButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/FirstTimeOverlay.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/FirstTimeOverlay.java new file mode 100644 index 00000000000..7a54881fe21 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/FirstTimeOverlay.java @@ -0,0 +1,42 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class FirstTimeOverlay extends IOSPage { + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'It’s the first time you’re using Wire on this device.'") + private WebElement heading; + + @iOSXCUITFindBy(accessibility = "ignore_backup") + private WebElement oKButton; + + @iOSXCUITFindBy(accessibility = "restore_backup") + private WebElement restoreButton; + + public FirstTimeOverlay(WebDriver driver) { + super(driver); + } + + public boolean isHeadingVisible() { + return waitUntilElementVisible(heading); + } + + public boolean waitUntilVisible() { + return waitUntilElementVisible(restoreButton); + } + + public boolean waitUntilInvisible() { + return waitUntilElementInvisible(restoreButton); + } + + public void accept() { + oKButton.click(); + } + + public void tapRestoreButton() { + waitUntilElementClickable(restoreButton); + restoreButton.click(); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/FolderViewPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/FolderViewPage.java new file mode 100644 index 00000000000..a8fcff21efa --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/FolderViewPage.java @@ -0,0 +1,256 @@ +package com.wearezeta.auto.ios.pages; + +import com.wearezeta.auto.common.CommonUtils; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.common.log.ZetaLogger; +import com.wearezeta.auto.common.misc.Timedelta; +import io.appium.java_client.MobileBy; + +import java.util.*; +import java.util.logging.Logger; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class FolderViewPage extends IOSPage { + private static final Logger log = ZetaLogger.getLog(ConversationsListPage.class.getSimpleName()); + + @iOSXCUITFindBy(xpath = "//XCUIElementTypeCollectionView[@name=\"conversation list\"]/*") + List conversationListItems; + + @iOSXCUITFindBy(accessibility = "PEOPLE") + WebElement peopleFolder; + + @iOSXCUITFindBy(xpath = "//XCUIElementTypeCell[@name=\"contacts - conversation_list_cell\"]/*") + WebElement peopleFolderItems; + + public FolderViewPage(WebDriver driver) { + super(driver); + } + + private static final By nameFolderViewRoot = + MobileBy.iOSNsPredicateString("name ENDSWITH 'bottomBarFolderListButton' AND label == 'List of conversations organized in folders'"); + + private static final String classStrFolderViewRoot = "XCUIElementTypeCollectionView"; + private static final By classFolderViewRoot = By.className(classStrFolderViewRoot); + + private static final By nameGroupFolder = MobileBy.AccessibilityId("GROUPS"); + private static final String classChainStrGroupsFolderRoot = + "**/XCUIElementTypeCell[`name == 'groups - conversation_list_cell'`]"; + private static final By nameFavoritesFolder = MobileBy.AccessibilityId("FAVORITES"); + private static final String classChainStrFavoritesFolderRoot = + "**/XCUIElementTypeCell[`name == 'favorites - conversation_list_cell'`]"; + private static final String strNameConversation = "title"; + private static final String strFolderCollapsed = "collapsed"; + private static final String strFolderExpanded = "expanded"; + + private static final By classChainConversationInList = + MobileBy.iOSClassChain("**/XCUIElementTypeButton[`name == 'title'`]"); + + private static final Function predicateStrConversationByLabel = text -> + MobileBy.iOSNsPredicateString(String.format("name == '%s' AND label == '%s'", strNameConversation, text)); + + private static final Function predicateStrIsFolderCollapsed = folderName -> + MobileBy.iOSNsPredicateString(String.format("name == '%s' AND value BEGINSWITH '%s'", folderName, strFolderCollapsed)); + + private static final Function predicateStrIsFolderExpanded = folderName -> + MobileBy.iOSNsPredicateString(String.format("name == '%s' AND value BEGINSWITH '%s'", folderName, strFolderExpanded)); + + private static final Function classChainStrConversationInGroupsFolderByName = (nameConversation) -> String.format( + "%s/**/XCUIElementTypeButton[`name == 'title' && label == '%s'`]", + classChainStrGroupsFolderRoot, nameConversation); + + private static final Function classChainStrConversationInFavoritesFolderByName = (nameConversation) -> String.format( + "%s/**/XCUIElementTypeButton[`name == 'title' && label == '%s'`]", + classChainStrFavoritesFolderRoot, nameConversation); + + private static final BiFunction predicatedStrRelativeConversationStatusByValue = (label, value) -> + MobileBy.iOSNsPredicateString(String.format("name == '%s' AND label == '%s' AND value ENDSWITH '%s'", strNameConversation, label, value)); + + private static final BiFunction predicateStrSecondaryLine = (label, value) -> + MobileBy.iOSNsPredicateString(String.format("name == '%s' AND label == '%s' AND value BEGINSWITH '%s'", strNameConversation, label, value)); + + private static final BiFunction predicateStrFolderBadgeCount = (folderName, number) -> String.format( + "name == '%s' AND value ENDSWITH ' %s'", folderName, number); + + private static final Function predicateStrFolderBadge = (folderName) -> String.format( + "name == '%s' AND value CONTAINS 'New messages:'", folderName); + + public boolean isVisible() { + return isLocatorDisplayed(nameFolderViewRoot, Timedelta.ofSeconds(10)); + } + + public boolean isPeopleFolderVisible() { + return waitUntilElementVisible(peopleFolder); + } + + public boolean isPeopleFolderInvisible() { + return waitUntilElementInvisible(peopleFolder); + } + + public boolean isFavoritesFolderVisible() { + return isLocatorDisplayed(nameFavoritesFolder); + } + + public boolean isFavoritesFolderInvisible() { + return isLocatorInvisible(nameFavoritesFolder); + } + + public void tapPeopleFolder() { + peopleFolder.click(); + } + + public void tapFavoritesFolder() { + getElement(nameFavoritesFolder).click(); + } + public void tapCustomFolder(String folderName) { + getElement(MobileBy.AccessibilityId(folderName.toUpperCase())).click(); + } + + public boolean isGroupFolderVisible() { + return isLocatorDisplayed(nameGroupFolder); + } + + public boolean isGroupFolderInvisible() { + return isLocatorInvisible(nameGroupFolder); + } + + public boolean isCustomFolderVisible(String name) { + return isLocatorDisplayed(MobileBy.AccessibilityId(name.toUpperCase())); + } + + public boolean isCustomFolderInvisible(String name) { + return isLocatorInvisible(MobileBy.AccessibilityId(name.toUpperCase())); + } + + public boolean isFolderExpanded(String folderName) { + return isLocatorDisplayed(predicateStrIsFolderExpanded.apply(folderName.toUpperCase())); + } + + public boolean isFolderCollapsed(String folderName) { + return isLocatorDisplayed(predicateStrIsFolderCollapsed.apply(folderName.toUpperCase())); + } + + public boolean isConversationInGroupsFolder(String conversationName) { + final By locator = MobileBy.iOSClassChain(classChainStrConversationInGroupsFolderByName.apply(conversationName)); + return isLocatorDisplayed(locator); + } + + public boolean isConversationNotInGroupsFolder(String conversationName) { + final By locator = MobileBy.iOSClassChain(classChainStrConversationInGroupsFolderByName.apply(conversationName)); + return isLocatorInvisible(locator); + } + + public boolean isConversationInFavoritesFolder(String conversationName) { + final By locator = MobileBy.iOSClassChain(classChainStrConversationInFavoritesFolderByName.apply(conversationName)); + return isLocatorDisplayed(locator); + } + + public boolean isConversationNotInFavoritesFolder(String conversationName) { + final By locator = MobileBy.iOSClassChain(classChainStrConversationInFavoritesFolderByName.apply(conversationName)); + return isLocatorInvisible(locator); + } + + public List getConversationOfPeopleFolder() { + List buttons = peopleFolderItems.findElements(classChainConversationInList); + return buttons.stream().map(button -> button.getAttribute("label")).collect(Collectors.toList()); + } + + public List getConversationsInCustomFolder(String folderName) { + List folders = getConversationsWithFolderInformation(); + for (Folder folder : folders) { + log.info("Folder " + folder.name + " contains: " + String.join(",", folder.conversations)); + if (folder.name.equalsIgnoreCase(folderName)) { + return folder.conversations; + } + } + return Collections.emptyList(); + } + + // data structure to hold folder names and conversations in folder + private class Folder { + public String name; + public List conversations = new ArrayList<>(); + + public Folder(String name) { + this.name = name; + } + + public void addConversation(String name) { + conversations.add(name); + } + } + + // This method creates a map containing the conversation name and the custom folder in which it is + public List getConversationsWithFolderInformation() { + List folders = new ArrayList<>(); + Folder currentFolder = null; + // Create + for (WebElement element : conversationListItems) { + if (element.getAttribute("type").equals("XCUIElementTypeOther")) { + // If folder element: remember which is the current custom folder + currentFolder = new Folder(element.getAttribute("label")); + folders.add(currentFolder); + } else if (element.getAttribute("type").equals("XCUIElementTypeCell")) { + // If conversation element: get individual conversation element + WebElement button = element.findElement(classChainConversationInList); + currentFolder.addConversation(button.getAttribute("label")); + } + } + return folders; + } + + public boolean isConversationItemWithStatusVisible(String status, String conversation) { + final WebElement root = getElement(classFolderViewRoot); + return isLocatorDisplayed(root, predicatedStrRelativeConversationStatusByValue.apply(conversation, status)); + } + + public boolean isConversationItemWithStatusInvisible(String status, String conversation) { + final WebElement root = getElement(classFolderViewRoot); + return isLocatorInvisible(root, predicatedStrRelativeConversationStatusByValue.apply(conversation, status)); + } + + public boolean isSecondaryLineVisible(String conversation, String secondaryLine) { + final By locator = predicateStrSecondaryLine.apply(conversation, secondaryLine); + return isLocatorDisplayed(locator, Timedelta.ofSeconds(3)); + } + + public boolean isSecondaryLineInvisible(String conversation, String secondaryLine) { + final By locator = predicateStrSecondaryLine.apply(conversation, secondaryLine); + return isLocatorInvisible(locator, Timedelta.ofSeconds(3)); + } + + public void tapConversationItemGroupedList(String conversationName) { + getConversationsListItem(conversationName, Timedelta.ofSeconds(10)).click(); + } + + public void swipeRightOnGroupedConversation(String name) { + final WebElement convoListItem = getConversationsListItem(name, Timedelta.ofSeconds(5)); + if (!CommonUtils.waitUntilTrue(Timedelta.ofSeconds(5), Timedelta.ofMillis(1), () -> { + swipe(convoListItem, SwipeDirection.RIGHT); + return isElementInvisible(convoListItem, Timedelta.ofSeconds(1)); + })) { + log.warning(String.format("The conversation item '%s' is still visible after being swiped to the right", name)); + } + } + + private WebElement getConversationsListItem(String name, Timedelta timeout) { + final By locator = predicateStrConversationByLabel.apply(name); + return getElement(locator, + String.format("The conversation '%s' is not visible in the list after %s", name, timeout), timeout); + } + + public boolean isBadgeCountForFolder(String folderName, int number) { + final By locator = MobileBy.iOSNsPredicateString(predicateStrFolderBadgeCount.apply(folderName.toUpperCase(), number)); + return isLocatorDisplayed(locator); + } + + public boolean isBadgeCountInvisibleForFolder(String folderName) { + final By locator = MobileBy.iOSNsPredicateString(predicateStrFolderBadge.apply(folderName.toUpperCase())); + return isLocatorInvisible(locator); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ForwardPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ForwardPage.java new file mode 100644 index 00000000000..710825578b9 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ForwardPage.java @@ -0,0 +1,90 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import java.util.function.Function; + +public class ForwardPage extends IOSPage { + + public ForwardPage(WebDriver driver) { + super(driver); + } + + @iOSXCUITFindBy(accessibility = "shield icon") + private WebElement shieldIcon; + + @iOSXCUITFindBy(accessibility = "legalHoldIcon") + private WebElement legalHoldIcon; + + @iOSXCUITFindBy(accessibility = "guestUserIcon") + private WebElement guestUserIcon; + + @iOSXCUITFindBy(accessibility = "img.external") + private WebElement externalIcon; + + @iOSXCUITFindBy(accessibility = "send") + private WebElement sendButton; + + @iOSXCUITFindBy(accessibility = "close") + private WebElement closeButton; + + private static final Function conversationLocatorByName = name -> MobileBy.iOSClassChain( + String.format("**/XCUIElementTypeOther[$name == 'textViewSearch'$]" + + "/**/XCUIElementTypeCell[$type == 'XCUIElementTypeStaticText' AND name == '%s'$]", + name)); + + public void selectConversation(String name) { + getElement(conversationLocatorByName.apply(name)).click(); + } + + public void tapSendButton() { + sendButton.click(); + } + + public void tapCloseButton() { + closeButton.click(); + } + + public boolean isConversationVisible(String name) { + return getDriver().findElement(conversationLocatorByName.apply(name)).isDisplayed(); + } + + public boolean isConversationInvisible(String name) { + return isLocatorInvisible(conversationLocatorByName.apply(name)); + } + + public boolean isShieldIconVisible() { + return shieldIcon.isDisplayed(); + } + + public boolean isShieldIconInvisible() { + return isElementInvisible(shieldIcon); + } + + public boolean isLegalHoldIndicatorVisible() { + return legalHoldIcon.isDisplayed(); + } + + public boolean isLegalHoldIndicatorInvisible() { + return isElementInvisible(legalHoldIcon); + } + + public boolean isGuestIconVisible() { + return guestUserIcon.isDisplayed(); + } + + public boolean isGuestIconInvisible() { + return isElementInvisible(guestUserIcon); + } + + public boolean isExternalIconVisible() { + return externalIcon.isDisplayed(); + } + + public boolean isExternalIconInvisible() { + return isElementInvisible(externalIcon); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/GiphyPreviewPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/GiphyPreviewPage.java new file mode 100644 index 00000000000..edf8cfe27b7 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/GiphyPreviewPage.java @@ -0,0 +1,49 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class GiphyPreviewPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "CANCEL") + private WebElement cancelButton; + + @iOSXCUITFindBy(accessibility = "SEND") + private WebElement sendButton; + + @iOSXCUITFindBy(accessibility = "giphyCollectionView") + private WebElement previewGrid; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeCollectionView/*[1]") + private WebElement firstGiphyInGrid; + + public GiphyPreviewPage(WebDriver driver) { + super(driver); + } + + public boolean isGridVisible() { + return previewGrid.isDisplayed(); + } + + public void selectFirstItem() { + waitUntilElementVisible(firstGiphyInGrid); + firstGiphyInGrid.click(); + } + + public void tapSendButton() { + sendButton.click(); + } + + public void tapCancelButton() { + cancelButton.click(); + } + + public boolean isSendButtonVisible() { + return sendButton.isDisplayed(); + } + + public boolean isCancelButtonVisible() { + return cancelButton.isDisplayed(); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/HistoryBackupPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/HistoryBackupPage.java new file mode 100644 index 00000000000..e61737c4474 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/HistoryBackupPage.java @@ -0,0 +1,26 @@ +package com.wearezeta.auto.ios.pages; + +import com.wearezeta.auto.common.misc.Timedelta; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class HistoryBackupPage extends IOSPage { + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeCell[$type == 'XCUIElementTypeStaticText' AND value == 'Back Up Now'$]") + private WebElement backUpNowButton; + + public static final Timedelta BACKUP_TIMEOUT = Timedelta.ofSeconds(15); + + public HistoryBackupPage(WebDriver driver) { + super(driver); + } + + public void initiateHistoryBackup() { + backUpNowButton.click(); + } + + public boolean isBackupNowButtonShown() { + return isElementVisible(backUpNowButton, BACKUP_TIMEOUT); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/IOSPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/IOSPage.java new file mode 100644 index 00000000000..88e16480200 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/IOSPage.java @@ -0,0 +1,1065 @@ +package com.wearezeta.auto.ios.pages; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.google.common.collect.ImmutableMap; +import com.wearezeta.auto.common.Config; +import com.wearezeta.auto.common.ImageUtil; +import com.wearezeta.auto.common.backend.Backend; +import com.wearezeta.auto.common.backend.BackendConnections; +import com.wearezeta.auto.common.email.MailboxProvider; +import com.wearezeta.auto.common.email.handlers.ISupportsMessagesPolling; +import com.wearezeta.auto.common.email.messages.VerificationMessage; +import com.wearezeta.auto.common.email.messages.WireMessage; +import com.wearezeta.auto.common.imagecomparator.QRCode; +import com.wearezeta.auto.common.log.ZetaLogger; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.common.Lifecycle; +import io.appium.java_client.AppiumBy; +import io.appium.java_client.MobileBy; +import io.appium.java_client.ios.IOSDriver; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; + +import java.util.logging.Logger; + +import org.openqa.selenium.*; +import com.wearezeta.auto.ios.pages.keyboard.IOSKeyboard; +import org.openqa.selenium.Dimension; +import org.openqa.selenium.NoSuchElementException; +import org.openqa.selenium.Point; +import org.openqa.selenium.Rectangle; +import org.openqa.selenium.TimeoutException; +import org.openqa.selenium.interactions.Actions; +import org.openqa.selenium.logging.LogEntries; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.openqa.selenium.remote.RemoteWebElement; +import org.openqa.selenium.support.ui.*; + +import javax.annotation.Nullable; +import javax.imageio.ImageIO; + +public class IOSPage { + private static final Logger log = ZetaLogger.getLog(IOSPage.class.getSimpleName()); + + @iOSXCUITFindBy(accessibility = "Allow Access to All Photos") + private WebElement allowAccessToAllPhotosItem; + + @iOSXCUITFindBy(accessibility = "Not Now") + private WebElement notNowButton; + + private static final String CRASHLOG = "crashlog"; + private static final String SERVER_LOG = "server"; + private static final String STRING_NOTIFICATION_ALERT = "Would Like to Send You Notifications"; + + private static final int DEFAULT_RETRY_COUNT = 2; + + private static final Function predicateAlertLabelByText = text -> + MobileBy.iOSNsPredicateString(String.format("label CONTAINS '%s'", text)); + + private static final By classAlertTitle = By.className("XCUIElementTypeAlert"); + + private static final By classAlertDescription = By.xpath("//XCUIElementTypeAlert//XCUIElementTypeStaticText[2]"); + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeButton' AND name == 'Cancel'") + private WebElement cancelButton; + + // Self node can only be found by xpath + @iOSXCUITFindBy(xpath = "//XCUIElementTypeApplication[@name='Maps']") + private WebElement mapsApplicationButton; + + private IOSKeyboard onScreenKeyboard; + + protected final WebDriver driver; + + public IOSPage(WebDriver driver) { + this.driver = driver; + } + + protected IOSDriver getDriver() { + return (IOSDriver) this.driver; + } + + private IOSKeyboard getOnScreenKeyboard() { + if (this.onScreenKeyboard == null) { + this.onScreenKeyboard = new IOSKeyboard(getDriver()); + } + return this.onScreenKeyboard; + } + + public boolean isKeyboardVisible() { + return this.getOnScreenKeyboard().isVisible(); + } + + public boolean isKeyboardInvisible(Timedelta timeout) { + return this.getOnScreenKeyboard().isInvisible(timeout); + } + + public void tapHideKeyboardButton() { + //The Appium method hideKeyboard() is known to be unstable + // https://developers.perfectomobile.com/display/TT/iOS+Limitation%3A+Appium+hideKeyboard+Method + //getDriver().hideKeyboard(); + this.getOnScreenKeyboard().pressHideButton(); + } + + public void tapSpaceKeyboardButton() { + this.getOnScreenKeyboard().pressSpaceButton(); + } + + public void tapNextKeyboardButton() { + this.getOnScreenKeyboard().pressNextButton(); + } + + public void tapKeyboardCommitButton() { + this.getOnScreenKeyboard().pressCommitButton(); + } + + public boolean waitUntilAlertIsVisible(Duration timeout) { + new WebDriverWait(getDriver(), timeout) + .ignoring(NoAlertPresentException.class) + .withMessage("No alert has been shown") + .until(ExpectedConditions.alertIsPresent()); + return true; + } + + public boolean waitUntilAlertIsVisible(int seconds) { + return getElementIfDisplayed(classAlertTitle, Timedelta.ofSeconds(seconds)).isPresent(); + } + + public boolean acceptAlertIfVisible() { + try { + acceptAlert(Timedelta.ofSeconds(5)); + return true; + } catch (TimeoutException e) { + log.info(String.format("Did not accept the alert: %s", e.getMessage())); + return false; + } + } + + public boolean isNotNowOnPasswordPromptVisible() { + log.info("Password keychain shown?"); + try { + getDriver().manage().timeouts().implicitlyWait(Duration.ZERO); + new WebDriverWait(getDriver(), Duration.ofSeconds(2)) + .ignoring(StaleElementReferenceException.class) + .until(driver -> !driver.findElements(AppiumBy.accessibilityId("Not Now")).isEmpty()); + log.info("Password keychain shown"); + return true; + } catch (TimeoutException e) { + log.info("Password keychain question was not shown"); + return false; + } finally { + getDriver().manage().timeouts().implicitlyWait(Duration.ofSeconds(getDefaultLookupTimeoutSeconds())); + } + } + + public void tapNotNowOnPasswordPrompt() { + notNowButton.click(); + } + + public void acceptNotificationAlertIfVisible() { + if (isAlertContainsText(STRING_NOTIFICATION_ALERT)) { + acceptAlert(); + } + } + + public void acceptAlert() { + acceptAlert(getDefaultLookupTimeout()); + } + + public void acceptAlert(Timedelta timeout) { + if (waitUntilAlertIsVisible(timeout.asDuration())) { + getDriver().switchTo().alert().accept(); + } else { + throw new TimeoutException("Alert did not appear after " + timeout.toString()); + } + } + + public void acceptAccessToAllPhotos() { + allowAccessToAllPhotosItem.click(); + } + + public void performTouchID(boolean match) { + getDriver().performTouchID(match); + } + + public void typeAlertText(String text) { + getDriver().switchTo().alert().sendKeys(text); + } + + private enum AlertAction { + ACCEPT, DISMISS, TAP_BUTTON + } + + private void handleAlert(AlertAction action, @Nullable String buttonLabel, Timedelta timeout) { + if (action != AlertAction.TAP_BUTTON && buttonLabel != null) { + log.warning(String.format("Button caption '%s' is only supported for '%s' alert action", buttonLabel, + AlertAction.TAP_BUTTON.name())); + } + final Timedelta started = Timedelta.now(); + do { + try { + switch (action) { + case ACCEPT: + getDriver().switchTo().alert().accept(); + return; + case DISMISS: + getDriver().switchTo().alert().dismiss(); + return; + case TAP_BUTTON: + assert buttonLabel != null; + getElement(MobileBy.AccessibilityId(buttonLabel)).click(); + return; + default: + throw new IllegalArgumentException(String.format("Illegal alert action '%s'", action.name())); + } + } catch (NoAlertPresentException e) { + Timedelta.ofSeconds(1).sleep(); + } + } while (Timedelta.now().isDiffLessOrEqual(started, timeout)); + throw new IllegalStateException( + String.format("No alert has been shown or it cannot be %s after %s", + (action == AlertAction.ACCEPT) ? "accepted" : "dismissed", timeout) + ); + } + + public String getAlertTitle() { + waitUntilLocatorVisible(classAlertTitle); + return getDriver().findElement(classAlertTitle).getText(); + } + + public String getAlertDescription() { + waitUntilLocatorVisible(classAlertDescription); + return getDriver().findElement(classAlertDescription).getText(); + } + + public boolean isAlertContainsText(String expectedText) { + final By locator = predicateAlertLabelByText.apply(expectedText); + final Optional alert = getElementIfExists(classAlertTitle); + return alert.isPresent() && isLocatorDisplayed(alert.get(), locator); + } + + public static boolean isTablet() { + return Config.current().isTablet(IOSPage.class); + } + + public boolean isAlertDoesNotContainsText(String expectedText) { + final By locator = predicateAlertLabelByText.apply(expectedText); + final Optional alert = getElementIfExists(classAlertTitle); + return alert.isEmpty() || isLocatorInvisible(alert.get(), locator); + } + + public void putWireToBackgroundFor(Timedelta duration) { + this.getDriver().runAppInBackground(duration.asDuration()); + } + + public void pressHomeButton() { + getDriver().runAppInBackground(Duration.ofMillis(-1)); + } + + public void rotateScreen(ScreenOrientation orientation) { + switch (orientation) { + case LANDSCAPE: + rotateLandscape(); + break; + case PORTRAIT: + rotatePortrait(); + break; + default: + throw new IllegalArgumentException(String.format("Unknown orientation '%s'", + orientation)); + } + } + + private void rotateLandscape() { + this.getDriver().rotate(ScreenOrientation.LANDSCAPE); + } + + private void rotatePortrait() { + this.getDriver().rotate(ScreenOrientation.PORTRAIT); + } + + public void lockScreen(Timedelta duration) { + this.getDriver().lockDevice(duration.asDuration()); + } + + protected void tapElementWithRetryIfStillDisplayed(WebElement el, Timedelta delay, int retryCount) { + int counter = 0; + do { + try { + el.click(); + } catch (WebDriverException | IllegalStateException e) { + log.fine(e.getMessage()); + continue; + } + if (isElementInvisible(el, delay)) { + return; + } + } while (++counter < retryCount); + throw new IllegalStateException(String.format("Locator %s is still displayed after %s tap retries", + el, retryCount)); + } + + protected void tapElementWithRetryIfStillDisplayed(WebElement el) { + tapElementWithRetryIfStillDisplayed(el, Timedelta.ofSeconds(3), DEFAULT_RETRY_COUNT); + } + + protected void tapElementWithRetryIfNextElementNotAppears(By locator, By nextLocator, Timedelta delay, int retryCount) { + int counter = 0; + do { + final Optional dstElement = getElementIfExists(locator); + if (dstElement.isPresent()) { + dstElement.get().click(); + if (isLocatorExist(nextLocator, delay)) { + return; + } + } + } while (++counter < retryCount); + throw new IllegalStateException(String.format("Locator %s did't appear", nextLocator)); + } + + //region Elements location + + protected WebElement getElement(WebElement parent, By locator) { + return this.getElement(parent, locator, + String.format("The element '%s' is not visible", locator), + getDefaultLookupTimeout() + ); + } + + @Deprecated // Please use @iOSXCUITFindBy or getDriver().findElement() instead + protected WebElement getElement(By locator) { + return this.getElement(locator, + String.format("The element '%s' is not visible", locator), + getDefaultLookupTimeout()); + } + + protected WebElement getElement(WebElement parent, By locator, String message) { + return this.getElement(parent, locator, message, getDefaultLookupTimeout()); + } + + @Deprecated // Please use @iOSXCUITFindBy or getDriver().findElement() instead + protected WebElement getElement(By locator, String message) { + return this.getElement(locator, message, getDefaultLookupTimeout()); + } + + private static final double MAX_EXISTENCE_DELAY_MS = 2000.0; + private static final long MIN_EXISTENCE_ITERATIONS_COUNT = 2; + + protected WebElement getElement(@Nullable WebElement parent, By locator, String message, + Timedelta timeout) { + final Timedelta started = Timedelta.now(); + int iterationNumber = 1; + do { + try { + final WebElement el; + if (parent == null) { + el = getDriver().findElement(locator); + } else { + el = parent.findElement(locator); + } + if (el.isDisplayed()) { + return el; + } + } catch (WebDriverException e) { + log.info("Failed to get element: " + e.getMessage()); + } + log.fine(String.format("The element '%s' is still not visible after %s", + locator, Timedelta.now().diff(started).toString())); + Timedelta.ofMillis(MAX_EXISTENCE_DELAY_MS / iterationNumber).sleep(); + iterationNumber++; + } while (Timedelta.now().isDiffLessOrEqual(started, timeout) || + iterationNumber <= MIN_EXISTENCE_ITERATIONS_COUNT); + throw new IllegalStateException(message); + } + + @Deprecated // Please use @iOSXCUITFindBy or getDriver().findElement() instead + protected WebElement getElement(By locator, String message, Timedelta timeout) { + return getElement(null, locator, message, timeout); + } + + @Deprecated // please use waitUntilLocatorVisible() + protected boolean isLocatorExist(By locator) { + return this.isLocatorExist(locator, getDefaultLookupTimeout()); + } + + protected boolean isLocatorExist(By locator, Timedelta timeout) { + final Timedelta started = Timedelta.now(); + int iterationNumber = 1; + do { + try { + final WebElement el = getDriver().findElement(locator); + if (el != null) { + return true; + } + } catch (WebDriverException e) { + // simply ignore + } + log.fine(String.format("The element '%s' is still not present after %s", + locator, Timedelta.now().diff(started).toString())); + Timedelta.ofMillis(MAX_EXISTENCE_DELAY_MS / iterationNumber).sleep(); + iterationNumber++; + } while (Timedelta.now().isDiffLessOrEqual(started, timeout) || + iterationNumber <= MIN_EXISTENCE_ITERATIONS_COUNT); + return false; + } + + protected boolean isLocatorNotExist(By locator) { + return isLocatorNotExist(locator, getDefaultLookupTimeout()); + } + + protected boolean isLocatorNotExist(By locator, Timedelta timeout) { + final Timedelta started = Timedelta.now(); + int iterationNumber = 1; + do { + try { + final WebElement el = getDriver().findElement(locator); + if (el == null) { + return true; + } + } catch (WebDriverException e) { + return true; + } + log.fine(String.format("The element '%s' is still present after %s", + locator, Timedelta.now().diff(started).toString())); + Timedelta.ofMillis(MAX_EXISTENCE_DELAY_MS / iterationNumber).sleep(); + iterationNumber++; + } while (Timedelta.now().isDiffLessOrEqual(started, timeout) || + iterationNumber <= MIN_EXISTENCE_ITERATIONS_COUNT); + return false; + } + + protected boolean isLocatorDisplayed(WebElement parent, By locator) { + return this.isLocatorDisplayed(parent, locator, getDefaultLookupTimeout()); + } + + @Deprecated // please use waitUntilLocatorVisible() + protected boolean isLocatorDisplayed(By locator) { + return this.isLocatorDisplayed(locator, getDefaultLookupTimeout()); + } + + protected boolean isLocatorDisplayed(@Nullable WebElement parent, By locator, Timedelta timeout) { + final Timedelta started = Timedelta.now(); + int iterationNumber = 1; + do { + try { + final WebElement el; + if (parent == null) { + el = getDriver().findElement(locator); + } else { + el = parent.findElement(locator); + } + if (el.isDisplayed()) { + return true; + } + } catch (WebDriverException e) { + // simply ignore + } + log.fine(String.format("The element '%s' is still not visible after %s", + locator, Timedelta.now().diff(started).toString())); + Timedelta.ofMillis(MAX_EXISTENCE_DELAY_MS / iterationNumber).sleep(); + iterationNumber++; + } while (Timedelta.now().isDiffLessOrEqual(started, timeout) || + iterationNumber <= MIN_EXISTENCE_ITERATIONS_COUNT); + return false; + } + + protected boolean isLocatorDisplayed(By locator, Timedelta timeout) { + return isLocatorDisplayed(null, locator, timeout); + } + + protected boolean isLocatorInvisible(By locator) { + return this.isLocatorInvisible(locator, getDefaultLookupTimeout()); + } + + protected boolean isLocatorInvisible(WebElement parent, By locator) { + return this.isLocatorInvisible(parent, locator, getDefaultLookupTimeout()); + } + + protected boolean isLocatorInvisible(@Nullable WebElement parent, By locator, Timedelta timeout) { + // TODO: Replace while loop with FluentWait + final Timedelta started = Timedelta.now(); + int iterationNumber = 1; + do { + try { + final WebElement el; + if (parent == null) { + el = getDriver().findElement(locator); + } else { + el = parent.findElement(locator); + } + if (!el.isDisplayed()) { + return true; + } + } catch (WebDriverException e) { + return true; + } + log.fine(String.format("The element '%s' is still visible after %s", + locator, Timedelta.now().diff(started).toString())); + Timedelta.ofMillis(MAX_EXISTENCE_DELAY_MS / iterationNumber).sleep(); + iterationNumber++; + } while (Timedelta.now().isDiffLessOrEqual(started, timeout) || + iterationNumber <= MIN_EXISTENCE_ITERATIONS_COUNT); + return false; + } + + protected boolean isLocatorInvisible(By locator, Timedelta timeout) { + return isLocatorInvisible(null, locator, timeout); + } + + @Deprecated // Please use waitUntilElementInvisible + protected boolean isElementInvisible(WebElement element) { + return this.isElementInvisible(element, getDefaultLookupTimeout()); + } + + protected boolean isElementInvisible(WebElement el, Timedelta timeout) { + final Timedelta started = Timedelta.now(); + int iterationNumber = 1; + do { + try { + if (!el.isDisplayed()) { + return true; + } + } catch (WebDriverException e) { + return true; + } + log.fine(String.format("The element '%s' is still visible after %s", + el, Timedelta.now().diff(started).toString())); + Timedelta.ofMillis(MAX_EXISTENCE_DELAY_MS / iterationNumber).sleep(); + iterationNumber++; + } while (Timedelta.now().isDiffLessOrEqual(started, timeout) || + iterationNumber <= MIN_EXISTENCE_ITERATIONS_COUNT); + return false; + } + + protected boolean isElementVisible(WebElement element) { + return this.isElementVisible(element, getDefaultLookupTimeout()); + } + + protected boolean isElementVisible(WebElement el, Timedelta timeout) { + final Timedelta started = Timedelta.now(); + int iterationNumber = 1; + do { + try { + if (el.isDisplayed()) { + return true; + } + } catch (WebDriverException e) { + // Element might not exist yet, ignore + } + log.fine(String.format("The element '%s' is still invisible after %s", + el, Timedelta.now().diff(started).toString())); + Timedelta.ofMillis(MAX_EXISTENCE_DELAY_MS / iterationNumber).sleep(); + iterationNumber++; + } while (Timedelta.now().isDiffLessOrEqual(started, timeout) || + iterationNumber <= MIN_EXISTENCE_ITERATIONS_COUNT); + return false; + } + + protected Optional getElementIfDisplayed(@Nullable WebElement parent, By locator, Timedelta timeout) { + final Timedelta started = Timedelta.now(); + int iterationNumber = 1; + do { + try { + final WebElement el; + if (parent == null) { + el = getDriver().findElement(locator); + } else { + el = parent.findElement(locator); + } + if (el.isDisplayed()) { + return Optional.of(el); + } + } catch (WebDriverException e) { + // simply ignore + } + log.fine(String.format("The element '%s' is still not visible after %s", + locator, Timedelta.now().diff(started).toString())); + Timedelta.ofMillis(MAX_EXISTENCE_DELAY_MS / iterationNumber).sleep(); + iterationNumber++; + } while (Timedelta.now().isDiffLessOrEqual(started, timeout)); + return Optional.empty(); + } + + protected Optional getElementIfDisplayed(By locator, Timedelta timeout) { + return getElementIfDisplayed(null, locator, timeout); + } + + protected Optional getElementIfExists(By locator) { + return this.getElementIfExists(locator, getDefaultLookupTimeout()); + } + + protected Optional getElementIfExists(By locator, Timedelta timeout) { + final Timedelta started = Timedelta.now(); + int iterationNumber = 1; + do { + try { + final WebElement el = getDriver().findElement(locator); + if (el != null) { + return Optional.of(el); + } + } catch (WebDriverException e) { + // simply ignore + } + log.fine(String.format("The element '%s' is still not present after %s", + locator, Timedelta.now().diff(started).toString())); + Timedelta.ofMillis(MAX_EXISTENCE_DELAY_MS / iterationNumber).sleep(); + iterationNumber++; + } while (Timedelta.now().isDiffLessOrEqual(started, timeout)); + return Optional.empty(); + } + + protected List selectVisibleElements(By locator) { + return selectVisibleElements(null, locator, getDefaultLookupTimeout()); + } + + protected List selectVisibleElements(@Nullable WebElement parent, By locator) { + return selectVisibleElements(parent, locator, getDefaultLookupTimeout()); + } + + protected List selectVisibleElements(By locator, Timedelta timeout) { + return selectVisibleElements(null, locator, timeout); + } + + protected List selectVisibleElements(@Nullable WebElement parent, By locator, Timedelta timeout) { + return selectElements(parent, locator, timeout) + .stream() + .filter(WebElement::isDisplayed) + .collect(Collectors.toList()); + } + + protected List selectElements(@Nullable WebElement parent, By locator, Timedelta timeout) { + final List result = new ArrayList<>(); + final Timedelta started = Timedelta.now(); + int iterationNumber = 1; + do { + if (parent == null) { + result.addAll(getDriver().findElements(locator)); + } else { + result.addAll(parent.findElements(locator)); + } + if (result.size() > 0) { + return result; + } + Timedelta.ofMillis(MAX_EXISTENCE_DELAY_MS / iterationNumber).sleep(); + iterationNumber++; + } while (Timedelta.now().isDiffLessOrEqual(started, timeout)); + return result; + } + + //endregion + + @Deprecated // Please create individual page and step classes for such buttons + public void tapCancelButton() { + waitUntilElementClickable(cancelButton); + cancelButton.click(); + } + + public void installApp(File appFile) { + getDriver().installApp(appFile.getAbsolutePath()); + // simctl returns too early sometimes + Timedelta.ofSeconds(3).sleep(); + } + + private static Point sizePercentsToRelativeCoordinates(WebElement el, int percentX, int percentY) { + final Rectangle elRect = el.getRect(); + final int tapX = elRect.width * percentX / 100; + final int tapY = elRect.height * percentY / 100; + return new Point(tapX, tapY); + } + + protected void longTapWithScript(WebElement el) { + getDriver().executeScript("mobile: touchAndHold", + ImmutableMap.of("element", ((RemoteWebElement) el).getId(), + "duration", (Duration.ofSeconds(3).toMillis() * 1.0 / 1000))); + } + + protected void longTapWithScript(WebElement el, int percentX, int percentY) { + final Point tapPoint = sizePercentsToRelativeCoordinates(el, percentX, percentY); + getDriver().executeScript("mobile: touchAndHold", + ImmutableMap.of("element", ((RemoteWebElement) el).getId(), + "x", tapPoint.x, + "y", tapPoint.y, + "duration", (Duration.ofSeconds(3).toMillis() * 1.0 / 1000))); + } + + protected void longTapWithActionsAPI(WebElement el) throws InterruptedException { + longTapWithActionsAPI(el, Duration.ofSeconds(2)); + } + + protected void longTapWithActionsAPI(WebElement el, Duration duration) throws InterruptedException { + Actions actions = new Actions(getDriver()); + actions.moveToElement(el).clickAndHold().pause(duration).release().perform(); + } + + protected void tapByPercentOfElementSize(WebElement el, int percentX, int percentY) { + //TODO adjust method to calculate offset needed instead of by percentage, or refactor to remove behaviour in general + final Point tapPoint = sizePercentsToRelativeCoordinates(el, percentX, percentY); + + Actions actions = new Actions(getDriver()); + actions.click(el).perform(); + } + + protected void tapAtTheCenterOfElement(WebElement el) { + tapByPercentOfElementSize(el, 50, 50); + } + + protected void tapAtTheLeftSideOfElement(WebElement el) { + Actions actions = new Actions(getDriver()); + actions.click(el).perform(); + } + + protected void tapScreenAt(int x, int y) { + getDriver().executeScript("mobile: tap", + ImmutableMap.of("x", x, + "y", y)); + } + + public void tapScreenByPercents(int percentX, int percentY) { + final Dimension size = getDriver().manage().window().getSize(); + tapScreenAt(percentX * size.getWidth() / 100, percentY * size.getHeight() / 100); + } + + public Optional readAlertText() { + return readAlertText(getDefaultLookupTimeout()); + } + + private Optional readAlertText(Timedelta timeout) { + final Timedelta start = Timedelta.now(); + do { + try { + final String text = getDriver().switchTo().alert().getText(); + if (text != null && text.length() > 0 && !text.equals("null") && !text.equals("{}")) { + return Optional.of(text); + } + } catch (WebDriverException e) { + Timedelta.ofSeconds(1).sleep(); + } + } while (Timedelta.now().isDiffLessOrEqual(start, timeout)); + return Optional.empty(); + } + + public void tapAlertButton(String caption) { + handleAlert(AlertAction.TAP_BUTTON, caption, getDefaultLookupTimeout()); + } + + public boolean isAlertButtonVisible(String caption) { + HashMap args = new HashMap<>(); + args.put("action", "getButtons"); + List buttons = (List) getDriver().executeScript("mobile: alert", args); + + for (String button : buttons) { + if (button.equals(caption)) { + return true; + } + } + return false; + } + + public Optional takeScreenshot() { + Optional screenshotImage; + try { + screenshotImage = takeFullScreenShot(); + } catch (Exception e) { + return Optional.empty(); + } + if (screenshotImage.isPresent()) { + final Dimension screenSize = getDriver().manage().window().getSize(); + if (screenshotImage.get().getWidth() != screenSize.getWidth()) { + // proportions are expected to be the same + final double scale = 1.0 * screenSize.getWidth() / screenshotImage.get().getWidth(); + screenshotImage = Optional.of( + ImageUtil.resizeImage(screenshotImage.get(), (float) scale) + ); + } + } + return screenshotImage; + } + + public Optional getElementScreenshot(WebElement element) { + final byte[] srcImage = element.getScreenshotAs(OutputType.BYTES); + try { + return Optional.ofNullable(ImageIO.read(new ByteArrayInputStream(srcImage))); + } catch (IOException e) { + e.printStackTrace(); + return Optional.empty(); + } + } + + public boolean isDefaultMapApplicationVisible() { + return waitUntilElementVisible(mapsApplicationButton); + } + + public LogEntries getCrashLogs() { + return getDriver().manage().logs().get(CRASHLOG); + } + + public LogEntries getAppiumLogs() { + return getDriver().manage().logs().get(SERVER_LOG); + } + + public List getWireLogs() { + final String currentLogPath = String.format(Config.current().isSimulator(getClass()) + ? "@%s:data/Library/Caches/current.log" + : "@%s/Library/Caches/current.log", + Lifecycle.getBundleId()); + try { + final byte[] content = getDriver().pullFile(currentLogPath); + final String logContent = new String(content, Charset.forName("UTF-8")); + return Arrays.asList(logContent.split("\n")); + } catch (WebDriverException e) { + e.printStackTrace(); + } + return Collections.emptyList(); + } + + public enum SwipeDirection { + UP, DOWN, LEFT, RIGHT + } + + public void swipe(SwipeDirection direction) { + swipe(null, direction); + } + + protected WebElement swipe(@Nullable WebElement el, SwipeDirection direction) { + final Map args; + if (el == null) { + args = ImmutableMap.of("direction", direction.name().toLowerCase()); + } else { + args = ImmutableMap.of("direction", direction.name().toLowerCase(), + "element", ((RemoteWebElement) el).getId()); + } + getDriver().executeScript("mobile: swipe", args); + return el; + } + public void setClipboard(String text) { + getDriver().setClipboardText(text); + } + + public void terminateApp(String bundleId) { + getDriver().terminateApp(bundleId); + } + + public void openURL(String url) { + getDriver().get(url); + } + + public void openDeepLink(String deeplink, String browser) { + log.fine("deeplink: "+deeplink); + this.activateApp(browser); + this.openURL(deeplink); + } + + public void activateApp(String bundleId) { + getDriver().activateApp(bundleId); + } + + /* + * Former DriverUtils methods + */ + + protected static Timedelta getDefaultLookupTimeout() { + return Timedelta.ofSeconds(getDefaultLookupTimeoutSeconds()); + } + + public static int getDefaultLookupTimeoutSeconds() { + return Integer.parseInt(Config.current().getDriverTimeout(IOSPage.class)); + } + + public boolean waitUntilElementVisible(WebElement element) { + return waitUntilElementVisible(element, Duration.ofSeconds(getDefaultLookupTimeoutSeconds())); + } + + public boolean waitUntilElementVisible(WebElement element, Duration duration) { + new WebDriverWait(getDriver(), duration) + .ignoring(StaleElementReferenceException.class) + .until(ExpectedConditions.visibilityOf(element)); + return element.isDisplayed(); + } + + public boolean waitUntilElementInvisible(WebElement element) { + return waitUntilElementInvisible(element, Duration.ofSeconds(getDefaultLookupTimeoutSeconds())); + } + + public boolean waitUntilElementInvisible(WebElement element, Duration timeout) { + try { + // For checking invisibility the implicit wait needs to be deactivated + driver.manage().timeouts().implicitlyWait(0, TimeUnit.SECONDS); + FluentWait wait = new WebDriverWait(getDriver(), Duration.ofSeconds(getDefaultLookupTimeoutSeconds())) + .pollingEvery(Duration.ofMillis(500)) + .ignoring(StaleElementReferenceException.class); + // Unfortunately ExpectedConditions.invisibilityOf() cannot be used here, because selenium does not return + // true on a NoSuchElementException when using an element (for invisibilityOfElementLocated it works) + return wait.until((drv) -> { + try { + return !element.isDisplayed(); + } catch (NoSuchElementException e) { + return true; + } + }); + } catch (TimeoutException e) { + return false; + } finally { + driver.manage().timeouts().implicitlyWait(getDefaultLookupTimeoutSeconds(), TimeUnit.SECONDS); + } + } + + public boolean waitUntilElementClickable(WebElement element) { + try { + new WebDriverWait(getDriver(), Duration.ofSeconds(getDefaultLookupTimeoutSeconds())) + .ignoring(StaleElementReferenceException.class) + .until(ExpectedConditions.elementToBeClickable(element)); + Wait waitStopped = new FluentWait<>(getDriver()) + .withTimeout(Duration.ofSeconds(getDefaultLookupTimeoutSeconds())) + .pollingEvery(Duration.ofMillis(100)) + .ignoring(NoSuchElementException.class) + .ignoring(StaleElementReferenceException.class); + waitStopped.until(elementStoppedMoving(element)); + return true; + } catch (TimeoutException e) { + return false; + } + } + + private ExpectedCondition elementStoppedMoving(final WebElement element) { + return new ExpectedCondition() { + + private Point location = null; + + public WebElement apply(WebDriver driver) { + if (element.isDisplayed()) { + Point currentLocation = element.getLocation(); + if (currentLocation.equals(location)) { + return element; + } + location = currentLocation; + } + + return null; + } + + public String toString() { + return "steadiness of element " + element; + } + }; + } + + public boolean waitUntilLocatorVisible(By locator) { + return waitUntilLocatorVisible(locator, getDefaultLookupTimeout().asDuration()); + } + + public boolean waitUntilLocatorVisible(By locator, Duration duration) { + new WebDriverWait(getDriver(), duration) + .ignoring(StaleElementReferenceException.class) + .until(ExpectedConditions.visibilityOfElementLocated(locator)); + return true; + } + + public int waitUntilNumberOfElementsToBe(By locator, int number) { + // very slow on real device, so we increase timeout by 3 + return new WebDriverWait(getDriver(), Duration.ofSeconds(getDefaultLookupTimeoutSeconds() * 3L)) + .ignoring(StaleElementReferenceException.class) + .until(ExpectedConditions.numberOfElementsToBe(locator, number)) + .size(); + } + + public Optional takeFullScreenShot() { + try { + final byte[] srcImage = getDriver().getScreenshotAs(OutputType.BYTES); + final BufferedImage bImageFromConvert = ImageIO.read(new ByteArrayInputStream(srcImage)); + return Optional.ofNullable(bImageFromConvert); + } catch (WebDriverException | NoClassDefFoundError | IOException e) { + log.severe("Selenium driver has failed to take the screenshot of the current screen!"); + } + return Optional.empty(); + } + + public void pushFile(String fileName, String path) { + try { + getDriver().pushFile(fileName, new File(path)); + } catch (IOException inputOutputException) { + inputOutputException.printStackTrace(); + throw new IllegalArgumentException( + String.format("Something went wrong while pushing the file %s to the device", path)); + } + } + + public boolean doesFileExistOnDevice(String fileName) { + try { + getDriver().pullFile(fileName); + } catch (WebDriverException e) { + return false; + } + return true; + } + + public List waitUntilElementContainsQRCode(final WebElement element) { + try { + Wait wait = new FluentWait<>(getDriver()) + .withTimeout(Duration.ofSeconds(getDefaultLookupTimeoutSeconds())) + .pollingEvery(Duration.ofSeconds(1)) + .ignoring(NoSuchElementException.class) + .ignoring(StaleElementReferenceException.class); + return wait.until(elementContainsQRCode(element)); + } catch (TimeoutException e) { + return Collections.emptyList(); + } + } + + private ExpectedCondition> elementContainsQRCode(final WebElement element) { + return driver -> { + Optional screenshot = getElementScreenshot(element); + if (screenshot.isEmpty()) { + log.info("Could not get screenshot of element"); + return null; + } + BufferedImage actualImage = screenshot.get(); + List codes; + try { + codes = QRCode.readMultipleCodes(actualImage); + } catch (com.google.zxing.NotFoundException e) { + log.info("Element contains no QR code"); + return null; + } + if (codes.isEmpty()) { + return null; + } else { + return codes; + } + }; + } + + private static final String SAFARI = "com.apple.mobilesafari"; + + public void openDeepLinkForDefault() { + Backend backend = BackendConnections.getDefault(); + String protocolHandler = "wire"; + + if (backend.getBackendName().contains("column")) { + protocolHandler = backend.getBackendName().contains("column-1") ? "wire-bk-test" : "wire-c3-test"; + } + + String deeplink = backend.getDeeplinkForiOS(protocolHandler); + log.fine("deeplink: " + deeplink); + activateApp(SAFARI); + openURL(deeplink); + } + + public void startVerificationEmailMonitoring(ClientUser user, IOSTestContext context) throws Exception { + final Map expectedHeaders = new HashMap<>(); + expectedHeaders.put(WireMessage.ZETA_PURPOSE_HEADER_NAME, VerificationMessage.MESSAGE_PURPOSE); + + ISupportsMessagesPolling mailbox = MailboxProvider.getInstance(BackendConnections.get(user), user.getEmail()); + context.setVerificationMessage(mailbox.getMessage(expectedHeaders, VerificationMessage.ACTIVATION_TIMEOUT)); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ImageFullScreenPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ImageFullScreenPage.java new file mode 100644 index 00000000000..978a3911f45 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ImageFullScreenPage.java @@ -0,0 +1,35 @@ +package com.wearezeta.auto.ios.pages; + +import java.awt.image.BufferedImage; +import java.util.Optional; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class ImageFullScreenPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "close") + private WebElement closeButton; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeScrollView[`name == 'fullScreenPage'`]/XCUIElementTypeImage") + private WebElement fullScreenImage; + + public ImageFullScreenPage(WebDriver driver) { + super(driver); + } + + public boolean isImageFullScreenShown() { + return waitUntilElementVisible(fullScreenImage); + } + + public void tapFullScreenCloseButton() { + waitUntilElementClickable(closeButton); + closeButton.click(); + } + + public Optional getPreviewPictureScreenshot() { + return Optional.ofNullable(getElementScreenshot(fullScreenImage) + .orElseThrow(() -> new IllegalStateException("No visible images are detected in fullscreen mode"))); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/KeyboardGalleryPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/KeyboardGalleryPage.java new file mode 100644 index 00000000000..f36566e9560 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/KeyboardGalleryPage.java @@ -0,0 +1,42 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class KeyboardGalleryPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "cameraRollButton") + private WebElement openCameraRollButton; + + @iOSXCUITFindBy(accessibility = "fullscreenCameraButton") + private WebElement fullscreenCameraButton; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeOther[$name == 'cameraRollButton'$]/" + + "**/XCUIElementTypeCollectionView/XCUIElementTypeCell[2]") + private WebElement firstGalleryPicture; + + public KeyboardGalleryPage(WebDriver driver) { + super(driver); + } + + public void selectFirstPicture() { + firstGalleryPicture.click(); + } + + public void tapCameraRollButton(){ + openCameraRollButton.click(); + } + + public void tapFullScreenButton(){ + fullscreenCameraButton.click(); + } + + public boolean isFirstItemGalleryVisible(){ + return firstGalleryPicture.isDisplayed(); + } + + public boolean isFirstItemGalleryInvisible(){ + return isElementInvisible(firstGalleryPicture); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/LegalHoldOverviewPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/LegalHoldOverviewPage.java new file mode 100644 index 00000000000..95c14601615 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/LegalHoldOverviewPage.java @@ -0,0 +1,62 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import java.util.function.Function; + +public class LegalHoldOverviewPage extends IOSPage { + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeCollectionView[`name == 'list.legalhold'`]") + private WebElement legalHoldPage; + + private static final String classChainLegalHoldTitle = "**/XCUIElementTypeNavigationBar[`name == 'LEGAL HOLD'`]"; + + private static final String strItemName = "user_cell.name"; + + private static final String classChainstrViewRoot = "**/XCUIElementTypeCell[`name == 'participants.section.participants.cell'`]"; + + private final By classChainCloseButton = MobileBy.iOSClassChain(String.format("%s/XCUIElementTypeButton[$name == '%s'$]", + classChainLegalHoldTitle, "close")); + + private final Function classChainStrItemCellByName = name -> + String.format("%s/**/XCUIElementTypeStaticText[$name == '%s' AND value CONTAINS '%s'$]", + classChainstrViewRoot, strItemName, name); + + private final Function classChainItemCellByName = name -> MobileBy.iOSClassChain( + classChainStrItemCellByName.apply(name)); + + public LegalHoldOverviewPage(WebDriver driver) { + super(driver); + } + + public boolean isVisible() { + return legalHoldPage.isDisplayed(); + } + + public boolean isInvisible() { + return isElementInvisible(legalHoldPage); + } + + public boolean isMyselfVisible(String name) { + return isSubjectDisplayNameVisible(name+" (You)"); + } + + public boolean isSubjectDisplayNameVisible(String name) { + return isLocatorDisplayed(classChainItemCellByName.apply(name)); + } + + public boolean isSubjectDisplayNameInvisible(String name) { + return isLocatorInvisible(classChainItemCellByName.apply(name)); + } + + public void tapOnSubject(String name) { + getElement(classChainItemCellByName.apply(name)).click(); + } + + public void tapCloseButton() { + this.tapAtTheCenterOfElement(getElement(classChainCloseButton)); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/LoginPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/LoginPage.java new file mode 100644 index 00000000000..882514adfed --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/LoginPage.java @@ -0,0 +1,143 @@ +package com.wearezeta.auto.ios.pages; + +import com.wearezeta.auto.common.misc.Timedelta; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.TimeoutException; +import org.openqa.selenium.WebElement; + +import org.openqa.selenium.WebDriver; + +import java.time.Duration; +import java.util.logging.Logger; + +public class LoginPage extends IOSPage { + + Logger log = Logger.getLogger(LoginPage.class.getSimpleName()); + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'Connect to server'") + private WebElement connectToServerAlertTitle; + + @iOSXCUITFindBy(iOSNsPredicate = "name BEGINSWITH 'Open in \"Wire") + private WebElement openInWireAlertTitle; + + @iOSXCUITFindBy(accessibility = "UseEmail") + private WebElement switchToEmailLogin; + + @iOSXCUITFindBy(accessibility = "UsePhone") + private WebElement switchToPhoneLogin; + + @iOSXCUITFindBy(accessibility = "Log in") + private WebElement loginScreen; + + @iOSXCUITFindBy(accessibility = "restore_backup") + private WebElement restoreButtonOnFirstTimeOverlay; + + @iOSXCUITFindBy(accessibility = "Log In") + private WebElement logInButton; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Log In' AND name == 'Log In' AND type == 'XCUIElementTypeButton'") + private WebElement invinsibleLoginButton; + + @iOSXCUITFindBy(accessibility = "companyLoginButton") + private WebElement companyLoginButton; + + @iOSXCUITFindBy(accessibility = "EmailField") + private WebElement emailField; + + @iOSXCUITFindBy(accessibility = "PasswordField") + private WebElement passwordField; + + @iOSXCUITFindBy(accessibility = "back") + private WebElement backButton; + + @iOSXCUITFindBy(accessibility = "bottomBarSettingsButton") + private WebElement profileButton; + + public LoginPage(WebDriver driver) { + super(driver); + } + + public boolean isVisible() { + return isElementVisible(loginScreen); + } + + public void acceptConnectToServerAlert() { + if (waitUntilElementVisible(connectToServerAlertTitle)) { + getDriver().switchTo().alert().accept(); + } else { + throw new TimeoutException("Alert to connect to server did not appear"); + } + + } + + public void acceptOpenInWireAlert() { + // We currently have no clue when this alert is shown. + // it is indeterministic so we wait for 1 second without implicit waits and timeouts. + if (!waitUntilElementInvisible(openInWireAlertTitle, Duration.ofSeconds(1))) { + getDriver().switchTo().alert().accept(); + } + } + + public void switchToEmailLogin() { + if(isElementVisible(switchToEmailLogin)){ + switchToEmailLogin.click(); + } + } + + public boolean isPhoneLoginVisible() { + return switchToPhoneLogin.isDisplayed(); + } + + public boolean isPhoneLoginInvisible() { + return isElementInvisible(switchToPhoneLogin); + } + + public boolean waitForLoginProperly() { + log.info("Start wait for login..."); + boolean noLoginForm = waitUntilElementInvisible(loginScreen); + log.info("noLoginForm: " + noLoginForm); + boolean noFirstTimeOverlay = waitUntilElementInvisible(restoreButtonOnFirstTimeOverlay); + log.info("noFirstTimeOverlay: " + noFirstTimeOverlay); + boolean profileButtonShown = waitUntilElementVisible(profileButton); + return noLoginForm && noFirstTimeOverlay && profileButtonShown; + } + + public void tapLoginButton() { + logInButton.click(); + } + + public boolean isLoginButtonInvisible() { + return isElementVisible(invinsibleLoginButton, LOGIN_TIMEOUT); + } + + public void setLogin(String login) { + waitUntilElementClickable(emailField); + emailField.click(); + emailField.clear(); + emailField.sendKeys(login); + } + + public void setPassword(String password) { + passwordField.click(); + passwordField.clear(); + passwordField.sendKeys(password); + } + + private static final Timedelta LOGIN_TIMEOUT = Timedelta.ofSeconds(30); + + public void tapBackButton() { + if (backButton.isDisplayed()) { + backButton.click(); + } + } + + public boolean isCompanyLoginButtonInvisible() { + return isElementInvisible(companyLoginButton, Timedelta.ofSeconds(1)); + } + + public void loginAs(String email, String password) { + setLogin(email); + setPassword(password); + tapLoginButton(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ManageDevicesOverlay.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ManageDevicesOverlay.java new file mode 100644 index 00000000000..8c325dd966f --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ManageDevicesOverlay.java @@ -0,0 +1,42 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.WebElement; + +public class ManageDevicesOverlay extends IOSPage{ + @iOSXCUITFindBy(accessibility = "manage_devices") + private WebElement manageDevicesButton; + + @iOSXCUITFindBy(accessibility = "Delete") + private WebElement deleteDeviceButton; + + public ManageDevicesOverlay(WebDriver driver) { + super(driver); + } + + public boolean waitUntilVisible() { + return manageDevicesButton.isDisplayed(); + } + + public boolean waitUntilInvisible() { + return waitUntilElementInvisible(manageDevicesButton); + } + + public void tapMangeDevicesButton() { + manageDevicesButton.click(); + } + + private String removeStringFor(String deviceName) { + return String.format("name CONTAINS 'Remove %s'", deviceName); + } + + public void tapDeleteButtonForDevice(String deviceName) { + getDriver().findElement(MobileBy.iOSNsPredicateString(removeStringFor(deviceName))).click(); + } + + public void tapDeleteButton() { + deleteDeviceButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/MapViewPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/MapViewPage.java new file mode 100644 index 00000000000..274e4c597ef --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/MapViewPage.java @@ -0,0 +1,19 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class MapViewPage extends IOSPage{ + + @iOSXCUITFindBy(accessibility = "sendLocation") + private WebElement sendLocationButton; + + public MapViewPage(WebDriver driver) { + super(driver); + } + + public void clickSendLocationButton() { + sendLocationButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/MessageDetailsPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/MessageDetailsPage.java new file mode 100644 index 00000000000..c43975248ac --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/MessageDetailsPage.java @@ -0,0 +1,36 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class MessageDetailsPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Close") + private WebElement closeButton; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeButton' AND name == 'Tab0' AND label CONTAINS 'Read'") + private WebElement readTabActive; + + private static final Function predicateStrUserByName = name -> + String.format("name == 'user_cell.name' AND value CONTAINS '%s'", name); + + public MessageDetailsPage(WebDriver driver) { + super(driver); + } + + public boolean isContactVisible(String name) { + return waitUntilLocatorVisible(MobileBy.iOSNsPredicateString(predicateStrUserByName.apply(name))); + } + + public void tapCloseButton() { + closeButton.click(); + } + + public boolean isContactVisibleInSeenTab(String name) { + return isContactVisible(name) && readTabActive.isDisplayed(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/MoveToCustomFolderPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/MoveToCustomFolderPage.java new file mode 100644 index 00000000000..8614e5dc3cf --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/MoveToCustomFolderPage.java @@ -0,0 +1,42 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.WebElement; + +public class MoveToCustomFolderPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Move To") + private WebElement moveToTitle; + + @iOSXCUITFindBy(accessibility = "close") + private WebElement closeButton; + + @iOSXCUITFindBy(accessibility = "button.newfolder.create") + private WebElement createNewButton; + + public MoveToCustomFolderPage(WebDriver driver) { + super(driver); + } + + public boolean isVisible() { + return waitUntilElementVisible(moveToTitle); + } + + public boolean isInvisible() { + return waitUntilElementInvisible(moveToTitle); + } + + public void tapCloseButton() { + closeButton.click(); + } + + public void tapCreateNewButton() { + createNewButton.click(); + } + + public void tapOnACustomFolder(String name) { + getDriver().findElement(MobileBy.AccessibilityId(name)).click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/PasteDialog.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/PasteDialog.java new file mode 100644 index 00000000000..3b31ae4dbad --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/PasteDialog.java @@ -0,0 +1,19 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class PasteDialog extends IOSPage { + + @iOSXCUITFindBy(accessibility = "OK") + private WebElement oKButton; + + public PasteDialog(WebDriver driver) { + super(driver); + } + + public void tapOKButton() { + oKButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/PicturePreviewPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/PicturePreviewPage.java new file mode 100644 index 00000000000..d2f7b035840 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/PicturePreviewPage.java @@ -0,0 +1,40 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class PicturePreviewPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "sketchButton") + private WebElement sketchButton; + + @iOSXCUITFindBy(accessibility = "OK") + private WebElement oKButton; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'OK' AND name == 'OK' AND type == 'XCUIElementTypeButton'") + private WebElement iPadOKButton; + + @iOSXCUITFindBy(accessibility = "Use Photo") + private WebElement usePhotoButton; + + public PicturePreviewPage(WebDriver driver) { + super(driver); + } + + public void tapSketchButton(){ + sketchButton.click(); + } + + public void tapOkButton(){ + oKButton.click(); + } + + public void tapOkButtonOnIPad(){ + iPadOKButton.click(); + } + + public void tapPhotoButton(){ + usePhotoButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/PollMessagesPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/PollMessagesPage.java new file mode 100644 index 00000000000..950f8b82f28 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/PollMessagesPage.java @@ -0,0 +1,63 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class PollMessagesPage extends IOSPage { + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeButton' AND value=='selected'") + private WebElement anySelectedPollButton; + + private static final Function pollMessageText = text -> + MobileBy.iOSNsPredicateString(String.format("name == 'Message' AND value CONTAINS '%s'", text)); + + private static final Function confirmedPollButtonByText = text -> + MobileBy.iOSNsPredicateString(String.format("name == '%s' AND value=='confirmed'", text)); + + private static final Function unselectedPollButtonByText = text -> + MobileBy.iOSNsPredicateString(String.format("name == '%s' AND value=='unselected'", text)); + + private static final Function selectedPollButtonByText = text -> + MobileBy.iOSNsPredicateString(String.format("name == '%s' AND value=='selected'", text)); + + public PollMessagesPage(WebDriver driver) { + super(driver); + } + + public boolean isPollMessageTextContains(String text) { + return waitUntilLocatorVisible(pollMessageText.apply(text)); + } + + public boolean areAllPollButtonsUnselected() { + return waitUntilElementInvisible(anySelectedPollButton); + } + + public void tapPollButtonWithTheText(String text) { + getDriver().findElement(MobileBy.AccessibilityId(text.toUpperCase())).click(); + } + + public boolean isPollButtonWithTextConfirmed(String buttonText) { + return waitUntilLocatorVisible(confirmedPollButtonByText.apply(buttonText.toUpperCase())); + } + + public boolean isPollButtonWithTextUnselected(String buttonText) { + return waitUntilLocatorVisible(unselectedPollButtonByText.apply(buttonText.toUpperCase())); + } + + public boolean isPollButtonWithTextSelected(String buttonText) { + return waitUntilLocatorVisible(selectedPollButtonByText.apply(buttonText.toUpperCase())); + } + + public boolean isPollErrorMessageVisible(String error) { + return waitUntilLocatorVisible(MobileBy.AccessibilityId(error)); + } + + public boolean isPollErrorMessageInvisible(String error) { + return isLocatorInvisible(MobileBy.AccessibilityId(error)); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ReactionsEditMenuPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ReactionsEditMenuPage.java new file mode 100644 index 00000000000..50db3f1d00b --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ReactionsEditMenuPage.java @@ -0,0 +1,170 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.MobileBy; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class ReactionsEditMenuPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Reactions") + private WebElement emojiPicker; + + @iOSXCUITFindBy(accessibility = "Forward") + private WebElement forwardButton; + + @iOSXCUITFindBy(accessibility = "Copy") + private WebElement copyItem; + + @iOSXCUITFindBy(accessibility = "Delete") + private WebElement deleteItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeButton' AND name == 'Details'") + private WebElement detailsItem; + + @iOSXCUITFindBy(accessibility = "Download") + private WebElement downloadItem; + + @iOSXCUITFindBy(accessibility = "Edit") + private WebElement editItem; + + @iOSXCUITFindBy(accessibility = "Like") + private WebElement likeItem; + + @iOSXCUITFindBy(accessibility = "Unlike") + private WebElement unlikeItem; + + @iOSXCUITFindBy(accessibility = "Paste") + private WebElement pasteItem; + + @iOSXCUITFindBy(accessibility = "Reply") + private WebElement replyItem; + + @iOSXCUITFindBy(accessibility = "Reveal") + private WebElement revealItem; + + @iOSXCUITFindBy(accessibility = "Save") + private WebElement saveItem; + + @iOSXCUITFindBy(accessibility = "Select All") + private WebElement selectAll; + + @iOSXCUITFindBy(accessibility = "Share") + private WebElement shareItem; + + @iOSXCUITFindBy(accessibility = "Cancel") + private WebElement cancelItem; + + private static final Function predicateReactionByValue = reaction -> MobileBy.iOSNsPredicateString(String.format("type == 'XCUIElementTypeButton' AND name == '%s'", reaction)); + + public ReactionsEditMenuPage(WebDriver driver) { + super(driver); + } + + public boolean isVisible() { + return waitUntilElementVisible(emojiPicker); + } + + public boolean isInvisible() { + return waitUntilElementInvisible(emojiPicker); + } + + public void iTapQuickReaction(String reaction) { + getDriver().findElement(predicateReactionByValue.apply(reaction)).click(); + } + + public boolean isForwardButtonInvisible() { + return waitUntilElementInvisible(forwardButton); + } + + public void tapCopy() { + copyItem.click(); + } + + public boolean isCopyVisible() { + return waitUntilElementVisible(copyItem); + } + + public boolean isCopyInvisible() { + return waitUntilElementInvisible(copyItem); + } + + public void tapDelete() { + deleteItem.click(); + } + + public boolean isDeleteVisible() { + return waitUntilElementVisible(deleteItem); + } + + public void tapDetails() { + detailsItem.click(); + } + + public void tapDownload() { + downloadItem.click(); + } + + public boolean isDownloadInvisible() { + return waitUntilElementInvisible(downloadItem); + } + + public void tapEdit() { + editItem.click(); + } + + public void tapLike() { + likeItem.click(); + } + + public void tapPaste() { + pasteItem.click(); + } + + public boolean isPasteInvisible() { + return waitUntilElementInvisible(pasteItem); + } + + public void tapReply() { + replyItem.click(); + } + + public boolean isReplyVisible() { + return waitUntilElementVisible(replyItem); + } + + public void tapSave() { + saveItem.click(); + } + + public boolean isSaveVisible() { + return waitUntilElementVisible(saveItem); + } + + public boolean isSaveInvisible() { + return waitUntilElementInvisible(saveItem); + } + + public void tapSelectAll() { + selectAll.click(); + } + + public void tapShare() { + shareItem.click(); + } + + public boolean isShareVisible() { + return waitUntilElementVisible(shareItem); + } + + public boolean isShareInvisible() { + return waitUntilElementInvisible(shareItem); + } + + public void tapCancel() { + cancelItem.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/RegistrationPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/RegistrationPage.java new file mode 100644 index 00000000000..0330cc15947 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/RegistrationPage.java @@ -0,0 +1,133 @@ +package com.wearezeta.auto.ios.pages; + +import com.wearezeta.auto.common.backend.BackendConnections; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.interactions.Actions; + +public class RegistrationPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "VerificationCode") + private WebElement verificationCodeInput; + + @iOSXCUITFindBy(accessibility = "Accept") + private WebElement acceptTOCButton; + + @iOSXCUITFindBy(accessibility = "resend_button") + private WebElement resendCode; + + @iOSXCUITFindBy(accessibility = "ConfirmButton") + private WebElement nameConfirmButton; + + @iOSXCUITFindBy(accessibility = "ConfirmButton") + private WebElement usernameConfirmButton; + + @iOSXCUITFindBy(accessibility = "RevealButton") + private WebElement passwordConfirmButton; + + @iOSXCUITFindBy(iOSNsPredicate = "label IN {'Create an account'}") + private WebElement registrationScreen; + + @iOSXCUITFindBy(accessibility = "NameField") + private WebElement nameField; + + @iOSXCUITFindBy(accessibility = "EmailField") + private WebElement emailField; + + @iOSXCUITFindBy(accessibility = "PasswordField") + private WebElement passwordField; + + @iOSXCUITFindBy(accessibility = "UsernameField") + private WebElement usernameField; + + @iOSXCUITFindBy(iOSNsPredicate = "value CONTAINS 'Enter the verification code we sent to'") + private WebElement mailVerifyPrompt; + + @iOSXCUITFindBy(accessibility = "back") + private WebElement backButton; + + @iOSXCUITFindBy(accessibility = "validation-rules") + private WebElement passwordRulesDesc; + + @iOSXCUITFindBy(accessibility = "validation-failure") + private WebElement passwordfailureMessage; + + public RegistrationPage(WebDriver driver) { + super(driver); + } + + public boolean isVisible() { + return waitUntilElementVisible(registrationScreen); + } + + public boolean isPasswordRulesVisible() { + return isElementVisible(passwordRulesDesc, Timedelta.ofSeconds(1)); + } + + public boolean isPasswordFailureVisible() { + return isElementVisible(passwordfailureMessage, Timedelta.ofSeconds(1)); + } + + public void inputActivationCode(ClientUser user) { + waitUntilElementVisible(verificationCodeInput); + verificationCodeInput.click(); + final String code = BackendConnections.get(user).getActivationCodeForEmail(user.getEmail()); + Actions a = new Actions(getDriver()); + a.sendKeys(code); + a.perform(); + } + + public void clickAcceptTOCButton() { + acceptTOCButton.click(); + } + + public void tapPasswordConfirmButton() { + passwordConfirmButton.click(); + } + + public void tapNameConfirmButton() { + nameConfirmButton.click(); + } + + public void tapUsernameConfirmButton() { + usernameConfirmButton.click(); + } + + public void clearPasswordInput() { + passwordField.clear(); + } + + public void typeEmail(String email) { + waitUntilElementClickable(emailField); + emailField.click(); + emailField.sendKeys(email); + } + + public void typeName(String name) { + waitUntilElementClickable(nameField); + nameField.click(); + nameField.sendKeys(name); + } + + public void typeUsername(String name) { + waitUntilElementClickable(usernameField); + usernameField.click(); + usernameField.sendKeys(name); + } + + public void typePassword(String password) { + passwordField.click(); + passwordField.sendKeys(password); + } + + public boolean isEmailVerificationPromptVisible() { + return waitUntilElementVisible(mailVerifyPrompt); + } + + public void tapBackButton() { + backButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/SearchUIPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/SearchUIPage.java new file mode 100644 index 00000000000..a06422e585b --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/SearchUIPage.java @@ -0,0 +1,161 @@ +package com.wearezeta.auto.ios.pages; + +import java.util.function.Function; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.ios.pages.search.SearchList; +import io.appium.java_client.MobileBy; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.*; + +import org.openqa.selenium.WebDriver; + +public class SearchUIPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "close") + private WebElement closeButton; + + @iOSXCUITFindBy(accessibility = "Search by name or username") + private WebElement searchBar; + + @iOSXCUITFindBy(accessibility = "Copy") + private WebElement copyInviteButton; + + @iOSXCUITFindBy(accessibility = "Invite More People") + private WebElement inviteMorePeopleButton; + + /** + @iOSXCUITFindBy(accessibility = "button.searchui.creategroup") + private WebElement createGroupButton; +*/ + @iOSXCUITFindBy(accessibility = "button.searchui.createguestroom") + private WebElement createGuestRoomButton; + + @iOSXCUITFindBy(accessibility= "create_group") + private WebElement createGroupButton; + + private static final Function classChainTopPeopleAvatarByIdx = idx -> MobileBy.iOSClassChain( + String.format("**/XCUIElementTypeCell[`name == 'TopPeopleCell'`][%s]", idx)); + + private static final Function predicateStringServiceSearchResult = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND value == '%s'", name)); + + private static final Function serviceNameByText = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND value == '%s'", name)); + + private static final Function predicateContact = name -> MobileBy.iOSNsPredicateString( + String.format("name == 'user_cell.name' AND value == '%s'", name)); + + private final SearchList searchList; + + public SearchUIPage(WebDriver driver) { + super(driver); + this.searchList = new SearchList(driver); + } + + public boolean isVisible() { + return searchList.isVisible(); + } + + public void typeSearchQuery(String text, boolean shouldClearFieldBeforeInput) { +// searchList.typeSearchQuery(text, shouldClearFieldBeforeInput); + searchBar.sendKeys(text); + // Wait for a user to be found + Timedelta.ofSeconds(2).sleep(); + } + + public void clearSearchInput() { + searchList.clearSearchQuery(); + } + + public void tapCreateGroupButton() { + createGroupButton.click(); + } + + public void tapCloseButton() { + closeButton.click(); + } + + public void tapSendInviteButton() { + inviteMorePeopleButton.click(); + } + + public void tapCopyInviteButton() { + copyInviteButton.click(); + } + + public boolean isElementFoundInSearch(String name) { + return searchList.isItemVisible(name); + } + + public boolean isElementNotFoundInSearch(String name) { + return searchList.isItemInvisible(name); + } + + public void selectElementInSearchResults(String name) { + searchList.selectItem(name); + } + + public void tapTopConnectionsAvatars(int numberToTap) { + for (int i = 1; i <= numberToTap; i++) { + final By locator = classChainTopPeopleAvatarByIdx.apply(i); + getElement(locator).click(); + } + } + + public void tapInstantConnectButton(String name) { + searchList.tapInstantConnectButton(name); + } + + public void tapOnTopConnectionAvatarByOrder(int i) { + final By locator = classChainTopPeopleAvatarByIdx.apply(i); + getDriver().findElement(locator).click(); + } + + public int getOccurrencesCount(String name) { + return searchList.getOccurrencesCount(name); + } + + public boolean isContactVisible(String text) { + final By locator = predicateContact.apply(text); + return waitUntilLocatorVisible(locator); + } + + public boolean isContactInvisible(String text) { + final By locator = predicateContact.apply(text); + return isLocatorInvisible(locator); + } + + public boolean isServiceVisibleInSearchResult(String serviceName) { + final By locator = predicateStringServiceSearchResult.apply(serviceName); + return waitUntilLocatorVisible(locator); + } + + public boolean isServiceInVisibleInSearchResult(String serviceName) { + final By locator = predicateStringServiceSearchResult.apply(serviceName); + return isLocatorInvisible(locator); + } + + public void tapOnService(String serviceName) { + getDriver().findElement(serviceNameByText.apply(serviceName)).click(); + } + + public boolean isCreateGroupButtonVisible() { + return isElementVisible(createGroupButton); + } + + public boolean isCreateGroupButtonInvisible() { + return waitUntilElementInvisible(createGroupButton); + } + + public boolean isCreateGuestRoomButtonVisible() { + return isElementVisible(createGuestRoomButton); + } + + public boolean isCreateGuestRoomButtonInvisible() { + return waitUntilElementInvisible(createGuestRoomButton); + } + + public void iOpenCreateGroupScreen() { + createGroupButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ServiceCreationPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ServiceCreationPage.java new file mode 100644 index 00000000000..89bda04dbcf --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ServiceCreationPage.java @@ -0,0 +1,18 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class ServiceCreationPage extends IOSPage { + public ServiceCreationPage(WebDriver driver) { + super(driver); + } + + @iOSXCUITFindBy(accessibility = "close") + private WebElement closeButton; + + public void tapCloseButton() { + closeButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ServiceDetailPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ServiceDetailPage.java new file mode 100644 index 00000000000..de49561b34c --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/ServiceDetailPage.java @@ -0,0 +1,19 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + + +public class ServiceDetailPage extends IOSPage{ + @iOSXCUITFindBy(accessibility = "Add Service") + private WebElement nameAddServiceButton; + + public ServiceDetailPage(WebDriver driver) { + super(driver); + } + + public void addService(){ + nameAddServiceButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/SettingsPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/SettingsPage.java new file mode 100644 index 00000000000..924e09ba77f --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/SettingsPage.java @@ -0,0 +1,473 @@ +package com.wearezeta.auto.ios.pages; + +import com.wearezeta.auto.common.backend.models.AccentColor; +import com.wearezeta.auto.common.log.ZetaLogger; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.*; +import org.openqa.selenium.support.ui.ExpectedCondition; +import org.openqa.selenium.support.ui.WebDriverWait; + +import java.awt.image.BufferedImage; +import java.time.Duration; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.logging.Logger; + +public class SettingsPage extends IOSPage { + + private static final Logger log = ZetaLogger.getLog(SettingsPage.class.getSimpleName()); + + @iOSXCUITFindBy(accessibility = "close") + private WebElement xButton; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeStaticText[`name == \"Advanced\"`]") + private WebElement advanced; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Devices'") + private WebElement devicesItem; + + @iOSXCUITFindBy(iOSNsPredicate = "label CONTAINS 'This makes audio calls use less data and work better on slower networks. Turn off to use constant bitrate encoding (CBR). This setting only affects 1:1 calls; conference calls always use CBR encoding.'") + private WebElement vbrText; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Account'") + private WebElement accountItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Back Up Conversations'") + private WebElement backUpConversationsItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Options'") + private WebElement optionsItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Log Out'") + private WebElement logOutItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Profile Picture'") + private WebElement pictureItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Reset Password'") + private WebElement resetPasswordItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Support'") + private WebElement supportItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Wire Support Website'") + private WebElement wireSupportWebsiteItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Delete Account'") + private WebElement deleteAccountItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Username'") + private WebElement usernameItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Email'") + private WebElement emailItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Name'") + private WebElement nameItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'About'") + private WebElement aboutItem; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND label == 'Profile Color'") + private WebElement colorItem; + + @iOSXCUITFindBy(accessibility = "Purple") + private WebElement colorPurple; + + @iOSXCUITFindBy(accessibility = "DomainFieldDisabled") + private WebElement nonEditableDomainLabel; + + @iOSXCUITFindBy(accessibility = "TeamFieldDisabled") + private WebElement nonEditableTeamLabel; + + @iOSXCUITFindBy(accessibility = "Terms of Use") + private WebElement termsOfUseItem; + + @iOSXCUITFindBy(accessibility = "Privacy Policy") + private WebElement privacyPolicyItem; + + @iOSXCUITFindBy(accessibility = "Wire Website") + private WebElement wireWebsiteItem; + + @iOSXCUITFindBy(accessibility = "Contact Support") + private WebElement contactSupportItem; + + @iOSXCUITFindBy(accessibility = "Report Misuse") + private WebElement reportMisuse; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeTable' AND visible == 1") + private WebElement predicateOptionsRoot; + + private static final Function classChainStrMenuItemByName = name -> + String.format("**/XCUIElementTypeCell[$type == 'XCUIElementTypeStaticText' AND label == '%s'$]", name); + + private static final BiFunction classChainStrSettingsValue = + (itemName, expectedValue) -> String.format("%s/*[`value == '%s'`]", + classChainStrMenuItemByName.apply(itemName), expectedValue); + + private static final By classChainSelfNameEditField = + MobileBy.iOSClassChain(String.format("%s/XCUIElementTypeTextField[-1]", + classChainStrMenuItemByName.apply("Name"))); + + private static final String xpathStrColorPicker = "//*[@name='COLOR']/following::XCUIElementTypeTable[1]"; + private static final By xpathColorPicker = By.xpath(xpathStrColorPicker); + + // indexation starts from 1 + private static final Function xpathSreColorByIdx = idx -> + String.format("%s/XCUIElementTypeCell[%s]", xpathStrColorPicker, idx); + + private static final Function predicateStrUniqueUsernameInSettings = name -> MobileBy.iOSNsPredicateString( + //FIXME: waiting for correct Id from Alexis String.format("name == 'UsernameFieldDisabled' OR name == 'UsernameField' AND value ='%s'", name)); + String.format("value ='@%s'", name)); + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeImage' AND name == 'imagePreview' AND value == 'image'") + private WebElement predicateSettingsProfilePicturePreview; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeImage' AND name == 'imagePreview' AND value == 'color'") + private WebElement predicateSettingsProfileColorPreview; + + @iOSXCUITFindBy(xpath = "//XCUIElementTypeStaticText[@name='COLOR']/preceding-sibling::XCUIElementTypeButton") + private WebElement xpathColorPickerCloseButton; + + @iOSXCUITFindBy(accessibility = "EmailField") + private WebElement nameEmailInput; + + @iOSXCUITFindBy(accessibility = "Verify email") + private WebElement nameVerifyEmailTitle; + + @iOSXCUITFindBy(accessibility = "Account") + WebElement accountBackButton; + + @iOSXCUITFindBy(accessibility = "Settings") + WebElement settingsBackButton; + + private static final Function predicateNavigationButtonByName = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeButton' AND name =[c] '%s'", name)); + + private static final Function predicateDomainName = name -> MobileBy.iOSNsPredicateString( + String.format("name == 'DomainFieldDisabled' AND value == '%s'", name)); + + private static final Function predicateTeamName = name -> MobileBy.iOSNsPredicateString( + String.format("name == 'TeamFieldDisabled' AND value == '%s'", name)); + + private static final Function predicateDomainNameOnUsernameUI = name -> MobileBy.iOSNsPredicateString( + String.format("label == '%s'", name)); + + private static final Function predicateNonEditableDomainNameOnUsernameUI = name -> MobileBy.iOSNsPredicateString( + String.format("label == '%s' AND type == 'XCUIElementTypeStaticText'", name)); + @iOSXCUITFindBy(accessibility = "ReadReceiptsSwitch") + private WebElement nameReadReceiptToggle; + + @iOSXCUITFindBy(accessibility = "Appearance") + private WebElement nameAppearanceText; + + @iOSXCUITFindBy(accessibility = "nameProfilePictureLabel") + private WebElement nameProfilePictureLabel; + + @iOSXCUITFindBy(accessibility = "Color") + private WebElement nameColorLabel; + + @iOSXCUITFindBy(accessibility = "NameFieldDisabled") + private WebElement nameDisplayNameDisabled; + + @iOSXCUITFindBy(accessibility = "UsernameFieldDisabled") + private WebElement nameUniqueUsernameDisabled; + + @iOSXCUITFindBy(accessibility = "handleTextField") + private WebElement nameUserName; + + @iOSXCUITFindBy(accessibility = "Beta Program") + private WebElement nameBetaProgram; + + @iOSXCUITFindBy(accessibility = "Beta Toggle") + private WebElement nameBetaToggle; + + private static final Function predicateBetaToggleValue = value -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeSwitch' AND name == '%s' AND value == '%s'", "Beta Toggle", value)); + + public SettingsPage(WebDriver driver) { + super(driver); + } + + private WebElement scrollToItem(String itemName) { + By locator = MobileBy.iOSClassChain(classChainStrMenuItemByName.apply(itemName)); + return new WebDriverWait(getDriver(), Duration.ofSeconds(getDefaultLookupTimeoutSeconds())) + .ignoring(StaleElementReferenceException.class) + .ignoring(NoSuchElementException.class) + .until(locatorIsScrolledIntoView(locator)); + } + + private WebElement scrollToItem(WebElement item) { + return new WebDriverWait(getDriver(), Duration.ofSeconds(getDefaultLookupTimeoutSeconds())) + .ignoring(StaleElementReferenceException.class) + .ignoring(NoSuchElementException.class) + .until(elementIsScrolledIntoView(item)); + } + + private ExpectedCondition elementIsScrolledIntoView(final WebElement element) { + return driver -> { + if (element.isDisplayed()) { + return element; + } + log.info("Scroll up"); + swipe(predicateOptionsRoot, SwipeDirection.UP); + return null; + }; + } + + private ExpectedCondition locatorIsScrolledIntoView(final By locator) { + return driver -> { + WebElement element = getDriver().findElement(locator); + if (element.isDisplayed()) { + return element; + } + log.info("Scroll up"); + swipe(predicateOptionsRoot, SwipeDirection.UP); + return null; + }; + } + + public void tapAccount() { + waitUntilElementClickable(accountItem); + accountItem.click(); + } + + public void tapDevices() { + devicesItem.click(); + } + + public void tapBackUpConversations() { + scrollToItem(backUpConversationsItem).click(); + } + + public void tapOptionsItem() { + waitUntilElementClickable(optionsItem); + optionsItem.click(); + } + + public void tapLogOutItem() { + scrollToItem(logOutItem).click(); + } + + public void tapPictureItem() { + pictureItem.click(); + } + + public void tapResetPasswordItem() { + scrollToItem(resetPasswordItem); + resetPasswordItem.click(); + } + + public void tapSupportItem() { + supportItem.click(); + } + + public void tapWireSupportWebsiteItem() { + wireSupportWebsiteItem.click(); + } + + public void tapDeleteAccountItem() { + scrollToItem(deleteAccountItem).click(); + } + + public void tapUsernameItem() { + usernameItem.click(); + } + + public void tapEmailItem() { + emailItem.click(); + } + + public void tapNameItem() { + nameItem.click(); + } + + public void tapAboutItem() { + aboutItem.click(); + } + + public void tapColorItem() { + scrollToItem(colorItem).click(); + } + + public void tapColorPurple() { + colorPurple.click(); + } + + public boolean isItemVisible(String itemName) { + return scrollToItem(itemName).isDisplayed(); + } + + public boolean isItemInvisible(String itemName) { + try { + scrollToItem(itemName); + } catch (Exception e) { + return true; + } + return isLocatorInvisible(MobileBy.iOSClassChain(classChainStrMenuItemByName.apply(itemName))); + } + + public void tapX() { + xButton.click(); + } + + public void tapNavigationButton(String name) { + getDriver().findElement(predicateNavigationButtonByName.apply(name)).click(); + } + + public boolean isSettingItemValueEqualTo(String itemName, String expectedValue) { + final By locator = MobileBy.iOSClassChain(classChainStrSettingsValue.apply(itemName, expectedValue)); + return waitUntilLocatorVisible(locator); + } + + public WebElement clearSelfName() { + final WebElement selfName = getElementIfExists(classChainSelfNameEditField).orElseThrow( + () -> new IllegalStateException("Name input is not present on the page") + ); + selfName.clear(); + return selfName; + } + + public void clearUsername() { + nameUserName.clear(); + } + + public void setSelfName(String newName) { + clearSelfName().sendKeys(newName); + } + + public BufferedImage getColorPickerStateScreenshot() { + return this.getElementScreenshot(getDriver().findElement(xpathColorPicker)).orElseThrow( + () -> new IllegalStateException("Cannot make a screenshot of Color Picker control") + ); + } + + public void closeColorPicker() { + xpathColorPickerCloseButton.click(); + } + + public void selectAccentColor(AccentColor byName) { + final By locator = By.xpath(xpathSreColorByIdx.apply(byName.getId())); + getDriver().findElement(locator).click(); + } + + public boolean isUniqueUsernameInSettingsDisplayed(String uniqueName) { + return waitUntilLocatorVisible(predicateStrUniqueUsernameInSettings.apply(uniqueName)); + } + + public boolean isProfilePicturePreviewInvisible() { + return waitUntilElementInvisible(predicateSettingsProfilePicturePreview); + } + + public void changeEmailAddress(String newEmail) { + nameEmailInput.clear(); + nameEmailInput.sendKeys(newEmail); + } + + public boolean waitUntilEmailVerificationHappens(Duration timeout) { + return waitUntilElementInvisible(nameVerifyEmailTitle, timeout); + } + + public boolean iSeeVBRText() { + return vbrText.isDisplayed(); + } + + public void switchToggleReadReceipts() { + nameReadReceiptToggle.click(); + } + + public boolean isDisplayNameInputFieldStatic() { + return isElementVisible(nameDisplayNameDisabled); + } + + public boolean isUniqueUsernameInputFieldStatic() { + return isElementVisible(nameUniqueUsernameDisabled); + } + + public boolean isAppearanceSectionInvisible() { + return waitUntilElementInvisible(nameAppearanceText) && isProfilePictureInvisible() && isAccentColorInvisible(); + } + + public boolean isProfilePictureInvisible() { + return waitUntilElementInvisible(nameProfilePictureLabel) && isProfilePicturePreviewInvisible(); + } + public boolean isAccentColorInvisible() { + return waitUntilElementInvisible(nameColorLabel) && waitUntilElementInvisible(predicateSettingsProfileColorPreview); + } + + public boolean isBetaToggleVisible() { + return isElementVisible(nameBetaProgram); + } + + public boolean isBetaToggleInvisible() { + return waitUntilElementInvisible(nameBetaProgram); + } + + public boolean isBetaToggleChecked() { + return waitUntilLocatorVisible(predicateBetaToggleValue.apply(1)); + } + + public boolean isBetaToggleUnchecked() { + return waitUntilLocatorVisible(predicateBetaToggleValue.apply(0)); + } + + public void tapBetaToggle() { + nameBetaToggle.click(); + } + + public boolean isDomainNameVisible(String domainName) { + final By locator = predicateDomainName.apply(domainName); + return waitUntilLocatorVisible(locator); + } + + public boolean isDomainNameVisibleOnUsernameUI(String domainName) { + final By locator = predicateDomainNameOnUsernameUI.apply(domainName); + return waitUntilLocatorVisible(locator); + } + + public boolean isNonEditableDomainNameFieldOnUsernameUI(String domainName) { + final By locator = predicateNonEditableDomainNameOnUsernameUI.apply(domainName); + return waitUntilLocatorVisible(locator); + } + + public boolean isDomainNonEditableOnSettings() { + return !nonEditableDomainLabel.isEnabled(); + } + + public boolean isTeamNonEditableOnSettings() { + return !nonEditableTeamLabel.isEnabled(); + } + + public boolean isTeamNameVisible(String domainName) { + final By locator = predicateTeamName.apply(domainName); + return waitUntilLocatorVisible(locator); + } + + public boolean isTeamNameInvisible(String domainName) { + final By locator = predicateTeamName.apply(domainName); + return isLocatorInvisible(locator); + } + + public void tapTermsOfUse(){ termsOfUseItem.click();} + + public void tapPrivacyPolicy(){ privacyPolicyItem.click();} + + public void tapWireWebsite(){ wireWebsiteItem.click();} + + public void tapContactSupport(){ contactSupportItem.click();} + + public void tapReportMisuse(){ reportMisuse.click();} + + public void tapAdvanced() { + advanced.click(); + } + + public void tapAccountBackButton() { + accountBackButton.click(); + } + public void tapSettingsBackButton() { + settingsBackButton.click(); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/SketchPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/SketchPage.java new file mode 100644 index 00000000000..d78bcf4cfcc --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/SketchPage.java @@ -0,0 +1,26 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class SketchPage extends IOSPage { + @iOSXCUITFindBy(accessibility = "sendButton") + private WebElement sendButton; + + @iOSXCUITFindBy(accessibility = "canvas") + private WebElement canvasButton; + + public SketchPage(WebDriver driver) { + super(driver); + } + + public void sketchRandomLines() { + swipe(canvasButton, SwipeDirection.UP); + swipe(canvasButton, SwipeDirection.LEFT); + } + + public void tapSendButton(){ + sendButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/StatusActionSheetPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/StatusActionSheetPage.java new file mode 100644 index 00000000000..d1a74d538d5 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/StatusActionSheetPage.java @@ -0,0 +1,14 @@ +package com.wearezeta.auto.ios.pages; + +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; + +public class StatusActionSheetPage extends IOSPage { + public StatusActionSheetPage(WebDriver driver) { + super(driver); + } + + public void tapStatusName(String name) { + getElement(MobileBy.AccessibilityId(name)).click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/TeamSearchUIPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/TeamSearchUIPage.java new file mode 100644 index 00000000000..06c34b92b2f --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/TeamSearchUIPage.java @@ -0,0 +1,19 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class TeamSearchUIPage extends SearchUIPage { + + @iOSXCUITFindBy(accessibility = "Services") + private WebElement servicesTabButton; + + public TeamSearchUIPage(WebDriver driver) { + super(driver); + } + + public void tapTeamSearchUITab() { + servicesTabButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/TopNavigationBarPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/TopNavigationBarPage.java new file mode 100644 index 00000000000..986dd44a889 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/TopNavigationBarPage.java @@ -0,0 +1,68 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class TopNavigationBarPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "bottomBarSettingsButton") + private WebElement profileButton; + + @iOSXCUITFindBy(accessibility = "legalhold") + private WebElement legalHoldIndicator; + + +// @iOSXCUITFindBy(accessibility = "Name") +// private WebElement userProfileName; +// + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeNavigationBar[`name == \"Conversations\"`]/XCUIElementTypeOther/XCUIElementTypeButton/XCUIElementTypeOther[1]/XCUIElementTypeImage") + private WebElement userProfileImage; + + @iOSXCUITFindBy(accessibility = "create_group_or_search_button") + private WebElement openSearchScreenButton; + + @iOSXCUITFindBy(accessibility = "Filter conversations") + private WebElement filterButton; + + public TopNavigationBarPage(WebDriver driver) { + super(driver); + } + + public void tapProfileButton() { + waitUntilElementClickable(profileButton); + profileButton.click(); + } + + public boolean isSelfProfileButtonVisible() { + return profileButton.isDisplayed(); + } + + public boolean isSelfProfileButtonInvisible() { + return waitUntilElementInvisible(profileButton); + } + + public void tapProfileImage() { + userProfileImage.click(); + } + + public boolean isLegalHoldIndicatorVisible() { + return waitUntilElementVisible(legalHoldIndicator); + } + + public boolean isLegalHoldIndicatorInvisible() { + return waitUntilElementInvisible(legalHoldIndicator); + } + + public void iTapLegalHoldIndicator() { + legalHoldIndicator.click(); + } + public void iOpenSearchScreen() { + openSearchScreenButton.click(); + } + + public void iTapOnFilterButton() { + filterButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/UniqueUsernamePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/UniqueUsernamePage.java new file mode 100644 index 00000000000..b55f9877afa --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/UniqueUsernamePage.java @@ -0,0 +1,33 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class UniqueUsernamePage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "handleTextField") + private WebElement input; + + @iOSXCUITFindBy(accessibility = "Save") + private WebElement saveButton; + + public UniqueUsernamePage(WebDriver driver) { + super(driver); + } + + public void tapSaveButton() { + saveButton.click(); + } + + public void inputStringInNameInput(String name) { + input.clear(); + if (name.length() > 0) { + input.sendKeys(name); + } + } + + public boolean isSaveButtonEnabled() { + return saveButton.isEnabled(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/UniqueUsernameTakeoverPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/UniqueUsernameTakeoverPage.java new file mode 100644 index 00000000000..6f6a48c6130 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/UniqueUsernameTakeoverPage.java @@ -0,0 +1,25 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.*; + +public class UniqueUsernameTakeoverPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Choose yours") + private WebElement chooseYoursButton; + + @iOSXCUITFindBy(accessibility = "Keep this one") + private WebElement keepThisOneButton; + + public UniqueUsernameTakeoverPage(WebDriver driver) { + super(driver); + } + + public boolean isVisible() { + return waitUntilElementVisible(chooseYoursButton); + } + + public void tapKeepThisOneButton() { + keepThisOneButton.click(); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/VideoPlayerPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/VideoPlayerPage.java new file mode 100644 index 00000000000..bec059ce349 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/VideoPlayerPage.java @@ -0,0 +1,33 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class VideoPlayerPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Play/Pause") + private WebElement playPauseButton; + + @iOSXCUITFindBy(accessibility = "Done") + private WebElement nameVideoDoneButton; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeApplication' AND name == 'Safari'") + private WebElement predicateWebPlayer; + + public VideoPlayerPage(WebDriver driver) { + super(driver); + } + + public boolean isVideoPlayerPageOpened() { + return predicateWebPlayer.isDisplayed(); + } + + public boolean isPlayPauseButtonVisible() { + return playPauseButton.isDisplayed(); + } + + public void tapDoneButton() { + nameVideoDoneButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/VoiceFiltersOverlay.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/VoiceFiltersOverlay.java new file mode 100644 index 00000000000..6d379741dd5 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/VoiceFiltersOverlay.java @@ -0,0 +1,76 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import java.util.Random; + +public class VoiceFiltersOverlay extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Start recording") + private WebElement startRecordButton; + + @iOSXCUITFindBy(accessibility = "Stop recording") + private WebElement stopRecordButton; + + @iOSXCUITFindBy(accessibility = "Send") + private WebElement confirmRecordButton; + + @iOSXCUITFindBy(accessibility = "Helium") + private WebElement effectHeliumButton; + + @iOSXCUITFindBy(accessibility = "Quick") + private WebElement effectHareButton; + + @iOSXCUITFindBy(accessibility = "Deep voice") + private WebElement effectJellyfishButton; + + @iOSXCUITFindBy(accessibility = "Hall effect") + private WebElement effectCathedralButton; + + @iOSXCUITFindBy(accessibility = "Alien") + private WebElement effectAlienButton; + + @iOSXCUITFindBy(accessibility = "Robotic") + private WebElement effectVocoderMedButton; + + @iOSXCUITFindBy(accessibility = "High to deep") + private WebElement effectRollerCoasterButton; + + private static final Random rand = new Random(); + + public VoiceFiltersOverlay(WebDriver driver) { + super(driver); + } + + public void tapOnStartButton() { + startRecordButton.click(); + } + + public void tapOnStopButton() { + stopRecordButton.click(); + } + + public void tapOnConfirmButton() { + confirmRecordButton.click(); + } + + public boolean isConfirmButtonVisible() { + return confirmRecordButton.isDisplayed(); + } + + public boolean isConfirmButtonInVisible() { + return isElementInvisible(confirmRecordButton); + } + + public void tapRandomEffectButtons(int count) { + final WebElement[] availableButtons = new WebElement[]{ + effectHeliumButton, effectJellyfishButton, effectHareButton, effectCathedralButton, + effectAlienButton, effectVocoderMedButton, effectRollerCoasterButton + }; + for (int i = 0; i < count; i++) { + final WebElement locator = availableButtons[rand.nextInt(availableButtons.length)]; + locator.click(); + } + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/WelcomePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/WelcomePage.java new file mode 100644 index 00000000000..eb8c73d0ba6 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/WelcomePage.java @@ -0,0 +1,66 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import java.util.List; +import java.util.stream.Collectors; + +public class WelcomePage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "WireLogo") + private WebElement wireLogo; + + @iOSXCUITFindBy(accessibility = "Trying to create a Pro or Enterprise account for your business or organization?") + private WebElement welcomeMessage; + + @iOSXCUITFindBy(accessibility = "Create An Account") + private WebElement createAnAccountBtn; + + @iOSXCUITFindBy(accessibility = "Log in") + private WebElement logInBtn; + + @iOSXCUITFindBy(accessibility = "Enterprise Login") + private WebElement enterpriseLogInBtn; + + @iOSXCUITFindBy(xpath = "//XCUIElementTypeStaticText") + private List staticTextElements; + + public WelcomePage(WebDriver driver) { + super(driver); + } + + public boolean isWireLogoVisible() { + return isElementVisible(wireLogo); + } + + public boolean isWelcomeMessageVisible() { + return welcomeMessage.isDisplayed(); + } + + public boolean isWelcomePageInvisible() { + return isElementInvisible(wireLogo) && isElementInvisible(welcomeMessage); + } + + public List getStaticTexts() { + return staticTextElements.stream().map(WebElement::getText).collect(Collectors.toList()); + } + + public boolean isEnterpriseLogInButtonVisible() { + return enterpriseLogInBtn.isDisplayed(); + } + + public void tapLoginButton() { + waitUntilElementClickable(logInBtn); + logInBtn.click(); + } + + public void tapCreateAnAccountButton() { + createAnAccountBtn.click(); + } + + public void tapEnterpriseLoginButton() { + waitUntilElementClickable(enterpriseLogInBtn); + enterpriseLogInBtn.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/calling/CallPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/calling/CallPage.java new file mode 100644 index 00000000000..3ce96ebaee9 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/calling/CallPage.java @@ -0,0 +1,24 @@ +package com.wearezeta.auto.ios.pages.calling; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class CallPage extends IOSPage { + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Constant Bit Rate'") + private WebElement labelCBR; + + public CallPage(WebDriver driver) { + super(driver); + } + + public boolean isCBRLabelVisible() { + return labelCBR.isDisplayed(); + } + + public boolean isCBRLabelInvisible() { + return isElementInvisible(labelCBR); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/calling/VideoCallingOverlayPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/calling/VideoCallingOverlayPage.java new file mode 100644 index 00000000000..1078dc6d5eb --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/calling/VideoCallingOverlayPage.java @@ -0,0 +1,365 @@ +package com.wearezeta.auto.ios.pages.calling; + +import com.google.zxing.NotFoundException; +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.ios.IOSTouchAction; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import io.appium.java_client.touch.WaitOptions; +import io.appium.java_client.touch.offset.PointOption; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.common.imagecomparator.QRCode; +import com.wearezeta.auto.common.misc.Timedelta; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.Dimension; +import org.openqa.selenium.WebElement; +import java.awt.image.BufferedImage; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Function; + +public class VideoCallingOverlayPage extends IOSPage { + + @iOSXCUITFindBy(iOSNsPredicate = "name BEGINSWITH 'videoView' AND name CONTAINS 'maximized' AND type == 'XCUIElementTypeButton'") + private WebElement maximizedVideoView; + + public VideoCallingOverlayPage(WebDriver driver) { + super(driver); + } + + private static final String strNameParticipantCell = "user_cell.name"; + + private static final String nameStrParticipantCamera = "img.video"; + + private static String nameMicrophoneUnmuted = "Microphone on"; + private static String nameMicrophoneMuted = "Microphone off"; + + @iOSXCUITFindBy(accessibility = "ThumbnailView") + private WebElement videoSelfPreview; + + @iOSXCUITFindBy(accessibility = "Double tap to go back, pinch to zoom") + private WebElement iSeeZoomInMessage; + + @iOSXCUITFindBy(accessibility = "Double tap on a tile for fullscreen") + private WebElement iSeeFullScreenMessage; + + @iOSXCUITFindBy(accessibility = "speakers_and_all_toggle.selected.all") + private WebElement speakerAndAllTabSelectedAll; + + @iOSXCUITFindBy(accessibility = "speakers_and_all_toggle.selected.speakers") + private WebElement speakerAndAllTabSelectedSpeakers; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'SPEAKERS' AND name == 'SPEAKERS' AND type == 'XCUIElementTypeButton'") + private WebElement speakerTab; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'ALL' AND name == 'ALL' AND value == 'ALL'") + private WebElement allTab; + + @iOSXCUITFindBy(accessibility = "No active video speakers...") + private WebElement noActivitySpeakerIcon; + + @iOSXCUITFindBy(iOSNsPredicate = "value CONTAINS 'page 1'") + private WebElement firstPagePaginationIcon; + + @iOSXCUITFindBy(iOSNsPredicate = "value == 'page 2 of 7'") + private WebElement secondPagePaginationIcon; + + @iOSXCUITFindBy(iOSNsPredicate = "value BEGINSWITH 'page'") + private WebElement paginationIcon; + + @iOSXCUITFindBy(iOSNsPredicate = "label CONTAINS 'camera'") + private WebElement callVideoButton; + + @iOSXCUITFindBy(iOSNsPredicate = "label CONTAINS 'speaker'") + private WebElement callSpeakerButton; + + @iOSXCUITFindBy(accessibility = "Turn off microphone") + private WebElement activeMuteButton; + + @iOSXCUITFindBy(accessibility = "Turn on microphone") + private WebElement inactiveMuteButton; + + @iOSXCUITFindBy(accessibility = "Turn on camera") + private WebElement switchONCameraButton; + + @iOSXCUITFindBy(accessibility = "Turn off camera") + private WebElement switchOffCameraButton; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Minimize calling view'") + private WebElement minimizeButton; + + @iOSXCUITFindBy(accessibility = "toast.mutedOnJoin") + private WebElement muteIndicatorBanner; + + @iOSXCUITFindBy(accessibility = "btn.close") + private WebElement closeButton; + + @iOSXCUITFindBy(accessibility = "End call") + private WebElement callLeaveButton; + + @iOSXCUITFindBy(accessibility = "ClassificationBannerClassified") + private WebElement nameClassifiedDomainLabel; + + @iOSXCUITFindBy(accessibility = "ClassificationBannerUnclassified") + private WebElement nameUnclassifiedDomainLabel; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeButton' AND label == 'Back'") + private WebElement tapBackButton; + + @iOSXCUITFindBy(accessibility = "Learn more about Wire’s pricing") + private WebElement learnMoreAboutWirePricingButton; + + @iOSXCUITFindBy(accessibility = "Upgrade now") + private WebElement upgradeNowButton; + + @iOSXCUITFindBy(accessibility = "Learn more about the Enterprise plan") + private WebElement learnMoreAboutWireEnterpriseButton; + + @iOSXCUITFindBy(accessibility = "CallStatusLabel") + private WebElement nameCallStatusLabel; + + @iOSXCUITFindBy(accessibility = "Accept") + private WebElement acceptButton; + + @iOSXCUITFindBy(accessibility = "OK") + private WebElement okButton; + + @iOSXCUITFindBy(accessibility = "Allow") + private WebElement allowButton; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'New Device' AND name == 'New Device' AND value == 'New Device'") + private WebElement alertNewDevice; + + @iOSXCUITFindBy(accessibility = "CallFlipCameraButton") + private WebElement switchCameraButton; + + @iOSXCUITFindBy(accessibility = "ConferenceCallingBadge") + private WebElement nameConferenceCallingBadge; + + @iOSXCUITFindBy(accessibility = "OpenOngoingCallButton") + private WebElement nameRestoreOverlayButton; + + // Copied from AVCallingOverlayPage to remove inheritance + private static final By nameMinimizeOverlayButton = MobileBy.AccessibilityId("CallDismissOverlayButton"); + private static final By predicateSeeAllButton = MobileBy.iOSNsPredicateString("name BEGINSWITH 'Show All'"); + private static final By nameEndCallButton = MobileBy.AccessibilityId("LeaveCallButton"); + private static final By nameSpeakersButton = MobileBy.AccessibilityId("CallSpeakerButton"); + private static final By nameMuteCallButton = MobileBy.AccessibilityId("CallMuteButton"); + protected static final By nameCallVideoButton = MobileBy.iOSNsPredicateString("label CONTAINS 'camera'"); + private static final By nameAcceptCallButton = MobileBy.AccessibilityId("AcceptButton"); + private static final By nameBitRateLabel = MobileBy.AccessibilityId("bitrate-indicator"); + + private static final By predicateLoudspeakerToggled = MobileBy.iOSNsPredicateString("type == 'XCUIElementTypeButton' AND name == 'CallSpeakerButton' AND value == '1'"); + + private static final Function predicateCallingSpeakerByName = name -> MobileBy.iOSNsPredicateString(String.format("name == 'Turn on speaker' AND label CONTAINS '%s'", name)); + + private static final Function predicateCallingMuteByName = name -> MobileBy.iOSNsPredicateString(String.format("name == 'Turn off microphone' AND label CONTAINS '%s'", name)); + + private static final Function predicateCallingMessageByName = name -> MobileBy.iOSNsPredicateString(String.format("name == '%s' AND label CONTAINS '%s'", name, name)); + + private static final Function predicateActiveVideoParticipant = usernameAlias -> MobileBy.iOSNsPredicateString(String.format("label CONTAINS '%s' AND label CONTAINS 'Camera on' AND label CONTAINS 'Microphone on' AND label CONTAINS 'Active speaker' AND type == 'XCUIElementTypeButton'", usernameAlias)); + + private static final Function predicateActiveVideoParticipantList = usernameAlias -> MobileBy.iOSNsPredicateString(String.format("label CONTAINS '%s' AND label CONTAINS 'Microphone on' AND type == 'XCUIElementTypeStaticText'", usernameAlias)); + + private static final Function predicateMaximizedVideoView = usernameAlias -> MobileBy.iOSNsPredicateString(String.format("name BEGINSWITH 'videoView' AND name CONTAINS '%s' AND name CONTAINS 'maximized' AND type == 'XCUIElementTypeButton'", usernameAlias)); + + private static final Function predicateMinimizedVideoView = usernameAlias -> MobileBy.iOSNsPredicateString(String.format("name BEGINSWITH 'videoView' AND name CONTAINS '%s' AND name CONTAINS 'minimized' AND type == 'XCUIElementTypeButton'", usernameAlias)); + + private static final Function predicateActiveSpeakerVideoView = usernameAlias -> MobileBy.iOSNsPredicateString(String.format("name BEGINSWITH 'videoView' AND name CONTAINS '%s' AND name CONTAINS 'minimized' AND name ENDSWITH 'active' AND type == 'XCUIElementTypeButton'", usernameAlias)); + + private static final Function predicateActiveSpeaker1to1VideoView = usernameAlias -> MobileBy.iOSNsPredicateString(String.format("name BEGINSWITH 'videoView' AND name CONTAINS '%s' AND name CONTAINS 'minimized' AND name ENDSWITH 'active' AND type == 'XCUIElementTypeStaticText'", usernameAlias)); + + private static final Function predicateInActiveSpeakerVideoView = usernameAlias -> MobileBy.iOSNsPredicateString(String.format("name BEGINSWITH 'videoView' AND name CONTAINS '%s' AND name CONTAINS 'minimized' AND name ENDSWITH 'inactive' AND type == 'XCUIElementTypeButton'", usernameAlias)); + + private static final Function predicateAvatarGroupAudioParticipant = usernameAlias -> MobileBy.iOSNsPredicateString(String.format("label CONTAINS '%s' AND label CONTAINS 'Camera off' AND label CONTAINS 'Microphone' AND type == 'XCUIElementTypeButton'", usernameAlias)); + + private static final Function predicateAvatarAudioParticipant = usernameAlias -> MobileBy.iOSNsPredicateString(String.format("label CONTAINS '%s' AND label CONTAINS 'Camera off' AND label CONTAINS 'Microphone' AND type == 'XCUIElementTypeStaticText'", usernameAlias)); + + private static final Function nameOfParticipantOnLabel = name -> MobileBy.iOSClassChain( + String.format("**/XCUIElementTypeCollectionView/XCUIElementTypeCell[$name == 'GridCell'$]/XCUIElementTypeOther/XCUIElementTypeButton[$name CONTAINS[cd] '%s' OR name CONTAINS[cd] '%s (You)'$]", name, name)); + + private static final BiFunction nameOfParticipantOnLabelOnPosition = (position, name) -> MobileBy.iOSClassChain( + String.format("**/XCUIElementTypeCollectionView/XCUIElementTypeCell[$name == 'GridCell'$][%s]/**/XCUIElementTypeButton[$label CONTAINS[cd] '%s' OR label CONTAINS[cd] '%s (You)'$]", position, name, name)); + + private static final Function muteImageOnParticipantLabel = position -> MobileBy.iOSClassChain( + String.format("**/XCUIElementTypeCollectionView/XCUIElementTypeCell[$name == 'GridCell'$][%s]/**/XCUIElementTypeButton[$label CONTAINS[cd] '%s'$]", position, nameMicrophoneMuted)); + + private static final Function unmuteImageOnParticipantLabel = position -> MobileBy.iOSClassChain( + String.format("**/XCUIElementTypeCollectionView/XCUIElementTypeCell[$name == 'GridCell'$][%s]/**/XCUIElementTypeButton[$label CONTAINS[cd] '%s'$]", position, nameMicrophoneUnmuted)); + + private static final Function classChainGroupCallParticipantsCellByIdx = idx -> + MobileBy.iOSClassChain(String.format( + "**/XCUIElementTypeCollectionView/XCUIElementTypeStaticText/XCUIElementTypeOther/XCUIElementTypeOther/XCUIElementTypeOther/XCUIElementTypeStaticText[$name == '%s'$][%s]", + strNameParticipantCell, idx)); + + private static final BiFunction classChainGroupCallParticipantsCellByNameAndText = + (name, text) -> MobileBy.iOSClassChain(String.format( + "**/XCUIElementTypeCollectionView/XCUIElementTypeStaticText/XCUIElementTypeOther/XCUIElementTypeOther/XCUIElementTypeOther/XCUIElementTypeStaticText[$name == '%s'$][$value CONTAINS[cd] '%s'$][$label CONTAINS[cd] '%s'$]", strNameParticipantCell, name, text)); + + private static final Function classChainGroupCallParticipantsCellByName = name -> + MobileBy.iOSClassChain(String.format( + "**/XCUIElementTypeCollectionView/XCUIElementTypeStaticText[$name == '%s'$]/" + + "**/*[`value == '%s' OR value == '%s (You)'`]", strNameParticipantCell, name, name)); + + private static final BiFunction classChainGroupCallParticipantsCellByNameAndIdx = (name, idx) -> + MobileBy.iOSClassChain(String.format( + "**/XCUIElementTypeCollectionView/XCUIElementTypeStaticText[$name == '%s'$][%s]/" + + "**/*[`value == '%s' OR value == '%s (You)'`]", strNameParticipantCell, idx, name, name)); + + private static final BiFunction classChainGroupCallParticipantsCellByNameAndIcon = + (name, iconId) -> MobileBy.iOSClassChain(String.format( + "**/XCUIElementTypeCollectionView/XCUIElementTypeStaticText[$name == '%s'$][$value == '%s' OR value == '%s (You)'$]/" + + "**/*[`name == '%s'`]", strNameParticipantCell, name, name, iconId)); + + private static final BiFunction classChainGroupCallParticipantsCellByNameAndMuteIcon = + (name, iconId) -> MobileBy.iOSClassChain(String.format( + "**/XCUIElementTypeCollectionView/XCUIElementTypeStaticText[`name CONTAINS[cd] '%s'`]/" + + "XCUIElementTypeOther/XCUIElementTypeOther/XCUIElementTypeOther/XCUIElementTypeImage[`name == '%s'`]", name, iconId)); + + public void tapDeclineButton(){ + tapAtTheCenterOfElement(callLeaveButton); + } + + public boolean isBitRateLabelVisible() { + return getDriver().findElement(nameBitRateLabel).isDisplayed(); + } + + public boolean isBitRateLabelInvisible() { + return isLocatorInvisible(nameBitRateLabel); + } + + public boolean isMuteIndicatorBannerVisible() { + return muteIndicatorBanner.isDisplayed(); + } + + public boolean isMuteIndicatorBannerInvisible() { + return isElementInvisible(muteIndicatorBanner); + } + + public void tapCloseButtonOnMuteIndicatorBanner(){ + closeButton.click(); + } + + @Deprecated // Please don't add more and instead use individual methods and iOSXCUITFindBy + protected By getLocatorByName(final String name) { + switch (name) { + case "Minimize": + return nameMinimizeOverlayButton; + case "Mute": + return nameMuteCallButton; + case "Leave": + return nameEndCallButton; + case "Accept": + return nameAcceptCallButton; + case "Call Video": + return nameCallVideoButton; + case "Call Speaker": + return nameSpeakersButton; + case "Show All": + return predicateSeeAllButton; + case "Constant Bitrate": + case "VARIABLE BIT RATE": + return nameBitRateLabel; + default: + throw new IllegalArgumentException(String.format("Button name '%s' is unknown", name)); + } + } + + public void iTapOnScreen() { + tapScreenByPercents(50,50); + } + + public boolean iSeeAvatarGroupAudioParticipants(String usernameAlias) { + return isLocatorExist(predicateAvatarGroupAudioParticipant.apply(usernameAlias)); + } + + public boolean iDontSeeGroupAvatarAudioParticipants(String usernameAlias) { + return isLocatorInvisible(predicateAvatarGroupAudioParticipant.apply(usernameAlias)); + } + + public boolean isCountOfParticipantsEqualTo(int expectedNumberOfParticipants, Timedelta timeout) { + assert expectedNumberOfParticipants > 0 : "The expected number of participants should be greater than zero"; + if (expectedNumberOfParticipants < 8) { + final By locator = classChainGroupCallParticipantsCellByIdx.apply(expectedNumberOfParticipants); + return isLocatorExist(locator, timeout); + } else { + return isLocatorExist(MobileBy.AccessibilityId(String.format("PARTICIPANTS (%s)", expectedNumberOfParticipants))); + } + } + + public boolean isRestoreButtonVisible() { + return waitUntilElementVisible(nameRestoreOverlayButton); + } + + public void tapRestoreButton() { + nameRestoreOverlayButton.click(); + } + + public void iTapMinimize(){ + minimizeButton.click(); + } + + public boolean iSeeLeaveCallButton(){ + return waitUntilElementVisible(callLeaveButton); + } + + public boolean iDontSeeLeaveCallButton(){ + return waitUntilElementInvisible(callLeaveButton); + } + + public boolean isClassifiedLabelVisibleOnCallingOverlay() { + return waitUntilElementVisible(nameClassifiedDomainLabel); + } + + public boolean isClassifiedLabelInvisibleOnCallingOverlay() { + return waitUntilElementInvisible(nameClassifiedDomainLabel); + } + + public boolean isUnclassifiedLabelVisibleOnCallingOverlay() { + return waitUntilElementVisible(nameUnclassifiedDomainLabel); + } + + public boolean isUnclassifiedLabelInvisibleOnCallingOverlay() { + return waitUntilElementInvisible(nameUnclassifiedDomainLabel); + } + + public void tapBackButton(){ + tapAtTheCenterOfElement(tapBackButton); + } + + public boolean isCallingMessageContainingVisible(String text) { + return isLocatorDisplayed(predicateCallingMessageByName.apply(text)); + } + + public void tapAcceptButton(){ + acceptButton.click(); + } + + public void tapOKButton() { + waitUntilElementVisible(okButton); + okButton.click(); + } + + public void swipeUpParticipantsList(){ + Dimension dimension = getDriver().manage().window().getSize(); + int scrollStart = (int) (dimension.getHeight() * 0.9); + int scrollEnd = (int) (dimension.getHeight() * 0.1); + IOSTouchAction action = new IOSTouchAction(getDriver()); + action.press(PointOption.point(1,scrollStart)) + .waitAction(WaitOptions.waitOptions(Duration.ofMillis(300))) + .moveTo(PointOption.point(1, scrollEnd)) + .release().perform(); + } + + public boolean iSeeNewDeviceAlert(){ + waitUntilElementVisible(alertNewDevice); + return alertNewDevice.isDisplayed(); + } + + public boolean iDontSeeNewDeviceAlert(){ + return isElementInvisible(alertNewDevice); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/CertificateDetailsPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/CertificateDetailsPage.java new file mode 100644 index 00000000000..2f5b66fa7a5 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/CertificateDetailsPage.java @@ -0,0 +1,28 @@ +package com.wearezeta.auto.ios.pages.details_overlay.common; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class CertificateDetailsPage extends IOSPage { + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeButton[`name == \"Show Certificate Details\"`]") + private WebElement showCertificate; + + @iOSXCUITFindBy(accessibility = "CertificateDetailsView") + private WebElement certificateDetails; + + public CertificateDetailsPage(WebDriver driver) { + super(driver); + } + + public void openCertificateDetails() { + showCertificate.click(); + } + + public boolean isCertificateDetailsPageVisible() { + return certificateDetails.isDisplayed(); + } + + public String getCertificate() { return certificateDetails.getText(); } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/DeviceDetailsPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/DeviceDetailsPage.java new file mode 100644 index 00000000000..eacf752ef9f --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/DeviceDetailsPage.java @@ -0,0 +1,75 @@ +package com.wearezeta.auto.ios.pages.details_overlay.common; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class DeviceDetailsPage extends IOSPage { + + // TODO: Once the accessibility identifier for the device verified switch is fixed, clean this up + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeSwitch[`value == \"0\"`][2]") + private WebElement verifySwitcher; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Device verified' AND name == 'Device verified' AND type == 'XCUIElementTypeSwitch'") + private WebElement verifyDeviceSwitcher; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Verified' AND name == 'device verified' AND type == 'XCUIElementTypeSwitch'") + private WebElement verifySelfDeviceSwitcher; + + @iOSXCUITFindBy(accessibility = "fingerprint") + private WebElement fingerprint; + + @iOSXCUITFindBy(accessibility = "Remove Device") + private WebElement removeDevice; + + @iOSXCUITFindBy(accessibility = "Go back to device overview") + private WebElement backButton; + + @iOSXCUITFindBy(accessibility = "Back") + private WebElement backButtonDeviceDetails; + + @iOSXCUITFindBy(accessibility = "Go back to device list") + private WebElement backButtonDeviceList; + + @iOSXCUITFindBy(accessibility = "Revoked") + private WebElement revoked; + + private static final Function predicateText = text -> MobileBy.iOSNsPredicateString(String.format("type == 'XCUIElementTypeLink' AND value CONTAINS '%s'", text)); + + public DeviceDetailsPage(WebDriver driver) { + super(driver); + } + + public void tapVerifyToggle() { + if (isElementVisible(verifySwitcher)){ + verifySwitcher.click();} + else if (isElementVisible(verifyDeviceSwitcher)){ + verifyDeviceSwitcher.click(); + } else { + verifySelfDeviceSwitcher.click(); + } + } + + public void tapBackButton() { + if (isElementVisible(backButton)){ + backButton.click(); + } else if (isElementVisible(backButtonDeviceDetails)) { + backButtonDeviceDetails.click(); + } else { + backButtonDeviceList.click(); + } + } + + public void tapRemoveDeviceButton() { + removeDevice.click(); + } + + public boolean isDeviceRevoked() { + return revoked.isDisplayed(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/UserDetailsDevicesPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/UserDetailsDevicesPage.java new file mode 100644 index 00000000000..2790f6863eb --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/UserDetailsDevicesPage.java @@ -0,0 +1,60 @@ +package com.wearezeta.auto.ios.pages.details_overlay.common; + +import com.wearezeta.auto.ios.pages.IOSPage; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; + +import java.util.function.Function; + +public class UserDetailsDevicesPage extends IOSPage { + + private static final String xpathStrDevicesRoot = + "//XCUIElementTypeButton[@label='Devices']/following::XCUIElementTypeCollectionView"; + + private static final By predicateStrDevideTitleValue = MobileBy.iOSNsPredicateString("name == 'device_cell.name' AND value == 'Legal Hold'"); + + private static final By nameLegalHoldIcon = MobileBy.AccessibilityId("img.device_class.legalhold"); + private final String notVerifiedLabel = "Not Verified"; + + private final Function classChainGetDeviceCellByNumber = (deviceNumber) -> MobileBy.iOSClassChain(String.format("**/XCUIElementTypeCollectionView[`visible == 1`]/XCUIElementTypeCell[%s]", deviceNumber)); + + private final Function xpathDeviceByIndex = + idx -> By.xpath(String.format("%s/XCUIElementTypeCell[%s]", getDevicesListRootXPath(), idx)); + + private final Function xpathDevicesByCount = count -> + By.xpath(String.format("//XCUIElementTypeCollectionView[count(XCUIElementTypeCell)=%s]", count)); + + public UserDetailsDevicesPage(WebDriver driver) { + super(driver); + } + + protected String getDevicesListRootXPath() { + return xpathStrDevicesRoot; + } + + public void openDeviceDetailsPage(int deviceIndex) { + final By locator = xpathDeviceByIndex.apply(deviceIndex); + getElement(locator).click(); + } + + public boolean isParticipantDevicesCountEqualTo(int expectedCount) { + final By locator = xpathDevicesByCount.apply(expectedCount); + return isLocatorDisplayed(locator); + } + + public boolean isLegalHoldTheFirstDevice() { + final By cellIndexLocator = xpathDeviceByIndex.apply(2); + return isLocatorDisplayed(getElement(cellIndexLocator), predicateStrDevideTitleValue) && isLocatorExist(nameLegalHoldIcon); + } + + public boolean isDeviceNumberNotVerified(int number) { + number++; + return getElement(classChainGetDeviceCellByNumber.apply(number)).getAttribute("label").contains(notVerifiedLabel); + } + + public boolean isDeviceNumberVerified(int number) { + number++; + return !getElement(classChainGetDeviceCellByNumber.apply(number)).getAttribute("label").contains(notVerifiedLabel); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/UserSettingsDevicesPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/UserSettingsDevicesPage.java new file mode 100644 index 00000000000..317adcac07c --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/common/UserSettingsDevicesPage.java @@ -0,0 +1,103 @@ +package com.wearezeta.auto.ios.pages.details_overlay.common; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.AppiumBy; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class UserSettingsDevicesPage extends IOSPage { + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeStaticText[`name == \"device proteus ID\"`][1]") + private WebElement deviceID; + + @iOSXCUITFindBy(accessibility = "Delete") + private WebElement deleteButton; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeSecureTextField' AND value CONTAINS 'Password'") + private WebElement deleteDevicePasswordField; + + @iOSXCUITFindBy(accessibility = "Wrong password") + private WebElement wrongPasswordDialog; + + @iOSXCUITFindBy(accessibility = "OK") + private WebElement oKButton; + + private static final Function predicateDeleteDeviceButtonByName = deviceName -> + MobileBy.iOSNsPredicateString(String.format("type == 'XCUIElementTypeButton' AND label CONTAINS 'Remove %s'", + deviceName)); + + private static final Function predicateDeleteDeviceButtonByID = deviceID -> + MobileBy.iOSNsPredicateString(String.format("name == 'device proteus ID' AND label CONTAINS '%s'", + deviceID)); + + private static final Function predicateDeviceListEntry = device -> MobileBy.iOSNsPredicateString( + String.format("name == 'device name' AND value CONTAINS '%s'", device)); + + private final Function xpathDevicesByCount = count -> + By.xpath(String.format("//XCUIElementTypeTable[count(XCUIElementTypeCell)=%s]", count)); + + private final Function classChainDeviceForIndex = + idx -> String.format("**/XCUIElementTypeStaticText[`name == \"device proteus ID\"`][%s]", idx); + + public UserSettingsDevicesPage(WebDriver driver) { + super(driver); + } + + public void tapDeleteDeviceButton(String deviceName) { + final By locator = predicateDeleteDeviceButtonByName.apply(deviceName); + getElement(locator).click(); + } + + public void tapDeleteButton() { + deleteButton.click(); + if (!isElementInvisible(deleteButton)) { + deleteButton.click(); + } + } + + public void typePasswordToConfirmDeleteDevice(String password) { + deleteDevicePasswordField.sendKeys(password); + } + + public boolean isDeviceVisibleInList(String device) { + final By locator = predicateDeviceListEntry.apply(device); + return isLocatorDisplayed(locator); + } + + public boolean isDeviceInvisibleInList(String device) { + final By locator = predicateDeviceListEntry.apply(device); + return isLocatorInvisible(locator); + } + + public boolean isUserDevicesCountEqualTo(int expectedCount) { + final By locator = xpathDevicesByCount.apply(expectedCount); + return isLocatorDisplayed(locator); + } + + public String getCurrentDeviceID() { + return deviceID.getAttribute("value"); + } + + public void openDeviceDetailsPage(int deviceIndex) { + final By locator = AppiumBy.iOSClassChain(classChainDeviceForIndex.apply(deviceIndex)); + getDriver().findElement(locator).click(); + } + + public boolean isWrongPasswordDialogVisible() { + return waitUntilElementVisible(wrongPasswordDialog); + } + + public void tapOKOnWrongPasswordDialog() { + oKButton.click(); + } + + public void openDeviceDetailsPageById(String deviceId) { + final By locator = predicateDeleteDeviceButtonByID.apply(deviceId); + getDriver().findElement(locator).click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupAddPeoplePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupAddPeoplePage.java new file mode 100644 index 00000000000..424f06adfcd --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupAddPeoplePage.java @@ -0,0 +1,51 @@ +package com.wearezeta.auto.ios.pages.details_overlay.group; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.ios.pages.search.GroupParticipantsSearchList; +import org.openqa.selenium.WebElement; + +public class GroupAddPeoplePage extends IOSPage { + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeButton' AND name == 'Add Participants'") + private WebElement addPeopleButton; + + @iOSXCUITFindBy(accessibility = "close") + private WebElement exitAddPeopleButton; + + private final GroupParticipantsSearchList participantsSearchList; + + public GroupAddPeoplePage(WebDriver driver) { + super(driver); + this.participantsSearchList = new GroupParticipantsSearchList(driver); + } + + public void tapCloseButton() { + exitAddPeopleButton.click(); + } + + public void tapAddButton() { + addPeopleButton.click(); + } + + public void typeSearchQuery(String text) { + participantsSearchList.typeSearchQuery(text); + } + + public void selectItem(String name) { + participantsSearchList.selectItem(name); + } + + public boolean isItemVisible(String name) { + return participantsSearchList.isItemVisible(name); + } + + public boolean isItemInvisible(String name) { + return participantsSearchList.isItemInvisible(name); + } + + public boolean waitUntilResultsLabelIsVisible(String msg) { + return participantsSearchList.waitUntilResultsLabelIsVisible(msg); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupConnectedParticipantProfilePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupConnectedParticipantProfilePage.java new file mode 100644 index 00000000000..4a9acf41444 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupConnectedParticipantProfilePage.java @@ -0,0 +1,140 @@ +package com.wearezeta.auto.ios.pages.details_overlay.group; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class GroupConnectedParticipantProfilePage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "left_button") + private WebElement leftActionButton; + + @iOSXCUITFindBy(accessibility = "right_button") + private WebElement rightActionButton; + + @iOSXCUITFindBy(accessibility = "cell.profile.group_admin_options") + private WebElement adminToggle; + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'label.team_role' AND value = 'External'") + private WebElement externalIcon; + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'label.group_role' AND value == 'Group Admin'") + private WebElement adminIcon; + + @iOSXCUITFindBy(accessibility = "Go back to conversation details") + private WebElement backButton; + + @iOSXCUITFindBy(accessibility = "Back") + private WebElement back; + + @iOSXCUITFindBy(accessibility = "Devices") + private WebElement devicesTab; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.name','name'}") + private WebElement nameLabel; + + private static final String strOpenConversationButton = "Open conversation"; + private static final String strConnectButton = "Connect"; + private Function predicateLeftButtonByLabel = text -> + MobileBy.iOSNsPredicateString(String.format("name == 'left_button' AND label == '%s'",text)); + + private static final Function predicateNameByValue = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.name','name'} AND value == '%s'", + name)); + + public GroupConnectedParticipantProfilePage(WebDriver driver) { + super(driver); + } + + public void tapRemoveFromConversationButton() { + rightActionButton.click(); + } + + public void tapOpenConversationButton() { + leftActionButton.click(); + } + + public void tapBackButton() { + if(isElementVisible(backButton)){ + backButton.click(); + } else { + back.click(); + } + } + + public void tapOpenMenuButton() { + rightActionButton.click(); + } + + public boolean isOpenConversationButtonVisible() { + return isLocatorDisplayed(predicateLeftButtonByLabel.apply(strOpenConversationButton)); + } + + public boolean isOpenConversationInvisible() { + return isLocatorInvisible(predicateLeftButtonByLabel.apply(strOpenConversationButton)); + } + + public boolean isMoreActionsButtonVisible() { + return isElementVisible(rightActionButton); + } + + public boolean isMoreActionsButtonInvisible() { return isElementInvisible(rightActionButton); + } + + public boolean isConnectButtonVisible() { + return isLocatorDisplayed(predicateLeftButtonByLabel.apply(strConnectButton)); + } + + public boolean isConnectButtonInvisible() { + return isLocatorInvisible(predicateLeftButtonByLabel.apply(strConnectButton)); + } + + public boolean isLeftActionButtonInvisible() { + return isElementInvisible(leftActionButton); + } + + public boolean isAdminToggleVisible() { + return adminToggle.isDisplayed(); + } + + public boolean isAdminToggleInvisible() { + return isElementInvisible(adminToggle); + } + + public void tapAdminToggle() { + adminToggle.click(); + } + + public boolean isAdminIconVisible() { + return adminIcon.isDisplayed(); + } + + public boolean isAdminIconInvisible() { + return isElementInvisible(adminIcon); + } + + public boolean isExternalIconVisible() { + return externalIcon.isDisplayed(); + } + + public boolean isExternalIconInvisible() { + return isElementInvisible(externalIcon); + } + + public boolean isUserDetailNameVisible(String expectedValue){ + return isLocatorDisplayed(predicateNameByValue.apply(expectedValue)); + } + + public boolean isUserDetailNameInvisible() { + return isElementInvisible(nameLabel); + } + + public void tapDevicesTab() { + devicesTab.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupDetailsPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupDetailsPage.java new file mode 100644 index 00000000000..ba7af36c728 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupDetailsPage.java @@ -0,0 +1,265 @@ +package com.wearezeta.auto.ios.pages.details_overlay.group; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.ios.pages.search.GroupParticipantsList; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriverException; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class GroupDetailsPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "close") + private WebElement exitGroupInfoPageButton; + + @iOSXCUITFindBy(accessibility = "OtherUserMetaControllerLeftButton") + private WebElement addPeopleButton; + + @iOSXCUITFindBy(accessibility = "OtherUserMetaControllerRightButton") + private WebElement openMenuButton; + + @iOSXCUITFindBy(accessibility = "group_details.list") + private WebElement listRoot; + + @iOSXCUITFindBy(accessibility = "NameField") + private WebElement conversationNameTextField; + + @iOSXCUITFindBy(accessibility = "ReadReceiptsSwitch") + private WebElement toggleReadReceipts; + + @iOSXCUITFindBy(accessibility = "cell.groupdetails.guestoptions") + private WebElement guestOptionsCell; + + @iOSXCUITFindBy(accessibility = "cell.groupdetails.servicesoptions") + private WebElement servicesOptionsCell; + + @iOSXCUITFindBy(accessibility = "cell.groupdetails.timeoutoptions") + private WebElement timedMessageOptionCell; + + @iOSXCUITFindBy(accessibility = "legalhold") + private WebElement legalHoldIndicator; + + @iOSXCUITFindBy(iOSNsPredicate = "name BEGINSWITH 'GROUP MEMBERS'") + private WebElement membersSection; + + @iOSXCUITFindBy(iOSNsPredicate = "name BEGINSWITH 'Participants'") + private WebElement seeAllButton; + + private static final Function predicateConversationNameByText = text -> MobileBy.iOSNsPredicateString( + String.format("name == 'NameField' AND value == '%s'", text)); + + private static final Function classChainUserInAdminSection = userName -> + MobileBy.iOSClassChain(String.format("**/XCUIElementTypeCollectionView[`name == " + + "'group_details.list'`]/*[`name == 'Admins - participants.section.participants.cell'`]/**/XCUIElementTypeStaticText[`value == '%s' OR value == '%s (You)'`]", userName, userName)); + + private static final By classChainShowAllInAdminSection = + MobileBy.iOSClassChain("**/XCUIElementTypeCollectionView[`name == " + + "'group_details.list'`]/*[`name == 'Admins - cell.call.show_all_participants'`]/**/XCUIElementTypeStaticText[`value BEGINSWITH 'Show All'`]"); + + private static final Function predicateStrMembersCount = count -> + String.format("value == 'GROUP MEMBERS (%s)'", count); + + private static final Function predicateStrAdminsCount = count -> + String.format("value == 'GROUP ADMINS (%s)'", count); + + private final GroupParticipantsList participantsList; + + public GroupDetailsPage(WebDriver driver) { + super(driver); + this.participantsList = new GroupParticipantsList(driver); + } + + + public boolean isGroupNameEqualTo(String expectedName) { + final By locator = predicateConversationNameByText.apply(expectedName); + return isLocatorDisplayed(locator); + } + + public void setGroupChatName(String name) { + this.isKeyboardVisible(); + try { + this.tapAtTheCenterOfElement(conversationNameTextField); + this.tapAtTheCenterOfElement(conversationNameTextField); + conversationNameTextField.clear(); + } catch (WebDriverException e) { + this.tapAtTheCenterOfElement(conversationNameTextField); + conversationNameTextField.clear(); + } + conversationNameTextField.sendKeys(name); + tapKeyboardCommitButton(); + } + + public boolean isGroupChatNameEnabled(){ + this.tapAtTheCenterOfElement(conversationNameTextField); + return this.isKeyboardVisible(); + } + + public boolean isNumberOfMembersParticipantsEquals(int expectedNumber) { + final By locator = MobileBy.iOSNsPredicateString(predicateStrMembersCount.apply(expectedNumber)); + if (!isLocatorDisplayed(locator, Timedelta.ofSeconds(3))) { + // Sometimes the list is too long, so we need to scroll it a bit + this.swipe(listRoot, SwipeDirection.UP); + return isLocatorDisplayed(locator, Timedelta.ofSeconds(5)); + } + return true; + } + + public boolean isNumberOfAdminsParticipantsEquals(int expectedNumber) { + final By locator = MobileBy.iOSNsPredicateString(predicateStrAdminsCount.apply(expectedNumber)); + if (!isLocatorDisplayed(locator, Timedelta.ofSeconds(3))) { + // Sometimes the list is too long, so we need to scroll it a bit + this.swipe(listRoot, SwipeDirection.UP); + return isLocatorDisplayed(locator, Timedelta.ofSeconds(5)); + } + return true; + } + + public int getGroupNameLength() { + return conversationNameTextField.getText().length(); + } + + public void tapAddPeopleButton() { + addPeopleButton.click(); + } + + public void tapOpenMenuButton() { + openMenuButton.click(); + } + + public void tapXButton() { + waitUntilElementClickable(exitGroupInfoPageButton); + exitGroupInfoPageButton.click(); + } + + public boolean isAddPeopleButtonVisible() { + return addPeopleButton.isDisplayed(); + } + + public boolean isAddPeopleButtonInvisible() { + return isElementInvisible(addPeopleButton); + } + + public boolean isExternalIndicatorVisibleFor(String name) { + return participantsList.isExternalIconVisibleFor(name); + } + + public void openGuestOptions() { + guestOptionsCell.click(); + } + + public boolean isGuestOptionsVisible() { + return guestOptionsCell.isDisplayed(); + } + + public boolean isGuestOptionsInvisible() { + return isElementInvisible(guestOptionsCell); + } + + public boolean isServicesOptionsVisible() { + return servicesOptionsCell.isDisplayed(); + } + + public void selectParticipant(String name) { + if (participantsList.isParticipantVisible(name)) { + participantsList.selectParticipant(name); + } else { + int nScroll = 0; + int maxScroll = 2; + while (participantsList.isParticipantInvisible(name) && nScroll <= maxScroll) { + //scroll down + this.swipe(listRoot, SwipeDirection.UP); + nScroll++; + } + if (participantsList.isParticipantInvisible(name)) { + nScroll = 0; + while(participantsList.isParticipantInvisible(name) && nScroll <= maxScroll){ + //scroll up + this.swipe(listRoot, SwipeDirection.DOWN); + nScroll++; + } + } + participantsList.selectParticipant(name); + } + } + + public int getServicesCount() { + return participantsList.getServicesCount(); + } + + public int getParticipantsCount() { + if (!participantsList.isParticipantCellVisible()) { + // Sometimes the list is too long, so we need to scroll it a bit + this.swipe(listRoot, SwipeDirection.UP); + } + return participantsList.getPeopleCount(); + } + + public boolean isParticipantVisible(String name) { + return participantsList.isParticipantVisible(name); + } + + public boolean isParticipantInvisible(String name) { + return participantsList.isParticipantInvisible(name); + } + + public boolean isTimedMessagesOptionVisible() { + return timedMessageOptionCell.isDisplayed(); + } + + public boolean isTimedMessagesOptionInvisible() { + return isElementInvisible(timedMessageOptionCell); + } + + public boolean isReadReceiptsVisible() { + return toggleReadReceipts.isDisplayed(); + } + + public boolean isReadReceiptsInvisible() { + return isElementInvisible(toggleReadReceipts); + } + + public boolean isLegalHoldIndicatorVisible() { + return isElementVisible(legalHoldIndicator); + } + + public boolean isLegalHoldIndicatorInvisible() { + return isElementInvisible(legalHoldIndicator); + } + + public void tapLegalHoldIndicator() { + legalHoldIndicator.click(); + } + + public boolean isMembersSectionVisible() { + return membersSection.isDisplayed(); + } + + public boolean isMembersSectionInvisible() { + return isElementInvisible(membersSection); + } + + public boolean isUserInAdminsSection(String userName) { + return isLocatorDisplayed(classChainUserInAdminSection.apply(userName)); + } + + public boolean isUserNotInAdminsSection(String userName) { + return isLocatorInvisible(classChainUserInAdminSection.apply(userName)); + } + + public boolean isSeeAllButtonVisibleInAdminsSection() { + return isLocatorDisplayed(classChainShowAllInAdminSection); + } + + public boolean isSeeAllButtonInvisibleInAdminsSection() { + return isLocatorInvisible(classChainShowAllInAdminSection); + } + + public void tapSeeAllButton() { + seeAllButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupPendingParticipantIncomingConnectionPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupPendingParticipantIncomingConnectionPage.java new file mode 100644 index 00000000000..992a30479b7 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupPendingParticipantIncomingConnectionPage.java @@ -0,0 +1,23 @@ +package com.wearezeta.auto.ios.pages.details_overlay.group; + +import com.wearezeta.auto.ios.pages.IOSPage; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; + +import java.util.function.Function; + +public class GroupPendingParticipantIncomingConnectionPage extends IOSPage { + + private static final Function predicateNameByValue = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.name','name'} AND value == '%s'", + name)); + + public GroupPendingParticipantIncomingConnectionPage(WebDriver driver) { + super(driver); + } + + public boolean isUserDetailNameVisible(String name) { + return isLocatorDisplayed(predicateNameByValue.apply(name)); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupPendingParticipantOutgoingConnectionPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupPendingParticipantOutgoingConnectionPage.java new file mode 100644 index 00000000000..50e180a2002 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupPendingParticipantOutgoingConnectionPage.java @@ -0,0 +1,47 @@ +package com.wearezeta.auto.ios.pages.details_overlay.group; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class GroupPendingParticipantOutgoingConnectionPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Go back to conversation details") + private WebElement backButton; + + @iOSXCUITFindBy(accessibility = "right_button") + private WebElement openMenuButton; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeButton[`label == 'Connect'`][-1]") + private WebElement connectButton; + + + private static final Function predicateNameByValue = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.name','name'} AND value == '%s'", + name)); + + public GroupPendingParticipantOutgoingConnectionPage(WebDriver driver) { + super(driver); + } + + public void tapBackButton() { + backButton.click(); + } + + public void tapOpenMenuButton() { + openMenuButton.click(); + } + + public boolean isUserNameVisible(String value) { + return isLocatorDisplayed(predicateNameByValue.apply(value)); + } + + public boolean isConnectButtonVisible() { + return connectButton.isDisplayed(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupPeoplePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupPeoplePage.java new file mode 100644 index 00000000000..340561070c2 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GroupPeoplePage.java @@ -0,0 +1,67 @@ +package com.wearezeta.auto.ios.pages.details_overlay.group; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class GroupPeoplePage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "GROUP MEMBERS") + private WebElement membersSection; + + @iOSXCUITFindBy(accessibility = "GROUP ADMINS") + private WebElement adminsSection; + + private static final Function classChainExternalIconForUser = name -> + MobileBy.iOSClassChain(String.format("**/XCUIElementTypeCollectionView[`name == 'group_details.full_list'`]/XCUIElementTypeCell[`name ENDSWITH 'participants.section.participants.cell'`][$name == 'user_cell.name' AND value == '%s' OR name == 'user_cell.name' AND value == '%s (You)'$]/**/XCUIElementTypeImage[`name == 'img.external'`]", name, name + )); + + private static final Function classChainUserInAdminSection = userName -> + MobileBy.iOSClassChain(String.format("**/XCUIElementTypeCollectionView[`name == " + + "'group_details.full_list'`]/*[`name == 'Admins - participants.section.participants.cell'`]/**/XCUIElementTypeStaticText[`value == '%s' OR value == '%s (You)'`]", userName, userName)); + + private static final Function classChainUser = userName -> + MobileBy.iOSClassChain(String.format("**/XCUIElementTypeCollectionView[`name == " + + "'group_details.full_list'`]/*[`name ENDSWITH 'participants.section.participants.cell'`]/**/XCUIElementTypeStaticText[`value == '%s' OR value == '%s (You)'`]", userName, userName)); + + public GroupPeoplePage(WebDriver driver) { + super(driver); + } + + public boolean isExternalIndicatorVisibleFor(String name) { + return isLocatorDisplayed(classChainExternalIconForUser.apply(name)); + } + + public boolean isMembersSectionVisible() { + return membersSection.isDisplayed(); + } + + public boolean isMembersSectionInvisible() { + return isElementInvisible(membersSection); + } + + public boolean isAdminsSectionVisible() { + return adminsSection.isDisplayed(); + } + + public boolean isAdminsSectionInvisible() { + return isElementInvisible(adminsSection); + } + + public boolean isUserInAdminsSection(String userName) { + return isLocatorDisplayed(classChainUserInAdminSection.apply(userName)); + } + + public boolean isUserNotInAdminsSection(String userName) { + return isLocatorInvisible(classChainUserInAdminSection.apply(userName)); + } + + public void selectParticipantPeoplePage(String name) { + getElement(classChainUser.apply(name)).click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GuestOptionsPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GuestOptionsPage.java new file mode 100644 index 00000000000..9133d40af2f --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/group/GuestOptionsPage.java @@ -0,0 +1,44 @@ +package com.wearezeta.auto.ios.pages.details_overlay.group; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class GuestOptionsPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Go back to conversation details") + private WebElement backButton; + + @iOSXCUITFindBy(accessibility = "Create Link") + private WebElement createLinkButton; + + private static final Function predicateStrAllowGuestsByValue = text -> + MobileBy.iOSNsPredicateString(String.format("name == 'toggle.guestoptions.allowguests' AND value == '%s'", text)); + + public GuestOptionsPage(WebDriver driver) { + super(driver); + } + + public void tapBackButton() { + backButton.click(); + } + + public boolean isCreateLinkButtonVisible() { + return createLinkButton.isDisplayed(); + } + + public boolean isCreateLinkButtonInvisible() { + return isElementInvisible(createLinkButton); + } + + public boolean isAllowGuestsEqualsTo(String expectedValue) { + final By locator = predicateStrAllowGuestsByValue.apply(expectedValue); + return isLocatorDisplayed(locator); + } + +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/ConnectionInboxPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/ConnectionInboxPage.java new file mode 100644 index 00000000000..5bfc8904670 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/ConnectionInboxPage.java @@ -0,0 +1,115 @@ +package com.wearezeta.auto.ios.pages.details_overlay.single; + +import java.util.function.Function; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.MobileBy; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.By; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class ConnectionInboxPage extends IOSPage { + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeButton[`name ==[c] 'ignore'`][-1]") + private WebElement ignoreButton; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeButton[`name == 'accept' OR name == 'CONNECT'`][-1]") + private WebElement connectButton; + + private static final Function predicateNameByValue = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.name','name'} AND value == '%s'", + name)); + private static final By predicateName = MobileBy.iOSNsPredicateString( + "type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.name','name'}"); + + private static final Function predicateABNameByName = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name == 'correlation' AND value='%s'", + name.trim().length() > 0 ? (name + " in Contacts") : "in Contacts")); + private static final By nameABName = MobileBy.AccessibilityId("correlation"); + + private static final Function predicateUniqueUsernameByUsername = username -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.username', 'username', 'handle'} AND value == '%s'", + username.startsWith("@") ? username : ("@" + username))); + private static final By predicateUniqueUsername = MobileBy.iOSNsPredicateString( + "type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.username', 'username', 'handle'}"); + + private static final Function predicateSSONameByValue = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.name','name'} AND value CONTAINS '%s'", + name)); + + private static final By nameTeamName = MobileBy.AccessibilityId("team name"); + private static final Function predicateTeamName = name -> MobileBy.iOSNsPredicateString( + String.format("name == 'team name' AND value == '%s'", name.toUpperCase())); + + public ConnectionInboxPage(WebDriver driver) { + super(driver); + } + + protected By getUserDetailLocator(String detailName, String expectedValue) { + switch (detailName.toLowerCase()) { + case "name": + return predicateNameByValue.apply(expectedValue); + case "unique username": + return predicateUniqueUsernameByUsername.apply(expectedValue); + case "address book name": + return predicateABNameByName.apply(expectedValue); + case "team name": + return predicateTeamName.apply(expectedValue); + case "sso username": + return predicateSSONameByValue.apply(expectedValue); + default: + throw new IllegalArgumentException(String.format("Unknown user detail name '%s'", detailName)); + } + } + + private By getUserDetailLocator(String detailName) { + switch (detailName.toLowerCase()) { + case "name": + return predicateName; + case "unique username": + return predicateUniqueUsername; + case "address book name": + return nameABName; + case "team name": + return nameTeamName; + case "create group button": + return nameTeamName; + default: + throw new IllegalArgumentException(String.format("Unknown user detail name '%s'", detailName)); + } + } + + public boolean isUserDetailVisible(String detailName, String value) { + final By locator = getUserDetailLocator(detailName, value); + return isLocatorDisplayed(locator); + } + + public boolean isUserDetailInvisible(String detailName, String value) { + final By locator = getUserDetailLocator(detailName, value); + return isLocatorInvisible(locator); + } + + public boolean isUserDetailVisible(String detailName) { + final By locator = getUserDetailLocator(detailName); + return isLocatorDisplayed(locator); + } + + public boolean isUserDetailInvisible(String detailName) { + final By locator = getUserDetailLocator(detailName); + return isLocatorInvisible(locator); + } + + public void tapIgnoreButton() { + ignoreButton.click(); + } + + public void tapConnectButton() { + connectButton.click(); + } + + public boolean isConnectButtonVisible() { + return waitUntilElementVisible(connectButton); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SelfProfilePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SelfProfilePage.java new file mode 100644 index 00000000000..6e48d5301df --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SelfProfilePage.java @@ -0,0 +1,110 @@ +package com.wearezeta.auto.ios.pages.details_overlay.single; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class SelfProfilePage extends IOSPage { +/** + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Settings' AND name == 'Settings' AND type == 'XCUIElementTypeStaticText'") + WebElement settingsButton; + */ + + @iOSXCUITFindBy(accessibility = "user image") + private WebElement profilePicture; + + @iOSXCUITFindBy(accessibility = "Add Account") + private WebElement addAccountButton; + + @iOSXCUITFindBy(accessibility = "Manage Team") + private WebElement manageTeamButton; + + @iOSXCUITFindBy(accessibility = "Name") + private WebElement setStatusButton; + + @iOSXCUITFindBy(accessibility = "close") + private WebElement profileCloseButton; + + public SelfProfilePage(WebDriver driver) { + super(driver); + } + + public void tapProfilePicture() { + profilePicture.click(); + } + + public void tapSetStatusButton() { + setStatusButton.click(); + } + + public void tapProfileCloseButton() { + profileCloseButton.click(); + } + + private static final Function predicateNameByValue = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.name','name'} AND value == '%s'", + name)); + + private static final Function predicateABNameByName = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name == 'correlation' AND value='%s'", + name.trim().length() > 0 ? (name + " in Contacts") : "in Contacts")); + + private static final Function predicateUniqueUsernameByUsername = username -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.username', 'username', 'handle'} AND value == '%s'", + username.startsWith("@") ? username : ("@" + username))); + + private static final Function predicateSSONameByValue = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.name','name'} AND value CONTAINS '%s'", + name)); + + private static final Function predicateTeamName = name -> MobileBy.iOSNsPredicateString( + String.format("label == 'Team name' AND value == '%s'", name)); + + protected By getUserDetailLocator(String detailName, String expectedValue) { + switch (detailName.toLowerCase()) { + case "name": + return predicateNameByValue.apply(expectedValue); + case "unique username": + return predicateUniqueUsernameByUsername.apply(expectedValue); + case "address book name": + return predicateABNameByName.apply(expectedValue); + case "team name": + return predicateTeamName.apply(expectedValue); + case "sso username": + return predicateSSONameByValue.apply(expectedValue); + default: + throw new IllegalArgumentException(String.format("Unknown user detail name '%s'", detailName)); + } + } + + public boolean isUserDetailVisible(String detailName, String value) { + final By locator = getUserDetailLocator(detailName, value); + return isLocatorDisplayed(locator); + } + + public boolean isUserDetailInvisible(String detailName, String value) { + final By locator = getUserDetailLocator(detailName, value); + return isLocatorInvisible(locator); + } + + public void tapAddAccountButton() { + waitUntilElementClickable(addAccountButton); + addAccountButton.click(); + } + + public void tapManageTeam() { + waitUntilElementClickable(manageTeamButton); + manageTeamButton.click(); + } +/** + public void tapSettingsButton() { + waitUntilElementClickable(settingsButton); + settingsButton.click(); + } + */ +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SingleConnectedUserProfilePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SingleConnectedUserProfilePage.java new file mode 100644 index 00000000000..b55a3ffa954 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SingleConnectedUserProfilePage.java @@ -0,0 +1,112 @@ +package com.wearezeta.auto.ios.pages.details_overlay.single; + +import java.util.function.Function; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.MobileBy; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.By; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class SingleConnectedUserProfilePage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Devices") + private WebElement devicesTab; + + @iOSXCUITFindBy(accessibility = "INFORMATION") + private WebElement informationLabel; + + @iOSXCUITFindBy(accessibility = "cell.profile.group_admin_options") + private WebElement adminToggle; + + @iOSXCUITFindBy(accessibility = "close") + private WebElement closeButton; + + @iOSXCUITFindBy(accessibility = "Back") + private WebElement backButton; + + @iOSXCUITFindBy(accessibility = "Go back to conversation details") + private WebElement goBacktoButton; + + @iOSXCUITFindBy(accessibility = "left_button") + private WebElement leftActionButton; + + @iOSXCUITFindBy(accessibility = "right_button") + private WebElement rightActionButton; + + @iOSXCUITFindBy(accessibility = "ReadReceiptsStatusFooter") + private WebElement readReceiptStatusFooter; + + @iOSXCUITFindBy(accessibility = "Start conversation") + private WebElement startConversation; + + private final Function xpathInformationKeyByIndex = + idx -> By.xpath(String.format("//XCUIElementTypeCell[%d]/XCUIElementTypeStaticText[2]", idx)); + private final Function xpathInformationValueByIndex = + idx -> By.xpath(String.format("//XCUIElementTypeCell[%d]/XCUIElementTypeStaticText", idx)); + + private Function predicateStrInformationKeyValue = text -> + String.format("label == '%s' AND value == '%s'", text, text); + + public SingleConnectedUserProfilePage(WebDriver driver) { + super(driver); + } + + public void tapBackButton() { + if (isElementVisible(goBacktoButton)) { + goBacktoButton.click(); + } else { + backButton.click(); + } + } + + public boolean isInformationLabelVisible() { + return informationLabel.isDisplayed(); + } + + public boolean isInformationLabelInvisible() { + return waitUntilElementInvisible(informationLabel); + } + + public boolean isAdminToggleVisible() { + return isElementVisible(adminToggle); + } + + public boolean isInformationKeyValuePairVisible(String key, String value, int index) { + if (isAdminToggleVisible()) { + index++; + } + final By cellKeyLocator = xpathInformationKeyByIndex.apply(index); + final By cellValueLocator = xpathInformationValueByIndex.apply(index); + final By keyID = MobileBy.iOSNsPredicateString(predicateStrInformationKeyValue.apply(key)); + final By valueID = MobileBy.iOSNsPredicateString(predicateStrInformationKeyValue.apply(value)); + return isLocatorDisplayed(getElement(cellKeyLocator), keyID) && isLocatorDisplayed(getElement(cellValueLocator), valueID); + } + + public boolean isReadReceiptFooterVisible() { + return waitUntilElementVisible(readReceiptStatusFooter); + } + + public void switchToDevicesTab() { + devicesTab.click(); + } + + public void tapXButton() { + closeButton.click(); + } + + public void tapCreateGroupButton() { + leftActionButton.click(); + } + + public void tapOpenMenuButton() { + rightActionButton.click(); + } + + public void tapStartConversation() { + startConversation.click(); + } + +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SinglePendingUserIncomingConnectionProfilePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SinglePendingUserIncomingConnectionProfilePage.java new file mode 100644 index 00000000000..1e3d8dff9fd --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SinglePendingUserIncomingConnectionProfilePage.java @@ -0,0 +1,48 @@ +package com.wearezeta.auto.ios.pages.details_overlay.single; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import java.util.function.Function; + +public class SinglePendingUserIncomingConnectionProfilePage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "CONNECT") + private WebElement connectButton; + + @iOSXCUITFindBy(accessibility = "IGNORE") + private WebElement ignoreButton; + + @iOSXCUITFindBy(accessibility = "Go back to conversation details") + private WebElement backButton; + + private Function predicateDisplayName = text -> + MobileBy.iOSNsPredicateString(String.format("label == '%s'", text)); + + public SinglePendingUserIncomingConnectionProfilePage(WebDriver driver) { + super(driver); + } + + public void tapConnectInboxStyleButton() { + connectButton.click(); + } + + public void tapIgnoreInboxStyleButton() { + ignoreButton.click(); + } + + public void tapBackButton() { + backButton.click(); + } + + public boolean isDisplayNameVisible(String value) { + return isLocatorDisplayed(predicateDisplayName.apply(value)); + } + + public boolean isDisplayNameInvisible(String value) { + return isLocatorInvisible(predicateDisplayName.apply(value)); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SinglePendingUserOutgoingConnectionPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SinglePendingUserOutgoingConnectionPage.java new file mode 100644 index 00000000000..fcba5b59b1d --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/SinglePendingUserOutgoingConnectionPage.java @@ -0,0 +1,154 @@ +package com.wearezeta.auto.ios.pages.details_overlay.single; + +import java.util.function.Function; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; + +import org.openqa.selenium.WebDriver; + +public class SinglePendingUserOutgoingConnectionPage extends IOSPage { + + private static final By nameCancelRequest = MobileBy.AccessibilityId("Cancel Request"); + private static final By nameBackButton = MobileBy.AccessibilityId("ConversationBackButton"); + private static final By nameBackButtoniPad = MobileBy.AccessibilityId("Go back to conversation details"); + protected static final By classChainConnectOtherUserButton = + MobileBy.iOSClassChain("**/XCUIElementTypeStaticText[`label == 'Connect'`]"); + + protected static final By nameArchiveRequestButton = MobileBy.AccessibilityId("archive connection"); + + protected static final By classChainCancelRequestButton = MobileBy.iOSClassChain( + "**/XCUIElementTypeStaticText[`label == 'Cancel Request' OR name == 'cancel connection'`][-1]"); + + public void tapBackButton() { + getElement(nameBackButton).click(); + } + + public void tapBackButtoniPad() { + getElement(nameBackButtoniPad).click(); + } + + protected static final By nameXButton = MobileBy.AccessibilityId("close"); + + private static final Function predicateNameByValue = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.name','name'} AND value == '%s'", + name)); + private static final By predicateName = MobileBy.iOSNsPredicateString( + "type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.name','name'}"); + + private static final Function predicateABNameByName = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name == 'correlation' AND value='%s'", + name.trim().length() > 0 ? (name + " in Contacts") : "in Contacts")); + private static final By nameABName = MobileBy.AccessibilityId("correlation"); + + private static final Function predicateUniqueUsernameByUsername = username -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.username', 'username', 'handle'} AND value == '%s'", + username.startsWith("@") ? username : ("@" + username))); + private static final By predicateUniqueUsername = MobileBy.iOSNsPredicateString( + "type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.username', 'username', 'handle'}"); + + private static final Function predicateSSONameByValue = name -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name IN {'user_profile.name','name'} AND value CONTAINS '%s'", + name)); + + private static final By nameTeamName = MobileBy.AccessibilityId("team name"); + private static final Function predicateTeamName = name -> MobileBy.iOSNsPredicateString( + String.format("name == 'team name' AND value == '%s'", name.toUpperCase())); + + public SinglePendingUserOutgoingConnectionPage(WebDriver driver) { + super(driver); + } + + protected By getButtonLocatorByName(String name) { + switch (name.toLowerCase()) { + case "archive": + return nameArchiveRequestButton; + case "connect": + return classChainConnectOtherUserButton; + case "cancel request": + return classChainCancelRequestButton; + case "x": + return nameXButton; + default: + throw new IllegalArgumentException(String.format("Unknown button name '%s'", name)); + } + } + + protected By getUserDetailLocator(String detailName, String expectedValue) { + switch (detailName.toLowerCase()) { + case "name": + return predicateNameByValue.apply(expectedValue); + case "unique username": + return predicateUniqueUsernameByUsername.apply(expectedValue); + case "address book name": + return predicateABNameByName.apply(expectedValue); + case "team name": + return predicateTeamName.apply(expectedValue); + case "sso username": + return predicateSSONameByValue.apply(expectedValue); + default: + throw new IllegalArgumentException(String.format("Unknown user detail name '%s'", detailName)); + } + } + + private By getUserDetailLocator(String detailName) { + switch (detailName.toLowerCase()) { + case "name": + return predicateName; + case "unique username": + return predicateUniqueUsername; + case "address book name": + return nameABName; + case "team name": + return nameTeamName; + case "create group button": + return nameTeamName; + default: + throw new IllegalArgumentException(String.format("Unknown user detail name '%s'", detailName)); + } + } + + public boolean isUserDetailVisible(String detailName, String value) { + final By locator = getUserDetailLocator(detailName, value); + return isLocatorDisplayed(locator); + } + + public boolean isUserDetailInvisible(String detailName, String value) { + final By locator = getUserDetailLocator(detailName, value); + return isLocatorInvisible(locator); + } + + public boolean isUserDetailVisible(String detailName) { + final By locator = getUserDetailLocator(detailName); + return isLocatorDisplayed(locator); + } + + public boolean isUserDetailInvisible(String detailName) { + final By locator = getUserDetailLocator(detailName); + return isLocatorInvisible(locator); + } + + public void tapButton(String name) { + final By locator = getButtonLocatorByName(name); + getElement(locator).click(); + } + + public boolean isButtonVisible(String name) { + final By locator = getButtonLocatorByName(name); + return isLocatorDisplayed(locator); + } + + public boolean isButtonInvisible(String name) { + final By locator = getButtonLocatorByName(name); + return isLocatorInvisible(locator); + } + + public boolean isCancelRequestButtonVisible() { + return isLocatorDisplayed(nameCancelRequest); + } + + public boolean isCancelRequestButtonInvisible() { + return isLocatorInvisible(nameCancelRequest); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/UserProfilePopupPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/UserProfilePopupPage.java new file mode 100644 index 00000000000..454a0718d07 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/details_overlay/single/UserProfilePopupPage.java @@ -0,0 +1,155 @@ +package com.wearezeta.auto.ios.pages.details_overlay.single; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; + +import java.util.function.Function; + +public class UserProfilePopupPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "close") + private WebElement xButton; + + @iOSXCUITFindBy(accessibility = "user image") + private WebElement profilePicture; + + @iOSXCUITFindBy(accessibility = "username") + private WebElement userNameLabel; + + @iOSXCUITFindBy(accessibility = "INFORMATION") + private WebElement informationLabel; + + @iOSXCUITFindBy(accessibility = "DEVICES") + private WebElement devicesTab; + + @iOSXCUITFindBy(accessibility = "right_button") + private WebElement rightActionButton; + + @iOSXCUITFindBy(accessibility = "Guest") + private WebElement guestLabel; + + private Function predicateUsernameByValue = text -> + MobileBy.iOSNsPredicateString(String.format("name == 'user_profile.name' AND label == '%s'", text)); + + private Function predicateUniqueUsernameByValue = text -> + MobileBy.iOSNsPredicateString(String.format("name == 'username' AND label == '%s'", text)); + + private static final String classChainInformationKey = "XCUIElementTypeStaticText[2]"; + private static final String classChainInformationValue = "XCUIElementTypeStaticText"; + + private final Function xpathInformationKeyByIndex = + idx -> By.xpath(String.format("//XCUIElementTypeCell[%d]/%s", idx, classChainInformationKey)); + private final Function xpathInformationValueByIndex = + idx -> By.xpath(String.format("//XCUIElementTypeCell[%d]/%s", idx, classChainInformationValue)); + + private Function predicateStrInformationKeyValue = text -> + String.format("label == '%s' AND value == '%s'", text, text); + + private static final String strLeftActionButton = "left_button"; + private static final String strOpenConversationButton = "Open conversation"; + private static final String strConnectButton = "CONNECT"; + private static final String strOpenSelfProfileButton = "Open Profile"; + private Function predicateLeftButtonByLabel = text -> + MobileBy.iOSNsPredicateString(String.format("name == '%s' AND label == '%s'", strLeftActionButton,text)); + + + public UserProfilePopupPage(WebDriver driver) { + super(driver); + } + + public void tapXButton() { + xButton.click(); + } + + public void tapOpenConversationButton() { + getElement(predicateLeftButtonByLabel.apply(strOpenConversationButton)).click(); + } + + public void tapSelfProfileButton() { + getElement(predicateLeftButtonByLabel.apply(strOpenSelfProfileButton)).click(); + } + + public void tapMoreActionsButton() { + rightActionButton.click(); + } + + public boolean isUserProfilePopupVisible() { + return isElementVisible(userNameLabel) && xButton.isDisplayed(); + } + + public boolean isUserProfilePopupInvisible() { + return isElementInvisible(userNameLabel) && isElementInvisible(xButton); + } + + public boolean isUserNameVisible(String value) { + return isLocatorDisplayed(predicateUsernameByValue.apply(value)); + } + + public boolean isUserNameInvisible(String value) { return isLocatorInvisible(predicateUsernameByValue.apply(value)); } + + public boolean isUniqueUserNameVisible(String value) { + return isLocatorDisplayed(predicateUniqueUsernameByValue.apply(value)); + } + + public boolean isUniqueUserNameInvisible(String value) { return isLocatorInvisible(predicateUniqueUsernameByValue.apply(value)); } + + public boolean isUserProfilePictureVisible() { + return isElementVisible(profilePicture); + } + + public boolean isInformationLabelVisible() { + return informationLabel.isDisplayed(); + } + + public boolean isInformationLabelInvisible() { + return isElementInvisible(informationLabel); + } + + public boolean isInformationKeyValuePairVisible(String key, String value, int index) { + final By cellKeyLocator = xpathInformationKeyByIndex.apply(index); + final By cellValueLocator = xpathInformationValueByIndex.apply(index); + final By keyID = MobileBy.iOSNsPredicateString(predicateStrInformationKeyValue.apply(key)); + final By valueID = MobileBy.iOSNsPredicateString(predicateStrInformationKeyValue.apply(value)); + return isLocatorDisplayed(getElement(cellKeyLocator), keyID) && isLocatorDisplayed(getElement(cellValueLocator), valueID); + } + + public boolean isMoreActionsButtonVisible() { + return rightActionButton.isDisplayed(); + } + + public boolean isMoreActionsButtonInvisible() { + return isElementInvisible(rightActionButton); + } + + public boolean isOpenConversationButtonVisible() { + return isLocatorDisplayed(predicateLeftButtonByLabel.apply(strOpenConversationButton)); + } + + public boolean isOpenConversationButtonInvisible() { + return isLocatorInvisible(predicateLeftButtonByLabel.apply(strOpenConversationButton)); + } + + public boolean isConnectButtonVisible() { + return isLocatorDisplayed(predicateLeftButtonByLabel.apply(strConnectButton)); + } + + public boolean isConnectButtonInvisible() { + return isLocatorInvisible(predicateLeftButtonByLabel.apply(strConnectButton)); + } + + public boolean isDevicesTabInvisible() { + return isElementInvisible(devicesTab); + } + + public boolean isGuestLabelVisible() { + return guestLabel.isDisplayed(); + } + + public boolean isGuestLabelInvisible() { + return isElementInvisible(guestLabel); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/external_app/FileChooseDialogPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/external_app/FileChooseDialogPage.java new file mode 100644 index 00000000000..7133e90d491 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/external_app/FileChooseDialogPage.java @@ -0,0 +1,81 @@ +package com.wearezeta.auto.ios.pages.external_app; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.MobileBy; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; +import org.openqa.selenium.interactions.Actions; + +public class FileChooseDialogPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "DOC.itemCollectionMenuButton.Ellipsis") + private WebElement ellipsisButton; + + @iOSXCUITFindBy(accessibility = "BackButton") + private WebElement iPadBrowserButton; + + @iOSXCUITFindBy(accessibility = "DOC.sidebar.item.On My iPad") + private WebElement onMyIPadSelection; + + @iOSXCUITFindBy(accessibility = "DOC.sortMenuButton.date") + private WebElement sortByDateEntry; + + @iOSXCUITFindBy(accessibility = "DOC.sortMenuButton.date.descending") + private WebElement sortByDateEntrySelected; + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'Browse' AND type == 'XCUIElementTypeButton'") + private WebElement browseFoldersButton; + + @iOSXCUITFindBy(accessibility = "On My iPhone") + private WebElement onMyIPhoneSelection; + + private static String predicateStringFileByName = "type == 'XCUIElementTypeCell' AND label CONTAINS '%s'"; + + public FileChooseDialogPage(WebDriver driver) { + super(driver); + } + + public void tapOnEllipsisButton() { + ellipsisButton.click(); + } + + public void dismissEllipsisMenu() { + //dismiss by tapping to the right of the menu + Actions action = new Actions(getDriver()); + action.click(sortByDateEntrySelected).moveByOffset(150, 0).click().build().perform(); + } + + public void tapOnMyIPhone() { + onMyIPhoneSelection.click(); + } + + public void tapOnMyIPad() { + onMyIPadSelection.click(); + } + + public void tapFileContaining(String name) { + By locator = MobileBy.iOSNsPredicateString(String.format(predicateStringFileByName, name)); + isLocatorDisplayed(locator); + getDriver().findElement(locator).click(); + } + + public void tapBrowseFoldersButton() { + waitUntilElementVisible(browseFoldersButton); + browseFoldersButton.click(); + } + + public void tapBrowserFolderButtonOnIPad(){ + waitUntilElementVisible(iPadBrowserButton); + iPadBrowserButton.click(); + } + + public boolean isSortByDateNotSelected() { + return isElementInvisible(sortByDateEntrySelected); + } + + public void tapSortByDateEntry() { + sortByDateEntry.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/external_app/FileSavingPopupPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/external_app/FileSavingPopupPage.java new file mode 100644 index 00000000000..5cb15771b8e --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/external_app/FileSavingPopupPage.java @@ -0,0 +1,59 @@ +package com.wearezeta.auto.ios.pages.external_app; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class FileSavingPopupPage extends IOSPage { + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeNavigationBar[`name == 'UIActivityContentView'`]/XCUIElementTypeOther/XCUIElementTypeOther") + private WebElement fileLabel; + + @iOSXCUITFindBy(accessibility = "Close") + private WebElement closeButton; + + @iOSXCUITFindBy(iOSNsPredicate = "name == 'Save to Files'") + private WebElement saveToFilesButton; + + @iOSXCUITFindBy(accessibility = "On My iPhone") + private WebElement onMyIPhoneChoice; + + @iOSXCUITFindBy(accessibility = "On My iPad") + private WebElement onMyIPadChoice; + + @iOSXCUITFindBy(accessibility = "Save") + private WebElement saveButton; + + public FileSavingPopupPage(WebDriver driver) { + super(driver); + } + + public String getFileLabel() { + waitUntilElementVisible(fileLabel); + return fileLabel.getAttribute("name"); + } + + public void tapSaveToFilesButton() { + saveToFilesButton.click(); + } + + public void tapOnMyIPhone() { + waitUntilElementVisible(onMyIPhoneChoice); + onMyIPhoneChoice.click(); + } + + public void tapOnMyIPad() { + waitUntilElementVisible(onMyIPadChoice); + onMyIPadChoice.click(); + } + + public void tapSaveButton() { + waitUntilElementClickable(saveButton); + saveButton.click(); + } + + public void tapCloseButton() { + closeButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/keyboard/IOSKeyboard.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/keyboard/IOSKeyboard.java new file mode 100644 index 00000000000..77af0a7045a --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/keyboard/IOSKeyboard.java @@ -0,0 +1,62 @@ +package com.wearezeta.auto.ios.pages.keyboard; + +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; + +import org.openqa.selenium.WebDriver; + +public class IOSKeyboard extends IOSPage { + private static final By classKeyboard = By.className("XCUIElementTypeKeyboard"); + private static final By predicateCommitButton = MobileBy.iOSNsPredicateString( + "name IN[c] {'Go', 'Send', 'Done', 'Return'}" + ); + + private static final By nameSpaceButton = MobileBy.AccessibilityId("space"); + private static final By nameNextButton = MobileBy.AccessibilityId("Next:"); + + private static final By nameHideKeyboardButton = MobileBy.AccessibilityId("Hide keyboard"); + + private static final By nameKeyboardDeleteButton = MobileBy.AccessibilityId("delete"); + + private static final Timedelta DEFAULT_VISIBILITY_TIMEOUT = Timedelta.ofSeconds(5); + + public IOSKeyboard(WebDriver driver) { + super(driver); + } + + public boolean isVisible(Timedelta timeout) { + return isLocatorDisplayed(nameSpaceButton, timeout); + } + + public boolean isInvisible(Timedelta timeout) { + return isLocatorInvisible(nameSpaceButton, timeout); + } + + public boolean isVisible() { + return isVisible(DEFAULT_VISIBILITY_TIMEOUT); + } + + public boolean isInvisible() { + return isInvisible(DEFAULT_VISIBILITY_TIMEOUT); + } + + public void pressSpaceButton() { + getElement(nameSpaceButton).click(); + } + + public void pressNextButton() { + getElement(nameNextButton).click(); + } + + public void pressHideButton() { + getElement(nameHideKeyboardButton).click(); + } + + public void pressCommitButton() { + getElement(classKeyboard) + .findElement(predicateCommitButton) + .click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/linear_groupcreation/AddPeoplePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/linear_groupcreation/AddPeoplePage.java new file mode 100644 index 00000000000..7084abecb4a --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/linear_groupcreation/AddPeoplePage.java @@ -0,0 +1,68 @@ +package com.wearezeta.auto.ios.pages.linear_groupcreation; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.ios.pages.IOSPage; +import com.wearezeta.auto.ios.pages.search.GroupParticipantsSearchList; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import java.util.function.Function; + +public class AddPeoplePage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "button.addpeople.create") + private WebElement createButton; + + @iOSXCUITFindBy(accessibility = "button.addpeople.skip") + private WebElement skipButton; + + @iOSXCUITFindBy(accessibility = "Back") + private WebElement backButton; + + private static final Function nameAddPeopleCount = count -> + MobileBy.AccessibilityId(String.format("Add Participants (%s)", count)); + + private final GroupParticipantsSearchList participantsSearchList; + + public AddPeoplePage(WebDriver driver) { + super(driver); + this.participantsSearchList = new GroupParticipantsSearchList(driver); + } + + public void tapCreateButton(){ + createButton.click(); + } + + public void tapSkipButton(){ + skipButton.click(); + } + + public void tapBackButton(){ + backButton.click(); + } + + public boolean isParticipantsCountEqualTo(int expectedCount) { + return isLocatorDisplayed(nameAddPeopleCount.apply(expectedCount)); + } + + public void typeSearchQuery(String query, boolean shouldClear) { + participantsSearchList.typeSearchQuery(query, shouldClear); + } + + public void selectItem(String name) { + participantsSearchList.selectItem(name); + } + + public boolean isItemVisible(String name) { + return participantsSearchList.isItemVisible(name); + } + + public boolean isItemInvisible(String name) { + return participantsSearchList.isItemInvisible(name); + } + + public boolean waitUntilResultsLabelIsVisible(String msg) { + return participantsSearchList.waitUntilResultsLabelIsVisible(msg); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/linear_groupcreation/NewGroupPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/linear_groupcreation/NewGroupPage.java new file mode 100644 index 00000000000..08f4ffee431 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/linear_groupcreation/NewGroupPage.java @@ -0,0 +1,135 @@ +package com.wearezeta.auto.ios.pages.linear_groupcreation; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import java.util.function.Function; + +public class NewGroupPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "NameField") + private WebElement groupNameTextfield; + + @iOSXCUITFindBy(accessibility = "button.newgroup.next") + private WebElement nextButton; + + @iOSXCUITFindBy(accessibility = "Go back to contact list") + private WebElement backButton; + + @iOSXCUITFindBy(accessibility = "cell.groupdetails.options") + private WebElement groupOptions; + + @iOSXCUITFindBy(accessibility = "toggle.newgroup.allowguests") + private WebElement toggleAllowGuests; + + @iOSXCUITFindBy(accessibility = "toggle.newgroup.allowservices") + private WebElement toggleAllowServices; + + @iOSXCUITFindBy(accessibility = "Protocol") + private WebElement protocolOption; + + @iOSXCUITFindBy(accessibility = "Proteus (default)") + private WebElement protocolButton; + + @iOSXCUITFindBy(accessibility = "MLS") + private WebElement mlsOption; + + private static final String strToggleAllowGuests = "toggle.newgroup.allowguests"; + + private static final Function classChainToggle = toggleName -> MobileBy.iOSClassChain( + String.format("**/XCUIElementTypeCell[`name == '%s'`]/**/XCUIElementTypeSwitch", toggleName)); + + private static final Function classChainAllowGuestsByValue = value -> MobileBy.iOSClassChain( + String.format("**/XCUIElementTypeCell[`name == '%s'`]/**/XCUIElementTypeSwitch[`value == '%s'`]", strToggleAllowGuests, value)); + + private static final Function predicateStrMaxParticipantLimit = limit -> + String.format("name CONTAINS 'Up to %s participants can join a group conversation'", limit); + + public NewGroupPage(WebDriver driver) { + super(driver); + } + + public void enterGroupName(String groupName) { + groupNameTextfield.clear(); + groupNameTextfield.sendKeys(groupName); + } + + public void tapNextButton(){ + nextButton.click(); + } + public void tapBackButton(){ + backButton.click(); + } + + public boolean isExpectedConversationOptionsVisible(String text) { + final By locator = MobileBy.AccessibilityId(text); + return isLocatorDisplayed(locator); + } + + public boolean isAllowGuestsEqualsTo(String expectedValue) { + return isLocatorDisplayed(classChainAllowGuestsByValue.apply(expectedValue)); + } + + public void switchToggle() { + getElement(classChainToggle.apply(strToggleAllowGuests)).click(); + } + + public boolean isProtocolVisible() { + return protocolOption.isDisplayed(); + } + + public boolean isProtocolInvisible() { + return isElementInvisible(protocolOption); + } + + public boolean isProteusValueVisible() { + return protocolButton.isDisplayed(); + } + + public boolean isProteusValueInvisible() { + return isElementInvisible(protocolButton); + } + + public boolean isMlsValueVisible() { + return mlsOption.isDisplayed(); + } + + public boolean isMlsValueInvisible() { + return isElementInvisible(mlsOption); + } + + public void tapProtocolOption() { + protocolButton.click(); + } + + public void tapMlsOption() { + mlsOption.click(); + } + + public void tapConversationOptions() { + groupOptions.click(); + } + + public boolean isMaxLimitEqualsTo(int expectedLimit) { + return isLocatorDisplayed(MobileBy.iOSNsPredicateString(predicateStrMaxParticipantLimit.apply(expectedLimit))); + } + + public boolean isGuestOptionVisible() { + return toggleAllowGuests.isDisplayed(); + } + + public boolean isGuestOptionInvisible() { + return isElementInvisible(toggleAllowGuests); + } + + public boolean isServiceOptionVisible() { + return toggleAllowServices.isDisplayed(); + } + + public boolean isServiceOptionInvisible() { + return isElementInvisible(toggleAllowServices); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/BaseSearchableItemsList.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/BaseSearchableItemsList.java new file mode 100644 index 00000000000..6d9d41beab3 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/BaseSearchableItemsList.java @@ -0,0 +1,255 @@ +package com.wearezeta.auto.ios.pages.search; + +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.Keys; +import org.openqa.selenium.WebDriverException; +import org.openqa.selenium.WebElement; + +import java.util.function.BiFunction; +import java.util.function.Function; + +public abstract class BaseSearchableItemsList extends IOSPage { + private String classChainStrViewRoot; + + private static final String nameStrSearchInput = "textViewSearch"; + + private static final By predicateSearchInput = MobileBy.iOSNsPredicateString(isTablet() ? + String.format("type == 'XCUIElementTypeTextView' AND name == '%s' AND visible == 1", nameStrSearchInput) : + String.format("type == 'XCUIElementTypeTextView' AND name == '%s'", nameStrSearchInput)); + + private static final By nameNoResults = MobileBy.AccessibilityId("No results."); + + private static final By predicateEveryoneIsHere = + MobileBy.iOSNsPredicateString("value BEGINSWITH 'Everyone' AND value ENDSWITH 'here.'"); + + private static final String nameStrItemName = "user_cell.name"; + private static final String nameStrItemUsername = "user_cell.username"; + static final String nameStrParticipantCell = "participants.section.participants.cell"; + static final By nameParticipantCell = MobileBy.iOSNsPredicateString("name ENDSWITH 'participants.section.participants.cell'"); + static final String nameStrServiceCellName = "participants.section.services.cell"; + static final By nameServiceCellName = MobileBy.AccessibilityId("participants.section.services.cell"); + + private final BiFunction classChainStrItemCellByCellAndName = (name, cellName) -> + String.format("%s/XCUIElementTypeCell[`name ENDSWITH '%s'`][$name == '%s' AND value == '%s' OR name == '%s' AND value == '%s (You)'$]", + classChainStrViewRoot, cellName, nameStrItemName, name, nameStrItemName, name); + + private final BiFunction classChainStrFederatedGroupDetailsItemCellByCellAndName = (name, cellName) -> + String.format("%s/XCUIElementTypeCell[`name ENDSWITH '%s'`][$name == '%s' AND value == '%s' OR name == '%s' AND value == '%s (You)'$]", + classChainStrViewRoot, cellName, nameStrItemUsername, name, nameStrItemUsername, name); + + final BiFunction classChainItemCellByCellAndName = (name, cellName) -> + MobileBy.iOSClassChain(classChainStrItemCellByCellAndName.apply(name, cellName)); + + final BiFunction classChainFederatedGroupDetailsItemCellByCellAndName = (name, cellName) -> + MobileBy.iOSClassChain(classChainStrFederatedGroupDetailsItemCellByCellAndName.apply(name, cellName)); + + private final BiFunction classChainItemNameByCellAndName = (name, cellName) -> + MobileBy.iOSClassChain(String.format("%s/XCUIElementTypeCell[`name ENDSWITH '%s'`]/" + + "**/XCUIElementTypeStaticText[`name == '%s' AND value == '%s' OR name == '%s' AND value == '%s (You)'`]", + classChainStrViewRoot, cellName, nameStrItemName, name, nameStrItemName, name)); + + private final BiFunction classChainItemCellByCellWithUniqueUsername = (name, cellName) -> + MobileBy.iOSClassChain(String.format("%s[$name == '%s'$]", + classChainStrItemCellByCellAndName.apply(name, cellName), nameStrItemUsername)); + + private final BiFunction classChainGuestIconForParticipantByCellAndName = (name, cellName) -> + MobileBy.iOSClassChain(String.format("%s/**/XCUIElementTypeImage[`name == 'img.guest'`]", + classChainStrItemCellByCellAndName.apply(name, cellName))); + + private final BiFunction classChainFederatedIconForParticipantByCellAndName = (name, cellName) -> + MobileBy.iOSClassChain(String.format("%s/**/XCUIElementTypeImage[`name == 'img.federated'`]", + classChainStrItemCellByCellAndName.apply(name, cellName))); + + private final BiFunction classChainExternalIconForParticipantByCellAndName = (name, cellName) -> + MobileBy.iOSClassChain(String.format("%s/**/XCUIElementTypeImage[`name == 'img.external'`]", + classChainStrItemCellByCellAndName.apply(name, cellName))); + + private final Function classChainStrItemCellByName = name -> + String.format("%s/XCUIElementTypeCell[$name == '%s' AND value == '%s'$]", + classChainStrViewRoot, nameStrItemName, name); + + private final Function classChainStrFederatedItemCellByName = name -> + String.format("%s/XCUIElementTypeCell[$name == '%s' AND value == '%s'$]", + classChainStrViewRoot, nameStrItemUsername, name); + + final Function classChainItemCellByName = name -> MobileBy.iOSClassChain( + classChainStrItemCellByName.apply(name)); + + final Function classChainFederatedItemCellByName = name -> MobileBy.iOSClassChain( + classChainStrFederatedItemCellByName.apply(name)); + + final Function classChainItemNameByName = name -> MobileBy.iOSClassChain( + String.format("%s/XCUIElementTypeCell/**/XCUIElementTypeStaticText[`name == '%s' AND value == '%s'`]", + classChainStrViewRoot, nameStrItemName, name)); + + private final Function classChainItemCellByNameWithUniqueUsername = name -> + MobileBy.iOSClassChain(String.format("%s[$name == '%s'$]", classChainStrItemCellByName.apply(name), + nameStrItemUsername)); + + private final Function classChainGuestIconForParticipantByName = name -> MobileBy.iOSClassChain( + String.format("%s/**/XCUIElementTypeImage[`name == 'img.guest'`]", classChainStrItemCellByName.apply(name))); + + private final Function classChainExternalIconForParticipantByName = name -> MobileBy.iOSClassChain( + String.format("%s/**/XCUIElementTypeImage[`name == 'img.external'`]", classChainStrItemCellByName.apply(name))); + + private final Function classChainVerifiedIconForParticipantByName = name -> MobileBy.iOSClassChain( + String.format("%s/**/XCUIElementTypeImage[`name == 'img.shield'`]", classChainStrItemCellByName.apply(name))); + + public BaseSearchableItemsList(WebDriver driver, String classChainStrViewRoot) { + super(driver); + this.classChainStrViewRoot = classChainStrViewRoot; + } + + public boolean isVisible() { + return isLocatorDisplayed(MobileBy.iOSClassChain(classChainStrViewRoot), Timedelta.ofSeconds(2)); + } + + public boolean isParticipantCellVisible() { + return isLocatorDisplayed(nameParticipantCell, Timedelta.ofSeconds(2)); + } + + boolean isUniqueUserNameLabelVisibleFor(String cellName, String name) { + return isLocatorDisplayed(classChainItemCellByCellWithUniqueUsername.apply(name, cellName)); + } + + boolean isUniqueUserNameLabelInvisibleFor(String cellName, String name) { + return isLocatorInvisible(classChainItemCellByCellWithUniqueUsername.apply(name, cellName)); + } + + void selectItem(String cellName, String name) { + getElement(classChainItemNameByCellAndName.apply(name, cellName)).click(); + } + + boolean isItemVisible(String cellName, String name) { + return isLocatorDisplayed(classChainItemCellByCellAndName.apply(name, cellName), Timedelta.ofSeconds(2)); + } + + boolean isItemInvisible(String cellName, String name) { + return isLocatorInvisible(classChainItemCellByCellAndName.apply(name, cellName), Timedelta.ofSeconds(1)); + } + + boolean isFederatedItemVisibleOnGroupDetails(String cellName, String name) { + return isLocatorDisplayed(classChainFederatedGroupDetailsItemCellByCellAndName.apply(name, cellName)); + } + + boolean isFederatedItemInvisibleOnGroupDetails(String cellName, String name) { + return isLocatorInvisible(classChainFederatedGroupDetailsItemCellByCellAndName.apply(name, cellName)); + } + + boolean isGuestLabelVisibleFor(String cellName, String name) { + return isLocatorExist(classChainGuestIconForParticipantByCellAndName.apply(name, cellName)); + } + + boolean isGuestLabelInvisibleFor(String cellName, String name) { + return isLocatorInvisible(classChainGuestIconForParticipantByCellAndName.apply(name, cellName)); + } + + boolean isExternalIconVisibleFor(String cellName, String name) { + return isLocatorExist(classChainExternalIconForParticipantByCellAndName.apply(name, cellName)); + } + + protected boolean isUniqueUserNameLabelVisibleFor(String name) { + return isLocatorDisplayed(classChainItemCellByNameWithUniqueUsername.apply(name)); + } + + protected boolean isUniqueUserNameLabelInvisibleFor(String name) { + return isLocatorInvisible(classChainItemCellByNameWithUniqueUsername.apply(name)); + } + + protected void selectItem(String name) { + getElement(classChainItemNameByName.apply(name)).click(); + } + + protected boolean isItemVisible(String name) { + return isLocatorDisplayed(classChainItemCellByName.apply(name)); + } + + protected boolean isItemInvisible(String name) { + return isLocatorInvisible(classChainItemCellByName.apply(name)); + } + + protected boolean isFederatedItemVisible(String name) { + return isLocatorDisplayed(classChainFederatedItemCellByName.apply(name)); + } + + protected boolean isFederatedItemInvisible(String name) { + return isLocatorInvisible(classChainFederatedItemCellByName.apply(name)); + } + + boolean isVerifiedLabelVisibleFor(String name) { + return isLocatorExist(classChainVerifiedIconForParticipantByName.apply(name)); + } + + protected boolean isGuestLabelVisibleFor(String name) { + return isLocatorExist(classChainGuestIconForParticipantByName.apply(name)); + } + + protected boolean isExternalLabelVisibleFor(String name) { + return isLocatorExist(classChainExternalIconForParticipantByName.apply(name)); + } + + protected boolean isExternalLabelInvisibleFor(String name) { + return isLocatorInvisible(classChainExternalIconForParticipantByName.apply(name)); + } + + protected boolean isGuestLabelInvisibleFor(String name) { + return isLocatorInvisible(classChainGuestIconForParticipantByName.apply(name)); + } + + private static By getLocatorByLabelName(String name) { + switch (name.toLowerCase()) { + case "no results": + return nameNoResults; + case "everyone is here": + return predicateEveryoneIsHere; + default: + throw new IllegalArgumentException(String.format("Unknown message label: '%s'", name)); + } + } + + public boolean waitUntilResultsLabelIsVisible(String label) { + final By locator = getLocatorByLabelName(label); + return isLocatorExist(locator); + } + + protected void typeSearchQuery(String text) { + typeSearchQuery(text, false); + } + + protected void typeSearchQuery(String text, boolean shouldClearFieldBeforeInput) { + final WebElement searchInput = getElement(predicateSearchInput); + if (shouldClearFieldBeforeInput) { + this.clearSearchQuery(); + } + searchInput.sendKeys(text + " "); + } + + public void clearSearchQuery() { + final WebElement searchInput = getElement(predicateSearchInput); + try { + this.tapAtTheCenterOfElement(searchInput); + searchInput.clear(); + } catch (WebDriverException e) { + this.tapAtTheCenterOfElement(searchInput); + searchInput.clear(); + } + } + protected void sendKeysToSearchInput(Keys... keys) { + for (Keys key : keys) { + getElement(predicateSearchInput).sendKeys(key); + } + } + + protected String getCurrentSearchQuery() { + return getElement(predicateSearchInput).getText(); + } + + boolean isFederatedLabelVisibleFor(String cellName, String name) { + return isLocatorExist(classChainFederatedIconForParticipantByCellAndName.apply(name, cellName)); + } +} + diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/GroupParticipantsList.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/GroupParticipantsList.java new file mode 100644 index 00000000000..df4d8335003 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/GroupParticipantsList.java @@ -0,0 +1,56 @@ +package com.wearezeta.auto.ios.pages.search; + +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.common.misc.Timedelta; + +public class GroupParticipantsList extends BaseSearchableItemsList { + private static final String classChainStrViewRoot = "**/XCUIElementTypeCollectionView[`name == 'group_details.list'`]"; + + public GroupParticipantsList(WebDriver driver) { + super(driver, classChainStrViewRoot); + } + + public void selectParticipant(String name) { + selectItem(nameStrParticipantCell, name); + } + + public int getPeopleCount() { + return selectVisibleElements(nameParticipantCell, Timedelta.ofSeconds(3)).size(); + } + + public int getServicesCount() { + return selectVisibleElements(nameServiceCellName, Timedelta.ofSeconds(3)).size(); + } + + @Override + public boolean isUniqueUserNameLabelVisibleFor(String name) { + return super.isUniqueUserNameLabelVisibleFor(nameStrParticipantCell, name); + } + + @Override + public boolean isUniqueUserNameLabelInvisibleFor(String name) { + return super.isUniqueUserNameLabelInvisibleFor(nameStrParticipantCell, name); + } + + public boolean isParticipantVisible(String name) { + return super.isItemVisible(nameStrParticipantCell, name); + } + + public boolean isParticipantInvisible(String name) { + return super.isItemInvisible(nameStrParticipantCell, name); + } + + @Override + public boolean isGuestLabelVisibleFor(String name) { + return super.isGuestLabelVisibleFor(nameStrParticipantCell, name); + } + + @Override + public boolean isGuestLabelInvisibleFor(String name) { + return super.isGuestLabelInvisibleFor(nameStrParticipantCell, name); + } + + public boolean isExternalIconVisibleFor(String name) { + return super.isExternalIconVisibleFor(nameStrParticipantCell, name); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/GroupParticipantsSearchList.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/GroupParticipantsSearchList.java new file mode 100644 index 00000000000..51d92684fce --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/GroupParticipantsSearchList.java @@ -0,0 +1,90 @@ +package com.wearezeta.auto.ios.pages.search; + +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.*; + +import java.util.function.Function; + +public class GroupParticipantsSearchList extends IOSPage { + private static final String classChainStrViewRoot = "**/XCUIElementTypeCollectionView[`name == 'add_participants.list'`]"; + + public GroupParticipantsSearchList(WebDriver driver) { + super(driver); + } + + private static final String nameStrSearchInput = "textViewSearch"; + + private static final By predicateSearchInput = MobileBy.iOSNsPredicateString(isTablet() ? + String.format("type == 'XCUIElementTypeTextView' AND name == '%s' AND visible == 1", nameStrSearchInput) : + String.format("type == 'XCUIElementTypeTextView' AND name == '%s'", nameStrSearchInput)); + + private static final By nameNoResults = MobileBy.AccessibilityId("button.searchui.open-services-no-results"); + + private static final By predicateEveryoneIsHere = + MobileBy.iOSNsPredicateString("value BEGINSWITH 'Everyone' AND value ENDSWITH 'here.'"); + + private static final String nameStrItemName = "user_cell.name"; + + private final Function classChainStrItemCellByName = name -> + String.format("%s/XCUIElementTypeCell[$name == '%s' AND value == '%s'$]", + classChainStrViewRoot, nameStrItemName, name); + + final Function classChainItemCellByName = name -> MobileBy.iOSClassChain( + classChainStrItemCellByName.apply(name)); + + final Function classChainItemNameByName = name -> MobileBy.iOSClassChain( + String.format("%s/XCUIElementTypeCell/**/XCUIElementTypeStaticText[`name == '%s' AND value == '%s'`]", + classChainStrViewRoot, nameStrItemName, name)); + + public void selectItem(String name) { + getElement(classChainItemNameByName.apply(name)).click(); + } + + public boolean isItemVisible(String name) { + return isLocatorDisplayed(classChainItemCellByName.apply(name)); + } + + public boolean isItemInvisible(String name) { + return isLocatorInvisible(classChainItemCellByName.apply(name)); + } + + private static By getLocatorByLabelName(String name) { + switch (name.toLowerCase()) { + case "no results": + return nameNoResults; + case "everyone is here": + return predicateEveryoneIsHere; + default: + throw new IllegalArgumentException(String.format("Unknown message label: '%s'", name)); + } + } + + public boolean waitUntilResultsLabelIsVisible(String label) { + final By locator = getLocatorByLabelName(label); + return isLocatorExist(locator); + } + + public void typeSearchQuery(String text) { + typeSearchQuery(text, false); + } + + public void typeSearchQuery(String text, boolean shouldClearFieldBeforeInput) { + final WebElement searchInput = getElement(predicateSearchInput); + if (shouldClearFieldBeforeInput) { + this.clearSearchQuery(); + } + searchInput.sendKeys(text + " "); + } + + public void clearSearchQuery() { + final WebElement searchInput = getElement(predicateSearchInput); + try { + this.tapAtTheCenterOfElement(searchInput); + searchInput.clear(); + } catch (WebDriverException e) { + this.tapAtTheCenterOfElement(searchInput); + searchInput.clear(); + } + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/MentionSuggestionsList.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/MentionSuggestionsList.java new file mode 100644 index 00000000000..b172343b9c1 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/MentionSuggestionsList.java @@ -0,0 +1,53 @@ +package com.wearezeta.auto.ios.pages.search; + +import org.openqa.selenium.WebDriver; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; + +import java.util.function.Function; + +public class MentionSuggestionsList extends BaseSearchableItemsList { + + private static final String classChainStrViewRoot = "**/XCUIElementTypeCollectionView[`name == 'mentions.list.collection'`]"; + + private static final Function classChainRecentMention = username -> MobileBy.iOSClassChain( + String.format("**/XCUIElementTypeTable/XCUIElementTypeCell[2]/**/XCUIElementTypeLink[`name == '@%s'`]", username)); + + private static final Function predicateSuggestionByName = name -> MobileBy.iOSNsPredicateString( + String.format("name == 'user_cell.name' AND value == '%s'", name)); + + public MentionSuggestionsList(WebDriver driver) { + super(driver, classChainStrViewRoot); + } + + public void tapSuggestedMention(String username) { + final By locator = predicateSuggestionByName.apply(username); + getElement(locator).click(); + } + + public boolean isRecentMention(String username) { + final By locator = classChainRecentMention.apply(username); + return isLocatorDisplayed(locator); + } + + public boolean isNotRecentMention(String username) { + final By locator = classChainRecentMention.apply(username); + return isLocatorInvisible(locator); + } + + public boolean isGuestLabelVisibleFor(String name) { + return super.isGuestLabelVisibleFor(name); + } + + public boolean isExternalLabelVisibleFor(String name) { + return super.isExternalLabelVisibleFor(name); + } + + public boolean isVerifiedLabelVisibleFor(String name) { + return super.isVerifiedLabelVisibleFor(name); + } + + public boolean isSuggestionsVisible() { + return isVisible(); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/SearchList.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/SearchList.java new file mode 100644 index 00000000000..f368882812e --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/search/SearchList.java @@ -0,0 +1,93 @@ +package com.wearezeta.auto.ios.pages.search; + +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.Keys; + +public class SearchList extends BaseSearchableItemsList { + private static final String classChainStrViewRoot = "**/XCUIElementTypeCollectionView[`name == 'search.list'`]"; + + public SearchList(WebDriver driver) { + super(driver, classChainStrViewRoot); + } + + public void tapInstantConnectButton(String name) { + this.tapByPercentOfElementSize(getElement(classChainItemCellByName.apply(name)), 94, 50); + } + + public int getOccurrencesCount(String name) { + return selectVisibleElements(classChainItemNameByName.apply(name)).size(); + } + + public boolean isFederatedItemVisible(String name) { + return super.isFederatedItemVisible(name); + } + + public boolean isFederatedItemInvisible(String name) { + return super.isFederatedItemInvisible(name); + } + + @Override + public boolean isUniqueUserNameLabelVisibleFor(String name) { + return super.isUniqueUserNameLabelVisibleFor(name); + } + + @Override + public boolean isUniqueUserNameLabelInvisibleFor(String name) { + return super.isUniqueUserNameLabelInvisibleFor(name); + } + + @Override + public void selectItem(String name) { + super.selectItem(name); + } + + @Override + public boolean isItemVisible(String name) { + return super.isItemVisible(name); + } + + @Override + public boolean isItemInvisible(String name) { + return super.isItemInvisible(name); + } + + @Override + public boolean isGuestLabelVisibleFor(String name) { + return super.isGuestLabelVisibleFor(name); + } + + @Override + public boolean isExternalLabelVisibleFor(String name) { + return super.isExternalLabelVisibleFor(name); + } + + @Override + public boolean isExternalLabelInvisibleFor(String name) { + return super.isExternalLabelInvisibleFor(name); + } + + @Override + public boolean isGuestLabelInvisibleFor(String name) { + return super.isGuestLabelInvisibleFor(name); + } + + @Override + public void typeSearchQuery(String text) { + super.typeSearchQuery(text); + } + + @Override + public void typeSearchQuery(String text, boolean shouldClearFieldBeforeInput) { + super.typeSearchQuery(text, shouldClearFieldBeforeInput); + } + + @Override + public void sendKeysToSearchInput(Keys... keys) { + super.sendKeysToSearchInput(keys); + } + + @Override + public String getCurrentSearchQuery() { + return super.getCurrentSearchQuery(); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/team_creation/InvitePeoplePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/team_creation/InvitePeoplePage.java new file mode 100644 index 00000000000..03bfd7cfb47 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/team_creation/InvitePeoplePage.java @@ -0,0 +1,27 @@ +package com.wearezeta.auto.ios.pages.team_creation; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.ios.pages.IOSPage; +import org.openqa.selenium.WebElement; + +public class InvitePeoplePage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "EmailInputField") + private WebElement enterEmailField; + + @iOSXCUITFindBy(accessibility = "button.addpeople.create") + private WebElement doneButton; + + public InvitePeoplePage(WebDriver driver) { + super(driver); + } + + public boolean isVisible() { + return isElementVisible(enterEmailField); + } + + public void tapDoneButton(){ + doneButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/team_creation/TCVerificationCodePage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/team_creation/TCVerificationCodePage.java new file mode 100644 index 00000000000..28e5eabf3c5 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/team_creation/TCVerificationCodePage.java @@ -0,0 +1,35 @@ +package com.wearezeta.auto.ios.pages.team_creation; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.ios.pages.IOSPage; +import org.openqa.selenium.WebElement; + +public class TCVerificationCodePage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "VerificationCode") + private WebElement emailCodeField; + + @iOSXCUITFindBy(accessibility = "resend_button") + private WebElement resendButton; + + @iOSXCUITFindBy(accessibility = "Back") + private WebElement backButton; + + public TCVerificationCodePage(WebDriver driver) { + super(driver); + } + + public void enterVerificationCode(String code) { + emailCodeField.clear(); + emailCodeField.sendKeys(code); + } + + public void tapResendCode(){ + resendButton.click(); + } + + public void tapBack(){ + backButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/webview/KeycloakWebViewPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/webview/KeycloakWebViewPage.java new file mode 100644 index 00000000000..baf8519af4d --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/webview/KeycloakWebViewPage.java @@ -0,0 +1,57 @@ +package com.wearezeta.auto.ios.pages.webview; + +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class KeycloakWebViewPage extends IOSPage { + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeOther[`name == \"Sign in to Keycloak\"`]/XCUIElementTypeOther[2]/XCUIElementTypeTextField") + private WebElement username; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeOther[`name == \"Sign in to Keycloak\"`]/XCUIElementTypeOther[3]/XCUIElementTypeSecureTextField") + private WebElement password; + + @iOSXCUITFindBy(iOSNsPredicate = "name == \"Sign In\"") + private WebElement signInButton; + + @iOSXCUITFindBy(accessibility = "Failed to retrieve certificate") + private WebElement certificateError; + + @iOSXCUITFindBy(accessibility = "Ok") + private WebElement ok; + + public KeycloakWebViewPage(WebDriver driver) { + super(driver); + } + + public void setUsername(String login) { + username.click(); + username.clear(); + username.sendKeys(login); + } + + public void setPassword(String password) { + this.password.click(); + this.password.clear(); + this.password.sendKeys(password); + } + + public void tapSignInButton() { + signInButton.click(); + } + + public boolean isKeycloakWebPageVisible() { + return isElementVisible(username, Timedelta.ofSeconds(20)); + } + + public boolean isKeycloakWebPageInvisible() { + return isElementInvisible(username); + } + + public boolean isCertificateErrorVisible() { + return isElementVisible(certificateError); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/webview/OktaWebViewPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/webview/OktaWebViewPage.java new file mode 100644 index 00000000000..9fff16f973a --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/webview/OktaWebViewPage.java @@ -0,0 +1,47 @@ +package com.wearezeta.auto.ios.pages.webview; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.ios.pages.IOSPage; +import org.openqa.selenium.WebElement; + +public class OktaWebViewPage extends IOSPage { + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Username' AND name == 'Username' AND type == 'XCUIElementTypeOther'") + private WebElement usernameField; + + @iOSXCUITFindBy(accessibility = "Password") + private WebElement passwordField; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeButton' AND name CONTAINS 'Sign In'") + private WebElement signInButton; + + public OktaWebViewPage(WebDriver driver) { + super(driver); + } + + public boolean isOktaWebPageVisible() { + return isElementVisible(usernameField, Timedelta.ofSeconds(20)); + } + + public boolean isOktaWebPageInvisible() { + return isElementInvisible(usernameField); + } + + public void setUsername(String login) { + usernameField.click(); + usernameField.clear(); + usernameField.sendKeys(login); + } + + public void setPassword(String password) { + passwordField.click(); + passwordField.clear(); + passwordField.sendKeys(password); + } + + public void tapSignInButton() { + signInButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/webview/WebViewPage.java b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/webview/WebViewPage.java new file mode 100644 index 00000000000..9f0a8c83703 --- /dev/null +++ b/wire-ios-automation/ios/src/main/java/com/wearezeta/auto/ios/pages/webview/WebViewPage.java @@ -0,0 +1,219 @@ +package com.wearezeta.auto.ios.pages.webview; + +import io.appium.java_client.AppiumBy; +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.ios.pages.IOSPage; +import io.appium.java_client.MobileBy; +import org.openqa.selenium.By; +import org.openqa.selenium.WebElement; +import java.util.function.Function; + +public class WebViewPage extends IOSPage { + + @iOSXCUITFindBy(accessibility = "Done") + private WebElement nameDoneButton; + + @iOSXCUITFindBy(accessibility = "Open") + private WebElement openButton; + + @iOSXCUITFindBy(accessibility = "ShareButton") + private WebElement safariShareButtonID; + + @iOSXCUITFindBy(accessibility = "More") + private WebElement moreButtonShareExt; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Wire' AND name == 'Wire' AND value == 'Wire'") + private WebElement idWireShareExt; + + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeCell[`name == \"Wire Bund\"`]/XCUIElementTypeOther/XCUIElementTypeImage") + private WebElement idWireColumnShareExt; + + @iOSXCUITFindBy(accessibility = "Choose") + private WebElement idShareExtChoose; + + @iOSXCUITFindBy(accessibility = "Send") + private WebElement idSendButton; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeSwitch' AND value == '0' AND name = 'Wire'") + private WebElement wireSwitchOff; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND name == 'Change Password'") + private WebElement changePasswordButton; + + @iOSXCUITFindBy(accessibility = "Join in App") + private WebElement joinInTheAppButton; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Address' AND name == 'URL'") + private WebElement urlBar; + + @iOSXCUITFindBy(accessibility = "OpenInSafariButton") + private WebElement openInSafariButton; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Done' AND name == 'Done'") + private WebElement webViewDoneButton; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Address'") + private WebElement tapURLLink; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Address' AND name == 'URL' AND type == 'XCUIElementTypeOther'") + private WebElement tapURLLinkOnRealDevice; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Paste and Go'") + private WebElement pasteURLLink; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Open in App' AND name == 'Open in App' AND value == 'Open in App'") + private WebElement openiOSApp; + + @iOSXCUITFindBy(iOSNsPredicate = "label == 'Download Wire' AND name == 'Download Wire' AND value == 'Download Wire'") + private WebElement downloadApp; + + private static final Function predicateText = value -> MobileBy.iOSNsPredicateString( + String.format("type == 'XCUIElementTypeStaticText' AND name CONTAINS '%s'", value)); + + private static final Function predicateStrAddressBarByUrlPart = urlPart -> + String.format("(label == 'Address') AND value CONTAINS '%s'", urlPart); + + @iOSXCUITFindBy(accessibility = "unlock_screen.text_field.enter_passcode") + private WebElement passcodeField; + + @iOSXCUITFindBy(accessibility = "unlock_screen.title.enter_passcode") + private WebElement title; + + @iOSXCUITFindBy(accessibility = "unlock_screen.button.unlock") + private WebElement unlockButton; + + private static final Timedelta VISIBILITY_TIMEOUT = Timedelta.ofSeconds(20); + + public WebViewPage(WebDriver driver) { + super(driver); + } + + public void closeWebView() { + nameDoneButton.click(); + } + + public boolean isWebPageVisible(String expectedUrl) { + return waitUntilElementVisible(getDriver().findElement(AppiumBy.iOSNsPredicateString(predicateStrAddressBarByUrlPart.apply(expectedUrl)))); + } + + public String getUrlFromUrlBar() { + return urlBar.getText(); + } + + public boolean isChangePasswordPageVisible() { + return isElementVisible(changePasswordButton, VISIBILITY_TIMEOUT); + } + + public boolean isTextVisible(String expectedText) { + return isLocatorExist(predicateText.apply(expectedText), Timedelta.ofSeconds(15)); + } + + public void tapShareButtonSafari() { + safariShareButtonID.click(); + } + + public void tapMoreButonShareExt() { + moreButtonShareExt.click(); + } + + public void enableWireShareExt() { + if (isElementVisible(wireSwitchOff)){ + wireSwitchOff.click(); + } + } + + public void tapOpenButton() { + openButton.click(); + } + + public void tapDoneOnShareExt() { + nameDoneButton.click(); + } + + public void tapWireInShareExt() { + tapAtTheCenterOfElement(idWireShareExt); + } + + public void tapWireColumnInShareExt() { + idWireColumnShareExt.click(); + } + + public void tapChooseInShareExt() { + waitUntilElementClickable(idShareExtChoose); + idShareExtChoose.click(); + } + + public void selectConversationInShareExt(String name) { + getDriver().findElement(MobileBy.AccessibilityId(name)).click(); + } + + public void tapSendButtonShareExt() { + idSendButton.click(); + } + + public void inputPasscode(String passcode) { + passcodeField.clear(); + passcodeField.sendKeys(passcode); + } + public boolean isUnlockWireInShareExtensionVisble() { + return waitUntilElementVisible(title); + } + public void tapUnlockButtonOnShareExtension() { + unlockButton.click(); + } + + public void tapJoinInTheAppButton() { + waitUntilElementClickable(joinInTheAppButton); + joinInTheAppButton.click(); + } + + public void iTapDoneOnWebView(){ + webViewDoneButton.click(); + } + + public void tapURLLinkSafari(){ + waitUntilElementVisible(tapURLLink); + tapURLLink.click(); + longTapWithScript(tapURLLink); + } + + public void tapURLLinkSafariRealDevice(){ + waitUntilElementVisible(tapURLLinkOnRealDevice); + longTapWithScript(tapURLLinkOnRealDevice); + } + + public void tapPasteURLLinkSafari(){ + waitUntilElementVisible(pasteURLLink); + tapAtTheCenterOfElement(pasteURLLink); + } + + public boolean goToiOSAppOnWebViewIsVisible() { + return openiOSApp.isEnabled(); + } + + public boolean goToiOSAppOnWebViewIsInvisible() { + return isElementInvisible(openiOSApp); + } + + public void tapOnGoToiOSAppOnWireWebView() { + tapAtTheCenterOfElement(openiOSApp); + } + + public boolean downloadAppOnWebViewVisible() { + return downloadApp.isEnabled(); + } + + public boolean downloadAppOnWebViewInvisible() { + return isElementInvisible(downloadApp); + } + + public void tapOnDownloadAppOnWebView() { + tapAtTheCenterOfElement(downloadApp); + } + + public void iTapOpenInSafarOnWebView(){ + openInSafariButton.click(); + } +} diff --git a/wire-ios-automation/ios/src/main/resources/Configuration.properties b/wire-ios-automation/ios/src/main/resources/Configuration.properties new file mode 100644 index 00000000000..825628b0efe --- /dev/null +++ b/wire-ios-automation/ios/src/main/resources/Configuration.properties @@ -0,0 +1,32 @@ +#Configuration file for IOS tests +#Configure before running tests +appiumUrl=${Url} +enableAppiumOutput=${enableAppiumOutput} +iosApplicationPath=${appPath} +oldAppPath=${oldAppPath} +realBuildNumber=${realBuildNumber} +bundleId=${bundleId} +isSimulator=${isSimulator} +UDID=${UDID} +appName=${appName} +platformVersion=${platformVersion} +driverTimeoutSeconds=${driverTimeoutSeconds} +projectBuildPath=${project.build.directory} +iOSToolsRoot=${project.build.directory}/../../tools/ios/ +defaultImagesPath=${project.build.directory}/../../tools/img/ +defaultAudioPath=${project.build.directory}/../../tools/audio/ +defaultVideoPath=${project.build.directory}/../../tools/video/ +defaultMiscResourcesPath=${project.build.directory}/../../tools/ios/misc/ +defaultEmail=${defaultEmail} +defaultEmailPassword=${defaultEmailPassword} +specialEmail=${specialEmail} +specialPassword=${specialPassword} +deviceName=${deviceName} +backendType=${backendType} +perfReportPath=${perfReportPath} +testrailUser=smoketester+ios@wire.com +testrailToken=3ss3NdlIsbkVZ/qjARyZ-t42ldIdkAFdZzbPgC3o0 +isOnGrid=${isOnGrid} +browserName=${browserName} +accountPagesPath=${accountPagesPath} +kubeConfigPath=${kubeConfigPath} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/resources/META-INF/services/javax.script.ScriptEngineFactory b/wire-ios-automation/ios/src/main/resources/META-INF/services/javax.script.ScriptEngineFactory new file mode 100644 index 00000000000..7a84fb0a06a --- /dev/null +++ b/wire-ios-automation/ios/src/main/resources/META-INF/services/javax.script.ScriptEngineFactory @@ -0,0 +1 @@ +apple.applescript.AppleScriptEngineFactory \ No newline at end of file diff --git a/wire-ios-automation/ios/src/main/resources/mixpanel.cer b/wire-ios-automation/ios/src/main/resources/mixpanel.cer new file mode 100644 index 00000000000..5d4106e68ea --- /dev/null +++ b/wire-ios-automation/ios/src/main/resources/mixpanel.cer @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDoDCCAoigAwIBAgIJAMKSROeRdgaBMA0GCSqGSIb3DQEBCwUAMGUxCzAJBgNV +BAYTAkRFMRMwEQYDVQQIDApTb21lLVN0YXRlMQ0wCwYDVQQKDARXaXJlMQswCQYD +VQQLDAJRQTElMCMGA1UEAwwcUUEgQ2VydGlmaWNhdGUgQXV0aG9yaXR5KENBKTAe +Fw0xODA3MTYwODM3MDNaFw0zODA3MTEwODM3MDNaMGUxCzAJBgNVBAYTAkRFMRMw +EQYDVQQIDApTb21lLVN0YXRlMQ0wCwYDVQQKDARXaXJlMQswCQYDVQQLDAJRQTEl +MCMGA1UEAwwcUUEgQ2VydGlmaWNhdGUgQXV0aG9yaXR5KENBKTCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAL2I7/bMuV158R+5dNioBAxbSyoQ9N08Nidm +KDpEWDbRzmnXnM633kK0nNyBn+OlpSJjOcRtUWqItZEkIw8e9nxjz9nKmjrMvxY4 +dhfHhk9pmX1ztv15AvYSzZhJfGjM/+nCuFNbCACkrpaaAk2aCBHbIQGaZACcwzH7 +n2Jc818f/lHRCwsw1c+mNtdDvLo6eOzhC8iFpx7cSmEGFLMegnZK4eCWECaDmQx4 +kWhhy17eiJ8jELEhWy2EGfZumGQVsfNi9tSOqA//2wvEr4NvUKQtqcasyrlionMj +gF4Phz1n8lhdGlIAm/0AdB+Vb8lH6BqMd1i/TEZnySL7ivAj0ZMCAwEAAaNTMFEw +HQYDVR0OBBYEFGi7qqgK55VxtmSB7kObvy3NusaWMB8GA1UdIwQYMBaAFGi7qqgK +55VxtmSB7kObvy3NusaWMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQAD +ggEBAF5QbdvBT6fZbdmc7SSr7sbbmR9/K+4irn6XiPES96YM57z8Dpet7qDDjavY +ZRSRHK6qJ+nOFkSeCAEbZhqlvZR6upujpiYorUDH3rsUsO2LrcrWNFLT+GUrvlMe +WFFmvW2rCG5yRNux+Dt6027hr98+OWGMin+3EhRWg15dXqdf1ZIeKEG9L3bx4m4C +9KASNIkAwpctX3qhvcNgNiITZooQXibaSJ4bNjjyLZDIAA4thJo957VAHFXUFHFU +a9sEndA06YpJNZZpEBy3VR0pUOnjwKMzQLLu0fLR2rC0f/8lQbQ/NRFkYYzN/8Hl +vOntbjYL44Ah0UZ1O+XwuTjpdCc= +-----END CERTIFICATE----- diff --git a/wire-ios-automation/ios/src/main/resources/scripts/export_trace_to_csv.txt b/wire-ios-automation/ios/src/main/resources/scripts/export_trace_to_csv.txt new file mode 100644 index 00000000000..c7b148c4270 --- /dev/null +++ b/wire-ios-automation/ios/src/main/resources/scripts/export_trace_to_csv.txt @@ -0,0 +1,29 @@ +tell application "Instruments" + activate + close every window + delay 2 + open "Users:jenkins:instrumentscli0.trace" +end tell +delay 2 +tell application "System Events" to tell process "Instruments" + set frontmost to true + delay 10 + tell menu bar item "Instrument" of menu bar 1 + click + click menu item "Export Track for 'Activity Monitor'..." of menu 1 + end tell + delay 1 + keystroke "g" using {command down, shift down} + delay 1 + tell sheet 1 of sheet 1 of window 1 + keystroke "/Project/iOS_Performance_Reports" + click button "Go" + end tell + delay 0.2 + tell sheet 1 of window 1 + click button "Save" + end tell +end tell +tell application "Instruments" + quit +end tell \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/pages/AdvancedSettingsPage.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/pages/AdvancedSettingsPage.java new file mode 100644 index 00000000000..b6c31c9ed15 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/pages/AdvancedSettingsPage.java @@ -0,0 +1,26 @@ +package com.wearezeta.auto.ios.pages; + +import io.appium.java_client.pagefactory.iOSXCUITFindBy; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.WebElement; + +public class AdvancedSettingsPage extends IOSPage { + @iOSXCUITFindBy(iOSClassChain = "**/XCUIElementTypeStaticText[`name == \"Version Technical Details\"`]") + private WebElement versionTechnicalDetailsMenu; + + @iOSXCUITFindBy(iOSNsPredicate = "type == 'XCUIElementTypeStaticText' AND value CONTAINS \"core-crypto\"") + private WebElement versionDetails; + + public AdvancedSettingsPage(WebDriver driver) { + super(driver); + } + + + public void openVersionTechnicalDetails() { + versionTechnicalDetailsMenu.click(); + } + + public boolean isVersionDetailsVisible() { + return versionDetails.isDisplayed(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/AdvancedSettingsSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/AdvancedSettingsSteps.java new file mode 100644 index 00000000000..7783f1df592 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/AdvancedSettingsSteps.java @@ -0,0 +1,31 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.AdvancedSettingsPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.jcodec.common.Assert.assertTrue; + +public class AdvancedSettingsSteps { + IOSTestContext context; + + public AdvancedSettingsSteps(IOSTestContext context) { + this.context = context; + } + + public AdvancedSettingsPage getAdvancedSettingsPage() { + return context.getPagesCollection().getPage(AdvancedSettingsPage.class); + } + + @When("I open Version Technical Details") + public void iOpenVersionTechnicalDetails() { + getAdvancedSettingsPage().openVersionTechnicalDetails(); + } + + @Then("I see my version details") + public void iSeeMyVersionDetails() { + assertTrue("Version details are not displayed", getAdvancedSettingsPage().isVersionDetailsVisible()); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ArchivePageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ArchivePageSteps.java new file mode 100644 index 00000000000..0790df6dfac --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ArchivePageSteps.java @@ -0,0 +1,66 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.CommonUtils; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.ArchivePage; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.regex.Pattern; + +public class ArchivePageSteps { + IOSTestContext context; + + public ArchivePageSteps(IOSTestContext context) { + this.context = context; + } + + private ArchivePage getArchivePage() { + return context.getPagesCollection() + .getPage(ArchivePage.class); + } + + /** + * Tap close button on Archive page + */ + @When("^I tap close Archive page button$") + public void IClickCloseArchivePageButton() { + getArchivePage().clickCloseArchivePageButton(); + } + + /** + * verifies the visibility of a specific item in the archived conversations list + * + * @param shouldNotSee equals to null if the item should be visible + * @param value conversation name/alias + */ + @Then("^I (do not )?see conversation (.*) in archived conversations list$") + public void ISeeUserInContactList(String shouldNotSee, String value) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + assertThat(String.format("The conversation '%s' is not visible in the conversation list", + value), getArchivePage().isConversationInList(value)); + } else { + assertThat( + String.format("The conversation '%s' is visible in the conversation list, but should be hidden", + value), getArchivePage().isConversationNotInList(value)); + } + } + + /** + * Open the corresponding conversation by tapping its name in the conversations list + * + * @param name conversation name/alias + */ + @Given("^I open archived (?:group |single |1:1 |\\s?)conversation \"(.*)\"") + public void IOpenArchivedConversation(String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + getArchivePage().tapConversationsListItem(name); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/BackupPasswordOverlaySteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/BackupPasswordOverlaySteps.java new file mode 100644 index 00000000000..d2ebaeed31d --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/BackupPasswordOverlaySteps.java @@ -0,0 +1,39 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.BackupPasswordOverlayPage; +import io.cucumber.java.en.And; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class BackupPasswordOverlaySteps { + + IOSTestContext context; + + public BackupPasswordOverlaySteps(IOSTestContext context) { + this.context = context; + } + + private BackupPasswordOverlayPage getPage() { + return context.getPagesCollection().getPage(BackupPasswordOverlayPage.class); + } + + /** + * Tap the corresponding button + * + */ + @When("^I tap Next button on Backup password overlay$") + public void iTapNextButtonBackupOverlay() { + getPage().tapNextButton(); + } + + /** + * Type the given backup password + * + * @param password the actual password value + */ + @And("^I type password \"(.*)\" on Backup password overlay$") + public void iTypeBackupPassword(String password) { + getPage().typePassword(password); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/BiometricAuthSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/BiometricAuthSteps.java new file mode 100644 index 00000000000..0148ce91d9d --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/BiometricAuthSteps.java @@ -0,0 +1,39 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.Config; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.IOSPage; +import io.cucumber.java.en.When; + +public class BiometricAuthSteps { + IOSTestContext context; + + public BiometricAuthSteps(IOSTestContext context) { + this.context = context; + } + + private static void verifyCurrentDeviceIsSimulator() { + if (!Config.current().isSimulator(BiometricAuthSteps.class)) { + throw new IllegalStateException("The current device is expected to be an iOS Simulator"); + } + } + + /** + * Perform simulated touch ID on Simulator. It is mandatory that Touch ID feature + * is already enrolled + * + * @param type either 'successful' or 'failed)' + * @ + */ + @When("^I perform (successful|failed) Touch ID$") + public void IPerformTouchID(String type) { + verifyCurrentDeviceIsSimulator(); + // Consistently needed to wait 2 seconds before sending the perform for it to be accepted + context.startPinging(); + Timedelta.ofSeconds(2).sleep(); + context.stopPinging(); + context.getPagesCollection().getPage(IOSPage.class) + .performTouchID(type.equalsIgnoreCase("successful")); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/BottomNavigationBarSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/BottomNavigationBarSteps.java new file mode 100644 index 00000000000..737fd6e6e99 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/BottomNavigationBarSteps.java @@ -0,0 +1,64 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.BottomNavigationBarPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class BottomNavigationBarSteps { + IOSTestContext context; + + public BottomNavigationBarSteps(IOSTestContext context) { + this.context = context; + } + + private BottomNavigationBarPage getBottomNavigationBarPage() { + return context.getPagesCollection() + .getPage(BottomNavigationBarPage.class); + } + + /** + * Open the corresponding view by tapping a button + * + */ + + @Then("^I open archived conversations$") + public void IOpenArchivedConversations() { + getBottomNavigationBarPage().openArchivedConversations(); + } + + /** + * Verify whether Archive button is visible at the bottom of conversations list + * + * @param shouldNotSee equals to null if Archive button should be visible + */ + @Then("^I (do not )?see Archive button at the bottom of conversations list$") + public void ISeeArchiveButton(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Archive button should be visible, but it's hidden", + getBottomNavigationBarPage().isArchiveButtonVisible()); + } else { + assertThat("Archive button should be invisible, but it's visible", + getBottomNavigationBarPage().isArchiveButtonInvisible()); + } + } + + /** + @Then("^I tap Folder button in bottom navigation bar$") + public void iTapFolderButton() { + getBottomNavigationBarPage().tapGroupedConversationsButton(); + } +*/ + @Then("^I tap Conversations button in bottom navigation bar$") + public void iTapRecentConversationsButton() { + getBottomNavigationBarPage().tapRecentConversationsButton(); + } + + @When("^I open settings screen") + public void iTapSettingsButton() { + getBottomNavigationBarPage().tapSettingsButton(); + } + +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CameraPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CameraPageSteps.java new file mode 100644 index 00000000000..92f87737e6e --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CameraPageSteps.java @@ -0,0 +1,53 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.CameraPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class CameraPageSteps { + + IOSTestContext context; + + public CameraPageSteps(IOSTestContext context) { + this.context = context; + } + + private CameraPage getCameraPage() { + return context.getPagesCollection().getPage(CameraPage.class); + } + + @When("^I tap Take Photo button on Camera page$") + public void iTapTakePhoto() { + getCameraPage().tapTakePhoto(); + } + + @When("^I tap Take Video button on Camera page$") + public void iTapTakeVideo() { + getCameraPage().tapTakeVideo(); + } + + @When("^I tap Use Video button on Camera page$") + public void iTapUseVideo() { + getCameraPage().tapUseVideo(); + } + + @Then("^I see Choose from library button on change profile pop up$") + public void iSeeChooseFromLibrary() { + assertThat("The Choose from library button is not visible on change profile pop up", + getCameraPage().isChooseFromLibraryVisible()); + } + + @Then("^I see Take Photo button on Camera page$") + public void iSeePhotoButton() { + assertThat("The take photo button is not visible on Camera screen", + getCameraPage().isTakePhotoButtonVisible()); + } + + @When("^I tap Choose from library button on change profile pop up$") + public void iTapChooseFromLibrary() { + getCameraPage().tapChooseFromLibrary(); + } + +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CameraRollPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CameraRollPageSteps.java new file mode 100644 index 00000000000..3122a4544b4 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CameraRollPageSteps.java @@ -0,0 +1,28 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.CameraRollPage; +import io.cucumber.java.en.When; + +public class CameraRollPageSteps { + + IOSTestContext context; + + public CameraRollPageSteps(IOSTestContext context) { + this.context = context; + } + + private CameraRollPage getCameraRollPage() { + return context.getPagesCollection().getPage(CameraRollPage.class); + } + + @When("^I select a picture from Camera Roll$") + public void ISelectPicture() { + getCameraRollPage().selectPicture(); + } + + @When("^I select first picture from Camera Roll$") + public void ISelectFirstPicture() { + getCameraRollPage().selectFirstPicture(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CollectionPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CollectionPageSteps.java new file mode 100644 index 00000000000..e9954521189 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CollectionPageSteps.java @@ -0,0 +1,37 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.CollectionPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class CollectionPageSteps { + + IOSTestContext context; + + public CollectionPageSteps(IOSTestContext context) { + this.context = context; + } + + private CollectionPage getCollectionPage() { + return context.getPagesCollection() + .getPage(CollectionPage.class); + } + + @When("^I (long )?tap the item number (\\d+) in collection category PICTURES$") + public void iTapPictureItemByIndex(String isLongTap, int index) { + getCollectionPage().tapPictureItemByIndex(index, isLongTap != null); + } + + @Then("^I see full-screen image preview in collection view$") + public void iSeeFullScreenImagePreview() { + assertThat("Full-screen image preview is expected to be visible", + getCollectionPage().isFullScreenImagePreviewVisible()); + } + + @When("^I tap X button in collection view$") + public void iTapCloseButton() { + getCollectionPage().tapCloseButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CommonBackendSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CommonBackendSteps.java new file mode 100644 index 00000000000..24cd7fb131a --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CommonBackendSteps.java @@ -0,0 +1,460 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.CommonSteps; +import com.wearezeta.auto.common.backend.Backend; +import com.wearezeta.auto.common.backend.BackendConnections; +import com.wearezeta.auto.common.backend.models.MuteState; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.ios.common.IOSTestContext; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +import java.io.ByteArrayInputStream; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.hamcrest.Matchers.containsString; + +public class CommonBackendSteps { + + IOSTestContext context; + + public CommonBackendSteps(IOSTestContext context) { + this.context = context; + } + + private CommonSteps getCommonSteps() { + return context.getCommonSteps(); + } + + private List userGetDeviceIds(String usernameAlias) { + return context.getCommonSteps().getDeviceIds(usernameAlias); + } + + @When("^User (.*) blocks user (.*)$") + public void BlockContact(String blockAsUserNameAlias, String userToBlockNameAlias) { + getCommonSteps().blockContact(blockAsUserNameAlias, userToBlockNameAlias); + } + + @Given("^(\\w+) waits? until (\\w+) exists in backend search results$") + public void UserWaitsUntilContactExistsInHisSearchResults( + String searchByNameAlias, String query) { + getCommonSteps().waitUntilContactIsFoundInSearch(searchByNameAlias, query); + } + + @Given("^User (.*) removes their avatar picture$") + public void UserRemovesAvatarPicture(String nameAlias) { + getCommonSteps().userDeletesAvatarPicture(nameAlias); + } + + @Given("^User (.*) removes all their registered OTR clients$") + public void UserRemovesAllRegisteredOtrClients(String userAs) { + getCommonSteps().userRemovesAllRegisteredOtrClients(userAs); + } + + @When("^User (.*) cancels all outgoing connection requests$") + public void CancelAllOutgoingConnectRequest(String userToNameAlias) { + getCommonSteps().cancelAllOutgoingConnectRequests(userToNameAlias); + } + + @Given("^User (.*) sent connection request to (.*)$") + public void GivenConnectionRequestIsSentTo(String userFromNameAlias, String usersToNameAliases) { + getCommonSteps().connectionRequestIsSentTo(userFromNameAlias, usersToNameAliases); + } + + @Given("^User (.*) has group conversation (.*) with (.*)$") + public void UserHasGroupChatWithContacts(String chatOwnerNameAlias, + String chatName, String otherParticipantsNameAlises) { + getCommonSteps().userHasGroupChatWithContacts(chatOwnerNameAlias, chatName, otherParticipantsNameAlises); + } + + @When("^User (.*) adds (.*) to group chat (.*)") + public void UserXDddsUserYToGroupChat(String chatOwnerNameAlias, + String userToAdd, String chatName) { + getCommonSteps().userXAddedContactsToGroupChat(chatOwnerNameAlias, userToAdd, chatName); + } + + @Given("^User (.*) is connected to (.*)$") + public void UserIsConnectedTo(String userFromNameAlias, String usersToNameAliases) { + getCommonSteps().userIsConnectedTo(userFromNameAlias, usersToNameAliases); + } + + @Given("^User (.*) accepts connection request from (.*)") + public void acceptConnectionRequestFrom(String asUserAlias, String userFromNameAliases) { + context.getCommonSteps().userAcceptsConnectionRequestFrom(asUserAlias, userFromNameAliases); + } + + @Given("^User (.*) leaves group chat (.*)$") + public void UserLeavesGroupChat(String userName, String chatName) { + getCommonSteps().userXLeavesGroupChat(userName, chatName); + } + + @Given("^User (.*) removes? user (.*) from group conversation (.*)$") + public void UserARemovesUserBFromGroupChat(String chatOwnerNameAlias, String userToRemove, String chatName) { + getCommonSteps().userXRemoveUserFromGroupConversation(chatOwnerNameAlias, userToRemove, chatName); + } + + @When("^User (\\w+) changes? name to (.*)$") + public void IChangeName(String userNameAlias, String newName) { + getCommonSteps().userChangesName(userNameAlias, newName); + } + + @When("^User (\\w+) changes? accent color to (.*)$") + public void IChangeAccentColor(String userNameAlias, String newColor) { + getCommonSteps().userChangesAccentColor(userNameAlias, newColor); + } + + @Given("^There (?:is|are) personal account users? (.*)") + public void ThereAreUsers(String nameAliases) { + final List userNames = getCommonSteps().thereArePersonalUsers(nameAliases).stream() + .map(ClientUser::getName) + .collect(Collectors.toList()); + getCommonSteps().usersSetUniqueUsername(String.join(",", userNames)); + userNames.forEach(x -> { + try { + getCommonSteps().userChangesUserAvatarPicture(x); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + @Then("^I verify user's (.*) email on the backend is equal to (.*)") + public void IVerifyEmailOnBackend(String user, String expectedValue) { + getCommonSteps().userVerifiesEmail(user, expectedValue); + } + + @Given("^User (.*) removes users? (.*) from team (.*)") + public void UserXRemovesUsersFromTeam(String userNameAlias, String userAliases, String teamName) { + getCommonSteps().userXRemovesUsersFromTeam(userNameAlias, userAliases, teamName); + } + + @Given("^User (.*) has conversation (.*) with (.*) in team (.*)") + public void UserHasGroupChatWithContacts(String chatOwnerNameAlias, + String chatName, String otherParticipantsNameAliases, String teamName) { + getCommonSteps().userHasGroupConversationInTeam(chatOwnerNameAlias, chatName, otherParticipantsNameAliases, teamName); + } + + @Given("^User (.*) has 1:1 conversation with (.*) in team (.*)") + public void UserHas1to1ChatInTeam(String chatOwnerNameAlias, String otherParticipantAlias, String teamName) { + getCommonSteps().userHasGroupConversationInTeam(chatOwnerNameAlias, null, otherParticipantAlias, teamName); + } + + + @Given("^There is a team owner \"(.*)\" with team \"(.*)\"( without unique username)?$") + public void thereIsATeamOwner(String userAlias, String teamName, String hasNoUniqueUsername) { + if (hasNoUniqueUsername == null) { + context.getCommonSteps().thereIsATeamOwner(userAlias, teamName, true); + } else { + context.getCommonSteps().thereIsATeamOwner(userAlias, teamName, false); + } + } + + @Given("^There is a team owner \"(.*)\" who sets up team \"(.*)\" for E2EI on (.*) backend") + public void thereIsATeamOwnerWhoSetsUpTeamForE2EIOnBackend(String userAlias, String teamName, String backend) { + thereIsATeamOwnerOnCustomBackend(userAlias, teamName, backend); + getCommonSteps().userAddsKeycloakUserForE2EI(userAlias, userAlias); + context.getCommonSteps().configureMLSForBund(userAlias, teamName); + context.getCommonSteps().enableE2EIFeatureTeam(userAlias, teamName); + } + + @Given("^There is a team owner \"(.*)\" with team \"(.*)\" on (.*) backend$") + public void thereIsATeamOwnerOnCustomBackend(String userAlias, String teamName, String backendName) { + Backend backend = BackendConnections.get(backendName); + context.getCommonSteps().thereIsATeamOwner(userAlias, teamName, backend); + } + + /** + * Adds users to a team by creating them on the backend. Team owner user should already exist + * + * @param ownerNameAlias team owner name/alias + * @param userNameAliases team members aliases + * @param teamName the name of the team + * @param role one of available team roles + */ + @Given("^User (.*) adds users? (.*) to team (.*) with role (Owner|Admin|Member|Partner)( and without unique usernames?)?$") + public void UserXAddsUsersToTeam(String ownerNameAlias, String userNameAliases, String teamName, String role, + String hasNoHandle) { + boolean hasHandle = hasNoHandle == null; + getCommonSteps().userXAddsUsersToTeam(ownerNameAlias, userNameAliases, teamName, role, hasHandle); + } + + /** + * Creates team owner user with one SSO team for Okta + * + * @param userAlias team owner name/alias + * @param teamName the name of the team to be created + */ + @Given("^There is a team owner \"(.*)\" with SSO team \"(.*)\" configured for okta$") + public void ThereIsASSOTeamOwnerForOkta(String userAlias, String teamName) { + getCommonSteps().thereIsASSOTeamOwnerForOkta(userAlias, teamName); + } + + @Given("^User (.*) adds users? (.*) to keycloak for E2EI$") + public void userAddsKeycloakUserForE2EI(String ownerNameAlias, String userNameAliases) { + getCommonSteps().userAddsKeycloakUserForE2EI(ownerNameAlias, userNameAliases); + } + + @When("^Admin user (.*) enables E2EI with ACME server for team \"(.*)\"$") + public void enableE2EIForTeam(String adminUserAlias, String teamName) { + context.getCommonSteps().enableE2EIFeatureTeam(adminUserAlias, teamName); + } + + @When("^Admin user (.*) enables E2EI with insecure ACME server for team \"(.*)\"$") + public void enableE2EIForTeamWithInsecureACME(String adminUserAlias, String teamName) { + context.getCommonSteps().enableE2EIFeatureTeamWithInsecureACME(adminUserAlias, teamName); + } + + @Given("^User (.*) adds users? (.*) to okta$") + public void userAddsOktaUsers(String ownerNameAlias, String userNameAliases) { + getCommonSteps().userAddsOktaUser(ownerNameAlias, userNameAliases); + } + + @Given("^User (.*) adds users? (.*) to okta and SCIM$") + public void userAddsUserToOktaAndSCIM(String ownerNameAlias, String userNameAliases) { + getCommonSteps().userAddsUserToOktaAndSCIM(ownerNameAlias, userNameAliases); + } + + @Given("^Team user (.*) invites wireless user (\\w+)(, which expires in \\d+ seconds?,)? to conversation (.*)$") + public void userInvitesWirelessUsers(String ownerNameAlias, String userNameAliases, String timeoutSeconds, + String conversationName) { + if (timeoutSeconds == null) { + getCommonSteps().userInvitesWirelessUsers(ownerNameAlias, userNameAliases, conversationName); + } else { + getCommonSteps().userInvitesWirelessUsers(ownerNameAlias, userNameAliases, + Duration.ofSeconds(Integer.parseInt(timeoutSeconds.replaceAll("\\D", ""))), + conversationName); + } + } + + @Given("Team user (.*) allows guests in conversation (.*)$") + public void userInvitesWirelessUsers(String userNameAlias, String conversationName) { + getCommonSteps().userAllowsGuestsInConversation(userNameAlias, conversationName); + } + + @Given("^User (.*) creates invite link for conversation (.*)") + public void userCreatesInviteLink(String userNameAlias, String conversationName) { + getCommonSteps().userCreatesInviteLink(userNameAlias, conversationName); + } + + /* + * Creates a user with custom credentials. Useful to test with users we know exist (e.g. + * the user who is connected to 255 people) + */ + @Given("^There is a known user (.*) with email (.*) and password (.*)$") + public void ThereIsAKnownUser(String name, String email, String password) { + getCommonSteps().thereIsAKnownUser(name, email, password, BackendConnections.getDefault()); + } + + @Given("^User (.*) (en|dis)ables (.*) services? for team (.*)$") + public void userWhitelistsService(String ownerOrAdminAlias, String action, String commaSeparatedServiceAliases, + String teamName) { + context.getCommonSteps().userSwitchesUsersServicesForTeam(ownerOrAdminAlias, + action.equals("en"), commaSeparatedServiceAliases, teamName); + } + + @When("^User (.*) (mutes|unmutes|allows only mentions for) conversation (.*)") + public void muteConversationWithUser(String userToNameAlias, String action, String dstConvo) { + switch (action) { + case "mutes": + getCommonSteps().userSetsMuteStatusForConversation(userToNameAlias, dstConvo, MuteState.MUTE_ALL); + break; + case "unmutes": + getCommonSteps().userSetsMuteStatusForConversation(userToNameAlias, dstConvo, MuteState.NONE); + break; + case "allows only mentions for": + getCommonSteps().userSetsMuteStatusForConversation(userToNameAlias, dstConvo, MuteState.MENTIONS_ONLY); + break; + default: + throw new IllegalArgumentException(String.format("Unknown action: %s", action)); + } + } + + @When("^User (.*) archives conversation (.*)") + public void archiveConversationWithUser(String userToNameAlias, String dstConvoName) { + getCommonSteps().userSetsArchivedStateForConversation(userToNameAlias, dstConvoName, true); + } + + @When("^User (.*) (?:adds|updates) rich profile field \"(.*)\" with value \"(.*)\"$") + public void userUpdatesEnrichedProfileViaSCIM(String userNameAlias, String key, String value) { + getCommonSteps().userUpdatesRichProfile(userNameAlias, key, value); + } + + @When("^User (.*) changes users? (.*) to role (.*) for conversation \"(.*)\"$") + public void userChangesRoleOtherInConversation(String userName, String subjectUsers, String conversationRole, String conversationName) { + getCommonSteps().userChangesRoleOtherInConversation(userName, subjectUsers, conversationRole, conversationName); + } + + // region foma + + /* + * The following 3 Methods can be used during the setup of a test when working with FOMA environment + * These methods will check for 60 seconds if the needed pods are available and if we can start the testcase. + */ + @Given("^I wait until the (federator|brig|galley) pod on (.*) is available$") + public void waitUntilPodIsAvailable(String service, String backendName) throws Exception { + context.getCommonSteps().waitUntilPodIsAvailable(backendName, service); + } + + /* + * This Method will turn the Federator for Federated environments on or off. + * Turning the federator off will disable federation for the selected environment. + */ + @Given("^Federator for backend (.*) is turned (on|off)$") + public void turnFederatorforFederatedEnvironmentOnOrOff(String backendName, String status) throws Exception { + if (status.equals("on")) { + context.getCommonSteps().turnFederatorInBackendOn(backendName); + context.getCommonSteps().checkPodsStatusOn(backendName, "federator"); + } else { + context.getCommonSteps().turnFederatorInBackendOff(backendName); + context.getCommonSteps().checkPodsStatusOff(backendName, "federator"); + } + } + + @Then("^The search policy is (.*) with no team level restriction from (.*) backend to (.*) backend$") + public void searchPolicyCheck(String searchPolicy, String fromBackend, String toBackend) { + String toDomain = BackendConnections.get(toBackend).getDomain(); + assertThat("Search policy is not correct", + context.getCommonSteps().getSearchPolicy(fromBackend), + containsString("\"domain\":\"" + toDomain + "\"," + + "\"restriction\":{\"tag\":\"allow_all\",\"value\":null}," + + "\"search_policy\":\"" + searchPolicy + "\"")); + } + + // endregion foma + + @Given("^User (.*) changes their email to (.*)$") + public void userXchangesEmailToNewEmail(String userNameAliases, String email) { + getCommonSteps().userChangesEmail(userNameAliases, email); + } + + @Given("^TeamOwner \"(.*)\" waits and enables conference calling feature for team (.*) via backdoor$") + public void TeamOwnerWaitsEnablesConferenceCallingViaBackdoor(String alias, String teamName) { + // Wait until stripe/ibis has set free account restrictions after team creation. + // This wait can be skipped if the driver was created because the creation usually + // takes more than 3 seconds and is happening before a call is started. + if (!context.isDriverCreated()) { + Timedelta.ofSeconds(10).sleep(); + } + context.getCommonSteps().enableConferenceCallingFeatureViaBackdoorTeam(alias, teamName); + } + + @Given("^TeamOwner \"(.*)\" enables conference calling feature for team (.*) via backdoor$") + public void TeamOwnerEnablesConferenceCallingViaBackdoor(String alias, String teamName) { + context.getCommonSteps().enableConferenceCallingFeatureViaBackdoorTeam(alias, teamName); + } + + @Given("^Personal Users (.*) enables conference calling feature via backdoor$") + public void PersonalUserEnablesConferenceCalling(String alias) { + context.getCommonSteps().enableConferenceCallingFeatureViaBackdoorPersonalUser(alias); + } + + @Given("^TeamOwner \"(.*)\" sets the search behaviour for SearchVisibilityInbound to SearchableByOwnTeam for team (.*)$") + public void TeamOwnerDisablesSearchInbound(String alias, String teamName) { + //SearchableByOwnTeam = disabled + context.getCommonSteps().disableSearchVisibilityInbound(alias, teamName); + } + + @Given("^TeamOwner \"(.*)\" enables the search behaviour for TeamSearchVisibility for team (.*)$") + public void TeamOwnerEnablesSearchOutbound(String alias, String teamName) { + context.getCommonSteps().enableTeamSearchVisibilityOutbound(alias, teamName); + } + + @Given("^TeamOwner \"(.*)\" sets the search behaviour for TeamSearchVisibility to SearchVisibilityNoNameOutsideTeam for team (.*)$") + public void TeamOwnerSetsSearchOutboundSearchVisibilityNoNameOutsideTeam(String alias, String teamName) { + context.getCommonSteps().setTeamSearchVisibilityOutboundNoNameOutsideTeam(alias, teamName); + } + + @When("^Admin user (.*) enables 2 Factor Authentication for team (.*)$") + public void userOwnerEnablesGuestLinksForTeam(String adminUserAlias, String teamName) { + context.getCommonSteps().enable2FAuthentication(adminUserAlias, teamName); + } + + @When("^Admin user (.*) disables 2 Factor Authentication for team (.*)$") + public void userOwnerDisablesGuestLinksForTeam(String adminUserAlias, String teamName) { + context.getCommonSteps().disable2FAuthentication(adminUserAlias, teamName); + } + + @When("^Admin user (.*) unlocks 2F Authentication for team (.*)$") + public void userOwnerUnlocks2FAuthenticationForTeam(String adminUserAlias, String teamName) { + context.getCommonSteps().unlock2FAuthentication(adminUserAlias, teamName); + } + + @Given("^User (.*) configures MLS for team \"(.*)\"$") + public void enableMLSForTeam(String adminUserAlias, String teamName) { + context.getCommonSteps().configureMLSForBund(adminUserAlias, teamName); + } + + @When("^Admin user (.*) disables MLS for team (.*) via backdoor$") + public void disableMLSForTeam(String adminUserAlias, String teamName) { + context.getCommonSteps().disableMLSFeatureTeam(adminUserAlias, teamName); + } + + @Given("^User (.*) has MLS conversation \"(.*)\" with (.*)$") + public void userHasMLSGroupChat(String chatOwnerNameAlias, String chatName, String participantAliases) { + context.getCommonSteps().userHasMLSGroupConversation(chatOwnerNameAlias, chatName, participantAliases); + } + + @When("^User (.*) adds (\\d+) devices?$") + public void userAddsDevices(String userNameAlias, int amount) { + for (int i = 1; i <= amount; i++) { + context.getCommonSteps().addDevice(userNameAlias, null, + "Device" + i, Optional.of("Label" + i), true); + } + } + + @Given("^There is a known user (.*) with email (.*) and password (.*) on (.*) backend$") + public void ThereIsAKnownUserOnBackend(String name, String email, String password, String backend) { + getCommonSteps().thereIsAKnownUser(name, email, password, BackendConnections.get(backend)); + } + + private static List extractEmails(String emails) { + return Arrays.stream(emails.split(",")) + .map(String::trim) + .collect(Collectors.toList()); + } + + @Then("^User (.*) send invitations? to emails (.*) to join the (.*) team as a member$") + public void ISendTeamInvites(String ownerAlias, String emails, String dstTeam) { + for (String email : extractEmails(emails)) { + context.getCommonSteps().userXSendsInvitationMailToMember(ownerAlias, email, dstTeam, "member"); + } + } + @Then("^I print all created users in the execution log$") + public void IPrintAllCreatedUsers() { + context.getCommonSteps().printAllCreatedUsers(); + } + + + @When("^Admin of (.*) backend revokes remembered certificate on ACME server$") + public void revokeRememberedCertificate(String backendName) throws CertificateException { + String pem = context.getRememberedCertificate(); + byte [] decoded = Base64.getDecoder().decode(pem + .replaceAll("-----BEGIN CERTIFICATE-----", "") + .replaceAll("-----END CERTIFICATE-----", "") + .replaceAll("\n", "") + .replaceAll(" ", "") + .strip()); + + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + X509Certificate certificate = (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(decoded)); + String serialNumber = "0x" + certificate.getSerialNumber().toString(16); + context.getCommonSteps().revokeCertificate(backendName, serialNumber, certificate.getSerialNumber()); + } + + @When("^Users? (.*) claims? key packages$") + public void claimKeyPackages(String userAliases) { + context.getCommonSteps().claimKeyPackages(userAliases); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CommonIOSSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CommonIOSSteps.java new file mode 100644 index 00000000000..0fd5f267a25 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CommonIOSSteps.java @@ -0,0 +1,627 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.*; +import com.wearezeta.auto.common.backend.Backend; +import com.wearezeta.auto.common.backend.BackendConnections; +import com.wearezeta.auto.common.misc.EphemeralTimeConverter; +import com.wearezeta.auto.common.testservice.models.LegalHoldStatus; +import com.wearezeta.auto.common.imagecomparator.QRCode; +import com.wearezeta.auto.common.log.ZetaLogger; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.common.Lifecycle; +import com.wearezeta.auto.ios.pages.CustomBackendRedirectionPage; +import com.wearezeta.auto.ios.pages.IOSPage; +import com.wire.qa.picklejar.engine.exception.SkipException; +import io.cucumber.java.en.And; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.nio.file.Path; +import java.util.logging.Logger; + +import static org.hamcrest.MatcherAssert.assertThat; + +import org.openqa.selenium.ScreenOrientation; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Duration; +import java.text.ParseException; + +import static org.hamcrest.Matchers.*; + +public class CommonIOSSteps { + + IOSTestContext context; + + public CommonIOSSteps(IOSTestContext context) { + this.context = context; + } + + private static final Logger log = ZetaLogger.getLog(IOSPage.class.getSimpleName()); + + private static final String SAFARI = "com.apple.mobilesafari"; + + static { + System.setProperty("org.apache.commons.logging.Log", "org.apache.commons.logging.impl.SimpleLog"); + System.setProperty("org.apache.commons.logging.simplelog.log.org.apache.http", "warn"); + } + + private CommonSteps getCommonSteps() { + return context.getCommonSteps(); + } + + private IOSPage getCommonPage() { + return context.getPagesCollection().getPage(IOSPage.class); + } + + private CustomBackendRedirectionPage getCustomBackendRedirectionPage() { + return context.getPagesCollection().getPage(CustomBackendRedirectionPage.class); + } + + /** + * Upgrade Wire to the recent version if the old one was previously installed + */ + @Given("^I upgrade Wire to the recent version$") + public void IUpgradeWire() { + getCommonPage().installApp(new File(Lifecycle.getAppPath())); + getCommonPage().activateApp(Lifecycle.getBundleId()); + } + + @Given("^I install the old version of Wire$") + public void installOldWire() { + getCommonPage().installApp(new File(Lifecycle.getOldAppPath())); + getCommonPage().activateApp(Lifecycle.getBundleId()); + } + + @Given("^I point the app to production backend$") + public void IStartOnProductionBackend() { + context.startAppOnProductionBackend(); + } + + @Given("^All other versions of Wire are uninstalled$") + public void IUninstallAllVersionsWire() { + context.uninstallAllVersionsOfWire(); + } + + @Given("^I enroll the simulator for Touch ID$") + public void iEnrollSimulatorTouchID() { + context.enrollSimulatorTouchID(); + } + + /** + * Restarts currently executed Wire instance + */ + @When("^I restart Wire$") + public void IRestartWire() { + final String wireBundleId = Lifecycle.getBundleId(); + getCommonPage().terminateApp(wireBundleId); + getCommonPage().activateApp(wireBundleId); + } + + @When("^I terminate Wire$") + public void ITerminateWire() { + getCommonPage().terminateApp(Lifecycle.getBundleId()); + } + + @Given("^The device is reset before and after the test$") + public void fullReset() { + context.doFullReset(); + } + + // region permissions + + @Given("^I allow microphone access") + public void iAllowMicrophoneAccess() { + context.allowMicrophoneAccess(); + } + + @Given("^I allow camera access") + public void iAllowCameraAccess() { + context.allowCameraAccess(); + } + + @Given("^I allow access to all photos") + public void iAllowAccessToAllPhotos() { + context.allowAccessToAllPhotos(); + } + + // endregion permissions + + @When("^I accept camera access alert on real device$") + public void iAcceptCameraAccessAlert() { + if (context.isRealDevice()) { + getCommonPage().acceptAlert(); + } + } + + @When("^I accept microphone access alert on real device$") + public void iAcceptMicrophoneAccessAlert() { + if (context.isRealDevice()) { + getCommonPage().acceptAlert(); + } + } + + @When("^I accept access to all photos on real device$") + public void iAcceptAccessToAllPhotos() { + if (context.isRealDevice()) { + getCommonPage().acceptAccessToAllPhotos(); + } + } + + @When("^I accept alert( if visible)?$") + public void IAcceptAlert(String mayIgnore) { + if (mayIgnore == null) { + getCommonPage().acceptAlert(); + } else { + if (getCommonPage().acceptAlertIfVisible()) { + log.info("Unexpected alert was present on " + context.getScenario().getName()); + } else { + log.info("Unexpected alert was not present on " + context.getScenario().getName()); + } + } + } + + @When("^I tap Not Now on save password alert$") + public void ITapNotNowPasswordSaveAlert() { + if (getCommonPage().isNotNowOnPasswordPromptVisible()) { + getCommonPage().tapNotNowOnPasswordPrompt(); + } + } + + @When("^I accept notification permission alert if visible$") + public void IAcceptNotificationAlert() { + getCommonPage().acceptNotificationAlertIfVisible(); + } + + + /** + * Tap the corresponding on-screen keyboard button + * + * @param btnName button name + */ + @When("^I tap (Hide|Space|Done|Next) keyboard button$") + public void ITapHideKeyboardBtn(String btnName) { + //sometimes the simulator doesn't show the keyboard up + if (getCommonPage().isKeyboardVisible()) { + switch (btnName.toLowerCase()) { + case "hide": + getCommonPage().tapHideKeyboardButton(); + break; + case "space": + getCommonPage().tapSpaceKeyboardButton(); + break; + case "done": + getCommonPage().tapKeyboardCommitButton(); + break; + case "next": + getCommonPage().tapNextKeyboardButton(); + break; + default: + throw new IllegalArgumentException(String.format("Unknown button name: %s", btnName)); + } + } else{ + log.warning("keyboard should be visible but it's not"); + } + } + + /** + * Closes the app for a certain amount of time in seconds + * + * @param seconds time in seconds to close the app + */ + @When("^I minimize Wire for (\\d+) seconds?$") + public void IMinimizeWire(int seconds) { + getCommonPage().putWireToBackgroundFor(Timedelta.ofSeconds(seconds)); + } + + /** + * Locks screen for a certain amount of time in seconds + * + * @param seconds time in seconds to lock screen + */ + @When("^I lock screen for (\\d+) seconds?$") + public void ILockScreen(int seconds) { + getCommonPage().lockScreen(Timedelta.ofSeconds(seconds)); + } + + @Given("^There (?:is|are) (\\d+) users? where (.*) is me$") + public void thereAreNUsersWhereXIsMe(int count, String myNameAlias) throws Exception { + getCommonSteps().thereAreNPersonalUsersWhereXIsMe(count, myNameAlias); + getCommonSteps().userChangesUserAvatarPicture(myNameAlias); + getCommonSteps().usersSetUniqueUsername(myNameAlias); + } + + /** + * Creates specified number of users and sets user with specified name as + * main user. The user is registered with a email only and has no phone + * number attached + */ + @Given("^There (?:are|is) (\\d+) users? with email address only where (.*) is me$") + public void ThereAreNUsersWhereXIsMeWithoutPhone(int count, String myNameAlias) throws Exception { + throw new RuntimeException("Phone number support was removed"); + } + + @When("^I wait for (\\d+) seconds?$") + public void WaitForTime(int seconds) { + context.startPinging(); + Timedelta.ofSeconds(seconds).sleep(); + context.stopPinging(); + } + + @Deprecated // Please try not to use this step. Replace Myself with explicit placeholders + @Given("^User (.*) is [Mm]e$") + public void UserXIsMe(String nameAlias) { + getCommonSteps().userXIsMe(nameAlias); + } + + @When("^I rotate UI to (landscape|portrait)$") + public void WhenIRotateUILandscape(String orientation) { + orientation = orientation.toUpperCase(); + getCommonPage().rotateScreen(ScreenOrientation.valueOf(orientation)); + Timedelta.ofSeconds(1).sleep(); + } + + /** + * Tap at the corresponding point of the visible viewport + * + * @param percentX 0 <= percentX <= 100 + * @param percentY 0 <= percentY <= 100 + */ + @When("^I tap at (\\d+)%,(\\d+)% of the viewport size") + public void ITapAtPoint(int percentX, int percentY) { + getCommonPage().tapScreenByPercents(percentX, percentY); + } + + @Deprecated // Please use alert title or alert description steps + @Then("^I (do not )?see alert contains text \"(.*)\"$") + public void ISeeAlertContains(String shouldNotBeVisible, String expectedText) { + if (shouldNotBeVisible == null) { + assertThat(String.format("There is no '%s' text on the alert", expectedText), + getCommonPage().isAlertContainsText(expectedText)); + } else { + assertThat(String.format("There is '%s' text on the alert", expectedText), + getCommonPage().isAlertDoesNotContainsText(expectedText)); + } + } + + @Then("^I see alert title contains text \"(.*)\"$") + public void ISeeAlertTitleContains(String expectedText) { + assertThat("Wrong alert title", getCommonPage().getAlertTitle(), containsString(expectedText)); + } + + @Then("^I see alert description contains text \"(.*)\"$") + public void ISeeAlertDescriptionContains(String expectedText) { + assertThat("Wrong alert description", getCommonPage().getAlertDescription(), + containsString(expectedText)); + } + + @And("^I type \"(.*)\" text into the alert input field$") + public void iTypeInAlertInput(String text) { + // Wait until alert visible + text = context.getUsersManager() + .replaceAliasesOccurrences(text, ClientUsersManager.FindBy.PASSWORD_ALIAS); + getCommonPage().typeAlertText(text); + } + + @And("^I tap (.*) button on the alert$") + public void iTapAlertButton(String caption) { + getCommonPage().tapAlertButton(caption); + } + + @And("^I (do not )?see (.*) button on the alert$") + public void iSeeAlertButton(String shouldNotSee, String caption) { + if (shouldNotSee == null) { + assertThat(String.format("The '%s' button is not visible on the alert", caption), getCommonPage().isAlertButtonVisible(caption)); + } else { + assertThat(String.format("The '%s' button is visible on the alert while it should not be", caption), + not(getCommonPage().isAlertButtonVisible(caption))); + } + } + + /** + * Create random file in project.build.directory folder for further usage + * + * @param size file size. Can be float value. Example: 1MB, 2.00KB + * @param name file name without extension + * @param ext file extension + */ + @Given("^I create temporary file (.*) in size with name \"(.*)\" and extension \"(.*)\"$") + public void ICreateTemporaryFile(String size, String name, String ext) { + final String tmpFilesRoot = Config.current().getBuildPath(getClass()); + CommonUtils.createRandomAccessFile(String.format("%s%s%s.%s", tmpFilesRoot, File.separator, name, ext), size); + } + + // Check ZIOS-6570 for more details + private static final String SIMULATOR_VIDEO_MESSAGE_PATH = "/var/tmp/video.mp4"; + + /** + * Prepares the existing video file to be uploaded by iOS simulator + * + * @param name the name of an existing file. The file should be located in tools/img folder + */ + @Given("^I prepare (.*) to be uploaded as a video message$") + public void IPrepareVideoMessage(String name) { + final File srcVideo = new File(Config.current().getVideoPath(getClass()) + File.separator + name); + if (!srcVideo.exists()) { + throw new IllegalArgumentException(String.format("The file %s does not exist or is not accessible", + srcVideo.getAbsolutePath())); + } + final String path = srcVideo.toPath().toString(); + + getCommonPage().pushFile(name, path); + } + + @Given("^I push image with QR code containing \"Image\" to camera roll$") + public void iPushImageWithQRCode() throws IOException { + final Path directory = Files.createTempDirectory("zautomation"); + final String qrcode = "Image"; + final String fileFormat = "png"; + String fileName = String.format("%s.%s", qrcode, fileFormat); + final File tempFile = new File(directory.toAbsolutePath() + File.separator + fileName); + tempFile.deleteOnExit(); + ImageIO.write(QRCode.generateCode(qrcode, Color.BLACK, Color.WHITE, 500, 4), fileFormat, tempFile); + if (!getCommonPage().doesFileExistOnDevice(fileName)) { + log.info(String.format( + "File named %s not found on device. Pushing %s...", + fileName, + tempFile.getAbsolutePath())); + getCommonPage().pushFile(fileName, tempFile.getAbsolutePath()); + } else { + // FIXME: This does not work yet b/c appium does not find the file on the device via the name + log.info("File named %s already found on device. Not pushing another one."); + } + } + + /** + * Clicks the send button on the keyboard + * + * @param canSkip equals to null if this step should throw an error if the button is not available for tapping + */ + @When("^I tap (?:Commit|Return|Send|Enter) button on the keyboard( if visible)?$") + public void ITapCommitButtonOnKeyboard(String canSkip) { + try { + getCommonPage().tapKeyboardCommitButton(); + } catch (IllegalStateException e) { + if (canSkip != null) { + return; + } + throw e; + } + } + + /** + * Minimizes/restores the App + * + * @param action either restore or minimize + * Restore Wire only to be used after app has been put in background + */ + @Given("^I (minimize|restore) Wire$") + public void IMinimizeWire(String action) { + switch (action.toLowerCase()) { + case "minimize": + getCommonPage().pressHomeButton(); + break; + case "restore": + getCommonPage().activateApp(Lifecycle.getBundleId()); + break; + default: + throw new IllegalArgumentException(String.format("Unknown action keyword: '%s'", action)); + } + } + + /** + * Verify visibility of default Map application + */ + @Then("^I see map application is opened$") + public void VerifyMapDefaultApplicationVisibility() { + assertThat("The default map application is not visible", + getCommonPage().isDefaultMapApplicationVisible()); + } + + /** + * Set the content of clipboard content from an existing file or a string + * + * @param source either 'string' or 'file' + * @param data the name of existing text file located in tools/ios/misc/ folder + * The text in the file is expected to be encoded in UTF-8 + * OR + * any non-empty string + */ + @Given("^I load clipboard content from (file|string) \"(.*)\"$") + public void ISetClipboard(String source, String data) throws IOException { + String text = data; + if (source.equalsIgnoreCase("file")) { + final File srcPath = new File(String.format("%s/%s", + Config.current().getMiscResourcesPath(getClass()), data)); + text = new String(Files.readAllBytes(srcPath.toPath()), StandardCharsets.UTF_8); + } + getCommonPage().setClipboard(text); + } + + /** + * Set the content of clipboard content from an existing file or a string + * + * @param source 'okta|active directory|ldap' + */ + @Given("^I load clipboard content with sso code from (okta)$") + public void ISetClipboardWithSSOCode(String source) { + String text; + if (source.equalsIgnoreCase("okta")) { + text = getCommonSteps().getSSOCode(); + } else { + throw new IllegalArgumentException(String.format("Unknown source '%s'", source)); + } + getCommonPage().setClipboard(text); + } + + @When("^Group admin user (.*) deletes conversation (.*)$") + public void deleteConversation(String userToNameAlias, String dstConversationName) { + context.getCommonSteps().userXDeletesConversation(userToNameAlias, dstConversationName); + } + + @Given("^I open Safari with url \"(.*)\"$") + public void IOpenSafariWithURL(String url){ + getCommonPage().activateApp(SAFARI); + getCommonPage().openURL(url); + //getCommonPage().tapConfirmButtonIfVisible(); + } + + @When("^I wait up until (\\d+) seconds until alert is visible$") + public void IWaitForAlertToShow(int seconds){ + assertThat(String.format("Alert has not showed up in '%s' seconds", seconds), + getCommonPage().waitUntilAlertIsVisible(seconds)); + } + + @When("^I open deep link for conversation (.*) that user (.*) has sent me in safari$") + public void iOpenDeepLinkForConversation(String conversationName, String nameAlias) { + String deeplink = getCommonSteps().getDeepLinkForConversation(conversationName, nameAlias); + getCommonPage().activateApp(SAFARI); + getCommonPage().openURL(deeplink); + } + + @When("^I open deep link for profile of user (.*) in safari") + public void iOpenDeepLinkForConversation(String nameAlias) { + String deeplink = getCommonSteps().getDeepLinkForUserProfile(nameAlias); + getCommonPage().openDeepLink(deeplink, SAFARI); + } + + // region legal hold + + @When("^User (.*) (un)?registers legal hold service with team \"(.*)\"$") + public void legalHoldFeatureIsTurnedOnForTeam(String userAlias, String unregister, String teamName) { + if(unregister == null) { + context.getCommonSteps().registerLegalHoldService(userAlias, teamName); + } else { + context.getCommonSteps().unregisterLegalHoldService(userAlias, teamName); + } + } + + @When("^Admin user (.*) sends Legal Hold request for user (.*)$") + public void adminUserXSendsLegalHoldRequest(String adminUserNameAlias, String userNameAlias) { + context.getCommonSteps().adminSendsLegalHoldRequestForUser(adminUserNameAlias, userNameAlias); + } + + @When("^Admin user (.*) turns off Legal Hold for user (.*)$") + public void adminUserXTurnsOffLegalHold(String adminUserNameAlias, String userNameAlias) { + context.getCommonSteps().adminTurnsOffLegalHoldForUser(adminUserNameAlias, userNameAlias); + } + + // end region legal hold + + // region custom backend + + //TODO: Once the app with the new protocol handler becomes old, this step can be removed + @When("^I open default backend via deep link with the old protocol in safari$") + public void iOpenDeepLinkWithOldProtocolForDefaultBackend() { + Backend backend = BackendConnections.getDefault(); + String protocolHandler = "wire"; + + if (backend.getBackendName().contains("column-1")) { + protocolHandler = "wire-bk"; + } + + String deeplink = backend.getDeeplinkForiOS(protocolHandler); + log.fine("deeplink: " + deeplink); + getCommonPage().activateApp(SAFARI); + getCommonPage().openURL(deeplink); + } + + @When("^I open default backend via deep link in safari$") + public void iOpenDeepLinkForDefaultBackend() { + getCommonPage().openDeepLinkForDefault(); + } + + @When("^I open a backend which has my build blacklisted via deep link in safari$") + public void iOpenDeepLinkForBlacklistedBackend() { + String deeplink = "wire://access/?config=https://wire-taco-test.s3.eu-west-1.amazonaws.com/blacklist-current.json"; + getCommonPage().openDeepLink(deeplink, SAFARI); + } + + @When("^I open a backend which has a higher minimum version via deep link in safari$") + public void iOpenDeepLinkForHigherMinimumBackend() { + String deeplink = "wire://access/?config=https://wire-taco-test.s3.eu-west-1.amazonaws.com/blacklist-minimum.json"; + getCommonPage().openDeepLink(deeplink, SAFARI); + } + + @When("^I open (.*) backend deep link in safari$") + public void iOpenDeepLinkForCustomBackend(String backendType) { + Backend backend = BackendConnections.getDefault(); + String protocolHandler = "wire"; + + if (backend.getBackendName().contains("column")) { + protocolHandler = backend.getBackendName().contains("column-1") ? "wire-bk-test" : "wire-c3-test"; + } + + String deeplink = BackendConnections.get(backendType).getDeeplinkForiOS(protocolHandler); + log.info("deeplink: " + deeplink); + getCommonPage().activateApp(SAFARI); + getCommonPage().openURL(deeplink); + } + + @When("^I open invite link url for conversation (.*) created by user (.*) in safari$") + public void iOpenInviteURL(String conversationName, String userName) { + String inviteLink = getCommonSteps().getInviteLinkOfConversation(userName, conversationName); + getCommonPage().activateApp(SAFARI); + getCommonPage().openURL(inviteLink); + } + + // endregion custom backend + + // region Folders + + @When("^User (.*) adds conversation \"(.*)\" to Favorites$") + public void userXAddsConversationToFavorites(String userNameAlias, String conversationName) { + context.getCommonSteps().userXAddsConversationToFavorites(userNameAlias, conversationName); + } + + @When("^User (.*) adds conversation \"(.*)\" to (.*) folder$") + public void userXAddsConversationToFolder(String userNameAlias, String conversationName, String folder) { + context.getCommonSteps().userXAddsConversationToFolder(userNameAlias, conversationName, folder); + } + + // endregion Folders + + // region Build feature flags + + @Given("^SFT calling is enabled for backend$") + public void isSFTEnabled() { + if (!BackendConnections.getDefault().isFeatureSFTEnabled()) { + throw new SkipException("Skip test because backend has SFT disabled"); + } + } + + // endregion + + @When("^User (.*) disables File Sharing for team (.*)$") + public void userOwnerDisablesFileSharingForTeam(String adminUserAlias, String teamName) { + context.getCommonSteps().disableFileSharingFeature(adminUserAlias, teamName); + } + + @Given("^I enable Federation$") + public void iEnableFederation() { + context.enableFederation(); + } + + @Given("^I enable API versioning (\\d+)$") + public void iEnableApiVersion(int version) { + context.enableApiVersioning(version); + } + + @Given("^I enable MLS support$") + public void iEnableMLSSupport() { + context.enableMLSSupport(); + } + + @When("I reset Wire") + public void iResetWire() { + context.getDriver().removeApp(Lifecycle.getBundleId()); + context.getDriver().installApp(Lifecycle.getAppPath()); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ContactsUiPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ContactsUiPageSteps.java new file mode 100644 index 00000000000..6222475399b --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ContactsUiPageSteps.java @@ -0,0 +1,93 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.pages.ContactsUiPage; + +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class ContactsUiPageSteps { + + IOSTestContext context; + + public ContactsUiPageSteps(IOSTestContext context) { + this.context = context; + } + + private ContactsUiPage getContactsUiPage() { + return context.getPagesCollection().getPage(ContactsUiPage.class); + } + + /** + * Verify that ContactsUI page is shown (by verifying search input) + */ + @When("^I see ContactsUI page$") + public void ISeeContactsUIPage() { + assertThat("Search on ContactsUI page is not shown", + getContactsUiPage().isSearchInputVisible()); + assertThat("Invite on ContactsUI page is not shown", + getContactsUiPage().isInviteButtonVisible()); + } + + /** + * Input user name in search field + * + * @param contact username + */ + @When("^I input user name (.*) in search on ContactsUI$") + public void IInputUserNameInSearchOnContactsUI(String contact) { + contact = context.getUsersManager() + .replaceAliasesOccurrences(contact, ClientUsersManager.FindBy.NAME_ALIAS); + getContactsUiPage().inputTextToSearch(contact); + } + + /** + * Verify is user is presented in ContactsUI page + * + * @param shouldNotBeVisible equals to null if the contact should be visible + * @param contact user name + */ + @Then("^I (do not )?see contact (.*) in ContactsUI page list$") + public void ISeeContactInContactsUIList(String shouldNotBeVisible, String contact) { + contact = context.getUsersManager() + .replaceAliasesOccurrences(contact, ClientUsersManager.FindBy.NAME_ALIAS); + if (shouldNotBeVisible == null) { + assertThat( + String.format("User '%s' should be visible in ContactsUI user list", contact), + getContactsUiPage().isContactVisible(contact) + ); + } else { + assertThat( + String.format("User '%s' should not be visible in ContactsUI user list", contact), + getContactsUiPage().isContactInvisible(contact) + ); + } + } + + @When("^I tap Invite Others button on Contacts UI page$") + public void iTapInviteOthersButton() { + getContactsUiPage().tapInviteOthersButton(); + } + + @When("^I tap Back button on Contacts UI page$") + public void iTapBackButton() { + getContactsUiPage().tapBackButton(); + } + + /** + * Click on Open button on ContactsUI next to user name + * + * @param contact user name + */ + @When("^I tap Open button next to user name (.*) on ContactsUI$") + public void IClickOpenButtonNextToUser(String contact) { + contact = context.getUsersManager() + .replaceAliasesOccurrences(contact, ClientUsersManager.FindBy.NAME_ALIAS); + getContactsUiPage().tapOpenButtonNextToUser(contact); + } + +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ConversationActionsPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ConversationActionsPageSteps.java new file mode 100644 index 00000000000..31084fce89f --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ConversationActionsPageSteps.java @@ -0,0 +1,173 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.ActionsSheetPage; +import io.cucumber.java.en.And; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class ConversationActionsPageSteps { + IOSTestContext context; + + public ConversationActionsPageSteps(IOSTestContext context) { + this.context = context; + } + + private ActionsSheetPage getPage() { + return context.getPagesCollection().getPage(ActionsSheetPage.class); + } + + @And("^I (do not )?see (Clear Content…|Clear|Clear and leave) conversation action button$") + public void ISeeXButtonInActionMenuClear(String shouldNotSee, String buttonTitle) { + if (shouldNotSee == null) { + assertThat(String.format("Menu item '%s' should be visible", buttonTitle), + getPage().isItemVisible(buttonTitle)); + } else { + assertThat(String.format("Menu item '%s' should not exist", buttonTitle), + getPage().isItemInvisible(buttonTitle)); + } + } + + @And("^I (do not )?see (Delete Group…|Delete Group) conversation action button$") + public void ISeeXButtonInActionMenuDelete(String shouldNotSee, String buttonTitle) { + if (shouldNotSee == null) { + assertThat(String.format("Menu item '%s' should be visible", buttonTitle), + getPage().isItemVisible(buttonTitle)); + } else { + assertThat(String.format("Menu item '%s' should not exist", buttonTitle), + getPage().isItemInvisible(buttonTitle)); + } + } + + @And("^I (do not )?see (|Block…|Unblock…|Block|Unblock) conversation action button$") + public void ISeeXButtonInActionMenuBlock(String shouldNotSee, String buttonTitle) { + if (shouldNotSee == null) { + assertThat(String.format("Menu item '%s' should be visible", buttonTitle), + getPage().isItemVisible(buttonTitle)); + } else { + assertThat(String.format("Menu item '%s' should not exist", buttonTitle), + getPage().isItemInvisible(buttonTitle)); + } + } + + @And("^I (do not )?see (Remove From Group…|Remove From Group) conversation action button$") + public void ISeeXButtonInActionMenuRemove(String shouldNotSee, String buttonTitle) { + if (shouldNotSee == null) { + assertThat(String.format("Menu item '%s' should be visible", buttonTitle), + getPage().isItemVisible(buttonTitle)); + } else { + assertThat(String.format("Menu item '%s' should not exist", buttonTitle), + getPage().isItemInvisible(buttonTitle)); + } + } + + @And("^I (do not )?see (Leave Group…|Leave) conversation action button$") + public void ISeeXButtonInActionMenuLeave(String shouldNotSee, String buttonTitle) { + if (shouldNotSee == null) { + assertThat(String.format("Menu item '%s' should be visible", buttonTitle), + getPage().isItemVisible(buttonTitle)); + } else { + assertThat(String.format("Menu item '%s' should not exist", buttonTitle), + getPage().isItemInvisible(buttonTitle)); + } + } + + @And("^I (do not )?see (Mute|Unmute) conversation action button$") + public void ISeeXButtonInActionMenuMute(String shouldNotSee, String buttonTitle) { + if (shouldNotSee == null) { + assertThat(String.format("Menu item '%s' should be visible", buttonTitle), + getPage().isItemVisible(buttonTitle)); + } else { + assertThat(String.format("Menu item '%s' should not exist", buttonTitle), + getPage().isItemInvisible(buttonTitle)); + } + } + + @And("^I (do not )?see (Cancel|Connect|Archive|Unarchive|Cancel Request) conversation action button$") + public void ISeeXButtonInActionMenu(String shouldNotSee, String buttonTitle) { + if (shouldNotSee == null) { + assertThat(String.format("Menu item '%s' should be visible", buttonTitle), + getPage().isItemVisible(buttonTitle)); + } else { + assertThat(String.format("Menu item '%s' should not exist", buttonTitle), + getPage().isItemInvisible(buttonTitle)); + } + } + + /** + * Tap the corresponding button to confirm/decline conversation action + * + * @param actionType either `confirm` or `decline` + */ + @Then("^I (confirm|decline) conversation action$") + public void doAction(String actionType) { + if (actionType.equalsIgnoreCase("confirm")) { + getPage().confirm(); + } else { + getPage().decline(); + } + } + + @When("^I tap (Clear Content…|Clear|Clear and leave) conversation action button$") + public void tapClearContentActionButton(String buttonTitle) { getPage().tapMenuItem(buttonTitle); } + + @When("^I tap (Delete Group…|Delete Group) conversation action button$") + public void tapDeletegroupActionButton(String buttonTitle) { getPage().tapMenuItem(buttonTitle); } + + @When("^I tap (Notifications…|Everything|Mentions and Replies|Nothing) conversation action button$") + public void tapNotificationsActionButton(String buttonTitle) { + getPage().tapMenuItem(buttonTitle); + } + + @When("^I tap (Block…|Unblock…|Block|Unblock) conversation action button$") + public void tapBlockActionButton(String buttonTitle) { + getPage().tapMenuItem(buttonTitle); + } + + @When("^I tap Move to… conversation action button$") + public void tapMoveToActionButton() { + getPage().tapMenuItem("Move to…"); + } + + @When("^I tap Remove from \"(.*)\" conversation action button$") + public void tapRemoveFromFolderActionButton(String buttonTitle) { + getPage().tapMenuItem(String.format("Remove from \"%s\"", buttonTitle)); + } + + @When("^I tap (Remove From Group…|Remove From Group|Remove) conversation action button$") + public void tapRemoveFromGroupActionButton(String buttonTitle) { + getPage().tapMenuItem(buttonTitle); + } + + @When("^I tap (Leave Group…|Leave) conversation action button$") + public void tapLeaveActionButton(String buttonTitle) { + getPage().tapMenuItem(buttonTitle); + } + + @When("^I tap (Mute|Unmute) conversation action button$") + public void tapMuteActionButton(String buttonTitle) { + getPage().tapMenuItem(buttonTitle); + } + + @When("^I tap (Add to Favorites|Remove from Favorites) conversation action button$") + public void tapFavoritesActionButton(String buttonTitle) { + getPage().tapMenuItem(buttonTitle); + } + + @When("^I tap (Cancel|Archive|Unarchive|Cancel Request|Revoke Link) conversation action button$") + public void tapCommonActionButton(String buttonTitle) { + getPage().tapMenuItem(buttonTitle); + } + + @Then("^I (do not )?see action sheet contains text \"(.*)\"$") + public void ISeeAlertContains(String shouldNotBeVisible, String expectedText) { + if (shouldNotBeVisible == null) { + assertThat(String.format("There is no '%s' text on the alert", expectedText), + getPage().isActionSheetContainsText(expectedText)); + } else { + assertThat(String.format("There is '%s' text on the alert", expectedText), + getPage().isActionSheetDoesNotContainsText(expectedText)); + } + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ConversationViewPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ConversationViewPageSteps.java new file mode 100644 index 00000000000..19e5ea06901 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ConversationViewPageSteps.java @@ -0,0 +1,1147 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.ImageUtil; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager.FindBy; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.ConversationViewPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static com.wearezeta.auto.ios.common.Pinger.log; +import static org.hamcrest.MatcherAssert.*; +import static org.hamcrest.Matchers.*; + +import java.util.Optional; +import java.awt.image.BufferedImage; + +import static com.wearezeta.auto.common.CommonSteps.DEFAULT_AUTOMATION_MESSAGE; + +public class ConversationViewPageSteps { + + IOSTestContext context; + + public ConversationViewPageSteps(IOSTestContext context) { + this.context = context; + } + + private ConversationViewPage getConversationViewPage() { + return context.getPagesCollection().getPage(ConversationViewPage.class); + } + + @When("^I see conversation view page$") + public void WhenISeePage() { + ISeeTextInput(null); + } + + @When("^I tap on text input$") + public void iTapOnTextInput() { + getConversationViewPage().tapTextInput(); + } + + @When("^I long tap on text input$") + public void iLongTapOnTextInput() { + try { + getConversationViewPage().longTapTextInput(); + } catch (InterruptedException e) { + log.severe(String.format("Caught Interrupted exception: %s", e.getMessage())); + } + } + + @When("^I scroll to the (top|bottom) of the conversation$") + public void ScrollToThe(String where) { + if (where.equals("top")) { + getConversationViewPage().scrollToTheTop(); + } else { + getConversationViewPage().scrollToTheBottom(); + } + } + + @When("^I (do not )?see text input in conversation view$") + public void ISeeTextInput(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Cursor input is not visible", getConversationViewPage().waitForCursorInputVisible()); + } else { + assertThat("Cursor input is visible, but should be hidden", + getConversationViewPage().waitForCursorInputInvisible()); + } + } + + @When("^I type the (default|.*) message$") + public void WhenITypeTheMessage(String msg) { + if (msg.equals("default")) { + getConversationViewPage().typeMessage(DEFAULT_AUTOMATION_MESSAGE); + } else { + msg = context.getUsersManager() + .replaceAliasesOccurrences(msg, FindBy.NAME_ALIAS); + msg = context.getUsersManager() + .replaceAliasesOccurrences(msg, FindBy.UNIQUE_USERNAME_ALIAS); + getConversationViewPage().typeMessage(msg.replaceAll("^\"|\"$", "")); + } + } + + @When("^I type first (\\d+) letters? of name \"(.*)\" in conversation input$") + public void ITypeXLettersIntoSearchInput(int count, String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, FindBy.NAME_ALIAS); + name = context.getUsersManager() + .replaceAliasesOccurrences(name, FindBy.UNIQUE_USERNAME_ALIAS); + + if (name.length() > count) { + getConversationViewPage().typeMessage(name.substring(0, count)); + } else { + throw new IllegalArgumentException(String.format("Name is only %s chars length. Put in step a less value", + name.length())); + } + } + + /** + * Verify whether the particular system message is visible in the conversation view + * + * @param expectedMsg the expected system message. may contyain user name aliases + * @param shouldNotSee equals to null if the message should be visible + */ + @Then("^I (do not )?see \"(.*)\" system message in the conversation view$") + public void ISeeSystemMessage(String shouldNotSee, String expectedMsg) { + expectedMsg = context.getUsersManager() + .replaceAliasesOccurrences(expectedMsg, FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + assertThat(String.format("The expected system message '%s' is not visible in the conversation", + expectedMsg), getConversationViewPage().isSystemMessageVisible(expectedMsg)); + } else { + assertThat(String.format( + "The expected system message '%s' should not be visible in the conversation", + expectedMsg), getConversationViewPage().isSystemMessageInvisible(expectedMsg)); + } + } + + /** + * Verify whether the ping message is visible in the conversation view + * + * @param expectedMsg ping message containing user name or you + * @param shouldNotSee equals to null if the message should be visible + */ + @Then("^I (do not )?see \"(.*)\" ping message in the conversation view$") + public void ISeePingMessage(String shouldNotSee, String expectedMsg) { + expectedMsg = context.getUsersManager() + .replaceAliasesOccurrences(expectedMsg, FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + assertThat(String.format("The expected ping message '%s' is not visible in the conversation", + expectedMsg), getConversationViewPage().isPingMessageVisible(expectedMsg)); + } else { + assertThat(String.format( + "The expected ping message '%s' should not be visible in the conversation", + expectedMsg), getConversationViewPage().isPingMessageInvisible(expectedMsg)); + } + } + + /** + * Type a default message or the given message in conversation view and send it + * + * @param msg the message that is going to be typed + */ + @When("^I type the (default|\".*\") message and send it$") + public void ITypeTheMessageAndSendIt(String msg) { + if (msg.equals("default")) { + getConversationViewPage().typeMessage(DEFAULT_AUTOMATION_MESSAGE, true); + } else { + msg = context.getUsersManager() + .replaceAliasesOccurrences(msg, FindBy.NAME_ALIAS); + getConversationViewPage().typeMessage(msg.replaceAll("^\"|\"$", ""), true); + } + } + + @When("^I tap Send Message button in conversation view$") + public void ITapSendMessageConvoButton() { + getConversationViewPage().tapSendMessageButton(); + } + + @When("^I tap Hourglass button in conversation view$") + public void ITapHourglassConvoButton() { + getConversationViewPage().tapHourglassButton(); + } + + @When("^I tap Collection button in conversation view$") + public void ITapCollectionConvoButton() { + getConversationViewPage().tapCollectionButton(); + } + + /** + * Click open conversation details button in 1:1 dialog + */ + @When("^I open (?:group |\\s?)conversation details$") + public void IOpenConversationDetails() { + getConversationViewPage().openConversationDetails(); + } + + /** + * Wait until text messages are visible in the conversation + * + * @param expectedCount the expected count of messages. Should be equal or greater than zero + * @param isDefault equals to null if presence of any messages are supposed to be verified + */ + @Then("^I see (\\d+) (default )?messages? in the conversation view$") + public void ThenISeeMessageInTheDialog(int expectedCount, String isDefault) { + final Optional expectedMsg = (isDefault == null) ? + Optional.empty() : Optional.of(DEFAULT_AUTOMATION_MESSAGE); + if (expectedCount == 0) { + if (expectedMsg.isPresent()) { + assertThat( + String.format("There are some '%s' messages in the conversation, but zero is expected", + expectedMsg.get()), + getConversationViewPage().waitUntilTextMessageIsNotVisible(expectedMsg.get())); + } else { + assertThat("There are some messages in the conversation, but zero is expected", + getConversationViewPage().waitUntilAllTextMessageAreNotVisible()); + } + } else if (expectedCount >= 1) { + if (expectedMsg.isPresent()) { + assertThat("Unexpected number of specific messages", + getConversationViewPage().numberOfSpecificTextMessagesVisible( + expectedMsg.get(), + expectedCount), + equalTo(expectedCount)); + } else { + assertThat("Unexpected number of messages", + getConversationViewPage().numberOfTextMessagesVisible(expectedCount), + equalTo(expectedCount)); + } + } + } + + @Then("^I see at least one message in the conversation view") + public void iSeeAtLeastMessage() { + assertThat("There seems to be no message in the current view", + getConversationViewPage().waitUntilMessageInConversation()); + } + + @Then("^I see last message in the conversation view is expected message (.*)") + public void iSeeLastMessageIs(String msg) { + msg = context.getUsersManager().replaceAliasesOccurrences(msg, FindBy.EMAIL_ALIAS); + msg = context.getUsersManager().replaceAliasesOccurrences(msg, FindBy.NAME_ALIAS); + assertThat("The last message in the conversation is different from the expected", + getConversationViewPage().getLastTextMessage(), equalTo(msg)); + } + + @Then("^I see last message in the conversation view contains expected message (.*)") + public void iSeeLastMessageContains(String msg) { + msg = context.getUsersManager().replaceAliasesOccurrences(msg, FindBy.EMAIL_ALIAS); + msg = context.getUsersManager().replaceAliasesOccurrences(msg, FindBy.NAME_ALIAS); + assertThat("The last message in the conversation does not contain the expected one", + getConversationViewPage().getLastTextMessage(), containsString(msg)); + } + + @When("^I tap Add Picture button from input tools$") + public void ITapAddPictureButtonByNameFromInputTools() { + getConversationViewPage().tapAddPictureButton(); + } + + @When("^I tap Mention button from input tools$") + public void ITapMentionButtonByNameFromInputTools() { + getConversationViewPage().tapMentionButton(); + } + + @When("^I tap Sketch button from input tools$") + public void ITapSketchButtonByNameFromInputTools() { + getConversationViewPage().tapSketchButton(); + } + + @When("^I tap ellipsis button from input tools$") + public void iTapEllipsisButtonByNameFromInputTools() { + getConversationViewPage().tapEllipsisButton(); + } + + @When("^I tap Ping button from input tools$") + public void ITapPingButtonByNameFromInputTools() { + getConversationViewPage().tapPingButton(); + } + + @When("^I tap File Transfer button from input tools$") + public void ITapFileTransferButtonByNameFromInputTools() { + getConversationViewPage().tapFileTransferButton(); + } + + @When("^I tap Share Location button from input tools$") + public void ITapShareLocationButtonByNameFromInputTools() { + getConversationViewPage().tapShareLocationButton(); + } + + @When("^I tap Video Message button from input tools$") + public void ITapVideoMessageButtonByNameFromInputTools() { + getConversationViewPage().tapVideoMessageButton(); + } + + @When("^I tap Audio Message button from input tools$") + public void ITapAudioMessageButtonByNameFromInputTools() { + getConversationViewPage().tapAudioMessageButton(); + } + + @When("^I long tap Audio Message button from input tools$") + public void ILongTapAudioMessageButtonByNameFromInputTools() { + getConversationViewPage().longTapAudioMessageButton(); + } + + @When("^I long tap Audio Message button for (\\d+) seconds from input tools$") + public void ILongTapForSecondsAudioMessageButtonByNameFromInputTools(int durationSeconds) { + getConversationViewPage().longTapAudioMessageButtonWithDuration(durationSeconds); + } + + @When("^I tap GIF button from input tools$") + public void ITapGIFButtonByNameFromInputTools() { + getConversationViewPage().tapGIFButton(); + } + + @When("^I (do not )?see Audio Message button in input tools palette$") + public void iSeeAudioMessageButton(String shouldNot) { + if (shouldNot == null) { + assertThat("Audio Message button in input tools palette is not visible", + getConversationViewPage().isAudioMessageButtonVisible()); + } else { + assertThat("Audio Message button in input tools palette is visible", + getConversationViewPage().isAudioMessageButtonInvisible()); + } + } + + @When("^I (do not )?see File Transfer button in input tools palette$") + public void iSeeFileTransferButton(String shouldNot) { + if (shouldNot == null) { + assertThat("File Transfer button in input tools palette is not visible", + getConversationViewPage().isFileTransferButtonVisible()); + } else { + assertThat("File Transfer button in input tools palette is visible", + getConversationViewPage().isFileTransferButtonInvisible()); + } + } + + @When("^I (do not )?see Video Message button in input tools palette$") + public void iSeeVideoMessageButton(String shouldNot) { + if (shouldNot == null) { + assertThat("Video Message button in input tools palette is not visible", + getConversationViewPage().isVideoMessageButtonVisible()); + } else { + assertThat("Video Message button in input tools palette is visible", + getConversationViewPage().isVideoMessageButtonInvisible()); + } + } + + @When("^I (do not )?see phone gallery button in a draw sketch view$") + public void iSeeGalleryon(String shouldNot) { + if (shouldNot == null) { + assertThat("Photo gallery button in a draw sketch view is not visible", + getConversationViewPage().isPhotoGalleryButtonVisible()); + } else { + assertThat("Photo gallery button in a draw sketch view is visible", + getConversationViewPage().isPhotoGalleryButtonInvisible()); + } + } + + @When("^I (do not )?see Sketch button in input tools palette$") + public void iSeeSketchButton(String shouldNot) { + if (shouldNot == null) { + assertThat("Sketch button in input tools palette is not visible", + getConversationViewPage().isSketchButtonVisible()); + } else { + assertThat("Sketch button in input tools palette is visible", + getConversationViewPage().isSketchButtonInvisible()); + } + } + + @When("^I (do not )?see Giphy button in input tools palette$") + public void iSeeGiphyButton(String shouldNot) { + if (shouldNot == null) { + assertThat("Giphy button in input tools palette is not visible", + getConversationViewPage().isGiphyButtonVisible()); + } else { + assertThat("Giphy button in input tools palette is visible", + getConversationViewPage().isGiphyButtonInvisible()); + } + } + + @When("^I (do not )?see degradation alert contains text New Device$") + public void iSeeDegradationAlert(String shouldNot) { + if (shouldNot == null) { + assertThat("Degradation alert in a call is not visible", + getConversationViewPage().isDegradationAlertVisible()); + } else { + assertThat("Degradation alert in a call is visible", + getConversationViewPage().isDegradationAlertInvisible()); + } + } + + @When("^I (do not )?see Add Picture button in input tools palette$") + public void iSeeAddPictureButton(String shouldNot) { + if (shouldNot == null) { + assertThat("Add Picture button in input tools palette is not visible", + getConversationViewPage().isAddPictureButtonVisible()); + } else { + assertThat("Add Picture button in input tools palette is visible", + getConversationViewPage().isAddPictureButtonInvisible()); + } + } + + @When("^I (do not )?see Ping button in input tools palette$") + public void iSeePingButton(String shouldNot) { + if (shouldNot == null) { + assertThat("Ping button in input tools palette is not visible", + getConversationViewPage().isPingButtonVisible()); + } else { + assertThat("Ping button in input tools palette is visible", + getConversationViewPage().isPingButtonInvisible()); + } + } + + @When("^I (do not )?see Mention button in input tools palette$") + public void iSeeMentionButton(String shouldNot) { + if (shouldNot == null) { + assertThat("Mention button in input tools palette is not visible", + getConversationViewPage().isMentionButtonVisible()); + } else { + assertThat("Mention button in input tools palette is visible", + getConversationViewPage().isMentionButtonInvisible()); + } + } + + @When("^I (do not )?see Share Location button in input tools palette$") + public void iSeeShareLocationButton(String shouldNot) { + if (shouldNot == null) { + assertThat("Share Location button in input tools palette is not visible", + getConversationViewPage().isShareLocationButtonVisible()); + } else { + assertThat("Share Location button in input tools palette is visible", + getConversationViewPage().isShareLocationButtonInvisible()); + } + } + + @When("^I tap Audio Call button$") + public void ITapAudioCallButton() { + getConversationViewPage().tapAudioButton(); + } + + @When(("I tap call button on start call alert")) + public void ITapCallButton() { + getConversationViewPage().tapStartCallButton(); + } + + @When("^I tap Video Call button$") + public void ITapVideoCallButton() { + getConversationViewPage().tapVideoCallButton(); + } + + @When("^I tap call anyway on degradation alert$") + public void ITapCallAnywayButton() { + getConversationViewPage().tapCallAnywayButton(); + } + + @When("^I tap cancel button on degradation alert$") + public void ITapCancelButton() { + getConversationViewPage().tapCancelButton(); + } + + @Then("^I see (\\d+) photos? in the conversation view$") + public void ISeeNewPhotoInTheDialog(int expectedCount) { + if (expectedCount == 0) { + assertThat("No images are expected to be visible in the conversations", + getConversationViewPage().areNoImagesVisible()); + } else { + assertThat("Unexpected number of images", + getConversationViewPage().numberOfImagesVisible(expectedCount), + equalTo(expectedCount)); + } + } + + @Then("^I see (\\d+) video? files in the conversation view$") + public void ISeeNewVideoInTheDialog(int expectedCount) { + if (expectedCount == 0) { + assertThat("No Video files are expected to be visible in the conversations", + getConversationViewPage().areNoVideoFilesVisible()); + } else { + assertThat("Unexpected number of video files", + getConversationViewPage().numberOfVideoFiles(expectedCount), + equalTo(expectedCount)); + } + } + + @Then("^I see (\\d+) file? transfer placeholder in the conversation view$") + public void ISeeNewFileInTheDialog(int expectedCount) { + if (expectedCount == 0) { + assertThat("File transfer placeholder is visible in the conversations", + getConversationViewPage().isFileTransferTopLabelInvisible()); + } else { + assertThat("Unexpected number of file placeholders", + getConversationViewPage().areXFilesVisible(expectedCount), + equalTo(expectedCount)); + } + } + + @Then("^I see (\\d+) placeholder photos? in the conversation view$") + public void iSeePlaceholderPhoto(int expectedCount) { + if (expectedCount == 0) { + assertThat("No placeholder images are expected to be visible in the conversations", + getConversationViewPage().areNoPlaceholderImagesVisible()); + } else { + assertThat("Unexpected number of placeholder images", + getConversationViewPage().numberOfPlaceholderImages(expectedCount), + equalTo(expectedCount)); + } + } + + @Then("^I (do not )?see a placeholder file in the conversation view$") + public void iSeePlaceholderFile(String doNot) { + if (doNot == null) { + assertThat( + "Placeholder File is expected to be visible in the conversations", + getConversationViewPage().isPlaceholderFileVisible()); + } else { + assertThat( + "Placeholder File is expected to be invisible in the conversations", + getConversationViewPage().isPlaceholderFileInvisible()); + } + } + + @When("^I long tap on restricted file transfer placeholder in conversation view$") + public void iLongTapPlaceholderFile() { + getConversationViewPage().longTapPlaceholderFile(); + } + + @Then("^I see text (.*) displayed on placeholder photo$") + public void iSeeTextOnPlaceholderPhoto(String expectedText) { + assertThat( + String.format("Text %s is not displayed on the placeholder image", expectedText), + getConversationViewPage().getPlaceholderImageText(), equalTo(expectedText)); + } + + @When("^I navigate back to conversations list") + public void INavigateToConversationsList() { + getConversationViewPage().returnToConversationsList(); + } + + @Then("^I (do not )?see TYPE A MESSAGE input placeholder text$") + public void ISeeStandardInputPlaceholderText(String shouldNotBeVisible) { + boolean result; + if (shouldNotBeVisible == null) { + result = getConversationViewPage().isPlaceholderStandardTextVisible(); + } else { + result = getConversationViewPage().isPlaceholderStandardTextInvisible(); + } + assertThat( + String.format("TYPE A MESSAGE placeholder text should be %s", (shouldNotBeVisible == null) ? "visible" : "not visible"), + result + ); + } + + @When("^I see the conversation with (.*) is opened$") + public void ISeeConversationWith(String participantNameAlias) { + participantNameAlias = context.getUsersManager() + .replaceAliasesOccurrences(participantNameAlias, ClientUsersManager.FindBy.NAME_ALIAS); + + assertThat( + String.format("User '%s' are not displayed on Upper Toolbar", participantNameAlias), + getConversationViewPage().isUpperToolbarContainNames(participantNameAlias) + ); + } + + /** + * Verify that all buttons in toolbar are visible or not + * + * @param shouldNotBeVisible equals to null if the toolbar should be visible + */ + @When("^I (do not )?see conversation tools buttons$") + public void ISeeOnlyPeopleButtonRestNotShown(String shouldNotBeVisible) { + if (shouldNotBeVisible == null) { + assertThat("Some of input tools buttons are not visible", + getConversationViewPage().areInputToolsVisible()); + } else { + assertThat("Some of input tools buttons are still visible", + getConversationViewPage().areInputToolsInvisible()); + } + } + + /** + * Verifies amount of messages in conversation + * + * @param expectedCount expected number of messages + */ + @When("^I see (\\d+) conversation (?:entries|entry)$") + public void ISeeXConvoEntries(int expectedCount) { + assertThat("The expected count of conversation entries is not equal to the actual count", + getConversationViewPage().getNumberOfMessageEntries(), equalTo(expectedCount)); + } + + /** + * Select the corresponding item from the modal menu, which appears after Delete badge is tapped + * + * @param name one of possible item names + */ + @When("^I select (Delete for Me|Delete for Everyone|Cancel) item from Delete menu$") + public void ISelectDeleteMenuItem(String name) { + getConversationViewPage().selectDeleteMenuItem(name); + } + + /** + * Clear conversation text input + */ + @When("^I clear conversation text input$") + public void IClearConversationTextInput() { + getConversationViewPage().clearTextInput(); + } + + /** + * Verify that conversation is scrolled to the end by verifying that plus + * button and text input is visible + */ + @When("^I see conversation is scrolled to the end$") + public void ISeeConversationIsScrolledToEnd() { + assertThat("The input field state looks incorrect", + getConversationViewPage().waitForCursorInputVisible()); + } + + /** + * Verify whether shield icon is visible next to convo input field + * + * @param shouldNotSee equals to null if the shield should be visible + */ + @Then("^I (do not )?see shield icon in the conversation view$") + public void ISeeShieldIcon(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The shield icon is not visible", + getConversationViewPage().isShieldIconVisible()); + } else { + assertThat("The shield icon is visible, but should be hidden", + getConversationViewPage().isShieldIconInvisible()); + } + } + + /** + * Verify that the "You called" notification is shown + */ + @Then("^I see \"You called\" notification in conversation view$") + public void ISeeYouCalledNotification() { + assertThat("\"You called\" message is not shown", getConversationViewPage(). + isYouCalledMessageVisible()); + } + + private static final double MAX_SIMILARITY_THRESHOLD = 0.985; + + /** + * Verify whether the particular picture is animated + */ + @Then("^I see the picture in the conversation view is animated$") + public void ISeePictureIsAnimated() { + final int maxFrames = 4; + final double avgThreshold = ImageUtil.getAnimationThreshold( + getConversationViewPage()::getRecentPictureScreenshot, maxFrames, Timedelta.ofMillis(0)); + assertThat(String.format("The picture in the conversation view seems to be static (%.2f >= %.2f)", + avgThreshold, MAX_SIMILARITY_THRESHOLD), avgThreshold < MAX_SIMILARITY_THRESHOLD); + } + + @When("^I tap file transfer option for 80 MB file") + public void iTapFileTransferOptionFor80MBFile() { + getConversationViewPage().tapFileTransferOptionFor80MBFile(); + } + + @When("^I tap file transfer option to send CountryCodes.plist file$") + public void iTapFileTransferOptionForCountryCodes() { + getConversationViewPage().tapFileTransferOptionForCountryCodesFile(); + } + + /** + * Verify file transfer placeholder visibility + * + * @param shouldNotBeVisible equals to null if the placeholder should be visible + */ + @When("^I (do not )?see file transfer placeholder$") + public void ISeeFileTransferPlaceHolder(String shouldNotBeVisible) { + if (shouldNotBeVisible == null) { + assertThat("File transfer placeholder is not visible", + getConversationViewPage().isFileTransferTopLabelVisible() && + getConversationViewPage().isFileTransferBottomLabelVisible()); + } else { + assertThat("File transfer placeholder is visible, but should be hidden", + getConversationViewPage().isFileTransferTopLabelInvisible()); + } + } + + /** + * Tap on file transfer action button to download/preview file + */ + @When("^I tap file transfer action button until File Actions menu is visible$") + public void ITapFileTransferActionButton() { + getConversationViewPage().tapFileTransferActionButton(); + } + + /** + * Verify whether File Transfer placeholder is visible in the conversation view + */ + @Then("^I wait up to (\\d+) seconds? until the file (.*) with size (.*) is ready for download from conversation view$") + public void IWaitUntilDownloadFinished(int timeoutSeconds, String expectedFilename, String expectedSize) { + assertThat(String.format( + "Cannot detect the Download Finished placeholder for a file '%s' in the conversation view after %s seconds", + expectedFilename, timeoutSeconds), + getConversationViewPage().waitUntilDownloadReadyPlaceholderVisible(expectedFilename, expectedSize, + Timedelta.ofSeconds(timeoutSeconds))); + } + + @When("^I tap default message in conversation view$") + public void iTapOnDefaultTextMessage() { + getConversationViewPage().tapMessageByText(DEFAULT_AUTOMATION_MESSAGE); + } + + @When("^I long tap default message in conversation view$") + public void iLongTapOnDefaultTextMessage() { + getConversationViewPage().longTapMessageByText(DEFAULT_AUTOMATION_MESSAGE); + } + + @When("^I long tap \"(.*)\" message in conversation view$") + public void iLongTapTextMessage(String msg) { + msg = context.getUsersManager().replaceAliasesOccurrences(msg, ClientUsersManager.FindBy.NAME_ALIAS); + getConversationViewPage().longTapMessageByText(msg); + } + + /** + * Tap pointed control button + */ + @When("^I tap Send record control button$") + public void ITapSendRecordControlButton() { + getConversationViewPage().tapSendRecordControlButton(); + } + + @When("^I tap on image in conversation view$") + public void iTapImageInConversationView() { + getConversationViewPage().tapImageInConversation(); + } + + @When("^I long tap on image in conversation view$") + public void iLongTapImageInConversationView() { + getConversationViewPage().longTapImageInConversation(); + } + + @When("^I long tap on file transfer placeholder in conversation view$") + public void ILongTapFileTransferPlaceholder() { + getConversationViewPage().longTapFileTransferPlaceholder(); + } + + @When("^I tap on file transfer placeholder in conversation view$") + public void ITapFileTransferPlaceholder() { + getConversationViewPage().tapFileTransferPlaceholder(); + } + + @When("^I tap Play audio message button$") + public void iTapAudioMessagePlayButton() { + getConversationViewPage().tapAudioMessagePlayButton(); + } + + @When("^I long tap on audio message placeholder in conversation view$") + public void ILongTapAudioMessagePlaceholder() { + getConversationViewPage().longTapPlayAudioMessageButton(); + } + + @When("^I tap on video message in conversation view$") + public void ITapVideoMessage() { + getConversationViewPage().tapVideoMessage(); + } + + @When("^I long tap on video message in conversation view$") + public void ILongTapVideoMessage() { + getConversationViewPage().longTapVideoMessage(); + } + + @When("^I tap on location map in conversation view$") + public void ITapLocationMessage() { + getConversationViewPage().tapLocationMap(); + } + + @When("^I long tap on link preview in conversation view$") + public void ILongTapLinkPreview() { + getConversationViewPage().longTapLinkPreview(); + } + + @When("^I tap on Youtube preview in conversation view$") + public void ITapYoutubePreviewLink() { + getConversationViewPage().singleTapYoutubePreview(); + } + + /** + * Verify play/pause button state in audio message placeholder + * + * @param buttonState play or pause + */ + @Then("^I see state of button on audio message placeholder is (play|Play)$") + public void ISeeAudioMessageControlButtonStateIs(String buttonState) { + boolean isStateCorrect = getConversationViewPage().isPlaceholderAudioMessageButtonState(buttonState); + assertThat(String.format("Wrong button state. The expected state is '%s'", buttonState), + isStateCorrect); + } + + @Then("^I see state of button on audio message placeholder is Pause$") + public void iSeeAudioMessageControlButtonStatePause() { + assertThat("Wrong button state", + getConversationViewPage().isAudioMessagePauseButtonVisible()); + } + + @Then("^I (do not )?see video message container in the conversation view$") + public void ISeeVideoMessageContainer(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("video message container is not visible", + getConversationViewPage().isVideoMessageVisible()); + } else { + assertThat("video message container is visible, but should be hidden", + getConversationViewPage().isVideoMessageInvisible()); + } + } + + @Then("^I (do not )?see audio message container in the conversation view$") + public void ISeeAudioMessageContainer(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("audio message container is not visible", + getConversationViewPage().isAudioMessageVisible()); + } else { + assertThat("audio message container is visible, but should be hidden", + getConversationViewPage().isAudioMessageInvisible()); + } + } + + @Then("^I (do not )?see location map container in the conversation view$") + public void ISeeLocationMapContainer(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("location map container is not visible", + getConversationViewPage().isLocationMapVisible()); + } else { + assertThat("location map container is visible, but should be hidden", + getConversationViewPage().isLocationMapInvisible()); + } + } + + @Then("^I (do not )?see link preview container in the conversation view$") + public void ISeeLinkPreviewContainer(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Link preview container is not visible", + getConversationViewPage().isLinkPreviewVisible()); + } else { + assertThat("Link preview container is visible, but should be hidden", + getConversationViewPage().isLinkPreviewInvisible()); + } + } + + /** + * Verify link preview image visibility + * + * @param shouldNotBeVisible equals to null if the placeholder should be visible + */ + @When("^I (do not )?see link preview image in the conversation view$") + public void ISeeLinkPreviewImage(String shouldNotBeVisible) { + if (shouldNotBeVisible == null) { + assertThat("Link preview image is not visible", + getConversationViewPage().isLinkPreviewImageVisible()); + } else { + assertThat("Link preview image is visible, but should be hidden", + getConversationViewPage().isLinkPreviewImageInvisible()); + } + } + + @When("^I tap Confirm button on Edit control") + public void ITapConfirmEditControlButton() { + getConversationViewPage().tapConfirmEditControlButton(); + } + + @When("^I tap Cancel button on Edit control") + public void ITapCancelEditControlButton() { + getConversationViewPage().tapCancelEditControlButton(); + } + + private static final Timedelta LIKE_ICON_STATE_CHANGE_TIMEOUT = Timedelta.ofSeconds(7); //seconds + private static final double LIKE_ICON_MIN_SIMILARITY = 0.9999; + + /** + * Store the current state of Like icon + */ + @When("^I remember the state of (?:Like|Unlike) icon in the conversation$") + public void IRememberLikeIconState() throws Exception { + context.setLikeIconState(() -> getConversationViewPage().getLikeIconState()); + } + + /** + * Verify whether the current state of Like icon differs from the previously remembered one + * + * @param shouldNotChange equals to null if the state should be changed + */ + @Then("^I see the state of (?:Like|Unlike) icon is (not )?changed in the conversation$") + public void IVerifyLikeIconState(String shouldNotChange) throws Exception { + boolean condition; + if (shouldNotChange == null) { + condition = context.getLikeIconState().isChanged(LIKE_ICON_STATE_CHANGE_TIMEOUT, LIKE_ICON_MIN_SIMILARITY); + } else { + condition = context.getLikeIconState().isNotChanged(LIKE_ICON_STATE_CHANGE_TIMEOUT, LIKE_ICON_MIN_SIMILARITY); + } + assertThat(String.format("Like icon state is expected %s in %s seconds", + (shouldNotChange == null) ? "to be changed" : "to be not changed", LIKE_ICON_STATE_CHANGE_TIMEOUT), + condition); + } + + /** + * Tap Like/Unlike icon in the conversation + */ + @When("^I tap (?:Like|Unlike) icon in the conversation$") + public void ITapLikeIcon() { + getConversationViewPage().tapLikeIcon(); + } + + /** + * Verify visibility of the Like/Unlike icon + * + * @param shouldNotSee eqauls to null if the icon should be visible + */ + @Then("^I (do not )?see (?:Like|Unlike) icon in the conversation$") + public void ISeeLikeIcon(String shouldNotSee) { + boolean condition; + if (shouldNotSee == null) { + condition = getConversationViewPage().isLikeIconVisible(); + } else { + condition = getConversationViewPage().isLikeIconInvisible(); + } + assertThat(String.format("The Like/Unlike icon is expected to be %s", + (shouldNotSee == null) ? "visible" : "invisible"), condition); + } + + /** + * Tap the toolbox of the recent message to open likers list + */ + @When("^I tap toolbox of the recent message$") + public void ITapMessageToolbox() { + getConversationViewPage().tapRecentMessageToolbox(); + } + + /** + * Tap the recent media container to show/hide like icon + * + * @param pWidth destination cell X tap point (in percent 0-100) + * @param pHeight destination cell Y tap point (in percent 0-100) + */ + @When("^I tap at (\\d+)% of width and (\\d+)% of height of the recent message$") + public void ITapAtContainerCorner(int pWidth, int pHeight) { + getConversationViewPage().tapAtRecentMessage(pWidth, pHeight); + } + + @When("^I tap at deep link message$") + public void ITapAtDeepLinkMessage() { + getConversationViewPage().tapAtDeepLinkMessage(); + } + + /** + * Verify whether the particular text is present or not on message toolbox + * + * @param shouldNotSee equals to null if the text should be visible + * @param expectedText part of the text to verify for presence + */ + @Then("^I (do not )?see \"(.*)\"( button)? on the message toolbox in conversation view$") + public void ISeeTextOnToolbox(String shouldNotSee, String expectedText, String button) { + expectedText = context.getUsersManager() + .replaceAliasesOccurrences(expectedText, FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + if (button == null) { + assertThat( + String.format("The expected '%s' text is not visible on the message toolbox", expectedText), + getConversationViewPage().isMessageToolboxTextVisible(expectedText) + ); + } else { + assertThat( + String.format("The expected '%s' text is not visible on the message toolbox button", expectedText), + getConversationViewPage().isMessageToolboxButtonVisible(expectedText)); + } + } else { + if (button == null) { + assertThat( + String.format("The expected '%s' text should not be visible on the message toolbox", expectedText), + getConversationViewPage().isMessageToolboxTextInvisible(expectedText) + ); + } else { + assertThat( + String.format("The expected '%s' text should not be visible on the message toolbox button", expectedText), + getConversationViewPage().isMessageToolboxButtonInvisible(expectedText)); + } + } + } + + /** + * Set ephemeral messages timer to a corresponding value + * + * @param value one of available timer values + */ + @When("^I set self deleting message expiration timer to (Off|10 seconds|5 minutes) on conversation view$") + public void ISetExpirationTimer(String value) { + getConversationViewPage().setMessageExpirationTimer(value); + } + + @Then("^I (do not )?see Has Guests banner in conversation view$") + public void ISeeBannerHasGuests(String shouldNotSee) { + String bannerType = "Has Guests"; + if (shouldNotSee == null) { + assertThat("Has Guests banner is expected to be visible", + getConversationViewPage().isConversationHasGuestsVisible()); + } else { + assertThat(String.format("'Has %s' banner is not expected to be visible", bannerType), + getConversationViewPage().isConversationHasGuestsInvisible()); + } + } + + /** + * Check if the last message in the conversation view contains a reply + * + * @param shouldNotSee there should be no reply visible if this string is not null + */ + @Then("^I (do not )?see the last message in conversation view contains a reply$") + public void iSeeTheLastMessageInConversationViewContainsAReply(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The conversation view does not contain a reply", + getConversationViewPage().isReplyVisible()); + } else { + assertThat("The quote should not be visible in input field", + getConversationViewPage().isReplyInvisible()); + } + } + + @Then("^I (do not )?see the input field$") + public void IDoNotSeeTheInputField(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The input field is not visible", + getConversationViewPage().isInputFieldVisible()); + } else { + assertThat("The input field should not be visible", + getConversationViewPage().isInputFieldInvisible()); + } + } + + /** + * Check if the message that is being replied to is of a certain type + * + * @param expectedElement the expected type of the message that is quoted + */ + @Then("^I see that I'm replying to an? (Image|Video|Audio|File) message$") + public void iSeePictureInQuoteAboveTextInputField(String expectedElement) { + assertThat(String.format("The input field quote does not contain an '%s' message", + expectedElement), getConversationViewPage().isInputFieldQuoteOfTypeVisible(expectedElement)); + } + + @Then("^I see reply to quoted Image in conversation view$") + public void iSeeImageReplyInConversationView() { + assertThat("Image in reply is NOT visible", + getConversationViewPage().isQuotedImageVisible()); + } + + /** + * Check if the message that has been quoted contains text + * + * @param expectedMessage the expected text of the quoted message + */ + @Then("^I see the quoted message contains text \"(.*)\"$") + public void iSeeReplyTextInQuotedMessage(String expectedMessage) { + assertThat("The text '%s' is not present in the quoted message", + getConversationViewPage().isQuotedMessageVisible(expectedMessage)); + } + + /** + * Checks for the number of reply cells visible in conversation view + * + * @param numberOfReplies the number of replies that should be in conversation view + */ + @Then("^I see (\\d+) (?:reply|replies) in the conversation view$") + public void iSeeTheNumberOfReplies(int numberOfReplies) { + assertThat(String.format("The number of replies in conversation view is not '%s'", numberOfReplies), + getConversationViewPage().isNumberOfReplyCellsVisible(numberOfReplies)); + } + + /** + * check if the recent message is seen by x amount of users + * NOTE: does not work when the time of reading is shown + * + * @param persons the amount of users + */ + @Then("^I see that recent message is seen by (\\d+) persons?$") + public void iSeeThatRecentMessageIsSeenByPerson(int persons) { + assertThat(String.format("The message is not seen by '%s' persons", persons), + getConversationViewPage().isMessageDeliveryStatusTextVisible(Integer.toString(persons))); + } + + /** + * checks if text is visible in the message details part of the message toolbox + * + * @param doNot wether or not it should be visible + * @param status the text that should or should not be visible in the details part of the message toolbox + */ + @Then("^I (do not )?see message details (.*) in message toolbox$") + public void iDoNotSeeTheMessageDetailsInToolbox(String doNot, String status) { + if (doNot == null) { + assertThat(String.format("The delivery status does not contain '%s'", status), getConversationViewPage().isMessageToolboxDetailsTextVisible(status)); + } else { + assertThat(String.format("The delivery status does contain '%s' while it should not", status), getConversationViewPage().isMessageToolboxDetailsTextInvisible(status)); + } + } + + /** + * checks is the delivery status is visible in the message toolbox + * + * @param doNot wether or not it should be visible + */ + @Then("^I (do not )?see the delivery status in message toolbox$") + public void iDoNotSeeTheDeliveryStatusInMessageToolbox(String doNot) { + if (doNot == null) { + assertThat("The delivery status is not visible", getConversationViewPage().isMessageDeliveryStatusVisible()); + } else { + assertThat("The delivery status is visible while it should not be", getConversationViewPage().isMessageDeliveryStatusInvisible()); + } + } + + /** + * I see the legal hold indicator next to self title in Conversation View + * + * @param shouldNotSee equals to null if the element should be visible + */ + @Then("^I (do not )?see legal hold indicator next to conversation title in conversation view$") + public void iSeeLegalHoldIndicatorNextToConversationTitle(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The legal hold indicator is not visible next to conversation title in conversation view", + getConversationViewPage().isLegalHoldIndicatorVisible()); + } else { + assertThat("The legal hold indicator is visible next to conversation title in conversation view while it should not be", + getConversationViewPage().isLegalHoldIndicatorInvisible()); + } + } + + @When("^I see an image with QR code \"(.*)\" in the conversation view$") + public void iSeeImageInConversation(String qrCode) { + BufferedImage actualImage = getConversationViewPage().getRecentPictureScreenshot().get(); + context.addAdditionalScreenshots(actualImage); + + assertThat("Could not find correct QR code", + getConversationViewPage().getQRCodeFromPicture(), + hasItem(qrCode)); + } + + @Then("^I see (.*) reaction in the conversation view$") + public void ISeeReactionX(String reaction) { + assertThat(String.format("Reaction %s is not visible", reaction), getConversationViewPage().isReactionVisible(reaction)); + } + + @Then("^I do not see (.*) reaction in the conversation view$") + public void IDoNotSeeReactionX(String reaction) { + assertThat(String.format("Reaction %s is visible", reaction), getConversationViewPage().isReactionInvisible(reaction)); + } + + + @Then("^I see User (.*) will get your message later in conversation view$") + public void iSeeUserWillGetYourMessageLater(String usernameAlias) { + String name = context.getUsersManager() + .replaceAliasesOccurrences(usernameAlias, FindBy.NAME_ALIAS); + assertThat(String.format("User %s will get your message later is not visible", name), getConversationViewPage().isUserWillGetYourMessageLaterVisible(name)); + } + + @Then("^I tap on Learn more link on delayed message in conversation view$") + public void ITapOnLearnMoreLinkOnDelayedMessage() { + getConversationViewPage().iTapOnLearnMoreLinkOnDelayedMessage(); + } + + @Then("^I do not see Enterprise Upgrade alert$") + public void IDoNotSeeEnterpriseUpgradeAlert() { + assertThat(getConversationViewPage().enterpriseUpgradeAlertPresent(), is(false)); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ConversationsListPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ConversationsListPageSteps.java new file mode 100644 index 00000000000..33a667a719c --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ConversationsListPageSteps.java @@ -0,0 +1,364 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.CommonUtils; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager.FindBy; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.ConversationsListPage; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; +import java.util.regex.Pattern; + +public class ConversationsListPageSteps { + + IOSTestContext context; + + public ConversationsListPageSteps(IOSTestContext context) { + this.context = context; + } + + private ConversationsListPage getConversationsListPage() { + return context.getPagesCollection() + .getPage(ConversationsListPage.class); + } + + @Given("^I (do not )?see conversations list$") + public void GivenISeeConversationsList(String doNot) { + if (doNot == null) { + assertThat("Conversations list is not visible after the timeout", + getConversationsListPage().isVisible()); + } else { + assertThat("Conversations list is visible while it should not be", + getConversationsListPage().isInvisible()); + } + } + + /** + * Open the corresponding conversation by tapping its name in the conversations list + * + * @param name conversation name/alias + */ + @Given("^I open (?:group |single |1:1 |\\s?)conversation \"(.*)\" in conversation list") + public void IOpenRecentConversation(String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, FindBy.NAME_ALIAS); + getConversationsListPage().tapConversationItemRecentList(name); + } + + @When("I long tap conversation '(.*)' in conversation list") + public void iLongTapConversationVInConversationList(String conversationName) { + getConversationsListPage().longTapConversationItemRecentList(conversationName); + } + + @When("I long tap alias conversation '(.*)' in conversation list") + public void iLongTapAliasConversationVInConversationList(String alias) { + String name = context.getUsersManager().findUserByNameOrNameAlias(alias).getName(); + getConversationsListPage().longTapConversationItemRecentList(name); + } + + @When("I long tap 1:1 conversation '(.*)' in conversation list") + public void iLongTap11ConversationVInConversationList(String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, FindBy.NAME_ALIAS); + getConversationsListPage().longTapConversationItemRecentList(name); + } + + private final static Timedelta CONVO_LIST_UPDATE_TIMEOUT = Timedelta.ofSeconds(10); + + /** + * Verify whether the first items in conversations list is the given item + * + * @param convoName conversation name + */ + @Then("^I see the name of the first conversation is (.*)") + public void ISeeUserNameFirstInContactList(String convoName) { + final String name = context.getUsersManager() + .replaceAliasesOccurrences(convoName, FindBy.NAME_ALIAS); + assertThat(String.format("The conversation '%s' is not the first conversation in the list after " + + "%s timeout", name, CONVO_LIST_UPDATE_TIMEOUT), + CommonUtils.waitUntilTrue(CONVO_LIST_UPDATE_TIMEOUT, Timedelta.ofSeconds(1), + () -> getConversationsListPage().isFirstConversationName(name)) + ); + } + + @Then("^I see conversation (.*) in conversations list$") + public void iSeeUserInContactList(String value) { + value = context.getUsersManager().replaceAliasesOccurrences(value, FindBy.NAME_ALIAS); + assertThat(String.format("The conversation '%s' is not visible in the conversation list", + value), getConversationsListPage().isConversationInList(value)); + } + + @Then("^I do not see conversation (.*) in conversations list$") + public void iDoNotSeeUserInContactList(String value) { + value = context.getUsersManager().replaceAliasesOccurrences(value, FindBy.NAME_ALIAS); + assertThat( + String.format("The conversation '%s' is visible in the conversation list, but should be hidden", + value), getConversationsListPage().isConversationNotInList(value)); + } + + @When("^I swipe right on conversation (.*) in Conversations view") + public void ISwipeRightOnConversationInConvView(String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, FindBy.NAME_ALIAS); + getConversationsListPage().swipeRightOnConversation(name); + } + + /** + * Verify visibility of EVERYTHING ARCHIVED placeholder message in conversation list + */ + @Then("^I see EVERYTHING ARCHIVED placeholder in conversations list$") + public void ISeeNoConversationMessage() { + assertThat("'EVERYTHING ARCHIVED' placeholder is not visible", + getConversationsListPage().isConversationsListPlaceholderVisible()); + } + + @When("^I tap JOIN button in conversations list next to (.*)") + public void ITapButtonInContactListNextTo(String contact) { + final String name = context.getUsersManager().replaceAliasesOccurrences(contact, FindBy.NAME_ALIAS); + getConversationsListPage().tapJoinButtonNextTo(name); + } + + @When("I tap Incoming Pending Requests item in conversations list") + public void ITapPendingRequestLinkContactList() { + getConversationsListPage().tapPendingRequest(); + } + + @When("I (do not )?see Pending request link in conversations list$") + public void ISeePendingRequestLinkInContacts(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Pending request link is not in conversations list", + getConversationsListPage().isPendingRequestInContactList()); + } else { + assertThat("Pending request link is shown in conversations list", + getConversationsListPage().pendingRequestInContactListIsNotShown()); + } + } + + /** + * Check value of status icon in conversation list + * + * @param status the number of unread messages in the status icon + * @param conversation the unread messages are in + */ + @Then("^I see status of conversations list item (.*) is (not )?\"(.*)\"$") + public void ISeeConversationStatus(String conversation, String notChanged, String status) { + conversation = context.getUsersManager() + .replaceAliasesOccurrences(conversation, ClientUsersManager.FindBy.NAME_ALIAS); + status = context.getUsersManager().replaceAliasesOccurrences(status, FindBy.NAME_ALIAS); + if (notChanged == null) { + assertThat(String.format("The current status for conversation item %s is not equal to %s", + conversation, status), + getConversationsListPage().isConversationItemWithStatusVisible(status, conversation)); + } else { + assertThat(String.format("The current status for conversation item %s is not expected to be equal " + + "to %s", + conversation, status), + getConversationsListPage().isConversationItemWithStatusInvisible(status, conversation)); + } + } + + /** + * Check the secondary line of the conversation item in conversation list + * + * @param secondaryLine the text of the secondary line + * @param conversation the secondary line belongs to + */ + @Then("^I see the secondary line in conversations list item (.*) is \"(.*)\"$") + public void ISeeTheSecondaryIs(String conversation, String secondaryLine) { + conversation = context.getUsersManager() + .replaceAliasesOccurrences(conversation, ClientUsersManager.FindBy.NAME_ALIAS); + final Pattern usernamePattern = Pattern.compile(ClientUsersManager. + STR_UNIQUE_USERNAME_ALIAS_TEMPLATE.apply("\\d+")); + if (usernamePattern.matcher(secondaryLine).find()) { + secondaryLine = context.getUsersManager() + .replaceAliasesOccurrences(secondaryLine, FindBy.UNIQUE_USERNAME_ALIAS); + } else { + secondaryLine = context.getUsersManager() + .replaceAliasesOccurrences(secondaryLine, ClientUsersManager.FindBy.NAME_ALIAS); + } + assertThat(String.format("The current secondary line for conversation item %s is not equal to %s", + conversation, secondaryLine), + getConversationsListPage().isSecondaryLineVisible(conversation, secondaryLine)); + } + + /** + * Check that the conversation item in conversation list has no status set + * + * @param isNot equals to null if a status should be visible + * @param conversation list item to check status of + */ + @Given("^I (do not )?see a status for conversations list item (.*)$") + public void IDoNotSeeAConversationStatusForContact(String isNot, String conversation) { + conversation = context.getUsersManager() + .replaceAliasesOccurrences(conversation, ClientUsersManager.FindBy.NAME_ALIAS); + if (isNot == null) { + assertThat(String.format("The status for conversation item %s is not set", conversation), + getConversationsListPage().isConversationItemStatusVisible(conversation)); + } else { + assertThat(String.format("The status for conversation item %s is set", conversation), + getConversationsListPage().isConversationItemStatusInvisible(conversation)); + } + } + + @Then("^I (do not )?see classified domain icon on the outgoing connection page$") + public void iSeeClassifiedDomainLabelOutgoingConnection(String shouldBeVisible) { + if (shouldBeVisible == null) { + assertThat("The classified domain label should be visible on the outgoing connection page", + getConversationsListPage().isClassifiedLabelVisible()); + } else { + assertThat("The classified domain label should be invisible on the outgoing connection page", + getConversationsListPage().isClassifiedLabelInvisible()); + } + } + + @Then("^I (do not )?see classified domain icon on the incoming connection page$") + public void iSeeClassifiedDomainLabelIncomingConnection(String shouldBeVisible) { + if (shouldBeVisible == null) { + assertThat("The classified domain label should be visible on the incoming connection page", + getConversationsListPage().isClassifiedLabelVisible()); + } else { + assertThat("The classified domain label should be invisible on the incoming connection page", + getConversationsListPage().isClassifiedLabelInvisible()); + } + } + + @Then("^I (do not )?see unclassified domain icon on the incoming connection page$") + public void iSeeNotClassifiedDomainLabelIncomingConnection(String shouldBeVisible) { + if (shouldBeVisible == null) { + assertThat("The unclassified domain label should be visible on the incoming connection page", + getConversationsListPage().isNotClassifiedLabelVisible()); + } else { + assertThat("The unclassified domain label should be invisible on the incoming connection page", + getConversationsListPage().isNotClassifiedLabelInvisible()); + } + } + + @Then("^I (do not )?see unclassified domain icon on the outgoing connection page$") + public void iSeeNotClassifiedDomainLabelOutgoingConnection(String shouldBeVisible) { + if (shouldBeVisible == null) { + assertThat("The unclassified domain label should be visible on the outgoing connection page", + getConversationsListPage().isNotClassifiedLabelVisible()); + } else { + assertThat("The unclassified domain label should be invisible on the outgoing connection page", + getConversationsListPage().isNotClassifiedLabelInvisible()); + } + } + + @Then("^I (do not )?see classified domain label in the conversation$") + public void iSeeClassifiedDomainLabelConvo(String shouldBeVisible) { + if (shouldBeVisible == null) { + assertThat("The classified domain label should be visible", + getConversationsListPage().isClassifiedLabelVisibleConvo()); + } else { + assertThat("The classified domain label should be invisible", + getConversationsListPage().isClassifiedLabelInvisibleConvo()); + } + } + + @Then("^I (do not )?see unclassified domain label in the conversation$") + public void iSeeNotClassifiedDomainLabelConvo(String shouldBeVisible) { + if (shouldBeVisible == null) { + assertThat("The unclassified domain label should be visible", + getConversationsListPage().isNotClassifiedLabelVisibleConvo()); + } else { + assertThat("The unclassified domain label should be invisible", + getConversationsListPage().isNotClassifiedLabelInvisibleConvo()); + } + } + + @Then("^I (do not )?see classified domain label on Group participant user profile page$") + public void iSeeClassifiedDomainLabelUserProfile(String shouldBeVisible) { + if (shouldBeVisible == null) { + assertThat("The classified domain label should be visible", + getConversationsListPage().isClassifiedLabelVisibleUserProfile()); + } else { + assertThat("The classified domain label should be invisible", + getConversationsListPage().isClassifiedLabelInvisibleUserProfile()); + } + } + + @Then("^I (do not )?see unclassified domain label on Group participant user profile page$") + public void iSeeNotClassifiedDomainLabelUserProfile(String shouldNot) { + if (shouldNot == null) { + assertThat("The unclassified domain label should be visible", + getConversationsListPage().isNotClassifiedLabelVisibleUserProfile()); + } else { + assertThat("The unclassified domain label should be invisible", + getConversationsListPage().isNotClassifiedLabelInvisibleUserProfile()); + } + } + + @When("I choose Mark as Read from conversation list context menu") + public void iChooseMarkAsReadFromConversationListContextMenu() { + getConversationsListPage().tapMarkAsRead(); + } + + @When("I choose Notifications... from conversation list context menu") + public void iChooseNotificationsFromConversationListContextMenu() { + getConversationsListPage().tapNotificationsMenu(); + } + + @When("I choose Nothing from the Notifications submenu") + public void iChooseNothingFromTheNotificationsSubmenu() { + getConversationsListPage().tapNothing(); + } + + @When("I choose Archive from conversation list context menu") + public void iChooseArchiveAsReadFromConversationListContextMenu() { + getConversationsListPage().tapArchive(); + } + + @When("I choose Favorite from conversation list context menu") + public void iChooseFavoriteAsReadFromConversationListContextMenu() { + getConversationsListPage().tapFavorite(); + } + + @When("I choose Move To from conversation list context menu") + public void iChooseMoveToFromConversationListContextMenu() { + getConversationsListPage().tapMoveTo(); + } + + @When("I choose Clear Content from conversation list context menu") + public void iChooseClearContentFromConversationListContextMenu() { + getConversationsListPage().tapClearContent(); + } + + @When("I choose Block from conversation list context menu") + public void iChooseBlockFromConversationListContextMenu() { + getConversationsListPage().tapBlock(); + } + + @When("I choose Leave Group from conversation list context menu") + public void iChooseLeaveGroupFromConversationListContextMenu() { + getConversationsListPage().tapLeaveGroup(); + } + + @When("I remove conversation from favorites in conversation list context menu") + public void iRemoveConversationFromFavoritesInConversationListContextMenu() { + getConversationsListPage().tapRemoveFromFavorite(); + } + + @When("I choose clear from clear content menu") + public void iChooseClearFromClearContentMenu() { + getConversationsListPage().tapClearInClearContent(); + } + + @When("I confirm the block dialog") + public void iConfirmTheBlockDialog() { + getConversationsListPage().tapBlockConfirm(); + } + + @When("I choose Leave and Clear in the dialog") + public void iChooseLeaveAndClearInTheDialog() { + getConversationsListPage().tapLeaveAndClearConfirm(); + } + + @Then("I see that I am certified on Conversation List Page") + public void iSeeThatIAmCertifiedOnConversationListPage() { + assertThat("User is missing certified status", getConversationsListPage().isCertified()); + } +} + diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CreateFolderSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CreateFolderSteps.java new file mode 100644 index 00000000000..9821338ec40 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CreateFolderSteps.java @@ -0,0 +1,29 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.pages.CreateFolderPage; +import com.wearezeta.auto.ios.common.IOSTestContext; +import io.cucumber.java.en.When; + +public class CreateFolderSteps { + IOSTestContext context; + + public CreateFolderSteps(IOSTestContext context) { + this.context = context; + } + + private CreateFolderPage getNewFolderPage() { + return context.getPagesCollection().getPage(CreateFolderPage.class); + } + + @When("^I enter Folder name \"(.*)\" on New Folder page$") + public void iEnterFoldernameOnNewFolderPage(String folderName) { + getNewFolderPage().enterFolderName(folderName); + } + + @When("^I tap Create button on New Folder page$") + public void iTapCreateNewButton() { + getNewFolderPage().tapCreateButton(); + } +} + + diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CustomBackendRedirectionPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CustomBackendRedirectionPageSteps.java new file mode 100644 index 00000000000..09293b5be89 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CustomBackendRedirectionPageSteps.java @@ -0,0 +1,54 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.backend.Backend; +import com.wearezeta.auto.common.backend.BackendConnections; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.CustomBackendRedirectionPage; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import org.apache.commons.lang3.StringUtils; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class CustomBackendRedirectionPageSteps { + + IOSTestContext context; + + public CustomBackendRedirectionPageSteps(IOSTestContext context) { + this.context = context; + } + + private CustomBackendRedirectionPage getCustomBackendRedirectionPage() { + return context.getPagesCollection().getPage(CustomBackendRedirectionPage.class); + } + + @Given("^I tap Proceed button on backend redirection page$") + public void iTapProceedButtonOnBackendRedirectionPage() { + getCustomBackendRedirectionPage().tapProceedButton(); + } + + @When("^I see redirection title on backend redirection page$") + public void iSeeRedirectionTitleOnBackendRedirectionPage() { + assertThat("Redirection title on redirection page is not visible while it should be.", getCustomBackendRedirectionPage().isRedirectionTitleVisible()); + } + + @Then("^I see backend information of backend (.*)$") + public void iSeeBackendInformationOfBackend(String backendName) { + Backend backend; + if(backendName.equals("default")) { + backend = BackendConnections.getDefault(); + } else { + backend = BackendConnections.get(backendName); + } + String domainName = backend.getBackendName() + ".wire.link"; + assertThat("Wrong or missing domain name on redirection page", + getCustomBackendRedirectionPage().isTextVisible(domainName)); + + assertThat("Wrong or missing backend url on redirection page", + getCustomBackendRedirectionPage().isTextVisible(StringUtils.chop(backend.getBackendUrl()))); + + assertThat("Wrong or missing backend websocket url on redirection page", + getCustomBackendRedirectionPage().isTextVisible(backend.getBackendWebsocket().replace("wss://", "https://"))); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CustomBackendWelcomePageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CustomBackendWelcomePageSteps.java new file mode 100644 index 00000000000..f7f5753d47a --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/CustomBackendWelcomePageSteps.java @@ -0,0 +1,36 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.CustomBackendWelcomePage; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class CustomBackendWelcomePageSteps { + + IOSTestContext context; + + public CustomBackendWelcomePageSteps(IOSTestContext context) { + this.context = context; + } + + private CustomBackendWelcomePage getCustomBackendWelcomePage() { + return context.getPagesCollection().getPage(CustomBackendWelcomePage.class); + } + + @Then("^I see \"([^\"]*)\" label on Custom backend welcome page$") + public void iSeeLabelOnCustomBackendWelcomePage(String backendName) { + assertThat("Custom backend connection message is not visible.", getCustomBackendWelcomePage().isConnectionMessageVisible(backendName)); + } + + @When("^I tap Login with Email button on Custom backend welcome page$") + public void iTapLoginWithEmailButtonOnCustomBackendWelcomePage() { + getCustomBackendWelcomePage().tapOnLoginWithEmailButton(); + } + + @Given("^I see Custom backend welcome page for backend \"([^\"]*)\"$") + public void iSeeCustomBackendWelcomePageForBackend(String backendName) { + assertThat("Custom backend Welcome page is not visible", getCustomBackendWelcomePage().isTextVisible(backendName)); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/E2EIOverlayPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/E2EIOverlayPageSteps.java new file mode 100644 index 00000000000..249a0d4730d --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/E2EIOverlayPageSteps.java @@ -0,0 +1,28 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.E2EIPage; +import io.cucumber.java.en.When; + +public class E2EIOverlayPageSteps { + + IOSTestContext context; + + public E2EIOverlayPageSteps(IOSTestContext context) { + this.context = context; + } + + private E2EIPage getE2EIPage() { + return context.getPagesCollection().getPage(E2EIPage.class); + } + + @When("^I tap Get Certificate button on Enrollment overlay$") + public void iTapGetCertificateButtonOnE2EIPage() { + getE2EIPage().tapGetCertificateButton(); + } + + @When("I click Ok on the Enrollment Success screen") + public void iClickOkOnTheEnrollmentSuccessScreen() { + getE2EIPage().tapOkButton(); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/EncryptionAtRestOverlaySteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/EncryptionAtRestOverlaySteps.java new file mode 100644 index 00000000000..91eef5daa8b --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/EncryptionAtRestOverlaySteps.java @@ -0,0 +1,52 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.backend.BackendConnections; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.EncryptionAtRestOverlay; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class EncryptionAtRestOverlaySteps { + IOSTestContext context; + + public EncryptionAtRestOverlaySteps(IOSTestContext context) { + this.context = context; + } + + private EncryptionAtRestOverlay getPage() { + return context.getPagesCollection().getPage(EncryptionAtRestOverlay.class); + } + + @Then("^I see Encryption At Rest overlay$") + public void iSeeOverlay() { + assertThat("Encryption at Rest overlay is not visible", getPage().isPasscodeOverlayVisible()); + } + + @Then("^I do not see Encryption At Rest overlay$") + public void iDontSeeOverlay() { + assertThat("Encryption At Rest overlay is shown", getPage().isPasscodeOverlayInvisible()); + } + + @When("^I confirm overlay if build has encryption at rest enabled$") + public void iConfirm() { + if (BackendConnections.getDefault().isFeatureEncryptionAtRestEnabled()) { + // Currently not possible as on iOS 17 it is not possible to interact with the passcode overlay + // getPage().waitUntilPasscodeOverlayIsVisible(); + getPage().typePasscode("a"); + getPage().pressEnter(); + } + } + + @When("^I type (.*) on the Encryption At Rest overlay input") + public void ITypeOnOverlay(String input) { + getPage().typePasscode(input); + } + + @When("^I press enter on the Encryption At Rest overlay input") + public void IPressEnterOnOverlay() { + getPage().pressEnter(); + } + +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/EnterpriseLoginPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/EnterpriseLoginPageSteps.java new file mode 100644 index 00000000000..66bb7a664ad --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/EnterpriseLoginPageSteps.java @@ -0,0 +1,50 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.EnterpriseLoginPage; +import io.cucumber.java.en.Then; +import static org.hamcrest.MatcherAssert.assertThat; + +public class EnterpriseLoginPageSteps { + IOSTestContext context; + + public EnterpriseLoginPageSteps(IOSTestContext context) { + this.context = context; + } + + private EnterpriseLoginPage getEnterpriseLoginPage() { + return context.getPagesCollection().getPage(EnterpriseLoginPage.class); + } + + @Then("^I (do not )?see Enterprise Login popup$") + public void iSeeEnterpriseLoginDialogueBox(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Enterprise Log In dialogue box is not visible.", getEnterpriseLoginPage().isEnterpriseLoginBoxVisible()); + } else { + assertThat("Enterprise Log In dialogue box is visible.", getEnterpriseLoginPage().isEnterpriseLoginBoxInvisible()); + } + } + + @Then("^I see Enterprise Login popup contains text \"([^\"]*)\"$") + public void iSeeDialogueBoxContainsText(String alertText) { + assertThat("Enterprise login popup doesn't contain expected text.", getEnterpriseLoginPage().isAlertContainsText(alertText)); + } + + @Then("^I see Enterprise Login popup contains button Cancel$") + public void iSeeDialogueBoxContainsOptionCancel() { + assertThat("Cancel option is not present on the Enterprise Log In popup.", getEnterpriseLoginPage().isCancelOptionVisible()); + } + + @Then("^I type \"([^\"]*)\" into EmailSSO code field$") + public void iTypeCodeIntoSSOCodeField(String code) { + if (code.equals("default")) { + code = context.getCommonSteps().getSSOCode(); + } + getEnterpriseLoginPage().typeCodeIntoEmailSSOField(code); + } + + @Then("^I see error message \"([^\"]*)\" on Enterprise Login popup$") + public void iSeeTheErrorMessage(String expectedText) { + assertThat("Actual error message is not same as expected.", getEnterpriseLoginPage().isAlertContainsText(expectedText)); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/FileInspectionPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/FileInspectionPageSteps.java new file mode 100644 index 00000000000..f091728e723 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/FileInspectionPageSteps.java @@ -0,0 +1,27 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.FileInspectionPage; +import io.cucumber.java.en.When; + +public class FileInspectionPageSteps { + IOSTestContext context; + + public FileInspectionPageSteps(IOSTestContext context) { + this.context = context; + } + + private FileInspectionPage getPage() { + return context.getPagesCollection().getPage(FileInspectionPage.class); + } + + @When("^I tap Share button in file inspection page$") + public void iTapShareButtonInFileInspectionPage() { + getPage().tapShareButton(); + } + + @When("^I tap Done button in file inspection page$") + public void iTapDoneButtonInFileInspectionPage() { + getPage().tapDoneButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/FirstTimeOverlaySteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/FirstTimeOverlaySteps.java new file mode 100644 index 00000000000..8f75fb0cac3 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/FirstTimeOverlaySteps.java @@ -0,0 +1,42 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.FirstTimeOverlay; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class FirstTimeOverlaySteps { + + IOSTestContext context; + + public FirstTimeOverlaySteps(IOSTestContext context) { + this.context = context; + } + + private FirstTimeOverlay getOverlay() { + return context.getPagesCollection().getPage(FirstTimeOverlay.class); + } + + @Then("^I see First Time overlay$") + public void iSeeOverlay() { + assertThat("Restore button is not visible", getOverlay().waitUntilVisible()); + assertThat("Overlay text wrong", getOverlay().isHeadingVisible()); + } + + @Then("^I do not see First Time overlay$") + public void iDoNotSeeOverlay() { + assertThat("Restore button is still visible", getOverlay().waitUntilInvisible()); + } + + @When("^I accept First Time overlay$") + public void iAccept() { + getOverlay().accept(); + } + + @When("^I tap Restore from backup button on First Time overlay$") + public void iTapRestoreButton() { + getOverlay().tapRestoreButton(); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/FolderViewSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/FolderViewSteps.java new file mode 100644 index 00000000000..130352637e5 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/FolderViewSteps.java @@ -0,0 +1,269 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.FolderViewPage; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.util.regex.Pattern; + +public class FolderViewSteps { + IOSTestContext context; + + public FolderViewSteps(IOSTestContext context) { + this.context = context; + } + + private FolderViewPage getFolderViewPage() { + return context.getPagesCollection() + .getPage(FolderViewPage.class); + } + + @Given("^I see Folder view$") + public void ISeeFolderView() { + assertThat("Folder view is not visible after the timeout", + getFolderViewPage().isVisible()); + } + + /** + * Open the corresponding conversation by tapping its name in the conversations list + * + * @param name conversation name/alias + */ + @Given("^I open (?:group |single |1:1 |\\s?)conversation \"(.*)\" in Folder view") + public void IOpenRecentConversation(String name) { + name = context.getUsersManager().replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + getFolderViewPage().tapConversationItemGroupedList(name); + } + + @Then("^I see People folder in Folder view$") + public void iSeePeopleFolder() { + assertThat("People folder is not visible", getFolderViewPage().isPeopleFolderVisible()); + } + + /** + * verifies the visibility of the Favorites folder in Folder view + * + * @param shouldNotSee equals to null if the item should be visible + */ + @Then("^I (do not )?see Favorites folder in Folder view$") + public void ISeeFavoritesFolder(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Favorites folder is not visible", getFolderViewPage().isFavoritesFolderVisible()); + } else { + assertThat("Favorites folder is not visible", getFolderViewPage().isFavoritesFolderInvisible()); + } + } + + /** + * Taps the People folder to collapse the folder + */ + @Then("^I (collapse|expand) People folder$") + public void ITapPeopleFolder() { + getFolderViewPage().tapPeopleFolder(); + } + + /** + * Taps the Favorites folder to collapse the folder + */ + @Then("^I (collapse|expand) Favorites folder$") + public void ITapFavoritesFolder() { + getFolderViewPage().tapFavoritesFolder(); + } + + /** + * Taps the custom folder to collapse the folder + * @param folderName name of the custom folder + */ + @Then("^I (collapse|expand) custom folder (.*)$") + public void ITapCustomFolder(String folderName) { + getFolderViewPage().tapCustomFolder(folderName); + } + + /** + * Taps the People folder to collapse the folder + */ + @Then("^I see People folder is (collapsed|expanded)$") + public void ISeePeopleFolderExpanded(String state) { + if (state.equals("collapsed")) { + getFolderViewPage().isFolderCollapsed("People"); + } else { + getFolderViewPage().isFolderExpanded("People"); + } + } + + /** + * verifies the visibility of the Groups folder in grouped conversation list + * + * @param shouldNotSee equals to null if the item should be visible + */ + @Then("^I (do not )?see Groups folder in Folder view$") + public void ISeeGroupsFolder(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Group folder is not visible", getFolderViewPage().isGroupFolderVisible()); + } else { + assertThat("Group folder is not visible", getFolderViewPage().isGroupFolderInvisible()); + } + } + + /** + * verifies the visibility of a custom folder in grouped conversation list + * + * @param shouldNotSee equals to null if the item should be visible + * @param folderName the name of the custom folder + */ + @Then("^I (do not )?see custom folder (.*) in Folder view$") + public void ISeeGroupsFolder(String shouldNotSee, String folderName) { + if (shouldNotSee == null) { + assertThat("Group folder is not visible", getFolderViewPage().isCustomFolderVisible(folderName)); + } else { + assertThat("Group folder is not visible", getFolderViewPage().isCustomFolderInvisible(folderName)); + } + } + + @Then("^I see conversation (.*) in People folder$") + public void iSeeUserInContactFolder(String value) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS); + assertThat("The conversation is not visible in the People folder", + getFolderViewPage().getConversationOfPeopleFolder(), hasItem(value)); + } + + @Then("^I do not see conversation (.*) in People folder$") + public void iDoNotSeeUserInContactFolder(String value) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS); + if (!getFolderViewPage().isPeopleFolderInvisible()) { + assertThat("The conversation is visible in the People folder, but should be hidden", + getFolderViewPage().getConversationOfPeopleFolder(), not(hasItem(value))); + } + } + + /** + * verifies the visibility of a specific conversation in the People folder + * + * @param shouldNotSee equals to null if the item should be visible + * @param value conversation name/alias + */ + @Then("^I (do not )?see conversation (.*) in Groups folder$") + public void ISeeUserInGroupFolder(String shouldNotSee, String value) { + if (shouldNotSee == null) { + assertThat(String.format("The conversation '%s' is not visible in the Groups list", + value), getFolderViewPage().isConversationInGroupsFolder(value)); + } else { + assertThat( + String.format("The conversation '%s' is visible in the Groups list, but should be hidden", + value), getFolderViewPage().isConversationNotInGroupsFolder(value)); + } + } + + /** + * verifies the visibility of a specific conversation in the Favorites folder + * + * @param shouldNotSee equals to null if the item should be visible + * @param value conversation name/alias + */ + @Then("^I (do not )?see conversation (.*) in Favorites folder$") + public void ISeeUserInFavoritesFolder(String shouldNotSee, String value) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + assertThat(String.format("The conversation '%s' is not visible in the Favorites list", + value), getFolderViewPage().isConversationInFavoritesFolder(value)); + } else { + assertThat( + String.format("The conversation '%s' is visible in the Favorites list, but should be hidden", + value), getFolderViewPage().isConversationNotInFavoritesFolder(value)); + } + } + + /** + * verifies the visibility of a specific conversation in a specific folder + * + * @param shouldNotSee equals to null if the item should be visible + * @param value conversation name/alias + * @param folderName the name of the folder that should contain the conversation + */ + @Then("^I (do not )?see conversation (.*) in custom folder (.*)$") + public void ISeeUserInGroupFolder(String shouldNotSee, String value, String folderName) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + assertThat("The conversation is not visible in custom folder", + getFolderViewPage().getConversationsInCustomFolder(folderName), hasItem(value)); + } else { + assertThat( + "The conversation is visible in the custom folder, but should not", + getFolderViewPage().getConversationsInCustomFolder(folderName), not(hasItem(value))); + } + } + + /** + * Check value of status icon in conversation list + * + * @param status the number of unread messages in the status icon + * @param conversation the unread messages are in + */ + @Then("^I see status of Folder view conversation item (.*) is (not )?(\\d+|ping|Pinged|missed call|active call|Silenced|you are mentioned|You are mentioned)$") + public void ISeeConversationStatus(String conversation, String notChanged, String status) { + conversation = context.getUsersManager() + .replaceAliasesOccurrences(conversation, ClientUsersManager.FindBy.NAME_ALIAS); + if (notChanged == null) { + assertThat(String.format("The current status for conversation item %s is not equal to %s", + conversation, status), + getFolderViewPage().isConversationItemWithStatusVisible(status, conversation)); + } else { + assertThat(String.format("The current status for conversation item %s is not expected to be equal " + + "to %s", + conversation, status), + getFolderViewPage().isConversationItemWithStatusInvisible(status, conversation)); + } + } + + @Then("^I (do not )?see the secondary line of Folder view conversation item (.*) is \"(.*)\"$") + public void ISeeTheSecondaryIs(String doNot, String conversation, String secondaryLine) { + conversation = context.getUsersManager() + .replaceAliasesOccurrences(conversation, ClientUsersManager.FindBy.NAME_ALIAS); + final Pattern usernamePattern = Pattern.compile(ClientUsersManager. + STR_UNIQUE_USERNAME_ALIAS_TEMPLATE.apply("\\d+")); + if (usernamePattern.matcher(secondaryLine).find()) { + secondaryLine = context.getUsersManager() + .replaceAliasesOccurrences(secondaryLine, ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + } else { + secondaryLine = context.getUsersManager() + .replaceAliasesOccurrences(secondaryLine, ClientUsersManager.FindBy.NAME_ALIAS); + } + if(doNot == null) { + assertThat(String.format("The current secondary line for conversation item %s is not equal to %s", + conversation, secondaryLine), + getFolderViewPage().isSecondaryLineVisible(conversation, secondaryLine)); + } else { + assertThat(String.format("The current secondary line for conversation item %s is equal to '%s' while it should not be", + conversation, secondaryLine), + getFolderViewPage().isSecondaryLineInvisible(conversation, secondaryLine)); + } + } + + @When("^I swipe right on conversation (.*) in Folder view") + public void ISwipeRightOnGroupedConversation(String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + getFolderViewPage().swipeRightOnGroupedConversation(name); + } + + @Then("^I see unread conversations badge is (\\d+) for folder \"(.*)\"$") + public void ISeeUnreadConversationsCounter(int number, String folderName) { + assertThat(String.format("The number of unread conversations is not %s for folder %s", number, folderName), + getFolderViewPage().isBadgeCountForFolder(folderName, number)); + } + + @Then("^I do not see unread conversations badge for folder \"(.*)\"$") + public void iDoNotSeeUnreadConversationsBadge(String folderName) { + assertThat(String.format("There is an unread conversations badge on folder %s", folderName), + getFolderViewPage().isBadgeCountInvisibleForFolder(folderName)); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ForwardPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ForwardPageSteps.java new file mode 100644 index 00000000000..70567474646 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ForwardPageSteps.java @@ -0,0 +1,97 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.ForwardPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class ForwardPageSteps { + IOSTestContext context; + + public ForwardPageSteps(IOSTestContext context) { + this.context = context; + } + + private ForwardPage getPage() { + return context.getPagesCollection().getPage(ForwardPage.class); + } + + @When("^I tap Send button on Forward page$") + public void ITapSendButton() { + getPage().tapSendButton(); + } + + /** + * Select the corresponding conversation from the list on Forward page + * + * @param name conversation name/alias + */ + @Then("^I select (.*) conversation on Forward page$") + public void ISelectConversation(String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + getPage().selectConversation(name); + } + + /** + * Verify whether a conversation is visible in the list of conversations available + * for message forwarding + * + * @param shouldNotSee equals to null if the conversation should be visible + * @param name conversation name/alias + */ + @When("^I (do not )?see (.*) conversation on Forward page$") + public void ISeeConversation(String shouldNotSee, String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + assertThat(String.format("The '%s' conversation is expected to be visible", name), + getPage().isConversationVisible(name)); + } else { + assertThat(String.format("The '%s' conversation is expected to be invisible", name), + getPage().isConversationInvisible(name)); + } + } + + /** + * Verify whether a conversation with legal hold has the legal hold indicator on the message forward page + * + * @param shouldNotSee equals to null if the legal hold indicator should be visible + */ + @When("^I (do not )?see (legal hold indicator|shield icon|guest icon|external icon) on Forward page$") + public void ISeeIconOnForwardPage(String shouldNotSee, String icon) { + if (shouldNotSee == null) { + if(icon.equals("shield icon")) { + // check if shield icon is visible + assertThat("Shield icon is not visible", getPage().isShieldIconVisible()); + } else if (icon.equals("guest icon")) { + // check if guest icon is visible + assertThat("Guest icon is not visible", getPage().isGuestIconVisible()); + } else if (icon.equals("external icon")) { + // check if guest icon is visible + assertThat("external icon is not visible", getPage().isExternalIconVisible()); + } else { + //check if legal hold indicator is visible + assertThat("Legal Hold indicator icon is not visible", getPage().isLegalHoldIndicatorVisible()); + } + } else { + // Should not be visible + if (icon.equals("shield icon")) { + // check if shield icon is not visible + assertThat("Shield icon is visible while it should not be", getPage().isShieldIconInvisible()); + } else if (icon.equals("guest icon")) { + // check if guest icon is not visible + assertThat("Guest icon is visible while it should not be", getPage().isGuestIconInvisible()); + } else if (icon.equals("external icon")) { + // check if external icon is not visible + assertThat("external icon is visible while it should not be", getPage().isExternalIconInvisible()); + } else { + // check if Legal hold indicator is not visible + assertThat("Legal hold indicator is visible while it should not be", getPage().isLegalHoldIndicatorInvisible()); + } + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/GiphyPreviewPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/GiphyPreviewPageSteps.java new file mode 100644 index 00000000000..f4882917862 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/GiphyPreviewPageSteps.java @@ -0,0 +1,42 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import static org.hamcrest.MatcherAssert.assertThat; + +import com.wearezeta.auto.ios.pages.GiphyPreviewPage; + +import io.cucumber.java.en.When; + +public class GiphyPreviewPageSteps { + IOSTestContext context; + + public GiphyPreviewPageSteps(IOSTestContext context) { + this.context = context; + } + + private GiphyPreviewPage getGiphyPreviewPage() { + return context.getPagesCollection() + .getPage(GiphyPreviewPage.class); + } + + @When("^I select the first item from Giphy grid$") + public void ISelectFirstItem() { + getGiphyPreviewPage().selectFirstItem(); + } + + @When("^I tap Send button on Giphy preview page$") + public void ITapSendButtonOnGiphyPreview() { + getGiphyPreviewPage().tapSendButton(); + } + + @When("^I see Giphy preview page$") + public void ISeeGiphyPreviewPage() { + assertThat("Giphy Send Button is not visible", getGiphyPreviewPage().isSendButtonVisible()); + assertThat("Giphy Cancel Button is not visible", getGiphyPreviewPage().isCancelButtonVisible()); + } + + @When("^I see Giphy grid preview$") + public void ISeeGiphyGridPreview() { + assertThat("Giphy grid is not shown", getGiphyPreviewPage().isGridVisible()); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/HistoryBackupSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/HistoryBackupSteps.java new file mode 100644 index 00000000000..2d215a63af3 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/HistoryBackupSteps.java @@ -0,0 +1,47 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.HistoryBackupPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.*; + +public class HistoryBackupSteps { + + IOSTestContext context; + + public HistoryBackupSteps(IOSTestContext context) { + this.context = context; + } + + private HistoryBackupPage getPage() { + return context.getPagesCollection().getPage(HistoryBackupPage.class); + } + + @When("^I initiate history backup from Settings$") + public void iInitiateHistoryBackupFromSettings() { + getPage().initiateHistoryBackup(); + } + + @Then("^I verify history backup for user (.*) from Settings is successfully completed$") + public void iVerifyHistoryBackupIsSuccessful(String userAlias) { + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(userAlias); + assertThat("Backup now button is not back", getPage().isBackupNowButtonShown()); + + // The files can be found under ~/Library/Developer/CoreSimulator/Devices/65061465-C2A1-4F3E-AEE6-E48C4E89D2B6/data/Containers/Shared/AppGroup/4806084A-DA44-4A49-A07F-9FC69E516F7D/File Provider Storage/Wire-5u4asoh3-Backup_20210204.ios_wbu + // But i was unable to find the correct app group hash to get them + // Appium is running "xcrun simctl get_app_container 65061465-C2A1-4F3E-AEE6-E48C4E89D2B6 com.wearezeta.zclient.ios-development groups" + // when using pullFile("@com.wearezeta.zclient.ios-development:groups/") but this returns a wrong app group hash. + // Neither com.apple.DocumentsApp nor com.apple.FileProvider.LocalStorage works + // See also https://stackoverflow.com/a/58299287 + /* + final TimeZone timezone = TimeZone.getTimeZone("UTC"); + DateFormat dateFormat = new SimpleDateFormat("yyyyMMdd"); + dateFormat.setTimeZone(timezone); + byte[] file = getPage().getBackupFile("com.wearezeta.zclient.ios-development", user.getUniqueUsername(), dateFormat.format(new Date())); + assertThat("Backup file has no content", file.length, greaterThan(0)); + */ + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ImageFullScreenPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ImageFullScreenPageSteps.java new file mode 100644 index 00000000000..57583bcccad --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ImageFullScreenPageSteps.java @@ -0,0 +1,44 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.ImageUtil; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.ImageFullScreenPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class ImageFullScreenPageSteps { + IOSTestContext context; + + public ImageFullScreenPageSteps(IOSTestContext context) { + this.context = context; + } + + private ImageFullScreenPage getImageFullScreenPage() { + return context.getPagesCollection().getPage(ImageFullScreenPage.class); + } + + @When("^I see Full Screen Page opened$") + public void ISeeFullScreenPage() { + assertThat("Image not in full screen", + getImageFullScreenPage().isImageFullScreenShown()); + } + + private static final double MAX_SIMILARITY_THRESHOLD = 0.995; + + @Then("^I see the picture on image fullscreen page is animated$") + public void ISeePictureIsAnimated() { + final int maxFrames = 4; + final double avgThreshold = ImageUtil.getAnimationThreshold(getImageFullScreenPage()::getPreviewPictureScreenshot, + maxFrames, Timedelta.ofMillis(10)); + assertThat(String.format("The picture in the image preview view seems to be static (%f >= %f)", + avgThreshold, MAX_SIMILARITY_THRESHOLD), avgThreshold < MAX_SIMILARITY_THRESHOLD); + } + + @When("^I tap X button on fullscreen image$") + public void ITapXOnButtonFullscreen() { + getImageFullScreenPage().tapFullScreenCloseButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/KeyboardGalleryPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/KeyboardGalleryPageSteps.java new file mode 100644 index 00000000000..d7583aeb6fb --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/KeyboardGalleryPageSteps.java @@ -0,0 +1,48 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.KeyboardGalleryPage; + +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class KeyboardGalleryPageSteps { + IOSTestContext context; + + public KeyboardGalleryPageSteps(IOSTestContext context) { + this.context = context; + } + + private KeyboardGalleryPage getKeyboardGalleryPage() { + return context.getPagesCollection().getPage(KeyboardGalleryPage.class); + } + + /** + * Tap the first visible picture on Keyboard Gallery overlay + */ + @When("^I select the first item from Keyboard Gallery$") + public void ISelectFirstPicture() { + getKeyboardGalleryPage().selectFirstPicture(); + } + + @When("^I tap Camera Roll button on Keyboard Gallery overlay$") + public void ITapCameraRollButton() { + getKeyboardGalleryPage().tapCameraRollButton(); + } + + @When("^I tap Fullscreen Camera button on Keyboard Gallery overlay$") + public void ITapFullscreenCameraButton() { + getKeyboardGalleryPage().tapFullScreenButton(); + } + + @When("^I (do not )?see first item from Keyboard Gallery$") + public void iSeeFirstItemGallery(String shouldNot) { + if (shouldNot == null) { + assertThat("Firt item from Keyboard Gallery is not visible", + getKeyboardGalleryPage().isFirstItemGalleryVisible()); + } else { + assertThat("Firt item from Keyboard Gallery is visible", + getKeyboardGalleryPage().isFirstItemGalleryInvisible()); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/LegalHoldOverviewPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/LegalHoldOverviewPageSteps.java new file mode 100644 index 00000000000..11765a52b38 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/LegalHoldOverviewPageSteps.java @@ -0,0 +1,59 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.LegalHoldOverviewPage; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.List; + +public class LegalHoldOverviewPageSteps { + + IOSTestContext context; + + public LegalHoldOverviewPageSteps(IOSTestContext context) { + this.context = context; + } + + private LegalHoldOverviewPage getLegalHoldOverviewPage() { + return context.getPagesCollection().getPage(LegalHoldOverviewPage.class); + } + + @Then("^I (do not )?see Myself as a legal hold subject on Legal hold overview page$") + public void ISeeMyselfAsLegalHoldSubject(String shouldNotSee) { + String name = context.getUsersManager().replaceAliasesOccurrences("Myself", ClientUsersManager.FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + assertThat(String.format("Myself should be visible as legal hold subject"), getLegalHoldOverviewPage().isMyselfVisible(name)); + } else { + assertThat(String.format("Myself should not be visible as legal hold subject"), getLegalHoldOverviewPage().isMyselfVisible(name)); + } + } + + @Then("^I (do not )?see legal hold subjects? (.*) on Legal hold overview page$") + public void ISeeLegalHoldSubject(String shouldNotSee, String subjects) { + final List aliases = context.getUsersManager().splitAliases(subjects); + for (final String alias : aliases) { + final String name = context.getUsersManager() + .replaceAliasesOccurrences(alias, ClientUsersManager.FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + assertThat(String.format("User '%s' should be visible", name), getLegalHoldOverviewPage().isSubjectDisplayNameVisible(name)); + } else { + assertThat(String.format("User '%s' should not be visible", name), getLegalHoldOverviewPage().isSubjectDisplayNameInvisible(name)); + } + } + } + + @When("^I tap legal hold subject (.*) on Legal hold overview page$") + public void iSelectSubject(String name) { + name = context.getUsersManager().replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + getLegalHoldOverviewPage().tapOnSubject(name); + } + + @When("^I tap close button legal hold overview page$") + public void iCloseLegalHoldPage() { + getLegalHoldOverviewPage().tapCloseButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/LoginPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/LoginPageSteps.java new file mode 100644 index 00000000000..c82fc563400 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/LoginPageSteps.java @@ -0,0 +1,190 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.log.ZetaLogger; +import com.wearezeta.auto.common.usrmgmt.NoSuchUserException; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.*; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.logging.Logger; + +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; + +import io.cucumber.java.en.*; + +public class LoginPageSteps { + + private static final Logger log = ZetaLogger.getLog(LoginPageSteps.class.getSimpleName()); + private IOSTestContext context; + + public LoginPageSteps(IOSTestContext context) { + this.context = context; + } + + private LoginPage getLoginPage() { + return context.getPagesCollection().getPage(LoginPage.class); + } + + /** + * Verifies whether sign in screen is the current screen + */ + @Given("^I see sign in screen$") + public void ISeeSignInScreen() { + assertThat("Login page is not visible", getLoginPage().isVisible()); + } + + @When("^I accept Connect to server alert$") + public void iAcceptConnectToServerAlert() { + getLoginPage().acceptConnectToServerAlert(); + } + + @When("^I accept Open in Wire alert$") + public void iAcceptOpenInWireAlert() { + getLoginPage().acceptOpenInWireAlert(); + } + + /** + * Tap EMAIL tab caption on log in screen + */ + @When("^I switch to Email Log In tab$") + public void ITapEmailButton() { + getLoginPage().switchToEmailLogin(); + } + + @Given("^I sign in user (.*) with email$") + public void iSignInUsingEmail(String nameAlias) { + ClientUser user = null; + try { + user = context.getUsersManager().findUserByNameOrNameAlias(nameAlias); + } catch (NoSuchUserException e) { + try { + // search for user by email aliases in case name is specified + user = context.getUsersManager().findUserByEmailOrEmailAlias(nameAlias); + } catch (NoSuchUserException ex) { + log.severe("Could not find user by name alias or email alias in users manager"); + } + } + log.info("Login with email " + user.getEmail() + " and password " + user.getPassword()); + LoginPage page = getLoginPage(); + log.info("Enter credentials"); + page.setLogin(user.getEmail()); + page.setPassword(user.getPassword()); + log.info("Tap login button"); + page.tapLoginButton(); + } + + @Given("^I sign in user (.*) with fast login$") + public void iSignInUsingFastLogin(String nameAlias) { + ClientUser user = null; + try { + user = context.getUsersManager().findUserByNameOrNameAlias(nameAlias); + } catch (NoSuchUserException e) { + try { + // search for user by email aliases in case name is specified + user = context.getUsersManager().findUserByEmailOrEmailAlias(nameAlias); + } catch (NoSuchUserException ex) { + log.severe("Could not find user by name alias or email alias in users manager"); + } + } + log.info("Fast login with email " + user.getEmail() + " and password " + user.getPassword()); + if (context.isDriverCreated()) { + throw new RuntimeException("The fast login step can only be used before the driver is created! " + + "Make sure you do not use the 'I tap Login button on Welcome page' step before."); + } + if (context.getScenario().hasTag("fastlogin")) { + throw new RuntimeException("Please remove @fastlogin for tests using this step!"); + } + context.setFastLoginUser(user); + context.getPagesCollection().getPage(WelcomePage.class).tapLoginButton(); + } + + /** + * Taps Login button on the corresponding screen + */ + @When("^I tap Login button on Login page$") + public void ITapSignInButton() { + getLoginPage().tapLoginButton(); + } + + /** + * Taps Login button on the corresponding screen + */ + @When("^I attempt to tap Login button$") + public void IAttemptToTapLoginButton() { + getLoginPage().tapLoginButton(); + } + + /** + * Assert that the login button is disabled. + */ + + @Then("^I don't see the Login button$") + public void IDontSeeTheLoginButton() { + assertThat("I see the the login button.", !getLoginPage().isLoginButtonInvisible()); + } + + /** + * Types login string into the corresponding input field on sign in page + * + * @param login login string (usually it is user email) + */ + @When("^I enter login (.*) on Login page$") + public void IEnteredLogin(String login) { + login = context.getUsersManager() + .replaceAliasesOccurrences(login, ClientUsersManager.FindBy.EMAIL_ALIAS); + getLoginPage().setLogin(login); + } + + @When("^I login as (.*)$") + public void ILogin(String email) { + ClientUser user = context.getUsersManager().findUserByEmailOrEmailAlias(email); + getLoginPage().setLogin(user.getEmail()); + getLoginPage().setPassword(user.getPassword()); + getLoginPage().tapLoginButton(); + } + + @When("I login") + public void iLogin() { + ClientUser me = context.getUsersManager().getSelfUser().get(); + getLoginPage().setLogin(me.getEmail()); + getLoginPage().setPassword(me.getPassword()); + getLoginPage().tapLoginButton(); + } + + /** + * Types password string into the corresponding input field on sign in page + * + * @param password password string + */ + @When("^I enter password (.*) on Login page$") + public void IEnterLoginPassword(String password) { + password = context.getUsersManager() + .replaceAliasesOccurrences(password, ClientUsersManager.FindBy.PASSWORD_ALIAS); + getLoginPage().setPassword(password); + } + + @Then("^I (do not )?see Phone login tab on Login page$") + public void iSeePhoneLogin(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Phone Login is not visible", getLoginPage().isPhoneLoginVisible()); + } else { + assertThat("Phone Login is visible", getLoginPage().isPhoneLoginInvisible()); + } + } + + @Then("^I should not see Company Login button on Login page$") + public void iShouldNotSeeCompanyLoginButton() { + assertThat("Company Login button is visible.", getLoginPage().isCompanyLoginButtonInvisible()); + } + + @Then("^I am signed in properly$") + public void iAmSignedInProperly() { + assertThat("Can't find profile button. Are we logged in?", getLoginPage().waitForLoginProperly()); + } + + @Then("I see Login page") + public void iDoNotSeeLoginPage() { + getLoginPage().isVisible(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/LoginSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/LoginSteps.java new file mode 100644 index 00000000000..daf31670362 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/LoginSteps.java @@ -0,0 +1,115 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.email.messages.VerificationMessage; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.*; +import com.wearezeta.auto.ios.pages.team_creation.TCVerificationCodePage; +import com.wearezeta.auto.ios.pages.webview.WebViewPage; +import io.cucumber.java.en.When; + +public class LoginSteps { + IOSTestContext context; + + public LoginSteps(IOSTestContext context) { + this.context = context; + } + + private WelcomePage getWelcomePage() { + return context.getPagesCollection().getPage(WelcomePage.class); + } + + private IOSPage getCommonPage() { + return context.getPagesCollection().getPage(IOSPage.class); + } + + private CustomBackendRedirectionPage getCustomBackendRedirectionPage() { + return context.getPagesCollection().getPage(CustomBackendRedirectionPage.class); + } + + private IOSPage getIOSPage() { + return context.getPagesCollection().getPage(IOSPage.class); + } + + private LoginPage getLoginPage() { + return context.getPagesCollection().getPage(LoginPage.class); + } + + private WebViewPage getWebViewPage() { + return context.getPagesCollection().getPage(WebViewPage.class); + } + + private TCVerificationCodePage getVerificationCodePage() { + return context.getPagesCollection().getPage(TCVerificationCodePage.class); + } + + private FirstTimeOverlay getFirstTimeOverlay() { + return context.getPagesCollection().getPage(FirstTimeOverlay.class); + } + + @When("I login to Wire as (.*)") + public void iLogin(String name) { + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(name); + getCommonPage().openDeepLinkForDefault(); + context.startPinging(); + Timedelta.ofSeconds(3).sleep(); + context.stopPinging(); + getWebViewPage().tapOpenButton(); + context.startPinging(); + Timedelta.ofSeconds(3).sleep(); + context.stopPinging(); + getCustomBackendRedirectionPage().tapProceedButton(); + getWelcomePage().tapLoginButton(); + getLoginPage().loginAs(user.getEmail(), user.getPassword()); + if (getCommonPage().isNotNowOnPasswordPromptVisible()) { + getCommonPage().tapNotNowOnPasswordPrompt(); + } + // Accept first time overlay + getFirstTimeOverlay().accept(); + } + + + @When("I login to the default email verified backend as (.*)") + public void iLoginToTheDefaultBackendAsName(String name) throws Exception { + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(name); + + // Open default backend + getCommonPage().openDeepLinkForDefault(); + getCustomBackendRedirectionPage().tapProceedButton(); + + // Enroll simulator in touch ID + context.enrollSimulatorTouchID(); + + // Start email inbox monitoring + getIOSPage().startVerificationEmailMonitoring(user, context); + + // Login as user + getWelcomePage().tapLoginButton(); + getIOSPage().startVerificationEmailMonitoring(user, context); + getLoginPage().loginAs(user.getEmail(), user.getPassword()); + + // Enter email verification code + VerificationMessage verificationInfo = new VerificationMessage(context.getVerificationMessage().get()); + getVerificationCodePage().enterVerificationCode(verificationInfo.getXZetaCode()); + + // Dismiss password prompt + if (getCommonPage().isNotNowOnPasswordPromptVisible()) { + getCommonPage().tapNotNowOnPasswordPrompt(); + } + + // Verify biometric + context.getPagesCollection().getPage(IOSPage.class) + .performTouchID(true); + + // Accept first time overlay + getFirstTimeOverlay().accept(); + + // Verify biometric + context.startPinging(); + Timedelta.ofSeconds(2).sleep(); + context.stopPinging(); + context.getPagesCollection().getPage(IOSPage.class) + .performTouchID(true); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ManageDeviceOverlaySteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ManageDeviceOverlaySteps.java new file mode 100644 index 00000000000..78718170b6e --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ManageDeviceOverlaySteps.java @@ -0,0 +1,54 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.ManageDevicesOverlay; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class ManageDeviceOverlaySteps { + IOSTestContext context; + + public ManageDeviceOverlaySteps(IOSTestContext context) { + this.context = context; + } + + private ManageDevicesOverlay getManageDevicesOverlay() { + return context.getPagesCollection().getPage(ManageDevicesOverlay.class); + } + + /** + * Verify whether Manage Devices overlay is visible + * + * @param shouldNotSee equals to null if the overlay should be visible + */ + @Then("^I (do not )?see Manage Devices overlay$") + public void iSeeManageDevicesOverlay(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Manage Devices overlay is not visible", + getManageDevicesOverlay().waitUntilVisible()); + } else { + assertThat("Manage Devices overlay is visible", + getManageDevicesOverlay().waitUntilInvisible()); + } + } + + /** + * Tap Mange Devices button + * + */ + @When("^I tap Manage Devices button on Devices Overlay$") + public void iTapMangeDevicesButton() { + getManageDevicesOverlay().tapMangeDevicesButton(); + } + + @Then("^I tap Delete for device (.*)$") + public void iTapDeleteForDevice(String deviceName) { + getManageDevicesOverlay().tapDeleteButtonForDevice(deviceName); + } + + @Then("^I tap Delete button on Devices Overlay$") + public void iTapDeleteButtonOnDevicesOverlay() { + getManageDevicesOverlay().tapDeleteButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MapViewPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MapViewPageSteps.java new file mode 100644 index 00000000000..988fedf436e --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MapViewPageSteps.java @@ -0,0 +1,22 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.MapViewPage; +import io.cucumber.java.en.When; + +public class MapViewPageSteps { + IOSTestContext context; + + public MapViewPageSteps(IOSTestContext context) { + this.context = context; + } + + private MapViewPage getMapViewPage() { + return context.getPagesCollection().getPage(MapViewPage.class); + } + + @When("^I tap Send location button from map view$") + public void ITapSendLocationButtonFromMapView() { + getMapViewPage().clickSendLocationButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MentionSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MentionSteps.java new file mode 100644 index 00000000000..e9ad0fbad24 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MentionSteps.java @@ -0,0 +1,103 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.ConversationViewPage; +import io.cucumber.java.en.And; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import com.wearezeta.auto.ios.pages.search.MentionSuggestionsList; +import static org.hamcrest.MatcherAssert.assertThat; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; + +public class MentionSteps { + IOSTestContext context; + + public MentionSteps(IOSTestContext context) { + this.context = context; + } + + private MentionSuggestionsList getSuggestedMentionsList() { + return context.getPagesCollection().getPage(MentionSuggestionsList.class); + } + + private ConversationViewPage getConversationViewPage() { + return context.getPagesCollection().getPage(ConversationViewPage.class); + } + + /** + * Tap on the suggested mention with name x + * + * @param name the name of the suggested mention that should be tapped + */ + @When("^I tap (.*) in the suggested mentions list$") + public void ITapSuggestedMentions(String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + getSuggestedMentionsList().tapSuggestedMention(name); + } + + /** + * Checks if the last message contains mentions + * + * @param doesNot equals to null if message should contain mention + * @param names The names of the users that should be mentioned + */ + @Then("^I see the last message in the conversation view (does not )?contains? mentions? (.*)$") + public void messageContainsMentions(String doesNot, String names) { + for (String name : context.getUsersManager().splitAliases(names)) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + if(doesNot == null) { + assertThat( + String.format("The last message in the conversation does not contain the expected mention @%s", + name), getSuggestedMentionsList().isRecentMention(name)); + } else { + assertThat( + String.format("The last message in the conversation contains the mention @'%s' while this is not expected", + name), getSuggestedMentionsList().isNotRecentMention(name)); + } + } + } + + /** + * Checks for icon in the suggestion list + * + * @param iconName the expected icon + * @param name the name of the user that has the icon + */ + @Then("^I see the (verified|guest|external) icon in the suggestions list for user (.*)$") + public void iSeeIconInSuggestionsList(String iconName, String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + switch (iconName.toLowerCase()) { + case "verified": + assertThat(String.format("The verified icon is not visible for user '%s' in the suggested mentions list", + name), getSuggestedMentionsList().isVerifiedLabelVisibleFor(name)); + break; + case "guest": + assertThat(String.format("The guest icon is not visible for user '%s' in the suggested mentions list", + name), getSuggestedMentionsList().isGuestLabelVisibleFor(name)); + break; + case "external": + assertThat(String.format("The external icon is not visible for user '%s' in the suggested mentions list", + name), getSuggestedMentionsList().isExternalLabelVisibleFor(name)); + break; + default: + throw new IllegalArgumentException(String.format("Unknown view type: %s", iconName)); + } + } + + /** + * Check if the suggestion list is visible + * @param doNotSee whether or not the suggestion list should be visible + */ + @Then("^I (do not )?see the suggested mentions list$") + public void iSeeTheSuggestedMentionsList(String doNotSee) { + if (doNotSee == null) { + assertThat("Suggestion list is invisble while this is not expected", getSuggestedMentionsList().isSuggestionsVisible()); + } else { + assertThat("Suggestion list is visbile while this is not expected", !getSuggestedMentionsList().isSuggestionsVisible()); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MessageDetailsPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MessageDetailsPageSteps.java new file mode 100644 index 00000000000..066ff13a4a3 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MessageDetailsPageSteps.java @@ -0,0 +1,34 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.MessageDetailsPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class MessageDetailsPageSteps { + IOSTestContext context; + + public MessageDetailsPageSteps(IOSTestContext context) { + this.context = context; + } + + private MessageDetailsPage getMessageDetailsPage() { + return context.getPagesCollection().getPage(MessageDetailsPage.class); + } + + @When("^I close the message details$") + public void ICloseTheMessageDetails() { + getMessageDetailsPage().tapCloseButton(); + } + + @Then("^I see user (.*) in the Seen list$") + public void ISeeUserInMessageDetailSeenPage(String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + assertThat(String.format("User name '%s' is not visible in Message Detail Seen list", name), + getMessageDetailsPage().isContactVisibleInSeenTab(name)); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MoveToCustomFolderSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MoveToCustomFolderSteps.java new file mode 100644 index 00000000000..8decdef8d6e --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/MoveToCustomFolderSteps.java @@ -0,0 +1,41 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.pages.MoveToCustomFolderPage; +import com.wearezeta.auto.ios.common.IOSTestContext; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class MoveToCustomFolderSteps { + IOSTestContext context; + + public MoveToCustomFolderSteps(IOSTestContext context) { + this.context = context; + } + + private MoveToCustomFolderPage getMoveToCustomFolderPage() { + return context.getPagesCollection().getPage(MoveToCustomFolderPage.class); + } + + @Given("^I (do not )?see Move to Custom Folder page$") + public void ISeeMoveToPage(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Move to Custom Folder page is not visible after the timeout", + getMoveToCustomFolderPage().isVisible()); + } else { + assertThat("Move to Custom Folder page is still visible after the timeout", + getMoveToCustomFolderPage().isInvisible()); + } + } + + @When("^I tap Create button on Custom Folder page$") + public void iTapCreateNewButton() { + getMoveToCustomFolderPage().tapCreateNewButton(); + } + + @When("^I tap folder \"(.*)\" on Custom Folder page$") + public void iTapACustomFolder(String folderName) { + getMoveToCustomFolderPage().tapOnACustomFolder(folderName); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/PasteDialogSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/PasteDialogSteps.java new file mode 100644 index 00000000000..7f07e42d0b7 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/PasteDialogSteps.java @@ -0,0 +1,20 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.PasteDialog; +import io.cucumber.java.en.When; + +public class PasteDialogSteps { + + private IOSTestContext context; + + public PasteDialogSteps(IOSTestContext context) { + this.context = context; + } + + @When("I tap OK button on paste dialog") + public void iTapOKButton() { + context.getPagesCollection().getPage(PasteDialog.class).tapOKButton(); + } + +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/PicturePreviewPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/PicturePreviewPageSteps.java new file mode 100644 index 00000000000..eee7a8c7c85 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/PicturePreviewPageSteps.java @@ -0,0 +1,37 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.PicturePreviewPage; +import io.cucumber.java.en.When; + +public class PicturePreviewPageSteps { + IOSTestContext context; + + public PicturePreviewPageSteps(IOSTestContext context) { + this.context = context; + } + + private PicturePreviewPage getPicturePreviewPage() { + return context.getPagesCollection().getPage(PicturePreviewPage.class); + } + + @When("^I tap Confirm button on Picture [Pp]review page$") + public void iTapConfirmButton() { + getPicturePreviewPage().tapOkButton(); + } + + @When("^I tap Sketch button on Picture [Pp]review page$") + public void ITapOnSketchButton() { + getPicturePreviewPage().tapSketchButton(); + } + + @When("^I tap Use Photo button on Picture [Pp]review page$") + public void ITapOnPhotoButton() { + getPicturePreviewPage().tapPhotoButton(); + } + + @When("^I tap OK button on Picture [Pp]review page on iPAD$") + public void ITapOnOkButtonOnIPad() { + getPicturePreviewPage().tapOkButtonOnIPad(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/PollMessagesSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/PollMessagesSteps.java new file mode 100644 index 00000000000..268cf73c12b --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/PollMessagesSteps.java @@ -0,0 +1,64 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.PollMessagesPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class PollMessagesSteps { + + IOSTestContext context; + + public PollMessagesSteps(IOSTestContext context) { + this.context = context; + } + + private PollMessagesPage getPollMessagesPage() { + return context.getPagesCollection().getPage(PollMessagesPage.class); + } + + @Then("^I see the poll message contains text \"([^\"]*)\"$") + public void iSeeThePollMessageContainsText(String text) { + assertThat("Poll message text is incorrect.", getPollMessagesPage().isPollMessageTextContains(text)); + } + + @Then("^I see all the poll buttons are in unselected state$") + public void iSeeAllThePollButtonsAreInUnselectedState() { + assertThat("All poll buttons are not in unselected mode.", getPollMessagesPage().areAllPollButtonsUnselected()); + } + + @When("^I tap poll button with the text \"([^\"]*)\"$") + public void iTapPollButtonWithTheText(String text) { + getPollMessagesPage().tapPollButtonWithTheText(text); + } + + @Then("^I see the poll button with the text \"([^\"]*)\" is Confirmed$") + public void iSeeThePollButtonWithTheTextIsSelected(String buttonText) { + assertThat("State of the poll button with text \"" + buttonText + "\" is not confirmed.", getPollMessagesPage().isPollButtonWithTextConfirmed(buttonText)); + } + + @Then("^I see the poll button with the text \"([^\"]*)\" is Unselected$") + public void iSeeThePollButtonWithTheTextIsUnselected(String buttonText) { + assertThat("State of the poll button with text \"" + buttonText + "\" is selected but it should not be.", getPollMessagesPage().isPollButtonWithTextUnselected(buttonText)); + + } + + @Then("^I see the poll button with the text \"([^\"]*)\" is Selected$") + public void iSeeThePollButtonWithTheTextIsLoading(String buttonText) { + assertThat("State of the poll button with text \"" + buttonText + "\" is not selected.", getPollMessagesPage().isPollButtonWithTextSelected(buttonText)); + } + + + @Then("^I (do not )?see error contains \"(.*)\" under the poll button$") + public void iSeeErrorUnderButtonInPollMessage(String shouldNotSee, String error) { + if (shouldNotSee == null) { + assertThat("Wrong error message is \" + error + \" + not visible", + getPollMessagesPage().isPollErrorMessageVisible(error)); + } else { + assertThat("Wrong error message is \" + error + \" + not visible", + getPollMessagesPage().isPollErrorMessageInvisible(error)); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ReactionsEditMenuPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ReactionsEditMenuPageSteps.java new file mode 100644 index 00000000000..d938ef33f89 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/ReactionsEditMenuPageSteps.java @@ -0,0 +1,154 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.ReactionsEditMenuPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class ReactionsEditMenuPageSteps { + + IOSTestContext context; + + public ReactionsEditMenuPageSteps(IOSTestContext context) { + this.context = context; + } + + @Then("^I see menu with quick reactions and other items$") + public void iSeeMenu() { + assertThat("Menu with quick reactions and other items is not visible", + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).isVisible()); + } + + @When("^I tap on (.*) reaction in quick reactions$") + public void iTapOnReaction(String reaction) { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).iTapQuickReaction(reaction); + } + + @When("^I do not see forward button on edit menu$") + public void iDontSeeForward() { + assertThat("Forward button > still visible", + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).isForwardButtonInvisible()); + } + + @When("^I tap on Copy on edit menu$") + public void iTapCopy() { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).tapCopy(); + } + + @Then("^I see Copy on edit menu$") + public void iSeeCopy() { + assertThat("Edit menu item not visible", + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).isCopyVisible()); + } + + @Then("^I do not see Copy on edit menu$") + public void iDoNotSeeCopy() { + assertThat("Edit menu item still visible", + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).isCopyInvisible()); + } + + @When("^I tap on Delete on edit menu$") + public void iTapDelete() { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).tapDelete(); + } + + @Then("^I see Delete on edit menu$") + public void iSeeDelete() { + assertThat("Edit menu item not visible", + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).isDeleteVisible()); + } + + @When("^I tap on Details on edit menu$") + public void iTapDetails() { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).tapDetails(); + } + + @When("^I tap on Download on edit menu$") + public void iTapDownload() { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).tapDownload(); + } + + @Then("^I do not see Download on edit menu$") + public void iDoNotSeeDownload() { + assertThat("Edit menu item still visible", + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).isDownloadInvisible()); + } + + @When("^I tap on Edit on edit menu$") + public void iTapEdit() { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).tapEdit(); + } + + @When("^I tap on Like on edit menu$") + public void iTapLike() { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).tapLike(); + } + + @When("^I tap on Paste on edit menu$") + public void iTapPaste() { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).tapPaste(); + } + + @Then("^I do not see Paste on edit menu$") + public void iDoNotSeePaste() { + assertThat("Edit menu item still visible", + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).isPasteInvisible()); + } + + @When("^I tap on Reply on edit menu$") + public void iTapReply() { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).tapReply(); + } + + @Then("^I see Reply on edit menu$") + public void iSeeReply() { + assertThat("Edit menu item not visible", + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).isReplyVisible()); + } + + @When("^I tap on Save on edit menu$") + public void iTapSave() { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).tapSave(); + } + + @Then("^I see Save on edit menu$") + public void iSeeSave() { + assertThat("Edit menu item not visible", + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).isSaveVisible()); + } + + @Then("^I do not see Save on edit menu$") + public void iDoNotSeeSave() { + assertThat("Edit menu item still visible", + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).isSaveInvisible()); + } + + @When("^I tap on Select All on edit menu$") + public void iTapSelectAll() { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).tapSelectAll(); + } + + @When("^I tap on Share on edit menu$") + public void iTapShare() { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).tapShare(); + } + + @Then("^I see Share on edit menu$") + public void iSeeShare() { + assertThat("Edit menu item not visible", + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).isShareVisible()); + } + + @Then("^I do not see Share on edit menu$") + public void iDoNotSeeShare() { + assertThat("Edit menu item still visible", + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).isShareInvisible()); + } + + @When("^I tap on Cancel on edit menu$") + public void iTapCancel() { + context.getPagesCollection().getPage(ReactionsEditMenuPage.class).tapCancel(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/RegistrationPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/RegistrationPageSteps.java new file mode 100644 index 00000000000..af1aa234c0c --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/RegistrationPageSteps.java @@ -0,0 +1,207 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.backend.BackendConnections; +import com.wearezeta.auto.common.email.messages.ActivationMessage; +import com.wearezeta.auto.common.email.messages.VerificationMessage; +import com.wearezeta.auto.common.email.messages.WireMessage; +import com.wearezeta.auto.common.email.handlers.ISupportsMessagesPolling; +import com.wearezeta.auto.common.email.MailboxProvider; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.common.usrmgmt.NoSuchUserException; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.IOSPage; +import com.wearezeta.auto.ios.pages.RegistrationPage; +import com.wearezeta.auto.ios.pages.team_creation.TCVerificationCodePage; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.HashMap; +import java.util.Map; + +public class RegistrationPageSteps { + IOSTestContext context; + + public RegistrationPageSteps(IOSTestContext context) { + this.context = context; + } + + private RegistrationPage getRegistrationPage() { + return context.getPagesCollection().getPage(RegistrationPage.class); + } + + private TCVerificationCodePage getVerificationCodePage() { + return context.getPagesCollection().getPage(TCVerificationCodePage.class); + } + + private IOSPage getIOSPage() { + return context.getPagesCollection().getPage(IOSPage.class); + } + + /** + * Verifies whether registration screen is the current screen + */ + @Given("I see registration screen") + public void ISeeRegistrationScreen() { + assertThat("Registration screen is not visible", getRegistrationPage().isVisible()); + } + + @Then("I see password failure message") + public void ISeePasswordFailureMessage() { + assertThat("password failure message is not visible", getRegistrationPage().isPasswordFailureVisible()); + } + + @Given("I see password rules") + public void ISeePasswordRules() { + assertThat("password rules is not visible", getRegistrationPage().isPasswordRulesVisible()); + } + + /** + * Click on I AGREE button to accept terms of service + */ + @When("^I accept terms of service$") + public void IAcceptTermsOfService() { + getRegistrationPage().clickAcceptTOCButton(); + } + + @When("^I enter activation code for the email address of (.*)") + public void iEnterActivationCodeForEmailString(String user) { + final ClientUser clientUser = context.getUsersManager().findUserByNameOrNameAlias(user); + getRegistrationPage().inputActivationCode(clientUser); + } + + @When("^I enter registration name \"(.*)\"$") + public void IEnterName(String name) { + try { + context.setUserToRegister(context.getUsersManager().findUserByNameOrNameAlias(name)); + getRegistrationPage().typeName(context.getUserToRegister().getName()); + } catch (NoSuchUserException e) { + getRegistrationPage().typeName(name); + } + } + + @When("^I input (custom )?name (.*) and commit it$") + public void IInputNameAndCommit(String isCustom, String name) { + if (isCustom == null) { + IEnterName(name); + } else { + getRegistrationPage().typeName(name); + } + getRegistrationPage().tapNameConfirmButton(); + } + + @When("^I set the username to (.*)$") + public void IEnterUsername(String name) { + context.setUserToRegister(context.getUsersManager().findUserByUniqueUsernameAlias(name)); + getRegistrationPage().typeUsername(context.getUserToRegister().getUniqueUsername()); + getRegistrationPage().tapUsernameConfirmButton(); + } + + @When("^I enter registration email \"(.*)\"$") + public void IEnterEmail(String email) { + try { + context.setUserToRegister(context.getUsersManager().findUserByEmailOrEmailAlias(email)); + getRegistrationPage().typeEmail(context.getUserToRegister().getEmail()); + } catch (NoSuchUserException e) { + getRegistrationPage().typeEmail(email); + } + getRegistrationPage().tapNameConfirmButton(); + } + + @When("^I set the password to \"(.*)\"$") + public void IEnterPassword(String password) { + try { + context.setUserToRegister(context.getUsersManager().findUserByPasswordAlias(password)); + getRegistrationPage().typePassword(context.getUserToRegister().getPassword()); + } catch (NoSuchUserException e) { + getRegistrationPage().typePassword(password); + } + getRegistrationPage().tapPasswordConfirmButton(); + } + + @When("^I clear password input$") + public void IClearPasswordInput() { + getRegistrationPage().clearPasswordInput(); + } + + /** + * Start monitoring thread for activation email for the particular mailbox + * + * @param mbox mailbox email address/an alias + */ + @When("^I start activation email monitoring on mailbox (.*)") + public void IStartActivationEmailMonitoringOnMbox(String mbox) throws Exception { + ClientUser user = context.getUsersManager().findUserByEmailOrEmailAlias(mbox); + + final Map expectedHeaders = new HashMap<>(); + expectedHeaders.put(WireMessage.ZETA_PURPOSE_HEADER_NAME, ActivationMessage.MESSAGE_PURPOSE); + ISupportsMessagesPolling mailbox = MailboxProvider.getInstance(BackendConnections.get(user), user.getEmail()); + context.setActivationMessage(mailbox.getMessage(expectedHeaders, ActivationMessage.ACTIVATION_TIMEOUT)); + } + + @When("^I start verification email monitoring on mailbox (.*)") + public void IStartVerificationEmailMonitoringOnMbox(String mbox) throws Exception { + ClientUser user = context.getUsersManager().findUserByEmailOrName(mbox); + getIOSPage().startVerificationEmailMonitoring(user, context); + } + + @When("^I enter verification code from Email$") + public void ICheckVerificationCodeInSubjectAndBody() throws Exception { + VerificationMessage verificationInfo = new VerificationMessage(context.getVerificationMessage().get()); + getVerificationCodePage().enterVerificationCode(verificationInfo.getXZetaCode()); + + } + + @When("^I wait until (\\d) mails arrived for (.*)$") + public void IWaitUntilXMailsArrived(int count, String emailAlias) throws Exception { + ClientUser user = context.getUsersManager().findUserByEmailOrEmailAlias(emailAlias); + context.startPinging(); + try { + ISupportsMessagesPolling mbox = MailboxProvider.getInstance(BackendConnections.get(user), user.getEmail()); + mbox.waitUntilMessagesCountReaches(user.getEmail(), count, Timedelta.ofMillis(0)); + } finally { + context.stopPinging(); + } + } + + /** + * Activate email address using activation keys as soon as the corresponding message is received. + * This steps expects mailbox monitoring to be already running + * + * @param address the expected email address for a user + * @param user user name/alias + */ + @Then("^I verify email address (.*) for (.*)") + public void IVerifyEmail(String address, String user) throws Exception { + if (context.getActivationMessage() == null) { + throw new IllegalStateException("Activation email monitoring is expected to be running"); + } + context.getCommonSteps().activateRegisteredUserByEmail(context.getActivationMessage()); + address = context.getUsersManager() + .replaceAliasesOccurrences(address, ClientUsersManager.FindBy.EMAIL_ALIAS); + final ClientUser dstUser = context.getUsersManager() + .findUserByNameOrNameAlias(user); + dstUser.setEmail(address); + context.setActivationMessage(null); + } + + /** + * Verifies that the email verification reminder on the login page is + * displayed + */ + @Then("^I see email verification reminder$") + public void ISeeEmailVerificationReminder() { + assertThat("Prompt not visible", getRegistrationPage().isEmailVerificationPromptVisible()); + } + + /** + * Taps back button on registration screen + */ + @When("^I tap Back button on Registration page$") + public void ITapBackButtonOnRegistrationPage() { + getRegistrationPage().tapBackButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/SearchUIPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/SearchUIPageSteps.java new file mode 100644 index 00000000000..35ee86cbe9c --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/SearchUIPageSteps.java @@ -0,0 +1,243 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.backend.BackendConnections; +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.ios.common.IOSTestContext; +import io.cucumber.java.en.Given; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.pages.SearchUIPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +public class SearchUIPageSteps { + IOSTestContext context; + + public SearchUIPageSteps(IOSTestContext context) { + this.context = context; + } + + private SearchUIPage getSearchUIPage() { + return context.getPagesCollection() + .getPage(SearchUIPage.class); + } + + /** + * Type in text in Search input field + * + * @param text text to input + * @param isUpper null if should be input as it is + * @param shouldClearBeforeInput equals to null if the field should not be cleared first + */ + @When("^I type \"(.*)\" in (cleared )?Search UI input field( in upper case)?$") + public void ITypeInSearchInput(String text, String shouldClearBeforeInput, String isUpper) { + text = context.getUsersManager() + .replaceAliasesOccurrences(text, ClientUsersManager.FindBy.NAME_ALIAS, + ClientUsersManager.FindBy.EMAIL_ALIAS, ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + getSearchUIPage().typeSearchQuery((isUpper == null) ? text : text.toUpperCase(), + shouldClearBeforeInput != null); + } + + @When("^I search user (.*) by email in Search UI input field$") + public void iSearchByEmail(String userAlias) { + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(userAlias); + getSearchUIPage().typeSearchQuery(user.getEmail(), false); + } + + @When("^I search user (.*) by handle and domain in Search UI input field$") + public void iSearchByHandleAndDomain(String userAlias) { + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(userAlias); + String domain = BackendConnections.get(user).getDomain(); + getSearchUIPage().typeSearchQuery(user.getUniqueUsername() + "@" + domain, false); + } + + @When("^I clear Search UI input field$") + public void iClearSearchInput() { + getSearchUIPage().clearSearchInput(); + } + + @When("^I enter unique username with backend domain of user (.*) in (cleared )?Search UI input field$") + public void ITypeUniqueUsernameAndDomainOfUser(String userAlias, String shouldClearBeforeInput) { + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(userAlias); + String backendName = BackendConnections.get(user).getDomain(); + + getSearchUIPage().typeSearchQuery(String.format("%s@%s", user.getUniqueUsername(), backendName), shouldClearBeforeInput != null); + } + + /** + * Fills in search field pointed amount of letters from username/conversation starting from the first one + * + * @param count amount of letters to be input + * @param name user name + * @param shouldBeCleared equals to null oif the input field should not be cleaned before input + */ + @When("^I type first (\\d+) letters? of (?:user|conversation) name \"(.*)\" into (cleared )?Search UI input field$") + public void ITypeXLettersIntoSearchInput(int count, String name, String shouldBeCleared) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS, + ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + if (name.length() > count) { + getSearchUIPage().typeSearchQuery(name.substring(0, count), shouldBeCleared != null); + } else { + throw new IllegalArgumentException(String.format("Name is only %s chars length. Put in step a less value", + name.length())); + } + } + + /** + * Verify that conversation or service is presented in search results + * + * @param name conversation or service name to search + * @param shouldNotExist equals to null if the conversation should be visible + * @param times defines count of entities in search result if set + */ + @When("^I see the (?:conversation|service) \"(.*)\" (does not )?exists? (\\d+ times? )?in Search results$") + public void ISeeConversationIsFoundInSearchResult(String name, String shouldNotExist, + String times) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + int expectedCount = 1; + if (times != null) { + expectedCount = Integer.parseInt(times.replaceAll("[\\D]", "")); + } + if (expectedCount == 1) { + if (shouldNotExist == null) { + assertThat(String.format("The conversation '%s' does not exist in Search results", name), + getSearchUIPage().isElementFoundInSearch(name)); + } else { + assertThat( + String.format("The conversation '%s' exists in Search results, but it should not", name), + getSearchUIPage().isElementNotFoundInSearch(name)); + } + } else { + final int actualCount = getSearchUIPage().getOccurrencesCount(name); + if (shouldNotExist == null) { + assertThat(String.format("The conversation '%s' should occur %d times in Search result", + name, expectedCount), actualCount, equalTo(expectedCount)); + } else { + assertThat(String.format("The conversation '%s' should not occur %d times in Search result", + name, expectedCount), actualCount, not(equalTo(expectedCount))); + } + } + } + + /** + * (Un)Select pointed amount of avatars from top people in a row starting from the first one + * + * @param count amount of avatars that should be (un)selected + */ + @Then("^I (?:unselect|select) (\\d+) avatars? from Top connections$") + public void ISelectTopConnectionAvatars(int count) { + getSearchUIPage().tapTopConnectionsAvatars(count); + } + + /** + * Tap on top connection contact avatar by pointed id order + * + * @param position contact position in top people. Starts from 1 + */ + @When("^I tap the (\\d+)\\w+ avatar in Top connections$") + public void IClickOnTopConnectionByOrder(int position) { + getSearchUIPage().tapOnTopConnectionAvatarByOrder(position); + } + + /** + * Click on conversation or service in search result with pointed name + * + * @param name conversation or service name + */ + @When("^I tap on (?:conversation|service) (.*) in search result$") + public void ITapOnConversationFromSearch(String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + getSearchUIPage().selectElementInSearchResults(name); + } + + @When("^I tap Create Group button on Search UI page$") + public void ITapCreateGroupButton() { + getSearchUIPage().tapCreateGroupButton(); + } + + @When("^I tap X button on Search UI page$") + public void ITapCloseButton() { + getSearchUIPage().tapCloseButton(); + } + + @When("^I tap Send Invite button on Search UI page$") + public void ITapSendInviteButton() { + getSearchUIPage().tapSendInviteButton(); + } + + @When("^I tap Copy Invite button on Search UI page$") + public void ITapCopyInviteButton() { + getSearchUIPage().tapCopyInviteButton(); + } + + @Then("^I (do not )?see Create Group button on Search UI page$") + public void ISeeCreateGroupButton(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The button Create Group is expected to be visible", + getSearchUIPage().isCreateGroupButtonVisible()); + } else { + assertThat("The button Create Group is expected to be invisible", + getSearchUIPage().isCreateGroupButtonInvisible()); + } + } + + @Then("^I (do not )?see Create Guest Room button on Search UI page$") + public void ISeeCreateGuestRoomButton(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The button Create Guest Room is expected to be visible", + getSearchUIPage().isCreateGuestRoomButtonVisible()); + } else { + assertThat("The button Create Guest Room is expected to be invisible", + getSearchUIPage().isCreateGuestRoomButtonInvisible()); + } + } + + /** + * Presses the instant connect plus button + * + * @param nameAlias user name/aias + */ + @When("^I tap the instant connect button next to (.*)") + public void ITapInstantConnectButton(String nameAlias) { + nameAlias = context.getUsersManager() + .replaceAliasesOccurrences(nameAlias, ClientUsersManager.FindBy.NAME_ALIAS); + getSearchUIPage().tapInstantConnectButton(nameAlias); + } + + @Given("^I (do not )?see the service \"([^\"]*)\" exists in service search results$") + public void iSeeTheServiceExistsInServiceSearchResults(String shouldNotSee, String serviceName) { + if (shouldNotSee == null) { + assertThat("Service " + serviceName + " does not exist in search result.", + getSearchUIPage().isServiceVisibleInSearchResult(serviceName)); + } else { + assertThat("Service \" + serviceName + \" exist in search result but it should not.", + getSearchUIPage().isServiceInVisibleInSearchResult(serviceName)); + } + } + + @Given("^I tap on service \"([^\"]*)\" in service search result$") + public void iTapOnServiceServiceNameInServiceSearchResult(String serviceName) { + getSearchUIPage().tapOnService(serviceName); + } + + @Then ("^I open create group screen$") + public void iOpenCreateGroupScreen() { + getSearchUIPage().iOpenCreateGroupScreen(); + } + @Then("^I (do not )?see contact (.*) in Search UI$") + public void iSeeContactInSearchUI(String shouldNotSee, String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + assertThat(String.format("The contact '%s' is not visible", name), getSearchUIPage().isContactVisible(name)); + } else { + assertThat(String.format("The contact '%s' is visible while it should not be", name), getSearchUIPage().isContactInvisible(name)); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/SketchPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/SketchPageSteps.java new file mode 100644 index 00000000000..f3b125e7328 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/SketchPageSteps.java @@ -0,0 +1,32 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.SketchPage; + +import io.cucumber.java.en.When; + +public class SketchPageSteps { + IOSTestContext context; + + public SketchPageSteps(IOSTestContext context) { + this.context = context; + } + + private SketchPage getSketchPage() { + return context.getPagesCollection() + .getPage(SketchPage.class); + } + + /** + * randomly draws lines in sketch feature + */ + @When("^I draw a random sketch$") + public void IDrawRandomSketches() { + getSketchPage().sketchRandomLines(); + } + + @When("^I tap Send button on Sketch page$") + public void ITapSendButton() { + getSketchPage().tapSendButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/StatusActionSheetSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/StatusActionSheetSteps.java new file mode 100644 index 00000000000..ae99a94df37 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/StatusActionSheetSteps.java @@ -0,0 +1,28 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.StatusActionSheetPage; +import io.cucumber.java.en.Then; + +public class StatusActionSheetSteps { + IOSTestContext context; + + public StatusActionSheetSteps(IOSTestContext context) { + this.context = context; + } + + private StatusActionSheetPage getStatusActionSheetPage() { + return context.getPagesCollection() + .getPage(StatusActionSheetPage.class); + } + + /** + * I tap the status that I want to set my profile to + * + * @param statusName the name of the status that I want (None, Available, Busy or Away) + */ + @Then("^I tap status (None|Available|Busy|Away)$") + public void iTapMyNameInConversationView(String statusName) { + getStatusActionSheetPage().tapStatusName(statusName); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/TestServiceSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/TestServiceSteps.java new file mode 100644 index 00000000000..abb96028b76 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/TestServiceSteps.java @@ -0,0 +1,343 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.CommonSteps; +import com.wearezeta.auto.common.CommonUtils; +import com.wearezeta.auto.common.Config; +import com.wearezeta.auto.common.backend.models.ReactionType; +import com.wearezeta.auto.common.log.ZetaLogger; +import com.wearezeta.auto.common.misc.EphemeralTimeConverter; +import com.wearezeta.auto.common.testservice.models.LegalHoldStatus; +import com.wearezeta.auto.common.imagecomparator.QRCode; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.file.Files; +import java.util.concurrent.*; +import java.util.logging.Logger; + +import static com.wearezeta.auto.common.CommonSteps.*; + +public class TestServiceSteps { + + private static final Logger log = ZetaLogger.getLog(TestServiceSteps.class.getSimpleName()); + IOSTestContext context; + + public TestServiceSteps(IOSTestContext context) { + this.context = context; + } + + private CommonSteps getCommonSteps() { + return context.getCommonSteps(); + } + + private ClientUsersManager getUsersManager() { + return context.getUsersManager(); + } + + @When("^User (.*) deletes? (everywhere )?the recent message from (?:user|group conversation) (.*)$") + public void UserXDeleteLastMessage(String userNameAlias, String deleteEverywhere, String dstNameAlias) { + getCommonSteps().userDeletesLatestMessage(userNameAlias, dstNameAlias, null, + deleteEverywhere != null); + } + + @When("^User (.*) shares? the default location to (?:user|group conversation) (.*) via device (.*)") + public void UserXSharesLocationTo(String senderAlias, String convoName, String deviceName) { + getCommonSteps().userSendsLocationToConversation(senderAlias, convoName, deviceName, + context.getSelfDeletingMessageTimeout(senderAlias, convoName), + 0, 0, "location", 1); + } + + @When("^User (.*) sends (.*) sized file with MIME type (.*) and name (.*)(?: via device (.*))? to conversation (.*)$") + public void iXSizedSendFile(String contact, String size, String mimeType, String fileName, String deviceName, + String dstConvoName) throws Exception { + String path = Files.createTempDirectory("zautomation") + .toAbsolutePath().toString().replace("%40", "@"); + RandomAccessFile f = new RandomAccessFile(path + "/" + fileName, "rws"); + int fileSize = Integer.valueOf(size.replaceAll("\\D+", "").trim()); + if (size.contains("MB")) { + f.setLength(fileSize * 1024 * 1024); + } else if (size.contains("KB")) { + f.setLength(fileSize * 1024); + } else { + f.setLength(fileSize); + } + f.close(); + context.startPinging(); + context.getCommonSteps().userSendsFileToConversation(contact, dstConvoName, + deviceName, context.getCommonSteps().getEphemeralTimeout(contact, dstConvoName), + path + "/" + fileName, mimeType); + context.stopPinging(); + } + + @Deprecated // Please use above step instead for sending "temporary" files + @When("^User (.*) sends? (temporary )?file (.*) having MIME type (.*) to (?:single user|group) conversation (.*) using " + + "device (.*)$") + public void UserSendsFile(String sender, String isTemporary, String fileName, String mimeType, + String convoName, String deviceName) { + String root; + if (isTemporary == null) { + if (mimeType.toLowerCase().contains("audio")) { + // send audio with metadata through ETS + root = Config.current().getAudioPath(getClass()); + context.getCommonSteps().userSendsAudioToConversation(sender, convoName, deviceName, + context.getSelfDeletingMessageTimeout(sender, convoName), root + File.separator + fileName, mimeType, Timedelta.ofSeconds(15)); + } else if (mimeType.toLowerCase().contains("video")) { + // Send video with metadata through ETS + root = Config.current().getVideoPath(getClass()); + context.getCommonSteps().userSendsVideoToConversation(sender, convoName, deviceName, + context.getSelfDeletingMessageTimeout(sender, convoName), root + File.separator + fileName, mimeType, Timedelta.ofSeconds(15), new int[]{800,600}); + } else { + //send file without ETS metadata + if (mimeType.toLowerCase().contains("image")) { + root = Config.current().getImagesPath(getClass()); + } else { + root = Config.current().getAudioPath(getClass()); + } + getCommonSteps().userSendsFileToConversation(sender, convoName, deviceName, + context.getSelfDeletingMessageTimeout(sender, convoName),root + File.separator + fileName, + mimeType); + } + } else { + // send temporary file + root = Config.current().getBuildPath(getClass()); + getCommonSteps().userSendsFileToConversation(sender, convoName, deviceName, + context.getSelfDeletingMessageTimeout(sender, convoName), root + File.separator + fileName, + mimeType); + } + } + + @Given("^Users? adds? the following devices?: (.*)") + public void UsersAddDevices(String mappingAsJson) { + getCommonSteps().usersAddDevices(mappingAsJson, false); + } + + @Given("^Users? of team owned by (.*) adds? the following 2FA devices?: (.*)") + public void UsersAddDevices(String teamOwnerAlias, String mappingAsJson) { + getCommonSteps().usersAdd2FADevices(teamOwnerAlias, mappingAsJson); + } + + @Given("^User (\\w+) pings conversation (.*)") + public void UserPingedConversation(String pingFromUserNameAlias, String dstConversationName) { + getCommonSteps().userPingsConversation(pingFromUserNameAlias, dstConversationName, + FIRST_AVAILABLE_DEVICE, context.getSelfDeletingMessageTimeout(pingFromUserNameAlias, dstConversationName)); + } + + @Given("^User (.*) sends? (\\d+) messages? using device (.*) to (?:user|group conversation) (.*)$") + public void userSendXMessagesToConversationUsingDevice(String msgFromUserNameAlias, + int msgsCount, String deviceName, + String conversationName) { + for (int i = 0; i < msgsCount; i++) { + getCommonSteps().userSendsGenericMessageToConversation(msgFromUserNameAlias, conversationName, + deviceName, context.getSelfDeletingMessageTimeout(msgFromUserNameAlias, conversationName), + DEFAULT_AUTOMATION_MESSAGE, LegalHoldStatus.DISABLED); + } + } + + @Given("^User (.*) (sets|changes) the unique username( to \".*\")?$") + public void userSetsUniqueUsername(String userAs, String action, String uniqueUsername) { + switch (action.toLowerCase()) { + case "sets": + getCommonSteps().usersSetUniqueUsername(userAs); + break; + case "changes": + if (uniqueUsername == null) { + throw new IllegalArgumentException("Unique username is mandatory to set"); + } + // Exclude quotes + uniqueUsername = uniqueUsername.substring(5, uniqueUsername.length() - 1); + uniqueUsername = getUsersManager().replaceAliasesOccurrences(uniqueUsername, + ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + getCommonSteps().userChangesUniqueUsername(userAs, uniqueUsername); + break; + default: + throw new IllegalArgumentException(String.format("Unknown action '%s'", action)); + + } + } + + @When("^User (.*) (likes|unlikes|received) the recent message from (?:user|group conversation) (.*)$") + @Deprecated // Use step to toggle reaction instead + public void userReactLastMessage(String userNameAlias, String reactionType, String dstNameAlias) { + switch (reactionType.toLowerCase()) { + case "likes": + getCommonSteps().userReactsToLatestMessage(userNameAlias, dstNameAlias, null, + ReactionType.LIKE); + break; + case "unlikes": + getCommonSteps().userReactsToLatestMessage(userNameAlias, dstNameAlias, null, + ReactionType.UNLIKE); + break; + case "received": + getCommonSteps().userSendsDeliveryConfirmationForLastEphemeralMessage(userNameAlias, dstNameAlias, null); + break; + default: + throw new IllegalArgumentException(String.format("Cannot identify the reaction type '%s'", + reactionType)); + } + } + + @Then("^User (.*) marks the recent message as read in conversation (.*) via device (.*)$") + public void userMarksMessageRead(String receiverAlias, String convoName, String deviceName) { + getCommonSteps().userSendsReadConfirmationForRecentMessage(receiverAlias, convoName, deviceName); + } + + @Given("^User (.*) sends (\\d+) (image|video|audio|temporary) files? (.*) to conversation (.*)") + public void UserSendsMultiplePictures(String senderUserNameAlias, int count, + String fileType, String fileName, + String dstConversationName) { + getCommonSteps().userSendsMultipleMedias(senderUserNameAlias, dstConversationName, + context.getSelfDeletingMessageTimeout(senderUserNameAlias, dstConversationName), + count, fileType, fileName); + } + + @Given("^User (.*) sends image with QR code containing \"(.*)\" to conversation (.*)") + public void WhenUserSendQRImageToConv(String senderUserNameAlias, String text, String dstConversationName) throws IOException { + File tempFile = File.createTempFile("zautomation", ".png"); + tempFile.deleteOnExit(); + ImageIO.write(QRCode.generateCode(text, Color.BLACK, Color.WHITE, 500, 1), "png", tempFile); + getCommonSteps().userSendsImageToConversationViaTestservice(senderUserNameAlias, dstConversationName, FIRST_AVAILABLE_DEVICE, tempFile.getPath(), + context.getSelfDeletingMessageTimeout(senderUserNameAlias, dstConversationName)); + } + + @Given("^User (.*) sends (\\d+) (default|long|\".*\") messages? to conversation (.*)") + public void UserSendsMultipleMessages(String senderUserNameAlias, int count, + String msg, String dstConversationName) { + context.startPinging(); + try { + getCommonSteps().userSendsMultipleMessages(senderUserNameAlias, dstConversationName, + context.getSelfDeletingMessageTimeout(senderUserNameAlias, dstConversationName), + count, msg, DEFAULT_AUTOMATION_MESSAGE, LegalHoldStatus.DISABLED); + } finally { + context.stopPinging(); + } + } + + @Given("^User (.*) sends invite link for conversation (.*) message to conversation (.*)") + public void UserSendsInviteLinkForConversationMessageToConversation(String senderUserNameAlias, String conversationTitle, String dstConversationName) { + String msg = getCommonSteps().getInviteLinkOfConversation(senderUserNameAlias, conversationTitle); + + context.startPinging(); + try { + getCommonSteps().userSendsMultipleMessages(senderUserNameAlias, dstConversationName, + context.getSelfDeletingMessageTimeout(senderUserNameAlias, dstConversationName), + 1, msg, DEFAULT_AUTOMATION_MESSAGE, LegalHoldStatus.DISABLED); + } finally { + context.stopPinging(); + } + } + + @Given("^User (.*) sends link preview for \"(.*)\" to conversation (.*)") + public void userSendsLinkPreview(String senderUserNameAlias, String url, String dstConversationName) + throws IOException { + context.startPinging(); + try { + File tempFile = File.createTempFile("zautomation", ".png"); + try { + ImageIO.write(QRCode.generateCode(url, Color.BLACK, Color.WHITE, 64, 1), + "png", tempFile); + getCommonSteps().userSendsLinkPreview(senderUserNameAlias, dstConversationName, null, + Timedelta.ofMillis(0), url, "Link preview for " + url, tempFile.getAbsolutePath()); + } finally { + tempFile.delete(); + } + } finally { + context.stopPinging(); + } + } + + @Given("^User (.*) sends (\\d+) (default|long|\".*\") messages? under (legal hold|unknown state) to conversation (.*)") + public void UserSendsMultipleMessagesUnderLegalHold(String senderUserNameAlias, int count, + String msg, String status, String dstConversationName) { + if (status.equals("legal hold")) { + getCommonSteps().userSendsMultipleMessages(senderUserNameAlias, dstConversationName, + context.getSelfDeletingMessageTimeout(senderUserNameAlias, dstConversationName), + count, msg, DEFAULT_AUTOMATION_MESSAGE, LegalHoldStatus.ENABLED); + } else { + getCommonSteps().userSendsMultipleMessages(senderUserNameAlias, dstConversationName, + context.getSelfDeletingMessageTimeout(senderUserNameAlias, dstConversationName), + count, msg, DEFAULT_AUTOMATION_MESSAGE, LegalHoldStatus.UNKNOWN); + } + } + + @When("^User (.*) sends delivery confirmation for the recent message in (.*) conversation") + public void userSendsDeliveryConfirmationForRecentMessage(String receiverAlias, String convoName) { + getCommonSteps().userSendsDeliveryConfirmationForRecentMessage(receiverAlias, convoName, + FIRST_AVAILABLE_DEVICE); + } + + @When("^User (.*) sends poll message \"(.*)\" with title \"(.*)\" and buttons \"(.*)\"(?: via device (.*))? to conversation (.*)$") + public void userSendPollMessageToConversation(String msgFromUserNameAlias, + String msg, String title, String buttons, String deviceName, String convoName) { + context.startPinging(); + // We timeout after 2 minutes because ETS sometimes crashes + final ExecutorService service = Executors.newSingleThreadExecutor(); + try { + final Future f = service.submit(() -> { + context.getCommonSteps().userSendsPollMessageToConversation(msgFromUserNameAlias, convoName, + deviceName, NO_EXPIRATION, msg, title, buttons, + LegalHoldStatus.DISABLED); + }); + f.get(2, TimeUnit.MINUTES); + } catch (final TimeoutException e) { + log.severe("Sending poll message via Testservice timed out: " + e.getMessage()); + } catch (final Exception e) { + log.severe("Sending poll message via Testservice failed: " + e.getMessage()); + } finally { + service.shutdown(); + context.stopPinging(); + } + } + + @When("^User (.*) sends button action confirmation to user (.*) on the latest poll(?: via device (.*))? in conversation (.*) with button \"(.*)\"$") + public void userSendsButtonActionConfirmationOnPollMessage(String senderAlias, String receiverAlias, String deviceName, String dstConvoName, String buttonText) { + context.startPinging(); + try { + context.getCommonSteps().userSendsButtonActionConfirmationToLatestPollMessage(senderAlias, receiverAlias, deviceName, dstConvoName, buttonText); + } finally { + context.stopPinging(); + } + } + + @Given("^User (.*) sends deep link for conversation (.*) to conversation (.*)") + public void UserSendsDeepLink(String senderUserNameAlias, String srcConversationName, String dstConversationName) { + String deeplink = getCommonSteps().getDeepLinkForConversation(srcConversationName, senderUserNameAlias); + deeplink = CommonUtils.formatMarkdownURL("deep link", deeplink); + getCommonSteps().userSendsMessageToConversation(senderUserNameAlias, dstConversationName,null,NO_EXPIRATION, deeplink, LegalHoldStatus.DISABLED); + } + + @When("^User (.*) sends? ephemeral message \"?(.*?)\"?\\s? with timer (10 seconds|5 minutes|1 hour|1 day|1 week|4 weeks) (?:via device (.*)\\s)?to (user|group conversation) (.*)$") + public void userSendEphemeralMessageToConversation(String msgFromUserNameAlias, + String msg, String msgTimer, String deviceName, String convotype, String dstConvoName) { + long msgTimerInMs = EphemeralTimeConverter.asMillis(msgTimer); + getCommonSteps().userSendsMessageToConversation(msgFromUserNameAlias, dstConvoName, deviceName, + Timedelta.ofMillis(msgTimerInMs), msg, LegalHoldStatus.DISABLED); + } + + @When("^User (.*) sends message \"(.*?)\" as reply to last message of conversation (.*) via device (.*)?$") + public void userRepliesToLatestMessage(String senderAlias, String message, String conversationName, String deviceName) { + getCommonSteps().userRepliesToLatestMessage(senderAlias, conversationName, deviceName, NO_EXPIRATION, message); + } + + @When("^User (.*) sends message \"(.*?)\" as reply to the last message of conversation (.*)$") + public void userRepliesToLatestMessage(String senderAlias, String message, String conversationName) { + getCommonSteps().userRepliesToLatestMessage(senderAlias, conversationName, FIRST_AVAILABLE_DEVICE, NO_EXPIRATION, message); + } + + @When("^User (.*) sends (\\d+) messages? \"(.*?)\" with mention to conversation (.*)$") + public void userSendsMessageWithMentions(String msgFromUserNameAlias, int numberOfMessages, String msg, String dstConvoName) { + msg = msg.replace("\\n", "\n"); + for (int i = 0; i < numberOfMessages; i++) { + getCommonSteps().userSendsTextWithMentions(msgFromUserNameAlias, dstConvoName, + FIRST_AVAILABLE_DEVICE, NO_EXPIRATION, msg); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/TopNavigationBarSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/TopNavigationBarSteps.java new file mode 100644 index 00000000000..33ab346eb37 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/TopNavigationBarSteps.java @@ -0,0 +1,69 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.TopNavigationBarPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import java.io.IOException; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class TopNavigationBarSteps { + IOSTestContext context; + + public TopNavigationBarSteps(IOSTestContext context) { + this.context = context; + } + + private TopNavigationBarPage getTopNavigationBarPage() { + return context.getPagesCollection() + .getPage(TopNavigationBarPage.class); + } + + @When("^I open Self profile$") + public void IOpenView() { + getTopNavigationBarPage().tapProfileButton(); + } + + @When("^I (do not )?see Self profile button on Conversations list page$") + public void IOpenView(String doNot) { + if(doNot == null) { + assertThat("The Self profile button is expected to be visible but it's not", + getTopNavigationBarPage().isSelfProfileButtonVisible()); + } else { + assertThat("The Self profile button is expected to be invisible but it's not", + getTopNavigationBarPage().isSelfProfileButtonInvisible()); + } + } + + @Then("^I tap on my profile photo in conversation list$") + public void iTapMyImageInConversationView() { + getTopNavigationBarPage().tapProfileImage(); + } + + @Then("^I (do not )?see legal hold indicator next to self title in Conversation list$") + public void iSeeLegalHoldIndicatorInConversationList(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The legal hold indicator is not visible next to self title", + getTopNavigationBarPage().isLegalHoldIndicatorVisible()); + } else { + assertThat("The legal hold indicator is visible next to self title while it should not be", + getTopNavigationBarPage().isLegalHoldIndicatorInvisible()); + } + } + + @Then("^I tap the legal hold indicator next to self title in Conversation list$") + public void iTapLegalHoldIndicatorInConversationList() { + getTopNavigationBarPage().iTapLegalHoldIndicator(); + } + + @When("^I open search screen") + public void iOpenSearchScreen() { + getTopNavigationBarPage().iOpenSearchScreen(); + } + @When("^I opened the filters") + public void iTapFilterButton() { + getTopNavigationBarPage().iTapOnFilterButton(); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/UniqueUsernamePageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/UniqueUsernamePageSteps.java new file mode 100644 index 00000000000..e3646c62ea2 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/UniqueUsernamePageSteps.java @@ -0,0 +1,52 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.UniqueUsernamePage; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class UniqueUsernamePageSteps { + IOSTestContext context; + + public UniqueUsernamePageSteps(IOSTestContext context) { + this.context = context; + } + + private UniqueUsernamePage getUniqueUsernamePage() { + return context.getPagesCollection().getPage(UniqueUsernamePage.class); + } + + @When("^I tap Save button on Unique Username page$") + public void ITapButtonOnUniqueUsernamePage() { + getUniqueUsernamePage().tapSaveButton(); + } + + /** + * Fill in name input an string + * + * @param name string to be input + */ + @When("^I enter \"(.*)\" name on Unique Username page$") + public void IFillInNameInInputOnUniqueUsernamePage(String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + getUniqueUsernamePage().inputStringInNameInput(name); + } + + /** + * Verify Save button isEnable state + * + * @param expectedState Disabe/Enable + */ + @When("^I see Save button state is (Disabled|Enabled) on Unique Username page$") + public void ISeeSaveButtonIsDisabled(String expectedState) { + boolean buttonState = getUniqueUsernamePage().isSaveButtonEnabled(); + if (expectedState.equals("Disabled")) { + assertThat(String.format("Wrong Save button state. Should be %s.", expectedState), !buttonState); + } else { + assertThat(String.format("Wrong Save button state. Should be %s.", expectedState), buttonState); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/UniqueUsernameTakeoverPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/UniqueUsernameTakeoverPageSteps.java new file mode 100644 index 00000000000..4ded45fcb69 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/UniqueUsernameTakeoverPageSteps.java @@ -0,0 +1,28 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.log.ZetaLogger; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.UniqueUsernameTakeoverPage; +import io.cucumber.java.en.When; + +import java.util.logging.Logger; + +public class UniqueUsernameTakeoverPageSteps { + + private static final Logger log = ZetaLogger.getLog(UniqueUsernameTakeoverPageSteps.class.getSimpleName()); + IOSTestContext context; + + public UniqueUsernameTakeoverPageSteps(IOSTestContext context) { + this.context = context; + } + + private UniqueUsernameTakeoverPage getPage() { + return context.getPagesCollection() + .getPage(UniqueUsernameTakeoverPage.class); + } + + @When("^I tap Keep This One button on Unique Username Takeover page$") + public void iTapKeepThis() { + getPage().tapKeepThisOneButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/VideoPlayerPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/VideoPlayerPageSteps.java new file mode 100644 index 00000000000..5d50828c04a --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/VideoPlayerPageSteps.java @@ -0,0 +1,37 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import io.cucumber.java.en.Then; +import static org.hamcrest.MatcherAssert.assertThat; + +import com.wearezeta.auto.ios.pages.VideoPlayerPage; + +import io.cucumber.java.en.When; + +public class VideoPlayerPageSteps { + IOSTestContext context; + + public VideoPlayerPageSteps(IOSTestContext context) { + this.context = context; + } + + private VideoPlayerPage getVideoPlayerPage() { + return context.getPagesCollection().getPage(VideoPlayerPage.class); + } + + @When("I see the video player web page is opened") + public void ISeeVideoPlayerWebPage() { + assertThat("Video Player web page is not opened", getVideoPlayerPage().isVideoPlayerPageOpened()); + } + + @Then("^I see pause button on Video page$") + public void ISeePauseButton() { + assertThat("Pause button is not displayed", getVideoPlayerPage(). + isPlayPauseButtonVisible()); + } + + @When("^I tap Done button on video message player page$") + public void ITapDoneButtonOnVideoMessagePlayer() { + getVideoPlayerPage().tapDoneButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/VoiceFiltersOverlaySteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/VoiceFiltersOverlaySteps.java new file mode 100644 index 00000000000..f804d7598d2 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/VoiceFiltersOverlaySteps.java @@ -0,0 +1,49 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.VoiceFiltersOverlay; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class VoiceFiltersOverlaySteps { + IOSTestContext context; + + public VoiceFiltersOverlaySteps(IOSTestContext context) { + this.context = context; + } + + private VoiceFiltersOverlay getVoiceFiltersOverlay() { + return context.getPagesCollection().getPage(VoiceFiltersOverlay.class); + } + + @When("^I tap Start Recording button on Voice Filters overlay$") + public void ITapOnStartButton() { + getVoiceFiltersOverlay().tapOnStartButton(); + } + + @When("^I tap Stop Recording button on Voice Filters overlay$") + public void ITapOnStopButton() { + getVoiceFiltersOverlay().tapOnStopButton(); + } + + @When("^I tap Confirm button on Voice Filters overlay$") + public void ITapOnConfirmButton() { + getVoiceFiltersOverlay().tapOnConfirmButton(); + } + + @When("^I tap (\\d+) random effect buttons? on Voice Filters overlay$") + public void ITapXRandomEffectButtons(int count) { + getVoiceFiltersOverlay().tapRandomEffectButtons(count); + } + + @Then("^I (do not )?see Confirm button on Voice Filters overlay$") + public void ISeeConfirmButton(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat(("The confirm recording button is not visible on Voice Filters overlay"), getVoiceFiltersOverlay().isConfirmButtonVisible()); + } else { + assertThat(("The confirm recording button is visible on Voice Filters overlay, but should be hidden"), getVoiceFiltersOverlay().isConfirmButtonInVisible()); + } + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/WelcomePageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/WelcomePageSteps.java new file mode 100644 index 00000000000..aefeb565f40 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/WelcomePageSteps.java @@ -0,0 +1,62 @@ +package com.wearezeta.auto.ios.steps; + +import com.wearezeta.auto.common.backend.BackendConnections; +import com.wearezeta.auto.common.misc.URLTransformer; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.WelcomePage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; + +public class WelcomePageSteps { + + IOSTestContext context; + + public WelcomePageSteps(IOSTestContext context) { + this.context = context; + } + + private WelcomePage getWelcomePage() { + return context.getPagesCollection().getPage(WelcomePage.class); + } + + @Then("^I (do not )?see Welcome page$") + public void iSeeWelcomePage(String doNot) { + if (doNot == null) { + assertThat("Wire Logo on Welcome page not visible.", getWelcomePage().isWireLogoVisible()); + assertThat("Welcome message on page is not visible.", getWelcomePage().isWelcomeMessageVisible()); + } else { + assertThat("Welcome page is visible while it should not be.", getWelcomePage().isWelcomePageInvisible()); + } + } + + @Then("I see domain name of backend on Welcome page") + public void iSeeDomainName() { + String domainName = URLTransformer.getHost(BackendConnections.getDefault().getBackendUrl()); + assertThat("Wrong or missing domain name on welcome page", + getWelcomePage().getStaticTexts(), hasItem(containsString(domainName))); + } + + @Then("^I see Enterprise Log In button on Welcome page$") + public void iSeeEnterpriseLogInButtonOnWelcomePage() { + assertThat("Enterprise Log In button is not visible.", getWelcomePage().isEnterpriseLogInButtonVisible()); + } + + @When("^I tap Login button on Welcome page$") + public void iTapLoginButtonOnWelcomePage() { + getWelcomePage().tapLoginButton(); + } + + @When("^I tap Create An Account button on Welcome page$") + public void iTapCreateAnAccountButtonOnWelcomePage() { + getWelcomePage().tapCreateAnAccountButton(); + } + + @When("^I tap Enterprise Login button on Welcome page$") + public void iTapEnterpriseLoginButtonOnWelcomePage() { + getWelcomePage().tapEnterpriseLoginButton(); + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/calling/CallPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/calling/CallPageSteps.java new file mode 100644 index 00000000000..78291e9d129 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/calling/CallPageSteps.java @@ -0,0 +1,185 @@ +package com.wearezeta.auto.ios.steps.calling; + +import com.wearezeta.auto.common.CommonUtils; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.calling.CallPage; +import com.wearezeta.auto.ios.pages.calling.VideoCallingOverlayPage; +import io.cucumber.java.en.And; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; + +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.not; + +public class CallPageSteps { + + IOSTestContext context; + + public CallPageSteps(IOSTestContext context) { + this.context = context; + } + + private CallPage getCallPage() { + return context.getPagesCollection().getPage(CallPage.class); + } + + private VideoCallingOverlayPage getPage() { + return context.getPagesCollection().getPage(VideoCallingOverlayPage.class); + } + + /** + * Verify whether calling overlay is visible or not + * + * @param shouldNotBeVisible equals to null if the overlay should be visible + */ + @Then("^I (do not )?see Calling overlay$") + public void ISeeCallingOverlay(String shouldNotBeVisible) { + if (shouldNotBeVisible == null) { + assertThat("Calling overlay is not visible", getPage().iSeeLeaveCallButton()); + } else { + assertThat("Calling overlay is visible, but should be hidden", getPage().iDontSeeLeaveCallButton()); + } + } + + @When("^I tap Accept button on (?:the |\\s*)Calling overlay$") + public void ITapAcceptButton() { + getPage().tapAcceptButton(); + } + + @When("^I tap Leave button on Calling overlay$") + public void iTapLeaveButton(){ + getPage().tapDeclineButton(); + } + + @When("^I tap Minimize button on Calling overlay$") + public void iTapMinimizeButton(){ + getPage().iTapMinimize(); + } + + @When("^I swipe up to see the participants list$") + public void iSwipeUpParticipantsList(){ + getPage().swipeUpParticipantsList(); + } + + @When("^I tap OK button on permission alert if visible$") + public void iTapOK(){ + getPage().tapOKButton(); + } + + @Then("^I see End Call button on Calling overlay$") + public void ISeeButton() { + assertThat("End Call button is not visible", getPage().iSeeLeaveCallButton()); + } + + /** + * Verify that call status message contains the particular text + * + * @param text the message to verify. This can contain user names + */ + @When("^I see call status message contains \"(.*)\"$") + public void ISeeCallStatusMessage(String text) { + text = context.getUsersManager() + .replaceAliasesOccurrences(text, ClientUsersManager.FindBy.NAME_ALIAS); + assertThat(String.format("Call status message containing '%s' is not visible", text), + getPage().isCallingMessageContainingVisible(text)); + } + + /** + * Verify CBR indicator is visible or invisible + */ + @Then("^I (do not )?see call indicator CONSTANT BIT RATE") + public void ISeeCBRIndicator(String shouldNotBeVisible) { + if (shouldNotBeVisible == null) { + assertThat("Call indicator CONSTANT BIT RATE is not visible", getPage().isBitRateLabelVisible()); + } else { + assertThat("Call indicator CONSTANT BIT RATE is visible", getPage().isBitRateLabelInvisible()); + } + } + + @Then("^I (do not )?see label call indicator CONSTANT BIT RATE") + public void ISeeLabelCBRIndicator(String shouldNotBeVisible) { + if (shouldNotBeVisible == null) { + assertThat("Call indicator label CONSTANT BIT RATE is not visible", + getCallPage().isCBRLabelVisible()); + } else { + assertThat("Call indicator label CONSTANT BIT RATE is visible", + getCallPage().isCBRLabelInvisible()); + } + } + + /** + * Verify VBR indicator is visible or invisible + */ + @Then("^I (do not )?see call indicator VARIABLE BIT RATE") + public void ISeeVBRIndicator(String shouldNotBeVisible) { + if (shouldNotBeVisible == null) { + assertThat("Call indicator VARIABLE BIT RATE is not visible", getPage().isBitRateLabelVisible()); + } else { + assertThat("Call indicator VARIABLE BIT RATE is visible", getPage().isBitRateLabelInvisible()); + } + } + + private static final Timedelta CALL_PARTICIPANTS_VISIBILITY_TIMEOUT = Timedelta.ofSeconds(20); + + /** + * Verifies a number of participants in the calling overlay + * + * @param expectedNumber the expected number of avatars + */ + @Then("^I see (\\d+) participants? on the Calling overlay$") + public void ISeeXAvatars(int expectedNumber) { + assertThat( + String.format("The actual number of calling avatars is not equal to the expected number %s", + expectedNumber), + getPage().isCountOfParticipantsEqualTo(expectedNumber, CALL_PARTICIPANTS_VISIBILITY_TIMEOUT) + ); + } + + /** + * Tap the top bar in order to restore the previously minimized overlay + */ + @And("^I restore Calling overlay$") + public void iRestoreOverlay() { + getPage().tapRestoreButton(); + } + + /** + * Verify whether minimized calling overlay bar is visible on the top of the screen + */ + @Then("^I see that Calling overlay is minimized$") + public void iSeeMinimizedOverlay() { + assertThat("Calling overlay is expected to be minimized", + getPage().isRestoreButtonVisible()); + } + + @When("^I tap Ok Button on Enterprise alert$") + public void iTapOkButton() { + getPage().tapOKButton(); + } + + @Then("^I (do not )?see SECURITY LEVEL: UNCLASSIFIED label on calling overlay$") + public void ISeeUnclassifiedLabelOnCallingOverlay(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Unclassified label is not visible", getPage().isUnclassifiedLabelVisibleOnCallingOverlay()); + } else { + assertThat("Unclassified label is not visible", getPage().isUnclassifiedLabelInvisibleOnCallingOverlay()); + } + } + + @Then("^I (do not )?see SECURITY LEVEL: VS-NfD label on calling overlay$") + public void ISeeClassifiedLabelOnCallingOverlay(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Classified label is not visible", getPage().isClassifiedLabelVisibleOnCallingOverlay()); + } else { + assertThat("Classified label is not visible", getPage().isClassifiedLabelInvisibleOnCallingOverlay()); + } + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/calling/CallingSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/calling/CallingSteps.java new file mode 100644 index 00000000000..685cd7c5b85 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/calling/CallingSteps.java @@ -0,0 +1,221 @@ +package com.wearezeta.auto.ios.steps.calling; + +import com.wearezeta.auto.common.CallingManager; +import com.wearezeta.auto.common.backend.Backend; +import com.wearezeta.auto.common.backend.BackendConnections; +import com.wearezeta.auto.common.backend.models.Conversation; +import com.wearezeta.auto.common.log.ZetaLogger; +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.ios.common.IOSTestContext; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import java.util.logging.Logger; + +import javax.management.InstanceNotFoundException; +import java.util.*; + +public class CallingSteps { + IOSTestContext context; + + private static final Logger log = ZetaLogger.getLog(CallingSteps.class.getSimpleName()); + + public CallingSteps(IOSTestContext context) { + this.context = context; + } + + /** + * Make call to a specific user. You may instantiate more than one incoming + * call from single caller by calling this step multiple times + * + * @param caller caller name/alias + * @param conversationName destination conversation name + */ + @When("(\\w+) calls (\\w+)$") + public void UserXCallsToUserYUsingCallBackend(String caller, String conversationName) throws Exception { + context.getCallingManager().callToConversation(caller, conversationName); + } + + /** + * Stop outgoing or incoming call (audio and video) to the other side + * + * @param instanceUsers comma separated list of usernames/aliases + * @param conversationName destination conversation name + */ + @When("^(.*) stops? (incoming call from|outgoing call to) (.*)") + public void UserXStopsIncomingOutgoingCallsToUserY(String instanceUsers, String typeOfCall, String conversationName) + throws Exception { + if (typeOfCall.equals("incoming call from")) { + context.getCallingManager() + .stopIncomingCall(context.getUsersManager() + .splitAliases(instanceUsers)); + } else { + context.getCallingManager() + .stopOutgoingCall(context.getUsersManager() + .splitAliases(instanceUsers), conversationName); + } + } + + + /** + * Verify whether call status is changed to one of the expected values after + * N seconds timeout + * + * @param caller caller name/alias + * @param conversationName destination conversation + * @param expectedStatuses comma-separated list of expected call statuses. See + * com.wearezeta.auto.common.calling2.v1.model.CallStatus for + * more details + * @param timeoutSeconds number of seconds to wait until call status is changed + */ + @Then("(.*) verif(?:ies|y) that call status to (.*) is changed to (.*) in (\\d+) seconds?$") + public void UserXVerifiesCallStatusToUserY(String caller, + String conversationName, String expectedStatuses, int timeoutSeconds) + throws Exception { + context.getCallingManager() + .verifyCallingStatus(caller, conversationName, expectedStatuses, timeoutSeconds); + } + + /** + * Verify whether waiting instance status is changed to one of the expected + * values after N seconds timeout + * + * @param callees comma separated list of callee names/aliases + * @param expectedStatuses comma-separated list of expected call statuses. See + * com.wearezeta.auto.common.calling2.v1.model.CallStatus for + * more details + * @param timeoutSeconds number of seconds to wait until call status is changed + */ + @Then("(.*) verif(?:ies|y) that waiting instance status is changed to (.*) in (\\d+) seconds?$") + public void UserXVerifiesCallStatusToUserY(String callees, + String expectedStatuses, int timeoutSeconds) throws Exception { + context.getCallingManager().verifyAcceptingCallStatus(context.getUsersManager() + .splitAliases(callees), expectedStatuses, timeoutSeconds); + } + + /** + * Verify that the instance has X active flows + * + * @param callees comma separated list of callee names/aliases + * @param numberOfFlows expected number of flows + */ + @Then("^Users? (.*) verif(?:ies|y) to have (\\d+) peer connections?$") + public void UserXVerifesHavingXPeerConnections(String callees, int numberOfFlows) throws Exception { + context.getCallingManager().verifyPeerConnections(callees, numberOfFlows); + } + + /** + * Verify that the instance has an established CBR connection + * + * @param callees comma separated list of callee names/aliases + */ + @Then("^Users? (.*) verif(?:ies|y) to have CBR connection$") + public void UserXVerifesHavingCbrConnections(String callees) throws Exception { + context.getCallingManager().verifyCbrConnections(callees); + } + + /** + * Verify that each flow of the instance had incoming and outgoing packets for audio + * running over the line + * + * @param callees comma separated list of callee names/aliases + */ + @Then("^Users? (.*) verif(?:ies|y) to send and receive audio$") + public void UserXVerifesAudio(final String callees) throws Exception { + context.getCallingManager().verifySendAndReceiveAudio(callees); + } + + @When("(.*) starts? instances? using (.*)$") + public void UserXStartsInstance(String callees, String callingServiceBackend) { + context.getCallingManager() + .startInstances(context.getUsersManager().splitAliases(callees), + callingServiceBackend, "iOS", context.getScenario().getName()); + } + + @When("(.*) starts? 2FA instances? using (.*)$") + public void UserXStarts2FAInstance(String callees, String callingServiceBackend) { + context.startPinging(); + List calleeNames = context.getUsersManager().splitAliases(callees); + for (String calleeName : calleeNames) { + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(calleeName); + Backend backend = BackendConnections.get(user); + + if (callingServiceBackend.contains("zcall")) { + String teamID = backend.getAllTeams(user).get(0).getId(); + backend.unlock2FAuthenticationFeature(teamID); + backend.disable2FAuthenticationFeature(teamID); + context.getCallingManager().startInstances(Collections.singletonList(calleeName), callingServiceBackend, + "iOS", context.getScenario().getName()); + backend.enable2FAuthenticationFeature(teamID); + backend.lock2FAuthenticationFeature(teamID); + } else { + String verificationCode = backend.getVerificationCode(user); + log.info("verificationCode: " + verificationCode); + context.getCallingManager().startInstance(calleeName, verificationCode, + callingServiceBackend, "iOS", context.getScenario().getName()); + } + } + context.stopPinging(); + } + + /** + * Automatically accept the next incoming call for the particular user as + * soon as it appears in UI. Waiting instance should be already created for + * this particular user + * + * @param callees comma separated list of callee names/aliases + */ + @When("(.*) accepts? next incoming call automatically$") + public void UserXAcceptsNextIncomingCallAutomatically(String callees) throws Exception { + userXVerifesInstanceStatusToUserY(callees, "started", 20); + context.getCallingManager() + .acceptNextCall(context.getUsersManager() + .splitAliases(callees)); + } + + @Then("Users? (.*) verif(?:y|ies) that instance status is changed to (.*) in (\\d+) seconds?$") + public void userXVerifesInstanceStatusToUserY(String callees, + String expectedStatuses, int timeoutSeconds) throws Exception { + context.startPinging(); + try { + context.getCallingManager().verifyInstanceStatus(context.getUsersManager().splitAliases(callees), + expectedStatuses, timeoutSeconds); + } finally { + context.stopPinging(); + } + } + + /** + * Make a video call to a specific user. + * + * @param callerName name of caller + * @param conversationName destination conversation name + */ + @When("(.*) starts? a video call to (.*)$") + public void UserXStartVideoCallsToUserYUsingCallBackend(String callerName, String conversationName) throws + Exception { + final ClientUser caller = context.getUsersManager().findUserByNameOrNameAlias(callerName); + Conversation conversation = context.getCommonSteps().getConversation(callerName, conversationName); + context.getCallingManager().startVideoCallToConversation(caller, conversation); + } + + private CallingManager getCallingManager() { + return context.getCallingManager(); + } + + /** + * Switch on/off video + * + * @param callees username/alias of the screensharing initiator + * @param state either 'on' or 'off' + */ + @When("User (.*) switches video (on|off)$") + public void userXSwitchesVideoOn(String callees, String state) throws InstanceNotFoundException { + final List users = context.getUsersManager().splitAliases(callees); + if (state.equalsIgnoreCase("on")) { + getCallingManager().switchVideoOn(users); + } else { + getCallingManager().switchVideoOff(users); + } + } +} \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/calling/VideoCallingOverlayPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/calling/VideoCallingOverlayPageSteps.java new file mode 100644 index 00000000000..180e4d4b482 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/calling/VideoCallingOverlayPageSteps.java @@ -0,0 +1,68 @@ +package com.wearezeta.auto.ios.steps.calling; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.calling.CallPage; +import com.wearezeta.auto.ios.pages.calling.VideoCallingOverlayPage; +import io.cucumber.java.en.Then; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class VideoCallingOverlayPageSteps { + + IOSTestContext context; + + public VideoCallingOverlayPageSteps(IOSTestContext context) { + this.context = context; + } + + private CallPage getCallPage() { + return context.getPagesCollection().getPage(CallPage.class); + } + + private VideoCallingOverlayPage getVideoCallingOverlayPage() { + return context.getPagesCollection().getPage(VideoCallingOverlayPage.class); + } + + @Then("^I (do not )?see Video Calling overlay$") + public void ISeeCallingOverlay(String shouldNotBeVisible) { + if (shouldNotBeVisible == null) { + assertThat("Video calling overlay is not visible", + getVideoCallingOverlayPage().iSeeLeaveCallButton()); + } else { + assertThat("Video calling overlay is visible, but should be hidden", + getVideoCallingOverlayPage().iDontSeeLeaveCallButton()); + } + } + + @Then("^I (do not )?see alert about New device$") + public void ISeeAlert(String shouldNotBeVisible) { + if (shouldNotBeVisible == null) { + assertThat("New device alert is not visible", + getVideoCallingOverlayPage().iSeeNewDeviceAlert()); + } else { + assertThat("New device alert is visible, but should be hidden", + getVideoCallingOverlayPage().iDontSeeNewDeviceAlert()); + } + } + + @Then("^I tap on screen to enable video calling overlay$") + public void iTapOnScreen() { + getVideoCallingOverlayPage().iTapOnScreen(); + } + + @Then("^I (do not )?see profile picture avatar for users (.*) on calling overlay$") + public void iSeeAvatarAGroupudioParticipants(String doNot, String nameAliasses) { + for (String name : context.getUsersManager().splitAliases(nameAliasses)) { + final String username = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + if (doNot == null) { + assertThat(String.format("'avatar profile picture' icon should be visible for participant %s", username), + getVideoCallingOverlayPage().iSeeAvatarGroupAudioParticipants(username)); + } else { + assertThat(String.format("'avatar profile picture' icon should not be visible for participant %s", username), + getVideoCallingOverlayPage().iDontSeeGroupAvatarAudioParticipants(username)); + } + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/CertificateDetailsPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/CertificateDetailsPageSteps.java new file mode 100644 index 00000000000..ea1898f7799 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/CertificateDetailsPageSteps.java @@ -0,0 +1,34 @@ +package com.wearezeta.auto.ios.steps.conversation_details; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.details_overlay.common.CertificateDetailsPage; +import static org.hamcrest.MatcherAssert.assertThat; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +public class CertificateDetailsPageSteps { + IOSTestContext context; + + public CertificateDetailsPageSteps(IOSTestContext context) { + this.context = context; + } + + private CertificateDetailsPage getCertificateDetailsPage() { + return context.getPagesCollection().getPage(CertificateDetailsPage.class); + } + + @When("I open my certificate details") + public void iOpenMyCertificateDetails() { + getCertificateDetailsPage().openCertificateDetails(); + } + + @Then("I see certificate details info") + public void iSeeCertificateDetailsInfo() { + assertThat("Certificate details not visible", getCertificateDetailsPage().isCertificateDetailsPageVisible()); + } + + @When("I copy my certificate details") + public void iCopyMyCertificateDetails() { + context.setRememberedCertificate(getCertificateDetailsPage().getCertificate()); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/DeviceDetailsPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/DeviceDetailsPageSteps.java new file mode 100644 index 00000000000..1444ed548a9 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/DeviceDetailsPageSteps.java @@ -0,0 +1,40 @@ +package com.wearezeta.auto.ios.steps.conversation_details; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.details_overlay.common.DeviceDetailsPage; +import static org.hamcrest.MatcherAssert.assertThat; + +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +public class DeviceDetailsPageSteps { + IOSTestContext context; + + public DeviceDetailsPageSteps(IOSTestContext context) { + this.context = context; + } + + private DeviceDetailsPage getDeviceDetailsPage() { + return context.getPagesCollection().getPage(DeviceDetailsPage.class); + } + + @When("^I tap Verify (?:button|switcher) on Device Details page$") + public void ITapVerifyToggle() { + getDeviceDetailsPage().tapVerifyToggle(); + } + + @When("^I tap Back (?:button|switcher) on Device Details page$") + public void ITapBackButton() { + getDeviceDetailsPage().tapBackButton(); + } + + @When("^I tap Remove Device (?:button|switcher) on Device Details page$") + public void ITapRemoveDeviceButton() { + getDeviceDetailsPage().tapRemoveDeviceButton(); + } + + @Then("I should see a revoked certificate in Device Details") + public void iShouldSeeARevokedCertificateInDeviceDetails() { + assertThat("Device should be revoked", getDeviceDetailsPage().isDeviceRevoked()); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/UserDetailsDevicesPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/UserDetailsDevicesPageSteps.java new file mode 100644 index 00000000000..db1c6d2adb3 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/UserDetailsDevicesPageSteps.java @@ -0,0 +1,60 @@ +package com.wearezeta.auto.ios.steps.conversation_details; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.details_overlay.common.UserDetailsDevicesPage; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class UserDetailsDevicesPageSteps { + IOSTestContext context; + + public UserDetailsDevicesPageSteps(IOSTestContext context) { + this.context = context; + } + + private UserDetailsDevicesPage getUserDetailsDevicesPage() { + return context.getPagesCollection().getPage(UserDetailsDevicesPage.class); + } + + /** + * Open the details page of corresponding device on conversation details page + * + * @param deviceIndex the device index. Starts from 1 + */ + @When("^I open details page of device number (\\d+) on Devices tab$") + public void IOpenDeviceDetails(int deviceIndex) { + deviceIndex++; + getUserDetailsDevicesPage().openDeviceDetailsPage(deviceIndex); + } + + /** + * Checks the number of devices in participant devices tab + * + * @param expectedNumDevices Expected number of devices + */ + @When("^I see (\\d+) items? (?:is|are) shown on Devices tab$") + public void ISeeDevicesShownInDevicesTab(int expectedNumDevices) { + expectedNumDevices++; + assertThat( + String.format("The expected number of devices: %s is not equals to actual count", expectedNumDevices), + getUserDetailsDevicesPage().isParticipantDevicesCountEqualTo(expectedNumDevices) + ); + } + + @When("^I see legal hold device as first item on Devices tab$") + public void ISeeLegalHoldDeviceAsFirstItem() { + assertThat( + "The legal hold device is not the first item", getUserDetailsDevicesPage().isLegalHoldTheFirstDevice()); + } + + @When("^I see the label (Verified|Not Verified) is shown on user details page for the device number (\\d+)$") + public void ISeeLabelForDeviceItem(String label, int deviceNumber) { + if (label.equals("Not Verified")) { + assertThat( + String.format("The label Legal Hold is not visible for device number %s", deviceNumber), getUserDetailsDevicesPage().isDeviceNumberNotVerified(deviceNumber)); + } else { + assertThat( + String.format("The label Verified is not visible for device number %s", deviceNumber), getUserDetailsDevicesPage().isDeviceNumberVerified(deviceNumber)); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupAddPeoplePageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupAddPeoplePageSteps.java new file mode 100644 index 00000000000..8b4171ced0e --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupAddPeoplePageSteps.java @@ -0,0 +1,97 @@ +package com.wearezeta.auto.ios.steps.conversation_details.group; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.details_overlay.group.GroupAddPeoplePage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.List; + +public class GroupAddPeoplePageSteps { + + IOSTestContext context; + + public GroupAddPeoplePageSteps(IOSTestContext context) { + this.context = context; + } + + private GroupAddPeoplePage getPage() { + return context.getPagesCollection().getPage(GroupAddPeoplePage.class); + } + + @When("^I tap Add Participants button on Group Add People page$") + public void iTapAddButton() { + getPage().tapAddButton(); + } + + /** + * Types the given string into the search field + * + * @param query search query text + */ + @When("^I type search query \"(.*)\" on Group Add People page$") + public void iTypeSearchQuery(String query) { + query = context.getUsersManager() + .replaceAliasesOccurrences(query, ClientUsersManager.FindBy.NAME_ALIAS, + ClientUsersManager.FindBy.EMAIL_ALIAS, ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + getPage().typeSearchQuery(query); + } + + @When("^I type first (\\d+) letters? of name \"(.*)\" in search input field on Add People page$") + public void ITypeFirstCharactersOfNameInSearchInputFieldOnAddPeoplePage(int count, String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + + if (name.length() > count) { + getPage().typeSearchQuery(name.substring(0, count)); + } else { + throw new IllegalArgumentException(String.format("Name is only %s chars length. Put in step a less value", + name.length())); + } + } + + /** + * Select/unselect the coresposning search result item + * + * @param name the name of the search item + */ + @When("^I (?:select|unselect) search result item (.*) on Group Add People page$") + public void iSelectItem(String name) { + name = context.getUsersManager().replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + getPage().selectItem(name); + } + + /** + * Verifies whether contacts are present on Group chat info page + * + * @param shouldNotSee equals to null if participants should be visible + * @param contacts one or more participant names/aliases + */ + @Then("^I (do not )?see search result items? (.*) on Group Add People page$") + public void ISeeContactInGroupInfo(String shouldNotSee, String contacts) { + final List aliases = context.getUsersManager().splitAliases(contacts); + for (final String alias : aliases) { + final String name = context.getUsersManager() + .replaceAliasesOccurrences(alias, ClientUsersManager.FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + assertThat(String.format("User '%s' should be visible", name), getPage().isItemVisible(name)); + } else { + assertThat(String.format("User '%s' should not be visible", name), getPage().isItemInvisible(name)); + } + } + } + + /** + * Verify whether a label is visible in search results + */ + @Then("^I see \"(No Results|Everyone is here)\" label on Group Add People page$") + public void ISeeResultLabel(String msg) { + assertThat(String.format("Label '%s' should be visible", msg), + getPage().waitUntilResultsLabelIsVisible(msg)); + } +} + diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupDetailsPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupDetailsPageSteps.java new file mode 100644 index 00000000000..0840eed9b09 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupDetailsPageSteps.java @@ -0,0 +1,254 @@ +package com.wearezeta.auto.ios.steps.conversation_details.group; + +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.IOSPage; +import com.wearezeta.auto.ios.pages.details_overlay.group.GroupDetailsPage; +import io.cucumber.java.en.And; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import com.wearezeta.auto.common.CommonUtils; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; + +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import java.util.List; + +public class GroupDetailsPageSteps { + IOSTestContext context; + + public GroupDetailsPageSteps(IOSTestContext context) { + this.context = context; + } + + private GroupDetailsPage getGroupDetailsPage() { + return context.getPagesCollection().getPage(GroupDetailsPage.class); + } + + @When("^I change group conversation name to \"(.*)\" on Group Details page$") + public void IChangeConversationNameTo(String name) { + getGroupDetailsPage().setGroupChatName(name); + } + + @When("^I tap Add People button on Group Details page$") + public void ITapAddPeopleButton() { + getGroupDetailsPage().tapAddPeopleButton(); + } + + @When("^I tap X button on Group Details page$") + public void ITapXButton() { + getGroupDetailsPage().tapXButton(); + } + + @When("^I tap Open Menu button on Group Details page$") + public void ITapOpenMenuButton() { + getGroupDetailsPage().tapOpenMenuButton(); + } + + @Then("^I (do not )?see Add People button on Group Details page$") + public void ItSeeAddPeopleButtonOnGroupInfoPage(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Button Add People should be visible", + getGroupDetailsPage().isAddPeopleButtonVisible()); + } else { + assertThat("Button Add People should not be visible", + getGroupDetailsPage().isAddPeopleButtonInvisible()); + } + } + + @When("^I try to change group conversation name to random with length (\\d+) on Group Details page$") + public void IChangeConversationNameToRandom(int length) { + String name = CommonUtils.generateRandomString(length); + getGroupDetailsPage().setGroupChatName(name); + } + + @Then("^I see conversation name \"(.*)\" on Group Details page$") + public void ISeeCorrectConversationName(String expectedName) { + assertThat(String.format("Group conversation name is not equal to '%s'", expectedName), + getGroupDetailsPage().isGroupNameEqualTo(expectedName)); + } + + @Then("^I see Group Name is enabled on Group Details page$") + public void ISeeConversationNameEnabled() { + assertThat("Group conversation name is not enabled but it should be", getGroupDetailsPage().isGroupChatNameEnabled()); + } + + @When("^I select participant (.*) on Group Details page$") + public void ISelectParticipant(String name) { + name = context.getUsersManager().replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + getGroupDetailsPage().selectParticipant(name); + } + + @Then("^I see (\\d+) Members label on Group Details page$") + public void ISeeThatConversationHasNumberMemberParticipants(int number) { + assertThat(String.format("The actual number of Members in the chat is not the same as expected number %s", + number), getGroupDetailsPage().isNumberOfMembersParticipantsEquals(number)); + } + + @Then("^I see (\\d+) Admins label on Group Details page$") + public void ISeeThatConversationHasNumberAdminParticipants(int number) { + assertThat(String.format("The actual number of Admins in the chat is not the same as expected number %s", + number), getGroupDetailsPage().isNumberOfAdminsParticipantsEquals(number)); + } + + @When("^I see (\\d+) (participants? avatars?|services?) on Group Details page$") + public void ISeeNumberParticipantsAvatars(int expectedCount, String type) { + assertThat(String.format("Actual number of items is not the same as expected (%s)", expectedCount), + CommonUtils.waitUntilTrue(Timedelta.ofSeconds(10), Timedelta.ofMillis(1), () -> { + final int actual = type.startsWith("service") + ? getGroupDetailsPage().getServicesCount() + : getGroupDetailsPage().getParticipantsCount(); + return actual == expectedCount; + }) + ); + } + + @And("^I swipe up on Group Details page$") + public void iSwipeUp() { + getGroupDetailsPage().swipe(IOSPage.SwipeDirection.UP); + } + + @Then("^I see the participant (.*) has External indicator on Group Details page$") + public void ISeeExternalIndicator(String name) { + name = context.getUsersManager().replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + assertThat(String.format("The participant '%s' has no 'External Indicator'", name), + getGroupDetailsPage().isExternalIndicatorVisibleFor(name)); + } + + @Then("^I (do not )?see participant names? (.*) on Group Details page$") + public void ISeeContactInGroupInfo(String shouldNotSee, String contacts) { + final List aliases = context.getUsersManager().splitAliases(contacts); + for (final String alias : aliases) { + final String name = context.getUsersManager() + .replaceAliasesOccurrences(alias, ClientUsersManager.FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + assertThat(String.format("User '%s' should be visible", name), + getGroupDetailsPage().isParticipantVisible(name)); + } else { + assertThat(String.format("User '%s' should not be visible", name), + getGroupDetailsPage().isParticipantInvisible(name)); + } + } + } + + @Then("^I see the length of group conversation name equals to (\\d+) on Group Details page$") + public void IVerifyNameLength(int expectedLength) { + final int actualLength = getGroupDetailsPage().getGroupNameLength(); + assertThat(String.format("The actual group name length %d is not equal to the expected length %d", + actualLength, expectedLength), actualLength, equalTo(expectedLength)); + } + + @When("^I tap Guest Options? on Group Details page$") + public void iOpenGuestOptions() { + getGroupDetailsPage().openGuestOptions(); + } + + /** + * Verifies whether Guest Options is present on Group chat info page + * + * @param shouldNotSee equals to null if Guest Options should be visible + */ + @Then("^I (do not )?see Guest Options on Group Details page$") + public void ISeeGuestOptionsOnGroupInfo(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Guest Options should be visible", + getGroupDetailsPage().isGuestOptionsVisible()); + } else { + assertThat("Guest Options should not be visible", + getGroupDetailsPage().isGuestOptionsInvisible()); + } + } + + @Then("^I (do not )?see Services Options on Group Details page$") + public void ISeeServicesOptionsOnGroupInfo(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Services Options should be visible", + getGroupDetailsPage().isServicesOptionsVisible()); + } else { + assertThat("Services Options should not be visible", + getGroupDetailsPage().isServicesOptionsVisible()); + } + } + + @Then("^I (do not )?see Timed Messages option on Group Details page$") + public void seeTimedMessagesOption(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Timed Messages option should be visible", + getGroupDetailsPage().isTimedMessagesOptionVisible()); + } else { + assertThat("Timed Messages Option should not be visible", + getGroupDetailsPage().isTimedMessagesOptionInvisible()); + } + } + + /** + * Checks if read receipts toggle is present on Group Details page + */ + @Then("^I (do not )?see the Read Receipts toggle on Group Details page$") + public void seeToggleReadReceipts(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Read receipts toggle should be visible", + getGroupDetailsPage().isReadReceiptsVisible()); + } else { + assertThat("Read receipts toggle should not be visible", + getGroupDetailsPage().isReadReceiptsInvisible()); + } + } + + /** + * Checks if legal hold indicator is visible on group details page + * + * @param shouldNotSee equals to null if indicator should be visible + */ + @Then("^I (do not )?see legal hold indicator on Group Details page$") + public void seeLegalHoldIndicator(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Legal hold indicator should be visible", + getGroupDetailsPage().isLegalHoldIndicatorVisible()); + } else { + assertThat("Legal hold indicator should not be visible", + getGroupDetailsPage().isLegalHoldIndicatorInvisible()); + } + } + + @When("^I tap legal hold indicator on Group Details page$") + public void iTapLegalHoldIndicator() { + getGroupDetailsPage().tapLegalHoldIndicator(); + } + + @When("^I (do not )?see the Members section on Conversation Details page$") + public void iSeeMembersSection(String doNot) { + if (doNot == null) { + assertThat("Members section should be visible", getGroupDetailsPage().isMembersSectionVisible()); + } else { + assertThat("Members section is visible while it should not be", getGroupDetailsPage().isMembersSectionInvisible()); + } + } + + @When("^I (do not )?see user (.*) in the Admins section$") + public void iSeeUserInAdminsSection(String doNot, String userName) { + userName = context.getUsersManager().replaceAliasesOccurrences(userName, ClientUsersManager.FindBy.NAME_ALIAS); + if (doNot == null) { + assertThat(String.format("User %s is expected to be in the Admins section, but is not", userName), getGroupDetailsPage().isUserInAdminsSection(userName)); + } else { + assertThat(String.format("User %s is displayed in the Admins section, but should not", userName), getGroupDetailsPage().isUserNotInAdminsSection(userName)); + } + } + + @When("^I (do not )?see the Show All button in the Admins section$") + public void iSeeSeeAllButtonInAdminsSection(String doNot) { + if (doNot == null) { + assertThat("The See All button is expected to be visible in the Admins section, but it's not", getGroupDetailsPage().isSeeAllButtonVisibleInAdminsSection()); + } else { + assertThat("The See All button should not be displayed in the Admins section, but it is", getGroupDetailsPage().isSeeAllButtonInvisibleInAdminsSection()); + } + } + + @When("^I tap the Show All button$") + public void iTapSeeAllButton() { + getGroupDetailsPage().tapSeeAllButton(); + } +} + diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupParticipantConnectedProfilePageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupParticipantConnectedProfilePageSteps.java new file mode 100644 index 00000000000..2a835f39fcc --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupParticipantConnectedProfilePageSteps.java @@ -0,0 +1,137 @@ +package com.wearezeta.auto.ios.steps.conversation_details.group; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.details_overlay.group.GroupConnectedParticipantProfilePage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class GroupParticipantConnectedProfilePageSteps { + IOSTestContext context; + + public GroupParticipantConnectedProfilePageSteps(IOSTestContext context) { + this.context = context; + } + + private GroupConnectedParticipantProfilePage getPage() { + return context.getPagesCollection().getPage(GroupConnectedParticipantProfilePage.class); + } + + @When("^I tap Remove From Conversation button on Group participant profile page$") + public void ITapRemoveButton() { + getPage().tapRemoveFromConversationButton(); + } + + @When("^I tap Open Conversation button on Group participant profile page$") + public void ITapOpenConversationButton() { + getPage().tapOpenConversationButton(); + } + + @When("^I tap Back button on Group participant profile page$") + public void ITapBackButton() { + getPage().tapBackButton(); + } + + @When("^I tap Open Menu button on Group participant profile page$") + public void ITapOpenMenuButton() { + getPage().tapOpenMenuButton(); + } + + @When("^I see name \"(.*)\" on Group participant profile page$") + public void ISeeLabel(String value) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS, + ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + assertThat(String.format("'%s' field is expected to be visible", value), + getPage().isUserDetailNameVisible(value)); + } + + @When("^I do not see name on Group participant profile page$") + public void IDoNotSeeName() { + assertThat("'name' field is expected to be invisible", + getPage().isUserDetailNameInvisible()); + } + + @When("^I switch to Devices tab on Group participant profile page$") + public void IChangeTabToDevices() { + getPage().tapDevicesTab(); + } + + @Then("^I (do not )?see Open Conversation button on Group participant profile page$") + public void ISeeOpenConversationButtonOnGroupInfoPage(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Open conversation button should be visible", + getPage().isOpenConversationButtonVisible()); + } else { + assertThat("Open conversation should not be visible", + getPage().isOpenConversationInvisible()); + } + } + + @Then("^I (do not )?see More Actions button on Group participant profile page$") + public void ISeeMoreActionsButton(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The More Actions button is not visible on Single user profile page", + getPage().isMoreActionsButtonVisible()); + } else { + assertThat("The More Actions button is still visible on Single user profile page", + getPage().isMoreActionsButtonInvisible()); + } + } + + @Then("^I (do not )?see Connect button on Group participant profile page$") + public void ISeeConnectButtonOnGroupInfoPage(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Connect button should be visible", + getPage().isConnectButtonVisible()); + } else { + assertThat("Connect button should not be visible", + getPage().isConnectButtonInvisible()); + } + } + + @Then("^I do not see left action button on Group participant profile page$") + public void IDoNotSeeLeftActionButtonOnGroupInfoPage() { + assertThat("Left action button should not be visible", + getPage().isLeftActionButtonInvisible()); + } + + @Then("^I (do not )?see Admin toggle on Group participant profile page$") + public void iSeeAdminToggle(String doNot) { + if (doNot == null) { + assertThat("Admin toggle is not visible while it should be", + getPage().isAdminToggleVisible()); + } else { + assertThat("Admin toggle is visible while it should not be", + getPage().isAdminToggleInvisible()); + } + } + + @Then("^I tap Admin toggle on Group participant profile page$") + public void iTapAdminToggle() { + getPage().tapAdminToggle(); + } + + @Then("^I (do not )?see Admin icon on Group participant profile page$") + public void iSeeAdminIcon(String doNot) { + if (doNot == null) { + assertThat("Admin icon is not visible while it should be", + getPage().isAdminIconVisible()); + } else { + assertThat("Admin icon is visible while it should not be", + getPage().isAdminIconInvisible()); + } + } + + @Then("^I (do not )?see External icon on Group participant profile page$") + public void iSeeExternalIcon(String doNot) { + if (doNot == null) { + assertThat("External icon is not visible while it should be", + getPage().isExternalIconVisible()); + } else { + assertThat("External icon is visible while it should not be", + getPage().isExternalIconInvisible()); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupParticipantIncomingPendingConnectionPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupParticipantIncomingPendingConnectionPageSteps.java new file mode 100644 index 00000000000..890592aed4c --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupParticipantIncomingPendingConnectionPageSteps.java @@ -0,0 +1,30 @@ +package com.wearezeta.auto.ios.steps.conversation_details.group; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.details_overlay.group.GroupPendingParticipantIncomingConnectionPage; +import io.cucumber.java.en.Then; +import static org.hamcrest.MatcherAssert.assertThat; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; + +public class GroupParticipantIncomingPendingConnectionPageSteps { + IOSTestContext context; + + public GroupParticipantIncomingPendingConnectionPageSteps(IOSTestContext context) { + this.context = context; + } + + private GroupPendingParticipantIncomingConnectionPage getGroupParticipantIncomingConnectionPage() { + return context.getPagesCollection() + .getPage(GroupPendingParticipantIncomingConnectionPage.class); + } + + @Then("^I see name \"(.*)\" on Group participant Pending incoming connection page$") + public void iSeeNameOnGroupParticipantPendingIncomingConnectionPage(String value) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS, + ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + assertThat(String.format("name '%s' is expected to be visible", value), + getGroupParticipantIncomingConnectionPage().isUserDetailNameVisible(value)); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupParticipantOutgoingPendingConnectionPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupParticipantOutgoingPendingConnectionPageSteps.java new file mode 100644 index 00000000000..c59ee0f8c0d --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupParticipantOutgoingPendingConnectionPageSteps.java @@ -0,0 +1,41 @@ +package com.wearezeta.auto.ios.steps.conversation_details.group; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.details_overlay.group.GroupPendingParticipantOutgoingConnectionPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class GroupParticipantOutgoingPendingConnectionPageSteps { + IOSTestContext context; + + public GroupParticipantOutgoingPendingConnectionPageSteps(IOSTestContext context) { + this.context = context; + } + + private GroupPendingParticipantOutgoingConnectionPage getPage() { + return context.getPagesCollection() + .getPage(GroupPendingParticipantOutgoingConnectionPage.class); + } + + @Then("^I see name \"(.*)\" on Group participant Pending outgoing connection page$") + public void ISeeLabel(String value) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS, + ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + assertThat(String.format("name '%s' is expected to be visible", value), + getPage().isUserNameVisible(value)); + } + + @Then("^I see Connect button on Group participant Pending outgoing connection page$") + public void ISeeConnectButton() { + assertThat("'Connect' button is expected to be visible", + getPage().isConnectButtonVisible()); + } + + @When("^I tap Open Menu button on Group participant Pending outgoing connection page$") + public void ITapOpenMenuButton() { + getPage().tapOpenMenuButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupPeoplePageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupPeoplePageSteps.java new file mode 100644 index 00000000000..80e1f8d1786 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GroupPeoplePageSteps.java @@ -0,0 +1,59 @@ +package com.wearezeta.auto.ios.steps.conversation_details.group; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.details_overlay.group.GroupPeoplePage; +import io.cucumber.java.en.Then; +import static org.hamcrest.MatcherAssert.assertThat; + +public class GroupPeoplePageSteps { + IOSTestContext context; + + public GroupPeoplePageSteps(IOSTestContext context) { + this.context = context; + } + + private GroupPeoplePage getPage() { + return context.getPagesCollection().getPage(GroupPeoplePage.class); + } + + @Then("^I see external indicator for user (.*) on People page$") + public void iSeeExternalIndicatorFor(String userName) { + userName = context.getUsersManager().replaceAliasesOccurrences(userName, ClientUsersManager.FindBy.NAME_ALIAS); + assertThat(String.format("External Indicator is not visible for user %s", userName), getPage().isExternalIndicatorVisibleFor(userName)); + } + + @Then("^I tap on user (.*) on People page$") + public void iTapOnUser(String userName) { + userName = context.getUsersManager().replaceAliasesOccurrences(userName, ClientUsersManager.FindBy.NAME_ALIAS); + getPage().selectParticipantPeoplePage(userName); + } + + @Then("^I (do not )?see Members section header on People page$") + public void iSeeSectionHeaderMembers(String doNot) { + if (doNot == null) { + assertThat("Members section is not visible on people page while it should be", getPage().isMembersSectionVisible()); + } else { + assertThat("Members section is visible on people page while it should not be", getPage().isMembersSectionInvisible()); + } + } + + @Then("^I (do not )?see Admins section header on People page$") + public void iSeeSectionHeaderAdmins(String doNot) { + if (doNot == null) { + assertThat("Admins section is not visible on people page while it should be", getPage().isAdminsSectionVisible()); + } else { + assertThat("Admins section is visible on people page while it should not be", getPage().isAdminsSectionInvisible()); + } + } + + @Then("^I (do not )?see user (.*) in the Admins section on People page$") + public void iSeeUserInAdminSection(String doNot, String userName) { + userName = context.getUsersManager().replaceAliasesOccurrences(userName, ClientUsersManager.FindBy.NAME_ALIAS); + if (doNot == null) { + assertThat("User %s is not visible in the admin section on People page while it should be", getPage().isUserInAdminsSection(userName)); + } else { + assertThat("User %s is visible in the admin section on People page while it should not be", getPage().isUserNotInAdminsSection(userName)); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GuestOptionsPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GuestOptionsPageSteps.java new file mode 100644 index 00000000000..f27ca58c2bd --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/group/GuestOptionsPageSteps.java @@ -0,0 +1,44 @@ +package com.wearezeta.auto.ios.steps.conversation_details.group; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.details_overlay.group.GuestOptionsPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class GuestOptionsPageSteps { + IOSTestContext context; + + public GuestOptionsPageSteps(IOSTestContext context) { + this.context = context; + } + + private GuestOptionsPage getPage() { + return context.getPagesCollection().getPage(GuestOptionsPage.class); + } + + /** + * Verify the current value of a toggle Allow Guests + */ + @When("^I verify the value of Allow Guests equals to \"(.*)\" on Guest Options page") + public void iVerifyAllowGuestValue(String expectedValue) { + assertThat(String.format("The value of Allow Guests is not equal to '%s'", expectedValue), + getPage().isAllowGuestsEqualsTo(expectedValue)); + } + + @When("^I tap Back button on Guest Options page$") + public void iTapBackButtonOnGuestOptionsPage(){ + getPage().tapBackButton(); + } + + @Then("^I (do not )?see Create Link button on Guest Options page$") + public void iSeeCreateLinkButton(String shouldNotBeVisible){ + if (shouldNotBeVisible == null) { + assertThat("'Create Link' button should be visible", + getPage().isCreateLinkButtonVisible()); + } else { + assertThat("'Create Link' button should be invisible", + getPage().isCreateLinkButtonInvisible()); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/ConnectionInboxPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/ConnectionInboxPageSteps.java new file mode 100644 index 00000000000..60388a5a3f7 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/ConnectionInboxPageSteps.java @@ -0,0 +1,71 @@ +package com.wearezeta.auto.ios.steps.conversation_details.single; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import io.cucumber.java.en.Then; +import static org.hamcrest.MatcherAssert.assertThat; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.pages.details_overlay.single.ConnectionInboxPage; + +import io.cucumber.java.en.When; + +public class ConnectionInboxPageSteps { + IOSTestContext context; + + public ConnectionInboxPageSteps(IOSTestContext context) { + this.context = context; + } + + private ConnectionInboxPage getPage() { + return context.getPagesCollection() + .getPage(ConnectionInboxPage.class); + } + + /** + * Verify user details presence on Single user Pending incoming connection page + * + * @param shouldNotSee equals to null if the label should be visible + * @param value the actual value or alias + * @param fieldType either unique username or name or Address Book name + */ + @Then("^I (do not )?see (unique username|name|Address Book name|common friends count) (\".*\" |\\s*)on Connection Inbox page$") + public void ISeeLabel(String shouldNotSee, String fieldType, String value) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS, + ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + if (shouldNotSee == null) { + if (value.startsWith("\"")) { + value = value.trim().replaceAll("^\"|\"$", ""); + assertThat(String.format("'%s' field is expected to be visible", value), + getPage().isUserDetailVisible(fieldType, value)); + } else { + assertThat(String.format("'%s' field is expected to be visible", fieldType), + getPage().isUserDetailVisible(fieldType)); + } + } else { + if (value.startsWith("\"")) { + value = value.trim().replaceAll("^\"|\"$", ""); + assertThat(String.format("'%s' field is expected to be invisible", value), + getPage().isUserDetailInvisible(fieldType, value)); + } else { + assertThat(String.format("'%s' field is expected to be invisible", fieldType), + getPage().isUserDetailInvisible(fieldType)); + } + } + } + + @Then("^I see Connect button on Connection Inbox page$") + public void iSeeConnectButton() { + assertThat("Button not visible", getPage().isConnectButtonVisible()); + } + + @When("^I tap Ignore button on Connection Inbox page$") + public void iTapIgnoreButton() { + getPage().tapIgnoreButton(); + } + + @When("^I tap Connect button on Connection Inbox page$") + public void iTapConnectButton() { + getPage().tapConnectButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/SingleConnectedUserProfilePageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/SingleConnectedUserProfilePageSteps.java new file mode 100644 index 00000000000..51ebd889ab1 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/SingleConnectedUserProfilePageSteps.java @@ -0,0 +1,82 @@ +package com.wearezeta.auto.ios.steps.conversation_details.single; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.IOSPage; +import io.cucumber.java.en.And; +import io.cucumber.java.en.Then; + +import static org.hamcrest.MatcherAssert.assertThat; + +import com.wearezeta.auto.ios.pages.details_overlay.single.SingleConnectedUserProfilePage; + +import io.cucumber.java.en.When; + +public class SingleConnectedUserProfilePageSteps { + IOSTestContext context; + + public SingleConnectedUserProfilePageSteps(IOSTestContext context) { + this.context = context; + } + + private SingleConnectedUserProfilePage getPage() { + return context.getPagesCollection().getPage(SingleConnectedUserProfilePage.class); + } + + @When("^I tap Create Group button on Single user profile page$") + public void ITapCreateGroupButton() { + getPage().tapCreateGroupButton(); + } + + @When("^I tap X button on Single user profile page$") + public void ITapXButton() { + getPage().tapXButton(); + } + + @When("^I tap Open Menu button on Single user profile page$") + public void ITapOpenMenuButton() { + getPage().tapOpenMenuButton(); + } + + @When("^I tap Back button on Single user profile page$") + public void ITapBackButton() { + getPage().tapBackButton(); + } + + @When("^I switch to Devices tab on Single user profile page$") + public void IChangeToDevicesTab() { + getPage().switchToDevicesTab(); + } + + @Then("^I (do not )?see Information label on Single user profile page$") + public void ISeeInformationLabel(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The Information label is not visible on Single user profile page", + getPage().isInformationLabelVisible()); + } else { + assertThat("The Information label is still visible on Single user profile page", + getPage().isInformationLabelInvisible()); + } + } + + @Then("^I see key \"(.*)\" and value \"(.*)\" at cell (\\d+) on Single user profile page$") + public void ISeeRichProfileKeyValuePair(String key, String value, int index) { + assertThat("The key value pair is not visible on Single user profile page", + getPage().isInformationKeyValuePairVisible(key, value, index)); + } + + @And("^I swipe (down|up) on Single user profile page$") + public void iSwipe(String direction) { + getPage().swipe(IOSPage.SwipeDirection.valueOf(direction.toUpperCase())); + } + + @Then("^I see Read Receipt Footer on Single user profile page$") + public void iSeeReadReceiptFooter() { + assertThat("The read receipt footer is not visible on Single user profile page", + getPage().isReadReceiptFooterVisible()); + } + + @When("^I tap Start Conversation button on Single user profile page$") + public void iTapStartConversationButton() { + getPage().tapStartConversation(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/SinglePendingUserIncomingConnectionProfilePageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/SinglePendingUserIncomingConnectionProfilePageSteps.java new file mode 100644 index 00000000000..25f32c7f92e --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/SinglePendingUserIncomingConnectionProfilePageSteps.java @@ -0,0 +1,47 @@ +package com.wearezeta.auto.ios.steps.conversation_details.single; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.pages.details_overlay.single.SinglePendingUserIncomingConnectionProfilePage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class SinglePendingUserIncomingConnectionProfilePageSteps { + IOSTestContext context; + + public SinglePendingUserIncomingConnectionProfilePageSteps(IOSTestContext context) { + this.context = context; + } + + private SinglePendingUserIncomingConnectionProfilePage getPage() { + return context.getPagesCollection() + .getPage(SinglePendingUserIncomingConnectionProfilePage.class); + } + + @Then("^I (do not )?see name \"(.*)\" on Single user Pending incoming connection profile page$") + public void ISeeDisplayName(String shouldNotSee, String value) { + value = context.getUsersManager().replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS, + ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + if (shouldNotSee == null) { + assertThat("Username is expected to be visible",getPage().isDisplayNameVisible(value)); + } else { + assertThat("Username is expected to be invisible",getPage().isDisplayNameInvisible(value)); + } + } + + @When("^I tap Connect inbox-style button on Single user Pending incoming connection profile page$") + public void ITapConnectInboxStyleButton() { + getPage().tapConnectInboxStyleButton(); + } + + @When("^I tap Ignore inbox-style button on Single user Pending incoming connection profile page$") + public void ITapIgnoreInboxStyleButton() { + getPage().tapIgnoreInboxStyleButton(); + } + + @When("^I tap Back button on Single user Pending incoming connection profile page$") + public void ITapBackButton() { + getPage().tapBackButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/SingleUserOutgoingPendingConnectionPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/SingleUserOutgoingPendingConnectionPageSteps.java new file mode 100644 index 00000000000..b21d94ef5dd --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/SingleUserOutgoingPendingConnectionPageSteps.java @@ -0,0 +1,103 @@ +package com.wearezeta.auto.ios.steps.conversation_details.single; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import io.cucumber.java.en.Then; +import static org.hamcrest.MatcherAssert.assertThat; + +import com.wearezeta.auto.ios.pages.details_overlay.single.SinglePendingUserOutgoingConnectionPage; + +import io.cucumber.java.en.When; + +public class SingleUserOutgoingPendingConnectionPageSteps { + IOSTestContext context; + + public SingleUserOutgoingPendingConnectionPageSteps(IOSTestContext context) { + this.context = context; + } + + private SinglePendingUserOutgoingConnectionPage getPage() { + return context.getPagesCollection().getPage(SinglePendingUserOutgoingConnectionPage.class); + } + + /** + * Verify user details presence on Single user Pending outgoing connection page + * + * @param shouldNotSee equals to null if the label should be visible + * @param value the actual value or alias + * @param fieldType either unique username or Address Book name or name + */ + @Then("^I (do not )?see (unique username|Address Book name|name|common friends count) (\".*\" |\\s*)on Single user Pending outgoing connection page$") + public void ISeeLabel(String shouldNotSee, String fieldType, String value) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS, + ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + if (shouldNotSee == null) { + if (value.startsWith("\"")) { + value = value.trim().replaceAll("^\"|\"$", ""); + assertThat(String.format("'%s' field is expected to be visible", value), + getPage().isUserDetailVisible(fieldType, value)); + } else { + assertThat(String.format("'%s' field is expected to be visible", fieldType), + getPage().isUserDetailVisible(fieldType)); + } + } else { + if (value.startsWith("\"")) { + value = value.trim().replaceAll("^\"|\"$", ""); + assertThat(String.format("'%s' field is expected to be invisible", value), + getPage().isUserDetailInvisible(fieldType, value)); + } else { + assertThat(String.format("'%s' field is expected to be invisible", fieldType), + getPage().isUserDetailInvisible(fieldType)); + } + } + } + + /** + * Verify button visibility on Single user Pending outgoing connection page + * + * @param shouldNotSee equals to null if the button should be visible + * @param btnName button name + */ + @Then("^I (do not )?see (Connect) button on Single user Pending outgoing connection page$") + public void ISeeConnectButton(String shouldNotSee, String btnName) { + if (shouldNotSee == null) { + assertThat(String.format("'%s' button is expected to be visible", btnName), + getPage().isButtonVisible(btnName)); + } else { + assertThat(String.format("'%s' button is expected to be invisible", btnName), + getPage().isButtonInvisible(btnName)); + } + } + + @Then("^I (do not )?see Cancel Request button on Single user Pending outgoing connection page$") + public void ISeeCancelRequestButton(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Cancel Request button is expected to be visible", + getPage().isCancelRequestButtonVisible()); + } else { + assertThat("'%s' button is expected to be invisible", + getPage().isCancelRequestButtonInvisible()); + } + } + + /** + * Tap the corresponding button on Single user Pending incoming connection page + * + * @param btnName button name + */ + @When("^I tap (Cancel Request|Archive|Connect|X) button on Single user Pending outgoing connection page$") + public void ITapButton(String btnName) { + getPage().tapButton(btnName); + } + + @When("^I tap Back button on Single user Pending outgoing connection page$") + public void ITapBackButton() { + getPage().tapBackButton(); + } + + @When("^I tap Back button on Single user Pending outgoing connection page on iPad$") + public void ITapBackButtoniPad() { + getPage().tapBackButtoniPad(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/UserProfilePopupPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/UserProfilePopupPageSteps.java new file mode 100644 index 00000000000..13616338e60 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/conversation_details/single/UserProfilePopupPageSteps.java @@ -0,0 +1,158 @@ +package com.wearezeta.auto.ios.steps.conversation_details.single; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.IOSPage; +import io.cucumber.java.en.And; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +import com.wearezeta.auto.ios.pages.details_overlay.single.UserProfilePopupPage; + +public class UserProfilePopupPageSteps { + private IOSTestContext context; + + public UserProfilePopupPageSteps(IOSTestContext context) { + this.context = context; + } + + private UserProfilePopupPage getPage() { + return context.getPagesCollection().getPage(UserProfilePopupPage.class); + } + + @Then("^I (do not )?see User profile popup page$") + public void ISeeUserPopupPage(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The user profile popup page is not visible", + getPage().isUserProfilePopupVisible()); + } else { + assertThat("The user profile popup page is still visible", + getPage().isUserProfilePopupInvisible()); + } + } + + @When("^I tap X button on User profile popup page$") + public void ITapXButton() { + getPage().tapXButton(); + } + + @When("^I tap Open Conversation button on User profile popup page$") + public void ITapOpenConversationButton() { + getPage().tapOpenConversationButton(); + } + + @When("^I tap Open self profile button on User profile popup page$") + public void ITapSelfProfileButton() { + getPage().tapSelfProfileButton(); + } + + @When("^I tap More Actions button on User profile popup page$") + public void ITapMoreActionsButton() { + getPage().tapMoreActionsButton(); + } + + @Then("^I (do not )?see user name (.*) on User profile popup page$") + public void ISeeDisplayName(String shouldNotSee, String value) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS); + if (shouldNotSee == null) { + assertThat(String.format("User name '%s' is expected to be visible", value), + getPage().isUserNameVisible(value)); + } else { + assertThat(String.format("\"User name '%s' field is expected to be visible", value), + getPage().isUserNameInvisible(value)); + } + } + + @Then("^I (do not )?see unique user name (.*) on User profile popup page$") + public void ISeeUniqueUserName(String shouldNotSee, String value) { + value = context.getUsersManager(). + replaceAliasesOccurrences(value, ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + if (shouldNotSee == null) { + assertThat(String.format("User name '%s' is expected to be visible", value), + getPage().isUniqueUserNameVisible(value)); + } else { + assertThat(String.format("\"User name '%s' field is expected to be visible", value), + getPage().isUniqueUserNameInvisible(value)); + } + } + + @Then("^I see profile picture on User profile popup page$") + public void ISeeProfilePicture() { + assertThat("The profile picture is not visible on User profile popup page", + getPage().isUserProfilePictureVisible()); + } + + @Then("^I (do not )?see Information label on User profile popup page$") + public void ISeeInformationLabel(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The Information label is not visible on User profile popup page", + getPage().isInformationLabelVisible()); + } else { + assertThat("The Information label is still visible on User profile popup page", + getPage().isInformationLabelInvisible()); + } + } + + @Then("^I see key \"(.*)\" and value \"(.*)\" at cell (\\d+) on User profile popup page$") + public void ISeeRichProfileKeyValuePair(String key, String value, int index) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.EMAIL_ALIAS); + assertThat("The key value pair is not visible on User profile popup page", + getPage().isInformationKeyValuePairVisible(key, value, index)); + } + + @And("^I swipe (down|up) on User profile popup page$") + public void iSwipe(String direction) { + getPage().swipe(IOSPage.SwipeDirection.valueOf(direction.toUpperCase())); + } + + @Then("^I (do not )?see Open Conversation button on User profile popup page$") + public void ISeeOpenConversationtButton(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The Open Conversation button is not visible on User profile popup page", + getPage().isOpenConversationButtonVisible()); + } else { + assertThat("The Open Conversation button is still visible on User profile popup page", + getPage().isOpenConversationButtonInvisible()); + } + } + + @Then("^I (do not )?see Connect button on User profile popup page$") + public void ISeeConnectButton(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The Connect button is not visible on User profile popup page", + getPage().isConnectButtonVisible()); + } else { + assertThat("The Connect button is still visible on User profile popup page", + getPage().isConnectButtonInvisible()); + } + } + + @Then("^I (do not )?see More Actions button on User profile popup page$") + public void ISeeMoreActionsButton(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The More Actions button is not visible on User profile popup page", + getPage().isMoreActionsButtonVisible()); + } else { + assertThat("The More Actions button is still visible on User profile popup page", + getPage().isMoreActionsButtonInvisible()); + } + } + + @Then("^I do not see Devices tab on User profile popup page$") + public void IDoNotSeeDevicesTab() { + assertThat("Devices should not be visible", + getPage().isDevicesTabInvisible()); + } + + @Then("^I (do not )?see GUEST label on User profile popup page$") + public void ISeeGuestLabel(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("GUEST label is expected to be visible ", getPage().isGuestLabelVisible()); + } else { + assertThat("GUEST label is expected to be invisible ", getPage().isGuestLabelInvisible()); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/external_app/FileChooseDialogSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/external_app/FileChooseDialogSteps.java new file mode 100644 index 00000000000..3ba2fde3dc0 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/external_app/FileChooseDialogSteps.java @@ -0,0 +1,56 @@ +package com.wearezeta.auto.ios.steps.external_app; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.external_app.FileChooseDialogPage; +import io.cucumber.java.en.When; + +public class FileChooseDialogSteps { + + IOSTestContext context; + + public FileChooseDialogSteps(IOSTestContext context) { + this.context = context; + } + + private FileChooseDialogPage getPage() { + return context.getPagesCollection().getPage(FileChooseDialogPage.class); + } + + @When("^I tap Browse button twice on bottom of File Choose Dialog$") + public void iTapBrowseFolders() { + getPage().tapBrowseFoldersButton(); + getPage().tapBrowseFoldersButton(); + } + + @When("I tap Browse button of File Choose Dialog on iPad$") + public void iTapBrowserFoldersOnIPad() { + getPage().tapBrowserFolderButtonOnIPad(); + } + + @When("^I tap On My iPhone on File Choose Dialog$") + public void iTapOnMyIPhone() { + getPage().tapOnMyIPhone(); + } + + @When("^I tap On My iPad on File Choose Dialog$") + public void iTapOnMyIPad() { + getPage().tapOnMyIPad(); + } + + @When("^I sort files by date on File Choose Dialog$") + public void iSortFilesByDate() { + getPage().tapOnEllipsisButton(); + if (getPage().isSortByDateNotSelected()) { + getPage().tapSortByDateEntry(); + } + getPage().dismissEllipsisMenu(); + } + + @When("^I tap file containing (.*) in File Choose Dialog$") + public void iTapFileContaining(String usernameAlias) { + usernameAlias = context.getUsersManager().replaceAliasesOccurrences(usernameAlias, + ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + getPage().tapFileContaining(usernameAlias); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/external_app/FileSavingSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/external_app/FileSavingSteps.java new file mode 100644 index 00000000000..c0753177e91 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/external_app/FileSavingSteps.java @@ -0,0 +1,61 @@ +package com.wearezeta.auto.ios.steps.external_app; + +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.external_app.FileSavingPopupPage; +import io.cucumber.java.en.When; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.TimeZone; + +import static org.hamcrest.MatcherAssert.*; +import static org.hamcrest.Matchers.containsString; + +public class FileSavingSteps { + + IOSTestContext context; + + public FileSavingSteps(IOSTestContext context) { + this.context = context; + } + + private FileSavingPopupPage getPage() { + return context.getPagesCollection().getPage(FileSavingPopupPage.class); + } + + @When("^I see correct name of backup file for user (.*) on File Saving Popup$") + public void iSeeNameOfFile(String userAlias) { + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(userAlias); + assertThat("File name in label does not contain user name", + getPage().getFileLabel(), containsString(user.getUniqueUsername())); + + final TimeZone timezone = TimeZone.getTimeZone("Europe/Berlin"); + DateFormat dateFormat = new SimpleDateFormat("yyyyMMdd"); + dateFormat.setTimeZone(timezone); + String filename = String.format("Wire-%s-Backup_%s", user.getUniqueUsername(), dateFormat.format(new Date())); + assertThat("File name in label does not contain correct filename", + getPage().getFileLabel(), containsString(filename)); + } + + @When("^I tap Save to Files button on File Saving Popup$") + public void iTapSaveToFiles() { + getPage().tapSaveToFilesButton(); + } + + @When("^I tap On My iPhone on File Saving Popup$") + public void iTapOnMyIPhone() { + getPage().tapOnMyIPhone(); + } + + @When("^I tap On My iPad on File Saving Popup$") + public void iTapOnMyIPad() { + getPage().tapOnMyIPad(); + } + + @When("^I tap Save button on File Saving Popup$") + public void iTapSave() { + getPage().tapSaveButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/linear_groupcreation/AddPeoplePageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/linear_groupcreation/AddPeoplePageSteps.java new file mode 100644 index 00000000000..5bcdf8468d9 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/linear_groupcreation/AddPeoplePageSteps.java @@ -0,0 +1,63 @@ +package com.wearezeta.auto.ios.steps.linear_groupcreation; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.linear_groupcreation.AddPeoplePage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class AddPeoplePageSteps { + IOSTestContext context; + + public AddPeoplePageSteps(IOSTestContext context) { + this.context = context; + } + + private AddPeoplePage getPage() { + return context.getPagesCollection().getPage(AddPeoplePage.class); + } + + @When("^I tap Create button on Add People page$") + public void ITapCreateButtonOnAddPeoplePage() { + getPage().tapCreateButton(); + } + + @When("^I tap Skip button on Add People page$") + public void ITapSkipButtonOnAddPeoplePage() { + getPage().tapSkipButton(); + } + + @When("^I tap Back button on Add People page$") + public void ITapBackButtonOnAddPeoplePage() { + getPage().tapBackButton(); + } + + @When("^I type \"(.*)\" in (cleared )?search input field on Add People page$") + public void ITypeInSearchInputFieldOnAddPeoplePage(String userName, String shouldBeCleared) { + userName = context.getUsersManager() + .replaceAliasesOccurrences(userName, ClientUsersManager.FindBy.NAME_ALIAS, ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + getPage().typeSearchQuery(userName, shouldBeCleared != null); + } + + @Then("^I see the count of selected participants is (\\d+) on Add People page$") + public void ISeeParticipantCount(int expectedCount) { + assertThat(String.format("The count of participant is not %s", expectedCount), + getPage().isParticipantsCountEqualTo(expectedCount)); + } + + @When("^I (?:select|unselect) search result item (.*) on Add People page$") + public void iSelectItem(String name) { + name = context.getUsersManager().replaceAliasesOccurrences(name, ClientUsersManager.FindBy.NAME_ALIAS); + getPage().selectItem(name); + } + + @Then("^I see \"(No Results|Everyone is here)\" label on Add People page$") + public void ISeeResultLabel(String msg) { + assertThat(String.format("Label '%s' should be visible", msg), + getPage().waitUntilResultsLabelIsVisible(msg)); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/linear_groupcreation/NewGroupPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/linear_groupcreation/NewGroupPageSteps.java new file mode 100644 index 00000000000..5c15c855d2d --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/linear_groupcreation/NewGroupPageSteps.java @@ -0,0 +1,124 @@ +package com.wearezeta.auto.ios.steps.linear_groupcreation; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.linear_groupcreation.NewGroupPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class NewGroupPageSteps { + IOSTestContext context; + + public NewGroupPageSteps(IOSTestContext context) { + this.context = context; + } + + private NewGroupPage getNewGroupPage() { + return context.getPagesCollection().getPage(NewGroupPage.class); + } + + @When("^I enter group name \"(.*)\" on New Group page$") + public void iEnterGroupnameOnNewGroupPage(String groupName) { + getNewGroupPage().enterGroupName(groupName); + } + + @When("^I tap Next button on New Group page$") + public void iTapNextButtonOnNewGroupPage() { + getNewGroupPage().tapNextButton(); + } + + @When("^I (?:expand|collapse) conversation options on New Group page$") + public void iChangeStateOfConversationOptions() { + getNewGroupPage().tapConversationOptions(); + } + + @When("^I verify the value of Allow Guests equals to \"(.*)\" on New Group page") + public void iVerifyAllowGuestValue(String expectedValue) { + assertThat(String.format("The value of Allow Guests is not equal to '%s'", expectedValue), + getNewGroupPage().isAllowGuestsEqualsTo(expectedValue)); + } + + @When("^I switch Allow Guests toggle on New Group page$") + public void iSwitchAllowGuestsToggle() { + getNewGroupPage().switchToggle(); + } + + @When("^I see the summary value of Conversation Options \"(.*)\" on New Group page") + public void iVerifyDefaultConversationOptionsValue(String options) { + assertThat( + String.format("Expected conversation options not visible '%s'", options), + getNewGroupPage().isExpectedConversationOptionsVisible(options)); + } + + @Then("^I (do not )?see Protocol option on New Group page$") + public void seeProtocol(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Protocol option should be visible", + getNewGroupPage().isProtocolVisible()); + } else { + assertThat("Protocol option should not be visible", + getNewGroupPage().isProtocolInvisible()); + } + } + + @Then("^I (do not )?see Proteus value in Protocol option on New Group page$") + public void seeProteusProtocol(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Proteus value should be visible", + getNewGroupPage().isProteusValueVisible()); + } else { + assertThat("Proteus value should not be visible", + getNewGroupPage().isProteusValueInvisible()); + } + } + + @Then("^I (do not )?see MLS value in Protocol option on New Group page$") + public void seeMlsProtocol(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("MLS value should be visible", + getNewGroupPage().isMlsValueVisible()); + } else { + assertThat("MLS value should not be visible", + getNewGroupPage().isMlsValueInvisible()); + } + } + + @When("^I tap Protocol option on New Group page$") + public void iTapProtocolOption() { + getNewGroupPage().tapProtocolOption(); + } + + @When("^I tap MLS option on New Group page$") + public void iTapMlsOption() { + getNewGroupPage().tapMlsOption(); + } + + @Then("^I see max (\\d+) participant limit on New Group page$") + public void ISeeMaxParticipantLimit(int limit) { + assertThat(String.format("Max limit '%d' is not visible", limit), + getNewGroupPage().isMaxLimitEqualsTo(limit)); + } + + @Then("^I (do not )?see Guests option on group creation view$") + public void ISeeGuestOption(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("'Guests option is expected to be visible", + getNewGroupPage().isGuestOptionVisible()); + } else { + assertThat("Guests option is not expected to be visible", + getNewGroupPage().isGuestOptionInvisible()); + } + } + + @Then("^I (do not )?see Services option on group creation view$") + public void ISeeServiceOption(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("'Services option is expected to be visible", + getNewGroupPage().isServiceOptionVisible()); + } else { + assertThat("Services option is not expected to be visible", + getNewGroupPage().isServiceOptionInvisible()); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/services/ServiceSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/services/ServiceSteps.java new file mode 100644 index 00000000000..757ac272983 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/services/ServiceSteps.java @@ -0,0 +1,37 @@ +package com.wearezeta.auto.ios.steps.services; + +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.ServiceCreationPage; +import com.wearezeta.auto.ios.pages.ServiceDetailPage; +import com.wearezeta.auto.ios.pages.TeamSearchUIPage; +import com.wearezeta.auto.ios.pages.details_overlay.group.GroupDetailsPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class ServiceSteps { + IOSTestContext context; + + public ServiceSteps(IOSTestContext context) { + this.context = context; + } + + private TeamSearchUIPage getTeamSearchUIPage() { + return context.getPagesCollection().getPage(TeamSearchUIPage.class); + } + + private ServiceDetailPage getServiceDetailPage() { + return context.getPagesCollection().getPage(ServiceDetailPage.class); + } + + @When("^I tap Services tab on Team Search UI page$") + public void ITapServiceTab() { + getTeamSearchUIPage().tapTeamSearchUITab(); + } + + @Then("^I tap Add Service button on service detail page$") + public void ITapOnAddServiceOnServiceDetailPage(){ + getServiceDetailPage().addService(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/settings/SelfDevicesPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/settings/SelfDevicesPageSteps.java new file mode 100644 index 00000000000..10fc2289413 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/settings/SelfDevicesPageSteps.java @@ -0,0 +1,107 @@ +package com.wearezeta.auto.ios.steps.settings; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.IOSPage; +import com.wearezeta.auto.ios.pages.details_overlay.common.UserSettingsDevicesPage; +import io.cucumber.java.en.And; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; + +public class SelfDevicesPageSteps { + IOSTestContext context; + + public SelfDevicesPageSteps(IOSTestContext context) { + this.context = context; + } + + private UserSettingsDevicesPage getUserSettingsDevicesPage() { + return context.getPagesCollection().getPage(UserSettingsDevicesPage.class); + } + + /** + * Open the details page of corresponding device on conversation details page + * + * @param deviceIndex the device index. Starts from 1 + */ + @When("^I open details page of device number (\\d+) on Settings page$") + public void IOpenDeviceDetails(int deviceIndex) { + getUserSettingsDevicesPage().openDeviceDetailsPage(deviceIndex); + } + + /** + * Presses the delete button for the particular device + * + * @param deviceName name of device that should be deleted + */ + @When("^I tap Delete (.*) button on Settings page$") + public void ITapDeleteButtonFromDevices(String deviceName) { + getUserSettingsDevicesPage().tapDeleteDeviceButton(deviceName); + getUserSettingsDevicesPage().tapDeleteButton(); + } + + /** + * Types in the password and presses OK to confirm the device deletion + * + * @param password of user + */ + @When("^I confirm with my (.*) the deletion of the device on Settings page$") + public void IConfirmWithMyPasswordTheDeletionOfTheDevice(String password) { + password = context.getUsersManager() + .replaceAliasesOccurrences(password, ClientUsersManager.FindBy.PASSWORD_ALIAS); + getUserSettingsDevicesPage().typePasswordToConfirmDeleteDevice(password); + context.getPagesCollection().getPage(IOSPage.class).tapAlertButton("OK"); + } + + @Then("^I see wrong password dialog$") + public void iSeeWrongPasswordDialog() { + assertThat("No wrong password dialog", getUserSettingsDevicesPage().isWrongPasswordDialogVisible()); + } + + @When("^I tap Ok Button on wrong password dialog$") + public void iClickOK() { + getUserSettingsDevicesPage().tapOKOnWrongPasswordDialog(); + } + + /** + * Verifies that device is or is not in device settings list + * + * @param shouldNot equals to null if the device is in list + * @param device name of device in list + */ + @Then("^I (do not )?see device (.*) in devices list on Settings page$") + public void ISeeDeviceInDevicesList(String shouldNot, String device) { + if (shouldNot == null) { + assertThat(String.format("The device %s is not visible in the device list", device), + getUserSettingsDevicesPage().isDeviceVisibleInList(device)); + } else { + assertThat(String.format("The device %s is still visible in the device list", device), + getUserSettingsDevicesPage().isDeviceInvisibleInList(device)); + } + } + + /** + * Checks the number of devices in participant devices tab + * + * @param expectedNumDevices Expected number of devices + */ + @Then("^I see (\\d+) devices? (?:is|are) shown on Settings page$") + public void ISeeDevicesShownInDevicesTab(int expectedNumDevices) { + assertThat( + String.format("The expected number of devices: %s is not equals to actual count", expectedNumDevices), + getUserSettingsDevicesPage().isUserDevicesCountEqualTo(expectedNumDevices) + ); + } + + @When("^I save the device id of the current device$") + public void iSaveMyCurrentDevice() { + String deviceID = getUserSettingsDevicesPage().getCurrentDeviceID(); + context.setCurrentDeviceId(deviceID); + } + + @And("I open my remembered device") + public void iOpenMyRememberedDevice() { + getUserSettingsDevicesPage().openDeviceDetailsPageById(context.getCurrentDeviceId()); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/settings/SelfProfilePageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/settings/SelfProfilePageSteps.java new file mode 100644 index 00000000000..599860f971c --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/settings/SelfProfilePageSteps.java @@ -0,0 +1,99 @@ +package com.wearezeta.auto.ios.steps.settings; + +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.details_overlay.single.SelfProfilePage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import java.awt.image.BufferedImage; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasItem; + +public class SelfProfilePageSteps { + IOSTestContext context; + + public SelfProfilePageSteps(IOSTestContext context) { + this.context = context; + } + + private SelfProfilePage getPage() { + return context.getPagesCollection().getPage(SelfProfilePage.class); + } + + @When("^I tap Add Account button on Self profile page$") + public void iTapAddAccount() { + getPage().tapAddAccountButton(); + } + + @When("^I tap Manage Team button on Self profile page$") + public void iTapManageTeam() { + getPage().tapManageTeam(); + } + +/** + @When("^I tap Settings button on Self profile page$") + public void iTapSettingsButton() { + getPage().tapSettingsButton(); + } */ + + /** + * Tap the picture preview self profile page + */ + @When("^I tap my picture preview on Self profile page$") + public void IPicturePreview() { + getPage().tapProfilePicture(); + } + @When("^I tap on set a status button on self profile page$") + public void ITapSetStatusButton() { + getPage().tapSetStatusButton(); + } + + @When("^I tap on profile close button$") + public void ITapProfileCloseButton() { + getPage().tapProfileCloseButton(); + } + /** + * Verify user details on Selfprofile page + * + * @param shouldNotSee equals to null if the corresponding details should be visible + * @param value user name or unique username or Address Book name + * @param fieldType one of available field types + */ + @When("^I (do not )?see (name|unique username|team name|sso username) \"(.*)\" on Self profile page$") + public void ISeeLabel(String shouldNotSee, String fieldType, String value) { + value = context.getUsersManager() + .replaceAliasesOccurrences(value, ClientUsersManager.FindBy.NAME_ALIAS, + ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + if (shouldNotSee == null) { + value = value.trim().replaceAll("^\"|\"$", ""); + assertThat(String.format("'%s' field is expected to be visible", value), + getPage().isUserDetailVisible(fieldType, value)); + } else { + value = value.trim().replaceAll("^\"|\"$", ""); + assertThat(String.format("'%s' field is expected to be invisible", value), + getPage().isUserDetailInvisible(fieldType, value)); + } + } + + private static final Timedelta PROFILE_PICTURE_CHANGE_TIMEOUT = Timedelta.ofSeconds(7); + private static final double PROFILE_PICTURE_MAX_SCORE = 0.7; + + /** + * Verify whether self profile picture has been changed or not + * + * @param shouldNotBeChanged equals to null if the picture should stay the same + */ + @Then("^I see the picture is (not )?changed on Self profile page$") + public void IVerifyPicture(String shouldNotBeChanged) throws Exception { + if (shouldNotBeChanged == null) { + assertThat("Self profile picture is still the same", + context.getProfilePictureState().isChanged(PROFILE_PICTURE_CHANGE_TIMEOUT, PROFILE_PICTURE_MAX_SCORE)); + } else { + assertThat("Self profile picture is expected to be the same", + context.getProfilePictureState().isNotChanged(PROFILE_PICTURE_CHANGE_TIMEOUT, PROFILE_PICTURE_MAX_SCORE)); + } + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/settings/SettingsPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/settings/SettingsPageSteps.java new file mode 100644 index 00000000000..8a0f890ef29 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/settings/SettingsPageSteps.java @@ -0,0 +1,435 @@ +package com.wearezeta.auto.ios.steps.settings; + +import com.wearezeta.auto.common.backend.BackendConnections; +import com.wearezeta.auto.common.backend.models.AccentColor; +import com.wearezeta.auto.common.email.messages.AccountDeletionMessage; +import com.wearezeta.auto.common.email.messages.ActivationMessage; +import com.wearezeta.auto.common.email.messages.WireMessage; +import com.wearezeta.auto.common.email.handlers.ISupportsMessagesPolling; +import com.wearezeta.auto.common.email.MailboxProvider; +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.SettingsPage; +import io.cucumber.java.en.And; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class SettingsPageSteps { + IOSTestContext context; + + public SettingsPageSteps(IOSTestContext context) { + this.context = context; + } + + private SettingsPage getSettingsPage() { + return context.getPagesCollection().getPage(SettingsPage.class); + } + + @When("^I select settings item Devices") + public void ISelectDevicesItem() { + getSettingsPage().tapDevices(); + } + + @When("^I select settings item Account") + public void ISelectAccountItem() { + getSettingsPage().tapAccount(); + } + + @When("^I select settings item Back Up Conversations") + public void ISelectBackUpConversationsItem() { + getSettingsPage().tapBackUpConversations(); + } + + @When("^I select settings item Options") + public void ISelectOptionsItem() { + getSettingsPage().tapOptionsItem(); + } + + @When("^I select settings item Log Out") + public void ISelectLogOutItem() { + getSettingsPage().tapLogOutItem(); + } + + @When("^I select settings item Picture") + public void ISelectPictureItem() { + getSettingsPage().tapPictureItem(); + } + + @When("^I select settings item Reset Password") + public void ISelectResetPasswordItem() { + getSettingsPage().tapResetPasswordItem(); + } + + @When("^I select settings item Support") + public void ISelectSupportItem() { + getSettingsPage().tapSupportItem(); + } + + @When("^I select settings item Wire Support Website") + public void ISelectWireSupportWebsiteItem() { + getSettingsPage().tapWireSupportWebsiteItem(); + } + + @When("^I select settings item Terms of use") + public void ISelectTermsOfUse() { + getSettingsPage().tapTermsOfUse(); + } + + @When("^I select settings item Privacy Policy") + public void ISelectPrivacyPolicy() { + getSettingsPage().tapPrivacyPolicy(); + } + + @When("^I select settings item Wire Website") + public void ISelectWireWebsite() { + getSettingsPage().tapWireWebsite(); + } + + @When("^I select settings item Contact Support") + public void ISelectContactSupport() { + getSettingsPage().tapContactSupport(); + } + + @When("^I select settings item Report Misuse") + public void ISelectReportMisuse() { + getSettingsPage().tapReportMisuse(); + } + + @When("^I select settings item Delete Account") + public void ISelectDeleteAccountItem() { + getSettingsPage().tapDeleteAccountItem(); + } + + @When("^I select settings item Username") + public void ISelectUsernameItem() { + getSettingsPage().tapUsernameItem(); + } + + @When("^I select settings item Email") + public void ISelectEmailItem() { + getSettingsPage().tapEmailItem(); + } + + @When("^I select settings item Name") + public void ISelectNameItem() { + getSettingsPage().tapNameItem(); + } + + @When("^I select settings item About") + public void ISelectAboutItem() { + getSettingsPage().tapAboutItem(); + } + + @When("^I select settings item Color") + public void ISelectColorItem() { + getSettingsPage().tapColorItem(); + } + + @When("^I select color Purple on Profile Color page") + public void ISelectColorPurple() { + getSettingsPage().tapColorPurple(); + } + + @When("^I clear Username input field on Settings page$") + public void iClearUsername() { + getSettingsPage().clearUsername(); + } + + /** + * Verify the current value of a setting + * + * @param itemName setting option name + * @param expectedValue the expected value. Can be user name/email/phone number alias + */ + @Then("^I verify the value of settings item (.*) equals to \"(.*)\"") + public void IVerifySettingsItemValue(String itemName, String expectedValue) { + expectedValue = context.getUsersManager() + .replaceAliasesOccurrences(expectedValue, ClientUsersManager.FindBy.EMAIL_ALIAS, + ClientUsersManager.FindBy.NAME_ALIAS); + assertThat(String.format("The value of '%s' setting item is not equal to '%s'", itemName, expectedValue), + getSettingsPage().isSettingItemValueEqualTo(itemName, expectedValue)); + } + + @When("^I see the text on VBR toggle in settings$") + public void ISeeVBRText() { + getSettingsPage().iSeeVBRText(); + } + + /** + * Verify whether the corresponding settings menu item is visible + * + * @param itemName the expected item name + */ + @Then("^I (do not )?see settings item (.*)$") + public void ISeeSettingsItem(String shouldNot, String itemName) { + if (shouldNot == null) { + assertThat(String.format("Settings menu item '%s' is not visible", itemName), + getSettingsPage().isItemVisible(itemName)); + } else { + assertThat(String.format("Settings menu item %s is visible", itemName), + getSettingsPage().isItemInvisible(itemName)); + } + } + + /** + * Start monitoring for account removal email confirmation + * + * @param name user name/alias + */ + @When("^I start waiting for (.*) account removal notification$") + public void IStartWaitingForAccountRemovalConfirmation(String name) throws Exception { + final ClientUser forUser = context.getUsersManager() + .findUserByNameOrNameAlias(name); + + final Map expectedHeaders = new HashMap<>(); + expectedHeaders.put(WireMessage.ZETA_PURPOSE_HEADER_NAME, AccountDeletionMessage.MESSAGE_PURPOSE); + + ISupportsMessagesPolling mailbox = MailboxProvider.getInstance(BackendConnections.get(forUser), + forUser.getEmail()); + context.setAccountRemovalConfirmation( + mailbox.getMessage(expectedHeaders, AccountDeletionMessage.DELETION_RECEIVING_TIMEOUT)); + } + + /** + * Make sure the account removal link is received + */ + @Then("^I verify account removal notification is received$") + public void IVerifyAccountRemovalNotificationIsReceived() throws Exception { + if (context.getAccountRemovalConfirmation() == null) { + throw new IllegalStateException("Please init email confirmation listener first"); + } + new AccountDeletionMessage(context.getAccountRemovalConfirmation().get()); + } + + /** + * Take a screenshot of self profile page and save it into internal var + */ + @When("^I remember my current profile picture$") + public void IRememberMyProfilePicture() throws Exception { + context.setProfilePictureState(() -> getSettingsPage().takeScreenshot(). + orElseThrow(() -> new IllegalStateException("Cannot take a screenshot of self profile page"))); + } + + @When("^I tap X navigation button on Settings page$") + public void iTapX() { + getSettingsPage().tapX(); + } + + @When("^I tap (Done|Back|Edit|Save|X|Go back to Settings|Go back to Setting|Go back to Account|Go back to device list) navigation button on Settings page$") + public void ITapNavigationButton(String name) { + getSettingsPage().tapNavigationButton(name); + } + + @When("^I clear Name input field on Settings page$") + public void IClearSelfName() { + getSettingsPage().clearSelfName(); + } + + @When("^I set \"(.*)\" value to Name input field on Settings page$") + public void ISetSelfName(String newValue) { + newValue = context.getUsersManager() + .replaceAliasesOccurrences(newValue, ClientUsersManager.FindBy.NAME_ALIAS); + getSettingsPage().setSelfName(newValue); + } + + private static final Timedelta COLOR_PICKER_STATE_CHANGE_TIMEOUT = Timedelta.ofSeconds(10); + private static final double MIN_COLOR_PICKER_SIMILARITY_SCORE = 0.999; + + /** + * Get and remember the screenshot of People Picker + */ + @When("^I remember the state of Color Picker$") + public void IRememberColorPickerState() throws Exception { + context.setColorPickerState(() -> getSettingsPage().getColorPickerStateScreenshot()); + } + + /** + * Verify that color picker state has been changed + */ + @Then("^I verify the state of Color Picker is changed$") + public void IVerifyColorPickerState() throws Exception { + assertThat("Color Picker state has not been changed", + context.getColorPickerState().isChanged(COLOR_PICKER_STATE_CHANGE_TIMEOUT, MIN_COLOR_PICKER_SIMILARITY_SCORE)); + } + + /** + * Changes the accent color by clicking the color picker + * + * @param color one of possible color values + */ + @When("^I set my accent color to (StrongBlue|StrongLimeGreen|BrightYellow|VividRed|BrightOrange|SoftPink|Violet)" + + " on Settings page$") + public void IChangeMyAccentColor(String color) { + getSettingsPage().selectAccentColor(AccentColor.getByName(color)); + } + + @Then("^I see \"(.*)\" unique username is displayed on Settings Page$") + public void ISeeUniqueUsernameOnSettingsPage(String name) { + name = context.getUsersManager() + .replaceAliasesOccurrences(name, ClientUsersManager.FindBy.UNIQUE_USERNAME_ALIAS); + assertThat(String.format("Unique username %s is not displayed on Settings Page", name), + getSettingsPage().isUniqueUsernameInSettingsDisplayed(name)); + } + + @Then("^I see unique username and domain of user (.*) is displayed on Settings Page$") + public void ISeeUniqueUsernameAndDomainOnSettingsPage(String userAlias) { + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(userAlias); + String domainName = BackendConnections.get(user).getDomain(); + String name = user.getUniqueUsername() + "@" + domainName; + assertThat(String.format("Unique username %s is not displayed on Settings Page", name), + getSettingsPage().isUniqueUsernameInSettingsDisplayed(name)); + } + + /** + * Tap X button on color picker control + */ + @When("^I close accent color picker on Settings page$") + public void ICloseColorPicker() { + getSettingsPage().closeColorPicker(); + } + + /** + * Change and commit email address + * + * @param newEmail new email address/alias + */ + @When("^I change email address to (.*) on Settings page$") + public void IChangeEmailAddress(String newEmail) { + newEmail = context.getUsersManager() + .replaceAliasesOccurrences(newEmail, ClientUsersManager.FindBy.EMAIL_ALIAS); + // TODO Continue investigating why this has to be performed two times to pass, maybe has to do with XCode 11.3? -> run regression on this branch with double clear disabled +// getSettingsPage().clearEmailAddress(); + getSettingsPage().changeEmailAddress(newEmail); + } + + /** + * Wait until the "check you email" label disappears from the UIand email address + * verification is detected by SE + */ + @When("^I wait until the UI detects successful email activation on Settings page$") + public void IWaitForActivation() { + final Duration timeout = Duration.ofSeconds(ActivationMessage.ACTIVATION_TIMEOUT.asSeconds()); + if (!getSettingsPage().waitUntilEmailVerificationHappens(timeout)) { + throw new IllegalStateException( + String.format("The UI didn't detect email activation after %s", timeout) + ); + } + } + + @When("^I toggle send read receipts on account page$") + public void iToggleSendReadReceiptsOnAccountPage() { + getSettingsPage().switchToggleReadReceipts(); + } + + @Then("^I can not change display name on Settings page$") + public void iCannotChangeDisplayName() { + assertThat("Name input IS visible", + getSettingsPage().isDisplayNameInputFieldStatic()); + } + + @Then("^I can not change unique username on Settings page$") + public void iCannotChangeUniqueUsername() { + assertThat("unique username input IS visible", + getSettingsPage().isUniqueUsernameInputFieldStatic()); + } + + @Then("^I do not see Appearance section on Settings page$") + public void iDoNotSeeAppearanceSection() { + assertThat("Appearance section IS visible", + getSettingsPage().isAppearanceSectionInvisible()); + } + + @Then("^I (do not )?see the beta toggle$") + public void iSeeTheBetaToggle(String doNot) { + if (doNot == null) { + assertThat("Beta toggle is not visible", + getSettingsPage().isBetaToggleVisible()); + } else { + assertThat("Beta toggle is visible while it should not be", + getSettingsPage().isBetaToggleInvisible()); + } + } + + @Then("^I see the beta toggle is (un)?checked$") + public void isBetaToggleChecked(String notChecked) { + if(notChecked == null) { + assertThat("Beta toggle is not checked", + getSettingsPage().isBetaToggleChecked()); + } else { + assertThat("Beta toggle is checked", + getSettingsPage().isBetaToggleUnchecked()); + } + } + + @Then("^I tap the beta toggle$") + public void iTapBetaToggle() { + getSettingsPage().tapBetaToggle(); + } + + @Then("^I see domain name of user (.*) on settings item Domain$") + public void iSeeDomainName(String userAlias) { + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(userAlias); + String domainName = BackendConnections.get(user).getDomain(); + assertThat(String.format("Domain name on settings Domain is not equal to '%s'", domainName), + getSettingsPage().isDomainNameVisible(domainName)); + } + + @Then("^I (do not )?see team name as (.*) on settings item Team$") + public void iSeeTeamName(String shouldNot, String domainName) { + if (shouldNot == null) { + assertThat(String.format("Team name on settings item Team is not equal to '%s'", domainName), + getSettingsPage().isTeamNameVisible(domainName)); + } else { + assertThat(String.format("Team name on settings item Team is visible and equal to '%s'", domainName), + getSettingsPage().isTeamNameInvisible(domainName)); + } + } + + @Then("^I see domain name of user (.*) on Username UI$") + public void iSeeDomainNameUserNameUI(String userAlias) { + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(userAlias); + String domainName = "@" + BackendConnections.get(user).getDomain(); + assertThat(String.format("Domain name on username UI is not equal to '%s'", domainName), + getSettingsPage().isDomainNameVisibleOnUsernameUI(domainName)); + } + + @Then("^I see domain name is not editable of user (.*) on Username UI$") + public void iSeeNoNEditableDomainNameUserNameUI(String userAlias){ + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(userAlias); + String domainName = "@" + BackendConnections.get(user).getDomain(); + assertThat(("Domain name on username UI is editable"), getSettingsPage().isNonEditableDomainNameFieldOnUsernameUI(domainName)); + } + + @Then("^I see domain name on settings item is not editable") + public void iSeeNoNEditableDomainNameSettingsUI(){ + assertThat(("Domain name on settings UI is editable"), getSettingsPage().isDomainNonEditableOnSettings()); + } + + @Then("^I see team name on settings item is not editable") + public void iSeeNoNEditableTeamNameSettingsUI(){ + assertThat(("Team name on settings UI is editable"), getSettingsPage().isTeamNonEditableOnSettings()); + } + + @When("I open the Advanced Settings menu") + public void iOpenTheAdvancedSettingsMenu() { + getSettingsPage().tapAdvanced(); + } + + @When("I tap on the account back button") + public void iTapAccountBackButton() { + getSettingsPage().tapAccountBackButton(); + } + + @When("I tap on the settings back button") + public void iTapSettingsBackButton() { + getSettingsPage().tapSettingsBackButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/team_creation/InvitePeopleSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/team_creation/InvitePeopleSteps.java new file mode 100644 index 00000000000..89f04abca12 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/team_creation/InvitePeopleSteps.java @@ -0,0 +1,23 @@ +package com.wearezeta.auto.ios.steps.team_creation; + +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.team_creation.InvitePeoplePage; +import io.cucumber.java.en.When; + +public class InvitePeopleSteps { + IOSTestContext context; + + public InvitePeopleSteps(IOSTestContext context) { + this.context = context; + } + + private InvitePeoplePage getInvitePeoplePage() { + return context.getPagesCollection() + .getPage(InvitePeoplePage.class); + } + + @When("^I tap Done button on Invite People page$") + public void ITapDoneButtonOptionsOnInvitePeoplePage() { + getInvitePeoplePage().tapDoneButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/team_creation/TCVerificationCodePageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/team_creation/TCVerificationCodePageSteps.java new file mode 100644 index 00000000000..ddc34477988 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/team_creation/TCVerificationCodePageSteps.java @@ -0,0 +1,44 @@ +package com.wearezeta.auto.ios.steps.team_creation; + +import com.wearezeta.auto.common.backend.BackendConnections; +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.team_creation.TCVerificationCodePage; +import io.cucumber.java.en.When; + +public class TCVerificationCodePageSteps { + IOSTestContext context; + + public TCVerificationCodePageSteps(IOSTestContext context) { + this.context = context; + } + + private TCVerificationCodePage getVerificationCodePage() { + return context.getPagesCollection().getPage(TCVerificationCodePage.class); + } + + @When("^I enter \"(.*)\" as Verification Code on Verification Code page$") + public void IEnterWrongVerificationCode(String code) { + getVerificationCodePage().enterVerificationCode(code); + } + + @When("^I type 2FA verification code (.*) into fields$") + public void IEnter2FAVerificationCodeOnVerificationCodePage(String code) { + getVerificationCodePage().enterVerificationCode(code); + } + + @When("^I tap Resend Code button on Verification Code page$") + public void ITapResendCodeOptionsButtonOnVerificationCodePage() { + getVerificationCodePage().tapResendCode(); + } + + @When("^I tap Back button on Verification Code page$") + public void ITapBackButtonOnVerificationCodePage() { + getVerificationCodePage().tapBack(); + } + + @When("^I accept Please enter a valid code alert on Verification Code page$") + public void iAcceptAlert() { + getVerificationCodePage().acceptAlert(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/webview/KeycloakWebViewPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/webview/KeycloakWebViewPageSteps.java new file mode 100644 index 00000000000..3a14c9d7b8a --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/webview/KeycloakWebViewPageSteps.java @@ -0,0 +1,74 @@ +package com.wearezeta.auto.ios.steps.webview; + +import com.wearezeta.auto.common.misc.Timedelta; +import com.wearezeta.auto.common.usrmgmt.ClientUser; +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.webview.KeycloakWebViewPage; +import com.wearezeta.auto.ios.pages.webview.OktaWebViewPage; +import io.cucumber.java.en.And; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class KeycloakWebViewPageSteps { + IOSTestContext context; + + public KeycloakWebViewPageSteps(IOSTestContext context) { + this.context = context; + } + + private KeycloakWebViewPage getPage() { + return context.getPagesCollection().getPage(KeycloakWebViewPage.class); + } + + @When("^I enter email (.*) on keycloak web view") + public void WhenIHaveEnteredUsername(String email) { + Timedelta.ofSeconds(1).sleep(); + email = context.getUsersManager() + .replaceAliasesOccurrences(email, ClientUsersManager.FindBy.EMAIL_ALIAS); + getPage().setUsername(email); + } + + @When("^I login to keycloak as \"(.*)\"") + public void WhenILoginOnKeycloak(String userAlias) { + Timedelta.ofSeconds(1).sleep(); + ClientUser user = context.getUsersManager().findUserByNameOrNameAlias(userAlias); + getPage().setUsername(user.getEmail()); + getPage().setPassword(user.getPassword()); + getPage().tapSignInButton(); + } + + @When("^I enter password (.*) on keycloak web view") + public void WhenIHaveEnteredPassword(String password) { + password = context.getUsersManager() + .replaceAliasesOccurrences(password, ClientUsersManager.FindBy.PASSWORD_ALIAS); + getPage().setPassword(password); + } + + @Then("^I click sign in button on keycloak web view") + public void iClickSignInButtonOnKeycloakWebView() { + getPage().tapSignInButton(); + } + + @Then("^I (do not )?see keycloak web view$") + public void ISeeOktaWebView(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The expected keycloak URL has not been opened in web browser", + getPage().isKeycloakWebPageVisible()); + } else { + assertThat("The expected keycloak URL is opened in web browser", + getPage().isKeycloakWebPageInvisible()); + } + } + + @Then("I see certificate error message") + public void iSeeCertificateErrorMessage() { + assertThat("The expected certificate error is not visible", getPage().isCertificateErrorVisible()); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/webview/OktaWebViewPageSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/webview/OktaWebViewPageSteps.java new file mode 100644 index 00000000000..86c0ce960c3 --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/webview/OktaWebViewPageSteps.java @@ -0,0 +1,51 @@ +package com.wearezeta.auto.ios.steps.webview; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.webview.OktaWebViewPage; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; + +import static org.hamcrest.MatcherAssert.assertThat; + +public class OktaWebViewPageSteps { + IOSTestContext context; + + public OktaWebViewPageSteps(IOSTestContext context) { + this.context = context; + } + + private OktaWebViewPage getPage() { + return context.getPagesCollection().getPage(OktaWebViewPage.class); + } + + @Then("^I (do not )?see okta web view$") + public void ISeeOktaWebView(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("The expected okta URL has not been opened in web browser", + getPage().isOktaWebPageVisible()); + } else { + assertThat("The expected okta URL is opened in web browser", + getPage().isOktaWebPageInvisible()); + } + } + + @When("^I enter user name (.*) on okta web view") + public void WhenIHaveEnteredUsername(String username) { + username = context.getUsersManager() + .replaceAliasesOccurrences(username, ClientUsersManager.FindBy.EMAIL_ALIAS); + getPage().setUsername(username); + } + + @When("^I enter password (.*) on okta web view") + public void WhenIHaveEnteredPassword(String password) { + password = context.getUsersManager() + .replaceAliasesOccurrences(password, ClientUsersManager.FindBy.PASSWORD_ALIAS); + getPage().setPassword(password); + } + + @Then("^I click sign in button on okta web view") + public void iClickSignInButtonOnOktaWebView() { + getPage().tapSignInButton(); + } +} diff --git a/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/webview/WebViewSteps.java b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/webview/WebViewSteps.java new file mode 100644 index 00000000000..80d7a6e4d8f --- /dev/null +++ b/wire-ios-automation/ios/src/test/java/com/wearezeta/auto/ios/steps/webview/WebViewSteps.java @@ -0,0 +1,174 @@ +package com.wearezeta.auto.ios.steps.webview; + +import com.wearezeta.auto.common.usrmgmt.ClientUsersManager; +import com.wearezeta.auto.ios.common.IOSTestContext; +import com.wearezeta.auto.ios.pages.webview.WebViewPage; +import io.cucumber.java.en.And; +import io.cucumber.java.en.Then; +import io.cucumber.java.en.When; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; + +public class WebViewSteps { + IOSTestContext context; + + public WebViewSteps(IOSTestContext context) { + this.context = context; + } + + private WebViewPage getPage() { + return context.getPagesCollection().getPage(WebViewPage.class); + } + + @When("^I close the web view$") + public void iCloseWebView() { + getPage().closeWebView(); + } + + @Then("^I see \"(.*)\" web page opened$") + public void ISeeWebPage(String expectedUrl) { + assertThat(String.format("The expected URL '%s' has not been opened in web browser", expectedUrl), + getPage().isWebPageVisible(expectedUrl)); + } + + @Then("^I see \"(.*)\" in url bar of web page opened$") + public void iSeeWebPageUrl(String expectedUrl) { + assertThat("Wrong URL", getPage().getUrlFromUrlBar(), containsString(expectedUrl)); + } + + @Then("^I do not see \"(.*)\" in url bar of web page opened$") + public void iDoNotSeeWebPageUrl(String expectedUrl) { + assertThat("Wrong URL", getPage().getUrlFromUrlBar(), not(containsString(expectedUrl))); + } + + @Then("I see text element \"(.*)\" on web page") + public void ISeeTextElement(String expectedText) { + assertThat(String.format("Expected text element '%s' was not found on web page", expectedText), + getPage().isTextVisible(expectedText)); + } + + @Then("^I see \"Change Password\" web page$") + public void ISeeChangePasswordPage() { + assertThat("Change Password button is not shown", getPage().isChangePasswordPageVisible()); + } + + @When("^I tap Share button in Safari$") + public void iTapShareInSafari() { + getPage().tapShareButtonSafari(); + } + + @When("^I tap Join in the app button in Safari$") + public void iTapJoinInTheAppButton() { + getPage().tapJoinInTheAppButton(); + } + + @When("^I tap More button on share extension$") + public void iTapMoreButtonShareExt() { + getPage().tapMoreButonShareExt(); + } + + @When("^I enable Wire in share extension$") + public void iEnableWireShareExt() { + getPage().enableWireShareExt(); + } + + @When("^I tap Done in share extension$") + public void iTapDoneInShareExt() { + getPage().tapDoneOnShareExt(); + } + + @When("^I tap Wire in share extension$") + public void iTapWireInShareExt() { + getPage().tapWireInShareExt(); + } + + @When("^I tap Wire Column in share extension$") + public void iTapWireColumnInShareExt() { + getPage().tapWireColumnInShareExt(); + } + + @When("^I tap Choose in share extension$") + public void iTapChooseInShareExt() { + getPage().tapChooseInShareExt(); + } + + @When("^I select conversation \"(.*)\" in share extension$") + public void iTapChooseInShareExt(String conversationName) { + conversationName = context.getUsersManager() + .replaceAliasesOccurrences(conversationName, ClientUsersManager.FindBy.NAME_ALIAS); + getPage().selectConversationInShareExt(conversationName); + } + + @When("^I tap Send button in share extension$") + public void iTapSendInShareExt() { + getPage().tapSendButtonShareExt(); + } + + @When("I enter passcode (.*) on unlock screen in share extension$") + public void iEnterPassCode(String passcode) { + getPage().inputPasscode(passcode); + } + + @And("^I see Unlock wire in share extension$") + public void iSeeOverlay() { + assertThat("unlock wire in share extension is not visible", getPage().isUnlockWireInShareExtensionVisble()); + } + + @When("^I press unlock on share extension screen$") + public void iPressUnlock() { + getPage().tapUnlockButtonOnShareExtension(); + } + + @When("^I tap Done Button on web view$") + public void iTapDoneOnWebView() { + getPage().iTapDoneOnWebView(); + } + + @When("^I tap URL on safari view$") + public void iTapURLSafari() { + getPage().tapURLLinkSafari(); + } + + @When("^I tap URL on safari view on real device$") + public void iTapURLSafariRealDevice() { + getPage().tapURLLinkSafariRealDevice(); + } + + @When("^I tap paste on safari view$") + public void iTapPasteURLSafari() { + getPage().tapPasteURLLinkSafari(); + } + + @Then("^I (do not )?see open in iOS app on wire web view$") + public void iSeeOpenApp(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Open iOS App button is not visible", getPage().goToiOSAppOnWebViewIsVisible()); + } else { + assertThat("Open iOS App button is visible", getPage().goToiOSAppOnWebViewIsInvisible()); + } + } + + @When("^I tap Open in iOS App on wire web view$") + public void iTapOpenApp() { + getPage().tapOnGoToiOSAppOnWireWebView(); + } + + @Then("^I (do not )?see download app on wire web view$") + public void iSeeDownloadApp(String shouldNotSee) { + if (shouldNotSee == null) { + assertThat("Download iOS App button is not visible", getPage().downloadAppOnWebViewVisible()); + } else { + assertThat("Download iOS App button is visible", getPage().downloadAppOnWebViewInvisible()); + } + } + + @When("^I tap Download App on wire web view$") + public void iTapDownloadApp() { + getPage().tapOnDownloadAppOnWebView(); + } + @When("^I tap Open in Safari Button on web view$") + public void iTapOpenInSafari() { + getPage().iTapOpenInSafarOnWebView(); + } +} diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/2FactorAuthentication.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/2FactorAuthentication.feature new file mode 100644 index 00000000000..a8ea7422f62 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/2FactorAuthentication.feature @@ -0,0 +1,72 @@ +Feature: 2 Factor Authentication + + @C1137641 @C1137645 @C1137643 @C1137646 @regression @rc @2FA @useSpecialEmail + Scenario Outline: I want to verify that verification code is required after login if 2F authentication is enabled for the team + Given There is a team owner "" with team "" + And User adds users to team with role Member + And Admin user unlocks 2F Authentication for team + And Admin user enables 2 Factor Authentication for team + And User has 1:1 conversation with in team + And I tap Login button on Welcome page +# C1137643 I want to see info that verification code has been sent + When I sign in user with email + Then I see email verification reminder +# C1137645 I should not be able to proceed with invalid code + When I enter "000000" as Verification Code on Verification Code page + Then I do not see First Time overlay +# C1137646 I want to see error message if invalid code was entered + And I see alert contains text "Please enter a valid verification code" +# C1137644 I want to receive new verification code email after clicking 'Resend code' button + When I accept Please enter a valid code alert on Verification Code page + And I tap Resend Code button on Verification Code page + And I wait until 3 mails arrived for + And I start verification email monitoring on mailbox + And I enter verification code from Email + Then I see First Time overlay + When I accept First Time overlay + Then I am signed in properly + + Examples: + | TeamOwner | TeamName | Member1 | Message | Email | + | user1Name | AwesomeTeam | user2Name | Hi there | user1Email | + + @C1137642 @C1137647 @C1137648 @regression @rc @2FA @useSpecialEmail + Scenario Outline: I want to receive verification code via email after logging in + Given There is a team owner "" with team "" + And User adds users to team with role Member + And I start verification email monitoring on mailbox + And Admin user unlocks 2F Authentication for team + And Admin user enables 2 Factor Authentication for team + And User has 1:1 conversation with in team + And I tap Login button on Welcome page + And I sign in user with email + And I see email verification reminder +# C1137647 I want to see verification code dialog/page disappearing after I enter valid code + When I enter verification code from Email + Then I see First Time overlay +# C1137648 I want to verify that verification code is not required after login if 2F authentication has been disabled + When I accept First Time overlay + And I am signed in properly + And Admin user disables 2 Factor Authentication for team + And I open settings screen + And I select settings item Account + And I select settings item Log Out + And I type "" text into the alert input field + And I accept alert + And I tap Login button on Welcome page + And I sign in user with email + And I accept First Time overlay + Then I see conversations list + When Admin user enables 2 Factor Authentication for team + And I open settings screen + And I select settings item Account + And I select settings item Log Out + And I type "" text into the alert input field + And I accept alert + And I tap Login button on Welcome page + And I sign in user with email + Then I see email verification reminder + + Examples: + | TeamOwner | TeamName | Member1 | Message | Email | Password | + | user1Name | AwesomeTeam | user2Name | Hi there | user1Email | user1Password | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Blacklist.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Blacklist.feature new file mode 100644 index 00000000000..34bb279584d --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Blacklist.feature @@ -0,0 +1,27 @@ +Feature: Blacklist + + @TC-5424 @rc @blacklist + Scenario Outline: I want to exclude params works in blacklist + Given There is 1 user where user1Name is me + When I open a backend which has my build blacklisted via deep link in safari + And I tap Proceed button on backend redirection page + And I tap Login button on Welcome page + And I login as user1Email + Then I see alert contains text "" + + Examples: + | AlertText | + | Update necessary | + + @TC-5425 @rc @blacklist + Scenario Outline: I want to verify min params works in blacklist + Given There is 1 user where user1Name is me + When I open a backend which has a higher minimum version via deep link in safari + And I tap Proceed button on backend redirection page + And I tap Login button on Welcome page + And I login as user1Email + Then I see alert contains text "" + + Examples: + | AlertText | + | Update necessary | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Calling.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Calling.feature new file mode 100644 index 00000000000..76b1165f81f --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Calling.feature @@ -0,0 +1,25 @@ +Feature: Calling + + @TC-5253 @calling + Scenario Outline: I want to accept a call through the incoming voice dialogue (Button) + Given I allow microphone access + And SFT calling is enabled for backend + And There are 2 users where is me + And User Myself is connected to + And User sets the unique username + And starts instance using + And I sign in user with fast login + And I accept alert if visible + And I am signed in properly + And I open conversation "" in conversation list + When calls me + And I see call status message contains "" + And I tap Accept button on Calling overlay + And I accept microphone access alert on real device + Then verifies that call status to me is changed to active in seconds + And User verifies to have 1 peer connection + And User verifies to send and receive audio + + Examples: + | Name | Contact | CallBackend | Timeout | + | user1Name | user2Name | chrome | 30 | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/2FA.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/2FA.feature new file mode 100644 index 00000000000..36512f98e75 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/2FA.feature @@ -0,0 +1,98 @@ +Feature: 2FA + + @TC-4883 @col1 @SF.Channel @TSFI.UserInterface @S0.1 @S2 @Security + Scenario Outline: I should not be able to login with invalid 2FA verification code if 2FA is enabled on backend + Given There is a team owner "" with team "" + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""},{}]} + And User is me + And All other versions of Wire are uninstalled + And I enroll the simulator for Touch ID + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + When I type 2FA verification code 123456 into fields + Then I do not see First Time overlay + And I see alert contains text "Please enter a valid code" + When I accept alert + And I type 2FA verification code 654321 into fields + Then I do not see First Time overlay + And I see alert contains text "Please enter a valid code" + When I accept alert + And I enter "000000" as Verification Code on Verification Code page + Then I do not see First Time overlay + And I see alert contains text "Please enter a valid code" + + Examples: + | TeamOwner | TeamName | Email | Password | + | user1Name | AwesomeTeam | user1Email | user1Password | + + @TC-4886 @col1 + Scenario Outline: I want to use back button on verification code page + Given There is a team owner "" with team "" + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""},{}]} + And User is me + And All other versions of Wire are uninstalled + And I enroll the simulator for Touch ID + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + When I tap Back button on Verification Code page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I enter verification code from Email + And I tap Not Now on save password alert + Then I see First Time overlay + + Examples: + | TeamOwner | TeamName | Email | Password | + | user1Name | AwesomeTeam | user1Email | user1Password | + + @TC-4885 @col1 @SF.Channel @TSFI.UserInterface @S0.1 @S2 @Security + Scenario Outline: I want to verify that verification code is required on login only if 2FA is enabled on backend + Given There is a team owner "" with team "" + And All other versions of Wire are uninstalled + And I enroll the simulator for Touch ID + When I login to the default email verified backend as + Then I am signed in properly + + Examples: + | TeamOwner | TeamName | + | user1Name | AwesomeTeam | + + @TC-4884 @col1 + Scenario Outline: I want to receive new verification code email after clicking 'Resend code' button + Given There is a team owner "" with team "" + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""},{}]} + And User is me + And All other versions of Wire are uninstalled + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I tap Resend Code button on Verification Code page + And I wait until 2 mails arrived for + And I start verification email monitoring on mailbox + And I enter verification code from Email + Then I see First Time overlay + When I accept First Time overlay + Then I am signed in properly + + Examples: + | TeamOwner | TeamName | Email | Password | + | user1Name | AwesomeTeam | user1Email | user1Password | \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/CBR.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/CBR.feature new file mode 100644 index 00000000000..0075c437397 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/CBR.feature @@ -0,0 +1,101 @@ +Feature: CBR + + @TC-4890 @col1 @cbr @SF.Calls @TSFI.RESTfulAPI @TSFI.Callkit @S0.4 @S3 @S4 @S5 @Security + Scenario Outline: I want to verify that CBR traffic after calling 1:1 audio and video call using chrome + Given I allow microphone access + And I allow camera access + And There is a team owner "" with team "" + And User adds user to team with role Member + And User has 1:1 conversation with in team + And User is me + And User sets the unique username + And starts 2FA instance using + And accepts next incoming call automatically + And I enroll the simulator for Touch ID + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + When I tap Audio Call button + And verifies that waiting instance status is changed to active in 20 seconds + And User verifies to have CBR connection + And I wait for 7 seconds + Then I see call indicator CONSTANT BIT RATE + When User switches video on + And User verifies to have CBR connection + And I wait for 5 seconds + And I tap on screen to enable video calling overlay + Then I see label call indicator CONSTANT BIT RATE + And I see call indicator CONSTANT BIT RATE + + Examples: + | TeamOwner | Member1 | CallBackend | TeamName | + | user1Name | user2Name | chrome | WeLikeCalling | + + @TC-4888 @col1 @cbr @SF.Calls @TSFI.RESTfulAPI @TSFI.Callkit @S0.4 @S3 @S4 @S5 @Security + Scenario Outline: I want to verify that CBR traffic after receiving 1:1 audio and video call using chrome + Given I allow microphone access + And I allow camera access + And There is a team owner "" with team "" + And User adds user to team with role Member + And User has 1:1 conversation with in team + And User is me + And User sets the unique username + And I enroll the simulator for Touch ID + When I login to the default email verified backend as + Then I am signed in properly + And I open conversation "" in conversation list + And starts 2FA instance using + When calls me + And I tap Accept button on Calling overlay + And verifies that call status to me is changed to active in 20 seconds + And User verifies to have 1 peer connection + And User verifies to have CBR connection + And I wait for 7 seconds + Then I see call indicator CONSTANT BIT RATE + When User switches video on + And User verifies to have CBR connection + And I wait for 5 seconds + Then I see label call indicator CONSTANT BIT RATE + And I see call indicator CONSTANT BIT RATE + + Examples: + | TeamOwner | Member1 | CallBackend | TeamName | + | user1Name | user2Name | chrome | Top Secret | + + @TC-4887 @TC-4891 @col1 @cbr @SF.Calls @TSFI.RESTfulAPI @TSFI.Callkit @S0.4 @S3 @S4 @S5 @Security + Scenario Outline: I want to verify that CBR traffic after receiving 1:1 audio and video call using zcall + Given I allow microphone access + And I allow camera access + And There is a team owner "" with team "" + And User adds user to team with role Member + And User is me + And User sets the unique username + And User Myself has 1:1 conversation with in team + And starts 2FA instance using + And I enroll the simulator for Touch ID + When I login to the default email verified backend as + Then I am signed in properly + When I open Self profile + And I open settings screen + And I select settings item Options + Then I do not see settings item Variable Bit Rate Encoding + When I tap X navigation button on Settings page + And I open conversation "" in conversation list + And calls me + And I tap Accept button on Calling overlay + And verifies that call status to me is changed to active in 20 seconds + And User verifies to have 1 peer connection + And User verifies to send and receive audio + And User verifies to have CBR connection + And I wait for 7 seconds + Then I see call indicator CONSTANT BIT RATE + When User switches video on + And User verifies to have CBR connection + And I wait for 7 seconds + And I tap on screen to enable video calling overlay + Then I see label call indicator CONSTANT BIT RATE + And I see call indicator CONSTANT BIT RATE + + Examples: + | TeamOwner | Member1 | CallBackend | TeamName | + | user1Name | user2Name | zcall_v3 | Do You Read me | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Col3Tests.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Col3Tests.feature new file mode 100644 index 00000000000..eca930d2b61 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Col3Tests.feature @@ -0,0 +1,616 @@ +Feature: Col 3 Tests + + @TC-6126 @col3 + Scenario Outline: I should be able to copy/paste messages when clipboard is enabled + Given There is a team owner "" with team "" + And User adds user to team with role Member + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}]} + And User is me + And User Myself has 1:1 conversation with in team +# And I tap Login button on Welcome page + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I do not see Encryption At Rest overlay + And I am signed in properly + And I open conversation "" in conversation list + And User sends 1 "" message to conversation Myself + And I see last message in the conversation view is expected message + When I long tap "" message in conversation view + Then I see Copy on edit menu + And I see Share on edit menu + + Examples: + | Member1 | TeamOwner | TeamName | DeviceMember1 | Message1 | Email | Password | DeviceTeamOwner | + | user1Name | user2Name | BestTeam | device1 | Hello! | user1Email | user1Password | DeviceA | + + @TC-7638 @TC-7639 @col3 + Scenario Outline: I want to see the VBR toggle is available in settings + Given There is a team owner "" with team "" + And User adds user to team with role Member + And User is me + And User sets the unique username + And User Myself has 1:1 conversation with in team + And starts 2FA instance using + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I do not see Encryption At Rest overlay + And I am signed in properly + When I open Self profile + And I open settings screen + And I select settings item Options + Then I see the text on VBR toggle in settings + And I verify the value of settings item Variable Bit Rate Encoding equals to "" + # I should not be able to see CBR label in calling if my VBR toggle is ON - TC-7639 + When I tap X navigation button on Settings page + And I open conversation "" in conversation list + And calls me + And I tap Accept button on Calling overlay + And I accept alert if visible + And verifies that call status to me is changed to active in 20 seconds + And User verifies to have 1 peer connection + And User verifies to have CBR connection + And I wait for 7 seconds + Then I do not see call indicator VARIABLE BIT RATE + When User switches video on + And User verifies to have CBR connection + And I wait for 7 seconds + Then I do not see call indicator VARIABLE BIT RATE + + Examples: + | TeamOwner | Member1 | CallBackend | Email | Password | TeamName | TeamOwnerEmail | ExpectedValue | + | user1Name | user2Name | zcall_v3 | user1Email | user1Password | Do You Read me | user1Email | 1 | + + @TC-7640 @col3 @cbr + Scenario Outline: I want to verify that there is no CBR traffic after receiving 1:1 audio and video call using + Given I allow microphone access + And I allow camera access + And There is a team owner "" with team "" + And User adds user to team with role Member + And User has 1:1 conversation with in team + And User is me + And User sets the unique username + And starts 2FA instance using + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I accept First Time overlay + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I do not see Encryption At Rest overlay + And I am signed in properly + And I open conversation "" in conversation list + When calls me + And I tap Accept button on Calling overlay + And verifies that call status to me is changed to active in 20 seconds + And User verifies to have CBR connection + Then I do not see call indicator CONSTANT BIT RATE + When User switches video on + And User verifies to have CBR connection + Then I do not see call indicator CONSTANT BIT RATE + + Examples: + | TeamOwner | Member1 | CallBackend | Email | Password | TeamName | TeamOwnerEmail | + | user1Name | user2Name | chrome | user1Email | user1Password | Top Secret | user1Email | + + @TC-7637 @col3 + Scenario Outline: I want to verify opening gallery tapping on gallery icon in col3 builds + Given I allow access to all photos + And I allow camera access + And There is a team owner "" with team "" + And User adds users to team with role Member + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}]} + And User is me + And User Myself has 1:1 conversation with in team + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I accept First Time overlay + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I do not see Encryption At Rest overlay + And I am signed in properly + And I open conversation "" in conversation list + When I tap Add Picture button from input tools + And I accept camera access alert on real device + And I accept access to all photos on real device + And I tap Camera Roll button on Keyboard Gallery overlay + And I select a picture from Camera Roll + And I tap Confirm button on Picture preview page + Then I see 1 photo in the conversation view + When I type the default message and send it + And I wait for 2 seconds + Then I see 1 default message in the conversation view + + Examples: + | TeamOwner | Member1 | TeamName | Email | Password | DeviceName | + | user1Name | user2Name | Zikzak | user2Email | user2Password | device1 | + + @TC-7636 @col3 + Scenario Outline: I want to verify that file sharing is enabled in col 3 builds + Given There is a team owner "" with team "" + And User adds users to team with role Member + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}]} + And User is me + And All other versions of Wire are uninstalled + And User Myself has 1:1 conversation with in team + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I accept First Time overlay + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I do not see Encryption At Rest overlay + And I am signed in properly + When I open conversation "" in conversation list + Then I see Add Picture button in input tools palette + And I see Sketch button in input tools palette + And I see Giphy button in input tools palette + And I see Audio Message button in input tools palette + And I see File Transfer button in input tools palette + And I see Video Message button in input tools palette + When User sends 1 image file testing.jpg to conversation + Then I see 1 photo in the conversation view + When I long tap on image in conversation view + Then I see Save on edit menu + And I see Share on edit menu + And I see Copy on edit menu + And I see Reply on edit menu + And I see menu with quick reactions and other items + And I see Delete on edit menu + When I tap on ❤️ reaction in quick reactions + And I wait for 2 seconds + Then I see 1 photo in the conversation view + # Enable share extension + When I navigate back to conversations list + And I open Safari with url "" + And I tap Share button in Safari + #And I tap More button on share extension + #And I enable Wire in share extension + #And I tap Done in share extension + And I tap Wire Column in share extension + And I wait for 3 seconds + And I tap Choose in share extension + And I wait for 3 seconds + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I select conversation "" in share extension + And I tap Send button in share extension + Then I do not see alert contains text "File sharing restrictions" + When I restore Wire + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I open conversation "" in conversation list + And I wait for 2 seconds + Then I see last message in the conversation view contains expected message + + Examples: + | Member1 | TeamOwner | TeamName | DeviceName | Email | Password | URL | Text | + | user1Name | user2Name | File sharing | device1 | user1Email | user1Password | https://www.duckduckgo.com | duckduckgo | + + @TC-8187 @TC-8188 @col3 + Scenario Outline: I should not see classified but unclassified banner in group conversation when self user is on unclassified domain and all participants are on classified domain + Given There is a team owner "" with team "" on column-3 backend + And User adds users , to team with role Member + And There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And User is me + And User is connected to ,, + And User has conversation with ,, in team + And User has conversation with , in team + And All other versions of Wire are uninstalled + And I open default backend via deep link in safari + And I wait for 3 seconds + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I perform successful Touch ID + And I accept First Time overlay + And I am signed in properly + When I open conversation "" in conversation list + Then I see unclassified domain label in the conversation + And I do not see classified domain label in the conversation + # C1266662 -- I should not see classified but unclassified banner in group conversation when on unclassified domain and participants are only from same unclassified domain + When I navigate back to conversations list + And I open conversation "" in conversation list + Then I see unclassified domain label in the conversation + And I do not see classified domain label in the conversation + + Examples: + | TeamOwner | Member1 | Member2 | TeamName | TeamName2 | TeamOwner2 | Member3 | Member4 | GroupConversationWithClassified | GroupConversationWithUnclassifiedOwnDomain | Email | Password | + | user1Name | user2Name | user3Name | The Unclassified Domain | The Classified Domain | user4Name | user5Name | user6Name | UnClassifiedConvoWithClassified | UnclassifiedConvoOnOwnDomain | user1Email | user1Password | + + @TC-8189 @col3 + Scenario Outline: I should not see classified but unclassified banner in 1:1 conversation with user from classified domain when on unclassified domain + Given There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-1 backend + And User is connected to + And User is me + And All other versions of Wire are uninstalled + And I open column-3 backend deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I perform successful Touch ID + And I accept First Time overlay + And I am signed in properly + When I open conversation "" in conversation list + Then I see unclassified domain label in the conversation + And I do not see classified domain label in the conversation + + Examples: + | TeamOwner | TeamName2 | TeamName | TeamOwner2 | Email | Password | + | user1Name | The Classified Domain | The UnClassified Domain | user2Name | user1Email | user1Password | + + @TC-8190 @col3 + Scenario Outline: I should not see classified but unclassified banner in group call when self user is on unclassified domain and all participants are on classified domain + Given There is a team owner "" with team "" on column-3 backend + And User adds users , to team with role Member + And There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And User is me + And User is connected to ,, + And User has conversation with ,,,, in team + And , starts 2FA instance using + And , accepts next incoming call automatically + And All other versions of Wire are uninstalled + And I open column-3 backend deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I perform successful Touch ID + And I accept First Time overlay + And I am signed in properly + And I open conversation "" in conversation list + And I tap Audio Message button from input tools + And I accept alert + And I tap Audio Call button + And I accept alert + And , verify that waiting instance status is changed to active in 30 seconds + When I see Calling overlay + Then I see SECURITY LEVEL: UNCLASSIFIED label on calling overlay + And I do not see SECURITY LEVEL: VS-NfD label on calling overlay + + Examples: + | TeamOwner | Member1 | Member2 | TeamName | TeamName2 | TeamOwner2 | Member3 | Member4 | GroupConversationWithClassified | CallBackend | Email | Password | + | user1Name | user2Name | user3Name | The Unclassified Domain | The Classified Domain | user4Name | user5Name | user6Name | UnClassifiedConvoWithClassified | chrome | user1Email | user1Password | + + @TC-8191 @col3 + Scenario Outline: I should not see classified but unclassified banner in group call when on unclassified domain and participants are only from same unclassified domain + Given There is a team owner "" with team "" on column-3 backend + And User adds users , to team with role Member + And User is me + And User has conversation with , in team + And , starts 2FA instance using + And , accepts next incoming call automatically + And All other versions of Wire are uninstalled + And I open column-3 backend deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I perform successful Touch ID + And I accept First Time overlay + And I am signed in properly + And I open conversation "" in conversation list + And I tap Audio Message button from input tools + And I accept alert + And I tap Audio Call button + And , verify that waiting instance status is changed to active in 30 seconds + When I see Calling overlay + Then I see SECURITY LEVEL: UNCLASSIFIED label on calling overlay + And I do not see SECURITY LEVEL: VS-NfD label on calling overlay + + Examples: + | TeamOwner | Member1 | Member2 | TeamName | TeamName2 | TeamOwner2 | Member3 | Member4 | GroupConversationWithOwnUnclassifiedOwnDomain | CallBackend | Email | Password | + | user1Name | user2Name | user3Name | The Unclassified Domain | The Classified Domain | user4Name | user5Name | user6Name | UnclassifiedConvoOnOwnDomain | chrome | user1Email | user1Password | + + @C1266658 @col3 + Scenario Outline: I want to see the unclassified banner in unclassified 1:1 conversation on same domain + Given There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-3 backend + And User is connected to + And User is me + And All other versions of Wire are uninstalled + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I do not see Encryption At Rest overlay + And I am signed in properly + When I open conversation "" in conversation list + Then I see unclassified domain label in the conversation + And I do not see classified domain label in the conversation + + Examples: + | TeamOwner | TeamName2 | TeamName | TeamOwner2 | Email | Password | + | user1Name | The Unclassified Domain | The Unclassified Domain | user2Name | user1Email | user1Password | + + @C1266657 @col3 + Scenario Outline: I want to see the unclassified banner in outgoing connection request from same unclassified domain + Given There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-3 backend + And User is me + And I enable Federation + And All other versions of Wire are uninstalled + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I do not see Encryption At Rest overlay + And I am signed in properly + When I open search screen + And I type "" in cleared Search UI input field + When I tap on conversation in search result + And I wait for 3 seconds + And I tap Connect button on Single user Pending outgoing connection page + Then I see unclassified domain icon on the outgoing connection page + + Examples: + | TeamOwner | TeamOwner2 | TeamName | Email | Password | TeamOwner2Uniqueusername | + | user1Name | user2Name | Test | user1Email | user1Password | user2UniqueUsername | + + @C1266660 @col3 + Scenario Outline: I want to see the unclassified banner in unclassified outgoing group call on same domain + Given There is a team owner "" with team "" on column-3 backend + And User adds users to team with role Member + And There is a team owner "" with team "" on column-3 backend + And User adds users to team with role Member + And User is me + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I do not see Encryption At Rest overlay + And I am signed in properly + And User is connected to , + And User has conversation with ,, in team + And I open conversation "" in conversation list + And ,, starts 2FA instance using + And ,, accepts next incoming call automatically + When I tap Audio Call button + And I accept alert if visible + And I accept alert if visible + Then I see SECURITY LEVEL: UNCLASSIFIED label on calling overlay + When ,, verifies that waiting instance status is changed to active in 60 seconds + And I wait for 10 seconds + Then I see profile picture avatar for users ,, on calling overlay + When I wait for 3 seconds + Then I see SECURITY LEVEL: UNCLASSIFIED label on calling overlay + + Examples: + | TeamOwner | Member1 | Member2 | TeamName | TeamOwner1 | GroupChat | CallBackend | Email | Password | + | user1Name | user2Name | user3Name | Stinky Pinky | user4Name | FederatedGroup | chrome | user1Email | user1Password | + + @C1266708 @col3 + Scenario Outline: I want to see the unclassified banner in unclassified incoming group call on same domain + Given There is a team owner "" with team "" on column-3 backend + And User adds users to team with role Member + And There is a team owner "" with team "" on column-3 backend + And User adds users to team with role Member + And User is me + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I do not see Encryption At Rest overlay + And I am signed in properly + And User is connected to , + And User has conversation with ,, in team + And I open conversation "" in conversation list + And ,, starts 2FA instance using + And , accepts next incoming call automatically + When calls + Then I see Calling overlay + And I see SECURITY LEVEL: UNCLASSIFIED label on calling overlay + When I tap Accept button on Calling overlay + And I accept alert if visible + And verifies that call status to is changed to active in 10 seconds + And , verifies that waiting instance status is changed to active in 60 seconds + And User ,, verifies to have 1 peer connection + Then I see profile picture avatar for users ,, on calling overlay + Then I see SECURITY LEVEL: UNCLASSIFIED label on calling overlay + + Examples: + | TeamOwner | Member1 | Member2 | TeamName | TeamOwner1 | GroupChat | CallBackend | Email | Password | + | user1Name | user2Name | user3Name | Stinky Pinky | user4Name | FederatedGroup | chrome | user1Email | user1Password | + + @C1266659 @col3 + Scenario Outline: I want to see the unclassified banner in unclassified incoming/ongoing/outgoing 1:1 call on same domain + Given There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-3 backend + And User is me + And User is connected to + And User has 1:1 conversation with in team + And All other versions of Wire are uninstalled + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I accept First Time overlay + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I do not see Encryption At Rest overlay + And I am signed in properly + And I open conversation "" in conversation list + And starts 2FA instance using + And accepts next incoming call automatically + When I tap Audio Call button + And I accept alert if visible + Then I see SECURITY LEVEL: UNCLASSIFIED label on calling overlay + When verifies that waiting instance status is changed to active in 20 seconds + And User verifies to send and receive audio + Then I see Calling overlay + And I see SECURITY LEVEL: UNCLASSIFIED label on calling overlay + When I tap Leave button on Calling overlay + And I wait for 3 seconds + And calls me + Then I see SECURITY LEVEL: UNCLASSIFIED label on calling overlay + And I tap Accept button on Calling overlay + And I accept alert if visible + Then I see SECURITY LEVEL: UNCLASSIFIED label on calling overlay + + Examples: + | TeamOwner | Member1 | TeamName | CallBackend | Email | Password | TeamOwner2 | + | user1Name | user2Name | Block | chrome | user1Email | user1Password | user3Name | + + @C1294772 @connect + Scenario Outline: I want to be connected to a user when both users have connection requests out to each other on col 3 + Given There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-3 backend + And User is me + And I enable Federation + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I accept alert if visible + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + And I wait for 3 seconds + And I see Encryption At Rest overlay + And I type bla on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I do not see Encryption At Rest overlay + And I am signed in properly + When I open search screen + And I type "" in cleared Search UI input field + When I tap on conversation in search result + And User sent connection request to Me + And I tap Connect button on Single user Pending outgoing connection page + And I tap X button on Single user Pending outgoing connection page + And I tap on conversation in search result + Then I see the input field + + Examples: + | TeamOwner | TeamOwner2 | TeamName | Email | Password | TeamOwner2Uniqueusername | + | user1Name | user2Name | Test | user1Email | user1Password | user2UniqueUsername | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/E2EE/E2EEDeviceManagement.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/E2EE/E2EEDeviceManagement.feature new file mode 100644 index 00000000000..4697c75987f --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/E2EE/E2EEDeviceManagement.feature @@ -0,0 +1,23 @@ +Feature: E2EE Device Management + + @TC-5883 @unstable @col1 @SF.Provisioning @TSFI.RESTfulAPI @S0.1 @S2 @Security + Scenario Outline: I should not be able to remove device with wrong password + Given There is a team owner "" with team "" on column-1 backend + And User is me + And I login to the default email verified backend as + And I perform successful Touch ID + Then I see conversations list + And Users of team owned by adds the following 2FA devices: {"Myself": [{"name": "", "label": ""}]} + And I accept alert if visible + And I open settings screen + And I select settings item Devices + And I tap Edit navigation button on Settings page + When I tap Delete button on Settings page + And I confirm with my WrongPassword the deletion of the device on Settings page + Then I see wrong password dialog + When I tap Ok Button on wrong password dialog + Then I see device in devices list on Settings page + + Examples: + | Name | DeviceName | + | user1Name | Device1 | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/E2EE/E2EEVerification.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/E2EE/E2EEVerification.feature new file mode 100644 index 00000000000..4e53605b2bd --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/E2EE/E2EEVerification.feature @@ -0,0 +1,44 @@ +Feature: E2EE Verification + + @TC-5908 @unstable @col1 @SF.Messages @TSFI.UserInterface @S0.1 @Security @WPB9932 + Scenario Outline: I want to see conversation degrades with warning when sending files to unverified devices + Given There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And User is me + And User has conversation with , in team + And Users of team owned by adds the following 2FA devices: {"": [{}], "": [{}]} + When I login to the default email verified backend as + Then I am signed in properly + When User sends 1 default message to conversation + And User sends 1 default message to conversation + And I open group conversation "" in conversation list + And I open conversation details + And I select participant on Group Details page + And I switch to Devices tab on Group participant profile page + And I open details page of device number 1 on Devices tab + And I tap Verify switcher on Device Details page + And I tap Back button on Device Details page + And I tap Back button on Group participant profile page + And I select participant on Group Details page + And I switch to Devices tab on Group participant profile page + And I open details page of device number 1 on Devices tab + And I tap Verify switcher on Device Details page + And I tap Back button on Device Details page + And I tap Back button on Group participant profile page + And I tap X button on Group Details page + And I see 2 default messages in the conversation view + When Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": ""}]} + # Wait for sync + And I wait for 5 seconds + And I tap on text input + And I type the default message and send it + # Wait for the placeholder + Then I see alert contains text "started using a new device" + And I see alert contains text "Do you still want to send your message?" + When I tap cancel button on degradation alert + # When @WPB9932 is fixed, the button might be different + Then I see "Retry" button on the message toolbox in conversation view + + Examples: + | TeamOwner | Member1 | DeviceName2 | DeviceLabel2 | Member2 | GroupChatName | ResendLabel | Message | TeamName | + | user1Name | user2Name | Device2 | Label2 | user3Name | ThisGroup | Resend | not a default message | The Irish | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/ExchangeMessages.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/ExchangeMessages.feature new file mode 100644 index 00000000000..014ac1c74cf --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/ExchangeMessages.feature @@ -0,0 +1,145 @@ +Feature: Exchange Messages + + @TC-4892 @TC-4896 @col1 + Scenario Outline: I want to send and receive messages in 1:1 + Given There is a team owner "" with team "" + And User adds user to team with role Member + And User has 1:1 conversation with in team + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}]} + And User is me + And User sets the unique username + When I login to the default email verified backend as + Then I am signed in properly + And I open conversation "" in conversation list + When I type the default message + And I tap on text input + And I tap Send Message button in conversation view + Then I see 1 default message in the conversation view + When User sends delivery confirmation for the recent message in Myself conversation + Then I see "" on the message toolbox in conversation view + When User sends 1 image file to conversation Myself + And I wait for 5 seconds + Then I see 1 photo in the conversation view + # TC-4896 - I want to receive messages in 1:1 when the app is in background + When I minimize Wire + And User sends 1 "" messages to conversation Myself + And I wait for 3 seconds + And I restore Wire + And I perform successful Touch ID + Then I see last message in the conversation view is expected message + + Examples: + | TeamOwner | TeamName | Member1 | DeviceName | DeliveredLabel | Picture | Message | + | user1Name |The Classified Domain | user2Name | device1 | Delivered | testing.jpg | Hi | + + @TC-4893 @TC-4897 @col1 + Scenario Outline: I want to send and receive messages in a classified group conversation + Given There is a team owner "" with team "" on column-1 backend + And User adds users to team with role Member + And There is a team owner "" with team "" on column-1 backend + And User adds users to team with role Member + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}]} + And User is me + When I login to the default email verified backend as + Then I am signed in properly + And User is connected to , + And User has conversation with ,, in team + And I open conversation "" in conversation list + And I see classified domain label in the conversation + When I type the default message + And I tap Send Message button in conversation view + Then I see 1 default messages in the conversation view + When User marks the recent message as read in conversation via device + Then I see that recent message is seen by 1 person + When User sends 1 image file to conversation + And I wait for 5 seconds + Then I see 1 photo in the conversation view + # TC-4897 - I want to receive messages in classified group conversations when the app is in background + When I minimize Wire + And User sends 1 "" messages to conversation + And I wait for 3 seconds + And I restore Wire + And I perform successful Touch ID + Then I see last message in the conversation view is expected message + + Examples: + | TeamOwner | Member1 | Member2 | TeamName | TeamOwner1 | GroupChat | DeviceName | Picture | Message | + | user1Name | user2Name | user3Name | Stinky Pinky | user4Name | FederatedGroup | device1 | testing.jpg | Hi | + + @C1288907 @C1288917 @C1288916 @unstable + Scenario Outline: I want to send and receive messages in unclassified group conversation + Given There is a team owner "" with team "" on column-1 backend + And User adds users to team with role Member + And There is a team owner "" with team "" on column-3 backend + And User adds users to team with role Member + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}]} + And User is connected to , + And User has conversation with ,, in team + And User is me + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + And I see unclassified domain label in the conversation + When I type the default message + And I tap Send Message button in conversation view + Then I see 1 default messages in the conversation view + #When User marks the recent message as read in conversation via device + #Then I see that recent message is seen by 1 person + When User sends 1 image file to conversation + And I wait for 5 seconds + Then I see 1 photo in the conversation view + # C1288916 - I want to receive messages in unclassified group conversations when the app is in background + When I minimize Wire + And User sends 1 "" messages to conversation + And I wait for 3 seconds + And I restore Wire + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + And I do not see Encryption At Rest overlay + Then I see last message in the conversation view is expected message + # C1288917 - I want to send and receive messages in 1:1 with an unclassified user + When I navigate back to conversations list + And I open conversation "" in conversation list + And I type the default message + And I tap Send Message button in conversation view + Then I see 1 default messages in the conversation view + When User sends delivery confirmation for the recent message in Myself conversation + Then I see "" on the message toolbox in conversation view + When User sends 1 image file to conversation Myself + And I wait for 5 seconds + Then I see 1 photo in the conversation view + + Examples: + | TeamOwner | Member1 | Member2 | TeamName | TeamOwner1 | GroupChat | DeliveredLabel | DeviceName | DeviceName1 | Picture | Message | + | user1Name | user2Name | user3Name | Stinky Pinky | user4Name | FederatedGroup | Delivered | device1 | device2 | testing.jpg | Hi | + + @TC-5014 @col1 + Scenario Outline: I should not see the encryption at rest overlay on receiving a message while the app is in foreground + Given There is a team owner "" with team "" + And User adds user , to team with role Member + And User has conversation with , in team + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}]} + And User is me + And User sets the unique username + When I login to the default email verified backend as + Then I am signed in properly + And I open conversation "" in conversation list + And I type the default message + And I tap on text input + And I tap Send Message button in conversation view + And I see 1 default message in the conversation view + When User sends 1 "" messages to conversation + Then I do not see Encryption At Rest overlay + When User sends 1 "" messages to conversation + Then I do not see Encryption At Rest overlay + When User sends 1 "" messages to conversation + And I wait for 3 seconds + Then I do not see Encryption At Rest overlay + Then I see last message in the conversation view is expected message + + Examples: + | TeamOwner | TeamName | Member1 | DeviceName | DeviceName1 | Message | Member2 | GroupChat | Message1 | + | user1Name |The Classified Domain | user2Name | device1 | device2 | Hi | user3Name | Group | Message | \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/FederationClassified.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/FederationClassified.feature new file mode 100644 index 00000000000..19b54d03177 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/FederationClassified.feature @@ -0,0 +1,344 @@ +Feature: Federation Classified + + @TC-4941 @col1 @SF.VSNFDLABEL @TSFI.UserInterface @TSFI.Federate @S0.1 @S7 @Security + Scenario Outline: I should not see classified but unclassified banner in 1:1 conversation with user from unclassified domain when on classified domain + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And User is connected to + And User is me + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + Then I see unclassified domain label in the conversation + And I do not see classified domain label in the conversation + When I open conversation details + Then I see unclassified domain label on Group participant user profile page + + Examples: + | TeamOwner | TeamName2 | TeamName | TeamOwner2 | + | user1Name | The Unclassified Domain | The Classified Domain | user2Name | + + @TC-4942 @col1 @col3 @SF.VSNFDLABEL @TSFI.UserInterface @TSFI.Federate @S0.1 @S7 @Security + Scenario Outline: I should not see classified but unclassified banner in 1:1 conversation with user from classified domain when on unclassified domain + Given There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-1 backend + And User is connected to + And User is me + And All other versions of Wire are uninstalled + And I enroll the simulator for Touch ID + And I open column-3 backend deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I accept First Time overlay + And I am signed in properly + And I perform successful Touch ID + When I open conversation "" in conversation list + Then I see unclassified domain label in the conversation + And I do not see classified domain label in the conversation + When I open conversation details + Then I see classified domain label on Group participant user profile page + + Examples: + | TeamOwner | TeamName2 | TeamName | TeamOwner2 | Email | Password | TeamOwnerEmail | + | user1Name | The Unclassified Domain | The Classified Domain | user2Name | user1Email | user1Password | user1Email | + + @TC-4980 @col1 @SF.VSNFDLABEL @TSFI.UserInterface @TSFI.Federate @S0.1 @S7 @Security + Scenario Outline: I should not see classified but unclassified banner in classified group conversation when user from unclassified domain joins + Given There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And There is a team owner "" with team "" on column-3 backend + And User is connected to + And User is me + And User has conversation with , in team + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + And I see classified domain label in the conversation + When I open conversation details + And I tap Add People button on Group Details page + And I type search query "" on Group Add People page + And I select search result item on Group Add People page + And I tap Add Participants button on Group Add People page + And I tap X button on Group Details page + Then I see unclassified domain label in the conversation + + Examples: + | TeamOwner | TeamName2 | TeamName | TeamOwner2 | Member1 | Member2 | GroupConversationWithClassified | + | user1Name | The Unclassified Domain | The Classified Domain | user4Name | user2Name | user3Name | ClassifiedDomainConvo | + + @TC-4946 @col1 @SF.VSNFDLABEL @TSFI.UserInterface @TSFI.Federate @S0.1 @S7 @Security + Scenario Outline: I should not see classified but unclassified banner in ongoing group call when user joins classified conversation from unclassified domain + Given There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And There is a team owner "" with team "" on column-3 backend + And User is me + And User is connected to + And User has conversation with , in team + And , start 2FA instance using + And , accepts next incoming call automatically + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + And I see classified domain label in the conversation + And I tap Audio Message button from input tools + And I accept alert if visible + And I tap Audio Call button + And , verify that waiting instance status is changed to active in 30 seconds + And I see Calling overlay + And I see SECURITY LEVEL: VS-NfD label on calling overlay + And I tap Minimize button on Calling overlay + And I open conversation details + And I tap Add People button on Group Details page + And I type search query "" on Group Add People page + When I select search result item on Group Add People page + And I tap Done keyboard button + And I tap X button on Group Details page + Then I see unclassified domain label in the conversation + When I restore Calling overlay + And I see Video Calling overlay + Then I do not see SECURITY LEVEL: VS-NfD label on calling overlay + + Examples: + | TeamOwner | Member1 | Member2 | TeamName | TeamName2 | TeamOwner2 | ClassifiedGroupConversation | CallBackend | + | user1Name | user2Name | user3Name | The Classified Domain | The UnClassified Domain | user4Name | classifiedGroupConversation | chrome | + + @TC-4926 @col1 + Scenario Outline: I want to see the classified banner in classified 1:1 conversation on same domain + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-1 backend + And User is connected to + And User is me + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + Then I see classified domain label in the conversation + And I do not see unclassified domain label in the conversation + + Examples: + | TeamOwner | TeamName2 | TeamName | TeamOwner2 | + | user1Name | The Unclassified Domain | The Classified Domain | user2Name | + + @TC-4927 @col1 + Scenario Outline: I want to see the classified banner in classified group conversation on same domain + Given There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And User is me + And User has conversation with , in team + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + Then I see classified domain label in the conversation + And I do not see unclassified domain label in the conversation + + Examples: + | TeamName2 | TeamOwner2 | Member3 | Member4 | GroupConversationWithClassifiedOwnDomain | + | The Classified Domain | user4Name | user5Name | user6Name | UnclassifiedConvoOnOwnDomain | + + @TC-4930 @TC-4934 @col1 + Scenario Outline: I want to see the classified banner in incoming connection request from same classified domain + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And User is me + And I enable Federation + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When User sent connection request to Me + And I wait for 3 seconds + And I tap Incoming Pending Requests item in conversations list + Then I see classified domain icon on the incoming connection page + # I want to see the unclassified banner in incoming connection request from same unclassified domain - TC-4934 + When I tap Ignore button on Connection Inbox page + And User sent connection request to Me + And I wait for 3 seconds + And I tap Incoming Pending Requests item in conversations list + Then I do not see classified domain icon on the incoming connection page + And I see unclassified domain icon on the incoming connection page + + Examples: + | TeamOwner | TeamOwner2 | TeamOwner1 | TeamName | + | user1Name | user2Name | user4Name | Test | + + @TC-4931 @TC-4947 @col1 @FS-1762 + Scenario Outline: I want to see the classified banner in outgoing connection request from same classified domain + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And User is me + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + And I open search screen + And I type "" in cleared Search UI input field + When I tap on conversation in search result + And I wait for 3 seconds + And I tap Connect button on Single user Pending outgoing connection page + Then I see classified domain icon on the outgoing connection page + # I want to see the unclassified banner in outgoing connection request from an unclassified domain - TC-4947 + When I tap X button on Single user profile page + And I enter unique username with backend domain of user in cleared Search UI input field + And I tap on conversation in search result + Then I do not see classified domain icon on the outgoing connection page + And I see unclassified domain icon on the outgoing connection page + #And I see federated title in connection page + + Examples: + | TeamOwner | TeamOwner2 | TeamOwner1 | TeamName | TeamOwner2Uniqueusername | + | user1Name | user2Name | user4Name | Test | user2UniqueUsername | + + @TC-4933 @TC-4932 @col1 + Scenario Outline: I want to see classified banner in group conversation when user from unclassified domain leaves + Given There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And There is a team owner "" with team "" on column-3 backend + And User adds users , to team with role Member + And User is me + And User is connected to ,, + And User has conversation with ,, in team + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + And I see unclassified domain label in the conversation + And I do not see classified domain label in the conversation + When User leaves group chat + And User leaves group chat + Then I see classified domain label in the conversation + And I do not see unclassified domain label in the conversation + + Examples: + | TeamOwner | Member1 | Member2 | TeamName | TeamName2 | TeamOwner2 | Member3 | Member4 | GroupConversationWithUnclassifiedOwnDomain | + | user1Name | user2Name | user3Name | The Unclassified Domain | The Classified Domain | user4Name | user5Name | user6Name | UnclassifiedConvoOnOwnDomain | + + @TC-4928 @col1 + Scenario Outline: I want to see the classified banner in incoming/outgoing/ongoing calls in a classified 1:1 conversation on same domain + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-1 backend + And User is me + And User is connected to + And All other versions of Wire are uninstalled + And I enroll the simulator for Touch ID + And I login to the default email verified backend as + And I am signed in properly + And I open conversation "" in conversation list + And starts 2FA instance using + And accepts next incoming call automatically + When I tap Audio Call button + And I accept alert if visible + Then I see SECURITY LEVEL: VS-NfD label on calling overlay + When verifies that waiting instance status is changed to active in 20 seconds + And User verifies to send and receive audio + Then I see Calling overlay + And I see SECURITY LEVEL: VS-NfD label on calling overlay + When I tap Leave button on Calling overlay + And verifies that waiting instance status is changed to destroyed in 40 seconds + And calls me + Then I see SECURITY LEVEL: VS-NfD label on calling overlay + And I tap Accept button on Calling overlay + And I accept alert if visible + Then I see SECURITY LEVEL: VS-NfD label on calling overlay + + Examples: + | TeamOwner | TeamName | CallBackend | TeamOwner2 | + | user1Name | Block | chrome | user2Name | + + @TC-4929 @col1 + Scenario Outline: I want to see the classified banner in outgoing/ongoing calls and call participants in a classified group conversation on same domain + Given There is a team owner "" with team "" on column-1 backend + And User adds users to team with role Member + And There is a team owner "" with team "" on column-1 backend + And User adds users to team with role Member + And User is me + When I login to the default email verified backend as + Then I am signed in properly + When User is connected to , + And User has conversation with ,, in team + And I open conversation "" in conversation list + And ,, starts 2FA instance using + And ,, accepts next incoming call automatically + When I tap Audio Call button + And I accept alert if visible + And I accept alert if visible + Then I see SECURITY LEVEL: VS-NfD label on calling overlay + When ,, verifies that waiting instance status is changed to active in 60 seconds + And I wait for 10 seconds + Then I see profile picture avatar for users ,, on calling overlay + When I wait for 3 seconds + Then I see SECURITY LEVEL: VS-NfD label on calling overlay + + Examples: + | TeamOwner | Member1 | Member2 | TeamName | TeamOwner1 | GroupChat | CallBackend | + | user1Name | user2Name | user3Name | Stinky Pinky | user4Name | FederatedGroup | chrome | + + @TC-4948 @col1 + Scenario Outline: I want to see the classified banner in incoming calls and call participants in a classified group conversation on same domain + Given There is a team owner "" with team "" on column-1 backend + And User adds users to team with role Member + And There is a team owner "" with team "" on column-1 backend + And User adds users to team with role Member + And User is me + When I login to the default email verified backend as + Then I am signed in properly + When User is connected to , + And User has conversation with ,, in team + And I open conversation "" in conversation list + And ,, starts 2FA instance using + And , accepts next incoming call automatically + When calls + Then I see Calling overlay + And I see SECURITY LEVEL: VS-NfD label on calling overlay + When I tap Accept button on Calling overlay + And I accept alert if visible + And verifies that call status to is changed to active in 10 seconds + And , verifies that waiting instance status is changed to active in 60 seconds + And User ,, verifies to have 1 peer connection + Then I see profile picture avatar for users ,, on calling overlay + Then I see SECURITY LEVEL: VS-NfD label on calling overlay + + Examples: + | TeamOwner | Member1 | Member2 | TeamName | TeamOwner1 | GroupChat | CallBackend | + | user1Name | user2Name | user3Name | Stinky Pinky | user4Name | FederatedGroup | chrome | + + @TC-4950 @TC-4951 @col1 + Scenario Outline: I want to see the classified banner in 1:1 conversation after accepting incoming connection request from same classified domain + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And User is me + And I enable Federation + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When User sent connection request to Me + And I wait for 3 seconds + And I tap Incoming Pending Requests item in conversations list + And I see classified domain icon on the incoming connection page + When I tap Connect button on Connection Inbox page + Then I see conversation view page + And I see classified domain label in the conversation + # I want to see the classified banner in 1:1 conversation after accepting incoming connection request from unclassified domain - TC-4951 + When I navigate back to conversations list + And User sent connection request to Me + And I wait for 3 seconds + And I tap Incoming Pending Requests item in conversations list + Then I do not see classified domain icon on the incoming connection page + When I tap Connect button on Connection Inbox page + Then I see conversation view page + And I see unclassified domain label in the conversation + + Examples: + | TeamOwner | TeamOwner2 | TeamOwner1 | TeamName | + | user1Name | user2Name | user4Name | Test | \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/FederationUnreachable.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/FederationUnreachable.feature new file mode 100644 index 00000000000..7bbd0b04274 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/FederationUnreachable.feature @@ -0,0 +1,497 @@ +Feature: Federation Offline + + ###################### + # Login + ###################### + + @C1305767 @federation @federationOffline + Scenario Outline: I want to login when I have a group conversation with a user who’s backend is unreachable + Given Federator for backend column-offline-ios is turned on + And I wait until the federator pod on column-offline-ios is available + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-offline-ios backend + And User is connected to , + And User has group conversation with , + And I enable Federation + And Federator for backend column-offline-ios is turned off + When I login to the default email verified backend as + Then I am signed in properly + Then I see conversation in conversations list + And I see conversation in conversations list + And I do not see conversation in conversations list +# I want to mention user who is offline and not have my client destroyed + When I open conversation "" in conversation list + And I tap Mention button from input tools + And I tap Name not available in the suggested mentions list + And I type the "okay" message and send it +# will see that the message will not be received by user + And I see "1 participant from column-offline-ios.wire.link won't get your message" system message in the conversation view + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumn3 | TeamOwnerColumnOffline | TeamNameColumn1 | TeamNameColumn3 | TeamNameColumnOffline | Group | + | user1Name | user2Name | user3Name | Team Column1 | Team Column3 | Team Offline | Fruit Salat | + + @C1305766 @federation @federationOffline + Scenario Outline: I want to login when I have a 1:1 conversation with a user who’s backend is unreachable + Given I wait until the federator pod on column-offline-ios is available + And Federator for backend column-offline-ios is turned on + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-offline-ios backend + And User is connected to + And User has 1:1 conversation with in team + And User is me + And I enable Federation + And Federator for backend column-offline-ios is turned off + When I login to the default email verified backend as + Then I am signed in properly + Then I do not see conversation in conversations list + And I see conversation Name not available in conversations list + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumnOffline | TeamNameColumn1 | TeamNameColumnOffline | + | user1Name | user3Name | Team Column1 | Team Offline | + + @C1305768 @federation @federationOffline + Scenario Outline: I want to login when I have an outgoing connection request with a user who is unreachable + Given I wait until the federator pod on column-offline-ios is available + And Federator for backend column-offline-ios is turned on + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-offline-ios backend + And User sent connection request to + And User is me + And I enable Federation + And Federator for backend column-offline-ios is turned off + When I login to the default email verified backend as + Then I am signed in properly + Then I see Pending request link in conversations list + When I tap Incoming Pending Requests item in conversations list + Then I see name "Name not available" on Single user Pending incoming connection profile page + And I see Cancel Request button on Single user Pending outgoing connection page + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumnOffline | TeamNameColumn1 | TeamNameColumnOffline | + | user1Name | user3Name | Team Column1 | Team Offline | + + @C1305769 @C1305772 @federation @federationOffline + Scenario Outline: I want to login when I have an incoming connection request from a user who is unreachable + Given I wait until the federator pod on column-offline-ios is available + And Federator for backend column-offline-ios is turned on + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-offline-ios backend + And User sent connection request to + And User is me + And User is me + And I enable Federation + And Federator for backend column-offline-ios is turned off + When I login to the default email verified backend as + Then I am signed in properly + Then I see conversation One person waiting in conversations list + And I see Pending request link in conversations list +# C1305772 - I want to accept a connection request from a user of a currently unreachable backend + When I tap Incoming Pending Requests item in conversations list +# Then I see Unverified user warning on connection request + And I tap Connect button on Connection Inbox page + Then I see alert contains text "Error" + And I see alert contains text "Something went wrong, please try again" + When I accept alert + And I tap Ignore button on Connection Inbox page + Then I do not see Pending request link in conversations list + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumnOffline | TeamNameColumn1 | TeamNameColumnOffline | + | user1Name | user3Name | Team Column1 | Team Offline | + + @C1305770 @federation @federationOffline + Scenario Outline: I want to login to my backend when my federator is turned off + Given I wait until the federator pod on column-offline-ios is available + And Federator for backend column-offline-ios is turned on + And There is a team owner "" with team "" on column-offline-ios backend + And Federator for backend column-offline-ios is turned off + And User is me + And I enable Federation + And I open column-offline-ios backend deep link in safari + And I accept alert + And I enter login on Login page + And I enter password on Login page + When I tap Login button on Login page + And I tap Not Now on save password alert + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + Then I am signed in properly + And I see conversations list + + Examples: + | TeamOwnerColumnOffline | TeamNameColumnOffline | Email | Password | + | user1Name | Team Offline | user1Email | user1Password | +# +# ###################### +# # Connection requests +# ###################### +# + @C1305773 @federation @federationOffline + Scenario Outline: I want to see my outgoing connection request sent to a user who became unavailable after sending the connection request + Given I wait until the federator pod on column-offline-ios is available + And Federator for backend column-offline-ios is turned on + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-offline-ios backend + And User sent connection request to + And User is me + And I enable Federation + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + Then I see Cancel Request button on Single user Pending outgoing connection page + When Federator for backend column-offline-ios is turned off + And I restart Wire + And I accept notification permission alert if visible + Then I see conversation in conversations list + When I open conversation "" in conversation list + Then I see Cancel Request button on Single user Pending outgoing connection page + When I tap Cancel Request button on Single user Pending outgoing connection page + Then I do not see conversation in conversations list + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumnOffline | TeamNameColumn1 | TeamNameColumnOffline | + | user1Name | user3Name | Team Column1 | Team Offline | + + @C1305771 @federation @federationOffline + Scenario Outline: I should not be able to send a connection request to a user that was cached in search but on unreachable backend + Given I wait until the federator pod on column-offline-ios is available + And Federator for backend column-offline-ios is turned on + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-offline-ios backend + And User is me + And I enable Federation + When I login to the default email verified backend as + Then I am signed in properly + When I open search screen + When I type "@" in Search UI input field + Then I see the conversation "" exist in Search results + Given Federator for backend column-offline-ios is turned off + When I tap on conversation in search result + And I tap Connect button on Single user Pending outgoing connection page + And I see alert contains text "Something went wrong, please try again" + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumnOffline | TeamOwnerColumnOfflineUsername | TeamNameColumn1 | TeamNameColumnOffline | ColOfflineBackendDomain | + | user1Name | user3Name | user3UniqueUsername | Team Column1 | Team Offline | @column-offline-ios.wire.link | + + ######################################## + # adding / removing participants ####### + ######################################## + + @C1305748 @C1305747 @federation @federationOffline + Scenario Outline: I want to add users who are reachable to a group conversation when I select multiple users of which one has an unreachable backend + And Federator for backend column-offline-ios is turned on + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-offline-ios backend + And User adds users , to team with role Member + And User adds users to team with role Member + And User is connected to ,,,, + And User is me + And User has group conversation with , + And I enable Federation + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + And Federator for backend column-offline-ios is turned off + And I open group conversation details + And I tap Add People button on Group Details page + And I select search result item on Group Add People page + And I select search result item on Group Add People page + And I select search result item on Group Add People page + And I tap Add Participants button on Group Add People page + Then I see 5 participants avatars on Group Details page + When I tap X button on Group Details page +# I want to see an error system message when I add a user to a group conversation who's backend is unreachable + Then I see " could not be added to the group" system message in the conversation view + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumnOffline | TeamNameColumn1 | TeamNameColumnOffline | TeamOwnerColumn3 | TeamNameColumn3 | Member1 | Member2 | Member3 | Group | + | user1Name | user3Name | Team Column1 | Team Offline | user2Name | Team Col3 | user4Name | user5Name | user6Name | heyyall | + + @C1305749 @federation @federationOffline + Scenario Outline: I want to remove a user from a conversation whos backend is unreachable + Given Federator for backend column-offline-ios is turned on + And I wait until the federator pod on column-offline-ios is available + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-offline-ios backend + And User is connected to , + And User has group conversation with , + And Users of team owned by adds the following 2FA devices: {"": [{"name": "device3"}]} + And I enable Federation + When I login to the default email verified backend as + Then I am signed in properly + When Federator for backend column-offline-ios is turned off + And I open conversation "" in conversation list + When I open group conversation details + And I select participant on Group Details page + And I tap Open Menu button on Group participant profile page + And I tap Remove From Group… conversation action button + And I tap Remove From Group conversation action button + Then I do not see participant name on Group Details page + When I tap X button on Group Details page + Then I see "You removed " system message in the conversation view + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumn3 | TeamOwnerColumnOffline | TeamNameColumn1 | TeamNameColumn3 | TeamNameColumnOffline | Group | + | user1Name | user2Name | user3Name | Team Column1 | Team Column3 | Team Offline | Fruit Salat | + + @C1305751 @federation @federationOffline + Scenario Outline: I should not see group conversation messages sent after I was removed from the conversation while my backend was unreachable + Given Federator for backend column-offline-ios is turned on + And I wait until the federator pod on column-offline-ios is available + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-offline-ios backend + And User is connected to , + And User has group conversation with , + And I enable Federation + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + When User sends 1 "This is message 1" message to conversation + And Federator for backend column-offline-ios is turned off + And User sends 1 "This is message 2" message to conversation + And User removes user from group conversation + And User sends 1 "This is message 3" message to conversation + And Federator for backend column-offline-ios is turned on + Then I see last message in the conversation view contains expected message This is message 2 + And I see " removed you" system message in the conversation view + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumn3 | TeamOwnerColumnOffline | TeamNameColumn1 | TeamNameColumn3 | TeamNameColumnOffline | Group | + | user1Name | user2Name | user3Name | Team Column1 | Team Column3 | Team Offline | Fruit Salat | + + ######################################## + # Backup ####### + ######################################## + + @C1305805 @C1305806 @C1305807 @C1305808 @C1305809 @history + Scenario Outline: I want to import a backup that I exported when I had connection requests to backend that is now offline + Given Federator for backend column-offline-ios is turned on + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-offline-ios backend + And User adds users , to team with role Member + And Users of team owned by adds the following 2FA devices: {"": [{"name": "device3"}]} + And User is me + And I enable Federation + And User is connected to , +# I want to import a backup that I exported when I had 1:1 conversations to backend that is now offline + And User has group conversation with , +# I want to import a backup that I exported when I had group conversations to backend that is now offline + And User has 1:1 conversation with in team + And I enable Federation + When I login to the default email verified backend as + Then I am signed in properly + When User sends 1 "There he is!" message to conversation +# C1305808 I want to import a backup while that I I have a connection requests to backend that is now offline, but was online when I exported the backup + And User sent connection request to +# C1305809 I want to import a backup while that I I have a 1:1 conversation with a backend that is now offline, but was online when I exported the backup + And User sent connection request to + And I open settings screen + And I select settings item Account + When I select settings item Back Up Conversations + And I initiate history backup from Settings + And I wait for 20 seconds + And I type password "" on Backup password overlay + And I tap Next button on Backup password overlay + And I see correct name of backup file for user on File Saving Popup + And I tap Save to Files button on File Saving Popup + And I tap On My iPhone on File Saving Popup + And I tap Save button on File Saving Popup + And I verify history backup for user from Settings is successfully completed + And I tap Go back to Account navigation button on Settings page + And I tap Go back to Settings navigation button on Settings page + And I select settings item Account + And I select settings item Log Out + And I type "" text into the alert input field + And I wait for 2 seconds + And I accept alert + And Federator for backend column-offline-ios is turned off + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I tap Restore from backup button on First Time overlay + And I tap Choose Backup File button on the alert + And I tap Browse button twice on bottom of File Choose Dialog + And I tap On My iPhone on File Choose Dialog + And I sort files by date on File Choose Dialog + And I tap file containing in File Choose Dialog + And I type "" text into the alert input field + # wait for backup to import + And I wait for 5 seconds + And I accept alert + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + Then I see conversations list + And I see conversation in conversations list + # I see connection request from in conversation list + And I tap Incoming Pending Requests item in conversations list + And I tap Ignore button on Connection Inbox page + And I open conversation "" in conversation list + And I see Cancel Request button on Single user Pending outgoing connection page + # Behaviour Right now: something went wrong when interacting with any of these connection requests + And I tap Back button on Single user Pending outgoing connection page + And I open conversation "" in conversation list + And I see last message in the conversation view is expected message There he is! + When I open group conversation details + Then I see participant names on Group Details page +# add check for when the user would not be having any metadata\ + When I select participant on Group Details page + And I tap Open Conversation button on Group participant profile page + Then I see the conversation with is opened + When I type the default message and send it + Then I see User will get your message later in conversation view + When I tap on Learn more link on delayed message in conversation view +# add check for opening correct url once url is accessible +# TODO +# and conversation hosted on foma + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumn1Username | BackupPassword | TeamOwnerColumnOffline | TeamNameColumn1 | TeamNameColumnOffline | Email | Password | TeamOwnerColumn3 | TeamNameColumn3 | Member1 | Member2 | Group | + | user1Name | user1UniqueUsername | Aqa123456!Q | user3Name | Team Column1 | Team Offline | user1Email | user1Password | user2Name | Team Col3 | user4Name | user5Name | heyyall | + + + @C1305813 @C1305812 @C1305814 @C1305815 @CC1305816 @history + Scenario Outline: I want to import a backup while that I I have a group conversation with a backend that is now offline, and was offline when I exported the backup +# C1305812 I want to import a backup while that I I have a 1:1 conversation with a backend that is now offline, and was offline when I exported the backup +#C1305813 I want to import a backup while that I I have a group conversation with a backend that is now offline, and was offline when I exported the backup +#C1305814 I want to import a backup while that I I have a 1:1 conversation with a backend that is now online, but was offline when I exported the backup +#C1305815 I want to import a backup while that I I have a group conversation with a backend that is now online, but was offline when I exported the backup +#C1305816 I want to import a backup while that I I have a connection requests to backend that is now online, but was offline when I exported the backup + Given Federator for backend column-offline-ios is turned on + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-offline-ios backend + And User adds users ,, to team with role Member + And Users of team owned by adds the following 2FA devices: {"": [{"name": "device3"}]} + And User is me + And I enable Federation + And User is connected to ,, + And User has group conversation with , + And User has 1:1 conversation with in team + And I enable Federation + When I login to the default email verified backend as + Then I am signed in properly + When User sends 1 "There he is!" message to conversation + And User sent connection request to + And User sent connection request to + And User blocks user + And Federator for backend column-offline-ios is turned off + And I open conversation "" in conversation list + And I type the default message and send it + And I navigate back to conversations list + And I open Self profile + And I open settings screen + And I select settings item Account + And I select settings item Back Up Conversations + And I initiate history backup from Settings +# Wait for creating backup + And I wait for 20 seconds + And I type password "" on Backup password overlay + And I tap Next button on Backup password overlay + And I see correct name of backup file for user on File Saving Popup + And I tap Save to Files button on File Saving Popup + And I tap On My iPhone on File Saving Popup + And I tap Save button on File Saving Popup + And I verify history backup for user from Settings is successfully completed + And I tap Go back to Account navigation button on Settings page + And I tap Go back to Settings navigation button on Settings page + And I select settings item Account + And I select settings item Log Out + And I type "" text into the alert input field + And I wait for 2 seconds + And I accept alert + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + When I tap Restore from backup button on First Time overlay + And I tap Choose Backup File button on the alert + And I tap Browse button twice on bottom of File Choose Dialog + And I tap On My iPhone on File Choose Dialog + And I sort files by date on File Choose Dialog + And I tap file containing in File Choose Dialog + And I type "" text into the alert input field + # wait for backup to import + And I wait for 2 seconds + And I accept alert + And I see Encryption At Rest overlay + And I type password on the Encryption At Rest overlay input + And I press enter on the Encryption At Rest overlay input + Then I see conversations list + # I see connection request from in conversation list + And I tap Incoming Pending Requests item in conversations list +# Wait as backend is very slow in recognizing this request + And I tap Ignore button on Connection Inbox page + And I wait for 20 seconds + And I open conversation "" in conversation list + And I see Cancel Request button on Single user Pending outgoing connection page + And I tap Back button on Single user Pending outgoing connection page + And I open conversation "" in conversation list + And I see last message in the conversation view is expected message 1 message + When I open group conversation details + Then I see participant names on Group Details page +# add check for when the user would not be having any metadata\ + When I select participant on Group Details page + And I tap Open Conversation button on Group participant profile page + Then I see the conversation with is opened + When I type the default message and send it + Then I see User will get your message later in conversation view + When I tap on Learn more link on delayed message in conversation view +# add check for opening correct url once url is accessible + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumn1Username | BackupPassword | TeamOwnerColumnOffline | TeamNameColumn1 | TeamNameColumnOffline | Email | Password | TeamOwnerColumn3 | TeamNameColumn3 | Member1 | Member2 | Member3 | Group | + | user1Name | user1UniqueUsername | Aqa123456!Q | user3Name | Team Column1 | Team Offline | user1Email | user1Password | user2Name | Team Col3 | user4Name | user5Name | user6Name | heyyall | + +# @C1305753 +# Scenario Outline: I want to see ephemeral messages disappear after the timer expired when sending backend has become unreachable after sending + +############### +# Messaging ### +############### + + @C1305754 @C1305757 @federation @federationOffline + Scenario Outline: I want to receive messages in a group conversation of which one user is unreachable + Given Federator for backend column-offline-ios is turned on + And I wait until the federator pod on column-offline-ios is available + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on column-offline-ios backend + And User is connected to , + And User has group conversation with , + And Users of team owned by adds the following 2FA devices: {"": [{"name": "device3"}]} + And I enable Federation + When I login to the default email verified backend as + Then I am signed in properly + When Federator for backend column-offline-ios is turned off + When User sends 1 "There he is!" message to conversation + And I open conversation "" in conversation list + Then I see last message in the conversation view is expected message There he is! +# I want to send messages in a group conversation of which one user is unreachable + When I type the "How are you" message and send it + Then I see User will get your message later in conversation view + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumn3 | TeamOwnerColumnOffline | TeamNameColumn1 | TeamNameColumn3 | TeamNameColumnOffline | Group | + | user1Name | user2Name | user3Name | Team Column1 | Team Column3 | Team Offline | Fruit Salat | \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/FileSharing.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/FileSharing.feature new file mode 100644 index 00000000000..b0a00ba8817 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/FileSharing.feature @@ -0,0 +1,192 @@ +Feature: File sharing + + @TC-4978 @SF.IOS-VSNFDAREA @TSFI.UserInterface @S0.1 @col1 @Security + Scenario Outline: I want to verify that file sharing is disabled on build time with FILE_SHARING_ENABLED=0 + Given There is a team owner "" with team "" on column-1 backend + And User adds users to team with role Member + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}]} + And User is me + And User Myself has 1:1 conversation with in team + And User disables File Sharing for team + And All other versions of Wire are uninstalled + And I login to the default email verified backend as + And I am signed in properly + When I open conversation "" in conversation list + Then I do not see Add Picture button in input tools palette + And I do not see Sketch button in input tools palette + And I do not see Giphy button in input tools palette + And I do not see Audio Message button in input tools palette + And I do not see File Transfer button in input tools palette + And I do not see Video Message button in input tools palette + # Disbale share extension + When I open Safari with url "" + And I tap Share button in Safari + And I tap Wire Column in share extension + And I wait for 3 seconds + And I tap Choose in share extension + And I wait for 3 seconds + And I perform successful Touch ID + And I select conversation "" in share extension + And I tap Send button in share extension + Then I see alert contains text "File sharing restrictions" + And I see alert contains text "You can not share this file because this feature is disabled." + + Examples: + | Member1 | TeamOwner | TeamName | DeviceName | URL | + | user1Name | user2Name | File sharing | device1 | https://www.duckduckgo.com | + + @C1151068 @C1151069 @C1151071 @C1151097 @C1151102 @col1filesharing @real + Scenario Outline: I want to see the option to record a video from within the app + Given There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And User has conversation with , in team + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""},{}]} + And User is me + And User Myself has 1:1 conversation with in team + And User Myself has 1:1 conversation with in team + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + Then I see Video Message button in input tools palette + # I want to send a video using the option to record a video from within the app - C1151069 + When I tap Video Message button from input tools + And I accept alert if visible + And I accept alert if visible + And I tap Take Video button on Camera page + And I wait for 5 seconds + And I tap Take Video button on Camera page + And I tap Use Video button on Camera page + And I wait for 5 seconds + And I accept alert if visible + Then I see video message container in the conversation view + When I wait for 8 seconds + And User sends delivery confirmation for the recent message in Myself conversation + Then I see "" on the message toolbox in conversation view + When I long tap on video message in conversation view + Then I do not see Download on edit menu + And I do not see Save on edit menu + And I see Share on edit menu + And I do not see Copy on edit menu + # I want to forward the recorded video to another user in 1:1 - C1151071 + When I tap on Share on edit menu + And I wait for 2 seconds + And I select conversation on Forward page + And I tap Send button on Forward page + And I navigate back to conversations list + And I open conversation "" in conversation list + Then I see 1 video files in the conversation view + And User sends delivery confirmation for the recent message in Myself conversation + Then I see "" on the message toolbox in conversation view + # I want to forward the recorded video to another user in group - C1151072 + When I long tap on video message in conversation view + And I tap on Share on edit menu + And I wait for 2 seconds + And I select conversation on Forward page + And I tap Send button on Forward page + And I navigate back to conversations list + And I open conversation "" in conversation list + Then I see 1 video files in the conversation view + When User sends delivery confirmation for the recent message in conversation + Then I see "" on the message toolbox in conversation view + # I should not be able to download the received videos - C1151097 + When User sends 1 video file to conversation + Then I see 1 video files in the conversation view + When I long tap on video message in conversation view + Then I do not see Download on edit menu + And I do not see Save on edit menu + And I see Share on edit menu + And I do not see Copy on edit menu + # I want to receive and open video file within the app - C1151102 + When I tap on video message in conversation view + And I wait for 2 seconds + Then I see pause button on Video page + + Examples: + | Member1 | Member2 | TeamOwner | TeamName | DeviceName | DeliveredLabel | GroupConversationName | VideoFileName | + | user1Name | user2Name | user3Name | File sharing | device1 | Delivered | FileSharing | testing.mp4 | + + @TC-4958 @TC-4959 @TC-4974 @TC-4966 @TC-4961 @col1filesharing @col1 + Scenario Outline: I want to see the option to record a voice recordings from within the app + Given There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And User has conversation with , in team + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}, {"name": ""}], "": [{"name": ""}]} + And User is me + And User Myself has 1:1 conversation with in team + And User Myself has 1:1 conversation with in team + And I allow microphone access + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + Then I see Audio Message button in input tools palette + # I want to send a voice recordings using the option to record a voice from within the app - TC-4959 + When I tap Audio Message button from input tools + And I tap Audio Message button from input tools + And I long tap Audio Message button from input tools + And I tap Send record control button + Then I see audio message container in the conversation view + When I wait for 3 seconds + When User sends delivery confirmation for the recent message in Myself conversation + Then I see "" on the message toolbox in conversation view + When I tap Play audio message button + And I wait for 3 seconds + And I long tap on audio message placeholder in conversation view + Then I do not see Download on edit menu + And I do not see Save on edit menu + And I do not see Share on edit menu + And I do not see Copy on edit menu + And I tap on Cancel on edit menu + # I should not be able to download the received audios - TC-4974 + When User sends file having MIME type to single user conversation using device + Then I see audio message container in the conversation view + When I long tap on audio message placeholder in conversation view + Then I do not see Download on edit menu + And I do not see Save on edit menu + And I do not see Copy on edit menu + And I tap on Cancel on edit menu + When I tap Play audio message button + And I wait for 2 seconds + And I long tap on audio message placeholder in conversation view + Then I do not see Download on edit menu + And I do not see Save on edit menu + And I do not see Share on edit menu + And I do not see Copy on edit menu + And I tap on Cancel on edit menu + # I want to receive and open voice recordings within the app - TC-4966 + When I tap Play audio message button + Then I see state of button on audio message placeholder is Play + + Examples: + | Member1 | Member2 | TeamOwner | TeamName | DeviceName | DeliveredLabel | GroupConversationName | FileName | FileMIME | ContactDevice | ContactDevice1 | + | user1Name | user2Name | user3Name | File sharing | device1 | Delivered | FileSharing | test.m4a | audio/mp3 | Device1 | Device2 | + + @TC-4971 @filesharing @services @col1 + Scenario Outline: I want to see an alert on receiving a file when the team settings is OFF + Given There is a team owner "" with team "" on column-1 backend + And User adds users to team with role Member + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}], "": [{"name": ""}]} + And User is me + And User Myself has 1:1 conversation with in team + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + Given I create temporary file in size with name "" and extension "" + When I open conversation "" in conversation list + And User disables File Sharing for team + Then I see alert contains text "Team settings changed" + And I see alert contains text "Sharing and receiving files of any type is now disabled." + When I tap OK button on the alert + Then I do not see Add Picture button in input tools palette + And I do not see Sketch button in input tools palette + And I do not see Giphy button in input tools palette + And I do not see Audio Message button in input tools palette + And I do not see File Transfer button in input tools palette + And I do not see Video Message button in input tools palette + But I see Ping button in input tools palette + And I see Mention button in input tools palette + + Examples: + | Member1 | TeamOwner | TeamName | DeviceName | FileName | FileExt | FileSize | DeviceName1 | + | user1Name | user2Name | File sharing | device1 | TestFile | pdf | 1204 KB | device2 | \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/GroupCreation.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/GroupCreation.feature new file mode 100644 index 00000000000..58ddbfb5b98 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/GroupCreation.feature @@ -0,0 +1,26 @@ +Feature: Group Creation + + @TC-4979 @col1 + Scenario Outline: I want to create a group conversation with the linear flow + Given There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And User is me + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + And I open search screen + And I open create group screen + And I enter group name "" on New Group page + And I tap Next button on New Group page + And I select search result item on Add People page + And I type "" in search input field on Add People page + And I select search result item on Add People page + And I tap Create button on Add People page + Then I see " " system message in the conversation view + When I navigate back to conversations list + Then I see conversation in conversations list + + + Examples: + | TeamOwner |TeamName | Member1 | Member2 | GroupConversationWithClassified | NewIntroductionMessage | + | user1Name | The Classified Domain | user2Name | user3Name | ClassifiedDomainConvo | You started the conversation | \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/GuestLinkCreation.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/GuestLinkCreation.feature new file mode 100644 index 00000000000..bfe44b23f2f --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/GuestLinkCreation.feature @@ -0,0 +1,19 @@ +Feature: Guest Link Creation + + @TC-4980 @col1 @SF.VSNFDLABEL @TSFI.UserInterface @TSFI.Federate @S0.1 @S7 @Security @shouldbeUI + Scenario Outline: I should not see Create Guest Link option on group details page when Guest Links are disabled on backend + Given There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And User is me + And User has conversation with , in team + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + And I open group conversation details + And I tap Guest Options on Group Details page + Then I do not see Create Link button on Guest Options page + + Examples: + | TeamOwner | TeamName | Member1 | Member2 | GroupConversationWithClassified | + | user1Name | The Classified Domain | user2Name | user3Name | ClassifiedDomainConvo | \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/HistoryImport.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/HistoryImport.feature new file mode 100644 index 00000000000..a885a984d14 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/HistoryImport.feature @@ -0,0 +1,76 @@ +Feature: History Import + + @TC-4981 @col1 @knownbug @WPB-11092 + Scenario Outline: I want to have the delivery statuses, likes, messages, system messages, assets, pings, failed-to-send messages, mute, archived statuses in my imported backup + Given There is a team owner "" with team "" + And User adds user , to team with role Member + And User is me + And User Myself has 1:1 conversation with in team + And User Myself has 1:1 conversation with in team + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}], "": [{"name": ""}], "Myself": [{"name": "M1", "label": "L1"}]} + And I login to the default email verified backend as + Then I am signed in properly + When I long tap alias conversation '' in conversation list + And I choose Archive from conversation list context menu + And I swipe right on conversation in Conversations view + And I tap Notifications… conversation action button + And I tap Nothing conversation action button + And User pings conversation Myself + # Wait for sync + And I wait for 2 seconds + And User sends 1 image file to conversation Myself + # Wait for sync + And I wait for 3 seconds + And User sends 1 default message to conversation Myself + # Wait for sync + And I wait for 2 seconds + And User Myself likes the recent message from user + And I open Self profile + And I open settings screen + And I select settings item Account + And I select settings item Back Up Conversations + And I initiate history backup from Settings + And I type password "" on Backup password overlay + And I tap Next button on Backup password overlay + And I see correct name of backup file for user on File Saving Popup + And I tap Save to Files button on File Saving Popup + And I tap On My iPhone on File Saving Popup + And I wait for 2 seconds + And I tap Save button on File Saving Popup + And I verify history backup for user from Settings is successfully completed + And I tap Go back to Account navigation button on Settings page + And I select settings item Log Out + And I type "" text into the alert input field + And I accept alert + And I tap Login button on Welcome page + And I enter login MyEmail on Login page + And I enter password MyPassword on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + When I tap Restore from backup button on First Time overlay + And I tap Choose Backup File button on the alert + And I tap Browse button twice on bottom of File Choose Dialog + And I tap On My iPhone on File Choose Dialog +# And I sort files by date on File Choose Dialog + And I tap file containing in File Choose Dialog + And I type "" text into the alert input field + And I accept alert + # wait for backup to import + And I wait for 9 seconds + And I perform successful Touch ID + And I confirm overlay if build has encryption at rest enabled + Then I see Self profile button on Conversations list page + #And I do not see conversation in conversations list + And I see Archive button at the bottom of conversations list + And I see status of conversations list item is "Silenced" + When I open conversation "" in conversation list + Then I see " pinged" ping message in the conversation view + And I see 1 default message in the conversation view + And I see 1 photo in the conversation view + + Examples: + | TeamOwner | Username | Member1 | Member2 | DeviceName1 | DeviceName2 | Picture | BackupPassword | Password | Email | LoginPassword | TeamName | TeamOwnerEmail | + | user1Name | user1UniqueUsername | user2Name | user3Name | D1 | D2 | testing.jpg | Gut3nM0rg3n! | user1Password | user1Email | user1Password | Chimseys | user1Email | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Images.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Images.feature new file mode 100644 index 00000000000..a816e592ebf --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Images.feature @@ -0,0 +1,103 @@ +Feature: Images + + @TC-4983 @TC-4984 @TC-4982 @TC-4987 @TC-4985 @TC-4986 @col1 + Scenario Outline: I want to see the option to draw an image from within the app + Given There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And User has conversation with , in team + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}, {}]} + And User is me + And User Myself has 1:1 conversation with in team + And User Myself has 1:1 conversation with in team + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + Then I see Sketch button in input tools palette + # I should not see the gallery option while drawing an image - TC-4984 + When I tap Sketch button from input tools + Then I do not see phone gallery button in a draw sketch view + # I want to send drawings with the drawing feature within the app - TC-4982 + When I draw a random sketch + And I tap Send button on Sketch page + Then I see 1 photo in the conversation view + When User sends delivery confirmation for the recent message in Myself conversation + Then I see "" on the message toolbox in conversation view + When I long tap on image in conversation view + Then I do not see Download on edit menu + And I do not see Save on edit menu + And I do not see Copy on edit menu + And I tap on screen to enable video calling overlay + # I should not be able to download the drawings - TC-4987 + When I tap on image in conversation view + Then I see Full Screen Page opened + And I do not see Copy on edit menu + And I do not see Download on edit menu + + Examples: + | Member1 | Member2 | TeamOwner | TeamName | GroupConversationName | DeliveredLabel | + | user1Name | user2Name | user3Name | File sharing | FileSharing | Delivered | + + @C1151106 @C1151107 @C1151108 @C1151109 @C1151084 @C1151085 @C1151086 @real + Scenario Outline: I want to see the option to send an image using camera roll from within the app + Given I allow access to all photos + And I allow camera access + And There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And User has conversation with , in team + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}, {}]} + And User is me + And User Myself has 1:1 conversation with in team + And User Myself has 1:1 conversation with in team + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + Then I see Add Picture button in input tools palette + And I do not see first item from Keyboard Gallery + # I want to send an image using camera roll from within the app - C1151107 + When I tap Add Picture button from input tools + And I accept camera access alert on real device + And I accept access to all photos on real device + And I tap Fullscreen Camera button on Keyboard Gallery overlay + And I accept alert if visible + And I tap Take Photo button on Camera page + And I tap Use Photo button on Picture preview page + And I accept alert if visible + Then I see 1 photo in the conversation view + When I long tap on audio message placeholder in conversation view + Then I do not see Download on edit menu + And I do not see Save on edit menu + And I see Share on edit menu + And I do not see Copy on edit menu + # I should not be able to download the received images - C1151108 + When User sends 1 image file to conversation Myself + Then I see 1 photo in the conversation view + When I long tap on audio message placeholder in conversation view + Then I do not see Download on edit menu + And I do not see Save on edit menu + And I do not see Copy on edit menu + # I want to receive and open images within the app - C1151109 + When I tap on image in conversation view + And I see Full Screen Page opened + Then I do not see Copy on edit menu + And I do not see Download on edit menu + # I want to forward the received image to another user in 1:1 - C1151087 + When I long tap on image in conversation view + And I tap on Share on edit menu + And I select conversation on Forward page + And I tap Send button on Forward page + And I long tap on image in conversation view + And I tap on Share on edit menu + And I select conversation on Forward page + And I tap Send button on Forward page + And I navigate back to conversations list + And I open conversation "" in conversation list + Then I see 1 photo in the conversation view + # I want to forward the received image to another user in a group conversation - C1151088 + When I navigate back to conversations list + And I open conversation "" in conversation list + Then I see 1 photo in the conversation view + + Examples: + | Member1 | Member2 | TeamOwner | TeamName | DeviceName | GroupConversationName | Picture | + | user1Name | user2Name | user3Name | File sharing | device1 | FileSharing | testing.jpg | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/LinkPreview.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/LinkPreview.feature new file mode 100644 index 00000000000..127ef6e00d7 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/LinkPreview.feature @@ -0,0 +1,28 @@ +Feature: Link Preview + + @TC-4995 @col1 + Scenario Outline: I should not see link preview on receiving/sharing a link + Given There is a team owner "" with team "" on column-1 backend + And User adds users , to team with role Member + And User has conversation with , in team + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": ""}], "": [{"name": ""}]} + And User is me + And All other versions of Wire are uninstalled + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + And I type the "" message and send it + And I wait for 3 seconds + Then I do not see link preview container in the conversation view + And I do not see link preview image in the conversation view + When I navigate back to conversations list + And I open conversation "" in conversation list + Then I do not see link preview container in the conversation view + When User sends 1 "" message to conversation + And I wait for 3 seconds + Then I do not see link preview container in the conversation view + And I do not see link preview image in the conversation view + + Examples: + | Member1 | Member2 | TeamOwner | TeamName | GroupConversationName | DeviceName1 | Link | Link1 | DeviceName2 | + | user1Name | user2Name | user3Name | File sharing | FileSharing | devcie1 | https://github.com/ | https://www.youtube.com/watch?v=xd955wt1Bs0&feature=youtu.be | device2 | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Login.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Login.feature new file mode 100644 index 00000000000..2b53dd67a5e --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Login.feature @@ -0,0 +1,19 @@ +Feature: Log In + + @TC-6256 @col1 @login @SF.Provisioning @TSFI.RESTfulAPI @S0.1 @S2 @Security + Scenario Outline: I want to verify that device management screen is shown appears after registering 7 devices + Given There is a team owner "" with team "" on column-1 backend + And User is me + And Users of team owned by adds the following 2FA devices: {"Myself": [{"name": "", "label": ""}, {"name": "", "label": ""}, {"name": "", "label": ""}, {"name": "", "label": ""}, {"name": "", "label": ""}, {"name": "", "label": ""}, {"name": "", "label": ""}]} + When I login to the default email verified backend as + Then I see Manage Devices overlay + When I tap Manage Devices button on Devices Overlay + And I tap Delete for device + And I tap Delete button on Devices Overlay + And I confirm with my MyPassword the deletion of the device on Settings page + And I perform successful Touch ID + Then I see conversations list + + Examples: + | Name | DeviceName1 | DeviceName2 | DeviceName3 | DeviceName4 | DeviceName5 | DeviceName6 | DeviceName7 | + | user1Name | Device1 | Device2 | Device3 | Device4 | Device5 | Device6 | Device7 | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Logout.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Logout.feature new file mode 100644 index 00000000000..353c8b4260a --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Logout.feature @@ -0,0 +1,31 @@ +Feature: Log Out + + @TC-6258 @logout @col1 @SF.Provisioning @TSFI.UserInterface @TSFI.RESTfulAPI @S0.1 @S2 @Security + Scenario Outline: I want to verify the appropriate device is logged out if you remove it from settings + Given There is a team owner "" with team "" on column-1 backend + And User is me + When I login to the default email verified backend as + Then I am signed in properly + And I see conversations list + When User Myself removes all their registered OTR clients + Then I see alert contains text "Your session expired" + When I accept alert + Then I see Login page + + Examples: + | Name | + | user1Name | + + @TC-6091 @logout @col1 @SF.Provisioning @TSFI.UserInterface @TSFI.RESTfulAPI @S0.1 @S2 @Security + Scenario Outline: I want to verify immediately being logged out after being removed from team + Given There is a team owner "" with team "" on column-1 backend + And User adds user to team with role Member + And User is me + When I login to the default email verified backend as + Then I am signed in properly + When User removes user from team + Then I see alert title contains text "" + + Examples: + | TeamOwner | Member1 | TeamName | SessionTimeoutText | + | user1Name | user2Name | kickme | Your session expired | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/MLS/E2EI.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/MLS/E2EI.feature new file mode 100644 index 00000000000..a79f5edab22 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/MLS/E2EI.feature @@ -0,0 +1,133 @@ +@e2ei @mls +Feature: E2EI + + @TC-8192 @SF.Messages @TSFI.UserInterface @TSFI.ACME @S0.1 @S0.3 @S8 @col1 @Security + Scenario Outline: I should not get a certificate if ACME server rejects identifier because user does not match + Given There is a team owner "" with team "MLS" + And User adds users , to team MLS with role Member + And User adds users , to keycloak for E2EI + And User configures MLS for team "MLS" + And Admin user enables E2EI with ACME server for team "MLS" + And I open default backend via deep link in safari + And I enroll the simulator for Touch ID + And I tap Proceed button on backend redirection page + And I tap Login button on Welcome page + And I start verification email monitoring on mailbox + And I login as + And I tap Not Now on save password alert + And I enter verification code from Email + When I accept First Time overlay + And I tap Get Certificate button on Enrollment overlay + And I accept alert if visible + Then I see keycloak web view + And I enter email on keycloak web view + And I enter password on keycloak web view + And I click sign in button on keycloak web view + Then I see certificate error message + + Examples: + | Owner | Member1 | Member1Email | Member2 | Member2Email | Member2Password | + | user1Name | user2Name | user2Email | user3Name | user3Email | user3Password | + + @TC-7979 @col1 + Scenario Outline: I should get a certificate if everything is valid + Given There is a team owner "" with team "E2EI" + And User adds users to team E2EI with role Member + And User adds users to keycloak for E2EI + And User configures MLS for team "E2EI" + And Admin user enables E2EI with ACME server for team "E2EI" + And I open default backend via deep link in safari + And I enroll the simulator for Touch ID + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I accept First Time overlay + And I tap Get Certificate button on Enrollment overlay + And I enter email on keycloak web view + And I enter password on keycloak web view + And I click sign in button on keycloak web view + And I click Ok on the Enrollment Success screen + And I perform successful Touch ID + And I am signed in properly + Then I see that I am certified on Conversation List Page + When I open Self profile + And I open settings screen + And I select settings item Devices + And I open details page of device number 1 on Settings page + And I open my certificate details + Then I see certificate details info + Examples: + | Owner | Member1 | Member1Email | Member1Password | + | user1Name | user2Name | user2Email | user2Password | + + @TC-8193 @SF.Messages @TSFI.UserInterface @TSFI.ACME @S0.1 @S0.3 @S8 @col1 @Security + Scenario Outline: I should not get a certificate if TLS certificate of ACME is invalid + Given There is a team owner "" with team "MLS" + And User adds users to team MLS with role Member + And User adds users to keycloak for E2EI + And User configures MLS for team "MLS" + And Admin user enables E2EI with insecure ACME server for team "MLS" + And I open default backend via deep link in safari + And I enroll the simulator for Touch ID + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I accept First Time overlay + And I tap Get Certificate button on Enrollment overlay + Then I see certificate error message + + Examples: + | Owner | Member1 | Member1Email | Member1Password | + | user1Name | user2Name | user2Email | user2Password | + + @col1 + Scenario Outline: Admin revoking a certificate and then user can remove their revoked device + Given There is a team owner "" who sets up team "E2EI" for E2EI on column-1 backend + When I login to the default email verified backend as + And I tap Get Certificate button on Enrollment overlay + And I accept alert if visible + Then I see keycloak web view + And I login to keycloak as "" + And I click Ok on the Enrollment Success screen + And I perform successful Touch ID + And I press enter on the Encryption At Rest overlay input + When I open Self profile + And I open settings screen + And I select settings item Devices + And I open details page of device number 1 on Settings page + And I open my certificate details + And I copy my certificate details + And Admin of column-1 backend revokes remembered certificate on ACME server + When I reset Wire + When I login to the default email verified backend as + And I accept alert if visible + And I tap Get Certificate button on Enrollment overlay + And I accept alert if visible + Then I see keycloak web view + And I login to keycloak as "" + And I click Ok on the Enrollment Success screen + And I accept alert if visible + And I perform successful Touch ID + When I open Self profile + And I open settings screen + And I select settings item Devices + And I open details page of device number 2 on Settings page + Then I should see a revoked certificate in Device Details + When I tap Remove Device button on Device Details page + And I confirm with my the deletion of the device on Settings page + Then I see 1 device is shown on Settings page + Examples: + | Owner1 | Owner1Password | + | user1Name | user1Password | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/MLS/MLSCol1.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/MLS/MLSCol1.feature new file mode 100644 index 00000000000..f3ffeea8742 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/MLS/MLSCol1.feature @@ -0,0 +1,312 @@ +Feature: MLS Col1 + + @CC1312749 @C1312729 @C1312730 @col3 @mlscol1 + Scenario Outline: I want to create a MLS group conversation with another team and exchange messages before removing participants + Given I enable MLS support + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And User adds users to team with role Member + And User adds users to team with role Member + And User configures MLS for team "" + And User configures MLS for team "" + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C1"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C2"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "device4", "label": "C1"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C2"}]} + And Users claims key packages + And Users claims key packages + And Users claims key packages + And Users claims key packages + And User is me + And I enable API versioning 5 + And User Myself is connected to , + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + And I wait for 10 seconds +# And I see Encryption At Rest overlay +# And I type password on the Encryption At Rest overlay input +# And I press enter on the Encryption At Rest overlay input +# And I do not see Encryption At Rest overlay + And I am signed in properly + And I open search screen + And I open create group screen + And I enter group name "" on New Group page + When I expand conversation options on New Group page + When I tap Protocol option on New Group page + And I tap MLS option on New Group page + When I tap Next button on New Group page + And I type "" in search input field on Add People page + And I select search result item on Add People page + And I type "" in search input field on Add People page + And I select search result item on Add People page + And I tap Create button on Add People page + Then I see " " system message in the conversation view + # And Conversation from user uses mls protocol + When I navigate back to conversations list +# C1312724 I want to add users to MLS group +# I want to create MLS conversation and add federated users + Then I see conversation in conversations list + When I open conversation "" in conversation list + And I open conversation details + And I tap Add People button on Group Details page + And I type search query "" on Group Add People page + And I select search result item on Group Add People page + And I tap Done keyboard button + Then I see participant names ,, on Group Details page + When I tap X button on Group Details page + Then I see "You added " system message in the conversation view +# C1312730 I want to create MLS conversation and remove users on federated backend + When I open group conversation details + And I select participant on Group Details page + And I tap Open Menu button on Group participant profile page + And I tap Remove From Group… conversation action button + And I tap Remove From Group conversation action button + Then I do not see participant name on Group Details page + When I tap X button on Group Details page + Then I see "You removed " system message in the conversation view + + Examples: + | TeamOwner | Member1 | Member2 | TeamOwner2 | TeamName | GroupName | TeamName2 | NewIntroductionMessage | DeviceName | Email | Password | DeviceName1 | + | user1Name | user2Name | user4Name | user3Name | Pyramid | MLS Group | Guest Team | You started the conversation | ContactDevice | user2Email | user2Password | TeamOwnerDevice1 | + + @C1312728 @C1312724 @C1312728 @C1312748 @C1312725 @col3 @mlscol1 + Scenario Outline: I want to create a group with a guest and react to each others messages before and after disabling MLS for the whole team + Given I enable MLS support + And There is a team owner "" with team "" + And There is a team owner "" with team "" + And User adds users , to team with role Member + And User configures MLS for team "" + And User configures MLS for team "" + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C1"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "device4", "label": "C1"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C2"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C2"}]} + And Users claims key packages + And Users claims key packages + And Users claims key packages + And Users claims key packages + And User is me + And I enable API versioning 5 + And User Myself is connected to + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + #And I see Encryption At Rest overlay + #And I type password on the Encryption At Rest overlay input + #And I press enter on the Encryption At Rest overlay input + #And I do not see Encryption At Rest overlay + And I wait for 10 seconds + And I am signed in properly + And I open search screen + And I open create group screen + And I enter group name "" on New Group page + # C1312728 I want to see a protocol dropdown during group creation as MLS enabled member + When I expand conversation options on New Group page + Then I see Guests option on group creation view + And I see Services option on group creation view + And I swipe up on Group Details page +# And I see the Read Receipts toggle on New Group page + And I see Protocol option on New Group page + And I see Proteus value in Protocol option on New Group page + When I tap Protocol option on New Group page + And I tap MLS option on New Group page + Then I see MLS value in Protocol option on New Group page + When I tap Next button on New Group page + And I type "" in search input field on Add People page + And I select search result item on Add People page + And I type "" in search input field on Add People page + And I select search result item on Add People page + And I tap Create button on Add People page + Then I see " " system message in the conversation view + # And Conversation from user uses mls protocol + When I navigate back to conversations list +# C1312724 I want to add users to MLS group + Then I see conversation in conversations list + When I open conversation "" in conversation list + And I open conversation details + And I tap Add People button on Group Details page + And I type search query "" on Group Add People page + And I select search result item on Group Add People page + And I tap Done keyboard button + Then I see participant names ,, on Group Details page + When I tap X button on Group Details page + Then I see "You added " system message in the conversation view + # I want to send and receive messages in the MLS conversation + When User sends 1 default message to conversation + Then I see 1 default messages in the conversation view +# When User sends 1 image file to conversation + When I type the default message and send it + Then I see 2 default messages in the conversation view + # C1312748 I want to see read receipts in the MLS conversation + When User marks the recent message as read in conversation via device + And I see that recent message is seen by 1 persons + And I long tap default message in conversation view + When I tap on Details on edit menu + Then I see user in the Seen list + When I close the message details + When I open group conversation details + And I select participant on Group Details page + And I tap Open Menu button on Group participant profile page + And I tap Remove From Group… conversation action button + And I tap Remove From Group conversation action button + Then I do not see participant name on Group Details page + When I tap X button on Group Details page + Then I see "You removed " system message in the conversation view + + Examples: + | TeamOwner | Member1 | Member2 | TeamOwner2 | TeamName | GroupName | TeamName2 | NewIntroductionMessage | DeviceName | Email | Password | DeviceName1 | + | user1Name | user2Name | user4Name | user3Name | Pyramid | MLS Group | Guest Team | You started the conversation | ContactDevice | user2Email | user2Password | TeamOwnerDevice1 | + + @C1312731 @C1312732 @col3 @mlscol1 + Scenario Outline: I want to send and receive reactions in MLS group conversation + Given I enable MLS support + And There is a team owner "" with team "" + And There is a team owner "" with team "" + And User adds users , to team with role Member + And User configures MLS for team "" + And User configures MLS for team "" + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C1"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "device4", "label": "C1"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C2"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C2"}]} + And Users claims key packages + And Users claims key packages + And Users claims key packages + And Users claims key packages + And User is me + And I enable API versioning 5 + And User Myself is connected to + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + #And I see Encryption At Rest overlay + #And I type password on the Encryption At Rest overlay input + #And I press enter on the Encryption At Rest overlay input + #And I do not see Encryption At Rest overlay + And I wait for 10 seconds + And I am signed in properly + When I open search screen + And I open create group screen + And I enter group name "" on New Group page + When I expand conversation options on New Group page + When I tap Protocol option on New Group page + And I tap MLS option on New Group page + When I tap Next button on New Group page + And I type "" in search input field on Add People page + And I select search result item on Add People page + And I type "" in search input field on Add People page + And I select search result item on Add People page + And I tap Create button on Add People page + Then I see " " system message in the conversation view + When User sends 1 default message to conversation + And I long tap default message in conversation view + And I tap on ❤️ reaction in quick reactions + And I see ❤️ reaction in the conversation view + And I long tap default message in conversation view + And I tap on ❤️ reaction in quick reactions + Then I do not see ❤️ reaction in the conversation view + When I type the default message and send it + And User likes the recent message from group conversation + And User marks the recent message as read in conversation via device + And I see ❤️ reaction in the conversation view + + Examples: + | TeamOwner | Member1 | Member2 | TeamOwner2 | TeamName | GroupName | TeamName2 | NewIntroductionMessage | DeviceName | Email | Password | DeviceName1 | + | user1Name | user2Name | user4Name | user3Name | Pyramid | MLS Group | Guest Team | You started the conversation | ContactDevice | user2Email | user2Password | TeamOwnerDevice1 | + + @C1312733 @C1312734 @C1312757 @C1312758 @mlscol1 + Scenario Outline: I want to add users to a MLS group conversation as a group admin + Given There is a team owner "" with team "" + And There is a team owner "" with team "" + And User adds users , to team with role Member + And User configures MLS for team "" + And User configures MLS for team "" + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C1"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "device4", "label": "C1"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C2"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C2"}]} + And Users claims key packages + And Users claims key packages + And Users claims key packages + And Users claims key packages + And User is me + And I enable API versioning 5 + And User Myself is connected to + And User has MLS conversation "" with + And User changes users to role Admin for conversation "" + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I tap Login with Email button on Custom backend welcome page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + #And I see Encryption At Rest overlay + #And I type password on the Encryption At Rest overlay input + #And I press enter on the Encryption At Rest overlay input + #And I do not see Encryption At Rest overlay + And I am signed in properly +# C1312735 I want to see and exchange messages in a MLS group which was created before I logged in + Then I see conversation in conversations list + When I open conversation "" in conversation list + And I type the default message and send it + Then I see 1 default messages in the conversation view + When User sends 1 default message to conversation + Then I see 2 default messages in the conversation view + And I open conversation details +# C1312757 I want to be able to add a user to MLS conversation after I have been promoted to Admin + And I tap Add People button on Group Details page + And I type search query "" on Group Add People page + And I select search result item on Group Add People page + And I tap Done keyboard button + Then I see participant names ,, on Group Details page + When I tap X button on Group Details page + Then I see "You added " system message in the conversation view + #C1312734 I want to remove users from a MLS group conversation as a group admin + #C1312758 I want to be able to remove a user to MLS conversation after I have been promoted to Admin + When I open group conversation details + And I select participant on Group Details page + And I tap Open Menu button on Group participant profile page + And I tap Remove From Group… conversation action button + And I tap Remove From Group conversation action button + Then I do not see participant name on Group Details page + When I tap X button on Group Details page + Then I see "You removed " system message in the conversation view + + Examples: + | TeamOwner | Member1 | Member2 | TeamOwner2 | TeamName | GroupName | TeamName2 | NewIntroductionMessage | DeviceName | Email | Password | DeviceName1 | + | user1Name | user2Name | user4Name | user3Name | Pyramid | MLS Group | Guest Team | You started the conversation | ContactDevice | user2Email | user2Password | TeamOwnerDevice1 | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/MLS/MLSCol3.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/MLS/MLSCol3.feature new file mode 100644 index 00000000000..4f46f45a284 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/MLS/MLSCol3.feature @@ -0,0 +1,96 @@ +Feature: MLS Col 3 + + @C1312728 @C1312724 @C1312728 @C1312748 @C1312725 @col3 @mlscol1 + Scenario Outline: I want to log in and create a MLS group + Given There is a team owner "" with team "" + And There is a team owner "" with team "" + And User adds users , to team with role Member + And User configures MLS for team "" + And User configures MLS for team "" + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C1"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "device4", "label": "C1"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C2"}]} + And Users of team owned by adds the following 2FA devices: {"": [{"name": "", "label": "C2"}]} + And Users claims key packages + And Users claims key packages + And Users claims key packages + And Users claims key packages + And User is me + And I enable API versioning 5 + And User Myself is connected to + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I tap Login with Email button on Custom backend welcome page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I wait for 3 seconds + And I accept First Time overlay + #And I see Encryption At Rest overlay + #And I type password on the Encryption At Rest overlay input + #And I press enter on the Encryption At Rest overlay input + #And I do not see Encryption At Rest overlay + And I am signed in properly + When I open search screen + And I open create group screen + And I enter group name "" on New Group page + # C1312728 I want to see a protocol dropdown during group creation as MLS enabled member + When I expand conversation options on New Group page + Then I see Guests option on group creation view + And I see Services option on group creation view +# And I see the Read Receipts toggle on New Group page + And I see Protocol option on New Group page + And I see Proteus value in Protocol option on New Group page + When I tap Protocol option on New Group page + And I tap MLS option on New Group page + Then I see MLS value in Protocol option on New Group page + When I tap Next button on New Group page + And I select search result item on Add People page + And I type "" in search input field on Add People page + And I select search result item on Add People page + And I tap Create button on Add People page + Then I see " " system message in the conversation view + # And Conversation from user uses mls protocol + When I navigate back to conversations list +# C1312724 I want to add users to MLS group + Then I see conversation in conversations list + When I open conversation "" in conversation list + And I open conversation details + And I tap Add People button on Group Details page + And I type search query "" on Group Add People page + And I select search result item on Group Add People page + And I tap Done keyboard button + Then I see participant names ,, on Group Details page + When I tap X button on Group Details page + Then I see "You added " system message in the conversation view + # I want to send and receive messages in the MLS conversation + When User sends 1 default message to conversation + Then I see 1 default messages in the conversation view +# When User sends 1 image file to conversation + When I type the default message and send it + Then I see 2 default messages in the conversation view + #Next section does not work right now, also needs adjustment for reactions + # C1312748 I want to see read receipts in the MLS conversation + When User marks the recent message as read in conversation via device + And I see that recent message is seen by 1 persons + And I long tap default message in conversation view + When I tap on Details on edit menu + Then I see user in the Seen list + When I close the message details + When I open group conversation details + And I select participant on Group Details page + And I tap Open Menu button on Group participant profile page + And I tap Remove From Group… conversation action button + And I tap Remove From Group conversation action button + Then I do not see participant name on Group Details page + When I tap X button on Group Details page + Then I see "You removed " system message in the conversation view + + Examples: + | TeamOwner | Member1 | Member2 | TeamOwner2 | TeamName | GroupName | TeamName2 | NewIntroductionMessage | DeviceName | Email | Password | DeviceName1 | + | user1Name | user2Name | user4Name | user3Name | Pyramid | MLS Group | Guest Team | You started the conversation | ContactDevice | user2Email | user2Password | TeamOwnerDevice1 | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/NonFullyConnectedGraphs.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/NonFullyConnectedGraphs.feature new file mode 100644 index 00000000000..7b32c13f7d1 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/NonFullyConnectedGraphs.feature @@ -0,0 +1,133 @@ +Feature: Non Fully Connected Graphs + + @C1305774 @C1305778 @federation @NFCG @col3 + Scenario Outline: I should not be able to create a group with both column 1 and external as a column 3 user + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on external backend + And User is connected to , + And I enable Federation + When I login to the default email verified backend as + Then I am signed in properly + When I tap Create Group button on Search UI page + And I enter group name "" on New Group page + And I tap Next button on New Group page + And I select search result item on Add People page + And I select search result item on Add People page + And I tap Create button on Add People page +# C1305778 I want to see an alert modal when I try to create a group with users from column 1 and external as a column 3 user + Then I see alert title contains text "Group can't be created" + And I see alert description contains text "People from backends column-1.wire.link and external.wire.link can't join the same group conversation. To create the group, remove affected participants." + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumn3 | TeamOwnerExternal | TeamNameColumn1 | TeamNameColumn3 | TeamNameExternal | GroupName | + | user1Name | user2Name | user3Name | Team Column1 | Team Column3 | Team Offline | DB train group | + + @C1305775 @C1305776 @C1305779 @federation @NFCG @col3 + Scenario Outline: I should not be able to add a column 1 user to a group with external users on column 3 + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And There is a team owner "" with team "" on external backend + And User is connected to , + And User has group conversation with + And User has group conversation with + And I enable Federation + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + And I open group conversation details + And I tap Add People button on Group Details page + And I select search result item on Group Add People page + And I tap Add Participants button on Group Add People page + And I tap X button on Group Details page + Then I see " could not be added to the group" system message in the conversation view +# C1305776 I should not be able to add a external user to a group with column 1 users on column 3 + When I navigate back to conversations list + And I open conversation "" in conversation list + And I open group conversation details + And I tap Add People button on Group Details page + And I select search result item on Group Add People page + And I tap Add Participants button on Group Add People page + And I tap X button on Group Details page +# C1305779 I want to see a system message when I try to add external user to a group with users from column 1 as a column 3 user + Then I see " could not be added to the group" system message in the conversation view + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumn3 | TeamOwnerExternal | TeamNameColumn1 | TeamNameColumn3 | TeamNameExternal | GroupExternal | GroupCol1 | + | user1Name | user2Name | user3Name | Team Column1 | Team Column3 | Team Offline | group With External | group with Col1 | + + @TC-5019 @col1 @NFCG + Scenario Outline: I should not be able to find external user if I am a user on column 1 + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on external backend + And User is me + And I enable Federation + When I login to the default email verified backend as + Then I am signed in properly + When I open search screen + When I type "@" in Search UI input field + Then I see the conversation "" does not exist in Search results + + Examples: + | TeamOwnerColumn1 | TeamOwnerExternal | TeamNameColumn1 | TeamNameExternal | TeamOwnerExternalUniqueUsername | ColExternalBackendDomain | + | user1Name | user2Name | Avocado | Banana | user2UniqueUsername | @external.wire.link | + + @C1305780 @C1305781 @federation @NFCG @col3 + Scenario Outline: I want to add a column 1 user to a conversation that previously had an external user participating + Given There is a team owner "" with team "" on column-3 backend + And User adds users to team with role Member + And There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on external backend + And User is connected to ,, + And User has conversation with , in team + And User has conversation with , in team + And I enable Federation + When I login to the default email verified backend as + Then I am signed in properly + When I open conversation "" in conversation list + And User removes user from group conversation + And I open group conversation details + And I tap Add People button on Group Details page + And I select search result item on Group Add People page + And I tap Add Participants button on Group Add People page + And I tap X button on Group Details page + Then I see "You added " system message in the conversation view +# I want to add a external user to a conversation that previously had a column 1 user participating + When I navigate back to conversations list + And I open conversation "" in conversation list + And User removes user from group conversation + And I open group conversation details + And I tap Add People button on Group Details page + And I select search result item on Group Add People page + And I tap Add Participants button on Group Add People page + And I tap X button on Group Details page + Then I see "You added " system message in the conversation view + + Examples: + | TeamOwnerColumn1 | TeamOwnerColumn3 | TeamOwnerExternal | TeamNameColumn1 | TeamNameColumn3 | TeamNameExternal | GroupExternal | GroupCol1 | Member1 | + | user1Name | user2Name | user3Name | Team Column1 | Team Column3 | Team Offline | group With External | group with Col1 | user4Name | + + @TC-5024 @col1 @NFCG + Scenario Outline: I should not be able to find column 1 user if I am a user on external + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on external backend + And User is me + And I enable Federation + And I open external backend deep link in safari + And I enroll the simulator for Touch ID + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I accept First Time overlay + And I am signed in properly + And I perform successful Touch ID + When I open search screen + When I type "@" in Search UI input field + Then I see the conversation "" does not exist in Search results + + Examples: + | TeamOwnerColumn1 | TeamOwnerExternal | TeamNameColumn1 | TeamNameExternal | TeamOwnerColumn1UniqueUsername | Email | Password | Col1BackendDomain | + | user1Name | user2Name | Avocado | Banana | user1UniqueUsername | user2Email | user2Password | @column-1.wire.link | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/On-Premises.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/On-Premises.feature new file mode 100644 index 00000000000..247b0b25e21 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/On-Premises.feature @@ -0,0 +1,60 @@ +Feature: On-Premises + + @TC-4996 @onpremise @col1 @col3 @shouldbeui + Scenario: I should see the domain name of custom backend + When I open default backend via deep link in safari + Then I see redirection title on backend redirection page + And I see backend information of backend default + When I tap Proceed button on backend redirection page + Then I see sign in screen + + @TC-4997 @onpremise @col1 @col3 @SF.Channel @TSFI.UserInterface @S0.1 @Security @shouldbeui + Scenario: I should not see phone login on build with disabled phone login + When I see sign in screen + Then I do not see Phone login tab on Login page + + @TC-4998 @onpremise @col1 @col3 @SF.Locking @TSFI.UserInterface @S0.1 @Security + Scenario Outline: I should see passcode overlay after login on custom backend with enabled encryption on rest + Given There is a team owner "" with team "" + Given User is me + When I login to the default email verified backend as + Then I am signed in properly + + Examples: + | TeamOwner | TeamName | + | user1Name | hoffman | + + @TC-4999 @SF.IOS-VSNFDAREA @TSFI.UserInterface @S0.1 @col1 @Security + Scenario Outline: I should not be able to copy/paste messages when clipboard is disabled on build time with CLIPBOARD_ENABLED=0 + Given There is a team owner "" with team "" + And User adds user to team with role Member + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}], "": [{"name": ""}]} + And User has conversation with in team + And User is me + When I login to the default email verified backend as + Then I am signed in properly + When User sends 1 default message to conversation + When I open conversation "" in conversation list + Then I see at least one message in the conversation view + When I long tap default message in conversation view + Then I do not see Copy on edit menu + When I tap on Cancel on edit menu + When I long tap on text input + Then I do not see Paste on edit menu + When I load clipboard content from string "" + And I long tap on text input + Then I do not see Paste on edit menu + And I do not see Share on edit menu + When I type the default message and send it + And I long tap default message in conversation view + Then I do not see Copy on edit menu + When I tap on Edit on edit menu + And I tap on text input + And I tap on Select All on edit menu + Then I do not see Copy on edit menu + And I do not see Share on edit menu + But I tap Cancel button on Edit control + + Examples: + | Member1 | TeamOwner | TeamName | DeviceMember1 | GroupChatName | DeviceTeamOwner | + | user1Name | user2Name | BestTeam | device1 | Group | device2 | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Search.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Search.feature new file mode 100644 index 00000000000..78645f57759 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Search.feature @@ -0,0 +1,130 @@ +Feature: Search + + @TC-5001 @SF.Usersearch @TSFI.UserInterface @S0.1 @col1 @Security + Scenario Outline: Local: I should not find a user from another team through full text search, if they have SearchableByOwnTeam enabled + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-1 backend + And User is me + And User sets the unique username + And TeamOwner "" sets the search behaviour for SearchVisibilityInbound to SearchableByOwnTeam for team + When I login to the default email verified backend as + Then I am signed in properly + And I open search screen + When I type "" in Search UI input field + Then I do not see contact in Search UI + When I type first 5 letters of user name "" into cleared Search UI input field + Then I do not see contact in Search UI + When I type "@" in cleared Search UI input field + Then I see contact in Search UI + + Examples: + | UserA | UserB | TeamNameA | TeamNameB | UserBUniqueUserName | + | user1Name | user2Name | Searcher | SearchEnabled | user2UniqueUsername | + + @TC-5002 @SF.Usersearch @TSFI.UserInterface @S0.1 @col1 @Security + Scenario Outline: Local: I should not find a user from another team by full text search, if my team has SearchVisibilityNoNameOutsideTeam enabled + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-1 backend + And User is me + And User sets the unique username + When I login to the default email verified backend as + Then I am signed in properly + When TeamOwner "" enables the search behaviour for TeamSearchVisibility for team + And TeamOwner "" sets the search behaviour for TeamSearchVisibility to SearchVisibilityNoNameOutsideTeam for team + And I open search screen + When I type "" in Search UI input field + Then I do not see contact in Search UI + When I type "@" in cleared Search UI input field + Then I see contact in Search UI + + Examples: + | UserA | UserB | TeamNameA | TeamNameB | UserBUniqueUserName | + | user1Name | user2Name | Searcher | SearchEnabled | user2UniqueUsername | + + @TC-5003 @SF.Usersearch @TSFI.UserInterface @S0.1 @col1 @Security + Scenario Outline: Local: I should not find a user from another team by email + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-1 backend + And User is me + And User sets the unique username + When I login to the default email verified backend as + Then I am signed in properly + And I open search screen + When I search user by email in Search UI input field + Then I do not see contact in Search UI + + Examples: + | UserA | UserB | TeamNameA | TeamNameB | + | user1Name | user2Name | Searcher | SearchEnabled | + + @TC-5004 @SF.Usersearch @TSFI.UserInterface @S0.1 @S7 @col1 @Security + Scenario Outline: Remote: I should not find a user from another team on another backend by full text search, if my team has SearchVisibilityNoNameOutsideTeam enabled + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And User is me + And User sets the unique username + When I login to the default email verified backend as + Then I am signed in properly + When TeamOwner "" enables the search behaviour for TeamSearchVisibility for team + And TeamOwner "" sets the search behaviour for TeamSearchVisibility to SearchVisibilityNoNameOutsideTeam for team + And I open search screen + When I type "" in Search UI input field + Then I do not see contact in Search UI + When I clear Search UI input field + And I search user by handle and domain in Search UI input field + Then I see contact in Search UI + + Examples: + | UserA | UserB | TeamNameA | TeamNameB | + | user1Name | user2Name | Searcher | SearchEnabled | + + @TC-5005 @SF.Usersearch @TSFI.UserInterface @S0.1 @S7 @col1 @Security + Scenario Outline: Remote: I should not find a user from another backend by email + Given There is a team owner "" with team "" on column-1 backend + And There is a team owner "" with team "" on column-3 backend + And User is me + And User sets the unique username + When I login to the default email verified backend as + Then I am signed in properly + And I open search screen + When I search user by email in Search UI input field + Then I do not see contact in Search UI + + Examples: + | UserA | UserB | TeamNameA | TeamNameB | + | user1Name | user2Name | Searcher | SearchEnabled | + + ##@C1305584 @SF.Usersearch @TSFI.UserInterface @S0.1 @S7 @col1 + # #Scenario Outline: Remote: I should not find a user on another backend by full text if their FederatedUserSearchPolicy is exact_handle_search + + @TC-5007 @SF.Usersearch @TSFI.UserInterface @S0.1 @S7 @col1 @Security + Scenario Outline: Remote: I should not find a user on another backend by full text or handle if their FederatedUserSearchPolicy is no_search + Given There is a team owner "" with team "" on column-2 backend + And There is a team owner "" with team "" on column-1 backend + And The search policy is no_search with no team level restriction from column-1 backend to column-2 backend + And User is me + And User sets the unique username + And I open column-2 backend deep link in safari + And I enroll the simulator for Touch ID + And I tap Proceed button on backend redirection page + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I start verification email monitoring on mailbox + And I tap Not Now on save password alert + And I see email verification reminder + And I enter verification code from Email + And I see First Time overlay + And I accept First Time overlay + And I am signed in properly + And I perform successful Touch ID + And I open search screen + When I search user by handle and domain in Search UI input field + Then I do not see contact in Search UI + When I clear Search UI input field + And I type "" in Search UI input field + Then I do not see contact in Search UI + + Examples: + | UserA | UserB | UserAEmail | UserAPassword | TeamNameA | TeamNameB | + | user1Name | user2Name | user1Email | user1Password | Searcher | SearchEnabled | \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/SelfProfile.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/SelfProfile.feature new file mode 100644 index 00000000000..9b8f36b669e --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/SelfProfile.feature @@ -0,0 +1,82 @@ +Feature: Self Profile + + @TC-5008 @TC-5009 @TC-5010 @col1 + Scenario Outline: I should not be able to change my domain name when I change my username + Given There is a team owner "" with team "" on column-1 backend + And User is me + When I login to the default email verified backend as + Then I am signed in properly + When I open Self profile + And I open settings screen + And I select settings item Account + When I select settings item Username + Then I see domain name of user on Username UI + And I see domain name is not editable of user on Username UI + # I should not be able to add special characters in my display user name - TC-5009 + When I clear Username input field on Settings page + And I enter "" name on Unique Username page + Then I see Save button state is Disabled on Unique Username page + When I enter "!@I #$%^&*,(,),,+=;:?\\/,," name on Unique Username page + Then I see Save button state is Disabled on Unique Username page + # I want to see my domain and team name in my Profile - TC-5010 + When I tap Save button on Unique Username page + And I tap on the account back button + Then I see domain name of user on settings item Domain + And I see domain name on settings item is not editable + And I see unique username and domain of user is displayed on Settings Page + And I see team name as on settings item Team + And I see team name on settings item is not editable + And I verify the value of settings item Email equals to "" + + Examples: + | TeamOwner | TeamName | Email | + | user1Name | Stinky Pinky | user1Email | + + @TC-5011 @TC-5012 @col1 + Scenario Outline: I want to change my Email from settings + Given There is a team owner "" with team "" on column-1 backend + And User is me + When I login to the default email verified backend as + Then I am signed in properly + When I open Self profile + And I open settings screen + And I select settings item Account + And I select settings item Email + And I start activation email monitoring on mailbox + When I change email address to on Settings page + And I tap Save navigation button on Settings page + And I wait for 3 seconds + And I verify email address for Myself + And I wait until the UI detects successful email activation on Settings page + Then I verify the value of settings item Email equals to "" + And I verify user's Myself email on the backend is equal to + # TC-5012 - I want to change name from settings + When I tap Go back to Settings navigation button on Settings page + And I select settings item Account + And I select settings item Name + And I set "" value to Name input field on Settings page + And I tap Return button on the keyboard + And I tap X navigation button on Settings page + And I see conversations list + And I open Self profile + And I open settings screen + And I select settings item Account + Then I verify the value of settings item Name equals to "" + + Examples: + | TeamOwner | TeamName | NewEmail | NewUsername | + | user1Name | Stinky Pinky | user2Email | NewName | + + @TC-5013 @col1 + Scenario Outline: I want to change my profile picture from settings + Given There is a team owner "" with team "" on column-1 backend + When I login to the default email verified backend as + Then I am signed in properly + When I open Self profile + And I wait for 5 seconds + When I tap my picture preview on Self profile page + And I see Take Photo button on Camera page + + Examples: + | TeamOwner | TeamName | + | user1Name | Stinky Pinky | \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/TechnicalInfo.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/TechnicalInfo.feature new file mode 100644 index 00000000000..376055d445d --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/TechnicalInfo.feature @@ -0,0 +1,9 @@ +Feature: Technical Info + + # Note: WPB10813 only affects 3.113, so it isn't a known bug for 3.112 + @TC-8130 @col1 @Security @WPB10813 + Scenario: I can see my version details + Given There is a team owner "user1Name" with team "Team Name" + When I login to the default email verified backend as user1Name + And I open settings screen + And I open the Advanced Settings menu diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Upgrade.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Upgrade.feature new file mode 100644 index 00000000000..5493d3b37ee --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Column-Tests/Upgrade.feature @@ -0,0 +1,39 @@ +Feature: Upgrade + + @TC-5015 + Scenario Outline: I want to update from previous version to the current one (team acc) + Given The device is reset before and after the test + And I install the old version of Wire + And There is a team owner "" with team "" + And User adds users , to team with role Member + And User has conversation with , in team + And Users of team owned by adds the following 2FA devices: {"": [{"name": ""}]} + And User is me + And I open default backend via deep link in safari + And I wait for 3 seconds + And I accept alert + When User sends 1 default message to conversation + And User sends 1 image file to conversation + And I accept alert if visible + And I see conversations list + # To let the content to be synchronized + And I wait for 5 seconds + And I upgrade Wire to the recent version + And I restore Wire + And I accept alert if visible + And I perform successful Touch ID + And I am signed in properly + And I see conversations list + When I open conversation "" in conversation list + Then I see 1 photo in the conversation view + And I see 1 default message in the conversation view + When I type the default message and send it + # This is to make the keyboard invisible + And I navigate back to conversations list + When I open conversation "" in conversation list + And I scroll to the bottom of the conversation + Then I see 2 default messages in the conversation view + + Examples: + | TeamOwner | TeamName | Member1 | Member2 | ConversationName | Picture | DeviceName | Email | Password | + | user1Name | TeamSmart | user2Name | user3Name | Upgrade Test | testing.jpg | device | user1Email | user1Password | \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/FileSending.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/FileSending.feature new file mode 100644 index 00000000000..69f7da00d6e --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/FileSending.feature @@ -0,0 +1,56 @@ +Feature: File Sending + + @flows @02 + Scenario Outline: Person submitting a file to an employee + # Team exists with at least 1 member + Given There is a team owner "" with team "" + # External guest has already been registered + And There is personal account user + And User adds user to team with role Member + # A document exists + And I create temporary file in size with name "" and extension "" + And User adds the following device: {"": [{"name": "Device1"}]} + And User has conversation with in team + And starts instance using + And User creates invite link for conversation + And User is me + And I sign in user with fast login + And I accept alert if visible + And I am signed in properly + When I minimize Wire + And I open invite link url for conversation created by user in safari + And I wait for 5 seconds + And I tap Join in the app button in Safari + And I tap Open button on the alert + And I tap OK button on the alert + And I type the "guten tag" message and send it + # Employee sends empty document to external guest + And User sends temporary file . having MIME type to group conversation using device Device1 + # Guest is able to download + And I long tap on file transfer placeholder in conversation view + And I tap on Download on edit menu + And I wait for 2 seconds + And I tap on file transfer placeholder in conversation view + # Guest sends filled document to the employee + And I tap Share button in file inspection page + And I tap More button on share extension + And I tap Wire in share extension + And I tap Choose in share extension + And I select conversation "" in share extension + And I tap Send button in share extension + And I tap Done button in file inspection page + Then I see 2 file transfer placeholder in the conversation view + And I type the "Here is my version" message and send it + # Employee is able to download the document (not implemented as other client) + And User sends message "Yeah this is not what we need" as reply to last message of conversation via device Device1 + And User sends 1 "let me call you to clarify" message to conversation + # Employee calls 1:1 + And calls + # Both are able to open camera + # Employee sends a few links + # Guest removes previous document message + # Guest send another document during call + + Examples: + | Member1 | TeamOwner | TeamName | CallBackend | Guest | ConversationTitle | FileSize | FileName | FileExt | FileMIME | + | user1Name | user3Name | SuperTeam | chrome | user2Name | Anmeldungdesk | 1204 KB | TestFile | pdf | application/pdf | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/Groups.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/Groups.feature new file mode 100644 index 00000000000..3a4e9569752 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/Groups.feature @@ -0,0 +1,171 @@ +Feature: Groups + + # Flow 2 - Team owner making an all team chat + @flows @WPB6540 + Scenario Outline: Team owner making an all team chat (contains bug) +# Pre-conditions + Given There is a team owner "" with team "" +# Team has at least 10 members + owner + And User adds user ,,,,,,,,, to team with role Member + And User is me + # Failling here - code authentication required? + And User adds the following device: {"": [{"name": ""}], "": [{"name": ""}]} +# Team owner logs in to their account + And I sign in user with fast login + And I accept alert if visible +# Team owner starts group creation flow + And I open search screen + And I open create group screen +# Team Owner checks the conversation options +# TeamOwner disallows guests + # When I expand conversation options on New Group page + Then I see Guests option on group creation view + And I see Services option on group creation view + And I verify the value of Allow Guests equals to "1" on New Group page + And I switch Allow Guests toggle on New Group page + And I verify the value of Allow Guests equals to "0" on New Group page +# TeamOwner names the conversation + And I enter group name "" on New Group page +# Team owner selects all team members + And I tap Next button on New Group page +# Team owner creates the conversation + And I type first 3 letters of name "" in search input field on Add People page + And I select search result item on Add People page + And I type first 3 letters of name "" in search input field on Add People page + And I select search result item on Add People page + And I type first 3 letters of name "" in search input field on Add People page + And I select search result item on Add People page + And I type first 3 letters of name "" in search input field on Add People page + And I select search result item on Add People page + And I type first 3 letters of name "" in search input field on Add People page + And I select search result item on Add People page + And I type first 3 letters of name "" in search input field on Add People page + And I select search result item on Add People page + And I type first 3 letters of name "" in search input field on Add People page + And I select search result item on Add People page + And I type first 3 letters of name "" in search input field on Add People page + And I select search result item on Add People page + And I type first 3 letters of name "" in search input field on Add People page + And I select search result item on Add People page + # Failing here due to request loop + And I type first 3 letters of name "" in search input field on Add People page + And I select search result item on Add People page +# Team owner exchanges messages + And I tap Create button on Add People page +# BUG Because guests are not allowed, none of the team members are actually added to the conversation?? +# https://wearezeta.atlassian.net/browse/WPB-6478 +# New member joining the team & logs in + And User adds user to team with role Member + And User adds the following device: {"": [{"name": "Device3"}]} +# Team owner invites new member to conversation + And I open group conversation details + And I see Add People button on Group Details page + When I tap Add People button on Group Details page + And I type search query "" on Group Add People page + And I select search result item on Group Add People page + And I tap Add Participants button on Group Add People page + And I tap X button on Group Details page + And I see "You added " system message in the conversation view +# Team owner sends welcome message with mention + When I type the "" message + When I tap Mention button from input tools + And I type first 2 letters of name "" in conversation input + And I tap in the suggested mentions list + And I tap Send Message button in conversation view + #TODO Uncomment once @WPB6540 fixed +# Mentions are not "accessible" accordingto appium... https://wearezeta.atlassian.net/browse/WPB-6540 +# Then I see the last message in the conversation view contains mentions + Then I see last message in the conversation view contains expected message +# New member responds + And User sends message "" as reply to last message of conversation via device Device3 + And I see last message in the conversation view is expected message + And I see 1 reply in the conversation view + + Examples: + | Member1 | TeamOwner | TeamName | ThankYouMessage | Member2 | ConversationTitle | Member3 | Member4 | Member5 | Member6 | Member7 | Member8 | Member9 | Member10 | Member11 | Message | + | user1Name | user3Name | SuperTeam | Thank you! Hello everyone | user2Name | Official | user4Name | user5Name | user6Name | user7Name | user8Name | user9Name | user10Name | user11Name | user12Name | Welcome to our new team member | + + + @flows @07 + Scenario Outline: Enterprise User hosts a planning group + #Users A is an enterprise user + Given I allow camera access + And I allow microphone access + And There is a team owner "" with team "" + And TeamOwner "" waits and enables conference calling feature for team via backdoor + And User adds user , to team with role Member + #User A has group with UserD & UserE + And User has conversation with , in team + #Users FreeB, FreeC are free users + And There are personal account users , + And User is Me + And Users adds the following devices: {"Myself": [{"name": ""}]} + And User is connected to , + And User is Me + #User A creates a group with another User B. + When I tap Login button on Welcome page + When I login + And I accept First Time overlay + And I accept alert if visible + And I open search screen + And I open create group screen + And I enter group name "" on New Group page + And I tap Next button on New Group page + And I select search result item on Group Add People page + And I select search result item on Group Add People page + And I select search result item on Group Add People page + And I tap Done button on Invite People page + #User A makes User B an admin + When I open group conversation details + And I select participant on Group Details page + And I tap Admin toggle on Group participant profile page + And I tap Back button on Group participant profile page + #User A adds User C to group + And I tap Add People button on Group Details page + And I select search result item on Add People page + And I tap Add Participants button on Group Add People page + And I tap X button on Group Details page + #User C sends a message to group + Given User sends 1 default messages to conversation + #User B reads the message + And User marks the recent message as read in conversation via device + #User A is unable to see that User B read the message + Then I do not see the delivery status in message toolbox + #User A pings the group + And I tap ellipsis button from input tools + And I tap Ping button from input tools + And I accept alert + #User A reacts to User C's message + When I long tap default message in conversation view + And I tap on ❤️ reaction in quick reactions + # User D member sends a link to the group chat + And User sends link preview for "https://www.wire.com/" to conversation + #User A sends a drawing + When I tap Sketch button on Picture Preview page + And I draw a random sketch + And I tap Send button on Sketch page + #User A sends a location + And I tap ellipsis button from input tools + And I tap Share Location button from input tools + And I tap Send location button from map view + #User A sends a voice message + And I tap Audio Message button from input tools + And I accept microphone access alert on real device + And I tap Start Recording button on Voice Filters overlay + And I wait for 1 second + And I tap Stop Recording button on Voice Filters overlay + When I tap random effect buttons on Voice Filters overlay + And I tap Confirm button on Voice Filters overlay + #UserA starts an audio call + And I accept alert if visible + And starts instance using chrome + And accepts next incoming call automatically + And I tap Audio Call button + And I tap call button on start call alert + #User A hangs up + And I tap Leave button on Calling overlay + Then I see conversation view page + + Examples: + | UserA | FreeB | FreeC | UserD | UserE | GroupName | DeviceName | TeamName | GiphyTag | ButtonsCount | + | user1Name | user2Name | user3Name | user4Name | user5Name | GroupName | device | TeamName | gif | 4 | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/NewDevice.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/NewDevice.feature new file mode 100644 index 00000000000..18d39744c28 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/NewDevice.feature @@ -0,0 +1,87 @@ +Feature: New Device + + # TODO - add stuff around the device verification and removing device to finish if deemed needed + # TODO - Fix the done button in webview + # TODO - failing due to not cleaning up simulators, fix incoming + @unstable + #Flow 4 - Employee is setting up new device after old device lost + Scenario Outline: Employee setting up new device after loosing old device + #Employee is part of a team, with at least one other member + Given There is a team owner "" with team "" + And User adds user , to team with role Member + And User is me + And User Myself has 1:1 conversation with in team + #Employee has multiple devices + #(TBD: timing out) And User adds the following device: {"": [{"name": "Device1"}]} and might not be necessary + #Employee logs in to Wire and records current device thumbprint + And I tap Login button on Welcome page + And I sign in user with email + And I accept First Time overlay + And I accept alert if visible + And I open settings screen + And I select settings item Devices + And I save the device id of the current device + And I tap Go back to Settings navigation button on Settings page + And I tap X navigation button on Settings page + #Employee sends a message to the other member + And I open conversation "" in conversation list + And I tap on text input + When I type the message + And I tap Send Message button in conversation view + #Employee makes a backup + And I navigate back to conversations list + And I open settings screen + And I select settings item Account + And I select settings item Back Up Conversations + When I initiate history backup from Settings + And I type password "" on Backup password overlay + And I tap Next button on Backup password overlay + And I tap Save to Files button on File Saving Popup + When I tap Save button on File Saving Popup + When I tap Go back to Account navigation button on Settings page + #Reset device + And I reset Wire + And I wait for 2 seconds + And I open default backend via deep link in safari + And I tap Proceed button on backend redirection page + And I tap Login button on Welcome page + And I sign in user with email + #Employee imports backup + When I tap Restore from backup button on First Time overlay + And I tap Choose Backup File button on the alert + And I tap Browse button twice on bottom of File Choose Dialog + And I tap On My iPhone on File Choose Dialog + And I tap file containing user1UniqueUsername in File Choose Dialog + And I type "" text into the alert input field + # wait for backup to import + And I wait for 5 seconds + And I accept alert + And I accept alert if visible + #Employee can see the message to the other member and send another + And I open conversation "" in conversation list + And I see last message in the conversation view is expected message + And I tap on text input + When I type the message + And I tap Send Message button in conversation view + Then I see last message in the conversation view is expected message + When I navigate back to conversations list + # Employee resets their password, but not really because hard to do via this + And I open settings screen + And I select settings item Account + #TODO: Figure out why done button isn't being clicked + #And I select settings item Reset Password + #Then I see "Change Password" web page + #When I tap Done Button on web view + When I tap Go back to Settings navigation button on Settings page + # Employee removes lost device + And I select settings item Devices + And I open my remembered device + And I tap Remove Device button on Device Details page + And I confirm with my the deletion of the device on Settings page + Then I see 1 device is shown on Settings page + # Employee verifies other device + # TBD and maybe not necessary + + Examples: + | Member1 | TeamOwner | TeamName | Member2 | BackupPassword | Text | Text2 | + | user1Name | user3Name | SuperTeam | user2Name | Aqa123456! | Hi | Text | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/NewPersonOnboarding.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/NewPersonOnboarding.feature new file mode 100644 index 00000000000..d306618ee1b --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/NewPersonOnboarding.feature @@ -0,0 +1,68 @@ +Feature: New Person Onboarding + + @flows @01 @TC-8583 + Scenario Outline: New Employee Onboarding 0 +# PreReqs +# Team exists +# New employee invited to team +# Team has group conversation + Given There is a team owner "" with team "" + And User adds user to team with role Member + And User adds user to team with role Member and without unique username + And User adds the following device: {"": [{"name": ""}]} + And User is me + And User has conversation with in team + And I see Welcome page + And I tap Login button on Welcome page +# # New employee logs in + And I enter login on Login page + And I enter password on Login page + And I tap Login button on Login page + And I accept First Time overlay + # New employee sets up their profile: name, picture, read receipts, color + And I set the username to + #And I wait for 3 seconds + And I accept alert if visible + And I open settings screen + And I select settings item Account + And I select settings item Picture + When I tap Choose from library button on change profile pop up + And I select a picture from Camera Roll + And I tap Confirm button on Picture preview page + And I wait for 3 seconds + And I select settings item Color + And I select color Purple on Profile Color page + And I tap on the account back button + And I toggle send read receipts on account page + And I select settings item Name + And I set "" value to Name input field on Settings page + And I tap Return button on the keyboard + And I tap on the settings back button + And I tap Conversations button in bottom navigation bar + # Search and contact team lead + And I open search screen + And I type "" in Search UI input field + And I tap on conversation in search result + And I tap Start Conversation button on Single user profile page + And I type the "Hey there, Everything is set up!" message and send it + And I wait for 5 seconds + And User marks the recent message as read in conversation via device + And User sends message "Cool! Welcome to our Wire Team! We will send you the Wifi password" as reply to last message of conversation via device +# Team lead sends invite link to the team conversation + And User sends ephemeral message "password" with timer 10 seconds to user + And I see last message in the conversation view is expected message password +# Team lead sends invite link to the team conversation + And User creates invite link for conversation + And User sends invite link for conversation message to conversation +# New employee able to follow link + And I tap at 50% of width and 50% of height of the recent message + And I tap Join in the app button in Safari + And I tap Open button on the alert + And I tap OK button on the alert + And I see conversation view page + And I open group conversation details + And I see conversation name "" on Group Details page + + Examples: + | Member1 | TeamOwner | TeamName | Member2 | ConversationTitle | Member2Email | Password | Member2UniqueUsername | Device | NewName | + | user1Name | user3Name | SuperTeam | user2Name | The Official Chat | user2Email | user2Password | user2UniqueUsername | device1 | My name without typo | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/Statuses.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/Statuses.feature new file mode 100644 index 00000000000..3e2889bad50 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/Statuses.feature @@ -0,0 +1,61 @@ +Feature: Statuses + + @flows @03 + Scenario Outline: Enterprise user goes for lunch break followed by focus time + #Enterprise Users A, B exist + Given I allow camera access + And I allow microphone access + And There is a team owner "" with team "" + And TeamOwner "" enables conference calling feature for team via backdoor + And User adds users to team with role Member + #There is a group conversation between A & B + And User has conversation with in team + And User is me + And starts instance using chrome + #1. User A logs in and doesn't set a status + When I tap Login button on Welcome page + And I login + And I accept First Time overlay + And I accept alert if visible + #2. User B sends a message in the group conversation and User A can follow the notification to the conversation and reply + Given User sends 1 "" messages to conversation + When I open group conversation "" in conversation list + And I see last message in the conversation view contains expected message + When I long tap "" message in conversation view + And I tap on Reply on edit menu + And I type the "Replying!" message and send it + Then I see 1 reply in the conversation view + When I navigate back to conversations list + # When I tap my profile name in conversation list + When I tap on my profile photo in conversation list + And I tap on set a status button on self profile page + And I tap status Away + And I tap Ok Button on Enterprise alert + And I tap on profile close button + #4. User B calls and User A does not get notification * if possible to test in automation, but also this would preferably be a lower level test anyways + When calls me + # We don't have a step for no notification and this might not be possible + # Also considered bad practice to check for the absence of something, should be tested at a lower level + #Then I do not see alert + #5. User A sets status to Busy and backgrounds app + When I restore Wire + When I tap on my profile photo in conversation list + And I tap on set a status button on self profile page + And I tap status Busy + And I tap Ok Button on Enterprise alert + And I tap on profile close button + And stops outgoing call to me + And I minimize Wire + And I restore Wire + #6. User B messages the group + Given User sends 1 "" messages to conversation + #7. User A does not get a notification * if possible to test in automation, but also this would preferably be a lower level test anyways + #TODO: We don't have a step for no notification and this might not be possible + And I open group conversation "" in conversation list + And I type the "I'll reply later" message and send it + #10. User A sets status to available + #11. User B sends a message to group + #12. User A sees message notification and clicks it to go to group + Examples: + | UserA | UserB | GroupName | TeamName | Message | + | user1Name | user2Name | GroupName | TeamName | you there? | \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/Upgrade.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/Upgrade.feature new file mode 100644 index 00000000000..e418702bcb4 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/Upgrade.feature @@ -0,0 +1,41 @@ +Feature: Upgrade + + @flows @06 + Scenario Outline: I want to update from previous version to the current one (team acc) + Given The device is reset before and after the test + And All other versions of Wire are uninstalled + And I install the old version of Wire + And There is a team owner "" with team "" + And User adds users , to team with role Member + And User has conversation with , in team + And Users adds the following devices: {"": [{"name": ""}]} + And User is me + When I login to Wire as + And I accept notification permission alert if visible + And I am signed in properly + And User sends 1 default message to conversation + And User sends 1 image file to conversation + And I accept alert if visible + And I see conversations list + # To let the content to be synchronized + And I wait for 5 seconds + And I upgrade Wire to the recent version + And I restore Wire + And I accept alert if visible + And I perform successful Touch ID + And I accept alert if visible + And I am signed in properly + And I see conversations list + When I open conversation "" in conversation list + Then I see 1 photo in the conversation view + And I see 1 default message in the conversation view + When I type the default message and send it + # This is to make the keyboard invisible + And I navigate back to conversations list + When I open conversation "" in conversation list + And I scroll to the bottom of the conversation + Then I see 2 default messages in the conversation view + + Examples: + | TeamOwner | TeamName | Member1 | Member2 | ConversationName | Picture | DeviceName | Member1Email | Password | + | user1Name | TeamSmart | user2Name | user3Name | Upgrade Test | testing.jpg | device | user2Email | user1Password | \ No newline at end of file diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/VideoCall.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/VideoCall.feature new file mode 100644 index 00000000000..9e0369f7e52 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Critical-Flows/VideoCall.feature @@ -0,0 +1,56 @@ +Feature: Video Calls + + @flows @05 + Scenario Outline: Team members attending stand up (Video call) + Given I allow camera access + And I allow microphone access + And There is a team owner "" with team "" + And TeamOwner "" waits and enables conference calling feature for team via backdoor + And User adds user , , ,, , to team with role Member + And User has conversation with ,, , , , in team + And User has conversation with ,, , in team + And ,,, , , starts instance using + And User is me + And ,,, ,, accepts next incoming call automatically + When I sign in user with fast login + And I accept alert if visible + And I open group conversation "" in conversation list + # Enabling calling needs to happen away from team creation to avoid iblis + And I tap Video Call button + And I tap call button on start call alert + # Note: Usually steps like "do not see..." should be avoided for time reasons, however if iblis is causing issues + # Then this step helps us diagnose the problem + Then I do not see Enterprise Upgrade alert + #And ,,,,, verifies that waiting instance status is changed to active in 40 seconds + And verifies that waiting instance status is changed to active in 60 seconds + #And User ,,,,, verifies to have 1 peer connection + And User verifies to have 1 peer connection +# App crashes upon next step of video switching on + When I tap Minimize button on Calling overlay + And I type the default message and send it + Then I see 1 default message in the conversation view + When I navigate back to conversations list + And I open group conversation "" in conversation list + And I type the default message and send it + Then I see 1 default message in the conversation view + #When User sends link preview for "https://www.wire.com/" to conversation + And I navigate back to conversations list + And I open group conversation "" in conversation list + # TODO: Getting connection refused on some of these test service calls. Fix after stabilizing flow + #Then I see link preview source is equal to https://www.wire.com/ + #When I tap on link preview in conversation view + #Then I see "https://www.wire.com/" web page opened + #When I launch Wire + #When User toggles reaction "👍🏼" on the recent message from group conversation + #Then I see 👍🏼 reaction in the conversation view + When I restore Calling overlay + And I see Video Calling overlay + #TODO: Test service picking up isn't reliable. This check honestly not necessary for flow anyways + #Then I see 7 videos in video grid + When I tap Leave button on Calling overlay + Then I do not see Calling overlay + #And I see link preview source is equal to https://www.wire.com/ + + Examples: + | Member1 | TeamOwner | TeamName | CallBackend | Member2 | ConversationTitle | Member3 | Member4 | Member5 | Member6 | ConversationTitle2 | + | user1Name | user3Name | SuperTeam | chrome | user2Name | conversation | user4Name | user5Name | user6Name | user7Name | EngineeringTeam | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/DeleteMessage.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/DeleteMessage.feature new file mode 100644 index 00000000000..36afc09c2d9 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/DeleteMessage.feature @@ -0,0 +1,25 @@ +Feature: Delete Message + + @TC-5746 @regression @rc @smoke @deletemessage + Scenario Outline: I want to verify that deleting is synchronised across own devices when they are online + Given There are 3 users where is me + And Users add the following devices: {"Myself": [{"name": ""}], "": [{"name": ""}]} + And User Myself is connected to , + And User Myself has group conversation with , + And I sign in user with fast login + And I accept alert if visible + And I am signed in properly + And User Myself sends 1 message using device to user + And User sends 1 message using device to group conversation + And I see conversations list + And I open conversation "" in conversation list + When User Myself deletes the recent message from user + Then I see 0 default messages in the conversation view + When I navigate back to conversations list + And I open group conversation "" in conversation list + And User Myself deletes the recent message from group conversation + Then I see 0 default messages in the conversation view + + Examples: + | Name | Contact1 | Contact2 | Device | ContactDevice | GroupChatName | + | user1Name | user2Name | user3Name | Device1 | Device2 | MyGroup | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/LinearGroupCreation.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/LinearGroupCreation.feature new file mode 100644 index 00000000000..ed860700290 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/LinearGroupCreation.feature @@ -0,0 +1,28 @@ +Feature: Linear Group Conversation + + @TC-5927 @TC-6459 @TC-5545 @regression @groupcreation @readReceipts @smoke @grouplimit @folders + Scenario Outline: Teams: I can create a group conversation with the linear flow + Given There is a team owner "" with team "" + And User adds users , to team with role Member + And User is me + And I sign in user with fast login + And I accept alert if visible + And I open search screen + And I open create group screen + And I see max participant limit on New Group page + And I enter group name "" on New Group page + # Then I verify the value of Read Receipts equals to on on New Group page + When I tap Next button on New Group page + And I type "" in search input field on Add People page + And I select search result item on Add People page + And I type "" in search input field on Add People page + And I select search result item on Add People page + And I tap Create button on Add People page + Then I see " " system message in the conversation view + And I navigate back to conversations list + And I see conversation in conversations list + + + Examples: + | TeamOwner | Member1 | Member2 | TeamName | GroupName | NewIntroductionMessage | DefaultConversationOptions | maxLimit | + | user1Name | user2Name | user3Name | Lollipop | FunFun | You started the conversation | Guests: On, Services: On, Read receipts: On | 500 | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Login.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Login.feature new file mode 100644 index 00000000000..429cab72e9b --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Login.feature @@ -0,0 +1,25 @@ +Feature: Log In + + @TC-6256 @rc @regression @login + Scenario Outline: I want to verify that device management screen is shown appears after registering 7 devices + Given There is 1 user where is me + And Users add the following devices: {"Myself": [{"name": "", "label": ""}, {"name": "", "label": ""}, {"name": "", "label": ""}, {"name": "", "label": ""}, {"name": "", "label": ""}, {"name": "", "label": ""}, {"name": "", "label": ""}]} + When I tap Login button on Welcome page + And I switch to Email Log In tab + And I enter login MyEmail on Login page + And I enter password MyPassword on Login page + And I tap Login button on Login page + And I accept First Time overlay + And I wait for 3 seconds + And I wait for 2 seconds + Then I see Manage Devices overlay + When I tap Manage Devices button on Devices Overlay + And I tap Delete for device + And I tap Delete button on Devices Overlay + And I type "myPassword" text into the alert input field + And I accept alert + Then I see conversations list + + Examples: + | Name | DeviceName1 | DeviceName2 | DeviceName3 | DeviceName4 | DeviceName5 | DeviceName6 | DeviceName7 | + | user1Name | Device1 | Device2 | Device3 | Device4 | Device5 | Device6 | Device7 | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Logout.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Logout.feature new file mode 100644 index 00000000000..3bb260eb57b --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Logout.feature @@ -0,0 +1,118 @@ +Feature: Log Out + + @TC-6086 @regression @logout + Scenario Outline: I want to logout + Given There is a team owner "" with team "MyTeam" + And I tap Login button on Welcome page + And I sign in user with email + And I accept First Time overlay + And I am signed in properly + And I open settings screen + And I select settings item Account + When I select settings item Log Out + And I type "" text into the alert input field + And I accept alert + Then I see Welcome page + + Examples: + | Name | Password | + | user1Name | user1Password | + + @TC-6087 @regression @regression @logout + Scenario Outline: I want to verify logging out from a team account when personal account is still logged in + Given There is a team owner "" with team "" + And User adds users , to team with role Member + And User has conversation with , in team + And There are personal account users , + And User is me + And User Myself is connected to + And I tap Login button on Welcome page + And I sign in user with email + And I accept First Time overlay + And I see conversation in conversations list + And I open Self profile + And User is me + And I tap Add Account button on Self profile page + And I tap Login button on Welcome page + And I sign in user with email + And I accept First Time overlay + And I am signed in properly + And I see conversation in conversations list + And I open settings screen + And I select settings item Account + When I select settings item Log Out + And I type "" text into the alert input field + And I accept alert + Then I see conversation in conversations list + + Examples: + | TeamOwner | TeamName | Member1 | Member2 | ConversationName | PersonalAccount | PersonalContact | Password | + | user1Name | SuperTeam | user2Name | user3Name | Team Convo | user4Name | user5Name | user2Password | + + @TC-6090 @regression @logout + Scenario Outline: I want to verify history is erased after logging out from the account + Given There are personal account users , + And User is me + And User Myself is connected to + And User adds the following device: {"": [{}]} + And I tap Login button on Welcome page + And I sign in user with email + And I accept First Time overlay + And I am signed in properly + And User sends 1 default message to conversation Myself + And User sends 1 image file to conversation Myself + And I see conversations list + And I open conversation "" in conversation list + And I see 1 photo in the conversation view + And I see 1 default message in the conversation view + And I navigate back to conversations list + And I open settings screen + And I select settings item Account + And I select settings item Log Out + And I type "" text into the alert input field + And I accept alert + And I see Welcome page + And I tap Login button on Welcome page + And I enter login MyEmail on Login page + And I enter password MyPassword on Login page + And I tap Login button on Login page + And I accept First Time overlay + And I am signed in properly + When I open conversation "" in conversation list + Then I see 0 default messages in the conversation view + And I see 0 photos in the conversation view + + Examples: + | Name | Contact | Picture | Password | + | user1Name | user2Name | testing.jpg | user1Password | + + @TC-6258 @regression @logout @TSFI.UserInterface @TSFI.RESTfulAPI @S0.1 @S2 + Scenario Outline: I want to verify the appropriate device is logged out if you remove it from settings + Given There is 1 user where is me + And I tap Login button on Welcome page + And I sign in user with email + And I accept First Time overlay + And I am signed in properly + And I see conversations list + When User Myself removes all their registered OTR clients + Then I see alert contains text "Your session expired" + When I accept alert + Then I see Welcome page + + Examples: + | Name | + | user1Name | + + @TC-6091 @regression @logout @TSFI.UserInterface @TSFI.RESTfulAPI @S0.1 @S2 + Scenario Outline: I want to verify immediately being logged out after being removed from team + Given There is a team owner "" with team "" + And User adds user to team with role Member + And User is me + And I sign in user with fast login + And I am signed in properly + When User removes user from team + Then I see alert title contains text "" + + Examples: + | TeamOwner | Member1 | TeamName | SessionTimeoutText | + | user1Name | user2Name | kickme | Your session expired | diff --git a/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Registration.feature b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Registration.feature new file mode 100644 index 00000000000..5d01b758c3a --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/com/wearezeta/auto/ios/Registration.feature @@ -0,0 +1,18 @@ +Feature: Registration + + @TC-6132 @regression @gdpr @rc @registration @smoke + Scenario Outline: I want to register new user using Email flow + When I tap Create An Account button on Welcome page + And I see registration screen + And I enter registration email "" + And I accept terms of service + And I enter activation code for the email address of + And I input name and commit it + And I set the password to "" + And I set the username to + And I accept alert if visible + Then I am signed in properly + + Examples: + | Name | Password | Email | UniqueUsername | + | user1Name | user1Password | user1Email | user1UniqueUsername | diff --git a/wire-ios-automation/ios/src/test/resources/junit-platform.properties b/wire-ios-automation/ios/src/test/resources/junit-platform.properties new file mode 100644 index 00000000000..416ec63b466 --- /dev/null +++ b/wire-ios-automation/ios/src/test/resources/junit-platform.properties @@ -0,0 +1,5 @@ +junit.jupiter.execution.parallel.enabled=true +junit.jupiter.execution.parallel.config.strategy=fixed +com.wire.qa.picklejar.features.package=com.wearezeta.auto.ios +com.wire.qa.picklejar.steps.packages=com.wearezeta.auto.ios +com.wire.qa.picklejar.engine.multiple-steps-matching-warning=false \ No newline at end of file diff --git a/wire-ios-automation/pom.xml b/wire-ios-automation/pom.xml new file mode 100644 index 00000000000..d1ad4a335ad --- /dev/null +++ b/wire-ios-automation/pom.xml @@ -0,0 +1,12 @@ + + 4.0.0 + + com.wearezeta.auto + auto-all + 1.0-SNAPSHOT + pom + + ios + + diff --git a/wire-ios-automation/tools/android/getLatestCand.sh b/wire-ios-automation/tools/android/getLatestCand.sh new file mode 100755 index 00000000000..125d687d8a2 --- /dev/null +++ b/wire-ios-automation/tools/android/getLatestCand.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +s3cmd ls s3://z-lohika/android/candidate/ | tail -n 1 | cut -d$' ' -f7 | xargs -I {} sh -c "s3cmd get --force {} ~/Downloads/cand.apk" \ No newline at end of file diff --git a/wire-ios-automation/tools/android/getLatestExp.sh b/wire-ios-automation/tools/android/getLatestExp.sh new file mode 100644 index 00000000000..ac670eeda44 --- /dev/null +++ b/wire-ios-automation/tools/android/getLatestExp.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +s3cmd ls s3://z-lohika/android/experimental/ | tail -n 1 | cut -d$' ' -f7 | xargs -I {} sh -c "s3cmd get --force {} ~/Downloads/exp.apk" \ No newline at end of file diff --git a/wire-ios-automation/tools/android/historian.py b/wire-ios-automation/tools/android/historian.py new file mode 100644 index 00000000000..9069682bb30 --- /dev/null +++ b/wire-ios-automation/tools/android/historian.py @@ -0,0 +1,1566 @@ +#!/usr/bin/python + +# Copyright 2014 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# TO USE: (see also usage() below) +# adb shell dumpsys batterystats --enable full-wake-history (post-KitKat only) +# adb shell dumpsys batterystats --reset +# optionally start monsoon/power monitor logging: +# if device/host clocks are not synced, run historian.py -v +# cts/tools/utils/monsoon.py --serialno 2294 --hz 1 --samples 100000 \ +# -timestamp | tee monsoon.out +# ...let device run a while... +# stop monsoon.py +# adb bugreport > bugreport.txt +# ./historian.py -p monsoon.out bugreport.txt + +import collections +import datetime +import fileinput +import getopt +import re +import StringIO +import subprocess +import sys +import time + +POWER_DATA_FILE_TIME_OFFSET = 0 # deal with any clock mismatch. +BLAME_CATEGORY = "wake_lock_in" # category to assign power blame to. +ROWS_TO_SUMMARIZE = ["wake_lock", "running"] # -s: summarize these rows + +getopt_debug = 0 +getopt_bill_extra_secs = 0 +getopt_power_quanta = 15 # slice monsoon data this many seconds, + # to avoid crashing visualizer +getopt_power_data_file = False +getopt_proc_name = "" +getopt_highlight_category = "" +getopt_show_all_wakelocks = False +getopt_sort_by_power = True +getopt_summarize_pct = -1 +getopt_report_filename = "" + +getopt_generate_chart_only = False +getopt_disable_chart_drawing = False + + +def usage(): + """Print usage of the script.""" + print "\nUsage: %s [OPTIONS] [FILE]\n" % sys.argv[0] + print " -a: show all wakelocks (don't abbreviate system wakelocks)" + print " -c: disable drawing of chart" + print " -d: debug mode, output debugging info for this program" + print (" -e TIME: extend billing an extra TIME seconds after each\n" + " wakelock, or until the next wakelock is seen. Useful for\n" + " accounting for modem power overhead.") + print " -h: print this message." + print (" -m: generate output that can be embedded in an existing page.\n" + " HTML header and body tags are not outputted.") + print (" -n [CATEGORY=]PROC: output another row containing only processes\n" + " whose name matches uid of PROC in CATEGORY.\n" + " If CATEGORY is not specified, search in wake_lock_in.") + print (" -p FILE: analyze FILE containing power data. Format per\n" + " line: ") + print (" -q TIME: quantize data on power row in buckets of TIME\n" + " seconds (default %d)" % getopt_power_quanta) + print " -r NAME: report input file name as NAME in HTML." + print (" -s PCT: summarize certain useful rows with additional rows\n" + " showing percent time spent over PCT% in each.") + print " -t: sort power report by wakelock duration instead of charge" + print " -v: synchronize device time before collecting power data" + print "\n" + sys.exit(1) + + +def parse_time(s, fmt): + if s == "0": return 0.0 + + p = re.compile(fmt) + match = p.search(s) + try: + d = match.groupdict() + except IndexError: + return -1.0 + + ret = 0.0 + if d["day"]: ret += float(d["day"])*60*60*24 + if d["hrs"]: ret += float(d["hrs"])*60*60 + if d["min"]: ret += float(d["min"])*60 + if d["sec"]: ret += float(d["sec"]) + if d["ms"]: ret += float(d["ms"])/1000 + return ret + + +def time_float_to_human(t, show_complete_time): + if show_complete_time: + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(t)) + else: + return time.strftime("%H:%M:%S", time.localtime(t)) + + +def abbrev_timestr(s): + """Chop milliseconds off of a time string, if present.""" + arr = s.split("s") + if len(arr) < 3: return "0s" + return arr[0]+"s" + + +def timestr_to_jsdate(timestr): + return "new Date(%s * 1000)" % timestr + + +def format_time(delta_time): + """Return a time string representing time past since initial event.""" + if not delta_time: + return str(0) + + timestr = "+" + datet = datetime.datetime.utcfromtimestamp(delta_time) + + if delta_time > 24 * 60 * 60: + timestr += str(datet.day - 1) + datet.strftime("d%Hh%Mm%Ss") + elif delta_time > 60 * 60: + timestr += datet.strftime("%Hh%Mm%Ss").lstrip("0") + elif delta_time > 60: + timestr += datet.strftime("%Mm%Ss").lstrip("0") + elif delta_time > 1: + timestr += datet.strftime("%Ss").lstrip("0") + ms = datet.microsecond / 1000.0 + timestr += "%03dms" % ms + return timestr + + +def format_duration(dur_ms): + """Return a time string representing the duration in human readable format.""" + if not dur_ms: + return "0ms" + + ms = dur_ms % 1000 + dur_ms = (dur_ms - ms) / 1000 + secs = dur_ms % 60 + dur_ms = (dur_ms - secs) / 60 + mins = dur_ms % 60 + hrs = (dur_ms - mins) / 60 + + out = "" + if hrs > 0: + out += "%dh" % hrs + if mins > 0: + out += "%dm" % mins + if secs > 0: + out += "%ds" % secs + if ms > 0 or not out: + out += "%dms" % ms + return out + + +def get_event_category(e): + e = e.lstrip("+-") + earr = e.split("=") + return earr[0] + + +def get_quoted_region(e): + e = e.split("\"")[1] + return e + + +def get_after_equal(e): + e = e.split("=")[1] + return e + + +def get_wifi_suppl_state(e): + try: + e = get_after_equal(e) + return e.split("(")[0] + except IndexError: + return "" + + +def get_event_subcat(cat, e): + """Get subcategory of an category from an event string. + + Subcategory can be use to distinguish simultaneous entities + within one category. To track possible concurrent instances, + add category name to concurrent_cat. Default is to track + events using only category name. + + Args: + cat: Category name + e: Event name + + Returns: + A string that is the subcategory of the event. Returns + the substring after category name if not empty and cat + is one of the categories tracked by concurrent_cat. + Default subcategory is the empty string. + """ + concurrent_cat = {"wake_lock_in", "sync", "top", "job", "conn"} + if cat in concurrent_cat: + try: + return get_after_equal(e) + except IndexError: + pass + return "" + + +def get_proc_pair(e): + if ":" in e: + proc_pair = get_after_equal(e) + return proc_pair.split(":", 1) + else: + return ("", "") + + +def as_to_mah(a): + return a * 1000 / 60 / 60 + + +def apply_fn_over_range(fn, start_time, end_time, arglist): + """Apply a given function per second quanta over a time range. + + Args: + fn: The function to apply + start_time: The starting time of the whole duration + end_time: The ending time of the whole duration + arglist: Additional argument list + + Returns: + A list of results generated by applying the function + over the time range. + """ + results = [] + cursor = start_time + + while cursor < end_time: + cursor_int = int(cursor) + next_cursor = float(cursor_int + 1) + if next_cursor > end_time: next_cursor = end_time + time_this_quanta = next_cursor - cursor + + results.append(fn(cursor_int, time_this_quanta, *arglist)) + + cursor = next_cursor + return results + + +def space_escape(match): + value = match.group() + p = re.compile(r"\s+") + return p.sub("_", value) + + +def parse_reset_time(line): + line = line.strip() + line = line.split("RESET:TIME: ", 1)[1] + st = time.strptime(line, "%Y-%m-%d-%H-%M-%S") + return time.mktime(st) + + +def is_file_legacy_mode(input_file): + """Autodetect legacy (K and earlier) format.""" + detection_on = False + for line in fileinput.input(input_file): + if not detection_on and line.startswith("Battery History"): + detection_on = True + if not detection_on: + continue + + split_line = line.split() + if not split_line: + continue + line_time = split_line[0] + if "+" not in line_time and "-" not in line_time: + continue + + fileinput.close() + return line_time[0] == "-" + return False + + +def is_emit_event(e): + return e[0] != "+" + + +def is_standalone_event(e): + return not (e[0] == "+" or e[0] == "-") + + +def is_proc_event(e): + return e.startswith("+proc") + + +def autovivify(): + """Returns a multidimensional dict.""" + return collections.defaultdict(autovivify) + + +def swap(swap_list, first, second): + swap_list[first], swap_list[second] = swap_list[second], swap_list[first] + + +def add_emit_event(emit_dict, cat, name, start, end): + newevent = (name, int(start), int(end)) + if end < start: + print "BUG: end time before start time: %s %s %s
" % (name, + start, + end) + else: + if getopt_debug: + print "Stored emitted event: %s
" % str(newevent) + + if cat in emit_dict: + emit_dict[cat].append(newevent) + else: + emit_dict[cat] = [newevent] + + +def sync_time(): + subprocess.call(["adb", "root"]) + subprocess.call(["sleep", "3"]) + start_time = int(time.time()) + while int(time.time()) == start_time: + pass + curr_time = time.strftime("%Y%m%d.%H%M%S", time.localtime()) + subprocess.call(["adb", "shell", "date", "-s", curr_time]) + sys.exit(0) + + +def parse_search_option(cmd): + global getopt_proc_name, getopt_highlight_category + if "=" in cmd: + getopt_highlight_category = cmd.split("=")[0] + getopt_proc_name = cmd.split("=")[1] + else: + getopt_highlight_category = "wake_lock_in" + getopt_proc_name = cmd + + +def parse_argv(): + """Parse argument and set up globals.""" + global getopt_debug, getopt_bill_extra_secs, getopt_power_quanta + global getopt_sort_by_power, getopt_power_data_file + global getopt_summarize_pct, getopt_show_all_wakelocks + global getopt_report_filename + global getopt_generate_chart_only + global getopt_disable_chart_drawing + + try: + opts, argv_rest = getopt.getopt(sys.argv[1:], + "acde:hmn:p:q:r:s:tv", ["help"]) + except getopt.GetoptError as err: + print "
\n"
+    print str(err)
+    usage()
+  try:
+    for o, a in opts:
+      if o == "-a": getopt_show_all_wakelocks = True
+      if o == "-c": getopt_disable_chart_drawing = True
+      if o == "-d": getopt_debug = True
+      if o == "-e": getopt_bill_extra_secs = int(a)
+      if o in ("-h", "--help"): usage()
+      if o == "-m": getopt_generate_chart_only = True
+      if o == "-n": parse_search_option(a)
+      if o == "-p": getopt_power_data_file = a
+      if o == "-q": getopt_power_quanta = int(a)
+      if o == "-r": getopt_report_filename = str(a)
+      if o == "-s": getopt_summarize_pct = int(a)
+      if o == "-t": getopt_sort_by_power = False
+      if o == "-v": sync_time()
+  except ValueError as err:
+    print str(err)
+    usage()
+
+  if not argv_rest:
+    usage()
+
+  return argv_rest
+
+
+class Printer(object):
+  """Organize and render the visualizer."""
+  _default_color = "#4070cf"
+
+  # -n option is represented by "highlight". All the other names specified
+  # in _print_setting are the same as category names.
+  _print_setting = [
+      ("battery_level", "#4070cf"),
+      ("plugged", "#2e8b57"),
+      ("screen", "#cbb69d"),
+      ("top", "#dc3912"),
+      ("sync", "#9900aa"),
+      ("wake_lock_pct", "#6fae11"),
+      ("wake_lock", "#cbb69d"),
+      ("highlight", "#4070cf"),
+      ("running_pct", "#6fae11"),
+      ("running", "#990099"),
+      ("wake_reason", "#b82e2e"),
+      ("wake_lock_in", "#ff33cc"),
+      ("job", "#cbb69d"),
+      ("mobile_radio", "#aa0000"),
+      ("data_conn", "#4070cf"),
+      ("conn", "#ff6a19"),
+      ("activepower", "#dd4477"),
+      ("device_idle", "#37ff64"),
+      ("motion", "#4070cf"),
+      ("active", "#119fc8"),
+      ("power_save", "#ff2222"),
+      ("wifi", "#119fc8"),
+      ("wifi_full_lock", "#888888"),
+      ("wifi_scan", "#888888"),
+      ("wifi_multicast", "#888888"),
+      ("wifi_radio", "#888888"),
+      ("wifi_running", "#109618"),
+      ("wifi_suppl", "#119fc8"),
+      ("wifi_signal_strength", "#9900aa"),
+      ("phone_signal_strength", "#dc3912"),
+      ("phone_scanning", "#dda0dd"),
+      ("audio", "#990099"),
+      ("phone_in_call", "#cbb69d"),
+      ("bluetooth", "#cbb69d"),
+      ("phone_state", "#dc3912"),
+      ("signal_strength", "#119fc8"),
+      ("video", "#cbb69d"),
+      ("flashlight", "#cbb69d"),
+      ("low_power", "#109618"),
+      ("fg", "#dda0dd"),
+      ("gps", "#ff9900"),
+      ("reboot", "#ddff77"),
+      ("power", "#ff2222"),
+      ("status", "#9ac658"),
+      ("health", "#888888"),
+      ("plug", "#888888"),
+      ("charging", "#888888"),
+      ("pkginst", "#cbb69d"),
+      ("pkgunin", "#cbb69d")]
+
+  _ignore_categories = ["user", "userfg"]
+
+  def __init__(self):
+    self._print_setting_cats = set()
+    for cat in self._print_setting:
+      self._print_setting_cats.add(cat[0])
+
+  def combine_wifi_states(self, event_list, start_time):
+    """Discard intermediate states and combine events chronologically."""
+    tracking_states = ["disconn", "completed", "disabled", "scanning"]
+    selected_event_list = []
+    for event in event_list:
+      state = get_wifi_suppl_state(event[0])
+      if state in tracking_states:
+        selected_event_list.append(event)
+
+    if len(selected_event_list) <= 1:
+      return set(selected_event_list)
+
+    event_name = "wifi_suppl="
+    for e in selected_event_list:
+      state = get_wifi_suppl_state(e[0])
+      event_name += (state + "->")
+    event_name = event_name[:-2]
+
+    sample_event = selected_event_list[0][0]
+    timestr_start = sample_event.find("(")
+    event_name += sample_event[timestr_start:]
+    return set([(event_name, start_time, start_time)])
+
+  def aggregate_events(self, emit_dict):
+    """Combine events with the same name occurring during the same second.
+
+    Aggregate events to keep visualization from being so noisy.
+
+    Args:
+      emit_dict: A dict containing events.
+
+    Returns:
+      A dict with repeated events happening within one sec removed.
+    """
+    output_dict = {}
+    for cat, events in emit_dict.iteritems():
+      output_dict[cat] = []
+      start_dict = {}
+      for event in events:
+        start_time = event[1]
+        if start_time in start_dict:
+          start_dict[start_time].append(event)
+        else:
+          start_dict[start_time] = [event]
+      for start_time, event_list in start_dict.iteritems():
+        if cat == "wifi_suppl":
+          event_set = self.combine_wifi_states(event_list, start_time)
+        else:
+          event_set = set(event_list)      # uniqify
+        for event in event_set:
+          output_dict[cat].append(event)
+    return output_dict
+
+  def print_emit_dict(self, cat, emit_dict):
+    for e in emit_dict[cat]:
+      if cat == "wake_lock":
+        cat_name = "wake_lock *"
+      else:
+        cat_name = cat
+      print "['%s', '%s', %s, %s]," % (cat_name, e[0],
+                                       timestr_to_jsdate(e[1]),
+                                       timestr_to_jsdate(e[2]))
+
+  def print_highlight_dict(self, highlight_dict):
+    catname = getopt_proc_name + " " + getopt_highlight_category
+    if getopt_highlight_category in highlight_dict:
+      for e in highlight_dict[getopt_highlight_category]:
+        print "['%s', '%s', %s, %s]," % (catname, e[0],
+                                         timestr_to_jsdate(e[1]),
+                                         timestr_to_jsdate(e[2]))
+
+  def print_events(self, emit_dict, highlight_dict):
+    """print category data in the order of _print_setting.
+
+    Args:
+      emit_dict: Major event dict.
+      highlight_dict: Additional event information for -n option.
+    """
+    emit_dict = self.aggregate_events(emit_dict)
+    highlight_dict = self.aggregate_events(highlight_dict)
+    cat_count = 0
+
+    for i in range(0, len(self._print_setting)):
+      cat = self._print_setting[i][0]
+      if cat in emit_dict:
+        self.print_emit_dict(cat, emit_dict)
+        cat_count += 1
+      if cat == "highlight":
+        self.print_highlight_dict(highlight_dict)
+
+    # handle category that is not included in _print_setting
+    if cat_count < len(emit_dict):
+      for cat in emit_dict:
+        if (cat not in self._print_setting_cats and
+            cat not in self._ignore_categories):
+          sys.stderr.write("event category not found: %s\n" % cat)
+          self.print_emit_dict(cat, emit_dict)
+
+  def print_chart_options(self, emit_dict, highlight_dict, width, height):
+    """Print Options provided to the visualizater."""
+    color_string = ""
+    cat_count = 0
+    # construct color string following the order of _print_setting
+    for i in range(0, len(self._print_setting)):
+      cat = self._print_setting[i][0]
+      if cat in emit_dict:
+        color_string += "'%s', " % self._print_setting[i][1]
+        cat_count += 1
+      if cat == "highlight" and highlight_dict:
+        color_string += "'%s', " % self._print_setting[i][1]
+        cat_count += 1
+
+      if cat_count % 4 == 0:
+        color_string += "\n\t"
+
+    # handle category that is not included in _print_setting
+    if cat_count < len(emit_dict):
+      for cat in emit_dict:
+        if cat not in self._print_setting_cats:
+          color_string += "'%s', " % self._default_color
+
+    print("\toptions = {\n"
+          "\ttimeline: { colorByRowLabel: true},\n"
+          "\t'width': %s,\n"
+          "\t'height': %s, \n"
+          "\tcolors: [%s]\n"
+          "\t};" % (width, height, color_string))
+
+
+class LegacyFormatConverter(object):
+  """Convert Kit-Kat bugreport format to latest format support."""
+  _TIME_FORMAT = (r"\-((?P\d+)d)?((?P\d+)h)?((?P\d+)m)?"
+                  r"((?P\d+)s)?((?P\d+)ms)?$")
+
+  def __init__(self):
+    self._end_time = 0
+    self._total_duration = 0
+
+  def parse_end_time(self, line):
+    line = line.strip()
+    try:
+      line = line.split("dumpstate: ", 1)[1]
+      st = time.strptime(line, "%Y-%m-%d %H:%M:%S")
+      self._end_time = time.mktime(st)
+    except IndexError:
+      pass
+
+  def get_timestr(self, line_time):
+    """Convert backward time string in Kit-Kat to forward time string."""
+    delta = self._total_duration - parse_time(line_time, self._TIME_FORMAT)
+    datet = datetime.datetime.utcfromtimestamp(delta)
+
+    if delta == 0:
+      return "0"
+
+    timestr = "+"
+    if delta > 24 * 60 * 60:
+      timestr += str(datet.day - 1) + datet.strftime("d%Hh%Mm%Ss")
+    elif delta > 60 * 60:
+      timestr += datet.strftime("%Hh%Mm%Ss").lstrip("0")
+    elif delta > 60:
+      timestr += datet.strftime("%Mm%Ss").lstrip("0")
+    elif delta > 1:
+      timestr += datet.strftime("%Ss").lstrip("0")
+
+    ms = datet.microsecond / 1000.0
+    timestr += "%03dms" % ms
+    return timestr
+
+  def get_header(self, line_time):
+    self._total_duration = parse_time(line_time, self._TIME_FORMAT)
+    start_time = self._end_time - self._total_duration
+    header = "Battery History\n"
+    header += "RESET:TIME: %s\n" % time.strftime("%Y-%m-%d-%H-%M-%S",
+                                                 time.localtime(start_time))
+    return header
+
+  def convert(self, input_file):
+    """Convert legacy format file into string that fits latest format."""
+    output_string = ""
+    history_start = False
+
+    for line in fileinput.input(input_file):
+      if "dumpstate:" in line:
+        self.parse_end_time(line)
+        if self._end_time:
+          break
+    fileinput.close()
+
+    if not self._end_time:
+      print "cannot find end time"
+      sys.exit(1)
+
+    for line in fileinput.input(input_file):
+      if not history_start and line.startswith("Battery History"):
+        history_start = True
+        continue
+      elif not history_start:
+        continue
+
+      if line.isspace(): break
+
+      line = line.strip()
+      arr = line.split()
+      if len(arr) < 4: continue
+
+      p = re.compile('"[^"]+"')
+      line = p.sub(space_escape, line)
+
+      split_line = line.split()
+      (line_time, line_battery_level, line_state) = split_line[:3]
+      line_events = split_line[3:]
+
+      if not self._total_duration:
+        output_string += self.get_header(line_time)
+      timestr = self.get_timestr(line_time)
+
+      event_string = " ".join(line_events)
+      newline = "%s _ %s %s %s\n" % (timestr, line_battery_level,
+                                     line_state, event_string)
+      output_string += newline
+
+    fileinput.close()
+    return output_string
+
+
+class BHEmitter(object):
+  """Process battery history section from bugreport.txt."""
+  _omit_cats = ["temp", "volt", "brightness", "sensor", "proc"]
+  # categories that have "+" and "-" events. If we see an event in these
+  # categories starting at time 0 without +/- sign, treat it as a "+" event.
+  _transitional_cats = ["plugged", "running", "wake_lock", "gps", "sensor",
+                        "phone_in_call", "mobile_radio", "phone_scanning",
+                        "proc", "fg", "top", "sync", "wifi", "wifi_full_lock",
+                        "wifi_scan", "wifi_multicast", "wifi_running", "conn",
+                        "bluetooth", "audio", "video", "wake_lock_in", "job",
+                        "device_idle", "wifi_radio"]
+  _in_progress_dict = autovivify()  # events that are currently in progress
+  _proc_dict = {}             # mapping of "proc" uid to human-readable name
+  _search_proc_id = -1        # proc id of the getopt_proc_name
+  match_list = []             # list of package names that match search string
+  cat_list = []               # BLAME_CATEGORY summary data
+
+  def store_event(self, cat, subcat, event_str, event_time, timestr):
+    self._in_progress_dict[cat][subcat] = (event_str, event_time, timestr)
+    if getopt_debug:
+      print "store_event: %s in %s/%s
" % (event_str, cat, subcat) + + def retrieve_event(self, cat, subcat): + """Pop event from in-progress event dict if match exists.""" + if cat in self._in_progress_dict: + try: + result = self._in_progress_dict[cat].pop(subcat) + if getopt_debug: + print "retrieve_event: found %s/%s
" % (cat, subcat) + return (True, result) + except KeyError: + pass + if getopt_debug: + print "retrieve_event: no match for event %s/%s
" % (cat, subcat) + return (False, False) + + def store_proc(self, e, highlight_dict): + proc_pair = get_after_equal(e) + (proc_id, proc_name) = proc_pair.split(":", 1) + self._proc_dict[proc_id] = proc_name # may overwrite + if getopt_proc_name and getopt_proc_name in proc_name and proc_id: + if proc_pair not in self.match_list: + self.match_list.append(proc_pair) + if self._search_proc_id == -1: + self._search_proc_id = proc_id + elif self._search_proc_id != proc_id: + if (proc_name[1:-1] == getopt_proc_name or + proc_name == getopt_proc_name): + # reinitialize + highlight_dict.clear() + # replace default match with complete match + self._search_proc_id = proc_id + swap(self.match_list, 0, -1) + + def procs_to_str(self): + l = sorted(self._proc_dict.items(), key=lambda x: x[0]) + result = "" + for i in l: + result += "%s: %s\n" % (i[0], i[1]) + return result + + def get_proc_name(self, proc_id): + if proc_id in self._proc_dict: + return self._proc_dict[proc_id] + else: + return "" + + def annotate_event_name(self, name): + if "*alarm*" in name: + try: + proc_pair = get_after_equal(name) + except IndexError: + return name + proc_id = proc_pair.split(":", 1)[0] + name = name + ":" + self.get_proc_name(proc_id) + if getopt_debug: + print "annotate_event_name: %s" % name + return name + + def abbreviate_event_name(self, name): + """Abbreviate location-related event name.""" + if not getopt_show_all_wakelocks: + if "wake_lock" in name: + if "LocationManagerService" in name or "NlpWakeLock" in name: + return "LOCATION" + if "UlrDispatching" in name: + return "LOCATION" + if "GCoreFlp" in name or "GeofencerStateMachine" in name: + return "LOCATION" + if "NlpCollectorWakeLock" in name or "WAKEUP_LOCATOR" in name: + return "LOCATION" + if "GCM" in name or "C2DM" in name: + return "GCM" + return name + + def process_wakelock_event_name(self, start_name, start_id, end_name, end_id): + start_name = self.process_event_name(start_name) + end_name = self.process_event_name(end_name) + event_name = "first=%s:%s, last=%s:%s" % (start_id, start_name, + end_id, end_name) + return event_name + + def process_event_timestr(self, start_timestr, end_timestr): + return "(%s-%s)" % (abbrev_timestr(start_timestr), + abbrev_timestr(end_timestr)) + + def process_event_name(self, event_name): + event_name = self.annotate_event_name(event_name) + event_name = self.abbreviate_event_name(event_name) + return event_name + + def track_event_parallelism_fn(self, start_time, time_this_quanta, time_dict): + if start_time in time_dict: + time_dict[start_time] += time_this_quanta + else: + time_dict[start_time] = time_this_quanta + if getopt_debug: + print "time_dict[%d] now %f added %f" % (start_time, + time_dict[start_time], + time_this_quanta) + + # track total amount of event time held per second quanta + def track_event_parallelism(self, start_time, end_time, time_dict): + apply_fn_over_range(self.track_event_parallelism_fn, + start_time, end_time, [time_dict]) + + def emit_event(self, cat, event_name, start_time, start_timestr, + end_event_name, end_time, end_timestr, + emit_dict, time_dict, highlight_dict): + (start_pid, start_pname) = get_proc_pair(event_name) + (end_pid, end_pname) = get_proc_pair(end_event_name) + + if cat == "wake_lock" and end_pname and end_pname != start_pname: + short_event_name = self.process_wakelock_event_name( + start_pname, start_pid, end_pname, end_pid) + else: + short_event_name = self.process_event_name(event_name) + event_name = short_event_name + self.process_event_timestr(start_timestr, + end_timestr) + + if getopt_highlight_category == cat: + if start_pid == self._search_proc_id or end_pid == self._search_proc_id: + add_emit_event(highlight_dict, cat, + event_name, start_time, end_time) + + if cat == BLAME_CATEGORY: + self.cat_list.append((short_event_name, start_time, end_time)) + + end_time += getopt_bill_extra_secs + self.track_event_parallelism(start_time, end_time, time_dict) + + if end_time - start_time < 1: + # HACK: visualizer library doesn't always render sub-second events + end_time += 1 + + add_emit_event(emit_dict, cat, event_name, start_time, end_time) + + def handle_event(self, event_time, time_str, event_str, + emit_dict, time_dict, highlight_dict): + """Handle an individual event. + + Args: + event_time: Event time + time_str: Event time as string + event_str: Event string + emit_dict: A dict tracking events to draw in the timeline, by row + time_dict: A dict tracking BLAME_CATEGORY duration, by seconds + highlight_dict: A separate event dict for -n option + """ + if getopt_debug: + print "

handle_event: %s at %s
" % (event_str, time_str) + + cat = get_event_category(event_str) + subcat = get_event_subcat(cat, event_str) + # events already in progress are treated as starting at time 0 + if (time_str == "0" and is_standalone_event(event_str) + and cat in self._transitional_cats): + event_str = "+" + event_str + if is_proc_event(event_str): self.store_proc(event_str, highlight_dict) + + if cat in self._omit_cats: return + + if not is_emit_event(event_str): + # "+" event, save it until we find a matching "-" + self.store_event(cat, subcat, event_str, event_time, time_str) + return + else: + # "-" or standalone event such as "wake_reason" + start_time = 0.0 + (found, event) = self.retrieve_event(cat, subcat) + if found: + (event_name, start_time, start_timestr) = event + else: + event_name = event_str + start_time = event_time + start_timestr = time_str + + # Events that were still going on at the time of reboot + # should be marked as ending at the time of reboot. + if event_str == "reboot": + self.emit_remaining_events(event_time, time_str, emit_dict, + time_dict, highlight_dict) + + self.emit_event(cat, event_name, start_time, start_timestr, + event_str, event_time, time_str, + emit_dict, time_dict, highlight_dict) + + def generate_summary_row(self, row_to_summarize, emit_dict, start_time, + end_time): + """Generate additional data row showing % time covered by another row.""" + + summarize_quanta = 60 + row_name = row_to_summarize + "_pct" + if row_to_summarize not in emit_dict: return + summarize_list = emit_dict[row_to_summarize] + seconds_dict = {} + + # Generate dict of seconds where the row to summarize is seen. + for i in summarize_list: + self.track_event_parallelism(i[1], i[2], seconds_dict) + + # Traverse entire range of time we care about and generate % events. + for summary_start_time in range(int(start_time), int(end_time), + summarize_quanta): + summary_end_time = summary_start_time + summarize_quanta + found_ctr = 0 + for second_cursor in range(summary_start_time, summary_end_time): + if second_cursor in seconds_dict: + found_ctr += 1 + + if found_ctr: + pct = int(found_ctr * 100 / summarize_quanta) + if pct > getopt_summarize_pct: + add_emit_event(emit_dict, row_name, "%s=%d" % (row_name, pct), + summary_start_time, summary_end_time) + + def generate_summary_rows(self, emit_dict, start_time, end_time): + if getopt_summarize_pct < 0: + return + + for i in ROWS_TO_SUMMARIZE: + self.generate_summary_row(i, emit_dict, start_time, end_time) + + def emit_remaining_events(self, end_time, end_timestr, emit_dict, time_dict, + highlight_dict): + for cat in self._in_progress_dict: + for subcat in self._in_progress_dict[cat]: + (event_name, s_time, s_timestr) = self._in_progress_dict[cat][subcat] + self.emit_event(cat, event_name, s_time, s_timestr, + event_name, end_time, end_timestr, + emit_dict, time_dict, highlight_dict) + + +class BlameSynopsis(object): + """Summary data of BLAME_CATEGORY instance used for power accounting.""" + + def __init__(self): + self.name = "" + self.mah = 0 + self.timestr = "" + self._duration_list = [] + + def add(self, name, duration, mah, t): + self.name = name + self._duration_list.append(duration) + self.mah += mah + if not self.timestr: + self.timestr = time_float_to_human(t, False) + + def get_count(self): + return len(self._duration_list) + + def get_median_duration(self): + return sorted(self._duration_list)[int(self.get_count() / 2)] + + def get_total_duration(self): + return sum(self._duration_list) + + def to_str(self, total_mah, show_power): + """Returns a summary string.""" + if total_mah: + pct = self.mah * 100 / total_mah + else: + pct = 0 + avg = self.get_total_duration() / self.get_count() + + ret = "" + if show_power: + ret += "%.3f mAh (%.1f%%), " % (self.mah, pct) + ret += "%3s events, " % str(self.get_count()) + ret += "%6.3fs total " % self.get_total_duration() + ret += "%6.3fs avg " % avg + ret += "%6.3fs median: " % self.get_median_duration() + ret += self.name + ret += " (first at %s)" % self.timestr + return ret + + +class PowerEmitter(object): + """Give power accounting and bill to wake lock.""" + + _total_amps = 0 + _total_top_amps = 0 + _line_ctr = 0 + _TOP_THRESH = .01 + + _quanta_amps = 0 + _start_secs = 0 + _power_dict = {} + _synopsis_dict = {} + + def __init__(self, cat_list): + self._cat_list = cat_list + + def get_range_power_fn(self, start_time, time_this_quanta, time_dict): + """Assign proportional share of blame. + + During any second, this event might have been held for + less than the second, and others might have been held during + that time. Here we try to assign the proportional share of the + blame. + + Args: + start_time: Starting time of this quanta + time_this_quanta: Duration of this quanta + time_dict: A dict tracking total time at different starting time + + Returns: + A proportional share of blame for the quanta. + """ + if start_time in self._power_dict: + total_time_held = time_dict[start_time] + multiplier = time_this_quanta / total_time_held + result = self._power_dict[start_time] * multiplier + if getopt_debug: + print("get_range_power: distance %f total time %f " + "base power %f, multiplier %f
" % + (time_this_quanta, total_time_held, + self._power_dict[start_time], multiplier)) + assert multiplier <= 1.0 + else: + if getopt_debug: + print "get_range_power: no power data available" + result = 0.0 + return result + + def get_range_power(self, start, end, time_dict): + power_results = apply_fn_over_range(self.get_range_power_fn, + start, end, [time_dict]) + result = 0.0 + for i in power_results: + result += i + return result + + def bill(self, time_dict): + for _, e in enumerate(self._cat_list): + (event_name, start_time, end_time) = e + if event_name in self._synopsis_dict: + sd = self._synopsis_dict[event_name] + else: + sd = BlameSynopsis() + + amps = self.get_range_power(start_time, + end_time + getopt_bill_extra_secs, + time_dict) + mah = as_to_mah(amps) + sd.add(event_name, end_time - start_time, mah, start_time) + if getopt_debug: + print "billed range %f %f at %fAs to %s
" % (start_time, end_time, + amps, event_name) + self._synopsis_dict[event_name] = sd + + def handle_line(self, secs, amps, emit_dict): + """Handle a power data file line.""" + self._line_ctr += 1 + + if not self._start_secs: + self._start_secs = secs + + self._quanta_amps += amps + self._total_amps += amps + + self._power_dict[secs] = amps + + if secs % getopt_power_quanta: + return + avg = self._quanta_amps / getopt_power_quanta + event_name = "%.3f As (%.3f A avg)" % (self._quanta_amps, avg) + add_emit_event(emit_dict, "power", event_name, self._start_secs, secs) + + if self._quanta_amps > self._TOP_THRESH * getopt_power_quanta: + self._total_top_amps += self._quanta_amps + add_emit_event(emit_dict, "activepower", event_name, + self._start_secs, secs) + + self._quanta_amps = 0 + self._start_secs = secs + + def report(self): + """Report bill of BLAME_CATEGORY.""" + mah = as_to_mah(self._total_amps) + report_power = self._line_ctr + + if report_power: + avg_ma = self._total_amps/self._line_ctr + print "

Total power: %.3f mAh, avg %.3f" % (mah, avg_ma) + top_mah = as_to_mah(self._total_top_amps) + print ("
Total power above awake " + "threshold (%.1fmA): %.3f mAh %.3f As" % (self._TOP_THRESH * 1000, + top_mah, + self._total_top_amps)) + print "
%d samples, %d min

" % (self._line_ctr, self._line_ctr / 60) + + if report_power and getopt_bill_extra_secs: + print("Power seen during each history event, including %d " + "seconds after each event:" % getopt_bill_extra_secs) + elif report_power: + print "Power seen during each history event:" + else: + print "Event summary:" + print "

"
+
+    report_list = []
+    total_mah = 0.0
+    total_count = 0
+    for _, v in self._synopsis_dict.iteritems():
+      total_mah += v.mah
+      total_count += v.get_count()
+      if getopt_sort_by_power and report_power:
+        sort_term = v.mah
+      else:
+        sort_term = v.get_total_duration()
+      report_list.append((sort_term, v.to_str(mah, report_power)))
+    report_list.sort(key=lambda tup: tup[0], reverse=True)
+    for i in report_list:
+      print i[1]
+    print "total: %.3f mAh, %d events" % (total_mah, total_count)
+    print "
\n" + + +def adjust_reboot_time(line, event_time): + # Line delta time is not reset after reboot, but wall time will + # be printed after reboot finishes. This function returns how much + # we are off and actual reboot event time. + line = line.strip() + line = line.split("TIME: ", 1)[1] + st = time.strptime(line, "%Y-%m-%d-%H-%M-%S") + wall_time = time.mktime(st) + return wall_time - event_time, wall_time + + +def get_app_id(uid): + """Returns the app ID from a string. + + Reverses and uses the methods defined in UserHandle.java to get + only the app ID. + + Args: + uid: a string representing the uid printed in the history output + + Returns: + An integer representing the specific app ID. + """ + abr_uid_re = re.compile(r"u(?P\d+)(?P[ias])(?P\d+)") + if not uid: + return 0 + if uid.isdigit(): + # 100000 is the range of uids allocated for a user. + return int(uid) % 100000 + if abr_uid_re.match(uid): + match = abr_uid_re.search(uid) + try: + d = match.groupdict() + if d["aidType"] == "i": # first isolated uid + return int(d["appId"]) + 99000 + if d["aidType"] == "a": # first application uid + return int(d["appId"]) + 10000 + return int(d["appId"]) # app id wasn't modified + except IndexError: + sys.stderr.write("Abbreviated app UID didn't match properly") + return uid + + +app_cpu_usage = {} + + +def save_app_cpu_usage(uid, cpu_time): + uid = get_app_id(uid) + if uid in app_cpu_usage: + app_cpu_usage[uid] += cpu_time + else: + app_cpu_usage[uid] = cpu_time + +# Constants defined in android.net.ConnectivityManager +conn_constants = { + "0": "TYPE_MOBILE", + "1": "TYPE_WIFI", + "2": "TYPE_MOBILE_MMS", + "3": "TYPE_MOBILE_SUPL", + "4": "TYPE_MOBILE_DUN", + "5": "TYPE_MOBILE_HIPRI", + "6": "TYPE_WIMAX", + "7": "TYPE_BLUETOOTH", + "8": "TYPE_DUMMY", + "9": "TYPE_ETHERNET", + "17": "TYPE_VPN", + } + + +def main(): + details_re = re.compile(r"^Details:\scpu=\d+u\+\d+s\s*(\((?P.*)\))?") + app_cpu_usage_re = re.compile( + r"(?P\S+)=(?P\d+)u\+(?P\d+)s") + proc_stat_re = re.compile((r"^/proc/stat=(?P-?\d+)\s+usr,\s+" + r"(?P-?\d+)\s+sys,\s+" + r"(?P-?\d+)\s+io,\s+" + r"(?P-?\d+)\s+irq,\s+" + r"(?P-?\d+)\s+sirq,\s+" + r"(?P-?\d+)\s+idle.*") + ) + + data_start_time = 0.0 + data_stop_time = 0 + data_stop_timestr = "" + + on_mode = False + time_offset = 0.0 + overflowed = False + reboot = False + prev_battery_level = -1 + bhemitter = BHEmitter() + emit_dict = {} # maps event categories to events + time_dict = {} # total event time held per second + highlight_dict = {} # search result for -n option + is_first_data_line = True + is_dumpsys_format = False + argv_remainder = parse_argv() + input_file = argv_remainder[0] + legacy_mode = is_file_legacy_mode(input_file) + proc_stat_summary = { + "usr": 0, + "sys": 0, + "io": 0, + "irq": 0, + "sirq": 0, + "idle": 0, + } + + if legacy_mode: + input_string = LegacyFormatConverter().convert(input_file) + input_file = StringIO.StringIO(input_string) + else: + input_file = open(input_file, "r") + + while True: + line = input_file.readline() + if not line: break + + if not on_mode and line.startswith("Battery History"): + on_mode = True + continue + elif not on_mode: + continue + + if line.isspace(): break + + line = line.strip() + + if "RESET:TIME: " in line: + data_start_time = parse_reset_time(line) + continue + if "OVERFLOW" in line: + overflowed = True + break + if "START" in line: + reboot = True + continue + if "TIME: " in line: + continue + + # escape spaces within quoted regions + p = re.compile('"[^"]+"') + line = p.sub(space_escape, line) + + if details_re.match(line): + match = details_re.search(line) + try: + d = match.groupdict() + if d["appCpu"]: + for app in d["appCpu"].split(", "): + app_match = app_cpu_usage_re.search(app) + try: + a = app_match.groupdict() + save_app_cpu_usage(a["uid"], + int(a["userTime"]) + int(a["sysTime"])) + except IndexError: + sys.stderr.write("App CPU usage line didn't match properly") + except IndexError: + sys.stderr.write("Details line didn't match properly") + + continue + elif proc_stat_re.match(line): + match = proc_stat_re.search(line) + try: + d = match.groupdict() + if d["usrTime"]: + proc_stat_summary["usr"] += int(d["usrTime"]) + if d["sysTime"]: + proc_stat_summary["sys"] += int(d["sysTime"]) + if d["ioTime"]: + proc_stat_summary["io"] += int(d["ioTime"]) + if d["irqTime"]: + proc_stat_summary["irq"] += int(d["irqTime"]) + if d["sirqTime"]: + proc_stat_summary["sirq"] += int(d["sirqTime"]) + if d["idleTime"]: + proc_stat_summary["idle"] += int(d["idleTime"]) + except IndexError: + sys.stderr.write("proc/stat line didn't match properly") + continue + + # pull apart input line by spaces + split_line = line.split() + if len(split_line) < 4: continue + (line_time, _, line_battery_level, fourth_field) = split_line[:4] + + # "bugreport" output has an extra hex field vs "dumpsys", detect here. + if is_first_data_line: + is_first_data_line = False + try: + int(fourth_field, 16) + except ValueError: + is_dumpsys_format = True + + if is_dumpsys_format: + line_events = split_line[3:] + else: + line_events = split_line[4:] + + fmt = (r"\+((?P\d+)d)?((?P\d+)h)?((?P\d+)m)?" + r"((?P\d+)s)?((?P\d+)ms)?$") + time_delta_s = parse_time(line_time, fmt) + time_offset + if time_delta_s < 0: + print "Warning: time went backwards: %s" % line + continue + + event_time = data_start_time + time_delta_s + if reboot and "TIME:" in line: + # adjust offset using wall time + offset, event_time = adjust_reboot_time(line, event_time) + if offset < 0: + print "Warning: time went backwards: %s" % line + continue + time_offset += offset + time_delta_s = event_time - data_start_time + reboot = False + line_events = {"reboot"} + + if line_battery_level != prev_battery_level: + # battery_level is not an actual event, it's on every line + if line_battery_level.isdigit(): + bhemitter.handle_event(event_time, format_time(time_delta_s), + "battery_level=" + line_battery_level, + emit_dict, time_dict, highlight_dict) + + for event in line_events: + # conn events need to be parsed in order to be useful + if event.startswith("conn"): + num, ev = get_after_equal(event).split(":") + if ev == "\"CONNECTED\"": + event = "+conn=" + else: + event = "-conn=" + + if num in conn_constants: + event += conn_constants[num] + else: + event += "UNKNOWN" + + bhemitter.handle_event(event_time, format_time(time_delta_s), event, + emit_dict, time_dict, highlight_dict) + + prev_battery_level = line_battery_level + data_stop_time = event_time + data_stop_timestr = format_time(time_delta_s) + + input_file.close() + if not on_mode: + print "Battery history not present in bugreport." + return + + bhemitter.emit_remaining_events(data_stop_time, data_stop_timestr, + emit_dict, time_dict, highlight_dict) + + bhemitter.generate_summary_rows(emit_dict, data_start_time, + data_stop_time) + + power_emitter = PowerEmitter(bhemitter.cat_list) + if getopt_power_data_file: + for line in fileinput.input(getopt_power_data_file): + + data = line.split(" ") + secs = float(data[0]) + POWER_DATA_FILE_TIME_OFFSET + amps = float(data[1]) + + power_emitter.handle_line(secs, amps, emit_dict) + + power_emitter.bill(time_dict) + + printer = Printer() + + if not getopt_generate_chart_only: + print "\n\n" + report_filename = argv_remainder[0] + if getopt_report_filename: + report_filename = getopt_report_filename + header = "Battery Historian analysis for %s" % report_filename + print "" + header + "" + if overflowed: + print ('Warning: History overflowed at %s, ' + 'many events may be missing.' % + time_float_to_human(data_stop_time, True)) + print "

" + header + "

" + + if legacy_mode: + print("

WARNING: legacy format detected; " + "history information is limited

\n") + + if not getopt_generate_chart_only: + print """ + + + """ + + print " + +""" + if not getopt_generate_chart_only: + print "\n\n" + + show_complete_time = False + if data_stop_time - data_start_time > 24 * 60 * 60: + show_complete_time = True + start_localtime = time_float_to_human(data_start_time, show_complete_time) + stop_localtime = time_float_to_human(data_stop_time, show_complete_time) + + print "
" + if not getopt_generate_chart_only: + print ("WARNING: Visualizer disabled. " + "If you see this message, download the HTML then open it.") + print "
" + print("

WARNING:\n" + "
*: wake_lock field only shows the first/last wakelock held \n" + "when the system is awake. For more detail, use wake_lock_in." + "
To enable full wakelock reporting (post-KitKat only) : \n" + "
adb shell dumpsys batterystats " + "--enable full-wake-history

") + + if getopt_proc_name: + if len(bhemitter.match_list) > 1: + print("

WARNING:\n" + "
Multiple match found on -n option %s" + "

    " % getopt_proc_name) + for match in bhemitter.match_list: + print "
  • %s
  • " % match + print ("
Showing search result for %s

" + % bhemitter.match_list[0].split(":", 1)[0]) + elif not bhemitter.match_list: + print("

WARNING:\n" + "
No match on -n option %s

" % getopt_proc_name) + + if not highlight_dict: + print ("Search - %s in %s - did not match any event" + % (getopt_proc_name, getopt_highlight_category)) + + print ("
(Local time %s - %s, %dm elapsed)
" + % (start_localtime, stop_localtime, + (data_stop_time-data_start_time) / 60)) + + print ("

\n" + "Zoom: " + "

\n" + "

\n") + + power_emitter.report() + + if app_cpu_usage: + print "App CPU usage:" + print "" + for (uid, use) in sorted(app_cpu_usage.items(), key=lambda x: -x[1]): + print "" % uid + print "" % format_duration(use) + print "
UIDDuration
%s%s
" + + print "
Proc/stat summary
    " + print "
  • Total User Time: %s
  • " % format_duration( + proc_stat_summary["usr"]) + print "
  • Total System Time: %s
  • " % format_duration( + proc_stat_summary["sys"]) + print "
  • Total IO Time: %s
  • " % format_duration( + proc_stat_summary["io"]) + print "
  • Total Irq Time: %s
  • " % format_duration( + proc_stat_summary["irq"]) + print "
  • Total Soft Irq Time: %s
  • " % format_duration( + proc_stat_summary["sirq"]) + print "
  • Total Idle Time: %s
  • " % format_duration( + proc_stat_summary["idle"]) + print "
" + + print "
Process table:"
+  print bhemitter.procs_to_str()
+  print "
\n" + + if not getopt_generate_chart_only: + print "\n" + + +if __name__ == "__main__": + main() diff --git a/wire-ios-automation/tools/android/testing-gallery-qa-release.apk b/wire-ios-automation/tools/android/testing-gallery-qa-release.apk new file mode 100644 index 0000000000000000000000000000000000000000..7f924fc4a4feb3ee423f3cfe6fb676ce3d6c4507 GIT binary patch literal 8541478 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 z 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
    gK(|GY@1evk2Qb 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
    c*zS2O#*H>I<0k<=l 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%0AxJ!NDHH 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

    dRR|QvE7?}F4Qt$IM)715Wl4@H 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 T#B>uE^pgu7S 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