From 7c01d33a28272f61f04028531c8794526f85348d Mon Sep 17 00:00:00 2001 From: Mg <50769329+glitchminer@users.noreply.github.com> Date: Mon, 8 Sep 2025 17:51:02 +0100 Subject: [PATCH 1/8] test(e2e_appium): locators --- test/e2e_appium/locators/app_locators.py | 40 +++++++++++++++ .../onboarding/analytics_screen_locators.py | 22 +++----- .../onboarding/backup_import_locators.py | 11 ++++ .../create_profile_screen_locators.py | 26 ++++------ .../locators/onboarding/home_locators.py | 19 +++++++ .../onboarding/loading_screen_locators.py | 29 +++++------ .../onboarding/login_page_locators.py | 26 ++++++++++ .../locators/onboarding/main_app_locators.py | 18 ++----- .../onboarding/onboarding_locators.py | 12 ++--- .../onboarding/password_screen_locators.py | 28 +++++----- .../onboarding/seed_phrase_input_locators.py | 44 +++++----------- .../locators/onboarding/wallet/__init__.py | 5 ++ .../onboarding/wallet/wallet_locators.py | 21 ++++++++ .../welcome_back_screen_locators.py | 51 +++++++++++++++++++ .../onboarding/welcome_screen_locators.py | 18 ++----- .../locators/settings/backup_seed_locators.py | 43 ++++++++++++++++ .../locators/settings/settings_locators.py | 23 +++++++++ 17 files changed, 310 insertions(+), 126 deletions(-) create mode 100644 test/e2e_appium/locators/app_locators.py create mode 100644 test/e2e_appium/locators/onboarding/backup_import_locators.py create mode 100644 test/e2e_appium/locators/onboarding/home_locators.py create mode 100644 test/e2e_appium/locators/onboarding/login_page_locators.py create mode 100644 test/e2e_appium/locators/onboarding/wallet/__init__.py create mode 100644 test/e2e_appium/locators/onboarding/wallet/wallet_locators.py create mode 100644 test/e2e_appium/locators/onboarding/welcome_back_screen_locators.py create mode 100644 test/e2e_appium/locators/settings/backup_seed_locators.py create mode 100644 test/e2e_appium/locators/settings/settings_locators.py diff --git a/test/e2e_appium/locators/app_locators.py b/test/e2e_appium/locators/app_locators.py new file mode 100644 index 00000000000..84810f89e37 --- /dev/null +++ b/test/e2e_appium/locators/app_locators.py @@ -0,0 +1,40 @@ +from .base_locators import BaseLocators + + +class AppLocators(BaseLocators): + # Left primary navigation (visible when not on Home) + LEFT_NAV_ANY = BaseLocators.xpath("//*[contains(@resource-id, '-navbar')]") + + LEFT_NAV_HOME = BaseLocators.xpath( + "//*[contains(@resource-id, 'Home Page-navbar')]" + ) + LEFT_NAV_WALLET = BaseLocators.xpath("//*[contains(@resource-id, 'Wallet-navbar')]") + LEFT_NAV_MARKET = BaseLocators.xpath("//*[contains(@resource-id, 'Market-navbar')]") + LEFT_NAV_MESSAGES = BaseLocators.xpath( + "//*[contains(@resource-id, 'Messages-navbar')]" + ) + LEFT_NAV_COMMUNITIES = BaseLocators.xpath( + "//*[contains(@resource-id, 'Communities Portal-navbar')]" + ) + LEFT_NAV_SETTINGS = BaseLocators.xpath( + "//*[contains(@resource-id, 'Settings-navbar')]" + ) + + # Home dock (visible only on Home) + HOME_DOCK_CONTAINER = BaseLocators.xpath( + "//*[contains(@resource-id, 'homeContainer.homeDock')]" + ) + DOCK_WALLET = BaseLocators.accessibility_id("Wallet") + DOCK_MESSAGES = BaseLocators.accessibility_id("Messages") + DOCK_COMMUNITIES = BaseLocators.accessibility_id("Communities Portal") + DOCK_MARKET = BaseLocators.accessibility_id("Market") + DOCK_SETTINGS = BaseLocators.accessibility_id("Settings") + + # Fallback: Settings tile on the shell grid (visible on Home) + HOME_GRID_SETTINGS = BaseLocators.xpath( + "//*[contains(@resource-id, 'homeContainer.homeGrid')]" + ) + + # Toast notifications + TOAST_MESSAGE = BaseLocators.id("QGuiApplication.mainWindow.statusToastMessage") + ANY_TOAST = BaseLocators.xpath("//*[contains(@resource-id, 'statusToastMessage')]") diff --git a/test/e2e_appium/locators/onboarding/analytics_screen_locators.py b/test/e2e_appium/locators/onboarding/analytics_screen_locators.py index 3a228e0f52d..5be01a00973 100644 --- a/test/e2e_appium/locators/onboarding/analytics_screen_locators.py +++ b/test/e2e_appium/locators/onboarding/analytics_screen_locators.py @@ -1,21 +1,13 @@ -""" -Analytics Locators for Status Desktop E2E Testing - -Element locators for the analytics consent screen. -""" - from ..base_locators import BaseLocators - class AnalyticsScreenLocators(BaseLocators): - """Locators for the Help Us Improve Status screen (analytics consent)""" - - # Screen identification - stable content-desc - ANALYTICS_PAGE_BY_CONTENT_DESC = BaseLocators.accessibility_id("Help us improve Status") - # Primary buttons - using stable content-desc + ANALYTICS_PAGE_BY_CONTENT_DESC = BaseLocators.accessibility_id( + "Help us improve Status" + ) SHARE_USAGE_DATA_BUTTON = BaseLocators.accessibility_id("Share usage data") - NOT_NOW_BUTTON = BaseLocators.accessibility_id("Not now") + NOT_NOW_BUTTON = BaseLocators.content_desc_contains("[tid:btnDontShare]") - # Container locator - stable without QMLTYPE - ONBOARDING_CONTAINER = BaseLocators.id("QGuiApplication.mainWindow.startupOnboardingLayout") + ONBOARDING_CONTAINER = BaseLocators.id( + "QGuiApplication.mainWindow.startupOnboardingLayout" + ) diff --git a/test/e2e_appium/locators/onboarding/backup_import_locators.py b/test/e2e_appium/locators/onboarding/backup_import_locators.py new file mode 100644 index 00000000000..cdb57633f60 --- /dev/null +++ b/test/e2e_appium/locators/onboarding/backup_import_locators.py @@ -0,0 +1,11 @@ +from ..base_locators import BaseLocators + + +class BackupImportLocators(BaseLocators): + + BACKUP_IMPORT_SCREEN = BaseLocators.accessibility_id("Import local backup") + IMPORT_FILE_BUTTON = BaseLocators.accessibility_id("Import from file...") + SKIP_IMPORT_BUTTON = BaseLocators.accessibility_id("Skip") + + IMPORT_FILE_BUTTON_ALT = BaseLocators.id("btnImportFile") + SKIP_IMPORT_BUTTON_ALT = BaseLocators.id("btnSkipImport") diff --git a/test/e2e_appium/locators/onboarding/create_profile_screen_locators.py b/test/e2e_appium/locators/onboarding/create_profile_screen_locators.py index 4eab5c355ea..fe844a1012a 100644 --- a/test/e2e_appium/locators/onboarding/create_profile_screen_locators.py +++ b/test/e2e_appium/locators/onboarding/create_profile_screen_locators.py @@ -1,28 +1,20 @@ -""" -Create Profile Locators for Status Desktop E2E Testing - -Element locators for the profile creation screen. -""" - from ..base_locators import BaseLocators class CreateProfileScreenLocators(BaseLocators): - """Locators for the Create Profile Screen during onboarding""" - # Screen identification - stable content-desc CREATE_PROFILE_SCREEN = BaseLocators.accessibility_id("Create profile") - - # Primary button - Let's go! (creates with password) LETS_GO_BUTTON = BaseLocators.accessibility_id("Let's go!") - - # Alternative buttons (for different profile creation methods) USE_RECOVERY_PHRASE_BUTTON = BaseLocators.accessibility_id("Use a recovery phrase") USE_KEYCARD_BUTTON = BaseLocators.accessibility_id("Use an empty Keycard") - # Container locators - stable without QMLTYPE - ONBOARDING_CONTAINER = BaseLocators.id("QGuiApplication.mainWindow.startupOnboardingLayout") + ONBOARDING_CONTAINER = BaseLocators.id( + "QGuiApplication.mainWindow.startupOnboardingLayout" + ) - # Partial resource-id locators (avoiding dynamic QMLTYPE numbers) - LETS_GO_BUTTON_BY_ID = BaseLocators.xpath("//*[contains(@resource-id, 'btnCreateWithPassword')]") - CREATE_PROFILE_PARTIAL = BaseLocators.xpath("//*[contains(@resource-id, 'CreateProfilePage')]") + LETS_GO_BUTTON_BY_ID = BaseLocators.xpath( + "//*[contains(@resource-id, 'btnCreateWithPassword')]" + ) + CREATE_PROFILE_PARTIAL = BaseLocators.xpath( + "//*[contains(@resource-id, 'CreateProfilePage')]" + ) diff --git a/test/e2e_appium/locators/onboarding/home_locators.py b/test/e2e_appium/locators/onboarding/home_locators.py new file mode 100644 index 00000000000..5b738b6b469 --- /dev/null +++ b/test/e2e_appium/locators/onboarding/home_locators.py @@ -0,0 +1,19 @@ +from ..base_locators import BaseLocators + + +class HomeLocators(BaseLocators): + + MAIN_LAYOUT = BaseLocators.xpath("//*[contains(@resource-id, 'StatusMainLayout')]") + HOME_CONTAINER = BaseLocators.xpath("//*[contains(@resource-id, 'homeContainer')]") + + WALLET_BUTTON = BaseLocators.accessibility_id("Wallet") + MESSAGES_BUTTON = BaseLocators.accessibility_id("Messages") + COMMUNITIES_BUTTON = BaseLocators.accessibility_id("Communities Portal") + MARKET_BUTTON = BaseLocators.accessibility_id("Market") + SETTINGS_BUTTON = BaseLocators.accessibility_id("Settings") + + SEARCH_FIELD = BaseLocators.accessibility_id( + "Jump to a community, chat, account or a dApp..." + ) + SHELL_GRID = BaseLocators.xpath("//*[contains(@resource-id, 'shellGrid')]") + PROFILE_BUTTON = BaseLocators.xpath("//*[contains(@resource-id, 'ProfileButton')]") diff --git a/test/e2e_appium/locators/onboarding/loading_screen_locators.py b/test/e2e_appium/locators/onboarding/loading_screen_locators.py index 095828ccacd..a2ba170c6a8 100644 --- a/test/e2e_appium/locators/onboarding/loading_screen_locators.py +++ b/test/e2e_appium/locators/onboarding/loading_screen_locators.py @@ -1,23 +1,22 @@ -""" -Loading Locators for Status Desktop E2E Testing - -Element locators for loading screens. -""" - from ..base_locators import BaseLocators class LoadingScreenLocators(BaseLocators): - """Locators for the Loading/Splash screen during onboarding""" - # Loading screen container - stable resource-id - SPLASH_SCREEN = BaseLocators.id("QGuiApplication.mainWindow.startupOnboardingLayout.OnboardingFlow_QMLTYPE_206.splashScreenV2") + # Loading screen container + SPLASH_SCREEN = BaseLocators.id( + "QGuiApplication.mainWindow.startupOnboardingLayout.ProfileCreationFlow_QMLTYPE_206.splashScreenV2" + ) - # Alternative using partial ID (avoiding dynamic QMLTYPE) - SPLASH_SCREEN_PARTIAL = BaseLocators.xpath("//*[contains(@resource-id, 'splashScreenV2')]") + # (avoiding dynamic QMLTYPE) + SPLASH_SCREEN_PARTIAL = BaseLocators.xpath( + "//*[contains(@resource-id, 'splashScreenV2')]" + ) - # Progress bar - avoiding dynamic QMLTYPE - PROGRESS_BAR = BaseLocators.xpath("//*[contains(@resource-id, 'StatusProgressBar')]") + PROGRESS_BAR = BaseLocators.xpath( + "//*[contains(@resource-id, 'StatusProgressBar')]" + ) - # Container locator - stable without QMLTYPE - ONBOARDING_CONTAINER = BaseLocators.id("QGuiApplication.mainWindow.startupOnboardingLayout") + ONBOARDING_CONTAINER = BaseLocators.id( + "QGuiApplication.mainWindow.startupOnboardingLayout" + ) diff --git a/test/e2e_appium/locators/onboarding/login_page_locators.py b/test/e2e_appium/locators/onboarding/login_page_locators.py new file mode 100644 index 00000000000..b393dc8155e --- /dev/null +++ b/test/e2e_appium/locators/onboarding/login_page_locators.py @@ -0,0 +1,26 @@ +from ..base_locators import BaseLocators + + +class LoginPageLocators(BaseLocators): + + # Screen identification + LOGIN_PAGE = BaseLocators.content_desc_contains("Log in") + + ENTER_RECOVERY_PHRASE_BUTTON = BaseLocators.accessibility_id( + "Enter recovery phrase" + ) + LOG_IN_BY_SYNCING_BUTTON = BaseLocators.accessibility_id("Log in by syncing") + LOG_IN_WITH_KEYCARD_BUTTON = BaseLocators.accessibility_id("Log in with Keycard") + + BACK_BUTTON = BaseLocators.id( + "QGuiApplication.mainWindow.startupOnboardingLayout.StatusBackButton_QMLTYPE_412_QML_2616" + ) + + ONBOARDING_FRAME = ( + BaseLocators.BY_XPATH, + "//*[contains(@resource-id, 'OnboardingFrame_QMLTYPE')]", + ) + BUTTON_FRAME = ( + BaseLocators.BY_XPATH, + "//*[contains(@resource-id, 'OnboardingButtonFrame_QMLTYPE')]", + ) diff --git a/test/e2e_appium/locators/onboarding/main_app_locators.py b/test/e2e_appium/locators/onboarding/main_app_locators.py index a2d37302dc6..3da9068e88a 100644 --- a/test/e2e_appium/locators/onboarding/main_app_locators.py +++ b/test/e2e_appium/locators/onboarding/main_app_locators.py @@ -1,32 +1,24 @@ -""" -Main App Locators for Status Desktop E2E Testing - -Element locators for the main application interface. -""" - from ..base_locators import BaseLocators class MainAppLocators(BaseLocators): - """Locators for the main Status Desktop application after onboarding""" - # Main layout - stable container (avoiding dynamic QMLTYPE) + # TODO: Ensure not used anywhere and remove (superceded by home_locators.py) + MAIN_LAYOUT = BaseLocators.xpath("//*[contains(@resource-id, 'StatusMainLayout')]") HOME_CONTAINER = BaseLocators.xpath("//*[contains(@resource-id, 'homeContainer')]") - # Main navigation dock buttons - using stable content-desc WALLET_BUTTON = BaseLocators.accessibility_id("Wallet") MESSAGES_BUTTON = BaseLocators.accessibility_id("Messages") COMMUNITIES_BUTTON = BaseLocators.accessibility_id("Communities Portal") MARKET_BUTTON = BaseLocators.accessibility_id("Market") SETTINGS_BUTTON = BaseLocators.accessibility_id("Settings") - # Search field - stable content-desc - SEARCH_FIELD = BaseLocators.accessibility_id("Jump to a community, chat, account or a dApp...") + SEARCH_FIELD = BaseLocators.accessibility_id( + "Jump to a community, chat, account or a dApp..." + ) - # Grid container - avoiding dynamic QMLTYPE SHELL_GRID = BaseLocators.xpath("//*[contains(@resource-id, 'shellGrid')]") - # Profile button (top right area) PROFILE_BUTTON = BaseLocators.xpath("//*[contains(@resource-id, 'ProfileButton')]") diff --git a/test/e2e_appium/locators/onboarding/onboarding_locators.py b/test/e2e_appium/locators/onboarding/onboarding_locators.py index cb3b3ff684c..3f211c43a1f 100644 --- a/test/e2e_appium/locators/onboarding/onboarding_locators.py +++ b/test/e2e_appium/locators/onboarding/onboarding_locators.py @@ -1,11 +1,10 @@ -""" -Onboarding screen locators for Status Desktop tablet E2E tests. -""" - from ..base_locators import BaseLocators class OnboardingLocators(BaseLocators): + + # TODO: Remove fallback locators and replace with accessibility_id/tid + # Welcome Screen Locators WELCOME_TEXT = BaseLocators.accessibility_id("Welcome to Status") WELCOME_TEXT_FALLBACK = BaseLocators.content_desc_contains("Welcome") @@ -55,25 +54,20 @@ class OnboardingLocators(BaseLocators): # Dynamic Locators @classmethod def get_step_screen(cls, step_name: str) -> tuple: - """Get screen locator for specific onboarding step""" return cls.accessibility_id(f"{step_name}_screen") @classmethod def get_input_field(cls, field_name: str) -> tuple: - """Get input field locator by name""" return cls.accessibility_id(f"{field_name}_input") @classmethod def get_error_message(cls, field_name: str) -> tuple: - """Get error message locator for specific field""" return cls.accessibility_id(f"{field_name}_error") @classmethod def get_button_by_text(cls, button_text: str) -> tuple: - """Get button locator by text""" return cls.button_with_text(button_text) @classmethod def get_screen_element(cls, element_name: str) -> tuple: - """Get any screen element by name""" return cls.accessibility_id(element_name) diff --git a/test/e2e_appium/locators/onboarding/password_screen_locators.py b/test/e2e_appium/locators/onboarding/password_screen_locators.py index 2ae7a9963a5..09b8367064e 100644 --- a/test/e2e_appium/locators/onboarding/password_screen_locators.py +++ b/test/e2e_appium/locators/onboarding/password_screen_locators.py @@ -1,27 +1,27 @@ -""" -Password Locators for Status Desktop E2E Testing - -Element locators for password creation and confirmation screens. -""" - from ..base_locators import BaseLocators class PasswordScreenLocators(BaseLocators): - """Locators for the Password Creation Screen during onboarding""" # Screen identification - stable content-desc PASSWORD_SCREEN = BaseLocators.accessibility_id("Create profile password") - # Password input fields - using partial resource-ids to distinguish them - # Both have content-desc="Type password" so we need to use resource-ids - PASSWORD_INPUT = BaseLocators.xpath("//*[contains(@resource-id, 'passwordViewNewPassword') and not(contains(@resource-id, 'Confirm'))]") - PASSWORD_CONFIRM_INPUT = BaseLocators.xpath("//*[contains(@resource-id, 'passwordViewNewPasswordConfirm')]") + # TODO: Replace fallbacks with accessibility_id/tid + + PASSWORD_INPUT = BaseLocators.xpath( + "//*[contains(@resource-id, 'passwordViewNewPassword') and not(contains(@resource-id, 'Confirm'))]" + ) + PASSWORD_CONFIRM_INPUT = BaseLocators.xpath( + "//*[contains(@resource-id, 'passwordViewNewPasswordConfirm')]" + ) # Password creation button - stable content-desc CONFIRM_PASSWORD_BUTTON = BaseLocators.accessibility_id("Confirm password") # Fallback using resource-id - CONFIRM_PASSWORD_BUTTON_BY_ID = BaseLocators.xpath("//*[contains(@resource-id, 'btnConfirmPassword')]") + CONFIRM_PASSWORD_BUTTON_BY_ID = BaseLocators.xpath( + "//*[contains(@resource-id, 'btnConfirmPassword')]" + ) - # Container locator - stable without QMLTYPE - ONBOARDING_CONTAINER = BaseLocators.id("QGuiApplication.mainWindow.startupOnboardingLayout") + ONBOARDING_CONTAINER = BaseLocators.id( + "QGuiApplication.mainWindow.startupOnboardingLayout" + ) diff --git a/test/e2e_appium/locators/onboarding/seed_phrase_input_locators.py b/test/e2e_appium/locators/onboarding/seed_phrase_input_locators.py index fc9a1b25596..27dde8de531 100644 --- a/test/e2e_appium/locators/onboarding/seed_phrase_input_locators.py +++ b/test/e2e_appium/locators/onboarding/seed_phrase_input_locators.py @@ -1,47 +1,31 @@ -""" -Seed Phrase Input Locators for Status Desktop E2E Testing - -Defines element locators for the seed phrase import screen. Uses stable -accessibility IDs where possible, plus alt variants for device differences. -""" - from ..base_locators import BaseLocators class SeedPhraseInputLocators(BaseLocators): - """Locators for the Seed Phrase Input screen""" - # Screen identification - SEED_PHRASE_INPUT_SCREEN = BaseLocators.accessibility_id("Seed phrase") + # TODO: Replace fallbacks and alts with accessibility_id/tid - # Tabs by word count (primary + alternative text variants) - TAB_12_WORDS_BUTTON = BaseLocators.accessibility_id("12 words") - TAB_12_WORDS_BUTTON_ALT = BaseLocators.accessibility_id("12-word") + SEED_PHRASE_INPUT_SCREEN_CREATE = BaseLocators.accessibility_id( + "Create profile using a recovery phrase" + ) + SEED_PHRASE_INPUT_SCREEN_LOGIN = BaseLocators.accessibility_id( + "Log in with your Status recovery phrase" + ) - TAB_18_WORDS_BUTTON = BaseLocators.accessibility_id("18 words") - TAB_18_WORDS_BUTTON_ALT = BaseLocators.accessibility_id("18-word") - - TAB_24_WORDS_BUTTON = BaseLocators.accessibility_id("24 words") - TAB_24_WORDS_BUTTON_ALT = BaseLocators.accessibility_id("24-word") + @staticmethod + def get_tab_locators(word_count: int) -> list: + base = str(word_count) + return [BaseLocators.accessibility_id(f"{base} word(s)")] - # Continue / Import actions (primary + alternative) CONTINUE_BUTTON = BaseLocators.accessibility_id("Continue") - CONTINUE_BUTTON_ALT = BaseLocators.accessibility_id("Continue import") - IMPORT_BUTTON = BaseLocators.accessibility_id("Import") IMPORT_BUTTON_ALT = BaseLocators.accessibility_id("Import seed phrase") - # Validation messages INVALID_SEED_TEXT = BaseLocators.accessibility_id("Invalid seed phrase") INVALID_SEED_TEXT_ALT = BaseLocators.accessibility_id("Seed phrase is invalid") - # Dynamic input fields – resolved via helper methods below @staticmethod def get_seed_word_input_field(position: int) -> tuple: - """Return locator for the given seed word input position (1..24).""" - return BaseLocators.accessibility_id(f"Word {position}") - - @staticmethod - def get_seed_word_input_field_alt(position: int) -> tuple: - """Alternative locator text for the given seed word input position.""" - return BaseLocators.accessibility_id(f"Seed word {position}") + return BaseLocators.xpath( + f"//*[contains(@resource-id, 'enterSeedPhraseInputField{position}')]" + ) diff --git a/test/e2e_appium/locators/onboarding/wallet/__init__.py b/test/e2e_appium/locators/onboarding/wallet/__init__.py new file mode 100644 index 00000000000..58f08a9eb86 --- /dev/null +++ b/test/e2e_appium/locators/onboarding/wallet/__init__.py @@ -0,0 +1,5 @@ +# Wallet locators package + +from .wallet_locators import WalletLocators + +__all__ = ["WalletLocators"] diff --git a/test/e2e_appium/locators/onboarding/wallet/wallet_locators.py b/test/e2e_appium/locators/onboarding/wallet/wallet_locators.py new file mode 100644 index 00000000000..425c58abe1a --- /dev/null +++ b/test/e2e_appium/locators/onboarding/wallet/wallet_locators.py @@ -0,0 +1,21 @@ +from ...base_locators import BaseLocators + +class WalletLocators(BaseLocators): + + WALLET_HEADER = BaseLocators.accessibility_id("Wallet") + ASSETS_TAB = BaseLocators.text_contains("Assets") + ACTIVITY_TAB = BaseLocators.text_contains("Activity") + + ACCOUNT_NAME_ANY = BaseLocators.xpath( + "//*[contains(@resource-id, 'Account') or contains(@text, 'Account')]" + ) + BALANCE_ANY = BaseLocators.xpath( + "//*[contains(@text, 'ETH') or contains(@text, 'USD')]" + ) + + SAVED_ADDRESSES_BUTTON = BaseLocators.xpath( + "//*[contains(@resource-id, 'savedAddressesBtn') or @content-desc='Saved addresses']" + ) + ADD_NEW_ADDRESS_BUTTON = BaseLocators.xpath( + "//*[contains(@resource-id, 'walletHeaderButton') or @content-desc='Add new address']" + ) diff --git a/test/e2e_appium/locators/onboarding/welcome_back_screen_locators.py b/test/e2e_appium/locators/onboarding/welcome_back_screen_locators.py new file mode 100644 index 00000000000..16687a2d4e4 --- /dev/null +++ b/test/e2e_appium/locators/onboarding/welcome_back_screen_locators.py @@ -0,0 +1,51 @@ +"""Welcome Back screen locators.""" + +from ..base_locators import BaseLocators + + +class WelcomeBackScreenLocators(BaseLocators): + """Locators for the Welcome Back screen (returning users).""" + + # TODO: Replace fallbacks with accessibility_id/tid + + # Screen identification + LOGIN_SCREEN = BaseLocators.xpath( + "//*[contains(@resource-id, 'LoginScreen_QMLTYPE')]" + ) + ONBOARDING_LAYOUT = BaseLocators.id( + "QGuiApplication.mainWindow.startupOnboardingLayout" + ) + + # User selection elements + USER_SELECTOR = BaseLocators.xpath( + "//*[contains(@resource-id, 'loginUserSelector')]" + ) + USER_SELECTOR_DELEGATE = BaseLocators.xpath( + "//*[contains(@resource-id, 'LoginUserSelectorDelegate_QMLTYPE')]" + ) + + # Password input elements + PASSWORD_BOX = BaseLocators.xpath("//*[contains(@resource-id, 'passwordBox')]") + PASSWORD_INPUT = BaseLocators.xpath( + "//*[contains(@resource-id, 'loginPasswordInput')]" + ) + PASSWORD_INPUT_BY_DESC = BaseLocators.content_desc_exact("Password") + + # Login action + LOGIN_BUTTON = BaseLocators.xpath("//*[contains(@resource-id, 'loginButton')]") + LOGIN_BUTTON_BY_DESC = BaseLocators.content_desc_exact("Log In") + + # Fallback locators for robustness + LOGIN_BUTTON_FALLBACKS = [ + BaseLocators.xpath("//*[contains(@resource-id, 'loginButton')]"), + BaseLocators.content_desc_exact("Log In"), + BaseLocators.text_exact("Log In"), + ] + + PASSWORD_INPUT_FALLBACKS = [ + BaseLocators.xpath("//*[contains(@resource-id, 'loginPasswordInput')]"), + BaseLocators.content_desc_exact("Password"), + BaseLocators.xpath( + "//android.widget.EditText[contains(@content-desc, 'Password')]" + ), + ] diff --git a/test/e2e_appium/locators/onboarding/welcome_screen_locators.py b/test/e2e_appium/locators/onboarding/welcome_screen_locators.py index ab20d38a6fc..e33d54e18de 100644 --- a/test/e2e_appium/locators/onboarding/welcome_screen_locators.py +++ b/test/e2e_appium/locators/onboarding/welcome_screen_locators.py @@ -1,21 +1,13 @@ -""" -Welcome Locators for Status Desktop E2E Testing - -Element locators for the welcome screen. -""" - from ..base_locators import BaseLocators - class WelcomeScreenLocators(BaseLocators): - """Locators for the Welcome screen""" - # Screen identification - stable content-desc + # Screen identification WELCOME_PAGE = BaseLocators.content_desc_contains("Welcome to Status") - # Primary buttons - using stable content-desc (QMLTYPE numbers are dynamic) - CREATE_PROFILE_BUTTON = BaseLocators.accessibility_id("Create profile") + CREATE_PROFILE_BUTTON = BaseLocators.content_desc_contains("[tid:btnCreateProfile]") LOGIN_BUTTON = BaseLocators.accessibility_id("Log in") - # Container locators - stable without QMLTYPE - ONBOARDING_LAYOUT = BaseLocators.id("QGuiApplication.mainWindow.startupOnboardingLayout") + ONBOARDING_LAYOUT = BaseLocators.xpath( + "//*[contains(@resource-id, 'startupOnboardingLayout')]" + ) diff --git a/test/e2e_appium/locators/settings/backup_seed_locators.py b/test/e2e_appium/locators/settings/backup_seed_locators.py new file mode 100644 index 00000000000..29c0064637e --- /dev/null +++ b/test/e2e_appium/locators/settings/backup_seed_locators.py @@ -0,0 +1,43 @@ +from ..base_locators import BaseLocators + + +class BackupSeedLocators(BaseLocators): + MODAL_ROOT = BaseLocators.xpath("//*[contains(@resource-id, 'BackupSeedModal')]") + + # Scroll container inside the modal (from XML: BackupSeedModal.StatusScrollView_QMLTYPE_*) + SCROLL_CONTAINER = BaseLocators.xpath( + "//*[contains(@resource-id,'BackupSeedModal') and contains(@resource-id,'StatusScrollView')]" + ) + + ACK_HAVE_PEN = BaseLocators.accessibility_id("I have a pen and paper") + ACK_WRITE_DOWN = BaseLocators.accessibility_id( + "I am ready to write down my recovery phrase" + ) + ACK_STORE_IT = BaseLocators.accessibility_id("I know where I’ll store it") + + # Reveal step container (helps scoping seed word extraction) + REVEAL_CONTAINER = BaseLocators.accessibility_id("Show recovery phrase") + + REVEAL_BUTTON = BaseLocators.content_desc_contains("[tid:btnReveal]") + SEED_WORD_INPUT_ANY = BaseLocators.id("seedWordInput") + # Test-mode TIDs: expose each word via Accessible.name with objectName seedWordText_ + SEED_WORD_TEXT_NODES = BaseLocators.content_desc_contains("[tid:seedWordText_") + + NEXT_BUTTON = BaseLocators.accessibility_id("I've backed up phrase") + CONTINUE_BUTTON = BaseLocators.accessibility_id("Continue") + DONE_BUTTON = BaseLocators.accessibility_id("Done") + DELETE_CHECKBOX = BaseLocators.accessibility_id( + "Permanently remove your recovery phrase from the Status app — you will not be able to view it again" + ) + # Confirm step container and inputs (single-screen, four-input UI) + CONFIRM_STEP_CONTAINER = BaseLocators.accessibility_id("Confirm recovery phrase") + CONFIRM_INPUTS_ANY = BaseLocators.xpath( + "//*[contains(@resource-id,'BackupSeedModal')]//*[contains(@resource-id,'seedInput_')]" + ) + FINAL_ACK_CHECKBOX = BaseLocators.accessibility_id( + "I acknowledge that Status will not be able to show me my recovery phrase again." + ) + COMPLETE_AND_DELETE_BUTTON = BaseLocators.accessibility_id( + "Complete & Delete My Recovery Phrase" + ) + NOT_NOW_BUTTON = BaseLocators.accessibility_id("Not Now") diff --git a/test/e2e_appium/locators/settings/settings_locators.py b/test/e2e_appium/locators/settings/settings_locators.py new file mode 100644 index 00000000000..a4ddc5a760d --- /dev/null +++ b/test/e2e_appium/locators/settings/settings_locators.py @@ -0,0 +1,23 @@ +from ..base_locators import BaseLocators + + +class SettingsLocators(BaseLocators): + + # TODO: Replace fallbacks with accessibility_id/tid + + LEFT_PANEL_CONTAINER = BaseLocators.xpath( + "//*[contains(@resource-id, 'Settings')] | //*[@content-desc='Settings']" + ) + + # SettingsList.qml sets objectName: model.subsection + "-MenuItem"; backUpSeed subsection is 19 + BACKUP_RECOVERY_MENU_ITEM = BaseLocators.content_desc_contains("[tid:19-MenuItem]") + + PROFILE_MENU_ITEM = BaseLocators.xpath("//*[contains(@resource-id,'0-MenuItem')]") + + SIGN_OUT_AND_QUIT = BaseLocators.text_contains("Sign out & Quit") + SIGN_OUT_AND_QUIT_ALT = BaseLocators.xpath( + "//*[contains(@content-desc, 'Sign out') and contains(@content-desc, 'Quit')] | //*[contains(@text, 'Sign out') and contains(@text, 'Quit')]" + ) + + CONFIRM_SIGN_OUT = BaseLocators.text_contains("Sign out") + CONFIRM_QUIT = BaseLocators.text_contains("Quit") From d2fd3fd026732de55529637d285d6d081dc57da5 Mon Sep 17 00:00:00 2001 From: Mg <50769329+glitchminer@users.noreply.github.com> Date: Tue, 9 Sep 2025 10:52:12 +0100 Subject: [PATCH 2/8] test(e2e_appium): pages --- test/e2e_appium/pages/__init__.py | 11 +- test/e2e_appium/pages/app.py | 151 +++++++++ test/e2e_appium/pages/base_page.py | 315 ++++++++++-------- test/e2e_appium/pages/onboarding/__init__.py | 7 +- .../pages/onboarding/create_profile_page.py | 28 +- test/e2e_appium/pages/onboarding/home_page.py | 30 ++ .../onboarding/seed_phrase_input_page.py | 272 +++------------ .../pages/settings/backup_seed_modal.py | 129 +++++++ .../pages/settings/settings_page.py | 52 +++ 9 files changed, 599 insertions(+), 396 deletions(-) create mode 100644 test/e2e_appium/pages/app.py create mode 100644 test/e2e_appium/pages/onboarding/home_page.py create mode 100644 test/e2e_appium/pages/settings/backup_seed_modal.py create mode 100644 test/e2e_appium/pages/settings/settings_page.py diff --git a/test/e2e_appium/pages/__init__.py b/test/e2e_appium/pages/__init__.py index 5e43ba576e9..f021e1ee37f 100644 --- a/test/e2e_appium/pages/__init__.py +++ b/test/e2e_appium/pages/__init__.py @@ -1,11 +1,9 @@ -""" -Pages package for Status Desktop tablet E2E tests. -Contains Page Object Model classes for different screens. -""" +"""Pages package for tablet E2E tests.""" from .base_page import BasePage +from .app import App from .onboarding import ( - MainAppPage, + HomePage, WelcomePage, AnalyticsPage, CreateProfilePage, @@ -15,7 +13,8 @@ __all__ = [ "BasePage", - "MainAppPage", + "HomePage", + "App", "WelcomePage", "AnalyticsPage", "CreateProfilePage", diff --git a/test/e2e_appium/pages/app.py b/test/e2e_appium/pages/app.py new file mode 100644 index 00000000000..369c9c52da1 --- /dev/null +++ b/test/e2e_appium/pages/app.py @@ -0,0 +1,151 @@ +from typing import Optional +import time + +from .base_page import BasePage +from locators.app_locators import AppLocators +from utils.screenshot import save_page_source +from utils.element_state_checker import ElementStateChecker + + +class App(BasePage): + + def __init__(self, driver): + super().__init__(driver) + self.locators = AppLocators() + + def has_left_nav(self, timeout: Optional[int] = 1) -> bool: + return self.is_element_visible(self.locators.LEFT_NAV_ANY, timeout=timeout) + + def is_ready(self, timeout: Optional[int] = None) -> bool: + return self.is_element_visible( + self.locators.LEFT_NAV_ANY, timeout=timeout + ) or self.is_element_visible(self.locators.HOME_DOCK_CONTAINER, timeout=timeout) + + def active_section(self) -> str: + """Return current section: home, messaging, wallet, communities, market, settings, unknown.""" + if self.has_left_nav(timeout=1): + mapping = { + "home": self.locators.LEFT_NAV_HOME, + "wallet": self.locators.LEFT_NAV_WALLET, + "market": self.locators.LEFT_NAV_MARKET, + "messaging": self.locators.LEFT_NAV_MESSAGES, + "communities": self.locators.LEFT_NAV_COMMUNITIES, + "settings": self.locators.LEFT_NAV_SETTINGS, + } + for name, locator in mapping.items(): + el = self.find_element_safe(locator, timeout=1) + if el is not None: + try: + checked = ElementStateChecker.is_checked(el) + if checked: + return name + except Exception: + pass + return "unknown" + if self.is_element_visible(self.locators.HOME_DOCK_CONTAINER, timeout=1): + return "home" + return "unknown" + + def navigate_to(self, section: str, timeout: int = 30) -> bool: + section = section.lower() + if self.has_left_nav(timeout=1): + target = { + "home": self.locators.LEFT_NAV_HOME, + "wallet": self.locators.LEFT_NAV_WALLET, + "market": self.locators.LEFT_NAV_MARKET, + "messaging": self.locators.LEFT_NAV_MESSAGES, + "messages": self.locators.LEFT_NAV_MESSAGES, + "communities": self.locators.LEFT_NAV_COMMUNITIES, + "settings": self.locators.LEFT_NAV_SETTINGS, + }.get(section) + if not target: + return False + return self.safe_click(target) + target = { + "home": self.locators.HOME_DOCK_CONTAINER, + "wallet": self.locators.DOCK_WALLET, + "market": self.locators.DOCK_MARKET, + "messaging": self.locators.DOCK_MESSAGES, + "messages": self.locators.DOCK_MESSAGES, + "communities": self.locators.DOCK_COMMUNITIES, + "settings": self.locators.DOCK_SETTINGS, + }.get(section) + if not target: + return False + if section == "home": + return self.is_element_visible( + self.locators.HOME_DOCK_CONTAINER, timeout=timeout + ) + return self.safe_click(target) + + # Convenience wrappers + def click_home(self) -> bool: + return self.navigate_to("home") + + def click_wallet(self) -> bool: + return self.navigate_to("wallet") + + def click_messages(self) -> bool: + return self.navigate_to("messaging") + + def click_communities(self) -> bool: + return self.navigate_to("communities") + + def click_market(self) -> bool: + return self.navigate_to("market") + + def click_settings(self) -> bool: + return self.navigate_to("settings", timeout=4, max_attempts=2) + + def click_settings_left_nav(self) -> bool: + return self.safe_click(self.locators.LEFT_NAV_SETTINGS, timeout=4, max_attempts=2) + + def is_toast_present(self, timeout: Optional[int] = 3) -> bool: + present = self.is_element_visible(self.locators.ANY_TOAST, timeout=timeout) + if not present: + return False + + try: + el = self.find_element_safe(self.locators.ANY_TOAST, timeout=1) + if el is not None: + text_value = ElementStateChecker.get_text_content(el) + try: + desc_value = el.get_attribute("content-desc") or "" + except Exception: + desc_value = "" + if text_value or desc_value: + self.logger.info( + f"Toast detected text='{text_value}' content-desc='{desc_value}'" + ) + except Exception as e: + self.logger.debug(f"Toast attribute read failed: {e}") + + try: + _ = save_page_source(self.driver, self._screenshots_dir, "toast") + except Exception as e: + self.logger.debug(f"Toast page source save failed: {e}") + + return True + + def get_toast_content_desc(self, timeout: Optional[int] = 3) -> Optional[str]: + """Return toast's content-desc, polling until non-empty or timeout.""" + try: + el = self.find_element_safe(self.locators.ANY_TOAST, timeout=timeout) + if el is None: + return None + + end = time.time() + (timeout or 0) + last_val: str = "" + while True: + try: + val = el.get_attribute("content-desc") or "" + if val: + return val + last_val = val + except Exception: + pass + if time.time() >= end: + return last_val or None + time.sleep(0.1) + except Exception: + return None diff --git a/test/e2e_appium/pages/base_page.py b/test/e2e_appium/pages/base_page.py index 9822da60b21..89128600dc6 100644 --- a/test/e2e_appium/pages/base_page.py +++ b/test/e2e_appium/pages/base_page.py @@ -8,13 +8,21 @@ from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC -from config import get_logger, log_element_action +from config import log_element_action from core import EnvironmentSwitcher +from utils.gestures import Gestures +from utils.screenshot import save_screenshot +from utils.app_lifecycle_manager import AppLifecycleManager +from utils.keyboard_manager import KeyboardManager +from utils.element_state_checker import ElementStateChecker class BasePage: def __init__(self, driver): self.driver = driver + self.gestures = Gestures(driver) + self.app_lifecycle = AppLifecycleManager(driver) + self.keyboard = KeyboardManager(driver) env_name = os.getenv("CURRENT_TEST_ENVIRONMENT", "lambdatest") try: @@ -22,8 +30,10 @@ def __init__(self, driver): env_config = switcher.switch_to(env_name) self.timeouts = env_config.timeouts element_wait_timeout = self.timeouts["element_wait"] + self._screenshots_dir = env_config.directories.get( + "screenshots", "screenshots" + ) except Exception: - # Use default timeouts if config unavailable self.timeouts = { "element_wait": 30, "element_click": 5, @@ -31,10 +41,19 @@ def __init__(self, driver): "default": 30, } element_wait_timeout = self.timeouts["element_wait"] + self._screenshots_dir = "screenshots" self.wait = WebDriverWait(driver, element_wait_timeout) - self.logger = logging.getLogger(self.__class__.__module__ + '.' + self.__class__.__name__) + self.logger = logging.getLogger( + self.__class__.__module__ + "." + self.__class__.__name__ + ) + + def take_screenshot(self, name: Optional[str] = None) -> Optional[str]: + try: + return save_screenshot(self.driver, self._screenshots_dir, name) + except Exception: + return None def _create_wait(self, timeout: Optional[int], config_key: str) -> WebDriverWait: """Create WebDriverWait with timeout from parameter or YAML config.""" @@ -46,14 +65,14 @@ def is_screen_displayed(self, timeout: Optional[int] = None): def find_element(self, locator, timeout: Optional[int] = None): """Find element with configurable timeout. - + Args: locator: Element locator tuple timeout: Override timeout (uses YAML element_wait config if None) - + Returns: WebElement instance - + Raises: TimeoutException: If element not found within timeout """ @@ -61,7 +80,7 @@ def find_element(self, locator, timeout: Optional[int] = None): locator_str = f"{locator[0]}: {locator[1]}" try: - wait = self._create_wait(timeout, "element_wait") + wait = self._create_wait(timeout, "element_find") element = wait.until(EC.presence_of_element_located(locator)) duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) log_element_action("find_element", locator_str, True, duration_ms) @@ -71,38 +90,12 @@ def find_element(self, locator, timeout: Optional[int] = None): log_element_action("find_element", locator_str, False, duration_ms) raise - def click_element(self, locator, timeout: Optional[int] = None): - """Click element with configurable timeout. - - Args: - locator: Element locator tuple - timeout: Override timeout (uses YAML element_click config if None) - - Returns: - bool: True if click successful, False otherwise - """ - start_time = datetime.now() - locator_str = f"{locator[0]}: {locator[1]}" - - try: - wait = self._create_wait(timeout, "element_click") - element = wait.until(EC.element_to_be_clickable(locator)) - element.click() - duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) - log_element_action("click_element", locator_str, True, duration_ms) - return True - except Exception: - duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) - log_element_action("click_element", locator_str, False, duration_ms) - return False - def is_element_visible( self, locator, fallback_locators: Optional[List[tuple]] = None, timeout: Optional[int] = None, ) -> bool: - """Check visibility for a locator, optionally trying fallbacks in order.""" locators_to_try: List[tuple] = [locator] if fallback_locators: locators_to_try.extend(fallback_locators) @@ -112,7 +105,7 @@ def is_element_visible( for loc in locators_to_try: try: - wait = self._create_wait(timeout, "element_wait") + wait = self._create_wait(timeout, "element_find") wait.until(EC.visibility_of_element_located(loc)) return True except Exception: @@ -149,12 +142,16 @@ def safe_click( self.logger.debug(f"Click attempt {attempts} failed for {loc}: {e}") if attempts >= max_attempts: break + self._wait_between_attempts() + from utils.exceptions import ElementInteractionError + message = ( f"Failed to click element after trying {len(locators_to_try)} locator(s) " f"with {max_attempts} attempt(s) each. Last locator: {locators_to_try[-1]}" ) self.logger.error(message) - raise RuntimeError(message) + self.take_screenshot(f"click_failure_{locators_to_try[0][1]}") + raise ElementInteractionError(message, str(locators_to_try[0]), "click") def safe_input(self, locator, text: str, timeout: Optional[int] = None) -> bool: """Qt-safe input by delegating to qt_safe_input with retries.""" @@ -166,21 +163,6 @@ def safe_input(self, locator, text: str, timeout: Optional[int] = None) -> bool: ) return False - def wait_for_element(self, locator, timeout: Optional[int] = None): - """Wait for element presence with configurable timeout. - - Args: - locator: Element locator tuple - timeout: Override timeout (uses YAML element_wait config if None) - """ - wait = self._create_wait(timeout, "element_wait") - - try: - return wait.until(EC.presence_of_element_located(locator)) - except Exception as e: - self.logger.error(f"Element not found within timeout: {locator}: {e}") - return None - def find_element_safe(self, locator, timeout: Optional[int] = None): """Find element and return None instead of raising on failure.""" try: @@ -190,89 +172,56 @@ def find_element_safe(self, locator, timeout: Optional[int] = None): return None def hide_keyboard(self) -> bool: - """Hide the virtual keyboard using multiple strategies""" - try: - # Strategy 1: Use Appium's built-in hide_keyboard method - try: - self.driver.hide_keyboard() - self.logger.info("Keyboard hidden successfully using hide_keyboard()") - return True - except Exception as e: - self.logger.debug(f"hide_keyboard() failed: {e}") - - # Strategy 2: Press back button (Android) - try: - self.driver.back() - self.logger.info("Keyboard hidden using back button") - return True - except Exception as e: - self.logger.debug(f"Back button failed: {e}") - - # Strategy 3: Swipe down gesture - try: - size = self.driver.get_window_size() - # Swipe from middle-top to middle-bottom - start_x = size["width"] // 2 - start_y = size["height"] // 3 - end_y = size["height"] * 2 // 3 - - self.driver.swipe(start_x, start_y, start_x, end_y, 500) - self.logger.info("Keyboard hidden using swipe gesture") - return True - except Exception as e: - self.logger.debug(f"Swipe gesture failed: {e}") - - self.logger.warning("All keyboard hiding strategies failed") - return False - - except Exception as e: - self.logger.error(f"Error hiding keyboard: {e}") - return False + return self.keyboard.hide_keyboard() def ensure_element_visible(self, locator, timeout=10) -> bool: - """Ensure element is visible, hide keyboard if it's blocking""" - try: - # First check if element is already visible - if self.is_element_visible(locator, timeout=2): - return True - - # Try hiding keyboard and check again - self.logger.info("Element not visible, attempting to hide keyboard") - if self.hide_keyboard(): - time.sleep(1) # Wait for keyboard animation - return self.is_element_visible(locator, timeout=timeout) - - return False + return self.keyboard.ensure_element_visible( + locator, self.is_element_visible, timeout + ) - except Exception as e: - self.logger.error(f"Error ensuring element visibility: {e}") - return False + def qt_safe_input( + self, + locator, + text: str, + timeout: Optional[int] = None, + max_retries: int = 3, + verify: bool = True, + ) -> bool: + """Qt/QML-safe text input with proper waiting and retry logic. - def qt_safe_input(self, locator, text: str, timeout: Optional[int] = None, max_retries: int = 3) -> bool: - """Qt/QML-safe text input with proper waiting and retry logic""" + Notes: + - max_retries represents total attempts; values <= 0 will still perform one attempt. + - When verify=False, skips post-type verification and returns True after a single attempt. + """ - for attempt in range(max_retries): + attempts_total = max(1, max_retries) + for attempt in range(attempts_total): try: - # Wait for element to be clickable (present and enabled) wait = self._create_wait(timeout, "element_click") element = wait.until(EC.element_to_be_clickable(locator)) - # Click to focus element.click() - # Wait for Qt field to become ready (with timeout) self._wait_for_qt_field_ready(element) - # Clear and input using ActionChains element.clear() - # Brief wait for clear to complete (Qt/QML requirement) self._wait_for_clear_completion(element) + self.driver.update_settings({"sendKeyStrategy": "oneByOne"}) actions = ActionChains(self.driver) actions.send_keys(text).perform() - # Verify input was successful + if ElementStateChecker.is_password_field(element): + self.logger.debug("Password field detected - skipping verification") + return True + + if not verify: + self.logger.info( + f"Qt input completed (no-verify, attempt {attempt + 1})" + ) + return True + if self._verify_input_success(element, text): self.logger.info(f"Qt input successful (attempt {attempt + 1})") return True @@ -280,61 +229,58 @@ def qt_safe_input(self, locator, text: str, timeout: Optional[int] = None, max_r except Exception as e: self.logger.warning(f"Qt input attempt {attempt + 1} failed: {e}") if attempt < max_retries - 1: - time.sleep(1) # Brief pause before retry + self._wait_between_attempts() - self.logger.error(f"Qt input failed after {max_retries} attempts") + self.logger.error(f"Qt input failed after {attempts_total} attempts") return False - def _wait_for_qt_field_ready(self, element, timeout: int = 5) -> bool: - """Wait for Qt field to be ready for input using polling""" + def _wait_for_qt_field_ready(self, element, timeout: Optional[int] = None) -> bool: + """Wait for Qt field to be ready for input using polling using YAML element_wait timeout by default.""" def field_is_ready(driver): try: - # Check if element is enabled and displayed - return element.is_enabled() and element.is_displayed() + return ElementStateChecker.is_enabled( + element + ) and ElementStateChecker.is_displayed(element) except Exception: return False + effective_timeout = ( + timeout if timeout is not None else self.timeouts.get("element_wait", 30) + ) try: - wait = WebDriverWait(self.driver, timeout) # Keep direct usage for custom condition + wait = WebDriverWait(self.driver, effective_timeout) wait.until(field_is_ready) return True except Exception: self.logger.warning("Qt field readiness timeout") return False - def _verify_input_success(self, element, expected_text: str) -> bool: - """Verify that text input was successful""" + def _wait_until_focused(self, element, max_wait: float = 1.0) -> bool: try: - # Check if this is a password field by resource-id or content-desc - resource_id = element.get_attribute("resource-id") or "" - content_desc = element.get_attribute("content-desc") or "" - - is_password = ( - "password" in resource_id.lower() - or content_desc.lower() == "type password" - ) - - if is_password: - # Password fields hide content for security - assume success if no exception - self.logger.debug("Password field detected - assuming input success") - return True + start = time.time() + while time.time() - start < max_wait: + try: + if ElementStateChecker.is_focused(element): + return True + except Exception: + pass + time.sleep(0.05) + except Exception: + pass + return False - # For non-password fields, verify text content - actual_text = element.get_attribute( - "text" - ) # Android UIAutomator2 uses 'text' not 'value' + def _verify_input_success(self, element, expected_text: str) -> bool: + try: + # Android UIAutomator2 exposes entered value via 'text' + actual_text = element.get_attribute("text") if actual_text is None: - # Secure input or unreadable field - assume success return True return len(actual_text) > 0 except Exception: - # If we can't verify, assume success if we got this far return True def _wait_for_clear_completion(self, element, max_wait: float = 1.0) -> bool: - """Wait for element clear operation to complete""" - start_time = time.time() while time.time() - start_time < max_wait: try: @@ -355,3 +301,86 @@ def _wait_for_clear_completion(self, element, max_wait: float = 1.0) -> bool: return True return True # Always return True to not block the flow + + def long_press_element(self, element, duration: int = 800) -> bool: + """Perform long-press gesture on element to trigger context menu. + + Args: + element: WebElement to long-press + duration: Long-press duration in milliseconds + + Returns: + bool: True if long-press successful, False otherwise + """ + try: + if self.gestures.long_press(element.id, duration): + self.logger.debug(f"Long-press performed (duration: {duration}ms)") + return True + return False + except Exception as e: + self.logger.debug(f"Long-press gesture failed: {e}") + return False + + def tap_coordinate_relative(self, element, x_offset: int, y_offset: int) -> bool: + """Tap at coordinates relative to element position. + + Args: + element: Reference element for coordinate calculation + x_offset: X offset from element's left edge (can be negative) + y_offset: Y offset from element's top edge (can be negative) + + Returns: + bool: True if tap successful, False otherwise + """ + try: + rect = element.rect + x = int(rect["x"] + x_offset) + y = int(max(0, rect["y"] + y_offset)) + + self.gestures.tap(x, y) + self.logger.debug(f"Coordinate tap at ({x}, {y}) relative to element") + return True + except Exception as e: + self.logger.debug(f"Coordinate tap failed: {e}") + return False + + def restart_app(self, app_package: str = "im.status.tablet") -> bool: + """Restart the app within the current session.""" + return self.app_lifecycle.restart_app(app_package) + + def restart_app_with_data_cleared( + self, app_package: str = "im.status.tablet" + ) -> bool: + """Restart the app with all app data cleared (fresh app state).""" + return self.app_lifecycle.restart_app_with_data_cleared(app_package) + + def wait_for_condition( + self, condition_func, timeout: Optional[int] = None, poll_interval: float = 0.1 + ) -> bool: + effective_timeout = timeout or self.timeouts.get("element_wait", 30) + deadline = time.time() + effective_timeout + + while time.time() < deadline: + try: + if condition_func(): + return True + except Exception: + pass + time.sleep(poll_interval) + return False + + def _wait_between_attempts(self, base_delay: float = 0.5) -> None: + env_name = os.getenv("CURRENT_TEST_ENVIRONMENT", "lambdatest").lower() + if env_name in ("lt", "lambdatest"): + time.sleep(base_delay * 1.5) + else: + time.sleep(base_delay * 0.5) + + def _is_element_enabled(self, locator) -> bool: + try: + element = self.find_element_safe(locator, timeout=1) + if not element: + return False + return ElementStateChecker.is_enabled(element) + except Exception: + return False diff --git a/test/e2e_appium/pages/onboarding/__init__.py b/test/e2e_appium/pages/onboarding/__init__.py index e29f5e0e7bc..67386020756 100644 --- a/test/e2e_appium/pages/onboarding/__init__.py +++ b/test/e2e_appium/pages/onboarding/__init__.py @@ -1,12 +1,11 @@ -"""Onboarding page objects package (barrel module)""" +"""Onboarding page objects package.""" -# Import from local modules (files were renamed to drop 'screen') from .welcome_page import WelcomePage from .analytics_page import AnalyticsPage from .create_profile_page import CreateProfilePage from .password_page import PasswordPage from .loading_page import SplashScreen -from .main_app_page import MainAppPage +from .home_page import HomePage from .seed_phrase_input_page import SeedPhraseInputPage __all__ = [ @@ -15,6 +14,6 @@ "CreateProfilePage", "PasswordPage", "SplashScreen", - "MainAppPage", + "HomePage", "SeedPhraseInputPage", ] diff --git a/test/e2e_appium/pages/onboarding/create_profile_page.py b/test/e2e_appium/pages/onboarding/create_profile_page.py index 64b78fabc58..8bab8c2e571 100644 --- a/test/e2e_appium/pages/onboarding/create_profile_page.py +++ b/test/e2e_appium/pages/onboarding/create_profile_page.py @@ -1,34 +1,24 @@ -""" -Create Profile Page for Status Desktop E2E Testing - -This page object encapsulates interactions with the profile creation screen -during the onboarding flow. Supports multiple profile creation methods: -- Create new profile with password -- Import via recovery phrase -- Use empty Keycard -""" - -import time from ..base_page import BasePage -from locators.onboarding.create_profile_screen_locators import CreateProfileScreenLocators +from locators.onboarding.create_profile_screen_locators import ( + CreateProfileScreenLocators, +) class CreateProfilePage(BasePage): - """Page object for the Create Profile Screen during onboarding""" - + def __init__(self, driver): super().__init__(driver) self.locators = CreateProfileScreenLocators() - self.IDENTITY_LOCATOR = (self.locators.CREATE_PROFILE_SCREEN) - + self.IDENTITY_LOCATOR = self.locators.CREATE_PROFILE_SCREEN + def click_lets_go(self) -> bool: self.logger.info("Clicking 'Let's go!' button") return self.safe_click(self.locators.LETS_GO_BUTTON_BY_ID) - + def click_use_recovery_phrase(self) -> bool: self.logger.info("Clicking 'Use a recovery phrase' button") return self.safe_click(self.locators.USE_RECOVERY_PHRASE_BUTTON) - + def click_use_keycard(self) -> bool: self.logger.info("Clicking 'Use an empty Keycard' button") - return self.safe_click(self.locators.USE_KEYCARD_BUTTON) \ No newline at end of file + return self.safe_click(self.locators.USE_KEYCARD_BUTTON) diff --git a/test/e2e_appium/pages/onboarding/home_page.py b/test/e2e_appium/pages/onboarding/home_page.py new file mode 100644 index 00000000000..3489bc02bc3 --- /dev/null +++ b/test/e2e_appium/pages/onboarding/home_page.py @@ -0,0 +1,30 @@ +from ..base_page import BasePage +from locators.onboarding.home_locators import HomeLocators + + +class HomePage(BasePage): + + def __init__(self, driver): + super().__init__(driver) + self.locators = HomeLocators() + + def is_home_loaded(self) -> bool: + return self.is_element_visible(self.locators.HOME_CONTAINER) + + def is_home_container_visible(self) -> bool: + return self.is_element_visible(self.locators.HOME_CONTAINER) + + def wait_for_home_load(self, timeout: int = 30) -> bool: + return self.is_element_visible(self.locators.HOME_CONTAINER, timeout=timeout) + + def is_search_field_visible(self) -> bool: + return self.is_element_visible(self.locators.SEARCH_FIELD) + + def click_dock_settings(self) -> bool: + return self.safe_click(self.locators.SETTINGS_BUTTON) + + def click_dock_wallet(self) -> bool: + return self.safe_click(self.locators.WALLET_BUTTON) + + def click_dock_messages(self) -> bool: + return self.safe_click(self.locators.MESSAGES_BUTTON) diff --git a/test/e2e_appium/pages/onboarding/seed_phrase_input_page.py b/test/e2e_appium/pages/onboarding/seed_phrase_input_page.py index cc1675954ad..247ca491a7a 100644 --- a/test/e2e_appium/pages/onboarding/seed_phrase_input_page.py +++ b/test/e2e_appium/pages/onboarding/seed_phrase_input_page.py @@ -1,182 +1,65 @@ -""" -Seed Phrase Input Page for Status Desktop E2E Testing - -Page object for the seed phrase input screen during onboarding. -Supports importing existing seed phrases for account recovery. -""" - import time from typing import List, Union -from selenium.webdriver.common.keys import Keys - from ..base_page import BasePage from locators.onboarding.seed_phrase_input_locators import SeedPhraseInputLocators class SeedPhraseInputPage(BasePage): - """Page object for the Seed Phrase Input Screen during onboarding""" - def __init__(self, driver): + def __init__(self, driver, flow_type: str = "create"): super().__init__(driver) self.locators = SeedPhraseInputLocators() - self.IDENTITY_LOCATOR = self.locators.SEED_PHRASE_INPUT_SCREEN - - def select_word_count_tab(self, word_count: int) -> bool: - """ - Select the appropriate tab for seed phrase word count. - - Args: - word_count: Number of words in seed phrase (12, 18, or 24) - - Returns: - bool: True if tab was selected successfully - """ - self.logger.info(f"Selecting {word_count}-word tab") - - # Map word count to locators - tab_locators = { - 12: [ - self.locators.TAB_12_WORDS_BUTTON, - self.locators.TAB_12_WORDS_BUTTON_ALT, - ], - 18: [ - self.locators.TAB_18_WORDS_BUTTON, - self.locators.TAB_18_WORDS_BUTTON_ALT, - ], - 24: [ - self.locators.TAB_24_WORDS_BUTTON, - self.locators.TAB_24_WORDS_BUTTON_ALT, - ], - } - - if word_count not in tab_locators: - self.logger.error( - f"Invalid word count: {word_count}. Must be 12, 18, or 24" - ) - return False - - # Try primary locator first, then alternative - for locator in tab_locators[word_count]: - if self.safe_click(locator): - self.logger.info(f"✅ Selected {word_count}-word tab") - return True - self.logger.error(f"❌ Failed to select {word_count}-word tab") - return False - - def enter_seed_phrase_words( - self, seed_phrase: Union[str, List[str]], use_autocomplete: bool = False - ) -> bool: - """ - Enter seed phrase words into individual input fields. - - Args: - seed_phrase: Seed phrase as string (space-separated) or list of words - use_autocomplete: Whether to use autocomplete functionality (enter partial words) - - Returns: - bool: True if all words were entered successfully - """ - # Convert string to list if necessary - if isinstance(seed_phrase, str): - words = seed_phrase.strip().split() + if flow_type == "login": + self.IDENTITY_LOCATOR = self.locators.SEED_PHRASE_INPUT_SCREEN_LOGIN else: - words = seed_phrase - - word_count = len(words) - self.logger.info(f"Entering {word_count}-word seed phrase") + self.IDENTITY_LOCATOR = self.locators.SEED_PHRASE_INPUT_SCREEN_CREATE - # Validate word count - if word_count not in [12, 18, 24]: - self.logger.error( - f"Invalid seed phrase length: {word_count}. Must be 12, 18, or 24 words" - ) - return False + def paste_seed_phrase_via_clipboard(self, seed_phrase: str) -> bool: + """Paste the seed phrase via clipboard into the first input.""" + PASTE_CHIP_X_OFFSET = 20 + PASTE_CHIP_Y_OFFSET = -36 + LONG_PRESS_DURATION = 800 - # Select appropriate tab - if not self.select_word_count_tab(word_count): - return False + try: + self.driver.set_clipboard_text(seed_phrase) + self.logger.debug("Seed phrase set to clipboard") - # Enter each word - for index, word in enumerate(words, start=1): - if not self._enter_single_word(index, word, use_autocomplete): - self.logger.error(f"❌ Failed to enter word {index}: '{word}'") + first_field_locator = self.locators.get_seed_word_input_field(1) + element = self.find_element_safe(first_field_locator) + if not element: + self.logger.error("First seed input field not found") return False - self.logger.info(f"✅ Successfully entered all {word_count} seed phrase words") - return True - - def _enter_single_word( - self, position: int, word: str, use_autocomplete: bool = False - ) -> bool: - """ - Enter a single word into the specified position. - - Args: - position: Word position (1-24) - word: The word to enter - use_autocomplete: Whether to use autocomplete (enter partial word + Enter) - - Returns: - bool: True if word was entered successfully - """ - self.logger.debug(f"Entering word {position}: '{word}'") + if not self.ensure_element_visible(first_field_locator): + self.logger.warning("First field not fully visible; continuing anyway") - # Get locator for this word position - primary_locator = self.locators.get_seed_word_input_field(position) - alt_locator = self.locators.get_seed_word_input_field_alt(position) + self.gestures.element_tap(element) + self.logger.debug("Clicked first input field") - # Find the input field - element = None - for locator in [primary_locator, alt_locator]: - element = self.find_element_safe(locator) - if element: - break - - if not element: - self.logger.error(f"Could not find input field for word {position}") - return False - - try: - # Clear any existing text - element.clear() - # Wait for clear using base helper instead of fixed sleep - self._wait_for_clear_completion(element) - - if use_autocomplete and len(word) > 4: - # Enter partial word for autocomplete - partial_word = word[:-1] - element.send_keys(partial_word) - # Brief wait for autocomplete suggestions to appear (UI response time) - time.sleep(0.2) # TODO: Replace with WebDriverWait for autocomplete suggestions + if not self.long_press_element(element, LONG_PRESS_DURATION): + self.logger.error("Failed to perform long-press on input field") + return False - # Press Enter to select autocomplete suggestion - element.send_keys(Keys.RETURN) - self.logger.debug( - f"Used autocomplete for word {position}: '{partial_word}' -> '{word}'" - ) - else: - # Enter complete word - element.send_keys(word) - self.logger.debug(f"Entered complete word {position}: '{word}'") + if not self.tap_coordinate_relative( + element, PASTE_CHIP_X_OFFSET, PASTE_CHIP_Y_OFFSET + ): + self.logger.error("Failed to tap paste chip") + return False + time.sleep(0.5) + self.logger.info("✅ Seed phrase paste completed successfully") return True except Exception as e: - self.logger.error(f"Error entering word {position}: {e}") + self.logger.error(f"Clipboard paste failed: {e}") return False def click_continue(self) -> bool: self.logger.info("Clicking Continue button") - # Try multiple locator patterns - continue_locators = [ - self.locators.CONTINUE_BUTTON, - self.locators.CONTINUE_BUTTON_ALT, - self.locators.IMPORT_BUTTON, - self.locators.IMPORT_BUTTON_ALT, - ] + continue_locators = [self.locators.CONTINUE_BUTTON] for locator in continue_locators: if self.safe_click(locator): @@ -186,89 +69,30 @@ def click_continue(self) -> bool: self.logger.error("❌ Failed to click Continue button") return False - def get_validation_error(self) -> str: - """ - Get any validation error message displayed. - - Returns: - str: Error message text, or empty string if no error - """ - error_locators = [ - self.locators.INVALID_SEED_TEXT, - self.locators.INVALID_SEED_TEXT_ALT, - ] - - for locator in error_locators: - element = self.find_element_safe(locator) - if element and element.is_displayed(): - error_text = element.text - self.logger.info(f"Validation error found: '{error_text}'") - return error_text - - return "" - def is_continue_button_enabled(self) -> bool: - """ - Check if the Continue/Import button is enabled. - - Returns: - bool: True if button is enabled and clickable - """ - continue_locators = [ - self.locators.CONTINUE_BUTTON, - self.locators.CONTINUE_BUTTON_ALT, - self.locators.IMPORT_BUTTON, - self.locators.IMPORT_BUTTON_ALT, - ] - - for locator in continue_locators: - element = self.find_element_safe(locator) - if element and element.is_displayed(): - is_enabled = element.is_enabled() - self.logger.debug(f"Continue button enabled: {is_enabled}") - return is_enabled + element = self.find_element_safe(self.locators.CONTINUE_BUTTON) + if element and element.is_displayed(): + is_enabled = element.is_enabled() + self.logger.debug(f"Continue button enabled: {is_enabled}") + return is_enabled self.logger.warning("Continue button not found") return False - def import_seed_phrase( - self, seed_phrase: Union[str, List[str]], use_autocomplete: bool = False - ) -> bool: - """ - Complete seed phrase import flow. - - Args: - seed_phrase: Seed phrase as string (space-separated) or list of words - use_autocomplete: Whether to use autocomplete functionality - - Returns: - bool: True if import was successful - """ + def import_seed_phrase(self, seed_phrase: Union[str, List[str]]) -> bool: + """Complete seed phrase import flow.""" self.logger.info("Starting seed phrase import process") - # Enter seed phrase words - if not self.enter_seed_phrase_words(seed_phrase, use_autocomplete): - return False + if isinstance(seed_phrase, list): + seed_phrase = " ".join(seed_phrase) - # Wait a moment for validation - time.sleep(1) - - # Check for validation errors - error_message = self.get_validation_error() - if error_message: - self.logger.error(f"Seed phrase validation failed: {error_message}") - return False - - # Check if continue button is enabled - if not self.is_continue_button_enabled(): - self.logger.error( - "Continue button is not enabled - seed phrase may be invalid" - ) + if not self.paste_seed_phrase_via_clipboard(seed_phrase): return False - # Click continue to import - if not self.click_continue(): - return False + try: + if self.hide_keyboard(): + time.sleep(0.3) + except Exception: + pass - self.logger.info("✅ Seed phrase import completed successfully") - return True + return self.click_continue() diff --git a/test/e2e_appium/pages/settings/backup_seed_modal.py b/test/e2e_appium/pages/settings/backup_seed_modal.py new file mode 100644 index 00000000000..451f5720ea1 --- /dev/null +++ b/test/e2e_appium/pages/settings/backup_seed_modal.py @@ -0,0 +1,129 @@ +from typing import Optional, Dict +from selenium.webdriver.support import expected_conditions as EC +import re + +from ..base_page import BasePage +from locators.settings.backup_seed_locators import BackupSeedLocators +from utils.element_state_checker import ElementStateChecker + + +class BackupSeedModal(BasePage): + def __init__(self, driver): + super().__init__(driver) + self.locators = BackupSeedLocators() + + def is_displayed(self, timeout: Optional[int] = 10) -> bool: + return self.is_element_visible(self.locators.MODAL_ROOT, timeout=timeout) + + def click_next(self) -> bool: + return self.safe_click(self.locators.NEXT_BUTTON) + + def reveal_seed_phrase(self) -> bool: + return self.safe_click(self.locators.REVEAL_BUTTON) + + def get_seed_words_map(self) -> Dict[int, str]: + mapping: Dict[int, str] = {} + try: + nodes = self.driver.find_elements(*self.locators.SEED_WORD_TEXT_NODES) + except Exception: + nodes = [] + for node in nodes: + try: + desc = node.get_attribute("content-desc") or "" + # Expected format: " [tid:seedWordText_N]" + m = re.match(r"^(?P.+?)\s+\[tid:seedWordText_(?P\d+)\]$", desc) + if m: + idx = int(m.group("idx")) + word = (m.group("word") or "").strip() + if word: + mapping[idx] = word + except Exception: + continue + return mapping + + def fill_confirmation_words(self, index_to_word: Dict[int, str]) -> bool: + try: + inputs = self.driver.find_elements(*self.locators.CONFIRM_INPUTS_ANY) + except Exception: + inputs = [] + if len(inputs) < 4: + return False + + def _pos_key(el): + try: + r = el.rect or {} + return (int(r.get("y", 0)), int(r.get("x", 0))) + except Exception: + return (0, 0) + + inputs = sorted(inputs, key=_pos_key) + for el in inputs[:4]: + idx = self._parse_input_index(el) + if idx is None or idx not in index_to_word: + try: + self.logger.error( + f"No word for requested index {idx}; available keys: {sorted(index_to_word.keys())}" + ) + except Exception: + pass + return False + word = index_to_word[idx] + try: + self.logger.info(f"Entering confirm word index {idx}: '{word}'") + except Exception: + pass + try: + self.gestures.element_tap(el) + self._wait_for_qt_field_ready(el) + self.driver.execute_script("mobile: type", {"text": word}) + except Exception: + return False + try: + self.hide_keyboard() + except Exception: + pass + return True + + def click_continue(self) -> bool: + return self.safe_click(self.locators.CONTINUE_BUTTON) + + def click_done(self) -> bool: + return self.safe_click(self.locators.DONE_BUTTON) + + def set_remove_checkbox(self, checked: bool = True) -> bool: + el = self.find_element_safe(self.locators.DELETE_CHECKBOX, timeout=3) + if not el: + return False + current = ElementStateChecker.is_checked(el) + if current == checked: + return True + return self.safe_click(self.locators.DELETE_CHECKBOX) + + def wait_until_closed(self, timeout: Optional[int] = 10) -> bool: + try: + wait = self._create_wait(timeout, "element_wait") + return wait.until( + EC.invisibility_of_element_located(self.locators.MODAL_ROOT) + ) + except Exception: + return False + + def _parse_input_index(self, element) -> Optional[int]: + try: + rid = element.get_attribute("resource-id") or "" + m = re.search(r"seedInput_(\d+)", rid) + if m: + return ( + int(m.group(1)) + 1 + ) # seedInput_ appears 0-based → convert to 1-based + desc = element.get_attribute("content-desc") or "" + m2 = re.search(r"\[tid:seedInput_(\d+)\]", desc) + if m2: + return int(m2.group(1)) + 1 + name = element.get_attribute("name") or "" + m3 = re.search(r"seedInput_(\d+)", name) + if m3: + return int(m3.group(1)) + 1 + except Exception: + return None + return None diff --git a/test/e2e_appium/pages/settings/settings_page.py b/test/e2e_appium/pages/settings/settings_page.py new file mode 100644 index 00000000000..c9ecf801f68 --- /dev/null +++ b/test/e2e_appium/pages/settings/settings_page.py @@ -0,0 +1,52 @@ +from typing import Optional + +from ..base_page import BasePage +from locators.settings.settings_locators import SettingsLocators +from .backup_seed_modal import BackupSeedModal + + +class SettingsPage(BasePage): + def __init__(self, driver): + super().__init__(driver) + self.locators = SettingsLocators() + + def is_loaded(self, timeout: Optional[int] = 6) -> bool: + return self.is_element_visible(self.locators.PROFILE_MENU_ITEM, timeout=timeout) + + def open_sign_out_and_quit(self) -> bool: + # Try exact then heuristic + if self.safe_click( + self.locators.SIGN_OUT_AND_QUIT, + fallback_locators=[self.locators.SIGN_OUT_AND_QUIT_ALT], + ): + return True + return False + + def confirm_sign_out(self) -> bool: + #TODO: Remove fallback locators + return self.safe_click( + self.locators.CONFIRM_SIGN_OUT, + fallback_locators=[self.locators.CONFIRM_QUIT], + ) + + def open_backup_recovery_phrase(self) -> Optional[BackupSeedModal]: + # Click explicit TID menu item only + try: + if not self.is_element_visible( + self.locators.BACKUP_RECOVERY_MENU_ITEM, timeout=10 + ): + return None + clicked = self.safe_click( + self.locators.BACKUP_RECOVERY_MENU_ITEM, timeout=5 + ) + except Exception: + return None + if not clicked: + return None + modal = BackupSeedModal(self.driver) + return modal if modal.is_displayed(timeout=10) else None + + def is_backup_entry_removed(self) -> bool: + return not self.is_element_visible( + self.locators.BACKUP_RECOVERY_MENU_ITEM, timeout=2 + ) From 344cacd6675af3fb3f0e7f0719d622c89fa2ef56 Mon Sep 17 00:00:00 2001 From: Mg <50769329+glitchminer@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:25:41 +0100 Subject: [PATCH 3/8] test(e2e_appium): tests --- test/e2e_appium/tests/base_test.py | 56 +++++--- .../tests/test_backup_recovery_phrase.py | 127 ++++++++++++++++++ 2 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 test/e2e_appium/tests/test_backup_recovery_phrase.py diff --git a/test/e2e_appium/tests/base_test.py b/test/e2e_appium/tests/base_test.py index 5da870b3504..e74669b5247 100644 --- a/test/e2e_appium/tests/base_test.py +++ b/test/e2e_appium/tests/base_test.py @@ -1,7 +1,7 @@ import os from functools import wraps -from core import SessionManager +from core.test_context import TestContext, TestConfiguration from config.logging_config import get_logger # Constants @@ -9,22 +9,16 @@ def lambdatest_reporting(func): - """ - Decorator to ensure LambdaTest result reporting for cloud tests. - - Automatically handles success/failure reporting without requiring - manual report_test_result() calls in test methods. - """ @wraps(func) def wrapper(self, *args, **kwargs): try: result = func(self, *args, **kwargs) - if hasattr(self, 'report_test_result'): + if hasattr(self, "report_test_result"): self.report_test_result(passed=True) return result except Exception as e: - if hasattr(self, 'report_test_result'): + if hasattr(self, "report_test_result"): error_msg = str(e) self.report_test_result(passed=False, error_message=error_msg) raise @@ -32,7 +26,6 @@ def wrapper(self, *args, **kwargs): return wrapper - class BaseTest: def setup_method(self, method): # Initialize logger for test instance @@ -48,8 +41,13 @@ def setup_method(self, method): if hasattr(self, "request") and hasattr(self.request, "config"): test_env = self.request.config.getoption("--env", default=test_env) - self.session_manager = SessionManager(test_env) - self.driver = self.session_manager.get_driver() + # Initialize test context (owns session/driver) + self.ctx = TestContext(environment=test_env).initialize( + TestConfiguration(environment=test_env) + ) + + self.session_manager = self.ctx._session_manager + self.driver = self.ctx.driver self.test_name = method.__name__ if not hasattr(self.__class__, "_active_drivers"): @@ -78,23 +76,33 @@ def teardown_method(self, method): if hasattr(self.__class__, "_active_drivers"): self.__class__._active_drivers.pop(id(self), None) - # Clean up driver - self.session_manager.cleanup_driver() + # Clean up via TestContext to avoid double cleanup + try: + if hasattr(self, "ctx") and self.ctx: + self.ctx.cleanup() + finally: + # Fallback to direct cleanup if context not present + try: + if self.session_manager and self.session_manager.driver: + self.session_manager.cleanup_driver() + except Exception: + pass def _validate_result_reporting(self): - """Validate that cloud tests use explicit result reporting.""" if self.session_manager.environment in CLOUD_ENVIRONMENTS: if not self._result_reported: error_msg = f"Test '{self.test_name}' failed to report result." self.logger.error(f"❌ {error_msg}") raise RuntimeError(error_msg) - def report_test_result(self, passed: bool = None, error_message: str = None, status: str = None): + def report_test_result( + self, passed: bool = None, error_message: str = None, status: str = None + ): """ Report test result to LambdaTest. Args: - passed: Whether the test passed (for backward compatibility) + passed: Whether the test passed error_message: Optional error message for failed tests status: Direct status override ("passed", "failed", "error", "unknown", "skipped", "ignored") """ @@ -107,7 +115,9 @@ def report_test_result(self, passed: bool = None, error_message: str = None, sta self.driver, self.test_name, final_status, error_message ) logger = get_logger("session") - logger.info(f"✅ Reported to LambdaTest: {self.test_name} = {final_status.upper()}") + logger.info( + f"✅ Reported to LambdaTest: {self.test_name} = {final_status.upper()}" + ) except Exception as e: logger = get_logger("session") logger.error(f"⚠️ Failed to report result to LambdaTest: {e}") @@ -161,3 +171,13 @@ def get_active_driver_for_test(cls, test_instance_id): if hasattr(cls, "_active_drivers"): return cls._active_drivers.get(test_instance_id) return None + + +class BaseAppReadyTest(BaseTest): + def setup_method(self, method): + super().setup_method(method) + try: + self.ctx.get_home() + except Exception as e: + self.logger.error(f"Failed to prepare app in BaseAppReadyTest: {e}") + raise diff --git a/test/e2e_appium/tests/test_backup_recovery_phrase.py b/test/e2e_appium/tests/test_backup_recovery_phrase.py new file mode 100644 index 00000000000..9946218930e --- /dev/null +++ b/test/e2e_appium/tests/test_backup_recovery_phrase.py @@ -0,0 +1,127 @@ +import os +import pytest + +from tests.base_test import BaseAppReadyTest, lambdatest_reporting +from utils.screenshot import save_page_source +from pages.app import App + + +class TestBackupRecoveryPhrase(BaseAppReadyTest): + @pytest.mark.critical + @pytest.mark.smoke + @lambdatest_reporting + def test_sign_out_from_settings(self): + # BaseAppReadyTest ensures authenticated home + + # Navigate to Settings: prefer left-nav when available, fallback to Home dock + opened = self.ctx.app.click_settings_left_nav() + assert opened, "Failed to open Settings" + assert self.ctx.settings.is_loaded(), "Settings not detected" + + # Open Sign out & Quit and confirm + assert self.ctx.settings.open_sign_out_and_quit(), ( + "Failed to open 'Sign out & Quit'" + ) + assert self.ctx.settings.confirm_sign_out(), "Failed to confirm sign out" + + self.ctx._detect_app_state() + assert self.ctx.app_state.requires_authentication, ( + "App should require authentication after sign out" + ) + + @pytest.mark.parametrize( + "remove_phrase", + [pytest.param(True, id="delete")], + ) + @lambdatest_reporting + def test_backup_recovery_phrase_flow(self, remove_phrase): + # BaseAppReadyTest ensures home; open Settings (left-nav preferred) + opened = self.ctx.app.click_settings_left_nav() + assert opened, "Failed to open Settings" + assert self.ctx.settings.is_loaded(), "Settings not detected" + + # Open 'Back up recovery phrase' entry and display modal + modal = self.ctx.settings.open_backup_recovery_phrase() + assert modal is not None and modal.is_displayed(), "Backup Seed modal not shown" + + # Step 1: Reveal recovery phrase; capture words for logging + assert modal.reveal_seed_phrase(), "Failed to reveal seed phrase" + word_map = modal.get_seed_words_map() + assert len(word_map) >= 12, ( + f"Expected 12 word mappings, got {len(word_map)}: {word_map}" + ) + + # Proceed to the confirm step (new UI shows 4 inputs at once) + assert modal.click_next(), "Failed to move to confirm step after reveal" + + # Fill all required confirmation inputs using the captured seed words (1-based indices) + index_to_word = {i: w for i, w in word_map.items()} + assert modal.fill_confirmation_words(index_to_word), ( + "Failed to fill confirmation words" + ) + + # Continue to the final screen and finish (keep path first) + assert modal.click_continue(), "Failed to proceed after confirmation" + assert modal.click_done(), "Failed to finish backup flow" + assert modal.wait_until_closed(), "Backup modal did not close after completion" + + # After keep, entry should remain + assert not self.ctx.settings.is_backup_entry_removed(), ( + "Backup entry should be present after completion" + ) + + # Verify toast appears and assert content-desc contains expected phrase (no fallbacks) + app = App(self.driver) + assert app.is_toast_present(timeout=3), ( + "Expected a toast to appear after backup completion" + ) + keep_msg = app.get_toast_content_desc(timeout=10) or "" + assert "backed up your recovery phrase" in keep_msg.lower() + + # Capture final state XML for reference + try: + shot_path = self.ctx.take_screenshot("post_backup") + base_dir = os.path.dirname(shot_path) if shot_path else "screenshots" + save_page_source(self.driver, base_dir, "post_backup") + except Exception: + pass + + # If requested, immediately perform the delete path in the same test + if remove_phrase: + driver = self.driver + driver.quit + modal = self.ctx.settings.open_backup_recovery_phrase() + + assert modal.reveal_seed_phrase(), "Failed to reveal seed phrase (2nd pass)" + assert modal.click_next(), "Failed to move to confirm step (2nd pass)" + assert modal.fill_confirmation_words(index_to_word), ( + "Failed to fill confirmation words (2nd pass)" + ) + assert modal.click_continue(), ( + "Failed to proceed after confirmation (2nd pass)" + ) + assert modal.set_remove_checkbox(True), "Failed to tick remove checkbox" + assert modal.click_done(), "Failed to finish (Done) (2nd pass)" + assert modal.wait_until_closed(), ( + "Backup modal did not close after deletion" + ) + + # Verify toast after delete via content-desc substring (no fallbacks) BEFORE checking entry removal + assert app.is_toast_present(timeout=5), ( + "Expected a toast to appear after deletion" + ) + delete_msg = app.get_toast_content_desc(timeout=5) or "" + assert "recovery phrase permanently removed" in delete_msg.lower() + + # After toast, verify entry removal + assert self.ctx.settings.is_backup_entry_removed(), ( + "Backup entry should be removed after deletion" + ) + + # Capture final XML after delete path as well + try: + shot_path = self.ctx.take_screenshot("post_backup_delete") + base_dir = os.path.dirname(shot_path) if shot_path else "screenshots" + save_page_source(self.driver, base_dir, "post_backup_delete") + except Exception: + pass From a7460712b4e71bf5248fc50e6032ada60ad90dea Mon Sep 17 00:00:00 2001 From: Mg <50769329+glitchminer@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:29:39 +0100 Subject: [PATCH 4/8] test(e2e_appium): core --- test/e2e_appium/core/environment.py | 14 +- test/e2e_appium/core/models.py | 50 +++ test/e2e_appium/core/session_manager.py | 31 +- test/e2e_appium/core/test_context.py | 464 +++++++++++++++++++++++ test/e2e_appium/core/test_run_context.py | 97 +++++ test/e2e_appium/core/user_manager.py | 65 ++++ 6 files changed, 714 insertions(+), 7 deletions(-) create mode 100644 test/e2e_appium/core/models.py create mode 100644 test/e2e_appium/core/test_context.py create mode 100644 test/e2e_appium/core/test_run_context.py create mode 100644 test/e2e_appium/core/user_manager.py diff --git a/test/e2e_appium/core/environment.py b/test/e2e_appium/core/environment.py index ccac707504e..3965719f9aa 100644 --- a/test/e2e_appium/core/environment.py +++ b/test/e2e_appium/core/environment.py @@ -1,7 +1,7 @@ import os import re from dataclasses import dataclass -from typing import Dict, Any +from typing import Dict, Any, List, Optional from pathlib import Path @@ -22,6 +22,7 @@ class EnvironmentConfig: directories: Dict[str, str] logging_config: Dict[str, Any] lambdatest_config: Dict[str, Any] = None + available_devices: Optional[List[Dict[str, Any]]] = None def validate(self) -> None: if self.environment == "local": @@ -33,8 +34,10 @@ def _validate_local_config(self): app_path = self.app_source.get("path_template", "") resolved_path = self._resolve_template(app_path) - if resolved_path and not Path(resolved_path).exists(): - raise ConfigurationError(f"Local app not found: {resolved_path}") + # Only enforce path existence if one is provided; otherwise assume appPackage/appActivity launch + if resolved_path: + if not Path(resolved_path).exists(): + raise ConfigurationError(f"Local app not found: {resolved_path}") try: import requests @@ -127,7 +130,10 @@ def get_device_capabilities(self) -> Dict[str, Any]: } if self.environment == "local": - base_caps["app"] = self.get_resolved_app_path() + app_path = self.get_resolved_app_path() + if app_path: + base_caps["app"] = app_path # Use APK if provided + # If no app path resolved, rely on provided appPackage/appActivity base_caps.update(self.capabilities) return base_caps diff --git a/test/e2e_appium/core/models.py b/test/e2e_appium/core/models.py new file mode 100644 index 00000000000..f835deafcee --- /dev/null +++ b/test/e2e_appium/core/models.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass, field +from typing import Dict, Any, Optional + + +@dataclass +class TestUser: + display_name: str + password: str = "StatusPassword123!" + seed_phrase: Optional[str] = None + source: str = "created" + + def to_dict(self) -> Dict[str, Any]: + return { + "display_name": self.display_name, + "password": self.password, + "seed_phrase": self.seed_phrase, + "source": self.source, + } + + +@dataclass +class TestAppState: + is_home_loaded: bool = False + current_screen: str = "unknown" + requires_authentication: bool = False + has_existing_profiles: bool = False + + +@dataclass +class TestConfiguration: + environment: str = "lambdatest" + profile_method: str = "password" + display_name: str = "TestUser" + validate_steps: bool = True + take_screenshots: bool = False + custom_config: Dict[str, Any] = field(default_factory=dict) + device_override: Optional[Dict[str, Any]] = None + + @classmethod + def from_pytest_marker(cls, request, marker_name: str) -> "TestConfiguration": + config = cls() + for marker in request.node.iter_markers(): + if marker.name == marker_name: + for key, value in marker.kwargs.items(): + if hasattr(config, key): + setattr(config, key, value) + else: + config.custom_config[key] = value + break + return config diff --git a/test/e2e_appium/core/session_manager.py b/test/e2e_appium/core/session_manager.py index 62a8658042a..7e6cd0af30c 100644 --- a/test/e2e_appium/core/session_manager.py +++ b/test/e2e_appium/core/session_manager.py @@ -6,7 +6,7 @@ from selenium.webdriver.remote.client_config import ClientConfig try: - from config import get_config, TestConfig, get_logger, log_session_info + from config import get_logger, log_session_info from core import EnvironmentSwitcher, ConfigurationError except ImportError: from config import get_logger, log_session_info @@ -16,10 +16,11 @@ class SessionManager: """Manages Appium driver sessions and environment configuration""" - def __init__(self, environment="lambdatest"): + def __init__(self, environment="lambdatest", device_override=None): self.environment = environment self.driver = None self.logger = get_logger("session") + self._device_override = device_override or None # Load YAML-based configuration (simplified) try: @@ -38,10 +39,34 @@ def __init__(self, environment="lambdatest"): f" Timeouts: default={timeouts.get('default')}s, wait={timeouts.get('element_wait')}s" ) + # Apply device override if provided + if self._device_override: + self._apply_device_override(self._device_override) + except ConfigurationError as e: self.logger.error(f"❌ Configuration error: {e}") self.logger.error("💡 Ensure YAML configuration files are properly set up") - raise # Don't fall back to legacy, force proper config + raise + + def _apply_device_override(self, override: dict) -> None: + """Override device fields from a device entry (name, platform_name, platform_version, tags).""" + try: + name = override.get("name") + platform_name = override.get("platform_name", self.env_config.platform_name) + platform_version = override.get( + "platform_version", self.env_config.platform_version + ) + if name: + self.env_config.device_name = name + if platform_name: + self.env_config.platform_name = platform_name + if platform_version: + self.env_config.platform_version = platform_version + self.logger.info( + f"🔧 Device override applied → {self.env_config.device_name} ({self.env_config.platform_name} {self.env_config.platform_version})" + ) + except Exception as e: + self.logger.warning(f"Failed to apply device override: {e}") def _get_lambdatest_naming(self) -> dict: """Generate LambdaTest build and test names from YAML config.""" diff --git a/test/e2e_appium/core/test_context.py b/test/e2e_appium/core/test_context.py new file mode 100644 index 00000000000..57defad57f2 --- /dev/null +++ b/test/e2e_appium/core/test_context.py @@ -0,0 +1,464 @@ +""" +Unified Test Execution Context for Appium-based E2E tests. +""" + +from typing import Dict, Any, Optional +from contextlib import contextmanager + +from appium.webdriver.webdriver import WebDriver + +from core.session_manager import SessionManager +from core.config_manager import EnvironmentSwitcher +from pages.onboarding import HomePage +from pages.app import App +from utils.exceptions import SessionManagementError +from config.logging_config import get_logger +from utils.gestures import Gestures +from utils.screenshot import save_screenshot +from utils.performance_monitor import PerformanceMonitor +from services import UserProfileService, AppStateManager, AppInitializationManager + + +from .models import TestUser, TestConfiguration +from .user_manager import UserManager + + +class TestContext: + """Consolidates session, user, app state, and configuration management.""" + + def __init__( + self, environment: str = "lambdatest", logger_name: str = "test_context" + ): + self.environment = environment + self.logger = get_logger(logger_name) + + # Core components + self._session_manager: Optional[SessionManager] = None + self._driver: Optional[WebDriver] = None + self._gestures: Optional[Gestures] = None + self._main_app: Optional[HomePage] = None + self._welcome_back: Optional[Any] = ( + None # Simplified type for missing WelcomeBackPage + ) + self._app: Optional[App] = None + + # Services + self._user_service: Optional[UserProfileService] = None + self._app_state_manager: Optional[AppStateManager] = None + self._app_initialization: Optional[AppInitializationManager] = None + + # Context state + self.config: Optional[TestConfiguration] = None + self.users = None + self.performance = PerformanceMonitor("test_context") + + # Lazy initialization flags + self._initialized = False + + class UserManager(UserManager): + pass + + @property + def user_manager(self) -> "TestContext.UserManager": + if self.users is None: + self.users = TestContext.UserManager(self) + return self.users + + def initialize(self, config: TestConfiguration) -> "TestContext": + self.config = config + self.environment = config.environment + + try: + # Create session manager + self._session_manager = SessionManager( + self.environment, device_override=config.device_override + ) + self._driver = self._session_manager.get_driver() + self._gestures = Gestures(self._driver) + self._app = App(self._driver) + + # Initialize services + self._user_service = UserProfileService(self._driver, self.performance) + self._app_state_manager = AppStateManager(self._driver) + self._app_initialization = AppInitializationManager(self._driver) + + # Initial app activation + self._app_initialization.perform_initial_activation() + + self._initialized = True + self.logger.info(f"✅ TestContext initialized for {self.environment}") + + return self + + except Exception as e: + self.logger.error(f"❌ TestContext initialization failed: {e}") + raise SessionManagementError( + f"Failed to initialize test context: {e}" + ) from e + + def attach( + self, + driver: WebDriver, + session_manager: Optional[SessionManager] = None, + config: Optional[TestConfiguration] = None, + ) -> "TestContext": + """ + Attach to an existing driver (and optional session manager). + + Use this when a higher-level test base already created the session + (e.g., BaseTest). Avoids double sessions and improves stability. + """ + try: + if config: + self.config = config + if config.environment: + self.environment = config.environment + self._driver = driver + self._session_manager = session_manager + self._gestures = Gestures(self._driver) + self._app = App(self._driver) + + # Initialize services + self._user_service = UserProfileService(self._driver, self.performance) + self._app_state_manager = AppStateManager(self._driver) + self._app_initialization = AppInitializationManager(self._driver) + + # Perform initial app activation + self._app_initialization.perform_initial_activation() + + self._initialized = True + self.logger.info("✅ TestContext attached to existing session") + return self + except Exception as e: + self.logger.error(f"❌ TestContext attach failed: {e}") + raise SessionManagementError( + f"Failed to attach to existing context: {e}" + ) from e + + @property + def driver(self) -> WebDriver: + if not self._driver: + raise SessionManagementError( + "Driver not initialized - call initialize() first" + ) + return self._driver + + def take_screenshot(self, name: Optional[str] = None) -> Optional[str]: + try: + switcher = EnvironmentSwitcher() + env_config = switcher.switch_to(self.environment) + base_dir = env_config.directories.get("screenshots", "screenshots") + except Exception: + base_dir = "screenshots" + try: + return save_screenshot(self.driver, base_dir, name) + except Exception: + return None + + @property + def main_app(self) -> HomePage: + if not self._main_app: + self._main_app = HomePage(self.driver) + return self._main_app + + @property + def app(self) -> App: + if not self._app: + self._app = App(self.driver) + return self._app + + @property + def settings(self): + from pages.settings.settings_page import SettingsPage + + return SettingsPage(self.driver) + + @property + def welcome_back(self): + class SimpleWelcomeBack: + def is_welcome_back_screen_displayed(self, timeout=10): + return False + + def perform_login(self, password): + return False + + return SimpleWelcomeBack() + + @property + def user_service(self) -> UserProfileService: + if not self._user_service: + self._user_service = UserProfileService(self.driver, self.performance) + return self._user_service + + @property + def app_state_manager(self) -> AppStateManager: + if not self._app_state_manager: + self._app_state_manager = AppStateManager(self.driver) + return self._app_state_manager + + @property + def user(self) -> Optional[TestUser]: + return self.user_service.current_user if self._user_service else None + + @property + def app_state(self): + return self.app_state_manager.state if self._app_state_manager else None + + def create_user_profile( + self, + method: Optional[str] = None, + seed_phrase: Optional[str] = None, + password: Optional[str] = None, + display_name: Optional[str] = None, + ) -> TestUser: + if not self._initialized: + raise SessionManagementError("TestContext not initialized") + + # Delegate to user service + method = method or self.config.profile_method + display_name = display_name or self.config.display_name + password = password or "StatusPassword123!" + + user = self.user_service.create_profile( + method=method, + seed_phrase=seed_phrase, + password=password, + display_name=display_name, + config={"validate_steps": getattr(self.config, "validate_steps", True)}, + ) + + # Update app state after successful creation + self.app_state_manager.state.is_home_loaded = True + self.app_state_manager.state.current_screen = "home" + + return user + + def login_existing_user(self, password: Optional[str] = None) -> TestUser: + if not self._initialized: + raise SessionManagementError("TestContext not initialized") + + password = password or "StatusPassword123!" + + if not self.app_state.has_existing_profiles: + raise SessionManagementError("No existing profiles detected") + + # Delegate to user service + user = self.user_service.login_existing_user(password) + + # Update app state after successful login + self.app_state_manager.state.is_home_loaded = True + self.app_state_manager.state.current_screen = "home" + self.app_state_manager.state.requires_authentication = False + + return user + + def restart_app_and_login(self) -> bool: + """Restart app, handle authentication, and return True when back on home.""" + if not self._initialized or not self.user: + raise SessionManagementError( + "TestContext not properly initialized with user" + ) + + with self.performance.measure_operation("restart_app_and_login"): + self.logger.info("🔄 Restarting app and handling authentication") + + # Restart app + if not self.main_app.restart_app(): + self.logger.error("App restart failed") + return False + + # Wait for app to stabilize and present either home or auth + self.wait_for_app_post_restart() + + # Detect new state and handle authentication + self.app_state_manager.detect_current_state() + + if self.app_state.requires_authentication: + return self._handle_post_restart_authentication() + elif self.app_state.is_home_loaded: + self.logger.info("✅ Auto-login successful") + return True + else: + self.logger.error("Unknown app state after restart") + return False + + def get_home(self, ensure: bool = True, auto_create: bool = True) -> HomePage: + if ensure: + # Refresh app state first + self.app_state_manager.detect_current_state() + + if not self.app_state.is_home_loaded: + # Try existing-user login if authentication is required + if self.app_state.requires_authentication: + try: + self.logger.info( + "Auth required - attempting existing user login" + ) + self.login_existing_user() + except Exception as e: + self.logger.warning(f"Existing user login failed: {e}") + + # If still not loaded and allowed, create a user + if not self.app_state.is_home_loaded and auto_create: + self.logger.info("Creating user to obtain home") + self.create_user_profile(method=self.config.profile_method) + + # Final assurance: home must be loaded + if not self.app_state.is_home_loaded: + raise SessionManagementError( + "Home not loaded after get_home() attempts" + ) + return self.main_app + + def use_test_account( + self, + account: Dict[str, Any], + simulate_returning: bool = False, + login_only: bool = False, + ) -> bool: + """Import or log into a predefined test account.""" + if not self._initialized: + raise SessionManagementError("TestContext not initialized") + + # Delegate to user service + success = self.user_service.import_test_account( + account, + simulate_returning, + login_only, + self.config.display_name if self.config else "TestUser", + ) + + if success: + # Update app state + self.app_state_manager.detect_current_state() + + return success + + def cleanup(self): + try: + if self._session_manager: + self.logger.info("🧹 Cleaning up test context") + self._session_manager.cleanup_driver() + self._session_manager = None + self._driver = None + + # Reset state and services + self._initialized = False + self._user_service = None + self._app_state_manager = None + self._app_initialization = None + + self.logger.info("✅ TestContext cleanup completed") + + except Exception as e: + self.logger.warning(f"⚠️ TestContext cleanup warning: {e}") + + # Per-context reporting helper (LambdaTest) + def report( + self, + status: str, + error_message: Optional[str] = None, + test_name: Optional[str] = None, + ) -> None: + try: + if test_name: + self.driver.execute_script(f"lambda-name={test_name}") + self.driver.execute_script(f"lambda-status={status}") + if error_message and status != "passed": + clean_error = error_message.replace('"', '\\"').replace("\n", "\\n")[ + :500 + ] + self.driver.execute_script( + f"lambda-description=Test failed: {clean_error}" + ) + except Exception: + pass + + def get_summary(self) -> Dict[str, Any]: + return { + "environment": self.environment, + "initialized": self._initialized, + "user": self.user.to_dict() if self.user else None, + "app_state": { + "is_home_loaded": self.app_state.is_home_loaded, + "current_screen": self.app_state.current_screen, + "requires_authentication": self.app_state.requires_authentication, + "has_existing_profiles": self.app_state.has_existing_profiles, + }, + "config": { + "profile_method": self.config.profile_method if self.config else None, + "display_name": self.config.display_name if self.config else None, + } + if self.config + else None, + "performance": self.performance.get_summary(), + } + + def _detect_app_state(self): + self.app_state_manager.detect_current_state() + + def _handle_post_restart_authentication(self) -> bool: + if self.welcome_back.is_welcome_back_screen_displayed(timeout=10): + self.logger.info("Handling welcome back authentication") + try: + # Use current user's password + current_user = self.user_service.current_user + password = ( + current_user.password if current_user else "StatusPassword123!" + ) + + success = self.welcome_back.perform_login(password) + if success and self.main_app.wait_for_home_load(timeout=30): + self.app_state_manager.state.is_home_loaded = True + self.app_state_manager.state.current_screen = "home" + self.app_state_manager.state.requires_authentication = False + return True + except Exception as e: + self.logger.error(f"Post-restart authentication failed: {e}") + + return False + + def wait_for_app_post_restart( + self, timeout: Optional[int] = None, poll_interval: float = 0.5 + ) -> bool: + """Public helper to wait for app readiness after a restart using YAML defaults.""" + effective_timeout = timeout + try: + if ( + effective_timeout is None + and self._session_manager + and self._session_manager.env_config + ): + effective_timeout = self._session_manager.env_config.timeouts.get( + "default", 30 + ) + except Exception: + effective_timeout = effective_timeout or 30 + return self._wait_for_app_ready( + timeout=int(effective_timeout or 30), poll_interval=poll_interval + ) + + def _wait_for_app_ready( + self, timeout: int = 30, poll_interval: float = 0.5 + ) -> bool: + return self.app_state_manager.wait_for_app_ready(timeout, poll_interval) + + +@contextmanager +def test_context(environment: str = "lambdatest", config: TestConfiguration = None): + context = TestContext(environment) + try: + if config: + context.initialize(config) + yield context + finally: + context.cleanup() + + +def create_test_context_from_marker(request, environment: str = None) -> TestContext: + config = TestConfiguration.from_pytest_marker(request, "create_profile_config") + env = environment or config.environment + + context = TestContext(env) + context.initialize(config) + return context diff --git a/test/e2e_appium/core/test_run_context.py b/test/e2e_appium/core/test_run_context.py new file mode 100644 index 00000000000..d5066ee9ca4 --- /dev/null +++ b/test/e2e_appium/core/test_run_context.py @@ -0,0 +1,97 @@ +""" +TestRunContext - Minimal multi-device orchestration + +Creates and manages multiple TestContext instances for scenarios +requiring more than one device (e.g., syncing or cross-user messaging). +""" + +from typing import List, Optional, Dict, Any + +from config.logging_config import get_logger +from core.test_context import TestContext, TestConfiguration +from core.config_manager import ConfigurationManager + + +class TestRunContext: + def __init__(self, contexts: List[TestContext]): + self.contexts = contexts + self.logger = get_logger("test_run_context") + + @classmethod + def create( + cls, + number: int = 2, + environment: str = "lambdatest", + config: Optional[TestConfiguration] = None, + device_overrides: Optional[List[Dict[str, Any]]] = None, + device_tags: Optional[List[str]] = None, + ) -> "TestRunContext": + """ + Create N TestContexts. + + - If device_overrides provided: use those devices directly (first N) + - Else if device_tags provided: filter YAML devices by tags, take first N + - Else: create N contexts using the default device from env config + """ + contexts: List[TestContext] = [] + + selected_devices: List[Dict[str, Any]] = [] + if device_overrides: + selected_devices = device_overrides[:number] + elif device_tags: + # Load env YAML and filter devices by tags + cfg_mgr = ConfigurationManager() + env_cfg = cfg_mgr.load_environment(environment) + all_devices = env_cfg.available_devices or [] + + def has_tags(dev): + tags = set(dev.get("tags", [])) + return all(tag in tags for tag in device_tags) + + selected_devices = [d for d in all_devices if has_tags(d)][:number] + + for i in range(number): + per_ctx_config = config or TestConfiguration(environment=environment) + if i < len(selected_devices): + # Pass device override into TestContext via custom_config + per_ctx_config.device_override = selected_devices[i] + ctx = TestContext(environment=environment).initialize(per_ctx_config) + contexts.append(ctx) + return cls(contexts) + + def cleanup(self) -> None: + for ctx in self.contexts: + try: + ctx.cleanup() + except Exception: + pass + + # Reporting helpers (LambdaTest-compatible via execute_script) + def report_all( + self, + status: str, + error_message: Optional[str] = None, + test_name: Optional[str] = None, + ) -> None: + for ctx in self.contexts: + try: + # Set status + ctx.driver.execute_script(f"lambda-status={status}") + # Optionally set test name + if test_name: + ctx.driver.execute_script(f"lambda-name={test_name}") + # Optionally set description for failures + if error_message and status != "passed": + clean_error = error_message.replace('"', '\\"').replace( + "\n", "\\n" + )[:500] + ctx.driver.execute_script( + f"lambda-description=Test failed: {clean_error}" + ) + except Exception: + # Avoid masking primary failures + continue + + # Convenience iterators + def __iter__(self): + return iter(self.contexts) diff --git a/test/e2e_appium/core/user_manager.py b/test/e2e_appium/core/user_manager.py new file mode 100644 index 00000000000..6121e9e0ee2 --- /dev/null +++ b/test/e2e_appium/core/user_manager.py @@ -0,0 +1,65 @@ +from typing import List, Optional + +from .models import TestUser + + +class UserManager: + def __init__(self, ctx) -> None: + self._ctx = ctx + self._profiles: List[TestUser] = [] + self._active_index: Optional[int] = None + + def create( + self, + method: Optional[str] = None, + display_name: Optional[str] = None, + password: Optional[str] = None, + seed_phrase: Optional[str] = None, + ) -> TestUser: + user = self._ctx.create_user_profile( + method=method, + display_name=display_name, + password=password, + seed_phrase=seed_phrase, + ) + self._profiles.append(user) + self._active_index = len(self._profiles) - 1 + return user + + def add_existing(self, display_name: str, password: str) -> TestUser: + user = TestUser( + display_name=display_name, password=password, source="existing_profile" + ) + self._profiles.append(user) + if self._active_index is None: + self._active_index = 0 + return user + + def list(self) -> List[TestUser]: + return list(self._profiles) + + def active(self) -> Optional[TestUser]: + if self._active_index is None: + return None + return self._profiles[self._active_index] + + def switch(self, index: int, password: Optional[str] = None) -> bool: + if index < 0 or index >= len(self._profiles): + raise IndexError("Profile index out of range") + target = self._profiles[index] + if not self._ctx.main_app.restart_app(): + return False + self._ctx._wait_for_app_ready(timeout=20) + self._ctx._detect_app_state() + if not self._ctx.app_state.has_existing_profiles: + self._ctx.logger.warning( + "No existing profiles detected after restart; cannot switch" + ) + return False + if not self._ctx.welcome_back.perform_login(password or target.password): + return False + self._active_index = index + self._ctx.user = target + self._ctx.app_state.is_main_app_loaded = True + self._ctx.app_state.current_screen = "main_app" + return True From 397bfcdd77823b39999d134c359556be1c774336 Mon Sep 17 00:00:00 2001 From: Mg <50769329+glitchminer@users.noreply.github.com> Date: Tue, 9 Sep 2025 11:59:38 +0100 Subject: [PATCH 5/8] test(e2e_appium): utils --- test/e2e_appium/conftest.py | 48 +++- test/e2e_appium/services/__init__.py | 9 + .../services/app_initialization_manager.py | 144 ++++++++++ test/e2e_appium/services/app_state_manager.py | 126 +++++++++ .../services/user_profile_service.py | 223 ++++++++++++++++ .../e2e_appium/utils/app_lifecycle_manager.py | 128 +++++++++ .../e2e_appium/utils/element_state_checker.py | 69 +++++ test/e2e_appium/utils/exceptions.py | 56 ++++ test/e2e_appium/utils/gestures.py | 104 ++++++++ test/e2e_appium/utils/keyboard_manager.py | 95 +++++++ test/e2e_appium/utils/onboarding_classes.py | 246 ++++++++++++++++++ test/e2e_appium/utils/performance_monitor.py | 61 +++++ test/e2e_appium/utils/screenshot.py | 49 ++++ test/e2e_appium/utils/toasts.py | 54 ++++ 14 files changed, 1411 insertions(+), 1 deletion(-) create mode 100644 test/e2e_appium/services/__init__.py create mode 100644 test/e2e_appium/services/app_initialization_manager.py create mode 100644 test/e2e_appium/services/app_state_manager.py create mode 100644 test/e2e_appium/services/user_profile_service.py create mode 100644 test/e2e_appium/utils/app_lifecycle_manager.py create mode 100644 test/e2e_appium/utils/element_state_checker.py create mode 100644 test/e2e_appium/utils/exceptions.py create mode 100644 test/e2e_appium/utils/gestures.py create mode 100644 test/e2e_appium/utils/keyboard_manager.py create mode 100644 test/e2e_appium/utils/onboarding_classes.py create mode 100644 test/e2e_appium/utils/performance_monitor.py create mode 100644 test/e2e_appium/utils/screenshot.py create mode 100644 test/e2e_appium/utils/toasts.py diff --git a/test/e2e_appium/conftest.py b/test/e2e_appium/conftest.py index 3fc045f277c..2409b922294 100644 --- a/test/e2e_appium/conftest.py +++ b/test/e2e_appium/conftest.py @@ -7,11 +7,12 @@ from .config.logging_config import get_logger from .core import EnvironmentSwitcher from .utils.lambdatest_reporter import LambdaTestReporter +from .utils.screenshot import save_screenshot, save_page_source # Expose fixture modules without star imports pytest_plugins = [ - "fixtures.onboarding_fixture", + # "fixtures.onboarding_fixture", # Disabled - using BaseAppReadyTest instead ] @@ -161,6 +162,51 @@ def pytest_runtest_makereport(item, call): logger = get_logger("session") logger.error(f"Failed to report test result to LambdaTest: {e}") + # Get screenshot and page source artifacts + try: + if getattr(rep, "failed", False) and not getattr(item, "_failure_artifacts_saved", False): + driver = None + try: + if hasattr(item, "instance") and hasattr(item.instance, "driver"): + driver = item.instance.driver + except Exception: + driver = None + + if driver: + # Resolve screenshots directory from environment config; fallback to 'screenshots' + try: + env_name = os.getenv("CURRENT_TEST_ENVIRONMENT", "lambdatest") + switcher = EnvironmentSwitcher() + env_config = switcher.switch_to(env_name) + screenshots_dir = env_config.directories.get("screenshots", "screenshots") + except Exception: + screenshots_dir = "screenshots" + + test_id = getattr(item, "name", "test") + (f"__{rep.when}" if getattr(rep, "when", None) else "") + + s_path = None + x_path = None + try: + s_path = save_screenshot(driver, str(screenshots_dir), f"FAILED_{test_id}") + except Exception: + pass + try: + x_path = save_page_source(driver, str(screenshots_dir), f"FAILED_{test_id}") + except Exception: + pass + + log = get_logger("conftest") + if s_path: + log.info(f"Saved failure screenshot: {s_path}") + if x_path: + log.info(f"Saved failure page source: {x_path}") + + setattr(item, "_failure_artifacts_saved", True) + except Exception as e: + log = get_logger("conftest") + log.warning(f"Artifact capture failed: {e}") + + def pytest_terminal_summary(terminalreporter, exitstatus, config): if not _logging_setup: return diff --git a/test/e2e_appium/services/__init__.py b/test/e2e_appium/services/__init__.py new file mode 100644 index 00000000000..88d28b6abdc --- /dev/null +++ b/test/e2e_appium/services/__init__.py @@ -0,0 +1,9 @@ +from .user_profile_service import UserProfileService +from .app_state_manager import AppStateManager +from .app_initialization_manager import AppInitializationManager + +__all__ = [ + "UserProfileService", + "AppStateManager", + "AppInitializationManager", +] diff --git a/test/e2e_appium/services/app_initialization_manager.py b/test/e2e_appium/services/app_initialization_manager.py new file mode 100644 index 00000000000..18737cb5c8a --- /dev/null +++ b/test/e2e_appium/services/app_initialization_manager.py @@ -0,0 +1,144 @@ +import time + +from config.logging_config import get_logger +from utils.gestures import Gestures + + +class AppInitializationManager: + + def __init__(self, driver): + self.driver = driver + self.gestures = Gestures(driver) + self.logger = get_logger("app_initialization") + + def perform_initial_activation( + self, timeout: float = 15.0, interval: float = 2.0 + ) -> bool: + """Perform initial app activation until UI appears or timeout is reached.""" + self.logger.debug("🚀 Starting app initialization sequence") + + self._wait_for_session_ready() + + deadline = time.time() + timeout + + while time.time() < deadline: + if not self._should_perform_activation_tap(): + self.logger.debug("↷ UI already present - skipping activation") + return True + + if self._perform_activation_tap(): + if self._wait_for_ui_response(timeout=interval): + self.logger.info("✓ App UI surfaced after activation") + return True + + self.logger.warning("⚠ App activation timeout - UI may not be ready") + return False + + def _wait_for_session_ready( + self, timeout: float = 2.0, poll_interval: float = 0.2 + ) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + try: + _ = self.driver.get_window_size() + try: + _ = self.driver.page_source + except Exception: + pass + break + except Exception: + time.sleep(poll_interval) + + def _should_perform_activation_tap(self) -> bool: + ui_checks = [ + self._is_home_container_visible, + self._is_welcome_back_visible, + self._is_welcome_visible, + self._is_ui_content_present, + ] + + for check in ui_checks: + try: + if check(): + return False + except Exception: + continue + + return True + + def _is_home_container_visible(self) -> bool: + try: + from pages.onboarding import HomePage + + main_app = HomePage(self.driver) + return main_app.is_element_visible( + main_app.locators.HOME_CONTAINER, timeout=1 + ) + except Exception: + return False + + def _is_welcome_back_visible(self) -> bool: + try: + from pages.onboarding import WelcomeBackPage + + welcome_back = WelcomeBackPage(self.driver) + return welcome_back.is_welcome_back_screen_displayed(timeout=1) + except Exception: + return False + + def _is_welcome_visible(self) -> bool: + try: + from pages.onboarding import WelcomePage + + welcome = WelcomePage(self.driver) + return welcome.is_screen_displayed(timeout=1) + except Exception: + return False + + def _is_ui_content_present(self) -> bool: + try: + src = self.driver.page_source + ui_markers = [ + "startupOnboardingLayout", + "homeContainer.homeDock", + "Welcome to Status", + ] + return any(marker in src for marker in ui_markers) + except Exception: + return False + + def _perform_activation_tap(self) -> bool: + try: + coords = self._get_safe_tap_coordinates() + if self.gestures.double_tap(coords[0], coords[1]): + self.logger.debug("✓ Activation double-tap performed") + return True + elif self.gestures.tap(coords[0], coords[1]): + self.logger.debug("✓ Activation tap performed") + return True + else: + self.logger.debug("⚠ Activation tap failed") + return False + except Exception as e: + self.logger.debug(f"⚠ Activation tap error: {e}") + return False + + def _get_safe_tap_coordinates(self) -> tuple: + try: + size = self.driver.get_window_size() + return (size["width"] // 2, size["height"] // 2) + except Exception: + self.logger.warning("⚠ Could not get window size; using fallback coords") + return (500, 300) + + def _wait_for_ui_response( + self, timeout: int = 5, poll_interval: float = 0.5 + ) -> bool: + deadline = time.time() + timeout + + while time.time() < deadline: + if not self._should_perform_activation_tap(): + return True + time.sleep(poll_interval) + + return False diff --git a/test/e2e_appium/services/app_state_manager.py b/test/e2e_appium/services/app_state_manager.py new file mode 100644 index 00000000000..997f9cccf09 --- /dev/null +++ b/test/e2e_appium/services/app_state_manager.py @@ -0,0 +1,126 @@ +import time + +from config.logging_config import get_logger +from core.models import TestAppState + + +class AppStateManager: + + def __init__(self, driver): + self.driver = driver + self.logger = get_logger("app_state_manager") + self.state = TestAppState() + + def detect_current_state(self) -> TestAppState: + self.logger.debug("🔍 Detecting app state...") + + if self._is_welcome_screen_displayed(): + self._set_welcome_state() + elif self._is_welcome_back_displayed(): + self._set_welcome_back_state() + elif self._is_app_section_loaded(): + self._set_app_section_state() + elif self._is_home_loaded(): + self._set_home_state() + else: + self._set_unknown_state() + + return self.state + + def wait_for_app_ready(self, timeout: int = 30, poll_interval: float = 0.5) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + try: + self.detect_current_state() + if self.state.is_home_loaded or self.state.requires_authentication: + return True + except Exception: + pass + time.sleep(poll_interval) + return False + + def _is_welcome_screen_displayed(self) -> bool: + try: + from pages.onboarding import WelcomePage + + welcome = WelcomePage(self.driver) + return welcome.is_screen_displayed(timeout=3) + except Exception: + return False + + def _is_welcome_back_displayed(self) -> bool: + try: + from pages.onboarding import WelcomeBackPage + + welcome_back = WelcomeBackPage(self.driver) + return welcome_back.is_welcome_back_screen_displayed(timeout=5) + except Exception: + return False + + def _is_app_section_loaded(self) -> bool: + try: + from pages.app import App + + app = App(self.driver) + section = app.active_section() + return section in ( + "home", + "messaging", + "wallet", + "communities", + "market", + "settings", + ) + except Exception: + return False + + def _is_home_loaded(self) -> bool: + try: + from pages.onboarding import HomePage + + main_app = HomePage(self.driver) + return main_app.is_home_loaded() + except Exception: + return False + + def _set_welcome_state(self): + self.state.is_home_loaded = False + self.state.current_screen = "welcome" + self.state.requires_authentication = False + self.state.has_existing_profiles = False + self.logger.debug("✓ Welcome screen detected") + + def _set_welcome_back_state(self): + self.state.has_existing_profiles = True + self.state.current_screen = "welcome_back" + self.state.requires_authentication = True + self.logger.debug("✓ Welcome back screen detected") + + def _set_app_section_state(self): + try: + from pages.app import App + + app = App(self.driver) + section = app.active_section() + if section == "home": + self.state.is_home_loaded = True + self.state.current_screen = "home" + self.state.requires_authentication = False + self.logger.debug("✓ Home detected (container)") + else: + self.state.is_home_loaded = False + self.state.current_screen = section + self.state.requires_authentication = False + self.logger.debug(f"✓ Section detected: {section}") + except Exception: + self._set_unknown_state() + + def _set_home_state(self): + self.state.is_home_loaded = True + self.state.current_screen = "home" + self.state.requires_authentication = False + self.logger.debug("✓ Home detected (fallback)") + + def _set_unknown_state(self): + self.state.current_screen = "unknown" + self.logger.debug("? Unknown app state detected") diff --git a/test/e2e_appium/services/user_profile_service.py b/test/e2e_appium/services/user_profile_service.py new file mode 100644 index 00000000000..1ae02855ae5 --- /dev/null +++ b/test/e2e_appium/services/user_profile_service.py @@ -0,0 +1,223 @@ +import time +from typing import Optional, Dict, Any + +from config.logging_config import get_logger +from core.models import TestUser +from utils.generators import generate_seed_phrase +from utils.onboarding_classes import ProfileCreationFlow, ProfileCreationConfig +from core.environment import ConfigurationError +from utils.exceptions import ( + SessionManagementError, +) + + +class UserProfileService: + + def __init__(self, driver, performance_monitor=None): + self.driver = driver + self.performance = performance_monitor + self.logger = get_logger("user_profile_service") + self._current_user: Optional[TestUser] = None + + @property + def current_user(self) -> Optional[TestUser]: + return self._current_user + + def create_profile( + self, + method: str = "password", + seed_phrase: Optional[str] = None, + password: str = "StatusPassword123!", + display_name: str = "TestUser", + config: Optional[Dict[str, Any]] = None, + ) -> TestUser: + """Create a new user profile using specified method.""" + operation_name = f"create_profile_{method}" + + if self.performance: + self.performance.start_timer(operation_name) + + try: + self.logger.info( + f"🎯 Creating user profile: {display_name} (method: {method})" + ) + + if method == "password": + user = TestUser( + display_name=display_name, + password=password, + source="created_password", + ) + elif method == "seed_phrase": + user = TestUser( + display_name=display_name, + password=password, + seed_phrase=seed_phrase or generate_seed_phrase(), + source="created_seed_phrase", + ) + elif method == "random": + user = TestUser( + display_name=f"{display_name}_{int(time.time())}", + password=password, + seed_phrase=generate_seed_phrase(), + source="created_random", + ) + else: + raise ConfigurationError(f"Invalid profile creation method: {method}") + + # Execute profile creation via ProfileCreationFlow + success = self._execute_profile_creation(user, method, config) + if not success: + raise SessionManagementError( + f"Failed to create profile for {display_name}" + ) + + self._current_user = user + self.logger.info(f"✅ User profile created: {user.display_name}") + + if self.performance: + self.performance.end_timer(operation_name) + + return user + + except Exception: + if self.performance: + self.performance.end_timer(operation_name) + raise + + def login_existing_user(self, password: str = "StatusPassword123!") -> TestUser: + """Login to existing user profile.""" + if self.performance: + self.performance.start_timer("login_existing_user") + + try: + self.logger.info("🔑 Attempting to login with existing user") + + # Import required page objects + from pages.onboarding import WelcomeBackPage, HomePage + + welcome_back = WelcomeBackPage(self.driver) + main_app = HomePage(self.driver) + + if welcome_back.is_welcome_back_screen_displayed(timeout=10): + success = welcome_back.perform_login(password) + if not success: + raise SessionManagementError("Login to existing profile failed") + + if main_app.wait_for_home_load(timeout=30): + user = TestUser( + display_name="ExistingUser", + password=password, + source="existing_profile", + ) + + self._current_user = user + self.logger.info("✅ Successfully logged into existing profile") + + if self.performance: + self.performance.end_timer("login_existing_user") + + return user + + raise SessionManagementError("Could not complete login to existing profile") + + except Exception: + if self.performance: + self.performance.end_timer("login_existing_user") + raise + + def import_test_account( + self, + account: Dict[str, Any], + simulate_returning: bool = False, + login_only: bool = False, + default_display_name: str = "TestUser", + ) -> bool: + """Import or log into a predefined test account.""" + display_name = account.get("display_name", default_display_name) + password = account.get("password", "StatusPassword123!") + seed_phrase = account.get("seed_phrase") + + if login_only: + try: + self._current_user = TestUser( + display_name=display_name, + password=password, + seed_phrase=seed_phrase, + source="existing_profile", + ) + self.login_existing_user(password=password) + return True + except Exception as e: + self.logger.error(f"Login-only path failed: {e}") + return False + + try: + if not seed_phrase: + self.logger.warning( + "No seed_phrase provided; falling back to password profile creation" + ) + self.create_profile( + method="password", password=password, display_name=display_name + ) + else: + self.create_profile( + method="seed_phrase", + seed_phrase=seed_phrase, + password=password, + display_name=display_name, + ) + + if simulate_returning: + # Would need app restart capability - delegate to calling code + self.logger.info("Simulate returning user requested - restart needed") + + return True + + except Exception as e: + self.logger.error(f"import_test_account failed: {e}") + return False + + def _execute_profile_creation( + self, user: TestUser, method: str, config: Optional[Dict[str, Any]] + ) -> bool: + try: + # Map to ProfileCreationConfig + use_seed = method == "seed_phrase" + flow_config = ProfileCreationConfig( + use_seed_phrase=use_seed, + seed_phrase=user.seed_phrase if use_seed else None, + password=user.password, + display_name=user.display_name, + skip_analytics=True, + validate_each_step=config.get("validate_steps", True) + if config + else True, + take_screenshots=config.get("take_screenshots", False) + if config + else False, + timeout_seconds=60, + ) + + self.logger.info( + f"Executing ProfileCreationFlow for user '{user.display_name}' (method={method})" + ) + flow = ProfileCreationFlow(self.driver, flow_config, self.logger) + result = flow.execute_complete_flow() + + if not result or not result.get("success"): + self.logger.error( + f"ProfileCreationFlow failed for '{user.display_name}': {result}" + ) + return False + + # Extract user data from flow result if present + data = result.get("user_data") or {} + display_name = data.get("display_name") or user.display_name + user.display_name = display_name + + return True + + except Exception as e: + self.logger.error(f"Profile creation error: {e}") + return False diff --git a/test/e2e_appium/utils/app_lifecycle_manager.py b/test/e2e_appium/utils/app_lifecycle_manager.py new file mode 100644 index 00000000000..2c7f108b8d8 --- /dev/null +++ b/test/e2e_appium/utils/app_lifecycle_manager.py @@ -0,0 +1,128 @@ +""" +App lifecycle management utilities. + +Handles app restart, termination, and data clearing operations. +Extracted from BasePage to follow Single Responsibility Principle. +""" + +import os +import subprocess + +from config.logging_config import get_logger + + +class AppLifecycleManager: + + def __init__(self, driver): + self.driver = driver + self.logger = get_logger("app_lifecycle") + + def restart_app(self, app_package: str = "im.status.tablet") -> bool: + """ + Restart the app within the current session. + + Useful for testing session persistence, returning user scenarios, + or app state recovery after restart. + + Args: + app_package: App package name (defaults to Status tablet) + + Returns: + bool: True if restart was successful + """ + try: + self.logger.info(f"🔄 Restarting app: {app_package}") + + self.driver.terminate_app(app_package) + self.logger.debug("✓ App terminated") + + self.driver.activate_app(app_package) + self.logger.debug("✓ App reactivated") + + self.logger.info("✅ App restart completed successfully") + return True + + except Exception as e: + self.logger.error(f"❌ App restart failed: {e}") + return False + + def restart_app_with_data_cleared( + self, app_package: str = "im.status.tablet" + ) -> bool: + """ + Restart the app with all app data cleared (fresh app state). + + This completely removes app data and cache, then relaunches the app. + Useful for testing fresh onboarding flows. + + Args: + app_package: The app package identifier + + Returns: + bool: True if restart successful, False otherwise + """ + try: + self.logger.info("🔄 Restarting app with data cleared...") + + # Cloud environments typically disallow ADB; skip and advise new session + env_name = os.getenv("CURRENT_TEST_ENVIRONMENT", "lambdatest").lower() + if env_name in ("lt", "lambdatest"): + self.logger.warning( + "Cloud run detected; skipping ADB data clear. Use a new session with noReset=false/fullReset." + ) + return False + + self.driver.terminate_app(app_package) + self.logger.debug("✓ App terminated") + + clear_data_result = subprocess.run( + ["adb", "shell", "pm", "clear", app_package], + capture_output=True, + text=True, + ) + + if clear_data_result.returncode != 0: + self.logger.warning( + f"⚠️ Clear app data failed: {clear_data_result.stderr}" + ) + else: + self.logger.debug("✓ App data cleared") + + self.driver.activate_app(app_package) + self.logger.debug("✓ App reactivated with fresh state") + + # Optional activation tap + try: + from utils.gestures import Gestures + + gestures = Gestures(self.driver) + gestures.tap(500, 300) + except Exception: + pass + + self.logger.info("✅ App restart with cleared data completed successfully") + return True + + except Exception as e: + self.logger.error(f"❌ App restart with data cleared failed: {e}") + return False + + def terminate_app(self, app_package: str = "im.status.tablet") -> bool: + """Terminate the specified app.""" + try: + self.driver.terminate_app(app_package) + self.logger.debug(f"✓ App terminated: {app_package}") + return True + except Exception as e: + self.logger.error(f"❌ Failed to terminate app: {e}") + return False + + def activate_app(self, app_package: str = "im.status.tablet") -> bool: + """Activate the specified app.""" + try: + self.driver.activate_app(app_package) + self.logger.debug(f"✓ App activated: {app_package}") + return True + except Exception as e: + self.logger.error(f"❌ Failed to activate app: {e}") + return False diff --git a/test/e2e_appium/utils/element_state_checker.py b/test/e2e_appium/utils/element_state_checker.py new file mode 100644 index 00000000000..7b7c29cdc60 --- /dev/null +++ b/test/e2e_appium/utils/element_state_checker.py @@ -0,0 +1,69 @@ + + + +class ElementStateChecker: + """Static utility methods for element state checks.""" + + @staticmethod + def is_enabled(element) -> bool: + try: + return str(element.get_attribute("enabled")).lower() == "true" + except Exception: + return False + + @staticmethod + def is_checked(element) -> bool: + try: + return str(element.get_attribute("checked")).lower() == "true" + except Exception: + return False + + @staticmethod + def is_focused(element) -> bool: + try: + return str(element.get_attribute("focused")).lower() == "true" + except Exception: + return False + + @staticmethod + def is_displayed(element) -> bool: + try: + return element.is_displayed() + except Exception: + return False + + @staticmethod + def get_text_content(element) -> str: + try: + # Try different attributes in order of preference + for attr in ("text", "content-desc", "name"): + value = element.get_attribute(attr) + if value and value.strip(): + return value.strip() + return "" + except Exception: + return "" + + @staticmethod + def is_password_field(element) -> bool: + try: + resource_id = element.get_attribute("resource-id") or "" + content_desc = element.get_attribute("content-desc") or "" + + return ( + "password" in resource_id.lower() + or content_desc.lower() == "type password" + ) + except Exception: + return False + + @staticmethod + def is_field_empty(element) -> bool: + try: + for attr in ("text", "content-desc", "name", "hint"): + val = element.get_attribute(attr) + if val and len(val.strip()) > 0: + return False + return True + except Exception: + return True diff --git a/test/e2e_appium/utils/exceptions.py b/test/e2e_appium/utils/exceptions.py new file mode 100644 index 00000000000..ee06ad6cadf --- /dev/null +++ b/test/e2e_appium/utils/exceptions.py @@ -0,0 +1,56 @@ +""" +Custom exceptions for the e2e_appium framework. + +Centralized exception definitions to avoid circular imports and provide +clear error handling throughout the test automation framework. +""" + +from typing import Dict, Any, Optional + + +class ProfileCreationFlowError(Exception): + """ + Custom exception for profile creation and onboarding flow failures. + + Provides structured error information including step context and execution results. + """ + + def __init__( + self, + message: str, + step: Optional[str] = None, + results: Optional[Dict[str, Any]] = None, + ): + super().__init__(message) + self.step = step + self.results = results or {} + + +class SessionManagementError(Exception): + """Exception for Appium session management failures.""" + + pass + + +class ElementInteractionError(Exception): + """Exception for element interaction failures (clicks, input, etc.).""" + + def __init__(self, message: str, locator: str = None, action: str = None): + super().__init__(message) + self.locator = locator + self.action = action + + +class PageLoadError(Exception): + """Exception for page/screen loading failures.""" + + def __init__(self, message: str, page_name: str = None, timeout: int = None): + super().__init__(message) + self.page_name = page_name + self.timeout = timeout + + +class TestContextError(Exception): + """Exception for TestContext state management issues.""" + + pass diff --git a/test/e2e_appium/utils/gestures.py b/test/e2e_appium/utils/gestures.py new file mode 100644 index 00000000000..1d9921672e1 --- /dev/null +++ b/test/e2e_appium/utils/gestures.py @@ -0,0 +1,104 @@ +class Gestures: + + def __init__(self, driver): + self._driver = driver + + def tap(self, x: int, y: int) -> bool: + try: + self._driver.execute_script("mobile: clickGesture", {"x": x, "y": y}) + return True + except Exception: + return False + + def long_press(self, element_id: str, duration_ms: int = 800) -> bool: + try: + self._driver.execute_script( + "mobile: longClickGesture", + {"elementId": element_id, "duration": duration_ms}, + ) + return True + except Exception: + return False + + def swipe_down( + self, left: int, top: int, width: int, height: int, percent: float = 0.8 + ) -> bool: + try: + self._driver.execute_script( + "mobile: swipeGesture", + { + "left": left, + "top": top, + "width": width, + "height": height, + "direction": "down", + "percent": percent, + }, + ) + return True + except Exception: + return False + + def swipe_up( + self, left: int, top: int, width: int, height: int, percent: float = 0.8 + ) -> bool: + try: + self._driver.execute_script( + "mobile: swipeGesture", + { + "left": left, + "top": top, + "width": width, + "height": height, + "direction": "up", + "percent": percent, + }, + ) + return True + except Exception: + return False + + def element_tap(self, element) -> bool: + try: + self._driver.execute_script( + "mobile: clickGesture", {"elementId": element.id} + ) + return True + except Exception: + return False + + def double_tap(self, x: int, y: int) -> bool: + """Attempt a double-tap at coordinates; fallback to two single taps.""" + try: + # Preferred: use count=2 if supported by the driver + self._driver.execute_script( + "mobile: clickGesture", {"x": x, "y": y, "count": 2} + ) + return True + except Exception: + # Fallback: two rapid single taps + try: + self._driver.execute_script("mobile: clickGesture", {"x": x, "y": y}) + self._driver.execute_script("mobile: clickGesture", {"x": x, "y": y}) + return True + except Exception: + return False + + def element_double_tap(self, element) -> bool: + """Attempt a double-tap on an element; fallback to two element taps.""" + try: + self._driver.execute_script( + "mobile: clickGesture", {"elementId": element.id, "count": 2} + ) + return True + except Exception: + try: + self._driver.execute_script( + "mobile: clickGesture", {"elementId": element.id} + ) + self._driver.execute_script( + "mobile: clickGesture", {"elementId": element.id} + ) + return True + except Exception: + return False diff --git a/test/e2e_appium/utils/keyboard_manager.py b/test/e2e_appium/utils/keyboard_manager.py new file mode 100644 index 00000000000..f7e7e6bc3b4 --- /dev/null +++ b/test/e2e_appium/utils/keyboard_manager.py @@ -0,0 +1,95 @@ +from config.logging_config import get_logger +from utils.gestures import Gestures + + +class KeyboardManager: + + def __init__(self, driver): + self.driver = driver + self.gestures = Gestures(driver) + self.logger = get_logger("keyboard_manager") + + def hide_keyboard(self) -> bool: + try: + try: + self.driver.hide_keyboard() + self.logger.info("Keyboard hidden successfully using hide_keyboard()") + return True + except Exception as e: + self.logger.debug(f"hide_keyboard() failed: {e}") + + try: + self.driver.back() + self.logger.info("Keyboard hidden using back button") + return True + except Exception as e: + self.logger.debug(f"Back button failed: {e}") + + try: + size = self.driver.get_window_size() + center_x = size["width"] // 2 + top = size["height"] // 3 + height = size["height"] // 3 + + self.gestures.swipe_down(max(0, center_x - 10), top, 20, height, 0.8) + self.logger.info("Keyboard hidden using swipe gesture") + return True + except Exception as e: + self.logger.debug(f"Swipe gesture failed: {e}") + + self.logger.warning("All keyboard hiding strategies failed") + return False + + except Exception as e: + self.logger.error(f"Error hiding keyboard: {e}") + return False + + def ensure_element_visible( + self, locator, is_element_visible_func, timeout=10 + ) -> bool: + try: + if is_element_visible_func(locator, timeout=2): + return True + + self.logger.info("Element not visible, attempting to hide keyboard") + if self.hide_keyboard(): + return is_element_visible_func(locator, timeout=timeout) + + return False + + except Exception as e: + self.logger.error(f"Error ensuring element visibility: {e}") + return False + + def dismiss_keyboard_in_modal( + self, modal_root_locator, find_element_safe_func + ) -> bool: + """Dismiss keyboard by tapping within modal header area (avoids Back navigation).""" + try: + root = find_element_safe_func(modal_root_locator, timeout=1) + if not root: + try: + size = self.driver.get_window_size() + self.gestures.swipe_down( + int(size["width"] * 0.5) - 10, + int(size["height"] * 0.3), + 20, + int(size["height"] * 0.2), + 0.6, + ) + return True + except Exception: + return False + + rect = root.rect + x = int(rect.get("x", 0)) + int(rect.get("width", 800)) // 2 + y = int(rect.get("y", 0)) + 24 + + try: + self.gestures.tap(x, y) + return True + except Exception: + return False + + except Exception: + return False diff --git a/test/e2e_appium/utils/onboarding_classes.py b/test/e2e_appium/utils/onboarding_classes.py new file mode 100644 index 00000000000..bcffc262002 --- /dev/null +++ b/test/e2e_appium/utils/onboarding_classes.py @@ -0,0 +1,246 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional, Dict, Any + +from pages.onboarding import ( + WelcomePage, + AnalyticsPage, + CreateProfilePage, + SeedPhraseInputPage, + PasswordPage, + SplashScreen, +) +from models.user_model import User, UserProfile +from utils.generators import generate_seed_phrase +from utils.exceptions import ProfileCreationFlowError + + +@dataclass +class ProfileCreationConfig: + """Configuration for onboarding flow execution.""" + + use_seed_phrase: bool = False + seed_phrase: Optional[str] = None + password: str = "StatusPassword123!" + display_name: str = "AutoTestUser" + skip_analytics: bool = True + validate_each_step: bool = True + take_screenshots: bool = False + timeout_seconds: int = 60 + + +class ProfileCreationFlow: + """ + Manages the complete onboarding flow execution. + + This class orchestrates all onboarding steps and provides detailed + execution results for analysis and debugging. + """ + + def __init__(self, driver, config: ProfileCreationConfig, logger): + self.driver = driver + self.config = config + self.logger = logger + self.start_time = datetime.now() + self.current_step = None + self.step_results = {} + self.test_user = None + + # Initialize page objects + self.welcome_page = WelcomePage(driver) + self.analytics_page = AnalyticsPage(driver) + self.create_profile_page = CreateProfilePage(driver) + self.seed_phrase_page = SeedPhraseInputPage(driver) + self.password_page = PasswordPage(driver) + self.splash_screen = SplashScreen(driver) + + def execute_complete_flow(self) -> Dict[str, Any]: + """ + Execute the complete onboarding flow. + + Returns: + Dict with execution results including success status, user data, + timing information, and step-by-step results. + """ + try: + self.logger.info("🚀 Starting complete onboarding flow execution") + + self._execute_welcome_step() + self._execute_analytics_step(skip=self.config.skip_analytics) + + if self.config.use_seed_phrase: + self._execute_seed_phrase_step() + else: + self._execute_create_profile_step() + + self._execute_password_step() + self._execute_loading_step() + + return self._build_success_result() + + except Exception as e: + self.logger.error( + f"❌ Onboarding flow failed at step '{self.current_step}': {e}" + ) + return self._build_error_result(e) + + def _execute_welcome_step(self): + self.current_step = "welcome_screen" + start_time = datetime.now() + + if not self.welcome_page.is_screen_displayed(timeout=30): + raise ProfileCreationFlowError("Welcome screen should be displayed") + + if not self.welcome_page.click_create_profile(): + raise ProfileCreationFlowError("Failed to click Create Profile button") + + self.step_results[self.current_step] = { + "success": True, + "duration_seconds": (datetime.now() - start_time).total_seconds(), + } + self.logger.debug("✓ Welcome screen step completed") + + def _execute_analytics_step(self, skip: bool = True): + self.current_step = "analytics_screen" + start_time = datetime.now() + + if self.analytics_page.is_screen_displayed(timeout=10): + self.logger.debug("📊 Analytics screen displayed") + + if skip: + if not self.analytics_page.skip_analytics_sharing(): + raise ProfileCreationFlowError("Failed to skip analytics") + action = "skipped" + else: + if not self.analytics_page.enable_analytics_sharing(): + raise ProfileCreationFlowError("Failed to enable analytics") + action = "enabled" + + self.step_results[self.current_step] = { + "success": True, + "action": action, + "duration_seconds": (datetime.now() - start_time).total_seconds(), + } + self.logger.debug(f"✓ Analytics screen {action}") + else: + self.logger.debug("📊 No analytics screen found - continuing") + self.step_results[self.current_step] = { + "success": True, + "action": "not_displayed", + "duration_seconds": (datetime.now() - start_time).total_seconds(), + } + + def _execute_create_profile_step(self): + self.current_step = "create_profile_screen" + start_time = datetime.now() + + if not self.create_profile_page.is_screen_displayed(): + raise ProfileCreationFlowError("Create profile screen should be displayed") + + # Generate user for this session + profile = UserProfile(display_name=self.config.display_name) + self.test_user = User(profile=profile, password=self.config.password) + + # Click "Let's go!" to start new profile creation + if not self.create_profile_page.click_lets_go(): + raise ProfileCreationFlowError("Failed to click Let's go button") + + self.step_results[self.current_step] = { + "success": True, + "display_name": self.test_user.profile.display_name, + "duration_seconds": (datetime.now() - start_time).total_seconds(), + } + self.logger.debug("✓ Create profile step completed") + + def _execute_seed_phrase_step(self): + """Execute seed phrase input step.""" + self.current_step = "seed_phrase_screen" + start_time = datetime.now() + + if not self.seed_phrase_page.is_screen_displayed(): + raise ProfileCreationFlowError("Seed phrase screen should be displayed") + + # Use provided seed phrase or generate one + seed_phrase = self.config.seed_phrase or generate_seed_phrase() + + # Generate user for this session + profile = UserProfile(display_name=self.config.display_name) + self.test_user = User( + profile=profile, + password=self.config.password, + recovery_phrase=seed_phrase, + ) + + if not self.seed_phrase_page.import_seed_phrase(seed_phrase): + raise ProfileCreationFlowError("Failed to import seed phrase") + + self.step_results[self.current_step] = { + "success": True, + "seed_phrase_length": len(seed_phrase.split()), + "duration_seconds": (datetime.now() - start_time).total_seconds(), + } + self.logger.debug("✓ Seed phrase step completed") + + def _execute_password_step(self): + """Execute password creation step.""" + self.current_step = "password_screen" + start_time = datetime.now() + + if not self.password_page.is_screen_displayed(): + raise ProfileCreationFlowError("Password screen should be displayed") + + if not self.password_page.create_password(self.config.password): + raise ProfileCreationFlowError("Failed to create password") + + self.step_results[self.current_step] = { + "success": True, + "duration_seconds": (datetime.now() - start_time).total_seconds(), + } + self.logger.debug("✓ Password creation step completed") + + def _execute_loading_step(self): + """Execute app loading step.""" + self.current_step = "app_loading" + start_time = datetime.now() + + if not self.splash_screen.wait_for_loading_completion(): + raise ProfileCreationFlowError("App loading failed or timed out") + + self.step_results[self.current_step] = { + "success": True, + "duration_seconds": (datetime.now() - start_time).total_seconds(), + } + self.logger.debug("✓ App loading completed") + + def _build_success_result(self) -> Dict[str, Any]: + """Build success result dictionary""" + end_time = datetime.now() + duration = (end_time - self.start_time).total_seconds() + + return { + "success": True, + "user_data": self.test_user.to_test_data(), + "execution_time_seconds": duration, + "steps_completed": list(self.step_results.keys()), + "step_results": self.step_results, + "config": self.config, + "start_time": self.start_time.isoformat(), + "end_time": end_time.isoformat(), + } + + def _build_error_result(self, error: Exception) -> Dict[str, Any]: + """Build error result dictionary""" + end_time = datetime.now() + duration = (end_time - self.start_time).total_seconds() + + return { + "success": False, + "error": str(error), + "failed_step": self.current_step, + "execution_time_seconds": duration, + "steps_completed": list(self.step_results.keys()), + "step_results": self.step_results, + "config": self.config, + "start_time": self.start_time.isoformat(), + "end_time": end_time.isoformat(), + } diff --git a/test/e2e_appium/utils/performance_monitor.py b/test/e2e_appium/utils/performance_monitor.py new file mode 100644 index 00000000000..86989662be0 --- /dev/null +++ b/test/e2e_appium/utils/performance_monitor.py @@ -0,0 +1,61 @@ + +import time +from typing import Dict, Any +from contextlib import contextmanager +from datetime import datetime + +from config.logging_config import get_logger + + +class PerformanceMonitor: + + def __init__(self, logger_name: str = "performance"): + self.logger = get_logger(logger_name) + self.metrics: Dict[str, Any] = {} + self.start_times: Dict[str, float] = {} + + @contextmanager + def measure_operation(self, operation_name: str): + start_time = time.time() + self.start_times[operation_name] = start_time + + try: + yield + finally: + duration = time.time() - start_time + self.metrics[operation_name] = { + "duration_ms": int(duration * 1000), + "timestamp": datetime.now().isoformat(), + } + self.logger.debug(f"⏱️ {operation_name}: {duration:.3f}s") + + def start_timer(self, operation_name: str) -> None: + self.start_times[operation_name] = time.time() + + def end_timer(self, operation_name: str) -> float: + if operation_name not in self.start_times: + self.logger.warning(f"No start time found for {operation_name}") + return 0.0 + + duration = time.time() - self.start_times[operation_name] + self.metrics[operation_name] = { + "duration_ms": int(duration * 1000), + "timestamp": datetime.now().isoformat(), + } + del self.start_times[operation_name] + return duration + + def get_summary(self) -> Dict[str, Any]: + if not self.metrics: + return {"total_operations": 0} + + durations = [m["duration_ms"] for m in self.metrics.values()] + return { + "total_operations": len(self.metrics), + "total_duration_ms": sum(durations), + "average_duration_ms": sum(durations) // len(durations), + "slowest_operation": max( + self.metrics.items(), key=lambda x: x[1]["duration_ms"] + ), + "operations": self.metrics, + } diff --git a/test/e2e_appium/utils/screenshot.py b/test/e2e_appium/utils/screenshot.py new file mode 100644 index 00000000000..e93b50a6cfc --- /dev/null +++ b/test/e2e_appium/utils/screenshot.py @@ -0,0 +1,49 @@ +import re +from datetime import datetime +from pathlib import Path +from typing import Optional + + +def _sanitize(name: str) -> str: + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", name.strip()) + return safe[:120] if len(safe) > 120 else safe + + +def build_screenshot_path(base_dir: str, name: Optional[str] = None) -> Path: + base = Path(base_dir or "screenshots") + base.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + stem = _sanitize(name or "screenshot") + return base / f"{stem}_{ts}.png" + + +def save_screenshot(driver, base_dir: str, name: Optional[str] = None) -> Optional[str]: + try: + path = build_screenshot_path(base_dir, name) + # Some drivers return bool, others return path; normalize to path string + _ = driver.get_screenshot_as_file(str(path)) + return str(path) + except Exception: + return None + + +def build_pagesource_path(base_dir: str, name: Optional[str] = None) -> Path: + base = Path(base_dir or "screenshots") + base.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + stem = _sanitize(name or "page_source") + return base / f"{stem}_{ts}.xml" + + +def save_page_source( + driver, base_dir: str, name: Optional[str] = None +) -> Optional[str]: + try: + path = build_pagesource_path(base_dir, name) + # page_source returns a string containing XML + xml = driver.page_source + with open(path, "w", encoding="utf-8") as f: + f.write(xml if isinstance(xml, str) else str(xml)) + return str(path) + except Exception: + return None diff --git a/test/e2e_appium/utils/toasts.py b/test/e2e_appium/utils/toasts.py new file mode 100644 index 00000000000..e09925e9f90 --- /dev/null +++ b/test/e2e_appium/utils/toasts.py @@ -0,0 +1,54 @@ +from typing import List + +from appium.webdriver.common.appiumby import AppiumBy +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + +from config.logging_config import get_logger + + +class Toasts: + + def __init__(self, driver): + self.driver = driver + self.logger = get_logger("toasts") + + def wait_for_messages(self, timeout: int = 5) -> List[str]: + messages: List[str] = [] + wait = WebDriverWait(self.driver, timeout) + + # Strategy 1: Native Android Toast widget + try: + toast = wait.until( + EC.presence_of_element_located( + (AppiumBy.XPATH, "//android.widget.Toast") + ) + ) + text = toast.get_attribute("text") or "" + if text: + messages.append(text) + except Exception: + pass + + # Strategy 2: Elements containing success/removed/added + xpath_patterns = [ + "//*[contains(@text, 'success')]", + "//*[contains(@text, 'removed')]", + "//*[contains(@text, 'added')]", + "//*[contains(@content-desc, 'success')]", + ] + for xp in xpath_patterns: + try: + els = self.driver.find_elements(AppiumBy.XPATH, xp) + for el in els: + txt = ( + el.get_attribute("text") + or el.get_attribute("content-desc") + or "" + ) + if txt and txt not in messages: + messages.append(txt) + except Exception: + continue + + return messages From ccbddebf042e10b099116dd50e295e83de7ec95a Mon Sep 17 00:00:00 2001 From: Mg <50769329+glitchminer@users.noreply.github.com> Date: Tue, 9 Sep 2025 12:02:15 +0100 Subject: [PATCH 6/8] test(e2e_appium): workflow --- .../e2e_appium/config/environments/local.yaml | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/test/e2e_appium/config/environments/local.yaml b/test/e2e_appium/config/environments/local.yaml index fbc55badbb6..68f73fd1a58 100644 --- a/test/e2e_appium/config/environments/local.yaml +++ b/test/e2e_appium/config/environments/local.yaml @@ -7,17 +7,31 @@ metadata: device: name: "sdk_gphone64_arm64" platform_version: "15" + platform_name: "android" + +devices: + - name: "sdk_gphone64_arm64" + platform_name: "android" + platform_version: "12L" + tags: ["emulator", "phone", "android"] + - name: "sdk_google_atv64_arm64" + platform_name: "android" + platform_version: "14" + tags: ["emulator", "android", "tv"] appium: server_url: "http://localhost:4723" app: + # Prefer attaching to already-installed package when path not provided source_type: "local_file" - path_template: "${LOCAL_APP_PATH}" + path_template: "${LOCAL_APP_PATH:-}" timeouts: default: 60 - element_wait: 45 + element_wait: 30 + element_find: 10 + element_click: 10 logging: level: "DEBUG" @@ -32,4 +46,8 @@ capabilities: platformName: "android" automationName: "UiAutomator2" newCommandTimeout: 300 - noReset: false \ No newline at end of file + noReset: false + appPackage: "im.status.tablet" + appActivity: "org.qtproject.qt.android.bindings.QtActivity" + unicodeKeyboard: true + resetKeyboard: true \ No newline at end of file From 0129ec712fd64e7d9daa463b03c95085c3ca3ed3 Mon Sep 17 00:00:00 2001 From: Mg <50769329+glitchminer@users.noreply.github.com> Date: Thu, 11 Sep 2025 14:22:17 +0100 Subject: [PATCH 7/8] test(e2e_appium): test_saved_addresses Adds test for add saved addresses flow. Added an alias for the menu button in SavedAddressesDelegate.qml to allow tests to access button. --- .../wallet/saved_addresses_locators.py | 67 +++++++++++++++++ test/e2e_appium/pages/base_page.py | 16 +++- .../pages/settings/settings_page.py | 23 +++++- .../pages/wallet/add_saved_address_modal.py | 44 +++++++++++ .../pages/wallet/saved_addresses_page.py | 74 +++++++++++++++++++ test/e2e_appium/pytest.ini | 4 +- test/e2e_appium/tests/test_saved_addresses.py | 47 ++++++++++++ test/e2e_appium/utils/generators.py | 15 ++++ .../controls/SavedAddressesDelegate.qml | 4 + 9 files changed, 289 insertions(+), 5 deletions(-) create mode 100644 test/e2e_appium/locators/wallet/saved_addresses_locators.py create mode 100644 test/e2e_appium/pages/wallet/add_saved_address_modal.py create mode 100644 test/e2e_appium/pages/wallet/saved_addresses_page.py create mode 100644 test/e2e_appium/tests/test_saved_addresses.py diff --git a/test/e2e_appium/locators/wallet/saved_addresses_locators.py b/test/e2e_appium/locators/wallet/saved_addresses_locators.py new file mode 100644 index 00000000000..d90cf4f85a2 --- /dev/null +++ b/test/e2e_appium/locators/wallet/saved_addresses_locators.py @@ -0,0 +1,67 @@ +from ..base_locators import BaseLocators + + +class SavedAddressesLocators(BaseLocators): + WALLET_SAVED_ADDRESSES_BUTTON = BaseLocators.xpath("//android.view.View.VirtualChild[@content-desc=\"Saved addresses [tid:savedAddressesBtn]\"]") + SETTINGS_WALLET_MENU_ITEM = BaseLocators.content_desc_contains("[tid:5-MenuItem]") + SAVED_ADDRESSES_ITEM = BaseLocators.xpath( + "//*[contains(@resource-id, 'savedAddressesItem') or contains(@content-desc, 'Saved Addresses')]" + ) + ADD_NEW_SAVED_ADDRESS_BUTTON_SETTINGS = BaseLocators.xpath( + "//android.view.View.VirtualChild[@content-desc=\"Add new address [tid:addNewSavedAddressButton]\"]" + ) + ADD_NEW_SAVED_ADDRESS_BUTTON_WALLET = BaseLocators.xpath( + "//android.view.View.VirtualChild[@content-desc=\"Add new address [tid:walletHeaderButton]\"]" + ) + SAVED_ADDRESS_ITEM_ANY = BaseLocators.xpath( + "//android.view.View.VirtualChild[@resource-id=\"savedAddressDelegate\"]" + ) + SAVED_ADDRESS_DETAILS_POPUP = BaseLocators.xpath( + "//*[contains(@resource-id, 'SavedAddressActivityPopup')]" + ) + POPUP_MENU_BUTTON_GENERIC = BaseLocators.xpath( + "//*[contains(@resource-id,'SavedAddressActivityPopup')]//*[contains(@resource-id, 'savedAddressView_Delegate_menuButton_')]" + ) + POPUP_MENU_BUTTON_TID = BaseLocators.content_desc_contains("tid:savedAddressMenuButton") + @staticmethod + def row_by_name(name: str) -> tuple: + return BaseLocators.xpath( + "//android.view.View.VirtualChild[@resource-id=\"savedAddressDelegate\"]" + + f"//*[contains(@resource-id, 'savedAddressView_Delegate_{name}')]" + ) + + @staticmethod + def row_menu_by_name(name: str) -> tuple: + return BaseLocators.xpath( + "//android.view.View.VirtualChild[" + + f"contains(@resource-id, 'savedAddressView_Delegate_{name}') and " + + f"contains(@resource-id, 'savedAddressView_Delegate_menuButton_{name}')" + + "]" + ) + + @staticmethod + def popup_menu_by_name(name: str) -> tuple: + return BaseLocators.xpath( + "//android.view.View.VirtualChild[" + + "contains(@resource-id,'QGuiApplication.mainWindow.SavedAddressActivityPopup') and " + + f"contains(@resource-id, 'savedAddressView_Delegate_menuButton_{name}')" + + "]" + ) + NAME_INPUT = BaseLocators.xpath( + "//android.view.View.VirtualChild[@content-desc=\"Address name [tid:statusBaseInput]\"]" + ) + ADDRESS_INPUT = BaseLocators.xpath( + "//android.view.View.VirtualChild[@content-desc=\"Ethereum address [tid:statusBaseInput]\"]" + ) + SAVE_BUTTON = BaseLocators.xpath( + "//android.view.View.VirtualChild[@content-desc=\"Add address [tid:addSavedAddress]\"]" + ) + DELETE_SAVED_ADDRESS_ACTION = BaseLocators.xpath( + "//*[@resource-id and contains(@resource-id, 'deleteSavedAddress') or @content-desc='Remove saved address']" + ) + CONFIRM_DELETE_BUTTON = BaseLocators.xpath( + "//*[@resource-id and contains(@resource-id, 'RemoveSavedAddressPopup-ConfirmButton')]" + ) + + + diff --git a/test/e2e_appium/pages/base_page.py b/test/e2e_appium/pages/base_page.py index 89128600dc6..d61deeae2f3 100644 --- a/test/e2e_appium/pages/base_page.py +++ b/test/e2e_appium/pages/base_page.py @@ -11,7 +11,7 @@ from config import log_element_action from core import EnvironmentSwitcher from utils.gestures import Gestures -from utils.screenshot import save_screenshot +from utils.screenshot import save_screenshot, save_page_source from utils.app_lifecycle_manager import AppLifecycleManager from utils.keyboard_manager import KeyboardManager from utils.element_state_checker import ElementStateChecker @@ -55,6 +55,20 @@ def take_screenshot(self, name: Optional[str] = None) -> Optional[str]: except Exception: return None + def dump_page_source(self, name: Optional[str] = None) -> Optional[str]: + try: + return save_page_source(self.driver, self._screenshots_dir, name) + except Exception: + return None + + def wait_for_invisibility(self, locator, timeout: Optional[int] = None) -> bool: + """Wait until the element located by locator becomes invisible or detached.""" + try: + wait = self._create_wait(timeout, "element_find") + return wait.until(EC.invisibility_of_element_located(locator)) + except Exception: + return False + def _create_wait(self, timeout: Optional[int], config_key: str) -> WebDriverWait: """Create WebDriverWait with timeout from parameter or YAML config.""" effective_timeout = timeout or self.timeouts.get(config_key, 30) diff --git a/test/e2e_appium/pages/settings/settings_page.py b/test/e2e_appium/pages/settings/settings_page.py index c9ecf801f68..334b35cc391 100644 --- a/test/e2e_appium/pages/settings/settings_page.py +++ b/test/e2e_appium/pages/settings/settings_page.py @@ -3,6 +3,8 @@ from ..base_page import BasePage from locators.settings.settings_locators import SettingsLocators from .backup_seed_modal import BackupSeedModal +from locators.wallet.saved_addresses_locators import SavedAddressesLocators +from pages.wallet.saved_addresses_page import SavedAddressesPage class SettingsPage(BasePage): @@ -14,7 +16,6 @@ def is_loaded(self, timeout: Optional[int] = 6) -> bool: return self.is_element_visible(self.locators.PROFILE_MENU_ITEM, timeout=timeout) def open_sign_out_and_quit(self) -> bool: - # Try exact then heuristic if self.safe_click( self.locators.SIGN_OUT_AND_QUIT, fallback_locators=[self.locators.SIGN_OUT_AND_QUIT_ALT], @@ -23,14 +24,12 @@ def open_sign_out_and_quit(self) -> bool: return False def confirm_sign_out(self) -> bool: - #TODO: Remove fallback locators return self.safe_click( self.locators.CONFIRM_SIGN_OUT, fallback_locators=[self.locators.CONFIRM_QUIT], ) def open_backup_recovery_phrase(self) -> Optional[BackupSeedModal]: - # Click explicit TID menu item only try: if not self.is_element_visible( self.locators.BACKUP_RECOVERY_MENU_ITEM, timeout=10 @@ -50,3 +49,21 @@ def is_backup_entry_removed(self) -> bool: return not self.is_element_visible( self.locators.BACKUP_RECOVERY_MENU_ITEM, timeout=2 ) + + def open_saved_addresses(self) -> Optional[SavedAddressesPage]: + locators = SavedAddressesLocators() + if not self.is_loaded(timeout=10): + return None + try: + self.safe_click(locators.SETTINGS_WALLET_MENU_ITEM) + except Exception: + pass + try: + if not self.is_element_visible(locators.SAVED_ADDRESSES_ITEM, timeout=10): + return None + if not self.safe_click(locators.SAVED_ADDRESSES_ITEM): + return None + except Exception: + return None + page = SavedAddressesPage(self.driver) + return page if page.is_loaded(timeout=10) else None diff --git a/test/e2e_appium/pages/wallet/add_saved_address_modal.py b/test/e2e_appium/pages/wallet/add_saved_address_modal.py new file mode 100644 index 00000000000..9d7a6e04702 --- /dev/null +++ b/test/e2e_appium/pages/wallet/add_saved_address_modal.py @@ -0,0 +1,44 @@ +from typing import Optional + +from ..base_page import BasePage +from locators.wallet.saved_addresses_locators import SavedAddressesLocators + + +class AddSavedAddressModal(BasePage): + def __init__(self, driver): + super().__init__(driver) + self.locators = SavedAddressesLocators() + + def is_displayed(self, timeout: Optional[int] = 10) -> bool: + return self.is_element_visible(self.locators.NAME_INPUT, timeout=timeout) + + def set_name(self, name: str) -> bool: + return self.qt_safe_input( + self.locators.NAME_INPUT, + name, + max_retries=1, + verify=False, + ) + + def set_address(self, address: str) -> bool: + return self.qt_safe_input( + self.locators.ADDRESS_INPUT, + address, + max_retries=1, + verify=False, + ) + + def save(self) -> bool: + self.hide_keyboard() + return self.safe_click(self.locators.SAVE_BUTTON) + + def add_saved_address(self, name: str, address: str) -> bool: + if not self.is_displayed(timeout=10): + return False + if not self.set_name(name): + return False + if not self.set_address(address): + return False + return self.save() + + diff --git a/test/e2e_appium/pages/wallet/saved_addresses_page.py b/test/e2e_appium/pages/wallet/saved_addresses_page.py new file mode 100644 index 00000000000..5360bd866ea --- /dev/null +++ b/test/e2e_appium/pages/wallet/saved_addresses_page.py @@ -0,0 +1,74 @@ +from typing import Optional + +from ..base_page import BasePage +from locators.wallet.saved_addresses_locators import SavedAddressesLocators + + +class SavedAddressesPage(BasePage): + def __init__(self, driver): + super().__init__(driver) + self.locators = SavedAddressesLocators() + + def is_loaded(self, timeout: Optional[int] = 10) -> bool: + return self.is_element_visible( + self.locators.ADD_NEW_SAVED_ADDRESS_BUTTON_WALLET, timeout=timeout + ) + + def open_add_saved_address_modal(self) -> bool: + if self.is_element_visible(self.locators.ADD_NEW_SAVED_ADDRESS_BUTTON_WALLET, timeout=2): + return self.safe_click(self.locators.ADD_NEW_SAVED_ADDRESS_BUTTON_WALLET) + return self.safe_click(self.locators.ADD_NEW_SAVED_ADDRESS_BUTTON_SETTINGS) + + def is_entry_visible(self, name: str, timeout: Optional[int] = 10) -> bool: + return self.is_element_visible(self.locators.row_by_name(name), timeout=timeout) + + def open_details(self, name: str) -> bool: + try: + row = self.find_element(self.locators.row_by_name(name), timeout=6) + row.click() + return self.is_element_visible(self.locators.SAVED_ADDRESS_DETAILS_POPUP, timeout=6) + except Exception: + return False + + + def open_row_menu(self, name: str) -> bool: + # If details popup is already visible, do NOT click the row again (it can close the popup). + if self.is_element_visible(self.locators.SAVED_ADDRESS_DETAILS_POPUP, timeout=2): + self.logger.debug("Details popup already visible. Dumping XML (pre-kebab-click)...") + self.dump_page_source(f"details_popup_open_{name}") + else: + # Open details popup by clicking the row once + try: + delegate = self.find_element(self.locators.row_by_name(name), timeout=4) + delegate.click() + except Exception: + return False + if not self.is_element_visible(self.locators.SAVED_ADDRESS_DETAILS_POPUP, timeout=5): + return False + self.logger.debug("SavedAddress details popup is visible. Dumping XML (pre-kebab-click)...") + self.dump_page_source(f"details_popup_open_{name}") + + try: + if self.safe_click(self.locators.popup_menu_by_name(name), timeout=4, max_attempts=1): + self.logger.debug("Clicked popup kebab via name-specific locator. Dumping XML...") + self.dump_page_source(f"kebab_clicked_name_{name}") + return True + except Exception: + return False + + return False + + def delete_saved_address_with_confirmation(self, name: str) -> bool: + if not self.open_row_menu(name): + return False + if not self.is_element_visible(self.locators.DELETE_SAVED_ADDRESS_ACTION, timeout=4): + return False + if not self.safe_click(self.locators.DELETE_SAVED_ADDRESS_ACTION): + return False + if not self.safe_click(self.locators.CONFIRM_DELETE_BUTTON): + return False + self.wait_for_invisibility(self.locators.CONFIRM_DELETE_BUTTON, timeout=6) + self.wait_for_invisibility(self.locators.SAVED_ADDRESS_DETAILS_POPUP, timeout=8) + return True + + diff --git a/test/e2e_appium/pytest.ini b/test/e2e_appium/pytest.ini index cfc38f80e8d..9712581d823 100644 --- a/test/e2e_appium/pytest.ini +++ b/test/e2e_appium/pytest.ini @@ -10,4 +10,6 @@ markers = critical: Critical path tests that must pass performance: Performance and timing validation tests onboarding_config: Custom configuration for onboarding fixture - messaging: Messaging tests \ No newline at end of file + messaging: Messaging tests + wallet: Wallet tests + saved_addresses: Saved addresses tests \ No newline at end of file diff --git a/test/e2e_appium/tests/test_saved_addresses.py b/test/e2e_appium/tests/test_saved_addresses.py new file mode 100644 index 00000000000..480bdac1011 --- /dev/null +++ b/test/e2e_appium/tests/test_saved_addresses.py @@ -0,0 +1,47 @@ +import pytest + +from tests.base_test import BaseAppReadyTest, lambdatest_reporting +from utils.generators import generate_ethereum_address, generate_account_name +from pages.wallet.add_saved_address_modal import AddSavedAddressModal +from pages.app import App +from locators.app_locators import AppLocators +from locators.wallet.saved_addresses_locators import SavedAddressesLocators +from pages.wallet.saved_addresses_page import SavedAddressesPage + + +class TestSavedAddresses(BaseAppReadyTest): + + @pytest.mark.wallet + @pytest.mark.saved_addresses + @pytest.mark.smoke + @lambdatest_reporting + def test_add_and_remove_saved_address(self): + assert self.ctx.app.safe_click(AppLocators().LEFT_NAV_WALLET, timeout=6), "Failed to open Wallet" + loc = SavedAddressesLocators() + assert self.ctx.app.safe_click(loc.WALLET_SAVED_ADDRESSES_BUTTON), "Failed to open Saved addresses from Wallet" + saved_addresses = SavedAddressesPage(self.driver) + assert saved_addresses.is_loaded(timeout=10), "Saved Addresses view not opened" + + assert saved_addresses.open_add_saved_address_modal(), "Add Saved Address modal button not clickable" + modal = AddSavedAddressModal(self.driver) + assert modal.is_displayed(timeout=10), "Add Saved Address modal did not appear" + + name = generate_account_name(12) + address = generate_ethereum_address() + assert modal.add_saved_address(name, address), "Failed to add saved address" + + app = App(self.driver) + assert app.is_toast_present(timeout=5), "Expected toast after saving address" + toast_text = app.get_toast_content_desc(timeout=10) or "" + assert "successfully added" in toast_text.lower(), f"Unexpected toast: '{toast_text}'" + + assert saved_addresses.is_entry_visible(name, timeout=30), f"Saved address '{name}' not visible in list" + + assert saved_addresses.open_details(name), "Failed to open saved address details" + assert saved_addresses.delete_saved_address_with_confirmation(name), "Failed to delete saved address via details menu" + + app = App(self.driver) + _ = app.get_toast_content_desc(timeout=5) + assert not saved_addresses.is_entry_visible(name, timeout=10), f"Saved address '{name}' still visible after deletion" + + diff --git a/test/e2e_appium/utils/generators.py b/test/e2e_appium/utils/generators.py index 896d6008a8d..285d48d5910 100644 --- a/test/e2e_appium/utils/generators.py +++ b/test/e2e_appium/utils/generators.py @@ -1,6 +1,8 @@ import random +import string from typing import Optional from eth_account.hdaccount import generate_mnemonic, Mnemonic +from eth_account import Account def generate_seed_phrase(word_count: Optional[int] = None) -> str: @@ -33,3 +35,16 @@ def generate_12_word_seed_phrase() -> str: def generate_24_word_seed_phrase() -> str: """Generate a 24-word seed phrase.""" return generate_seed_phrase(24) + + +def generate_ethereum_address() -> str: + """Generate a random EIP-55 checksummed Ethereum address.""" + acct = Account.create() + return acct.address + + +def generate_account_name(length: int = 12) -> str: + """Generate a simple name for UI entries.""" + length = max(4, min(length, 24)) + letters = string.ascii_letters + return "".join(random.choice(letters) for _ in range(length)) diff --git a/ui/app/AppLayouts/Wallet/controls/SavedAddressesDelegate.qml b/ui/app/AppLayouts/Wallet/controls/SavedAddressesDelegate.qml index d776959fab5..b8df57ab633 100644 --- a/ui/app/AppLayouts/Wallet/controls/SavedAddressesDelegate.qml +++ b/ui/app/AppLayouts/Wallet/controls/SavedAddressesDelegate.qml @@ -33,6 +33,8 @@ StatusListItem { property int usage: SavedAddressesDelegate.Usage.Delegate property bool showButtons: sensor.containsMouse + property alias menuButtonAlias: menuButton + property alias sendButton: sendButton signal aboutToOpenPopup() @@ -98,12 +100,14 @@ StatusListItem { onClicked: root.openSendModal(d.visibleAddress) }, StatusRoundButton { + id: menuButton objectName: "savedAddressView_Delegate_menuButton_" + root.name visible: !!root.name enabled: root.showButtons type: StatusRoundButton.Type.Quinary radius: 8 icon.name: "more" + Accessible.name: Utils.formatAccessibleName("Options", objectName) onClicked: { menu.openMenu(this, x + width - menu.width - statusListItemComponentsSlot.spacing, y + height + Theme.halfPadding, { From cc819c0f21deb9d1a3598331ed3a98961da9b956 Mon Sep 17 00:00:00 2001 From: "Mag." <50769329+glitchminer@users.noreply.github.com> Date: Thu, 25 Sep 2025 22:53:30 +0100 Subject: [PATCH 8/8] test(e2e_appium): test_onboarding_import_seed (#18852) --- test/e2e_appium/conftest.py | 2 +- .../e2e_appium/fixtures/onboarding_fixture.py | 46 ++++++- .../onboarding/returning_login_locators.py | 24 ++++ .../onboarding/seed_phrase_input_locators.py | 2 +- .../onboarding/wallet/wallet_locators.py | 12 ++ test/e2e_appium/pages/onboarding/__init__.py | 2 + .../pages/onboarding/loading_page.py | 21 ++- .../onboarding/seed_phrase_input_page.py | 3 +- .../services/app_initialization_manager.py | 12 +- .../tests/test_backup_recovery_phrase.py | 2 +- test/e2e_appium/tests/test_onboarding_flow.py | 2 +- .../tests/test_onboarding_import_seed.py | 128 ++++++++++++++++++ .../e2e_appium/utils/app_lifecycle_manager.py | 70 ++++++++-- test/e2e_appium/utils/generators.py | 26 +++- test/e2e_appium/utils/keyboard_manager.py | 49 ++++--- 15 files changed, 355 insertions(+), 46 deletions(-) create mode 100644 test/e2e_appium/locators/onboarding/returning_login_locators.py create mode 100644 test/e2e_appium/tests/test_onboarding_import_seed.py diff --git a/test/e2e_appium/conftest.py b/test/e2e_appium/conftest.py index 2409b922294..dfab239012b 100644 --- a/test/e2e_appium/conftest.py +++ b/test/e2e_appium/conftest.py @@ -12,7 +12,7 @@ # Expose fixture modules without star imports pytest_plugins = [ - # "fixtures.onboarding_fixture", # Disabled - using BaseAppReadyTest instead + "fixtures.onboarding_fixture", ] diff --git a/test/e2e_appium/fixtures/onboarding_fixture.py b/test/e2e_appium/fixtures/onboarding_fixture.py index 338a21a96e8..1c62b70c589 100644 --- a/test/e2e_appium/fixtures/onboarding_fixture.py +++ b/test/e2e_appium/fixtures/onboarding_fixture.py @@ -211,6 +211,39 @@ def _execute_welcome_step(self): if self.config.take_screenshots: self._take_screenshot("welcome_completed") + def _execute_seed_phrase_import_step(self): + """Execute seed phrase import from Create Profile screen into Password.""" + self.current_step = "seed_phrase_import" + self.logger.info("Step 3: Seed Phrase Import") + + if self.config.validate_each_step: + assert self.create_profile_page.is_screen_displayed(), ( + "Create profile screen should be displayed before seed phrase import" + ) + + self.create_profile_page.click_use_recovery_phrase() + + seed_phrase = self.config.seed_phrase or generate_seed_phrase() + self.config.seed_phrase = seed_phrase + + seed_page = SeedPhraseInputPage(self.driver, flow_type="create") + if self.config.validate_each_step: + assert seed_page.is_screen_displayed(), ( + "Seed phrase input screen should be displayed" + ) + + success = seed_page.import_seed_phrase(seed_phrase) + assert success, "Should successfully import seed phrase via clipboard" + + self.step_results["seed_phrase_import"] = { + "success": True, + "word_count": len(seed_phrase.split()), + "timestamp": datetime.now(), + } + + if self.config.take_screenshots: + self._take_screenshot("seed_phrase_import_completed") + def _execute_analytics_step(self): """Execute analytics screen interaction""" self.current_step = "analytics_screen" @@ -309,12 +342,17 @@ def _execute_loading_step(self): def _execute_main_app_verification(self): """Execute main app verification""" - self.current_step = "main_app_verification" - self.logger.info("Step 6: Main App Verification") + self.current_step = "wallet_verification" + self.logger.info("Step 6: Wallet Landing Verification") + + from locators.onboarding.wallet.wallet_locators import WalletLocators - assert self.main_app_page.is_main_app_loaded(), "Main app should be loaded" + locators = WalletLocators() + assert self.main_app_page.is_element_visible( + locators.WALLET_FOOTER_SEND_BUTTON + ), "Wallet landing screen should be visible after onboarding" - self.step_results["main_app_verification"] = { + self.step_results["wallet_verification"] = { "success": True, "timestamp": datetime.now(), } diff --git a/test/e2e_appium/locators/onboarding/returning_login_locators.py b/test/e2e_appium/locators/onboarding/returning_login_locators.py new file mode 100644 index 00000000000..da2ea60b4e5 --- /dev/null +++ b/test/e2e_appium/locators/onboarding/returning_login_locators.py @@ -0,0 +1,24 @@ +from ..base_locators import BaseLocators + + +class ReturningLoginLocators(BaseLocators): + + # Returning Login ("Welcome back") user selector button + LOGIN_USER_SELECTOR_FULL_ID = BaseLocators.xpath( + "//*[@resource-id='QGuiApplication.mainWindow.startupOnboardingLayout.OnboardingFlow_QMLTYPE_165.LoginScreen_QMLTYPE_364.loginUserSelector.LoginUserSelectorDelegate_QMLTYPE_370']" + ) + LOGIN_USER_SELECTOR = BaseLocators.xpath( + "//*[contains(@resource-id, 'loginUserSelector') or contains(@resource-id, 'LoginUserSelector')]" + ) + + # Dropdown item to proceed with login + LOGIN_DROPDOWN_ITEM = BaseLocators.xpath( + "//*[contains(@resource-id, 'StatusDropdown.logInDelegate') or contains(@resource-id, 'logInDelegate')]" + ) + + CREATE_PROFILE_DROPDOWN_ITEM = BaseLocators.xpath( + "//*[contains(@resource-id, 'StatusDropdown.createProfileDelegate') or contains(@resource-id, 'createProfileDelegate')]" + ) + + # Text fallback for the Create profile item + CREATE_PROFILE_TEXT = BaseLocators.text_contains("Create profile") diff --git a/test/e2e_appium/locators/onboarding/seed_phrase_input_locators.py b/test/e2e_appium/locators/onboarding/seed_phrase_input_locators.py index 27dde8de531..8b2640419de 100644 --- a/test/e2e_appium/locators/onboarding/seed_phrase_input_locators.py +++ b/test/e2e_appium/locators/onboarding/seed_phrase_input_locators.py @@ -17,7 +17,7 @@ def get_tab_locators(word_count: int) -> list: base = str(word_count) return [BaseLocators.accessibility_id(f"{base} word(s)")] - CONTINUE_BUTTON = BaseLocators.accessibility_id("Continue") + CONTINUE_BUTTON = BaseLocators.content_desc_contains("[tid:btnContinue]") IMPORT_BUTTON = BaseLocators.accessibility_id("Import") IMPORT_BUTTON_ALT = BaseLocators.accessibility_id("Import seed phrase") diff --git a/test/e2e_appium/locators/onboarding/wallet/wallet_locators.py b/test/e2e_appium/locators/onboarding/wallet/wallet_locators.py index 425c58abe1a..1ac8fca4fcc 100644 --- a/test/e2e_appium/locators/onboarding/wallet/wallet_locators.py +++ b/test/e2e_appium/locators/onboarding/wallet/wallet_locators.py @@ -3,6 +3,9 @@ class WalletLocators(BaseLocators): WALLET_HEADER = BaseLocators.accessibility_id("Wallet") + WALLET_FOOTER_SEND_BUTTON = BaseLocators.xpath( + "//android.view.View.VirtualChild[@content-desc='Send [tid:walletFooterSendButton]']" + ) ASSETS_TAB = BaseLocators.text_contains("Assets") ACTIVITY_TAB = BaseLocators.text_contains("Activity") @@ -19,3 +22,12 @@ class WalletLocators(BaseLocators): ADD_NEW_ADDRESS_BUTTON = BaseLocators.xpath( "//*[contains(@resource-id, 'walletHeaderButton') or @content-desc='Add new address']" ) + WALLET_HEADER_ADDRESS = BaseLocators.content_desc_contains("[tid:walletHeaderButton]") + + # Account selection + ACCOUNT_1_BY_TEXT = BaseLocators.xpath( + "//*[contains(@text, 'Account 1') or contains(@content-desc, 'Account 1')]" + ) + ACCOUNT_LIST_ITEM_ANY = BaseLocators.xpath( + "//*[contains(@resource-id, 'walletAccountListItem')]" + ) diff --git a/test/e2e_appium/pages/onboarding/__init__.py b/test/e2e_appium/pages/onboarding/__init__.py index 67386020756..bb602ae6895 100644 --- a/test/e2e_appium/pages/onboarding/__init__.py +++ b/test/e2e_appium/pages/onboarding/__init__.py @@ -7,6 +7,7 @@ from .loading_page import SplashScreen from .home_page import HomePage from .seed_phrase_input_page import SeedPhraseInputPage +from .main_app_page import MainAppPage __all__ = [ "WelcomePage", @@ -16,4 +17,5 @@ "SplashScreen", "HomePage", "SeedPhraseInputPage", + "MainAppPage", ] diff --git a/test/e2e_appium/pages/onboarding/loading_page.py b/test/e2e_appium/pages/onboarding/loading_page.py index d7ccf54fc2e..5ce3bf5554c 100644 --- a/test/e2e_appium/pages/onboarding/loading_page.py +++ b/test/e2e_appium/pages/onboarding/loading_page.py @@ -6,9 +6,11 @@ from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC +from selenium.common.exceptions import TimeoutException from ..base_page import BasePage from locators.onboarding.loading_screen_locators import LoadingScreenLocators +from pages.onboarding.main_app_page import MainAppPage class SplashScreen(BasePage): @@ -32,6 +34,23 @@ def wait_for_loading_completion(self, timeout: int = 60) -> bool: ) self.logger.info("Loading completed - screen disappeared") return True + except TimeoutException: + self.logger.warning( + f"Loading did not complete within {timeout} seconds; checking main app state" + ) + try: + # Fallback: detect main app container to avoid false negatives on cloud runs + main_app = MainAppPage(self.driver) + if main_app.is_main_app_loaded(): + self.logger.info( + "Main app container visible despite splash locator; continuing" + ) + return True + except Exception: + pass + return False except Exception: - self.logger.warning(f"Loading did not complete within {timeout} seconds") + self.logger.warning( + "Unexpected error while waiting for loading completion", exc_info=True + ) return False diff --git a/test/e2e_appium/pages/onboarding/seed_phrase_input_page.py b/test/e2e_appium/pages/onboarding/seed_phrase_input_page.py index 247ca491a7a..4d1aefe541f 100644 --- a/test/e2e_appium/pages/onboarding/seed_phrase_input_page.py +++ b/test/e2e_appium/pages/onboarding/seed_phrase_input_page.py @@ -50,6 +50,7 @@ def paste_seed_phrase_via_clipboard(self, seed_phrase: str) -> bool: time.sleep(0.5) self.logger.info("✅ Seed phrase paste completed successfully") + self.hide_keyboard() return True except Exception as e: @@ -91,7 +92,7 @@ def import_seed_phrase(self, seed_phrase: Union[str, List[str]]) -> bool: try: if self.hide_keyboard(): - time.sleep(0.3) + time.sleep(0.5) except Exception: pass diff --git a/test/e2e_appium/services/app_initialization_manager.py b/test/e2e_appium/services/app_initialization_manager.py index 18737cb5c8a..a36bef805b8 100644 --- a/test/e2e_appium/services/app_initialization_manager.py +++ b/test/e2e_appium/services/app_initialization_manager.py @@ -14,7 +14,11 @@ def __init__(self, driver): def perform_initial_activation( self, timeout: float = 15.0, interval: float = 2.0 ) -> bool: - """Perform initial app activation until UI appears or timeout is reached.""" + """Perform initial app activation until UI appears or timeout is reached. + + Raises: + RuntimeError: If the UI never surfaces before the timeout expires. + """ self.logger.debug("🚀 Starting app initialization sequence") self._wait_for_session_ready() @@ -30,9 +34,9 @@ def perform_initial_activation( if self._wait_for_ui_response(timeout=interval): self.logger.info("✓ App UI surfaced after activation") return True - - self.logger.warning("⚠ App activation timeout - UI may not be ready") - return False + + self.logger.error("⚠ App activation timeout - UI never surfaced") + raise RuntimeError("App activation timed out before UI became available") def _wait_for_session_ready( self, timeout: float = 2.0, poll_interval: float = 0.2 diff --git a/test/e2e_appium/tests/test_backup_recovery_phrase.py b/test/e2e_appium/tests/test_backup_recovery_phrase.py index 9946218930e..1664f7f4f49 100644 --- a/test/e2e_appium/tests/test_backup_recovery_phrase.py +++ b/test/e2e_appium/tests/test_backup_recovery_phrase.py @@ -8,7 +8,6 @@ class TestBackupRecoveryPhrase(BaseAppReadyTest): @pytest.mark.critical - @pytest.mark.smoke @lambdatest_reporting def test_sign_out_from_settings(self): # BaseAppReadyTest ensures authenticated home @@ -33,6 +32,7 @@ def test_sign_out_from_settings(self): "remove_phrase", [pytest.param(True, id="delete")], ) + @pytest.mark.smoke @lambdatest_reporting def test_backup_recovery_phrase_flow(self, remove_phrase): # BaseAppReadyTest ensures home; open Settings (left-nav preferred) diff --git a/test/e2e_appium/tests/test_onboarding_flow.py b/test/e2e_appium/tests/test_onboarding_flow.py index 5d6e08b44e9..22815067bab 100644 --- a/test/e2e_appium/tests/test_onboarding_flow.py +++ b/test/e2e_appium/tests/test_onboarding_flow.py @@ -43,7 +43,7 @@ def test_onboarding_new_password_skip_analytics(self, onboarded_user): "analytics_screen", "password_screen", "loading_screen", - "main_app_verification", + "wallet_verification", ] completed_steps = result["steps_completed"] diff --git a/test/e2e_appium/tests/test_onboarding_import_seed.py b/test/e2e_appium/tests/test_onboarding_import_seed.py new file mode 100644 index 00000000000..c2dc8b1e0e3 --- /dev/null +++ b/test/e2e_appium/tests/test_onboarding_import_seed.py @@ -0,0 +1,128 @@ +import pytest + +from tests.base_test import BaseTest, lambdatest_reporting +from pages.onboarding import ( + WelcomePage, + AnalyticsPage, + CreateProfilePage, + SeedPhraseInputPage, + PasswordPage, + SplashScreen, +) +from pages.base_page import BasePage +from locators.onboarding.wallet.wallet_locators import WalletLocators +from utils.generators import generate_seed_phrase, get_wallet_address_from_mnemonic + + +class TestOnboardingImportSeed(BaseTest): + @pytest.mark.smoke + @pytest.mark.onboarding + @lambdatest_reporting + def test_import_and_reimport_seed(self): + seed_phrase = generate_seed_phrase() + password = "TestPassword123!" + + welcome = WelcomePage(self.driver) + assert welcome.is_screen_displayed(timeout=30), "Welcome screen should be visible" + assert welcome.click_create_profile(), "Failed to click Create profile" + + analytics = AnalyticsPage(self.driver) + assert analytics.is_screen_displayed(), "Analytics screen should be visible" + assert analytics.skip_analytics_sharing(), "Failed to click Not now" + + create = CreateProfilePage(self.driver) + assert create.is_screen_displayed(), "Create profile screen should be visible" + assert create.click_use_recovery_phrase(), "Failed to click Use a recovery phrase" + + seed_page = SeedPhraseInputPage(self.driver, flow_type="create") + assert seed_page.is_screen_displayed(), "Seed phrase input (create) should be visible" + assert seed_page.import_seed_phrase(seed_phrase), "Failed to import seed phrase" + + password_page = PasswordPage(self.driver) + assert password_page.is_screen_displayed(), "Password screen should be visible" + assert password_page.create_password(password), "Failed to create password" + + splash = SplashScreen(self.driver) + assert splash.wait_for_loading_completion(timeout=60), "App did not finish loading" + + wallet_locators = WalletLocators() + + base = BasePage(self.driver) + try: + base.safe_click(wallet_locators.ACCOUNT_LIST_ITEM_ANY) + except Exception: + base.safe_click(wallet_locators.ACCOUNT_1_BY_TEXT) + + # Read the header address displayed (truncated) via wallet header button + header_el = base.find_element_safe(wallet_locators.WALLET_HEADER_ADDRESS, timeout=10) + assert header_el is not None, "Wallet header address button not found" + header_desc = header_el.get_attribute("content-desc") or "" + assert header_desc, "Header content-desc is empty" + + full_addr = get_wallet_address_from_mnemonic(seed_phrase) + expected_display = f"0×{full_addr[2:6]}…{full_addr[-4:]}" + assert header_desc.startswith(expected_display), ( + f"Header address display mismatch. Expected prefix '{expected_display}', got '{header_desc}'" + ) + + base_page = base + restarted = False + try: + restarted = base_page.restart_app("im.status.tablet") + except Exception: + restarted = False + + if not restarted: + try: + self.driver.terminate_app("im.status.tablet") + self.driver.start_activity( + "im.status.tablet", "org.qtproject.qt.android.bindings.QtActivity" + ) + except Exception: + pass + + from locators.onboarding.returning_login_locators import ReturningLoginLocators + base = base_page + rel = ReturningLoginLocators() + + def nudge_user_selector() -> bool: + try: + self.driver.tap([(500, 300)]) + return True + except Exception: + return False + + opened = False + selector_locators = [rel.LOGIN_USER_SELECTOR_FULL_ID, rel.LOGIN_USER_SELECTOR] + + for _ in range(5): + nudge_user_selector() + for locator in selector_locators: + el = base.find_element_safe(locator, timeout=3) + if el and base.gestures.element_tap(el): + opened = True + break + if opened: + break + assert opened, "Returning login user selector did not open" + + try: + base.safe_click(rel.CREATE_PROFILE_DROPDOWN_ITEM, timeout=10, max_attempts=2) + except Exception: + el = base.find_element_safe(rel.CREATE_PROFILE_DROPDOWN_ITEM, timeout=3) + assert el is not None, "Create profile item not found in dropdown" + assert base.gestures.element_tap(el), "Failed to tap Create profile dropdown item" + + analytics = AnalyticsPage(self.driver) + assert analytics.is_screen_displayed(), "Analytics screen should be visible after choosing Create profile" + analytics.skip_analytics_sharing() + + create = CreateProfilePage(self.driver) + assert create.is_screen_displayed(), "Create profile screen should be visible (re-import path)" + assert create.click_use_recovery_phrase(), "Failed to click Use a recovery phrase (re-import path)" + + seed_login = SeedPhraseInputPage(self.driver, flow_type="create") + assert seed_login.is_screen_displayed(), "Seed phrase screen should be visible (re-import path)" + assert seed_login.paste_seed_phrase_via_clipboard(seed_phrase), "Failed to paste seed phrase (re-import path)" + + assert not seed_login.is_continue_button_enabled(), "Continue should be disabled for already added seed phrase" diff --git a/test/e2e_appium/utils/app_lifecycle_manager.py b/test/e2e_appium/utils/app_lifecycle_manager.py index 2c7f108b8d8..ea4cd47d98b 100644 --- a/test/e2e_appium/utils/app_lifecycle_manager.py +++ b/test/e2e_appium/utils/app_lifecycle_manager.py @@ -30,17 +30,14 @@ def restart_app(self, app_package: str = "im.status.tablet") -> bool: Returns: bool: True if restart was successful """ + env_name = os.getenv("CURRENT_TEST_ENVIRONMENT", "lambdatest").lower() + try: self.logger.info(f"🔄 Restarting app: {app_package}") + if env_name in ("lambdatest", "lt"): + return self._restart_lambda_test(app_package) - self.driver.terminate_app(app_package) - self.logger.debug("✓ App terminated") - - self.driver.activate_app(app_package) - self.logger.debug("✓ App reactivated") - - self.logger.info("✅ App restart completed successfully") - return True + return self._restart_local(app_package) except Exception as e: self.logger.error(f"❌ App restart failed: {e}") @@ -126,3 +123,60 @@ def activate_app(self, app_package: str = "im.status.tablet") -> bool: except Exception as e: self.logger.error(f"❌ Failed to activate app: {e}") return False + + def _restart_lambda_test(self, app_package: str) -> bool: + """Restart the app on LambdaTest using lambda-adb with a close/launch fallback.""" + try: + self.logger.debug("Cloud run detected; restarting via lambda-adb commands") + self.driver.execute_script( + "lambda-adb", + { + "command": "shell", + "text": f"am force-stop {app_package}", + }, + ) + self.logger.debug("✓ lambda-adb force-stop issued") + self.driver.execute_script( + "lambda-adb", + { + "command": "shell", + "text": ( + "am start -n " + f"{app_package}/org.qtproject.qt.android.bindings.QtActivity" + ), + }, + ) + self.logger.debug("✓ lambda-adb start activity issued") + self.logger.info("✅ App restart completed successfully (LambdaTest lambda-adb)") + return True + except Exception as lambda_error: + self.logger.warning( + "lambda-adb restart failed on LambdaTest: %s. Attempting close/launch fallback.", + lambda_error, + ) + try: + self.driver.close_app() + self.logger.debug("✓ App closed via close_app") + self.driver.launch_app() + self.logger.debug("✓ App launched via launch_app") + self.logger.info( + "✅ App restart completed successfully (LambdaTest close/launch fallback)" + ) + return True + except Exception as fallback_error: + self.logger.error( + "❌ App restart failed on LambdaTest fallback path: %s", + fallback_error, + ) + return False + + def _restart_local(self, app_package: str) -> bool: + """Restart the app on local/emulator environments.""" + self.driver.terminate_app(app_package) + self.logger.debug("✓ App terminated") + + self.driver.activate_app(app_package) + self.logger.debug("✓ App reactivated") + + self.logger.info("✅ App restart completed successfully") + return True diff --git a/test/e2e_appium/utils/generators.py b/test/e2e_appium/utils/generators.py index 285d48d5910..5d05a007666 100644 --- a/test/e2e_appium/utils/generators.py +++ b/test/e2e_appium/utils/generators.py @@ -1,9 +1,17 @@ +import logging import random import string from typing import Optional -from eth_account.hdaccount import generate_mnemonic, Mnemonic +from eth_account.hdaccount import Language, generate_mnemonic, Mnemonic from eth_account import Account +try: + Account.enable_unaudited_hdwallet_features() +except Exception: + logging.getLogger(__name__).debug( + "Failed to enable unaudited HD wallet features", exc_info=True + ) + def generate_seed_phrase(word_count: Optional[int] = None) -> str: """Generate a valid BIP39 seed phrase. @@ -21,9 +29,12 @@ def generate_seed_phrase(word_count: Optional[int] = None) -> str: if word_count not in [12, 18, 24]: raise ValueError("word_count must be 12, 18, or 24") + language = Language.ENGLISH + mnemonic_helper = Mnemonic(language) + words = "" - while not Mnemonic().is_mnemonic_valid(mnemonic=words): - words = generate_mnemonic(num_words=word_count, lang="english") + while not mnemonic_helper.is_mnemonic_valid(mnemonic=words): + words = generate_mnemonic(num_words=word_count, lang=language) return words @@ -40,7 +51,7 @@ def generate_24_word_seed_phrase() -> str: def generate_ethereum_address() -> str: """Generate a random EIP-55 checksummed Ethereum address.""" acct = Account.create() - return acct.address + return acct.address.lower() def generate_account_name(length: int = 12) -> str: @@ -48,3 +59,10 @@ def generate_account_name(length: int = 12) -> str: length = max(4, min(length, 24)) letters = string.ascii_letters return "".join(random.choice(letters) for _ in range(length)) + + +def get_wallet_address_from_mnemonic( + seed_phrase: str, derivation_path: str = "m/44'/60'/0'/0/0" +) -> str: + account = Account.from_mnemonic(seed_phrase, account_path=derivation_path) + return account.address diff --git a/test/e2e_appium/utils/keyboard_manager.py b/test/e2e_appium/utils/keyboard_manager.py index f7e7e6bc3b4..e4e01a8653c 100644 --- a/test/e2e_appium/utils/keyboard_manager.py +++ b/test/e2e_appium/utils/keyboard_manager.py @@ -1,7 +1,8 @@ +import time + from config.logging_config import get_logger from utils.gestures import Gestures - class KeyboardManager: def __init__(self, driver): @@ -9,31 +10,39 @@ def __init__(self, driver): self.gestures = Gestures(driver) self.logger = get_logger("keyboard_manager") - def hide_keyboard(self) -> bool: + def hide_keyboard(self, retries: int = 3, delay: float = 0.5) -> bool: try: - try: - self.driver.hide_keyboard() - self.logger.info("Keyboard hidden successfully using hide_keyboard()") + if not self.driver.is_keyboard_shown(): + self.logger.debug("Keyboard not shown, nothing to hide") return True - except Exception as e: - self.logger.debug(f"hide_keyboard() failed: {e}") - try: - self.driver.back() - self.logger.info("Keyboard hidden using back button") - return True - except Exception as e: - self.logger.debug(f"Back button failed: {e}") + for attempt in range(1, retries + 1): + try: + self.driver.hide_keyboard() + time.sleep(delay) # let UI settle + if not self.driver.is_keyboard_shown(): + self.logger.info(f"Keyboard hidden using hide_keyboard() (attempt {attempt})") + return True + else: + self.logger.debug(f"hide_keyboard() attempt {attempt} executed but keyboard still visible") + except Exception as e: + self.logger.debug(f"hide_keyboard() attempt {attempt} failed: {e}") + time.sleep(delay) try: size = self.driver.get_window_size() center_x = size["width"] // 2 - top = size["height"] // 3 - height = size["height"] // 3 - - self.gestures.swipe_down(max(0, center_x - 10), top, 20, height, 0.8) - self.logger.info("Keyboard hidden using swipe gesture") - return True + start_y = int(size["height"] * 0.4) + end_y = int(size["height"] * 0.2) + + self.logger.debug(f"Swiping from ({center_x},{start_y}) to ({center_x},{end_y}) to dismiss keyboard") + self.gestures.swipe_down(center_x, start_y, 20, end_y, 0.8) + time.sleep(delay) + if not self.driver.is_keyboard_shown(): + self.logger.info("Keyboard hidden using swipe gesture") + return True + else: + self.logger.debug("Swipe executed but keyboard still visible") except Exception as e: self.logger.debug(f"Swipe gesture failed: {e}") @@ -41,7 +50,7 @@ def hide_keyboard(self) -> bool: return False except Exception as e: - self.logger.error(f"Error hiding keyboard: {e}") + self.logger.error(f"Unexpected error hiding keyboard: {e}") return False def ensure_element_visible(