diff --git a/ci/Jenkinsfile.test-e2e.android b/ci/Jenkinsfile.test-e2e.android new file mode 100644 index 00000000000..18adb45ffa6 --- /dev/null +++ b/ci/Jenkinsfile.test-e2e.android @@ -0,0 +1,256 @@ +#!/usr/bin/env groovy +library 'status-jenkins-lib@v1.9.27' + +pipeline { + agent { + dockerfile { + label 'linuxcontainer' + filename 'tests.Dockerfile' + dir 'ci' + args '--user jenkins' + } + } + + parameters { + gitParameter( + name: 'GIT_REF', + description: 'Git branch to checkout.', + branchFilter: 'origin/(.*)', + branch: '', + defaultValue: 'master', + quickFilterEnabled: false, + selectedValue: 'DEFAULT', + sortMode: 'ASCENDING_SMART', + tagFilter: '*', + type: 'PT_BRANCH' + ) + string( + name: 'BUILD_SOURCE', + description: 'URL to APK or Jenkins build (pkg/*.apk). Required when BROWSERSTACK_APP_ID is empty.', + defaultValue: '' + ) + string( + name: 'BROWSERSTACK_APP_ID', + description: 'Existing BrowserStack app identifier (bs://...). Leave empty to upload from BUILD_SOURCE.', + defaultValue: '' + ) + choice( + name: 'TEST_DEVICE_ID', + description: 'BrowserStack device id to run on (leave empty to use the environment default).', + choices: ['', 'galaxy_tab_s10p_android_15', 'pixel_8_android_14', 'pixel_7_android_13', 'samsung_s23_android_13'] + ) + string( + name: 'PYTEST_ARGS', + description: 'Pytest flags (e.g. "-m smoke" or "-m critical") and other args.', + defaultValue: '-n=5 -m smoke tests' + ) + } + + options { + timestamps() + timeout(time: 120, unit: 'MINUTES') + buildDiscarder(logRotator( + daysToKeepStr: '30', + numToKeepStr: '30', + artifactNumToKeepStr: '30', + )) + disableRestartFromStage() + } + + environment { + VIRTUAL_ENV = "${env.WORKSPACE_TMP}/venv-appium" + PYTHONUNBUFFERED = "1" + TEST_DEVICE_ID = "${params.TEST_DEVICE_ID}" + BROWSERSTACK_PROJECT_NAME = "Mobile E2E ${utils.getBuildType()}" + } + + stages { + stage('Prep') { + steps { + script { + setNewBuildName() + updateGitHubStatus() + } + } + } + + stage('Setup Python environment') { + steps { + dir('test/e2e_appium') { + sh """ + set -euo pipefail + python3 -m venv ${VIRTUAL_ENV} + source ${VIRTUAL_ENV}/bin/activate + pip install --upgrade pip + pip install -r requirements.txt + """ + } + } + } + + stage('Use provided BrowserStack app') { + when { + expression { params.BROWSERSTACK_APP_ID?.trim() } + } + steps { + script { + env.BROWSERSTACK_APP_ID = params.BROWSERSTACK_APP_ID.trim() + echo "Using provided BrowserStack app id: ${env.BROWSERSTACK_APP_ID}" + } + } + } + + stage('Download') { + when { + allOf { + expression { !params.BROWSERSTACK_APP_ID?.trim() } + expression { params.BUILD_SOURCE?.startsWith('http') } + } + } + steps { timeout(5) { script { dir('test/e2e_appium') { + if (!params.BUILD_SOURCE?.trim()) { + error('Specify BUILD_SOURCE when BROWSERSTACK_APP_ID is empty.') + } + sh 'mkdir -p ./pkg/' + fileOperations([ + fileDownloadOperation( + url: params.BUILD_SOURCE, + targetFileName: 'downloaded.apk', + targetLocation: './pkg/', + userName: '', + password: '', + ) + ]) + def apkPath = utils.findFile('test/e2e_appium/pkg/*.apk') + if (!apkPath) { + error("Unable to locate APK under pkg/. Ensure BUILD_SOURCE produces pkg/*.apk artifacts.") + } + env.APK_PATH = apkPath + echo "APK ready at ${env.APK_PATH}" + } } } } + } + + stage('Copy') { + when { + allOf { + expression { !params.BROWSERSTACK_APP_ID?.trim() } + expression { !params.BUILD_SOURCE?.startsWith('http') } + } + } + steps { timeout(5) { script { dir('test/e2e_appium') { + if (!params.BUILD_SOURCE?.trim()) { + error('Specify BUILD_SOURCE when BROWSERSTACK_APP_ID is empty.') + } + copyArtifacts( + projectName: params.BUILD_SOURCE, + filter: 'pkg/*.apk', + selector: lastWithArtifacts(), + target: './' + ) + def apkPath = utils.findFile('test/e2e_appium/pkg/*.apk') + if (!apkPath) { + error("Unable to locate APK under pkg/. Ensure BUILD_SOURCE produces pkg/*.apk artifacts.") + } + env.APK_PATH = apkPath + echo "APK ready at ${env.APK_PATH}" + } } } } + } + + stage('Upload APK to BrowserStack') { + when { + expression { !env.BROWSERSTACK_APP_ID } + } + steps { + script { + withCredentials([ + usernamePassword( + credentialsId: 'browserstack-status-desktop', + usernameVariable: 'BROWSERSTACK_USERNAME', + passwordVariable: 'BROWSERSTACK_ACCESS_KEY' + ) + ]) { + def response = sh( + script: "./scripts/upload_browserstack_apk.sh", + returnStdout: true + ).trim() + def result = readJSON text: response + def appUrl = result?.app_url + if (!appUrl) { + error("BrowserStack upload failed: ${response}") + } + env.BROWSERSTACK_APP_ID = appUrl + env.BROWSERSTACK_BUILD_NAME = result?.custom_id + env.BROWSERSTACK_BUILD_IDENTIFIER = env.BUILD_NUMBER + echo "BrowserStack app uploaded: ${env.BROWSERSTACK_APP_ID}" + } + } + } + } + + stage('Run pytest suite') { + steps { + script { + dir('test/e2e_appium') { + println("Using TEST_DEVICE_ID: ${env.TEST_DEVICE_ID}") + + withCredentials([ + usernamePassword( + credentialsId: 'browserstack-status-desktop', + usernameVariable: 'BROWSERSTACK_USERNAME', + passwordVariable: 'BROWSERSTACK_ACCESS_KEY' + ) + ]) { + sh "${VIRTUAL_ENV}/bin/python -m pytest --env browserstack ${params.PYTEST_ARGS?.trim() ?: ''}" + } + } + } + } + } + + stage('Publish test results') { + steps { + script { + def runId = env.E2E_RUN_ID?.trim() + def reportsPattern = runId ? "test/e2e_appium/reports/${runId}/**/*.xml" : "test/e2e_appium/reports/**/*.xml" + junit allowEmptyResults: true, testResults: reportsPattern + def archivePattern = runId ? "test/e2e_appium/reports/${runId}/**/*" : "test/e2e_appium/reports/**/*" + archiveArtifacts artifacts: archivePattern, allowEmptyArchive: true + } + } + } + } + + post { + success { + script { + github.notifyPR(true) + } + } + failure { + script { + github.notifyPR(false) + } + } + cleanup { + cleanWs(disableDeferredWipeout: true) + } + } +} + +def setNewBuildName() { + if (currentBuild.upstreamBuilds) { + def parent = utils.parentOrCurrentBuild() + currentBuild.displayName = parent.getFullDisplayName().minus('status-desktop ยป ') + } +} + +def updateGitHubStatus() { + if (params.BUILD_SOURCE ==~ /.*\/PR-[0-9]+\/?$/) { + github.statusUpdate( + context: 'jenkins/prs/tests/e2e-android', + commit: jenkins.getJobCommitByPath(params.BUILD_SOURCE), + repo_url: 'https://github.com/status-im/status-desktop' + ) + } +} + diff --git a/scripts/upload_browserstack_apk.sh b/scripts/upload_browserstack_apk.sh new file mode 100755 index 00000000000..694722090ac --- /dev/null +++ b/scripts/upload_browserstack_apk.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Upload APK to BrowserStack and output JSON response to stdout +# Requires environment variables: +# APK_PATH - Path to the APK file +# BROWSERSTACK_USERNAME - BrowserStack username +# BROWSERSTACK_ACCESS_KEY - BrowserStack access key +# BUILD_NUMBER - Build number (optional, defaults to current timestamp) + +if [[ -z "${APK_PATH:-}" ]]; then + echo "Error: APK_PATH environment variable is required" >&2 + exit 1 +fi + +if [[ ! -f "${APK_PATH}" ]]; then + echo "Error: APK_PATH does not exist or is not a file: ${APK_PATH}" >&2 + exit 1 +fi + +if [[ ! -r "${APK_PATH}" ]]; then + echo "Error: APK_PATH is not readable: ${APK_PATH}" >&2 + exit 1 +fi + +if [[ -z "${BROWSERSTACK_USERNAME:-}" ]]; then + echo "Error: BROWSERSTACK_USERNAME environment variable is required" >&2 + exit 1 +fi + +if [[ -z "${BROWSERSTACK_ACCESS_KEY:-}" ]]; then + echo "Error: BROWSERSTACK_ACCESS_KEY environment variable is required" >&2 + exit 1 +fi + +APK_NAME=$(basename "${APK_PATH}") +SANITIZED_NAME=$(printf '%s' "${APK_NAME}" | tr -cs '[:alnum:]._-' '-' | cut -c1-80) +BUILD_ID="${BUILD_NUMBER:-$(date +%s)}" +CUSTOM_ID="${SANITIZED_NAME}-${BUILD_ID}" + +curl -s -u "${BROWSERSTACK_USERNAME}:${BROWSERSTACK_ACCESS_KEY}" \ + -X POST "https://api-cloud.browserstack.com/app-automate/upload" \ + -F "file=@${APK_PATH}" \ + -F "custom_id=${CUSTOM_ID}" + diff --git a/test/e2e_appium/.gitignore b/test/e2e_appium/.gitignore index 7190d7691a0..f8f77bb6835 100644 --- a/test/e2e_appium/.gitignore +++ b/test/e2e_appium/.gitignore @@ -46,6 +46,7 @@ report/ reports/ screenshots/ logs/ +log/ *.log result.xml junit.xml diff --git a/test/e2e_appium/config/environments/base.yaml b/test/e2e_appium/config/environments/base.yaml index 1a5d8bdd80e..14892f35b3b 100644 --- a/test/e2e_appium/config/environments/base.yaml +++ b/test/e2e_appium/config/environments/base.yaml @@ -27,13 +27,11 @@ timeouts: logging: level: "INFO" - enable_screenshots: true - enable_video_recording: true enable_xml_report: true enable_html_report: true enable_junit_report: true directories: - logs: "logs" - reports: "reports" - screenshots: "screenshots" + reports: "reports/${E2E_RUN_ID:-local}" + logs: "reports/${E2E_RUN_ID:-local}/logs" + screenshots: "reports/${E2E_RUN_ID:-local}/screenshots" diff --git a/test/e2e_appium/config/environments/browserstack.yaml b/test/e2e_appium/config/environments/browserstack.yaml index bddb3d4990e..7cd91e3dd29 100644 --- a/test/e2e_appium/config/environments/browserstack.yaml +++ b/test/e2e_appium/config/environments/browserstack.yaml @@ -13,10 +13,10 @@ provider: auth: username: "${BROWSERSTACK_USERNAME:-}" access_key: "${BROWSERSTACK_ACCESS_KEY:-}" - build_name_template: "Status_E2E_Mobile" + build_name_template: "${BROWSERSTACK_BUILD_NAME:-${BROWSERSTACK_BUILD_IDENTIFIER:-${GIT_COMMIT:-Status Mobile}}}" build_identifier_template: "${BROWSERSTACK_BUILD_IDENTIFIER:-${GIT_COMMIT:-local}}" - session_name_template: "${TEST_NAME:-Status Test}" - project_name: "Status E2E Appium" + session_name_template: "${TEST_NAME:-${PYTEST_CURRENT_TEST:-Status Test}}" + project_name: "${BROWSERSTACK_PROJECT_NAME:-Status E2E Appium}" hub_url: "https://hub-cloud.browserstack.com/wd/hub" max_parallel_sessions: 5 sdk: @@ -29,7 +29,13 @@ device_defaults: newCommandTimeout: 300 bstack:options: deviceOrientation: "landscape" - "appiumVersion" : "2.19.0" + "appiumVersion": "2.19.0" + video: true + debug: false + networkLogs: false + appiumLogs: true + deviceLogs: true + appProfiling: false orientation: "LANDSCAPE" appium:unicodeKeyboard: true appium:resetKeyboard: true @@ -76,7 +82,5 @@ execution: logging: level: "INFO" - enable_video_recording: true - enable_screenshots: true enable_xml_report: true enable_html_report: true diff --git a/test/e2e_appium/config/environments/local.yaml b/test/e2e_appium/config/environments/local.yaml index df55426ebec..39feea4f9f7 100644 --- a/test/e2e_appium/config/environments/local.yaml +++ b/test/e2e_appium/config/environments/local.yaml @@ -53,9 +53,4 @@ timeouts: logging: level: "DEBUG" - enable_video_recording: false -directories: - logs: "logs/local" - reports: "reports/local" - screenshots: "screenshots/local" diff --git a/test/e2e_appium/config/logging_config.py b/test/e2e_appium/config/logging_config.py index 26529b5b2e7..46750a9ab08 100644 --- a/test/e2e_appium/config/logging_config.py +++ b/test/e2e_appium/config/logging_config.py @@ -247,9 +247,9 @@ def setup_logging(config: Optional[LoggingConfig] = None) -> Dict[str, Any]: if config is None: config = LoggingConfig() - # Create logs directory + # Create logs directory (create parents for nested per-run paths) logs_dir = Path(config.logs_dir) - logs_dir.mkdir(exist_ok=True) + logs_dir.mkdir(parents=True, exist_ok=True) # Clear any existing handlers root_logger = logging.getLogger() diff --git a/test/e2e_appium/config/settings.py b/test/e2e_appium/config/settings.py index 98498d2b62e..ec41f6816bd 100644 --- a/test/e2e_appium/config/settings.py +++ b/test/e2e_appium/config/settings.py @@ -1,7 +1,9 @@ import os from dataclasses import dataclass, field +from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional +from uuid import uuid4 from core.config_manager import ConfigurationManager, EnvironmentSwitcher from core.environment import DeviceConfig, EnvironmentConfig @@ -23,6 +25,7 @@ class TestConfig: logging_level: str concurrency: Dict[str, int] pytest_addopts: List[str] + run_id: str provider_options: Dict[str, Any] = field(default_factory=dict) @property @@ -45,6 +48,15 @@ def platform_version(self) -> str: _CONFIG_CACHE: Optional[TestConfig] = None +def _ensure_run_id() -> str: + run_id = os.getenv("E2E_RUN_ID") + if run_id: + return run_id + generated = f"local-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}-{uuid4().hex[:6]}" + os.environ["E2E_RUN_ID"] = generated + return generated + + def _select_device(env_config: EnvironmentConfig) -> DeviceConfig: device_id = os.getenv("TEST_DEVICE_ID") tag_env = os.getenv("TEST_DEVICE_TAGS", "") @@ -77,6 +89,7 @@ def _ensure_directories(*paths: str) -> None: def load_config() -> TestConfig: + run_id = _ensure_run_id() env_name = os.getenv("TEST_ENVIRONMENT") manager = ConfigurationManager() if not env_name: @@ -89,9 +102,15 @@ def load_config() -> TestConfig: env_config.device_defaults.get("capabilities", {}) ) - reports_dir = env_config.directories.get("reports", "reports") - logs_dir = env_config.directories.get("logs", "logs") - screenshots_dir = env_config.directories.get("screenshots", "screenshots") + reports_dir = env_config.resolve_template( + env_config.directories.get("reports", "reports") + ) + logs_dir = env_config.resolve_template( + env_config.directories.get("logs", "logs") + ) + screenshots_dir = env_config.resolve_template( + env_config.directories.get("screenshots", "screenshots") + ) _ensure_directories(reports_dir, logs_dir, screenshots_dir) execution = env_config.execution or {} @@ -114,6 +133,7 @@ def load_config() -> TestConfig: logging_level=logging_config.get("level", "INFO"), concurrency=env_config.concurrency_limits(), pytest_addopts=pytest_addopts, + run_id=run_id, provider_options=env_config.provider.options, ) return config diff --git a/test/e2e_appium/conftest.py b/test/e2e_appium/conftest.py index bce63e9f30c..fca312460d8 100644 --- a/test/e2e_appium/conftest.py +++ b/test/e2e_appium/conftest.py @@ -4,9 +4,8 @@ from pathlib import Path from typing import List -from .config import setup_logging, log_test_start, log_test_end -from .config.logging_config import get_logger -from .core import EnvironmentSwitcher +from .config import get_config, setup_logging, log_test_start, log_test_end +from .config.logging_config import get_logger, LoggingConfig from .utils.cloud_reporter import CloudResultReporter from .utils.screenshot import save_screenshot, save_page_source @@ -23,33 +22,38 @@ def pytest_configure(config): global _logging_setup - _logging_setup = setup_logging() # Normalize CLI --env to CURRENT_TEST_ENVIRONMENT so all components agree try: cli_env = getattr(config.option, "env", None) if cli_env: os.environ["CURRENT_TEST_ENVIRONMENT"] = cli_env + os.environ["TEST_ENVIRONMENT"] = cli_env except Exception: # Do not block test runs if normalization fails pass - # Use YAML-based configuration - switcher = EnvironmentSwitcher() - env_name = ( - os.getenv("CURRENT_TEST_ENVIRONMENT") or switcher.auto_detect_environment() - ) - try: - env_config = switcher.switch_to(env_name) + config_obj = get_config(refresh=True) - # Use directories from YAML config - reports_dir = Path(env_config.directories.get("reports", "reports")) - enable_xml_report = env_config.logging.get("enable_xml_report", True) - enable_html_report = env_config.logging.get("enable_html_report", True) + reports_dir = Path(config_obj.reports_dir) + logs_dir = Path(config_obj.logs_dir) + enable_xml_report = config_obj.enable_xml_report + enable_html_report = config_obj.enable_html_report + + logging_cfg = LoggingConfig( + logs_dir=str(logs_dir), + console_level=config_obj.logging_level, + file_level=config_obj.logging_level, + ) + _logging_setup = setup_logging(logging_cfg) logger = get_logger("conftest") - logger.info("Using reports directory from %s config: %s", env_name, reports_dir) + logger.info( + "Using reports directory from %s config: %s", + config_obj.environment_name, + reports_dir, + ) except Exception as e: # Simplified fallback using defaults @@ -57,11 +61,13 @@ def pytest_configure(config): enable_xml_report = True enable_html_report = True + _logging_setup = setup_logging() + logger = get_logger("conftest") logger.warning("Using default configuration: %s", e) logger.warning("Ensure YAML config files are properly set up") - reports_dir.mkdir(exist_ok=True) + reports_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -178,16 +184,9 @@ def pytest_runtest_makereport(item, call): if driver: # Resolve screenshots directory from environment config; fallback to 'screenshots' try: - env_switcher = EnvironmentSwitcher() - env_name = ( - os.getenv("CURRENT_TEST_ENVIRONMENT") - or env_switcher.auto_detect_environment() - ) - env_config = env_switcher.switch_to(env_name) - screenshots_dir = env_config.directories.get( - "screenshots", "screenshots" - ) - logs_dir = env_config.directories.get("logs", "logs") + config_obj = get_config() + screenshots_dir = config_obj.screenshots_dir or "screenshots" + logs_dir = config_obj.logs_dir or "logs" except Exception: screenshots_dir = "screenshots" logs_dir = "logs" diff --git a/test/e2e_appium/core/providers/browserstack.py b/test/e2e_appium/core/providers/browserstack.py index 0c91b60b964..10855000ed4 100644 --- a/test/e2e_appium/core/providers/browserstack.py +++ b/test/e2e_appium/core/providers/browserstack.py @@ -29,9 +29,13 @@ def __init__(self, env_config): "and BROWSERSTACK_ACCESS_KEY." ) self.hub_url = env_config.get_provider_option("hub_url", self.HUB_URL) - self.project_name = env_config.get_provider_option( + project_name_option = env_config.get_provider_option( "project_name", "Status E2E Appium" ) + if isinstance(project_name_option, str): + self.project_name = env_config.resolve_template(project_name_option) + else: + self.project_name = project_name_option self.sdk_options = env_config.get_provider_option("sdk", {}) def create_driver( diff --git a/test/e2e_appium/core/test_context.py b/test/e2e_appium/core/test_context.py index fc906ba4d9e..7ef2809f903 100644 --- a/test/e2e_appium/core/test_context.py +++ b/test/e2e_appium/core/test_context.py @@ -8,10 +8,10 @@ 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 import get_config from config.logging_config import get_logger from utils.gestures import Gestures from utils.screenshot import save_screenshot @@ -180,9 +180,8 @@ def driver(self) -> WebDriver: 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") + config = get_config() + base_dir = config.screenshots_dir or "screenshots" except Exception: base_dir = "screenshots" try: diff --git a/test/e2e_appium/fixtures/onboarding_fixture.py b/test/e2e_appium/fixtures/onboarding_fixture.py index 1c62b70c589..938ecea8bcf 100644 --- a/test/e2e_appium/fixtures/onboarding_fixture.py +++ b/test/e2e_appium/fixtures/onboarding_fixture.py @@ -7,6 +7,7 @@ """ from dataclasses import dataclass, field +from pathlib import Path from typing import Optional, Dict, Any import pytest import time @@ -23,6 +24,7 @@ ) from utils.generators import generate_seed_phrase from models.user_model import User, UserProfile +from config import get_config from config.logging_config import get_logger @@ -67,6 +69,16 @@ def __init__(self, driver, config: OnboardingConfig = None, logger=None): self.config = config or OnboardingConfig() self.logger = logger or get_logger("onboarding_flow") + if self.config.take_screenshots and not self.config.screenshot_path: + try: + resolved_dir = get_config().screenshots_dir + if resolved_dir: + Path(resolved_dir).mkdir(parents=True, exist_ok=True) + self.config.screenshot_path = resolved_dir + except Exception: + # Leave as None if config unavailable + pass + # Initialize page objects self.welcome_page = WelcomePage(self.driver) self.analytics_page = AnalyticsPage(self.driver) @@ -362,12 +374,20 @@ def _execute_main_app_verification(self): def _take_screenshot(self, name: str): """Take screenshot during flow execution""" - if self.config.screenshot_path: + try: + base_dir = self.config.screenshot_path + if not base_dir: + base_dir = get_config().screenshots_dir + except Exception: + base_dir = None + + if base_dir: try: + Path(base_dir).mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%H%M%S") screenshot_name = f"{name}_{timestamp}.png" - screenshot_path = f"{self.config.screenshot_path}/{screenshot_name}" - self.driver.save_screenshot(screenshot_path) + screenshot_path = Path(base_dir) / screenshot_name + self.driver.save_screenshot(str(screenshot_path)) self.logger.debug(f"๐Ÿ“ท Screenshot saved: {screenshot_path}") except Exception as e: self.logger.warning(f"โš ๏ธ Failed to take screenshot '{name}': {e}") diff --git a/test/e2e_appium/locators/onboarding/wallet/wallet_locators.py b/test/e2e_appium/locators/onboarding/wallet/wallet_locators.py index e0702cf42f6..aac59738f63 100644 --- a/test/e2e_appium/locators/onboarding/wallet/wallet_locators.py +++ b/test/e2e_appium/locators/onboarding/wallet/wallet_locators.py @@ -1,14 +1,14 @@ from ...base_locators import BaseLocators -class WalletLocators(BaseLocators): +class WalletLocators(BaseLocators): WALLET_HEADER = BaseLocators.content_desc_contains("walletHeader") WALLET_FOOTER_SEND_BUTTON = BaseLocators.xpath( "//*[contains(@resource-id, 'walletFooterSendButton')]" ) 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')]" ) @@ -16,13 +16,15 @@ class WalletLocators(BaseLocators): "//*[contains(@text, 'ETH') or contains(@text, 'USD')]" ) - SAVED_ADDRESSES_BUTTON = BaseLocators.xpath( - "//*[contains(@resource-id, 'savedAddressesBtn') or @content-desc='Saved addresses']" + SAVED_ADDRESSES_BUTTON = BaseLocators.content_desc_contains( + "[tid:savedAddressesBtn]" ) 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]") + WALLET_HEADER_ADDRESS = BaseLocators.content_desc_contains( + "[tid:walletHeaderButton]" + ) # Account selection ACCOUNT_1_BY_TEXT = BaseLocators.xpath( diff --git a/test/e2e_appium/locators/wallet/saved_addresses_locators.py b/test/e2e_appium/locators/wallet/saved_addresses_locators.py index d90cf4f85a2..71ecb60c5f4 100644 --- a/test/e2e_appium/locators/wallet/saved_addresses_locators.py +++ b/test/e2e_appium/locators/wallet/saved_addresses_locators.py @@ -2,19 +2,21 @@ class SavedAddressesLocators(BaseLocators): - WALLET_SAVED_ADDRESSES_BUTTON = BaseLocators.xpath("//android.view.View.VirtualChild[@content-desc=\"Saved addresses [tid:savedAddressesBtn]\"]") + WALLET_SAVED_ADDRESSES_BUTTON = BaseLocators.content_desc_contains( + "[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]\"]" + '//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]\"]" + '//android.view.View.VirtualChild[@content-desc="Add new address [tid:walletHeaderButton]"]' ) SAVED_ADDRESS_ITEM_ANY = BaseLocators.xpath( - "//android.view.View.VirtualChild[@resource-id=\"savedAddressDelegate\"]" + '//android.view.View.VirtualChild[@resource-id="savedAddressDelegate"]' ) SAVED_ADDRESS_DETAILS_POPUP = BaseLocators.xpath( "//*[contains(@resource-id, 'SavedAddressActivityPopup')]" @@ -22,11 +24,14 @@ class SavedAddressesLocators(BaseLocators): 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") + 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\"]" + '//android.view.View.VirtualChild[@resource-id="savedAddressDelegate"]' + f"//*[contains(@resource-id, 'savedAddressView_Delegate_{name}')]" ) @@ -47,14 +52,15 @@ def popup_menu_by_name(name: str) -> tuple: + f"contains(@resource-id, 'savedAddressView_Delegate_menuButton_{name}')" + "]" ) + NAME_INPUT = BaseLocators.xpath( - "//android.view.View.VirtualChild[@content-desc=\"Address name [tid:statusBaseInput]\"]" + '//android.view.View.VirtualChild[@content-desc="Address name [tid:statusBaseInput]"]' ) ADDRESS_INPUT = BaseLocators.xpath( - "//android.view.View.VirtualChild[@content-desc=\"Ethereum address [tid:statusBaseInput]\"]" + '//android.view.View.VirtualChild[@content-desc="Ethereum address [tid:statusBaseInput]"]' ) SAVE_BUTTON = BaseLocators.xpath( - "//android.view.View.VirtualChild[@content-desc=\"Add address [tid:addSavedAddress]\"]" + '//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']" @@ -62,6 +68,3 @@ def popup_menu_by_name(name: str) -> tuple: CONFIRM_DELETE_BUTTON = BaseLocators.xpath( "//*[@resource-id and contains(@resource-id, 'RemoveSavedAddressPopup-ConfirmButton')]" ) - - - diff --git a/test/e2e_appium/pages/app.py b/test/e2e_appium/pages/app.py index 369c9c52da1..8bf10a75911 100644 --- a/test/e2e_appium/pages/app.py +++ b/test/e2e_appium/pages/app.py @@ -8,7 +8,6 @@ class App(BasePage): - def __init__(self, driver): super().__init__(driver) self.locators = AppLocators() @@ -98,34 +97,68 @@ 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) + return self.safe_click( + self.locators.LEFT_NAV_SETTINGS, timeout=4, max_attempts=2 + ) + + def wait_for_toast( + self, + expected_substring: Optional[str] = None, + timeout: float = 6.0, + poll_interval: float = 0.2, + stability: float = 0.0, + ) -> Optional[str]: + """Poll for a toast message and optionally match its content.""" + + deadline = time.time() + (timeout or 0) + last_seen: Optional[str] = None + + while time.time() < deadline: + remaining = max(deadline - time.time(), 0.3) + desc = self.get_toast_content_desc(timeout=remaining) + if desc: + last_seen = desc + matches = ( + not expected_substring or expected_substring.lower() in desc.lower() + ) + if matches: + if stability > 0: + stable_until = time.time() + stability + while time.time() < stable_until: + if not self.is_element_visible( + self.locators.ANY_TOAST, timeout=0.1 + ): + break + time.sleep(0.05) + else: + self.logger.info(f"Toast detected text='{desc}'") + 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 desc + else: + self.logger.info(f"Toast detected text='{desc}'") + 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 desc + + time.sleep(min(poll_interval, max(deadline - time.time(), 0.1))) + + if last_seen: + self.logger.debug( + "Toast detected but did not match expectation: '%s'", last_seen + ) + return None 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 + return self.wait_for_toast(timeout=timeout or 3.0) is not None def get_toast_content_desc(self, timeout: Optional[int] = 3) -> Optional[str]: """Return toast's content-desc, polling until non-empty or timeout.""" diff --git a/test/e2e_appium/pages/base_page.py b/test/e2e_appium/pages/base_page.py index ebd4db76da8..a0f4677c213 100644 --- a/test/e2e_appium/pages/base_page.py +++ b/test/e2e_appium/pages/base_page.py @@ -1,15 +1,14 @@ import time -import os import logging from datetime import datetime +from pathlib import Path from typing import Optional, List from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC -from config import log_element_action -from core import EnvironmentSwitcher +from config import get_config, log_element_action from utils.gestures import Gestures from utils.screenshot import save_screenshot, save_page_source from utils.app_lifecycle_manager import AppLifecycleManager @@ -23,16 +22,16 @@ def __init__(self, driver): self.gestures = Gestures(driver) self.app_lifecycle = AppLifecycleManager(driver) self.keyboard = KeyboardManager(driver) - env_name = os.getenv("CURRENT_TEST_ENVIRONMENT", "lambdatest") - try: - switcher = EnvironmentSwitcher() - 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" - ) + config = get_config() + self.timeouts = config.environment.timeouts + element_wait_timeout = self.timeouts.get("element_wait", 30) + self._screenshots_dir = config.screenshots_dir or "screenshots" + try: + Path(self._screenshots_dir).mkdir(parents=True, exist_ok=True) + except Exception: + # Do not block tests if directory creation fails + pass except Exception: self.timeouts = { "element_wait": 30, @@ -146,6 +145,7 @@ def safe_click( attempts = 0 while attempts < max_attempts: attempts += 1 + element = None try: wait = self._create_wait(timeout, "element_click") element = wait.until(EC.element_to_be_clickable(loc)) @@ -153,6 +153,12 @@ def safe_click( log_element_action("click_element", f"{loc[0]}: {loc[1]}", True, 0) return True except Exception as e: + if element is not None and self._gesture_tap_fallback(element, loc): + log_element_action( + "click_element", f"{loc[0]}: {loc[1]}", True, 0 + ) + return True + self.logger.debug(f"Click attempt {attempts} failed for {loc}: {e}") if attempts >= max_attempts: break @@ -359,13 +365,33 @@ def tap_coordinate_relative(self, element, x_offset: int, y_offset: int) -> bool self.logger.debug(f"Coordinate tap failed: {e}") return False - def restart_app(self, app_package: str = "app.status.mobile") -> bool: + def _gesture_tap_fallback(self, element, locator) -> bool: + """Fallback tap using Appium gestures when native click fails.""" + try: + if self.gestures.element_tap(element): + self.logger.debug(f"Gesture tap fallback succeeded for {locator}") + return True + except Exception as err: + self.logger.debug(f"Gesture tap fallback error for {locator}: {err}") + + try: + rect = element.rect + center_x = int(rect["x"] + rect["width"] / 2) + center_y = int(rect["y"] + rect["height"] / 2) + if self.gestures.tap(center_x, center_y): + self.logger.debug( + f"Coordinate tap fallback succeeded for {locator} at ({center_x}, {center_y})" + ) + return True + except Exception as err: + self.logger.debug(f"Coordinate fallback error for {locator}: {err}") + return False + + def restart_app(self, app_package: Optional[str] = None) -> 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 = "app.status.mobile" - ) -> bool: + def restart_app_with_data_cleared(self, app_package: Optional[str] = None) -> bool: """Restart the app with all app data cleared (fresh app state).""" return self.app_lifecycle.restart_app_with_data_cleared(app_package) @@ -385,8 +411,8 @@ def wait_for_condition( 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"): + env_name = os.getenv("CURRENT_TEST_ENVIRONMENT", "browserstack").lower() + if env_name in ("browserstack",): time.sleep(base_delay * 1.5) else: time.sleep(base_delay * 0.5) diff --git a/test/e2e_appium/pages/settings/change_password_modal.py b/test/e2e_appium/pages/settings/change_password_modal.py index 905a4e723be..f2bd5dfca92 100644 --- a/test/e2e_appium/pages/settings/change_password_modal.py +++ b/test/e2e_appium/pages/settings/change_password_modal.py @@ -34,7 +34,9 @@ def complete_reencrypt_and_restart(self, timeout: int = 90) -> bool: restart_confirmed = False while time.time() < deadline: - modal_present = self.find_element_safe(self.locators.MODAL_CONTAINER, timeout=1) + modal_present = self.find_element_safe( + self.locators.MODAL_CONTAINER, timeout=1 + ) if not modal_present: restart_confirmed = True break @@ -67,8 +69,16 @@ def complete_reencrypt_and_restart(self, timeout: int = 90) -> bool: try: self.app_lifecycle.activate_app() - except Exception: - pass + except Exception as err: + self.logger.debug("App activation after password change failed: %s", err) + + try: + from services.app_state_manager import AppStateManager + + if not AppStateManager(self.driver).wait_for_app_ready(timeout=45): + self.logger.debug("App state manager did not confirm readiness in time") + except Exception as err: + self.logger.debug("App readiness wait failed: %s", err) return True def _wait_for_primary_button_enabled(self, timeout: int = 10) -> bool: diff --git a/test/e2e_appium/pages/wallet/saved_addresses_page.py b/test/e2e_appium/pages/wallet/saved_addresses_page.py index 5360bd866ea..bdb7f960c7a 100644 --- a/test/e2e_appium/pages/wallet/saved_addresses_page.py +++ b/test/e2e_appium/pages/wallet/saved_addresses_page.py @@ -10,13 +10,20 @@ def __init__(self, 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 + return bool( + self.is_element_visible( + self.locators.ADD_NEW_SAVED_ADDRESS_BUTTON_WALLET, timeout=timeout + ) + or self.is_element_visible( + self.locators.ADD_NEW_SAVED_ADDRESS_BUTTON_SETTINGS, 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) + if self.safe_click( + self.locators.ADD_NEW_SAVED_ADDRESS_BUTTON_WALLET, timeout=4 + ): + return True return self.safe_click(self.locators.ADD_NEW_SAVED_ADDRESS_BUTTON_SETTINGS) def is_entry_visible(self, name: str, timeout: Optional[int] = 10) -> bool: @@ -26,15 +33,20 @@ 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) + 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)...") + 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 @@ -43,14 +55,22 @@ def open_row_menu(self, name: str) -> bool: delegate.click() except Exception: return False - if not self.is_element_visible(self.locators.SAVED_ADDRESS_DETAILS_POPUP, timeout=5): + 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.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...") + 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: @@ -61,7 +81,9 @@ def open_row_menu(self, name: str) -> bool: 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): + 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 @@ -70,5 +92,3 @@ def delete_saved_address_with_confirmation(self, name: str) -> bool: 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/requirements.txt b/test/e2e_appium/requirements.txt index 26c9e220e36..6ac0c9b7c1f 100644 --- a/test/e2e_appium/requirements.txt +++ b/test/e2e_appium/requirements.txt @@ -1,7 +1,8 @@ -Appium-Python-Client==5.1.1 -pytest==7.4.3 +Appium-Python-Client==5.2.4 +pytest==8.4.2 pytest-html==4.1.1 -PyYAML==6.0.1 -jsonschema==4.19.2 -requests==2.31.0 -eth_account==0.12.3 \ No newline at end of file +pytest-xdist==3.8.0 +PyYAML==6.0.3 +jsonschema==4.25.1 +requests==2.32.5 +eth_account==0.13.7 diff --git a/test/e2e_appium/tests/test_backup_recovery_phrase.py b/test/e2e_appium/tests/test_backup_recovery_phrase.py index 1664f7f4f49..959e90a57ec 100644 --- a/test/e2e_appium/tests/test_backup_recovery_phrase.py +++ b/test/e2e_appium/tests/test_backup_recovery_phrase.py @@ -1,14 +1,14 @@ import os import pytest -from tests.base_test import BaseAppReadyTest, lambdatest_reporting +from tests.base_test import BaseAppReadyTest, cloud_reporting from utils.screenshot import save_page_source from pages.app import App class TestBackupRecoveryPhrase(BaseAppReadyTest): @pytest.mark.critical - @lambdatest_reporting + @cloud_reporting def test_sign_out_from_settings(self): # BaseAppReadyTest ensures authenticated home @@ -33,7 +33,7 @@ def test_sign_out_from_settings(self): [pytest.param(True, id="delete")], ) @pytest.mark.smoke - @lambdatest_reporting + @cloud_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() @@ -72,10 +72,12 @@ def test_backup_recovery_phrase_flow(self, remove_phrase): # 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.wait_for_toast( + expected_substring="backed up your recovery phrase", + timeout=8, + stability=0.2, ) - keep_msg = app.get_toast_content_desc(timeout=10) or "" + assert keep_msg, "Expected a toast to appear after backup completion" assert "backed up your recovery phrase" in keep_msg.lower() # Capture final state XML for reference @@ -107,10 +109,12 @@ def test_backup_recovery_phrase_flow(self, remove_phrase): ) # 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.wait_for_toast( + expected_substring="recovery phrase permanently removed", + timeout=8, + stability=0.2, ) - delete_msg = app.get_toast_content_desc(timeout=5) or "" + assert delete_msg, "Expected a toast to appear after deletion" assert "recovery phrase permanently removed" in delete_msg.lower() # After toast, verify entry removal diff --git a/test/e2e_appium/tests/test_onboarding_flow.py b/test/e2e_appium/tests/test_onboarding_flow.py index 22815067bab..84aadece527 100644 --- a/test/e2e_appium/tests/test_onboarding_flow.py +++ b/test/e2e_appium/tests/test_onboarding_flow.py @@ -6,7 +6,7 @@ """ import pytest -from tests.base_test import BaseTest, lambdatest_reporting +from tests.base_test import BaseTest, cloud_reporting class TestOnboardingFlow(BaseTest): @@ -15,7 +15,7 @@ class TestOnboardingFlow(BaseTest): @pytest.mark.smoke @pytest.mark.onboarding @pytest.mark.e2e - @lambdatest_reporting + @cloud_reporting @pytest.mark.onboarding_config( custom_display_name="E2E_TestUser", skip_analytics=True, @@ -55,7 +55,7 @@ def test_onboarding_new_password_skip_analytics(self, onboarded_user): self.logger.info("Complete onboarding flow test with fixture passed!") @pytest.mark.onboarding - @lambdatest_reporting + @cloud_reporting @pytest.mark.onboarding_config(custom_display_name="E2E_TestUser") def test_onboarding_lands_on_main_app(self, onboarded_app): app = onboarded_app diff --git a/test/e2e_appium/tests/test_onboarding_import_seed.py b/test/e2e_appium/tests/test_onboarding_import_seed.py index e7fe987f3ea..f3ac71bda3a 100644 --- a/test/e2e_appium/tests/test_onboarding_import_seed.py +++ b/test/e2e_appium/tests/test_onboarding_import_seed.py @@ -1,6 +1,6 @@ import pytest -from tests.base_test import BaseTest, lambdatest_reporting +from tests.base_test import BaseTest, cloud_reporting from pages.onboarding import ( WelcomePage, AnalyticsPage, @@ -17,13 +17,15 @@ class TestOnboardingImportSeed(BaseTest): @pytest.mark.smoke @pytest.mark.onboarding - @lambdatest_reporting + @cloud_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.is_screen_displayed(timeout=30), ( + "Welcome screen should be visible" + ) assert welcome.click_create_profile(), "Failed to click Create profile" analytics = AnalyticsPage(self.driver) @@ -32,10 +34,14 @@ def test_import_and_reimport_seed(self): 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" + 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.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) @@ -43,7 +49,9 @@ def test_import_and_reimport_seed(self): 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" + assert splash.wait_for_loading_completion(timeout=60), ( + "App did not finish loading" + ) wallet_locators = WalletLocators() @@ -54,7 +62,9 @@ def test_import_and_reimport_seed(self): 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) + 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" @@ -66,22 +76,11 @@ def test_import_and_reimport_seed(self): ) base_page = base - restarted = False - try: - restarted = base_page.restart_app("app.status.mobile") - except Exception: - restarted = False - - if not restarted: - try: - self.driver.terminate_app("app.status.mobile") - self.driver.start_activity( - "app.status.mobile", "org.qtproject.qt.android.bindings.QtActivity" - ) - except Exception: - pass + restarted = base_page.restart_app() + assert restarted, "Failed to restart app before re-importing seed" from locators.onboarding.returning_login_locators import ReturningLoginLocators + base = base_page rel = ReturningLoginLocators() @@ -107,22 +106,38 @@ def nudge_user_selector() -> bool: assert opened, "Returning login user selector did not open" try: - base.safe_click(rel.CREATE_PROFILE_DROPDOWN_ITEM, timeout=10, max_attempts=2) + 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" + 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" + 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)" + 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 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" + assert not seed_login.is_continue_button_enabled(), ( + "Continue should be disabled for already added seed phrase" + ) diff --git a/test/e2e_appium/tests/test_saved_addresses.py b/test/e2e_appium/tests/test_saved_addresses.py index 480bdac1011..cccd08a28a3 100644 --- a/test/e2e_appium/tests/test_saved_addresses.py +++ b/test/e2e_appium/tests/test_saved_addresses.py @@ -1,6 +1,6 @@ import pytest -from tests.base_test import BaseAppReadyTest, lambdatest_reporting +from tests.base_test import BaseAppReadyTest, cloud_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 @@ -10,19 +10,24 @@ class TestSavedAddresses(BaseAppReadyTest): - @pytest.mark.wallet @pytest.mark.saved_addresses @pytest.mark.smoke - @lambdatest_reporting + @cloud_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" + 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" + 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" + + 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" @@ -33,15 +38,23 @@ def test_add_and_remove_saved_address(self): 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 "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.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" + 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" - - + assert not saved_addresses.is_entry_visible(name, timeout=10), ( + f"Saved address '{name}' still visible after deletion" + ) diff --git a/test/e2e_appium/tests/test_settings_password_change_password.py b/test/e2e_appium/tests/test_settings_password_change_password.py index 462b5a7a453..b4f7113457c 100644 --- a/test/e2e_appium/tests/test_settings_password_change_password.py +++ b/test/e2e_appium/tests/test_settings_password_change_password.py @@ -2,14 +2,14 @@ from constants import AppSections from locators.onboarding.wallet.wallet_locators import WalletLocators -from tests.base_test import BaseAppReadyTest, lambdatest_reporting +from tests.base_test import BaseAppReadyTest, cloud_reporting from utils.generators import generate_secure_password class TestSettingsPasswordChange(BaseAppReadyTest): @pytest.mark.critical @pytest.mark.smoke - @lambdatest_reporting + @cloud_reporting def test_change_password_and_login(self): assert self.ctx.app.click_settings_left_nav(), "Failed to open Settings" assert self.ctx.settings.is_loaded(), "Settings not detected" @@ -25,9 +25,7 @@ def test_change_password_and_login(self): pass modal = password_settings.change_password(old_password, new_password) - assert modal and modal.is_displayed(), ( - "Change password modal did not appear" - ) + assert modal and modal.is_displayed(), "Change password modal did not appear" assert modal.complete_reencrypt_and_restart(), ( "Failed to complete password re-encryption flow" ) @@ -38,6 +36,7 @@ def test_change_password_and_login(self): assert self.ctx.welcome_back.perform_login(new_password), ( "Unable to authenticate after restart with the new password" ) + locators = WalletLocators() assert self.ctx.app.is_element_visible( locators.WALLET_FOOTER_SEND_BUTTON, timeout=15 diff --git a/test/e2e_appium/utils/app_lifecycle_manager.py b/test/e2e_appium/utils/app_lifecycle_manager.py index f4bc19c38f0..e837f694555 100644 --- a/test/e2e_appium/utils/app_lifecycle_manager.py +++ b/test/e2e_appium/utils/app_lifecycle_manager.py @@ -7,17 +7,26 @@ import os import subprocess +from typing import Optional, Tuple from config.logging_config import get_logger class AppLifecycleManager: - def __init__(self, driver): self.driver = driver self.logger = get_logger("app_lifecycle") + self._default_package, self._default_activity = self._resolve_app_identifiers() + + @property + def default_package(self) -> Optional[str]: + return self._default_package + + @property + def default_activity(self) -> Optional[str]: + return self._default_activity - def restart_app(self, app_package: str = "app.status.mobile") -> bool: + def restart_app(self, app_package: Optional[str] = None) -> bool: """ Restart the app within the current session. @@ -25,27 +34,22 @@ def restart_app(self, app_package: str = "app.status.mobile") -> bool: or app state recovery after restart. Args: - app_package: App package name (defaults to Status tablet) + app_package: App package name override. Falls back to detected capabilities. 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) - - return self._restart_local(app_package) - - except Exception as e: - self.logger.error(f"โŒ App restart failed: {e}") + package = self._resolve_package(app_package) + if not package: return False - def restart_app_with_data_cleared( - self, app_package: str = "app.status.mobile" - ) -> bool: + self.logger.info("Restarting app: %s", package) + success = self._restart_via_mobile_commands(package) + if not success: + self.logger.error("App restart failed after all attempts") + return success + + def restart_app_with_data_cleared(self, app_package: Optional[str] = None) -> bool: """ Restart the app with all app data cleared (fresh app state). @@ -53,40 +57,43 @@ def restart_app_with_data_cleared( Useful for testing fresh onboarding flows. Args: - app_package: The app package identifier + app_package: The app package identifier override. Returns: bool: True if restart successful, False otherwise """ + package = self._resolve_package(app_package) + if not package: + return False + try: - self.logger.info("๐Ÿ”„ Restarting app with data cleared...") + 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"): + env_name = os.getenv("CURRENT_TEST_ENVIRONMENT", "browserstack").lower() + if env_name in ("browserstack",): 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") + self.driver.terminate_app(package) + self.logger.debug("App terminated") clear_data_result = subprocess.run( - ["adb", "shell", "pm", "clear", app_package], + ["adb", "shell", "pm", "clear", package], capture_output=True, text=True, ) if clear_data_result.returncode != 0: self.logger.warning( - f"โš ๏ธ Clear app data failed: {clear_data_result.stderr}" + "Clear app data failed: %s", clear_data_result.stderr ) else: - self.logger.debug("โœ“ App data cleared") + self.logger.debug("App data cleared") - self.driver.activate_app(app_package) - self.logger.debug("โœ“ App reactivated with fresh state") + self._activate_app(package) # Optional activation tap try: @@ -97,86 +104,145 @@ def restart_app_with_data_cleared( except Exception: pass - self.logger.info("โœ… App restart with cleared data completed successfully") + self.logger.info("App restart with data cleared completed successfully") return True except Exception as e: - self.logger.error(f"โŒ App restart with data cleared failed: {e}") + self.logger.error("App restart with data cleared failed: %s", e) return False - def terminate_app(self, app_package: str = "app.status.mobile") -> bool: + def terminate_app(self, app_package: Optional[str] = None) -> bool: """Terminate the specified app.""" + package = self._resolve_package(app_package) + if not package: + return False + try: - self.driver.terminate_app(app_package) - self.logger.debug(f"โœ“ App terminated: {app_package}") + self.driver.terminate_app(package) + self.logger.debug("App terminated: %s", package) return True except Exception as e: - self.logger.error(f"โŒ Failed to terminate app: {e}") + self.logger.error("Failed to terminate app: %s", e) return False - def activate_app(self, app_package: str = "app.status.mobile") -> bool: + def activate_app( + self, app_package: Optional[str] = None, app_activity: Optional[str] = None + ) -> bool: """Activate the specified app.""" + package = self._resolve_package(app_package) + if not package: + return False + + activity = app_activity or self._default_activity try: - self.driver.activate_app(app_package) - self.logger.debug(f"โœ“ App activated: {app_package}") + self._activate_app(package) return True except Exception as e: - self.logger.error(f"โŒ Failed to activate app: {e}") + self.logger.debug( + "activate_app failed for %s: %s; attempting start_activity", package, e + ) + if activity: + try: + self.driver.start_activity(package, activity) + self.logger.debug("App started via start_activity") + return True + except Exception as start_err: + self.logger.error( + "Failed to start activity %s for %s: %s", + activity, + package, + start_err, + ) + return False + self.logger.error("Failed to activate app: %s", 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.""" + def _activate_app(self, package: str) -> None: + self.driver.activate_app(package) + self.logger.debug("App activated: %s", package) + + def _restart_via_mobile_commands(self, app_package: str) -> bool: + """Restart the app using Appium mobile: terminateApp / launchApp commands.""" 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, - ) + self.logger.debug("Attempting mobile restart for %s", app_package) 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)" + self.driver.execute_script( + "mobile: terminateApp", {"appId": app_package} + ) + self.logger.debug("App terminated via mobile: terminateApp") + except Exception as terminate_err: + self.logger.debug( + "mobile: terminateApp failed (non-fatal): %s", terminate_err ) + + try: + self.driver.activate_app(app_package) + self.logger.info("App restart completed via activate_app") return True - except Exception as fallback_error: + except Exception as activate_err: + self.logger.debug( + "activate_app failed for %s: %s; attempting start_activity", + app_package, + activate_err, + ) + if self._default_activity: + self.driver.start_activity(app_package, self._default_activity) + self.logger.info("App restart completed via start_activity") + return True self.logger.error( - "โŒ App restart failed on LambdaTest fallback path: %s", - fallback_error, + "No default activity available to restart %s", app_package ) return False + except Exception: + self.logger.exception( + "App restart via activate_app/start_activity failed for %s", + app_package, + ) + 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") + def _resolve_package(self, override: Optional[str]) -> Optional[str]: + package = override or self._default_package + if package: + return package + self.logger.error( + "Unable to determine app package. Ensure appPackage capability is set." + ) + return None + + def _resolve_app_identifiers(self) -> Tuple[Optional[str], Optional[str]]: + """ + Extract the application package and activity from driver capabilities. + + Returns: + tuple(package, activity) + """ + capability_sources = [ + getattr(self.driver, "capabilities", None), + getattr(self.driver, "desired_capabilities", None), + ] + + package = None + activity = None + + for caps in capability_sources: + if not caps: + continue + package = caps.get("appium:appPackage") or caps.get("appPackage") or package + activity = ( + caps.get("appium:appActivity") or caps.get("appActivity") or activity + ) + if package and activity: + break + + if package: + self.logger.debug(f"Detected AUT package from capabilities: {package}") + else: + self.logger.warning( + "AUT package not found in capabilities; falling back to legacy default" + ) + package = "im.status.app" - self.driver.activate_app(app_package) - self.logger.debug("โœ“ App reactivated") + if activity: + self.logger.debug(f"Detected AUT launch activity: {activity}") - self.logger.info("โœ… App restart completed successfully") - return True + return package, activity diff --git a/test/e2e_appium/utils/generators.py b/test/e2e_appium/utils/generators.py index 34a2399118f..3b0e0c7edab 100644 --- a/test/e2e_appium/utils/generators.py +++ b/test/e2e_appium/utils/generators.py @@ -3,8 +3,14 @@ import secrets import string from typing import Optional -from eth_account.hdaccount import Language, generate_mnemonic, Mnemonic + from eth_account import Account +from eth_account.hdaccount import Mnemonic + + +_DEFAULT_MNEMONIC_LANGUAGE = "english" +_ALLOWED_WORD_COUNTS = (12, 18, 24) +_MNEMONIC_HELPER = Mnemonic(_DEFAULT_MNEMONIC_LANGUAGE) _SECURE_RANDOM = random.SystemRandom() @@ -28,18 +34,12 @@ def generate_seed_phrase(word_count: Optional[int] = None) -> str: Valid BIP39 seed phrase as a string. """ if word_count is None: - word_count = random.choice([12, 18, 24]) + word_count = random.choice(_ALLOWED_WORD_COUNTS) - if word_count not in [12, 18, 24]: + if word_count not in _ALLOWED_WORD_COUNTS: raise ValueError("word_count must be 12, 18, or 24") - language = Language.ENGLISH - mnemonic_helper = Mnemonic(language) - - words = "" - while not mnemonic_helper.is_mnemonic_valid(mnemonic=words): - words = generate_mnemonic(num_words=word_count, lang=language) - return words + return _MNEMONIC_HELPER.generate(num_words=word_count) def generate_12_word_seed_phrase() -> str: