diff --git a/.github/workflows/e2e-appium-android.yml b/.github/workflows/e2e-appium-android.yml new file mode 100644 index 0000000000..71cee46816 --- /dev/null +++ b/.github/workflows/e2e-appium-android.yml @@ -0,0 +1,253 @@ +name: E2E Appium Android + +permissions: + actions: read + contents: read + checks: write + pull-requests: write + +on: + push: + branches: + - test/e2e_appium/add-e2e_appium-framework + workflow_dispatch: + inputs: + apk_source: + description: 'APK source (lt://APP..., artifact run id, or direct URL)' + type: string + default: 'lt://APP...' + required: true + + target: + description: 'Test target (marker name, test file, or custom pytest args)' + type: string + default: 'onboarding' + required: true + + device_config: + description: 'Device configuration' + type: choice + default: 'default' + options: + - 'default' # Galaxy Tab S8 + - 'pixel_tablet' # Google Pixel Tablet + - 'galaxy_tab_a' # Samsung Galaxy Tab A + + parallel_execution: + description: 'Enable parallel test execution' + type: boolean + default: false + +concurrency: + group: e2e-${{ github.ref }}-${{ inputs.target || 'onboarding' }} + cancel-in-progress: true + +jobs: + e2e-test: + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Normalize inputs + id: vars + run: | + # Normalize all inputs to handle both push and workflow_dispatch triggers + echo "target=${{ inputs.target || 'onboarding' }}" >> $GITHUB_OUTPUT + echo "parallel_execution=${{ inputs.parallel_execution || 'false' }}" >> $GITHUB_OUTPUT + echo "device_config=${{ inputs.device_config || 'default' }}" >> $GITHUB_OUTPUT + echo "apk_source=${{ inputs.apk_source || 'lt://APP10160232441755188546651464' }}" >> $GITHUB_OUTPUT + + echo "๐Ÿ“‹ Normalized inputs:" + echo " Target: ${{ steps.vars.outputs.target }}" + echo " Parallel: ${{ steps.vars.outputs.parallel_execution }}" + echo " Device: ${{ steps.vars.outputs.device_config }}" + echo " APK: ${{ steps.vars.outputs.apk_source }}" + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11.9' + cache: 'pip' + + - name: Install dependencies + working-directory: test/e2e_appium + run: | + pip install -r requirements.txt + if [ "${{ steps.vars.outputs.parallel_execution }}" = "true" ]; then + pip install pytest-xdist + fi + + - name: Detect APK source type + id: detect_source + run: | + SOURCE="${{ steps.vars.outputs.apk_source }}" + + if [[ "$SOURCE" == lt://* ]]; then + echo "source_type=lambdatest_url" >> $GITHUB_OUTPUT + echo "app_url=$SOURCE" >> $GITHUB_OUTPUT + echo "๐Ÿ“ฑ Using existing LambdaTest app: $SOURCE" + elif [[ "$SOURCE" == http* ]]; then + echo "source_type=direct_url" >> $GITHUB_OUTPUT + echo "download_url=$SOURCE" >> $GITHUB_OUTPUT + echo "๐ŸŒ Will download APK from URL: $SOURCE" + else + # Assume SOURCE is a GitHub Actions run id + echo "source_type=github_artifact" >> $GITHUB_OUTPUT + echo "artifact_run_id=$SOURCE" >> $GITHUB_OUTPUT + echo "๐Ÿ“ฆ Will download GitHub artifact from run: $SOURCE" + fi + + - name: Download APK from GitHub artifact + if: steps.detect_source.outputs.source_type == 'github_artifact' + uses: actions/download-artifact@v4 + with: + # Your build workflow uploads "Status-tablet-${{ inputs.architecture }}-${{ github.run_number }}" + pattern: Status-tablet-* + merge-multiple: true + run-id: ${{ steps.detect_source.outputs.artifact_run_id }} + path: ./apk/ + github-token: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository }} + + - name: Download APK from URL + if: steps.detect_source.outputs.source_type == 'direct_url' + run: | + echo "Downloading APK from: ${{ steps.detect_source.outputs.download_url }}" + mkdir -p ./apk + curl -L -o ./apk/downloaded.apk "${{ steps.detect_source.outputs.download_url }}" + ls -la ./apk/ + + - name: Upload APK to LambdaTest + if: steps.detect_source.outputs.source_type != 'lambdatest_url' + id: upload-lt + working-directory: test/e2e_appium + env: + LT_USERNAME: ${{ secrets.LT_USERNAME }} + LT_ACCESS_KEY: ${{ secrets.LT_ACCESS_KEY }} + run: | + if [ "${{ steps.detect_source.outputs.source_type }}" = "github_artifact" ]; then + APK_PATH="$(find ../../apk -type f -name '*.apk' | head -n 1)" + else + APK_PATH="../../apk/downloaded.apk" + fi + + if [ -z "$APK_PATH" ] || [ ! -f "$APK_PATH" ]; then + echo "No APK found in ./apk" >&2 + ls -la ../../apk || true + exit 1 + fi + + echo "๐Ÿš€ Uploading APK to LambdaTest: $APK_PATH" + python scripts/upload_apk_to_lambdatest.py \ + --apk-path "$APK_PATH" \ + --app-name "E2E-${{ steps.vars.outputs.target }}-${{ github.run_number }}" + + - name: Setup test environment + run: | + # Set app URL based on source type + if [ "${{ steps.detect_source.outputs.source_type }}" = "lambdatest_url" ]; then + echo "STATUS_APP_URL=${{ steps.detect_source.outputs.app_url }}" >> $GITHUB_ENV + else + echo "STATUS_APP_URL=${{ steps.upload-lt.outputs.app_url }}" >> $GITHUB_ENV + fi + + # Set device name + case "${{ steps.vars.outputs.device_config }}" in + "pixel_tablet") echo "DEVICE_NAME=Google Pixel Tablet" >> $GITHUB_ENV ;; + "galaxy_tab_a") echo "DEVICE_NAME=Samsung Galaxy Tab A8" >> $GITHUB_ENV ;; + *) echo "DEVICE_NAME=Samsung Galaxy Tab S8" >> $GITHUB_ENV ;; + esac + + echo "๐ŸŽฏ Test configuration:" + echo " Target: ${{ steps.vars.outputs.target }}" + echo " Device: $DEVICE_NAME" + echo " App: $STATUS_APP_URL" + echo " Parallel: ${{ steps.vars.outputs.parallel_execution }}" + + - name: Build pytest command + id: pytest + run: | + TARGET="${{ steps.vars.outputs.target }}" + + # Auto-detect target type and build pytest args + if [[ "$TARGET" == *.py ]]; then + # Test file + PYTEST_ARGS="tests/$TARGET" + elif [[ "$TARGET" == *::* ]] || [[ "$TARGET" == *test_* ]]; then + # Specific test or custom args + PYTEST_ARGS="$TARGET" + else + # Marker + PYTEST_ARGS="-m $TARGET" + fi + + echo "pytest_args=$PYTEST_ARGS" >> $GITHUB_OUTPUT + echo "๐Ÿ“‹ Will run: pytest $PYTEST_ARGS" + + - name: Run E2E tests + working-directory: test/e2e_appium + env: + LT_USERNAME: ${{ secrets.LT_USERNAME }} + LT_ACCESS_KEY: ${{ secrets.LT_ACCESS_KEY }} + STATUS_APP_URL: ${{ env.STATUS_APP_URL }} + DEVICE_NAME: ${{ env.DEVICE_NAME }} + run: | + mkdir -p reports screenshots logs + + PYTEST_CMD="python -m pytest ${{ steps.pytest.outputs.pytest_args }}" + PYTEST_CMD="$PYTEST_CMD --env=lambdatest -v" + PYTEST_CMD="$PYTEST_CMD --html=reports/e2e-results.html --self-contained-html" + PYTEST_CMD="$PYTEST_CMD --junitxml=reports/junit-e2e.xml" + + if [ "${{ steps.vars.outputs.parallel_execution }}" = "true" ]; then + PYTEST_CMD="$PYTEST_CMD -n auto" + fi + + echo "๐Ÿงช Running: $PYTEST_CMD" + $PYTEST_CMD + + - name: Generate test summary + if: always() + run: | + echo "## ๐Ÿงช E2E Test Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Setting | Value |" >> $GITHUB_STEP_SUMMARY + echo "|---------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| **Target** | \`${{ steps.vars.outputs.target }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Device** | ${{ env.DEVICE_NAME }} |" >> $GITHUB_STEP_SUMMARY + echo "| **App** | \`${{ env.STATUS_APP_URL }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Parallel** | ${{ steps.vars.outputs.parallel_execution }} |" >> $GITHUB_STEP_SUMMARY + echo "| **Source Type** | ${{ steps.detect_source.outputs.source_type }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Add test results if available + if [ -f "test/e2e_appium/reports/junit-e2e.xml" ]; then + echo "โœ… **Test execution completed** - Check artifacts for detailed results" >> $GITHUB_STEP_SUMMARY + else + echo "โŒ **Test execution failed** - No results generated" >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-results-${{ github.run_number }}-${{ steps.vars.outputs.target }} + path: | + test/e2e_appium/reports/**/* + test/e2e_appium/screenshots/**/* + test/e2e_appium/logs/**/* + retention-days: 14 + if-no-files-found: warn + + - name: Test results summary + if: always() + run: | + echo "๐Ÿ“Š E2E Results (${{ steps.vars.outputs.target }} on ${{ env.DEVICE_NAME }})" + if [ -f "test/e2e_appium/reports/junit-e2e.xml" ]; then + echo "โœ… Test report generated: test/e2e_appium/reports/junit-e2e.xml" + else + echo "โš ๏ธ No test report found" + fi diff --git a/test/e2e_appium/.env.template b/test/e2e_appium/.env.template new file mode 100644 index 0000000000..db004d9bcb --- /dev/null +++ b/test/e2e_appium/.env.template @@ -0,0 +1,23 @@ +# Environment Configuration Template +# Copy this file to .env.local and configure for your setup + +# ===== LAMBDATEST CONFIGURATION ===== +# Required for cloud testing +LT_USERNAME=your_lambdatest_username +LT_ACCESS_KEY=your_lambdatest_access_key +STATUS_APP_URL=lt://APP123456789 + +# Optional build information +BUILD_NAME=E2E_Appium Tests +TEST_NAME=Automated Test Run +BUILD_NUMBER=dev + +# ===== LOCAL CONFIGURATION ===== +# Required for local testing +LOCAL_APP_PATH=/path/to/your/status-app.apk + +# ===== SHARED CONFIGURATION ===== +# Optional overrides (defaults provided in YAML configs) +DEVICE_NAME=StatusTest_API34 +PLATFORM_VERSION=14 +LOG_LEVEL=DEBUG diff --git a/test/e2e_appium/.gitignore b/test/e2e_appium/.gitignore new file mode 100644 index 0000000000..7190d7691a --- /dev/null +++ b/test/e2e_appium/.gitignore @@ -0,0 +1,115 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Personal AI/IDE preferences (keep team .cursorrules tracked) +.cursorrules.local +.cursorrules.personal +.cursor/ + +# Test artifacts and reports +report/ +reports/ +screenshots/ +logs/ +*.log +result.xml +junit.xml +test-results/ +test-reports/ +allure-results/ +allure-reports/ + +# Local configuration files (keep team configs tracked) +configs/environments/local_* +configs/environments/*_local.json +configs/environments/*_local.yaml +*.local.json +*.local.yaml + +# Credentials and sensitive data +*credentials* +*secrets* +*.key +*.pem +.env.local +.env.*.local + +# Test session artifacts +driver_logs/ +session_logs/ +video_recordings/ +performance_logs/ + +# Appium and WebDriver +node_modules/ +appium.log +driver.log +selenium-debug.log + +# LambdaTest artifacts +lambdatest_* +lt_* + +# Android +*.apk +*.aab +*.keystore + +# iOS +*.ipa +*.mobileprovision + +# OS +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.temp +*.backup +*.bak + +# Framework-specific temporary files +.pytest_cache/ +.cache/ +.coverage +htmlcov/ +.tox/ +.nox/ +config.sh +.specstory/ diff --git a/test/e2e_appium/README.md b/test/e2e_appium/README.md new file mode 100644 index 0000000000..2705460971 --- /dev/null +++ b/test/e2e_appium/README.md @@ -0,0 +1,173 @@ +# E2E Testing Framework + +Automated end-to-end testing for Status Desktop using Appium and LambdaTest. + +## Quick Start + +### 1. Setup +```bash +cd test/e2e_appium +pip install -r requirements.txt +``` + +### 2. Configure LambdaTest (GitHub Secrets) +Repository administrators need to set: +- `LT_USERNAME` - Your LambdaTest username +- `LT_ACCESS_KEY` - Your LambdaTest access key + +### 3. Run Tests via GitHub Actions +1. Build APK using `android-build.yml` workflow (architecture: `x86_64`) +2. Run tests using `e2e-appium-android.yml` workflow +3. Use artifact name: `Status-tablet-x86_64` + +## Test Selection + +**By markers:** +```bash +pytest -m smoke # Core functionality +pytest -m onboarding # User registration flow +pytest -m critical # Essential features +pytest -m performance # Performance validation tests +``` + +**Specific tests:** +```bash +# Run onboarding tests with fixture +pytest tests/test_onboarding_flow.py::TestOnboardingFlow::test_complete_onboarding_flow_with_fixture + +# (Legacy examples removed for v1) + +# Run tests that depend on onboarding +pytest tests/test_onboarding_dependent_features.py +``` + +## Local Testing + +```bash +# Setup local environment +python scripts/local_setup.py + +# Run tests with local APK +export LOCAL_APP_PATH="/path/to/Status-tablet.apk" +pytest -m onboarding --env=local -v +``` + +## GitHub Actions Workflows + +### Build APK Workflow +- **File**: `android-build.yml` +- **Architecture**: `x86_64` (required for LambdaTest) +- **Output**: `Status-tablet-x86_64` artifact + +### E2E Testing Workflow +- **File**: `e2e-appium-android.yml` +- **APK source**: GitHub artifact (default) +- **Test target**: `onboarding` (default) +- **Device**: Galaxy Tab S8 (default) + +## Workflow Input Options + +### APK Sources +- **GitHub artifacts**: `Status-tablet-x86_64` (recommended) +- **Direct URLs**: `https://example.com/app.apk` +- **LambdaTest IDs**: `lt://APP123456789` + +### Device Options +- **default**: Galaxy Tab S8 (Android 14) +- **pixel_tablet**: Google Pixel Tablet +- **galaxy_tab_a**: Samsung Galaxy Tab A + +### Test Targets +- **onboarding**: Complete user onboarding flow +- **smoke**: Quick critical functionality tests +- **critical**: Essential features that must pass + +## Onboarding Fixture + +The framework includes a reusable onboarding fixture that eliminates code duplication: + +```python +# Simple usage - fixture handles complete onboarding +def test_my_feature(self, onboarded_user): + user_data = onboarded_user['user_data'] + assert onboarded_user['success'] + # Test your feature here + +# Advanced usage with custom configuration +@pytest.mark.onboarding_config(custom_display_name="MyUser") +def test_with_custom_onboarding(self, onboarded_user): + assert onboarded_user['user_data']['display_name'] == "MyUser" +``` + +๐Ÿ“– **[Quick Guide: Using Onboarding Fixtures](docs/USING_ONBOARDING_FIXTURES.md)** +๐Ÿ“– **[Complete Onboarding Fixture Documentation](docs/ONBOARDING_FIXTURE.md)** + +## Environment Configuration + +### For LambdaTest (Cloud) +```bash +# Set in GitHub repository secrets +LT_USERNAME=your_username +LT_ACCESS_KEY=your_access_key +``` + +### For Local Testing +```bash +export LOCAL_APP_PATH="/path/to/Status-tablet.apk" +export CURRENT_TEST_ENVIRONMENT="local" +``` + +## Troubleshooting + +**APK Build Issues:** +- Verify x86_64 architecture selected in android-build.yml +- Check build workflow completed successfully + +**Test Execution Issues:** +- Verify LambdaTest credentials in repository secrets +- Check APK artifact name matches exactly: `Status-tablet-x86_64` +- Review workflow logs for detailed error messages + +**Local Testing Issues:** +- Ensure Appium server running: `appium` +- Verify Android emulator running with correct device name +- Check LOCAL_APP_PATH points to valid APK file + +## Framework Structure + +``` +test/e2e_appium/ +โ”œโ”€โ”€ tests/ # Test files +โ”œโ”€โ”€ pages/ # Page object models +โ”œโ”€โ”€ config/ # Configuration files +โ”œโ”€โ”€ scripts/ # Automation and setup scripts +โ”œโ”€โ”€ docs/ # Documentation +โ””โ”€โ”€ .github/actions/ # Reusable workflow actions +``` + +## Contributing + +- **[Contributing Guide](CONTRIBUTING.md)** - Complete guide for new contributors +- **[Framework Architecture](docs/FRAMEWORK_ARCHITECTURE.md)** - Technical design and patterns +- **[Code Guidelines](docs/CODE_GUIDELINES.md)** - Coding standards and best practices + +## Documentation + +### Getting Started +- **[Quick Start Guide](docs/QUICK_START.md)** - 5-minute setup +- **[Local Setup Guide](docs/LOCAL_SETUP.md)** - Detailed development setup +- **[Environment Management](docs/ENVIRONMENT_MANAGEMENT.md)** - Configuration system + +### Workflow & CI/CD +- **[Workflow Quick Reference](docs/WORKFLOW_QUICKREF.md)** - GitHub Actions reference +- **[GitHub Actions Guide](docs/github-actions.md)** - CI/CD workflows +- **[Test Reporting](docs/REPORTING_RESULTS.md)** - Understanding results + +### Framework Usage +- **[Using Onboarding Fixtures](docs/USING_ONBOARDING_FIXTURES.md)** - Fixture patterns +- **[Onboarding Fixture Documentation](docs/ONBOARDING_FIXTURE.md)** - Detailed fixture guide +- **[Logging Guide](docs/LOGGING.md)** - Logging system + +### Reference +- **[Framework Architecture](docs/FRAMEWORK_ARCHITECTURE.md)** - Technical architecture +- **[Code Guidelines](docs/CODE_GUIDELINES.md)** - Development standards \ No newline at end of file diff --git a/test/e2e_appium/__init__.py b/test/e2e_appium/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/e2e_appium/cli/env_manager.py b/test/e2e_appium/cli/env_manager.py new file mode 100755 index 0000000000..5cf8a683d8 --- /dev/null +++ b/test/e2e_appium/cli/env_manager.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 + +import sys +import argparse +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from core import EnvironmentSwitcher, ConfigurationError + + +def list_environments() -> None: + switcher = EnvironmentSwitcher() + environments = switcher.config_manager.list_available_environments() + + print("Available environments:") + for env in environments: + print(f" โ€ข {env}") + + +def validate_environment(environment: str) -> bool: + try: + switcher = EnvironmentSwitcher() + config = switcher.switch_to(environment) + print(f"โœ… Environment '{environment}' is valid") + + print("\nConfiguration Summary:") + print(f" Device: {config.device_name}") + print(f" Platform: {config.platform_name} {config.platform_version}") + print(f" App Source: {config.app_source['source_type']}") + print(f" Appium Server: {config.get_appium_server_url()}") + + return True + + except ConfigurationError as e: + print(f"โŒ Environment '{environment}' is invalid: {e}") + return False + + +def switch_environment(environment: str) -> bool: + try: + switcher = EnvironmentSwitcher() + config = switcher.switch_to(environment) + print(f"โœ… Switched to environment: {environment}") + print(f"Run: export CURRENT_TEST_ENVIRONMENT={environment}") + return True + + except ConfigurationError as e: + print(f"โŒ Failed to switch: {e}") + return False + + +def auto_detect() -> str: + switcher = EnvironmentSwitcher() + environment = switcher.auto_detect_environment() + print(f"๐Ÿ” Auto-detected environment: {environment}") + return environment + + +def main() -> None: + parser = argparse.ArgumentParser(description="Environment management CLI") + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + subparsers.add_parser("list", help="List available environments") + + validate_parser = subparsers.add_parser( + "validate", help="Validate environment configuration" + ) + validate_parser.add_argument("environment", help="Environment to validate") + + switch_parser = subparsers.add_parser("switch", help="Switch to environment") + switch_parser.add_argument("environment", help="Environment to switch to") + + subparsers.add_parser("auto-detect", help="Auto-detect best environment") + + validate_all_parser = subparsers.add_parser( + "validate-all", help="Validate all environments" + ) + + args = parser.parse_args() + + if args.command == "list": + list_environments() + elif args.command == "validate": + validate_environment(args.environment) + elif args.command == "switch": + switch_environment(args.environment) + elif args.command == "auto-detect": + auto_detect() + elif args.command == "validate-all": + switcher = EnvironmentSwitcher() + environments = switcher.config_manager.list_available_environments() + all_valid = True + for env in environments: + if not validate_environment(env): + all_valid = False + print() + + if all_valid: + print("โœ… All environments are valid") + else: + print("โŒ Some environments have issues") + sys.exit(1) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/test/e2e_appium/cli/validate_setup.py b/test/e2e_appium/cli/validate_setup.py new file mode 100644 index 0000000000..0cde23a467 --- /dev/null +++ b/test/e2e_appium/cli/validate_setup.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Validation script to check E2E framework setup +Verifies all components are in place before running workflows +""" + +import os +import sys + + +def check_file_exists(filepath, description): + """Check if a file exists and print status""" + if os.path.exists(filepath): + print(f"โœ… {description}: {filepath}") + return True + else: + print(f"โŒ {description}: {filepath} (missing)") + return False + + +def check_github_workflows(): + """Check if GitHub workflows are in place""" + print("\n๐Ÿ“‹ Checking GitHub Workflows...") + workflows_dir = ".github/workflows" + + required_workflows = [ + ("android-build.yml", "Android Build workflow"), + ("e2e-appium-android.yml", "E2E Appium Android workflow"), + ] + + all_present = True + for workflow_file, description in required_workflows: + filepath = os.path.join(workflows_dir, workflow_file) + if not check_file_exists(filepath, description): + all_present = False + + return all_present + + +def check_scripts(): + """Check if required scripts are in place""" + print("\n๐Ÿ Checking Scripts...") + scripts_dir = "test/e2e_appium/scripts" + + required_scripts = [ + ("upload_apk_to_lambdatest.py", "LambdaTest APK upload script"), + ("artifact_discovery.py", "Artifact discovery script"), + ("run_tests.py", "Test execution script"), + ("local_setup.py", "Local setup script"), + ] + + all_present = True + for script_file, description in required_scripts: + filepath = os.path.join(scripts_dir, script_file) + if not check_file_exists(filepath, description): + all_present = False + + return all_present + + +def check_test_framework(): + """Check if test framework components are in place""" + print("\n๐Ÿงช Checking Test Framework...") + test_dir = "test/e2e_appium" + + required_components = [ + ("conftest.py", "Pytest configuration"), + ("pytest.ini", "Pytest settings"), + ("requirements.txt", "Python dependencies"), + ("tests", "Test directory"), + ("pages", "Page objects directory"), + ("config", "Configuration directory"), + ] + + all_present = True + for component, description in required_components: + filepath = os.path.join(test_dir, component) + if not check_file_exists(filepath, description): + all_present = False + + return all_present + + +def check_documentation(): + """Check if documentation is in place""" + print("\n๐Ÿ“š Checking Documentation...") + docs_dir = "test/e2e_appium" + + required_docs = [ + ("README.md", "Main documentation"), + ("docs/github-actions.md", "GitHub Actions guide"), + ("docs/setup-secrets.md", "Secrets setup guide"), + ("env_template", "Environment template"), + ] + + all_present = True + for doc_file, description in required_docs: + filepath = os.path.join(docs_dir, doc_file) + if not check_file_exists(filepath, description): + all_present = False + + return all_present + + +def check_environment(): + """Check environment requirements""" + print("\n๐ŸŒ Checking Environment...") + + # Check Python version + python_version = sys.version_info + if python_version >= (3, 8): + print( + f"โœ… Python version: {python_version.major}.{python_version.minor}.{python_version.micro}" + ) + else: + print( + f"โŒ Python version: {python_version.major}.{python_version.minor}.{python_version.micro} (requires 3.8+)" + ) + return False + + # Check if we're in the right directory + if os.path.exists("test/e2e_appium"): + print("โœ… Current directory: status-desktop repository") + else: + print("โŒ Current directory: Not in status-desktop repository") + return False + + return True + + +def check_secrets_reminder(): + """Remind about GitHub secrets setup""" + print("\n๐Ÿ” GitHub Secrets Reminder...") + print("โš ๏ธ Remember to set up these GitHub repository secrets:") + print(" - LT_USERNAME (your LambdaTest username)") + print(" - LT_ACCESS_KEY (your LambdaTest access key)") + print(" ๐Ÿ“– See docs/setup-secrets.md for detailed instructions") + + +def main(): + """Main validation function""" + print("๐Ÿ” E2E Framework Setup Validation") + print("=" * 50) + + checks = [ + ("Environment", check_environment), + ("GitHub Workflows", check_github_workflows), + ("Scripts", check_scripts), + ("Test Framework", check_test_framework), + ("Documentation", check_documentation), + ] + + all_passed = True + for check_name, check_func in checks: + if not check_func(): + all_passed = False + + check_secrets_reminder() + + print("\n" + "=" * 50) + if all_passed: + print("๐ŸŽ‰ Setup validation PASSED!") + print("โœ… Framework is ready for testing") + print("\n๐Ÿ“‹ Next steps:") + print(" 1. Set up GitHub secrets (see docs/setup-secrets.md)") + print(" 2. Build an x86_64 APK using android-build.yml") + print(" 3. Run E2E tests using e2e-appium-android.yml") + else: + print("โŒ Setup validation FAILED!") + print("๐Ÿ”ง Please fix the missing components above") + return 1 + + return 0 + + +if __name__ == "__main__": + exit(main()) diff --git a/test/e2e_appium/cli/validate_test_patterns.py b/test/e2e_appium/cli/validate_test_patterns.py new file mode 100755 index 0000000000..6b69725ca4 --- /dev/null +++ b/test/e2e_appium/cli/validate_test_patterns.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +Test Pattern Validation Script + +Validates that cloud tests follow proper result reporting patterns: +- Use @lambdatest_reporting decorator, OR +- Call self.report_test_result() explicitly, OR +- Use CloudTestCase.run_test_with_reporting() + +Usage: + python scripts/validate_test_patterns.py + python scripts/validate_test_patterns.py --fix-warnings +""" + +import argparse +import ast +import sys +from pathlib import Path +from typing import List, Dict + + +class TestPatternValidator: + def __init__(self): + self.issues = [] + self.test_files = [] + + def validate_project(self, test_dir: Path) -> Dict[str, List[str]]: + """Validate all test files in the project.""" + results = {"compliant": [], "warnings": [], "errors": []} + + # Find all test files + for test_file in test_dir.rglob("test_*.py"): + if test_file.name == "__init__.py": + continue + + validation_result = self.validate_file(test_file) + + if validation_result["status"] == "compliant": + results["compliant"].append(str(test_file)) + elif validation_result["status"] == "warning": + results["warnings"].append( + f"{test_file}: {validation_result['message']}" + ) + else: + results["errors"].append(f"{test_file}: {validation_result['message']}") + + return results + + def validate_file(self, file_path: Path) -> Dict[str, str]: + """Validate a single test file.""" + try: + with open(file_path, "r") as f: + content = f.read() + + tree = ast.parse(content) + + # Find test classes + test_classes = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.ClassDef) and "Test" in node.name + ] + + if not test_classes: + return {"status": "compliant", "message": "No test classes found"} + + # Check for cloud test patterns + for test_class in test_classes: + result = self._validate_test_class(test_class, content) + if result["status"] != "compliant": + return result + + return { + "status": "compliant", + "message": "All tests follow proper patterns", + } + + except Exception as e: + return {"status": "error", "message": f"Failed to parse file: {e}"} + + def _validate_test_class( + self, test_class: ast.ClassDef, content: str + ) -> Dict[str, str]: + """Validate patterns in a test class.""" + # Check if it inherits from BaseTest (likely cloud test) + is_cloud_test = any( + isinstance(base, ast.Name) and base.id == "BaseTest" + for base in test_class.bases + ) + + if not is_cloud_test: + return {"status": "compliant", "message": "Not a cloud test class"} + + # Find test methods + test_methods = [ + node + for node in test_class.body + if isinstance(node, ast.FunctionDef) and node.name.startswith("test_") + ] + + for test_method in test_methods: + result = self._validate_test_method(test_method, content) + if result["status"] != "compliant": + return result + + return { + "status": "compliant", + "message": "All test methods properly configured", + } + + def _validate_test_method( + self, test_method: ast.FunctionDef, content: str + ) -> Dict[str, str]: + """Validate a single test method.""" + method_name = test_method.name + + # Check for @lambdatest_reporting decorator + has_decorator = any( + (isinstance(dec, ast.Name) and dec.id == "lambdatest_reporting") + or (isinstance(dec, ast.Attribute) and dec.attr == "lambdatest_reporting") + for dec in test_method.decorator_list + ) + + if has_decorator: + return { + "status": "compliant", + "message": f"{method_name} uses decorator pattern", + } + + # Check for explicit report_test_result calls + method_source = ast.get_source_segment(content, test_method) or "" + has_explicit_reporting = "report_test_result" in method_source + + if has_explicit_reporting: + return { + "status": "compliant", + "message": f"{method_name} uses explicit reporting", + } + + # Check for run_test_with_reporting usage + has_template_pattern = "run_test_with_reporting" in method_source + + if has_template_pattern: + return { + "status": "compliant", + "message": f"{method_name} uses template pattern", + } + + # No proper pattern found + return { + "status": "warning", + "message": f"{method_name} missing result reporting pattern. " + f"Add @lambdatest_reporting decorator or call self.report_test_result()", + } + + +def main(): + parser = argparse.ArgumentParser(description="Validate test patterns") + parser.add_argument( + "--test-dir", + default="test/e2e_appium/tests", + help="Directory to scan for test files", + ) + parser.add_argument( + "--fix-warnings", + action="store_true", + help="Show suggestions for fixing warnings", + ) + + args = parser.parse_args() + + test_dir = Path(args.test_dir) + if not test_dir.exists(): + print(f"โŒ Test directory not found: {test_dir}") + sys.exit(1) + + validator = TestPatternValidator() + results = validator.validate_project(test_dir) + + print("=" * 60) + print("๐Ÿ” TEST PATTERN VALIDATION RESULTS") + print("=" * 60) + + if results["compliant"]: + print(f"โœ… Compliant files ({len(results['compliant'])}):") + for file in results["compliant"]: + print(f" {file}") + print() + + if results["warnings"]: + print(f"โš ๏ธ Warnings ({len(results['warnings'])}):") + for warning in results["warnings"]: + print(f" {warning}") + print() + + if args.fix_warnings: + print("๐Ÿ’ก To fix warnings:") + print(" 1. Add @lambdatest_reporting decorator to test methods") + print(" 2. Or call self.report_test_result(passed=True/False) explicitly") + print(" 3. Or use CloudTestCase.run_test_with_reporting() pattern") + print() + + if results["errors"]: + print(f"โŒ Errors ({len(results['errors'])}):") + for error in results["errors"]: + print(f" {error}") + print() + + # Summary + total = ( + len(results["compliant"]) + len(results["warnings"]) + len(results["errors"]) + ) + if total > 0: + compliance_rate = len(results["compliant"]) / total * 100 + print( + f"๐Ÿ“Š Compliance Rate: {compliance_rate:.1f}% ({len(results['compliant'])}/{total})" + ) + + # Exit with error code if issues found + if results["warnings"] or results["errors"]: + print("\n๐Ÿ’ก Run with --fix-warnings for suggestions") + sys.exit(1) + else: + print("๐ŸŽ‰ All tests follow proper patterns!") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/test/e2e_appium/config/__init__.py b/test/e2e_appium/config/__init__.py new file mode 100644 index 0000000000..6cde60bdbe --- /dev/null +++ b/test/e2e_appium/config/__init__.py @@ -0,0 +1,24 @@ +""" +Configuration module for Status Desktop E2E Appium tests. +""" + +from .settings import get_config, TestConfig +from .logging_config import ( + setup_logging, + get_logger, + log_test_start, + log_test_end, + log_element_action, + log_session_info, +) + +__all__ = [ + "get_config", + "TestConfig", + "setup_logging", + "get_logger", + "log_test_start", + "log_test_end", + "log_element_action", + "log_session_info", +] diff --git a/test/e2e_appium/config/environments/base.yaml b/test/e2e_appium/config/environments/base.yaml new file mode 100644 index 0000000000..c4047ff22c --- /dev/null +++ b/test/e2e_appium/config/environments/base.yaml @@ -0,0 +1,32 @@ +metadata: + framework_version: "2.0.0" + supported_platforms: ["android", "ios"] + +device: + platform_name: "android" + platform_version: "14" + device_orientation: "landscape" + +timeouts: + default: 30 + element_wait: 30 + element_click: 10 + element_find: 15 + +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" + +test_execution: + retry_failed_tests: 1 + parallel_workers: 1 + test_timeout: 300 \ No newline at end of file diff --git a/test/e2e_appium/config/environments/lambdatest.yaml b/test/e2e_appium/config/environments/lambdatest.yaml new file mode 100644 index 0000000000..04b66e5d9c --- /dev/null +++ b/test/e2e_appium/config/environments/lambdatest.yaml @@ -0,0 +1,34 @@ +extends: "base.yaml" + +metadata: + environment: "lambdatest" + description: "LambdaTest cloud environment" + +device: + name: "Galaxy Tab S8" + +appium: + server_url: "https://mobile-hub.lambdatest.com/wd/hub" + +app: + source_type: "cloud_upload" + app_id_template: "${STATUS_APP_URL}" + +lambdatest: + build_name_template: "Status E2E Tests - ${BUILD_NUMBER:-${TIMESTAMP}}" + test_name_template: "${TEST_NAME:-Automated Test}" + project: "Status E2E_Appium" + idle_timeout: 600 + +capabilities: + "lt:options": + w3c: true + appiumVersion: "2.16.2" + devicelog: true + visual: true + video: true + isRealMobile: false + deviceOrientation: "landscape" + network: false + "appium:options": + automationName: "UiAutomator2" \ No newline at end of file diff --git a/test/e2e_appium/config/environments/local.yaml b/test/e2e_appium/config/environments/local.yaml new file mode 100644 index 0000000000..fbc55badbb --- /dev/null +++ b/test/e2e_appium/config/environments/local.yaml @@ -0,0 +1,35 @@ +extends: "base.yaml" + +metadata: + environment: "local" + description: "Local development environment" + +device: + name: "sdk_gphone64_arm64" + platform_version: "15" + +appium: + server_url: "http://localhost:4723" + +app: + source_type: "local_file" + path_template: "${LOCAL_APP_PATH}" + +timeouts: + default: 60 + element_wait: 45 + +logging: + level: "DEBUG" + enable_video_recording: false + +directories: + logs: "logs/local" + reports: "reports/local" + screenshots: "screenshots/local" + +capabilities: + platformName: "android" + automationName: "UiAutomator2" + newCommandTimeout: 300 + noReset: false \ No newline at end of file diff --git a/test/e2e_appium/config/logging_config.py b/test/e2e_appium/config/logging_config.py new file mode 100644 index 0000000000..26529b5b2e --- /dev/null +++ b/test/e2e_appium/config/logging_config.py @@ -0,0 +1,387 @@ +import os +import sys +import json +import logging +import logging.handlers +from datetime import datetime +from pathlib import Path +from typing import Dict, Any, Optional +from dataclasses import dataclass + + +@dataclass +class LoggingConfig: + # Logging levels and output + console_level: str = "INFO" + file_level: str = "DEBUG" + log_format: str = "structured" # 'simple', 'detailed', 'structured' + + # File logging settings + logs_dir: str = "logs" + max_file_size: int = 10 * 1024 * 1024 # 10MB + backup_count: int = 5 + + # Performance tracking + enable_performance_logging: bool = True + performance_threshold_ms: int = 1000 # Log slow operations + + # Features + enable_console_colors: bool = True + enable_json_logging: bool = True + log_sensitive_data: bool = False + + +class ColoredFormatter(logging.Formatter): + """Colored console formatter for better readability.""" + + COLORS = { + "DEBUG": "\033[36m", # Cyan + "INFO": "\033[32m", # Green + "WARNING": "\033[33m", # Yellow + "ERROR": "\033[31m", # Red + "CRITICAL": "\033[35m", # Magenta + "RESET": "\033[0m", # Reset + } + + def format(self, record): + if hasattr(record, "no_color") or not sys.stdout.isatty(): + return super().format(record) + + level_color = self.COLORS.get(record.levelname, self.COLORS["RESET"]) + record.levelname = f"{level_color}{record.levelname}{self.COLORS['RESET']}" + return super().format(record) + + +class StructuredFormatter(logging.Formatter): + """JSON structured formatter for machine-readable logs.""" + + def format(self, record): + log_entry = { + "timestamp": datetime.fromtimestamp(record.created).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + "module": record.module, + "function": record.funcName, + "line": record.lineno, + } + + # Add extra fields if present + if hasattr(record, "test_name"): + log_entry["test_name"] = record.test_name + if hasattr(record, "session_id"): + log_entry["session_id"] = record.session_id + if hasattr(record, "duration_ms"): + log_entry["duration_ms"] = record.duration_ms + if hasattr(record, "element_locator"): + log_entry["element_locator"] = record.element_locator + + return json.dumps(log_entry) + + +class PerformanceTracker: + """Track and log performance metrics with historical analysis.""" + + def __init__( + self, + logger: logging.Logger, + threshold_ms: int = 1000, + enable_analytics: bool = False, + ): + self.logger = logger + self.threshold_ms = threshold_ms + self.start_time = None + self.operation_name = None + + # Check environment variable override + env_analytics = os.getenv("E2E_ENABLE_PERFORMANCE_ANALYTICS", "").lower() + if env_analytics in ("true", "1", "yes", "on"): + enable_analytics = True + elif env_analytics in ("false", "0", "no", "off"): + enable_analytics = False + + self.enable_analytics = enable_analytics + + # Initialize analytics if enabled + if self.enable_analytics: + try: + from .performance_analytics import ( + PerformanceAnalytics, + PerformanceMetric, + ) + + self.analytics = PerformanceAnalytics() + self.PerformanceMetric = PerformanceMetric + except ImportError: + self.logger.warning( + "Performance analytics unavailable - running without historical analysis" + ) + self.enable_analytics = False + + def start(self, operation_name: str, **context): + """Start timing an operation.""" + self.operation_name = operation_name + self.start_time = datetime.now() + self.context = context + + self.logger.debug(f"๐Ÿš€ Started: {operation_name}", extra=context) + + def end(self, success: bool = True, **result_context): + """End timing and log results with historical analysis.""" + if not self.start_time: + return + + end_time = datetime.now() + duration = end_time - self.start_time + duration_ms = int(duration.total_seconds() * 1000) + + log_data = { + "duration_ms": duration_ms, + "operation": self.operation_name, + "success": success, + **self.context, + **result_context, + } + + # Historical analysis + if self.enable_analytics and hasattr(self, "analytics"): + try: + # Get comprehensive test context + test_name = result_context.get( + "test_name", self.context.get("test_name", "unknown_test") + ) + session_id = result_context.get( + "session_id", self.context.get("session_id", "unknown_session") + ) + + # Try to get comprehensive device info from config + device_info = "unknown" + device_type = "unknown" + platform = "unknown" + platform_version = "unknown" + + try: + from .settings import get_config + + config = get_config() + device_info = config.device_name + platform = config.platform_name.lower() + platform_version = config.platform_version + + # Determine device type based on device name + device_name_lower = config.device_name.lower() + if ( + "tablet" in device_name_lower + or "ipad" in device_name_lower + or "tab s8" in device_name_lower + or "galaxy tab" in device_name_lower + ): + device_type = "tablet" + elif ( + "desktop" in device_name_lower or "chrome" in device_name_lower + ): + device_type = "desktop" + else: + device_type = "phone" + + except Exception: + device_info = self.context.get("device", "unknown") + + metric = self.PerformanceMetric( + test_name=test_name, + operation_name=self.operation_name, + duration_ms=duration_ms, + success=success, + timestamp=end_time, + session_id=session_id, + environment=self.context.get("environment", "lambdatest"), + device=device_info, + device_type=device_type, + platform=platform, + platform_version=platform_version, + ) + + # This will log enhanced analytics + analysis = self.analytics.record_performance(metric) + + # Add analytics to log data + log_data.update( + { + "historical_analysis": { + "average_ms": analysis.average_duration_ms, + "delta_ms": analysis.performance_delta_ms, + "delta_percent": analysis.performance_delta_percent, + "percentile": analysis.percentile_ranking, + "trend": analysis.performance_trend, + "total_runs": analysis.total_runs, + } + } + ) + + except Exception as e: + self.logger.debug(f"Analytics error: {e}") + + # Log level based on performance and success + if not success: + level = logging.ERROR + emoji = "โŒ" + elif duration_ms > self.threshold_ms: + level = logging.WARNING + emoji = "โš ๏ธ" + else: + level = logging.INFO + emoji = "โœ…" + + self.logger.log( + level, f"{emoji} {self.operation_name}: {duration_ms}ms", extra=log_data + ) + + +def setup_logging(config: Optional[LoggingConfig] = None) -> Dict[str, Any]: + """ + Set up logging configuration. + + Returns: + Dict with logger instances and configuration info. + """ + if config is None: + config = LoggingConfig() + + # Create logs directory + logs_dir = Path(config.logs_dir) + logs_dir.mkdir(exist_ok=True) + + # Clear any existing handlers + root_logger = logging.getLogger() + for handler in root_logger.handlers[:]: + root_logger.removeHandler(handler) + + # Console handler with colors + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(getattr(logging, config.console_level.upper())) + + if config.enable_console_colors: + console_format = "%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s" + console_handler.setFormatter(ColoredFormatter(console_format)) + else: + console_format = "%(asctime)s | %(levelname)-8s | %(name)-20s | %(message)s" + console_handler.setFormatter(logging.Formatter(console_format)) + + # File handler with rotation + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + log_file = logs_dir / f"test_run_{timestamp}.log" + + file_handler = logging.handlers.RotatingFileHandler( + log_file, maxBytes=config.max_file_size, backupCount=config.backup_count + ) + file_handler.setLevel(getattr(logging, config.file_level.upper())) + + if config.log_format == "structured": + file_handler.setFormatter(StructuredFormatter()) + else: + file_format = "%(asctime)s | %(levelname)-8s | %(name)-25s | %(funcName)-15s:%(lineno)-3d | %(message)s" + file_handler.setFormatter(logging.Formatter(file_format)) + + # JSON handler for structured logging + json_handler = None + if config.enable_json_logging: + json_file = logs_dir / f"structured_{timestamp}.json" + json_handler = logging.FileHandler(json_file) + json_handler.setLevel(logging.DEBUG) + json_handler.setFormatter(StructuredFormatter()) + + # Configure root logger + root_logger.setLevel(logging.DEBUG) + root_logger.addHandler(console_handler) + root_logger.addHandler(file_handler) + if json_handler: + root_logger.addHandler(json_handler) + + # Create specialized loggers + loggers = { + "main": logging.getLogger("e2e_appium"), + "config": logging.getLogger("e2e_appium.config"), + "tests": logging.getLogger("e2e_appium.tests"), + "pages": logging.getLogger("e2e_appium.pages"), + "performance": logging.getLogger("e2e_appium.performance"), + "session": logging.getLogger("e2e_appium.session"), + } + + # Log startup information + main_logger = loggers["main"] + main_logger.info("=" * 80) + main_logger.info("๐Ÿš€ E2E Test Framework Starting") + main_logger.info("=" * 80) + main_logger.info(f"๐Ÿ“ Logs directory: {logs_dir.absolute()}") + main_logger.info(f"๐Ÿ“„ Main log file: {log_file.name}") + if json_handler: + main_logger.info(f"๐Ÿ“Š JSON log file: {json_file.name}") + main_logger.info(f"๐ŸŽš๏ธ Console level: {config.console_level}") + main_logger.info(f"๐ŸŽš๏ธ File level: {config.file_level}") + main_logger.info("=" * 80) + + return { + "loggers": loggers, + "config": config, + "log_file": str(log_file), + "json_file": str(json_file) if json_handler else None, + "performance_tracker": lambda: PerformanceTracker(loggers["performance"]), + } + + +def get_logger(name: str) -> logging.Logger: + """Get a logger with the specified name.""" + return logging.getLogger(f"e2e_appium.{name}") + + +def log_test_start(test_name: str, **context): + """Log test start with context.""" + logger = get_logger("tests") + logger.info( + f"๐Ÿงช Starting test: {test_name}", extra={"test_name": test_name, **context} + ) + + +def log_test_end(test_name: str, success: bool, duration_ms: int, **context): + """Log test completion with results.""" + logger = get_logger("tests") + emoji = "โœ…" if success else "โŒ" + status = "PASSED" if success else "FAILED" + + logger.info( + f"{emoji} Test {status}: {test_name} ({duration_ms}ms)", + extra={ + "test_name": test_name, + "success": success, + "duration_ms": duration_ms, + **context, + }, + ) + + +def log_element_action( + action: str, locator: str, success: bool = True, duration_ms: int = 0, **context +): + """Log element interaction with performance data.""" + logger = get_logger("pages") + emoji = "โœ…" if success else "โŒ" + + logger.info( + f"{emoji} {action}: {locator} ({duration_ms}ms)", + extra={ + "action": action, + "element_locator": locator, + "success": success, + "duration_ms": duration_ms, + **context, + }, + ) + + +def log_session_info(session_id: str, action: str, **context): + """Log session management information.""" + logger = get_logger("session") + logger.info( + f"๐Ÿ”„ Session {action}: {session_id}", + extra={"session_id": session_id, **context}, + ) diff --git a/test/e2e_appium/config/performance_analytics.py b/test/e2e_appium/config/performance_analytics.py new file mode 100644 index 0000000000..48ff675d68 --- /dev/null +++ b/test/e2e_appium/config/performance_analytics.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +""" +Tracks historical performance data and provides insights for test optimization +""" + +import sqlite3 +import statistics +from datetime import datetime, timedelta +from pathlib import Path +from typing import Dict, List, Optional +from dataclasses import dataclass + +from .logging_config import get_logger + + +@dataclass +class PerformanceMetric: + """Performance metric data structure.""" + + test_name: str + operation_name: str + duration_ms: int + success: bool + timestamp: datetime + session_id: str + environment: str = "unknown" + device: str = "unknown" + device_type: str = "unknown" # tablet, phone, desktop + platform: str = "unknown" # android, ios, windows + platform_version: str = "unknown" + + +@dataclass +class PerformanceAnalysis: + """Performance analysis results.""" + + test_name: str + operation_name: str + current_duration_ms: int + average_duration_ms: float + median_duration_ms: float + min_duration_ms: int + max_duration_ms: int + performance_trend: str # "improving", "degrading", "stable" + percentile_ranking: float # 0-100, where 100 is fastest + total_runs: int + success_rate: float + + @property + def is_above_average(self) -> bool: + """True if current run is slower than average.""" + return self.current_duration_ms > self.average_duration_ms + + @property + def performance_delta_ms(self) -> int: + """Difference from average in milliseconds.""" + return self.current_duration_ms - int(self.average_duration_ms) + + @property + def performance_delta_percent(self) -> float: + """Percentage difference from average.""" + if self.average_duration_ms == 0: + return 0.0 + return (self.performance_delta_ms / self.average_duration_ms) * 100 + + +class PerformanceAnalytics: + """Performance analytics system.""" + + def __init__(self, db_path: str = "logs/performance_analytics.db"): + self.db_path = Path(db_path) + self.logger = get_logger("performance.analytics") + self._init_database() + + def _init_database(self): + """Initialize the performance database.""" + self.db_path.parent.mkdir(exist_ok=True) + + with sqlite3.connect(self.db_path) as conn: + # Create table with all columns + conn.execute(""" + CREATE TABLE IF NOT EXISTS performance_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + test_name TEXT NOT NULL, + operation_name TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + success BOOLEAN NOT NULL, + timestamp TEXT NOT NULL, + session_id TEXT, + environment TEXT, + device TEXT, + device_type TEXT, + platform TEXT, + platform_version TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Check if we need to add missing columns (for existing databases) + cursor = conn.execute("PRAGMA table_info(performance_metrics)") + columns = [row[1] for row in cursor.fetchall()] + + missing_columns = [ + ("device_type", "TEXT"), + ("platform", "TEXT"), + ("platform_version", "TEXT"), + ] + + for column_name, column_type in missing_columns: + if column_name not in columns: + try: + conn.execute( + f"ALTER TABLE performance_metrics ADD COLUMN {column_name} {column_type}" + ) + self.logger.info(f"Added missing column: {column_name}") + except sqlite3.OperationalError as e: + self.logger.debug( + f"Column {column_name} may already exist: {e}" + ) + + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_test_operation + ON performance_metrics(test_name, operation_name) + """) + + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_timestamp + ON performance_metrics(timestamp) + """) + + self.logger.debug(f"Performance analytics database initialized: {self.db_path}") + + def record_performance(self, metric: PerformanceMetric) -> PerformanceAnalysis: + """ + Record a performance metric and return analysis. + + Returns: + PerformanceAnalysis with historical context + """ + # Store the metric + with sqlite3.connect(self.db_path) as conn: + conn.execute( + """ + INSERT INTO performance_metrics + (test_name, operation_name, duration_ms, success, timestamp, + session_id, environment, device, device_type, platform, platform_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + metric.test_name, + metric.operation_name, + metric.duration_ms, + metric.success, + metric.timestamp.isoformat(), + metric.session_id, + metric.environment, + metric.device, + metric.device_type, + metric.platform, + metric.platform_version, + ), + ) + + # Generate analysis + analysis = self._analyze_performance( + metric.test_name, metric.operation_name, metric.duration_ms + ) + + # Log insights + self._log_performance_insights(analysis) + + return analysis + + def _analyze_performance( + self, test_name: str, operation_name: str, current_duration_ms: int + ) -> PerformanceAnalysis: + """Analyze performance against historical data.""" + + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute( + """ + SELECT duration_ms, success FROM performance_metrics + WHERE test_name = ? AND operation_name = ? + ORDER BY timestamp DESC + LIMIT 100 + """, + (test_name, operation_name), + ) + + historical_data = cursor.fetchall() + + if not historical_data: + # First run + return PerformanceAnalysis( + test_name=test_name, + operation_name=operation_name, + current_duration_ms=current_duration_ms, + average_duration_ms=current_duration_ms, + median_duration_ms=current_duration_ms, + min_duration_ms=current_duration_ms, + max_duration_ms=current_duration_ms, + performance_trend="baseline", + percentile_ranking=100.0, + total_runs=1, + success_rate=100.0, + ) + + # Extract durations and success rates + durations = [row[0] for row in historical_data] + successes = [row[1] for row in historical_data] + + # Calculate statistics + avg_duration = statistics.mean(durations) + median_duration = statistics.median(durations) + min_duration = min(durations) + max_duration = max(durations) + + # Calculate percentile ranking (lower duration = higher percentile) + better_count = sum(1 for d in durations if current_duration_ms <= d) + percentile = (better_count / len(durations)) * 100 + + # Calculate trend (last 10 vs previous 10) + trend = self._calculate_trend(durations) + + # Success rate + success_rate = (sum(successes) / len(successes)) * 100 + + return PerformanceAnalysis( + test_name=test_name, + operation_name=operation_name, + current_duration_ms=current_duration_ms, + average_duration_ms=avg_duration, + median_duration_ms=median_duration, + min_duration_ms=min_duration, + max_duration_ms=max_duration, + performance_trend=trend, + percentile_ranking=percentile, + total_runs=len(durations) + 1, # +1 for current run + success_rate=success_rate, + ) + + def _calculate_trend(self, durations: List[int]) -> str: + """Calculate performance trend.""" + if len(durations) < 10: + return "insufficient_data" + + # Compare recent 5 runs vs previous 5 runs + recent_avg = statistics.mean(durations[:5]) + previous_avg = statistics.mean(durations[5:10]) + + diff_percent = ((recent_avg - previous_avg) / previous_avg) * 100 + + if diff_percent <= -5: + return "improving" + elif diff_percent >= 5: + return "degrading" + else: + return "stable" + + def _log_performance_insights(self, analysis: PerformanceAnalysis): + """Log performance insights with structured formatting.""" + + # Determine performance status + if analysis.total_runs == 1: + status_emoji = "๐Ÿ†•" + status = "BASELINE" + elif analysis.performance_delta_percent <= -10: + status_emoji = "๐Ÿš€" + status = "EXCELLENT" + elif analysis.performance_delta_percent <= 0: + status_emoji = "โœ…" + status = "GOOD" + elif analysis.performance_delta_percent <= 20: + status_emoji = "โš ๏ธ" + status = "SLOW" + else: + status_emoji = "๐ŸŒ" + status = "VERY_SLOW" + + # Log main performance result + self.logger.info( + f"{status_emoji} Performance {status}: {analysis.operation_name} = {analysis.current_duration_ms}ms", + extra={ + "performance_status": status, + "current_duration_ms": analysis.current_duration_ms, + "average_duration_ms": analysis.average_duration_ms, + "delta_ms": analysis.performance_delta_ms, + "delta_percent": analysis.performance_delta_percent, + "percentile_ranking": analysis.percentile_ranking, + "total_runs": analysis.total_runs, + "operation_name": analysis.operation_name, + }, + ) + + # Log detailed analytics + self.logger.debug( + f"๐Ÿ“Š Performance Analytics: avg={analysis.average_duration_ms:.0f}ms, " + f"median={analysis.median_duration_ms:.0f}ms, " + f"min={analysis.min_duration_ms}ms, max={analysis.max_duration_ms}ms, " + f"trend={analysis.performance_trend}, runs={analysis.total_runs}", + extra={ + "performance_analytics": { + "average_ms": analysis.average_duration_ms, + "median_ms": analysis.median_duration_ms, + "min_ms": analysis.min_duration_ms, + "max_ms": analysis.max_duration_ms, + "trend": analysis.performance_trend, + "success_rate": analysis.success_rate, + "percentile": analysis.percentile_ranking, + } + }, + ) + + def get_performance_report( + self, test_name: Optional[str] = None, days: int = 30 + ) -> Dict: + """Generate comprehensive performance report.""" + + since_date = datetime.now() - timedelta(days=days) + + with sqlite3.connect(self.db_path) as conn: + if test_name: + cursor = conn.execute( + """ + SELECT test_name, operation_name, + AVG(duration_ms) as avg_duration, + MIN(duration_ms) as min_duration, + MAX(duration_ms) as max_duration, + COUNT(*) as total_runs, + AVG(CASE WHEN success = 1 THEN 100.0 ELSE 0.0 END) as success_rate + FROM performance_metrics + WHERE test_name = ? AND timestamp >= ? + GROUP BY test_name, operation_name + ORDER BY avg_duration DESC + """, + (test_name, since_date.isoformat()), + ) + else: + cursor = conn.execute( + """ + SELECT test_name, operation_name, + AVG(duration_ms) as avg_duration, + MIN(duration_ms) as min_duration, + MAX(duration_ms) as max_duration, + COUNT(*) as total_runs, + AVG(CASE WHEN success = 1 THEN 100.0 ELSE 0.0 END) as success_rate + FROM performance_metrics + WHERE timestamp >= ? + GROUP BY test_name, operation_name + ORDER BY avg_duration DESC + """, + (since_date.isoformat(),), + ) + + results = cursor.fetchall() + + return { + "report_period_days": days, + "total_operations": len(results), + "operations": [ + { + "test_name": row[0], + "operation_name": row[1], + "avg_duration_ms": row[2], + "min_duration_ms": row[3], + "max_duration_ms": row[4], + "total_runs": row[5], + "success_rate": row[6], + } + for row in results + ], + } + + def cleanup_old_data(self, days_to_keep: int = 90): + """Clean up old performance data.""" + cutoff_date = datetime.now() - timedelta(days=days_to_keep) + + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute( + """ + DELETE FROM performance_metrics + WHERE timestamp < ? + """, + (cutoff_date.isoformat(),), + ) + + deleted_count = cursor.rowcount + + if deleted_count > 0: + self.logger.info(f"๐Ÿงน Cleaned up {deleted_count} old performance records") + + return deleted_count diff --git a/test/e2e_appium/config/schemas/environment.json b/test/e2e_appium/config/schemas/environment.json new file mode 100644 index 0000000000..541f9508fa --- /dev/null +++ b/test/e2e_appium/config/schemas/environment.json @@ -0,0 +1,62 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["metadata", "device", "app", "timeouts"], + "properties": { + "metadata": { + "type": "object", + "required": ["environment"], + "properties": { + "environment": { + "type": "string", + "enum": ["local", "lambdatest", "staging"] + }, + "description": { + "type": "string" + } + } + }, + "device": { + "type": "object", + "required": ["name", "platform_name"], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "platform_name": { + "type": "string", + "enum": ["android", "ios"] + }, + "platform_version": { + "type": "string" + } + } + }, + "app": { + "type": "object", + "required": ["source_type"], + "properties": { + "source_type": { + "type": "string", + "enum": ["local_file", "cloud_upload", "url"] + } + } + }, + "timeouts": { + "type": "object", + "properties": { + "default": { + "type": "integer", + "minimum": 5, + "maximum": 300 + }, + "element_wait": { + "type": "integer", + "minimum": 5, + "maximum": 300 + } + } + } + } +} \ No newline at end of file diff --git a/test/e2e_appium/config/settings.py b/test/e2e_appium/config/settings.py new file mode 100644 index 0000000000..c3dbfac0fa --- /dev/null +++ b/test/e2e_appium/config/settings.py @@ -0,0 +1,269 @@ +import os +import logging +from dataclasses import dataclass, field +from typing import Dict, Any + + +@dataclass +class TestConfig: + lt_username: str = field(default_factory=lambda: os.getenv("LT_USERNAME", "")) + lt_access_key: str = field(default_factory=lambda: os.getenv("LT_ACCESS_KEY", "")) + + device_name: str = field( + default_factory=lambda: os.getenv("DEVICE_NAME", "Galaxy Tab S8") + ) + platform_name: str = field( + default_factory=lambda: os.getenv("PLATFORM_NAME", "android") + ) + platform_version: str = field( + default_factory=lambda: os.getenv("PLATFORM_VERSION", "14") + ) + device_orientation: str = field( + default_factory=lambda: os.getenv("DEVICE_ORIENTATION", "landscape") + ) + + default_timeout: int = field( + default_factory=lambda: int(os.getenv("DEFAULT_TIMEOUT", "30")) + ) + element_wait_timeout: int = field( + default_factory=lambda: int(os.getenv("ELEMENT_WAIT_TIMEOUT", "30")) + ) + element_click_timeout: int = field( + default_factory=lambda: int(os.getenv("ELEMENT_CLICK_TIMEOUT", "10")) + ) + element_find_timeout: int = field( + default_factory=lambda: int(os.getenv("ELEMENT_FIND_TIMEOUT", "15")) + ) + + status_app_url: str = field( + default_factory=lambda: os.getenv("STATUS_APP_URL", "lt://") + ) + + lt_hub_url: str = "https://mobile-hub.lambdatest.com/wd/hub" + build_name: str = field( + default_factory=lambda: os.getenv("BUILD_NAME", "E2E_Appium Tests") + ) + test_name: str = field( + default_factory=lambda: os.getenv("TEST_NAME", "Automated Test Run") + ) + idle_timeout: int = 600 + + log_level: str = field(default_factory=lambda: os.getenv("LOG_LEVEL", "INFO")) + enable_screenshots: bool = field( + default_factory=lambda: os.getenv("ENABLE_SCREENSHOTS", "true").lower() + == "true" + ) + enable_video_recording: bool = field( + default_factory=lambda: os.getenv("ENABLE_VIDEO_RECORDING", "true").lower() + == "true" + ) + enable_network_logs: bool = True + enable_device_logs: bool = True + + screenshots_dir: str = field( + default_factory=lambda: os.getenv("SCREENSHOTS_DIR", "screenshots") + ) + logs_dir: str = field(default_factory=lambda: os.getenv("LOGS_DIR", "logs")) + reports_dir: str = field( + default_factory=lambda: os.getenv("REPORTS_DIR", "reports") + ) + + enable_xml_report: bool = field( + default_factory=lambda: os.getenv("ENABLE_XML_REPORT", "true").lower() == "true" + ) + enable_html_report: bool = field( + default_factory=lambda: os.getenv("ENABLE_HTML_REPORT", "true").lower() + == "true" + ) + enable_junit_report: bool = field( + default_factory=lambda: os.getenv("ENABLE_JUNIT_REPORT", "true").lower() + == "true" + ) + + enable_performance_analytics: bool = field( + default_factory=lambda: os.getenv( + "E2E_ENABLE_PERFORMANCE_ANALYTICS", "false" + ).lower() + in ("true", "1", "yes", "on") + ) + performance_report_days: int = field( + default_factory=lambda: int(os.getenv("E2E_PERFORMANCE_REPORT_DAYS", "7")) + ) + + build_number: str = field(default_factory=lambda: os.getenv("BUILD_NUMBER", "")) + build_url: str = field(default_factory=lambda: os.getenv("BUILD_URL", "")) + git_commit: str = field(default_factory=lambda: os.getenv("GIT_COMMIT", "")) + git_branch: str = field(default_factory=lambda: os.getenv("GIT_BRANCH", "")) + + local_appium_server: str = field( + default_factory=lambda: os.getenv( + "LOCAL_APPIUM_SERVER", "http://localhost:4723" + ) + ) + local_app_path: str = field(default_factory=lambda: os.getenv("LOCAL_APP_PATH", "")) + + def __post_init__(self): + self._validate_required_fields() + self._validate_timeouts() + self._validate_urls() + self._create_directories() + + def _validate_required_fields(self): + errors = [] + warnings = [] + + test_environment = os.getenv("TEST_ENVIRONMENT", "local") + + if test_environment in ["lambdatest", "lt"]: + if not self.lt_username: + errors.append( + "LT_USERNAME environment variable is required for LambdaTest execution" + ) + + if not self.lt_access_key: + errors.append( + "LT_ACCESS_KEY environment variable is required for LambdaTest execution" + ) + + if not self.status_app_url or self.status_app_url == "lt://": + errors.append("STATUS_APP_URL must be provided (LambdaTest app ID)") + else: + if not self.lt_username or not self.lt_access_key: + warnings.append( + "LambdaTest credentials not set (OK for local development)" + ) + + if warnings: + logger = logging.getLogger(__name__) + for warning in warnings: + logger.warning(f"โš ๏ธ {warning}") + + if errors: + error_msg = "Configuration validation failed:\n" + "\n".join( + f" โ€ข {error}" for error in errors + ) + error_msg += "\n\nPlease set the required environment variables. See env_variables.example for guidance." + raise ValueError(error_msg) + + def _validate_timeouts(self): + timeouts = { + "default_timeout": self.default_timeout, + "element_wait_timeout": self.element_wait_timeout, + "element_click_timeout": self.element_click_timeout, + "element_find_timeout": self.element_find_timeout, + } + + for name, value in timeouts.items(): + if value < 5: + raise ValueError(f"{name} must be at least 5 seconds, got {value}") + if value > 300: + raise ValueError(f"{name} should not exceed 300 seconds, got {value}") + + def _validate_urls(self): + if self.status_app_url and not ( + self.status_app_url.startswith("lt://") + or self.status_app_url.startswith("http") + ): + raise ValueError( + "STATUS_APP_URL must be a LambdaTest app ID (lt://) or valid URL" + ) + + def _create_directories(self): + for directory in [self.screenshots_dir, self.logs_dir, self.reports_dir]: + if directory: + os.makedirs(directory, exist_ok=True) + + def get_lambdatest_capabilities(self) -> Dict[str, Any]: + build_name = self.build_name + if self.build_number: + build_name += f" - Build {self.build_number}" + + test_name = self.test_name + if self.git_branch: + test_name += f" ({self.git_branch})" + + return { + "lt:options": { + "w3c": True, + "platformName": self.platform_name, + "deviceName": self.device_name, + "appiumVersion": "2.1.3", + "platformVersion": self.platform_version, + "app": self.status_app_url, + "devicelog": self.enable_device_logs, + "visual": self.enable_screenshots, + "video": self.enable_video_recording, + "build": build_name, + "name": test_name, + "project": "Status E2E_Appium", + "deviceOrientation": self.device_orientation, + "idleTimeout": self.idle_timeout, + "isRealMobile": False, + }, + "appium:options": {"automationName": "UiAutomator2"}, + } + + def get_local_capabilities(self) -> Dict[str, Any]: + if not self.local_app_path: + raise ValueError("local_app_path is required for local testing") + + return { + "platformName": self.platform_name, + "deviceName": self.device_name, + "platformVersion": self.platform_version, + "app": self.local_app_path, + "automationName": "UiAutomator2", + } + + def get_build_info(self) -> Dict[str, str]: + return { + "build_name": self.build_name, + "test_name": self.test_name, + "build_number": self.build_number, + "build_url": self.build_url, + "git_commit": self.git_commit, + "git_branch": self.git_branch, + } + + def summary(self) -> Dict[str, Any]: + return { + "device": f"{self.device_name} ({self.platform_name} {self.platform_version})", + "app_url": self.status_app_url, + "lt_username": self.lt_username, + "lt_access_key": "***" + self.lt_access_key[-4:] + if self.lt_access_key + else "NOT SET", + "hub_url": self.lt_hub_url + " (using secure ClientConfig auth)", + "timeouts": { + "default": self.default_timeout, + "element_wait": self.element_wait_timeout, + "click": self.element_click_timeout, + "find": self.element_find_timeout, + }, + "build_info": self.get_build_info(), + "logging": { + "level": self.log_level, + "screenshots": self.enable_screenshots, + "video": self.enable_video_recording, + }, + "performance_analytics": { + "enabled": self.enable_performance_analytics, + "report_days": self.performance_report_days, + }, + } + + +_config_instance = None + + +def get_config() -> TestConfig: + global _config_instance + if _config_instance is None: + _config_instance = TestConfig() + return _config_instance + + +def reload_config() -> TestConfig: + global _config_instance + _config_instance = None + return get_config() diff --git a/test/e2e_appium/conftest.py b/test/e2e_appium/conftest.py new file mode 100644 index 0000000000..3fc045f277 --- /dev/null +++ b/test/e2e_appium/conftest.py @@ -0,0 +1,244 @@ +import os +import pytest +from datetime import datetime +from pathlib import Path + +from .config import setup_logging, log_test_start, log_test_end +from .config.logging_config import get_logger +from .core import EnvironmentSwitcher +from .utils.lambdatest_reporter import LambdaTestReporter + + +# Expose fixture modules without star imports +pytest_plugins = [ + "fixtures.onboarding_fixture", +] + + +_logging_setup = None + + +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: + normalized_env = ( + "lambdatest" if cli_env in ("lt", "lambdatest") else "local" + ) + os.environ["CURRENT_TEST_ENVIRONMENT"] = normalized_env + except Exception: + # Do not block test runs if normalization fails + pass + + # Use YAML-based configuration + env_name = os.getenv("CURRENT_TEST_ENVIRONMENT", "lambdatest") + + try: + switcher = EnvironmentSwitcher() + env_config = switcher.switch_to(env_name) + + # Use directories from YAML config + reports_dir = Path(env_config.directories.get("reports", "reports")) + enable_xml_report = env_config.logging_config.get("enable_xml_report", True) + enable_html_report = env_config.logging_config.get("enable_html_report", True) + + logger = get_logger("conftest") + logger.info(f"๐Ÿ“ Using reports directory from {env_name} config: {reports_dir}") + + except Exception as e: + # Simplified fallback using defaults + reports_dir = Path("reports") + enable_xml_report = True + enable_html_report = True + + logger = get_logger("conftest") + logger.warning(f"โš ๏ธ Using default configuration: {e}") + logger.warning("๐Ÿ’ก Ensure YAML config files are properly set up") + + reports_dir.mkdir(exist_ok=True) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + if not hasattr(config.option, "xmlpath") or not config.option.xmlpath: + if enable_xml_report: + xml_report = reports_dir / f"pytest_results_{timestamp}.xml" + config.option.xmlpath = str(xml_report) + + if not hasattr(config.option, "htmlpath") or not config.option.htmlpath: + if enable_html_report: + html_report = reports_dir / f"pytest_report_{timestamp}.html" + config.option.htmlpath = str(html_report) + config.option.self_contained_html = True + + logger = _logging_setup["loggers"]["main"] if _logging_setup else None + if logger: + logger.info("๐Ÿ“Š Automatic report generation enabled:") + if hasattr(config.option, "xmlpath") and config.option.xmlpath: + logger.info(f" ๐Ÿ“„ XML Report: {config.option.xmlpath}") + if hasattr(config.option, "htmlpath") and config.option.htmlpath: + logger.info(f" ๐ŸŒ HTML Report: {config.option.htmlpath}") + + +def pytest_addoption(parser): + parser.addoption( + "--env", + action="store", + default="lt", + help="Test environment: local or lt (LambdaTest)", + ) + + +@pytest.fixture(scope="session") +def test_environment(request): + return request.config.getoption("--env") + + +@pytest.fixture(scope="function") +def performance_tracker(request): + if not _logging_setup: + return None + + tracker = _logging_setup["performance_tracker"]() + + if hasattr(tracker, "context"): + tracker.context = tracker.context or {} + tracker.context.update( + { + "test_name": request.node.name, + "test_module": request.node.module.__name__ + if request.node.module + else "unknown", + } + ) + + return tracker + + +def pytest_runtest_setup(item): + test_name = item.name + test_file = item.location[0] if item.location else "unknown" + + log_test_start( + test_name, + test_file=test_file, + markers=[mark.name for mark in item.iter_markers()], + ) + + +def pytest_runtest_teardown(item, nextitem): + test_name = item.name + + # Determine test success from the test result + success = True + duration_ms = 0 + + if hasattr(item, "rep_call"): + success = item.rep_call.passed + + if hasattr(item, "_test_start_time"): + duration = datetime.now() - item._test_start_time + duration_ms = int(duration.total_seconds() * 1000) + + log_test_end(test_name, success, duration_ms) + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + outcome = yield + rep = outcome.get_result() + + setattr(item, "rep_" + rep.when, rep) + + # Report setup/call/teardown so setup failures are reflected in LT + if rep.when in ("setup", "call", "teardown"): + try: + LambdaTestReporter.report_test_result(item, rep) + except Exception as e: + logger = get_logger("session") + logger.error(f"Failed to report test result to LambdaTest: {e}") + +def pytest_terminal_summary(terminalreporter, exitstatus, config): + if not _logging_setup: + return + + logger = _logging_setup["loggers"]["main"] + + passed = len(terminalreporter.stats.get("passed", [])) + failed = len(terminalreporter.stats.get("failed", [])) + skipped = len(terminalreporter.stats.get("skipped", [])) + errors = len(terminalreporter.stats.get("error", [])) + + total = passed + failed + skipped + errors + + logger.info("=" * 60) + logger.info("๐ŸŽฏ TEST EXECUTION SUMMARY") + logger.info("=" * 60) + logger.info(f"Total Tests: {total}") + logger.info(f"โœ… Passed: {passed}") + logger.info(f"โŒ Failed: {failed}") + logger.info(f"โญ๏ธ Skipped: {skipped}") + logger.info(f"๐Ÿ’ฅ Errors: {errors}") + + if total > 0: + success_rate = (passed / total) * 100 + logger.info(f"๐Ÿ“Š Success Rate: {success_rate:.1f}%") + + logger.info("Reports Generated:") + if hasattr(config.option, "xmlpath") and config.option.xmlpath: + xml_file = Path(config.option.xmlpath) + if xml_file.exists(): + logger.info(f" ๐Ÿ“„ XML Report: {xml_file}") + else: + logger.warning(f" โš ๏ธ XML Report expected but not found: {xml_file}") + + if hasattr(config.option, "htmlpath") and config.option.htmlpath: + html_file = Path(config.option.htmlpath) + if html_file.exists(): + logger.info(f" ๐ŸŒ HTML Report: {html_file}") + else: + logger.warning(f" โš ๏ธ HTML Report expected but not found: {html_file}") + + logger.info("=" * 60) + + if failed > 0: + logger.warning(f"โš ๏ธ {failed} test(s) failed. Check reports for details.") + + failed_tests = terminalreporter.stats.get("failed", []) + for test_report in failed_tests[:5]: + test_name = test_report.nodeid.split("::")[-1] + if hasattr(test_report, "longrepr") and test_report.longrepr: + error_msg = ( + str(test_report.longrepr).split("\n")[-2] + if test_report.longrepr + else "Unknown error" + ) + logger.error(f" โŒ {test_name}: {error_msg}") + + if len(failed_tests) > 5: + logger.error(f" ... and {len(failed_tests) - 5} more failures") + + if errors > 0: + logger.error(f"๐Ÿ’ฅ {errors} test(s) had errors. Check reports for details.") + + error_tests = terminalreporter.stats.get("error", []) + for test_report in error_tests[:3]: + test_name = test_report.nodeid.split("::")[-1] + if hasattr(test_report, "longrepr") and test_report.longrepr: + error_msg = ( + str(test_report.longrepr).split("\n")[-2] + if test_report.longrepr + else "Unknown error" + ) + logger.error(f" ๐Ÿ’ฅ {test_name}: {error_msg}") + + if len(error_tests) > 3: + logger.error(f" ... and {len(error_tests) - 3} more errors") + + if passed == total and total > 0: + logger.info("๐ŸŽ‰ All tests passed successfully!") + + logger.info("=" * 60) diff --git a/test/e2e_appium/core/__init__.py b/test/e2e_appium/core/__init__.py new file mode 100644 index 0000000000..3601f1e882 --- /dev/null +++ b/test/e2e_appium/core/__init__.py @@ -0,0 +1,11 @@ +from .config_manager import ConfigurationManager, EnvironmentSwitcher +from .environment import EnvironmentConfig, ConfigurationError +from .session_manager import SessionManager + +__all__ = [ + "ConfigurationManager", + "EnvironmentSwitcher", + "EnvironmentConfig", + "ConfigurationError", + "SessionManager", +] diff --git a/test/e2e_appium/core/config_manager.py b/test/e2e_appium/core/config_manager.py new file mode 100644 index 0000000000..8225dc18a7 --- /dev/null +++ b/test/e2e_appium/core/config_manager.py @@ -0,0 +1,121 @@ +import yaml +import json +import os +from pathlib import Path +from typing import Dict, Any, List +from .environment import EnvironmentConfig, ConfigurationError + + +class ConfigurationManager: + def __init__(self, config_dir: Path = None) -> None: + self.config_dir = config_dir or Path(__file__).parent.parent / "config" + self.environments_dir = self.config_dir / "environments" + self.schemas_dir = self.config_dir / "schemas" + + def load_environment(self, environment: str) -> EnvironmentConfig: + base_config = self._load_yaml(self.environments_dir / "base.yaml") + + env_file = self.environments_dir / f"{environment}.yaml" + if not env_file.exists(): + raise ConfigurationError(f"Environment '{environment}' not found") + + env_config = self._load_yaml(env_file) + merged_config = self._deep_merge(base_config, env_config) + + self._validate_schema(merged_config) + config = self._create_config_object(merged_config) + config.validate() + + return config + + def list_available_environments(self) -> List[str]: + env_files = self.environments_dir.glob("*.yaml") + return [f.stem for f in env_files if f.stem != "base"] + + def _load_yaml(self, file_path: Path) -> Dict[str, Any]: + try: + with open(file_path, "r") as f: + return yaml.safe_load(f) + except yaml.YAMLError as e: + raise ConfigurationError(f"Invalid YAML in {file_path}: {e}") + + def _deep_merge(self, base: Dict, override: Dict) -> Dict: + result = base.copy() + + for key, value in override.items(): + if key == "extends": + continue + + if ( + key in result + and isinstance(result[key], dict) + and isinstance(value, dict) + ): + result[key] = self._deep_merge(result[key], value) + else: + result[key] = value + + return result + + def _validate_schema(self, config: Dict[str, Any]) -> None: + schema_file = self.schemas_dir / "environment.json" + if not schema_file.exists(): + return + + try: + import jsonschema + + with open(schema_file, "r") as f: + schema = json.load(f) + jsonschema.validate(config, schema) + except ImportError: + pass + except (jsonschema.ValidationError, jsonschema.SchemaError) as e: + raise ConfigurationError(f"Configuration validation failed: {e}") + + def _create_config_object(self, config: Dict[str, Any]) -> EnvironmentConfig: + return EnvironmentConfig( + environment=config["metadata"]["environment"], + device_name=config["device"]["name"], + platform_name=config["device"]["platform_name"], + platform_version=config["device"]["platform_version"], + app_source=config["app"], + appium_config=config["appium"], + capabilities=config["capabilities"], + timeouts=config["timeouts"], + directories=config["directories"], + logging_config=config["logging"], + lambdatest_config=config.get("lambdatest", {}), + ) + + +class EnvironmentSwitcher: + def __init__(self) -> None: + self.config_manager = ConfigurationManager() + + def switch_to(self, environment: str) -> EnvironmentConfig: + available = self.config_manager.list_available_environments() + if environment not in available: + raise ConfigurationError( + f"Environment '{environment}' not found. " + f"Available: {', '.join(available)}" + ) + + config = self.config_manager.load_environment(environment) + os.environ["CURRENT_TEST_ENVIRONMENT"] = environment + + return config + + def auto_detect_environment(self) -> str: + if os.getenv("LT_USERNAME") and os.getenv("LT_ACCESS_KEY"): + return "lambdatest" + + try: + import requests + + requests.get("http://localhost:4723/status", timeout=2) + return "local" + except Exception: + pass + + return "local" diff --git a/test/e2e_appium/core/environment.py b/test/e2e_appium/core/environment.py new file mode 100644 index 0000000000..ccac707504 --- /dev/null +++ b/test/e2e_appium/core/environment.py @@ -0,0 +1,133 @@ +import os +import re +from dataclasses import dataclass +from typing import Dict, Any +from pathlib import Path + + +class ConfigurationError(Exception): + pass + + +@dataclass +class EnvironmentConfig: + environment: str + device_name: str + platform_name: str + platform_version: str + app_source: Dict[str, Any] + appium_config: Dict[str, Any] + capabilities: Dict[str, Any] + timeouts: Dict[str, int] + directories: Dict[str, str] + logging_config: Dict[str, Any] + lambdatest_config: Dict[str, Any] = None + + def validate(self) -> None: + if self.environment == "local": + self._validate_local_config() + elif self.environment == "lambdatest": + self._validate_lambdatest_config() + + 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}") + + try: + import requests + + server_url = self.appium_config.get("server_url", "http://localhost:4723") + response = requests.get(f"{server_url}/status", timeout=5) + if response.status_code != 200: + raise ConfigurationError("Appium server not responding correctly") + except requests.RequestException: + raise ConfigurationError("Cannot connect to Appium server") + + def _validate_lambdatest_config(self): + required_vars = ["LT_USERNAME", "LT_ACCESS_KEY"] + missing = [var for var in required_vars if not os.getenv(var)] + + if missing: + raise ConfigurationError(f"Missing LambdaTest variables: {missing}") + + app_id = self.app_source.get("app_id_template", "") + resolved_app_id = self._resolve_template(app_id) + if not resolved_app_id or resolved_app_id == "lt://": + raise ConfigurationError("STATUS_APP_URL must be provided for LambdaTest") + + def _resolve_template(self, template: str) -> str: + if not template: + return "" + + def replace_var(match): + var_name = match.group(1) + default_part = ( + match.group(2) if len(match.groups()) > 1 and match.group(2) else "" + ) + + # Handle nested variable resolution in defaults + if default_part.startswith("${") and default_part.endswith("}"): + default = self._resolve_template(default_part) + else: + default = default_part + + return os.getenv(var_name, default) + + # Handle ${VAR:-default} syntax - need to be careful with nested braces + result = template + while "${" in result: + # Find variable patterns, handling nested braces properly + pattern = r"\$\{([^}:-]+)(?::-([^${}]*(?:\$\{[^}]*\}[^${}]*)*))?\}" + new_result = re.sub(pattern, replace_var, result) + if new_result == result: + # No more substitutions possible, break to avoid infinite loop + break + result = new_result + + return result + + def get_resolved_app_path(self) -> str: + if self.app_source["source_type"] == "local_file": + return self._resolve_template(self.app_source["path_template"]) + elif self.app_source["source_type"] == "cloud_upload": + return self._resolve_template(self.app_source["app_id_template"]) + return "" + + def get_appium_server_url(self) -> str: + return self.appium_config["server_url"] + + def get_device_capabilities(self) -> Dict[str, Any]: + if self.environment == "lambdatest": + # For LambdaTest, structure capabilities according to their expected format + base_caps = {} + + # Start with any existing capabilities from YAML + base_caps.update(self.capabilities) + + # Ensure lt:options exists and add device-specific capabilities + lt_options = base_caps.setdefault("lt:options", {}) + lt_options.update( + { + "platformName": self.platform_name, + "platformVersion": self.platform_version, + "deviceName": self.device_name, + } + ) + + return base_caps + else: + # For local and other environments, use traditional structure + base_caps = { + "platformName": self.platform_name, + "platformVersion": self.platform_version, + "deviceName": self.device_name, + } + + if self.environment == "local": + base_caps["app"] = self.get_resolved_app_path() + + base_caps.update(self.capabilities) + return base_caps diff --git a/test/e2e_appium/core/session_manager.py b/test/e2e_appium/core/session_manager.py new file mode 100644 index 0000000000..62a8658042 --- /dev/null +++ b/test/e2e_appium/core/session_manager.py @@ -0,0 +1,185 @@ +import os +from datetime import datetime +from appium import webdriver +from appium.options.common import AppiumOptions +from appium.webdriver.appium_connection import AppiumConnection +from selenium.webdriver.remote.client_config import ClientConfig + +try: + from config import get_config, TestConfig, get_logger, log_session_info + from core import EnvironmentSwitcher, ConfigurationError +except ImportError: + from config import get_logger, log_session_info + from core import EnvironmentSwitcher, ConfigurationError + + +class SessionManager: + """Manages Appium driver sessions and environment configuration""" + + def __init__(self, environment="lambdatest"): + self.environment = environment + self.driver = None + self.logger = get_logger("session") + + # Load YAML-based configuration (simplified) + try: + switcher = EnvironmentSwitcher() + self.env_config = switcher.switch_to(environment) + + self.logger.info(f"โœ… Configuration loaded for {environment}") + self.logger.info( + f" Device: {self.env_config.device_name} ({self.env_config.platform_name} {self.env_config.platform_version})" + ) + self.logger.info(f" App: {self.env_config.get_resolved_app_path()}") + + # Log timeout configuration + timeouts = self.env_config.timeouts + self.logger.info( + f" Timeouts: default={timeouts.get('default')}s, wait={timeouts.get('element_wait')}s" + ) + + 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 + + def _get_lambdatest_naming(self) -> dict: + """Generate LambdaTest build and test names from YAML config.""" + if not self.env_config or not self.env_config.lambdatest_config: + # Use sensible defaults if config missing + timestamp = datetime.now().strftime("%Y%m%d_%H%M") + return { + "build": f"Status E2E Tests - {timestamp}", + "name": "Automated Test", + "project": "Status E2E_Appium", + } + + lt_config = self.env_config.lambdatest_config + + # Get templates with defaults + build_template = lt_config.get( + "build_name_template", "Status E2E Tests - ${BUILD_NUMBER:-${TIMESTAMP}}" + ) + test_template = lt_config.get( + "test_name_template", "${TEST_NAME:-Automated Test}" + ) + project_name = lt_config.get("project", "Status E2E_Appium") + + # Add timestamp as fallback for build number + timestamp = datetime.now().strftime("%Y%m%d_%H%M") + os.environ.setdefault("TIMESTAMP", timestamp) + + # Resolve templates + build_name = self.env_config._resolve_template(build_template) + test_name = self.env_config._resolve_template(test_template) + + # Add branch info if available + git_branch = os.getenv("GIT_BRANCH") + if git_branch and git_branch not in test_name: + test_name += f" ({git_branch})" + + return {"build": build_name, "name": test_name, "project": project_name} + + def get_driver(self): + if self.driver: + return self.driver + + if self.environment in ["lt", "lambdatest"]: + self.driver = self._create_lambdatest_driver() + elif self.environment == "local": + self.driver = self._create_local_driver() + else: + raise ValueError(f"Unsupported environment: {self.environment}") + + return self.driver + + def _create_lambdatest_driver(self): + options = AppiumOptions() + + if self.env_config: + # Use new YAML-based configuration + capabilities = self.env_config.get_device_capabilities() + server_url = self.env_config.get_appium_server_url() + + if self.env_config.environment == "lambdatest": + # Get LambdaTest naming configuration + naming = self._get_lambdatest_naming() + + capabilities.setdefault("lt:options", {}).update( + { + "app": self.env_config.get_resolved_app_path(), + "build": naming["build"], + "name": naming["name"], + "project": naming["project"], + } + ) + + # Get LambdaTest credentials (still from environment variables) + username = os.getenv("LT_USERNAME") + access_key = os.getenv("LT_ACCESS_KEY") + + else: + raise ConfigurationError("Environment configuration not available") + + options.load_capabilities(capabilities) + + client_config = ClientConfig( + remote_server_addr=server_url, username=username, password=access_key + ) + + # Simple retry for transient hub failures + last_error = None + for attempt in range(2): # 2 attempts total + try: + driver = webdriver.Remote( + command_executor=AppiumConnection(client_config=client_config), + options=options, + ) + session_id = driver.session_id if driver else "unknown" + log_session_info(session_id, "created", environment=self.environment) + return driver + except Exception as e: + self.logger.warning( + f"LambdaTest session creation attempt {attempt + 1} failed: {e}" + ) + last_error = e + # Raise last error if retries exhausted + raise last_error + + def _create_local_driver(self): + options = AppiumOptions() + + if self.env_config: + # Use new YAML-based configuration + capabilities = self.env_config.get_device_capabilities() + server_url = self.env_config.get_appium_server_url() + else: + raise ConfigurationError( + "Environment configuration not available for local driver" + ) + + options.load_capabilities(capabilities) + + return webdriver.Remote(server_url, options=options) + + def cleanup_driver(self): + if self.driver: + session_id = ( + self.driver.session_id + if hasattr(self.driver, "session_id") + else "unknown" + ) + log_session_info(session_id, "cleanup", environment=self.environment) + self.driver.quit() + self.driver = None + + def get_configuration_summary(self): + if self.env_config: + return { + "environment": self.env_config.environment, + "device": f"{self.env_config.device_name} ({self.env_config.platform_name} {self.env_config.platform_version})", + "app_source": self.env_config.app_source["source_type"], + "app_path": self.env_config.get_resolved_app_path(), + "appium_server": self.env_config.get_appium_server_url(), + } + return {"environment": self.environment} diff --git a/test/e2e_appium/fixtures/__init__.py b/test/e2e_appium/fixtures/__init__.py new file mode 100644 index 0000000000..376b0e9b79 --- /dev/null +++ b/test/e2e_appium/fixtures/__init__.py @@ -0,0 +1,40 @@ +# E2E_Appium Fixtures - Clean Modular Export System +# +# This module provides a clean, organized fixture system for the e2e_appium framework. +# All fixtures are properly exported to avoid import conflicts and circular dependencies. + +# Import and re-export core fixtures from the original comprehensive module +from .onboarding_fixture import ( + OnboardingConfig, + OnboardingFlow, + OnboardingFlowError, + onboarding_config, + custom_onboarding_config, + onboarded_user, + onboarding_flow_factory, + # Seed phrase generation fixtures + generated_seed_phrase, + generated_12_word_seed_phrase, + generated_24_word_seed_phrase, + onboarding_config_with_seed_phrase, +) + +# Import and re-export fixtures from modular system + + +# Export all fixtures for easy importing +__all__ = [ + # Core onboarding fixtures + "OnboardingConfig", + "OnboardingFlow", + "OnboardingFlowError", + "onboarding_config", + "custom_onboarding_config", + "onboarded_user", + "onboarding_flow_factory", + # Seed phrase generation fixtures + "generated_seed_phrase", + "generated_12_word_seed_phrase", + "generated_24_word_seed_phrase", + "onboarding_config_with_seed_phrase", +] diff --git a/test/e2e_appium/fixtures/onboarding_fixture.py b/test/e2e_appium/fixtures/onboarding_fixture.py new file mode 100644 index 0000000000..338a21a96e --- /dev/null +++ b/test/e2e_appium/fixtures/onboarding_fixture.py @@ -0,0 +1,660 @@ +""" +Onboarding Flow Fixture for E2E Tests + +This module provides reusable fixtures for onboarding functionality that can be +used across multiple test suites. It follows the Page Object Model pattern and +provides flexible configuration options. +""" + +from dataclasses import dataclass, field +from typing import Optional, Dict, Any +import pytest +import time +from datetime import datetime + +from pages.onboarding import ( + WelcomePage, + AnalyticsPage, + CreateProfilePage, + SeedPhraseInputPage, + PasswordPage, + SplashScreen, + MainAppPage, +) +from utils.generators import generate_seed_phrase +from models.user_model import User, UserProfile +from config.logging_config import get_logger + + +@dataclass +class OnboardingConfig: + """Configuration options for onboarding flow execution""" + + skip_analytics: bool = True + skip_profile_creation: bool = False + custom_user_data: Optional[Dict[str, Any]] = None + timeout_per_step: int = 30 + take_screenshots: bool = False + screenshot_path: Optional[str] = None + validate_each_step: bool = True + + # Advanced options + custom_password: Optional[str] = None + custom_display_name: Optional[str] = None + wait_for_complete_loading: bool = True + verify_main_app: bool = True + + # Seed phrase import options + use_seed_phrase: bool = False + seed_phrase: Optional[str] = None + seed_phrase_autocomplete: bool = False + + # Test context + test_environment: str = "e2e_test" + test_metadata: Dict[str, Any] = field(default_factory=dict) + + +class OnboardingFlow: + """ + Encapsulates the complete onboarding flow with reusable methods. + + This class provides a clean interface for executing onboarding steps + and can be configured for different test scenarios. + """ + + def __init__(self, driver, config: OnboardingConfig = None, logger=None): + self.driver = driver + self.config = config or OnboardingConfig() + self.logger = logger or get_logger("onboarding_flow") + + # Initialize page objects + self.welcome_page = WelcomePage(self.driver) + self.analytics_page = AnalyticsPage(self.driver) + self.create_profile_page = CreateProfilePage(self.driver) + self.seed_phrase_page = SeedPhraseInputPage(self.driver) + self.password_page = PasswordPage(self.driver) + self.loading_page = SplashScreen(self.driver) + self.main_app_page = MainAppPage(self.driver) + + # Track execution state + self.current_step = "initialization" + self.start_time = datetime.now() + self.step_results = {} + + # Generate test user if not provided + self.test_user = self._create_test_user() + + def _create_test_user(self) -> User: + """Create a test user with appropriate data for the environment""" + + if self.config.custom_user_data: + return User.from_test_data(self.config.custom_user_data) + + # Create user with custom overrides + display_name = ( + self.config.custom_display_name + or f"E2E_User_{datetime.now().strftime('%H%M%S')}" + ) + + password = self.config.custom_password or "TestPassword123!" + + profile = UserProfile( + display_name=display_name, + bio=f"Created during E2E test at {datetime.now().isoformat()}", + ) + + return User( + profile=profile, + password=password, + environment=self.config.test_environment, + test_context=self.config.test_metadata, + ) + + def execute_complete_flow(self) -> Dict[str, Any]: + """ + Execute the complete onboarding flow. + + Returns: + Dict containing execution results and metadata + """ + flow_type = ( + "seed phrase import" + if self.config.use_seed_phrase + else "new profile creation" + ) + self.logger.info( + f"๐Ÿš€ Starting complete onboarding flow execution ({flow_type})" + ) + + try: + # Step 1: Welcome Screen + self._execute_welcome_step() + + # Step 2: Analytics Screen (conditionally skip) + if not self.config.skip_analytics: + self._execute_analytics_step() + else: + self._execute_analytics_skip_step() + + # Step 3a: Create Profile Screen OR Step 3b: Seed Phrase Import + if self.config.use_seed_phrase: + # Seed phrase import flow + self._execute_seed_phrase_import_step() + else: + # New profile creation flow + if not self.config.skip_profile_creation: + self._execute_create_profile_step() + + # Step 4: Password Creation + self._execute_password_step() + + # Step 5: Loading Screen + self._execute_loading_step() + + # Step 6: Main App Verification + if self.config.verify_main_app: + self._execute_main_app_verification() + + execution_result = self._build_success_result() + self.logger.info( + f"โœ… Complete onboarding flow executed successfully ({flow_type})" + ) + return execution_result + + except Exception as e: + error_result = self._build_error_result(e) + self.logger.error( + f"โŒ Onboarding flow failed at step '{self.current_step}': {str(e)}" + ) + raise OnboardingFlowError( + f"Onboarding failed at step '{self.current_step}': {str(e)}", + step=self.current_step, + results=error_result, + ) + + def _execute_welcome_step(self): + """Execute welcome screen interaction""" + self.current_step = "welcome_screen" + self.logger.info("Step 1: Welcome Screen") + + # Wait for screen to be fully loaded before activating accessibility + # Wait for app to be visually ready (look for any UI elements) + max_wait = 10 + for attempt in range(max_wait): + try: + # Check if any UI elements are present (even without accessibility) + elements = self.main_app_page.driver.find_elements("xpath", "//*") + if len(elements) > 5: # Basic UI structure loaded + break + time.sleep(1) + except Exception: + time.sleep(1) + + try: + self.main_app_page.driver.tap([(500, 300)]) + time.sleep(1) + except Exception: + pass # Non-critical if tap fails + + if self.config.validate_each_step: + assert self.welcome_page.is_screen_displayed(timeout=30), ( + "Welcome screen should be displayed" + ) + + self.welcome_page.click_create_profile() + + self.step_results["welcome_screen"] = { + "success": True, + "timestamp": datetime.now(), + } + + if self.config.take_screenshots: + self._take_screenshot("welcome_completed") + + def _execute_analytics_step(self): + """Execute analytics screen interaction""" + self.current_step = "analytics_screen" + self.logger.info("Step 2: Analytics Screen (interacting)") + + if self.config.validate_each_step: + assert self.analytics_page.is_screen_displayed(), ( + "Analytics screen should be displayed" + ) + + # Interact with analytics consent (accept sharing) + self.analytics_page.accept_analytics_sharing() + + self.step_results["analytics_screen"] = { + "success": True, + "action": "shared", + "timestamp": datetime.now(), + } + + def _execute_analytics_skip_step(self): + """Execute analytics screen skip action""" + self.current_step = "analytics_screen_skip" + self.logger.info("Step 2: Analytics Screen (skipping)") + + if self.config.validate_each_step: + assert self.analytics_page.is_screen_displayed(), ( + "Analytics screen should be displayed" + ) + + self.analytics_page.skip_analytics_sharing() + + self.step_results["analytics_screen"] = { + "success": True, + "action": "skipped", + "timestamp": datetime.now(), + } + + if self.config.take_screenshots: + self._take_screenshot("analytics_skipped") + + def _execute_create_profile_step(self): + """Execute create profile screen interaction""" + self.current_step = "create_profile_screen" + self.logger.info("Step 3: Create Profile Screen") + + if self.config.validate_each_step: + assert self.create_profile_page.is_screen_displayed(), ( + "Create profile screen should be displayed" + ) + + self.create_profile_page.click_lets_go() + + self.step_results["create_profile_screen"] = { + "success": True, + "timestamp": datetime.now(), + } + + if self.config.take_screenshots: + self._take_screenshot("profile_created") + + def _execute_password_step(self): + """Execute password creation step""" + self.current_step = "password_screen" + self.logger.info("Step 4: Password Screen") + + if self.config.validate_each_step: + assert self.password_page.is_screen_displayed(), ( + "Password screen should be displayed" + ) + + success = self.password_page.create_password(self.test_user.password) + assert success, "Should successfully create password" + + self.step_results["password_screen"] = { + "success": True, + "password_length": len(self.test_user.password), + "timestamp": datetime.now(), + } + + if self.config.take_screenshots: + self._take_screenshot("password_created") + + def _execute_loading_step(self): + """Execute loading screen wait""" + self.current_step = "loading_screen" + self.logger.info("Step 5: Loading Screen") + + if self.config.wait_for_complete_loading: + success = self.loading_page.wait_for_loading_completion() + assert success, "Should successfully complete loading" + + self.step_results["loading_screen"] = { + "success": True, + "timestamp": datetime.now(), + } + + def _execute_main_app_verification(self): + """Execute main app verification""" + self.current_step = "main_app_verification" + self.logger.info("Step 6: Main App Verification") + + assert self.main_app_page.is_main_app_loaded(), "Main app should be loaded" + + self.step_results["main_app_verification"] = { + "success": True, + "timestamp": datetime.now(), + } + + if self.config.take_screenshots: + self._take_screenshot("onboarding_completed") + + def _take_screenshot(self, name: str): + """Take screenshot during flow execution""" + if self.config.screenshot_path: + try: + 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) + self.logger.debug(f"๐Ÿ“ท Screenshot saved: {screenshot_path}") + except Exception as e: + self.logger.warning(f"โš ๏ธ Failed to take screenshot '{name}': {e}") + + 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(), + } + + + + +class OnboardingFlowError(Exception): + """Custom exception for onboarding flow failures""" + + def __init__(self, message: str, step: str = None, results: Dict[str, Any] = None): + super().__init__(message) + self.step = step + self.results = results + + +# Pytest Fixtures + + +@pytest.fixture(scope="function") +def onboarding_config(): + """Default onboarding configuration fixture""" + return OnboardingConfig() + + +@pytest.fixture(scope="function") +def custom_onboarding_config(): + """Factory fixture for creating custom onboarding configurations""" + + def _create_config(**kwargs) -> OnboardingConfig: + return OnboardingConfig(**kwargs) + + return _create_config + + +@pytest.fixture(scope="function") +def onboarded_user(request, test_environment): + """ + Execute the complete onboarding flow and return a result dictionary. + + Returns: + dict: { + 'success': bool, + 'user_data': dict, # includes display_name, ids, etc. + 'steps_completed': List[str], + 'step_results': Dict[str, Any], # per-step info (e.g., analytics action) + 'execution_time_seconds': float, + ... + } + + Default behavior: + - skip_analytics=True unless overridden with @pytest.mark.onboarding_config + - validate_each_step=True, screenshots disabled by default + + Usage: + def test_something_after_onboarding(onboarded_user): + user_data = onboarded_user['user_data'] + assert user_data['display_name'] is not None + """ + + # Get driver from test instance if available, otherwise create new one + if hasattr(request.instance, "driver"): + driver = request.instance.driver + logger = getattr(request.instance, "logger", get_logger("onboarding_fixture")) + else: + # Create temporary driver for fixture-only usage + from core import SessionManager + + session_manager = SessionManager(test_environment) + driver = session_manager.get_driver() + logger = get_logger("onboarding_fixture") + + # Get configuration from test markers or use default + config = OnboardingConfig() + + # Check for custom config in test markers + for marker in request.node.iter_markers(): + if marker.name == "onboarding_config": + config = OnboardingConfig(**marker.kwargs) + break + + # Execute onboarding flow + onboarding_flow = OnboardingFlow(driver, config, logger) + + try: + result = onboarding_flow.execute_complete_flow() + logger.info("โœ… Onboarding fixture completed successfully") + + # Store result for access in tests + request.node.onboarding_result = result + + return result + + except Exception as e: + logger.error(f"โŒ Onboarding fixture failed: {e}") + raise + + +@pytest.fixture(scope="function") +def onboarding_flow_factory(test_environment): + """ + Factory fixture for creating OnboardingFlow instances with custom configuration. + + This fixture provides more control for tests that need to customize the onboarding process. + + Usage: + def test_custom_onboarding(onboarding_flow_factory): + config = OnboardingConfig(skip_analytics=False, custom_display_name="CustomUser") + flow = onboarding_flow_factory(config) + result = flow.execute_complete_flow() + """ + + def _create_flow(config: OnboardingConfig, driver=None) -> OnboardingFlow: + if driver is None: + from core import SessionManager + + session_manager = SessionManager(test_environment) + driver = session_manager.get_driver() + + logger = get_logger("onboarding_flow_factory") + return OnboardingFlow(driver, config, logger) + + return _create_flow + + +# Additional fixtures for better integration with existing patterns + + +@pytest.fixture(scope="function") +def user_account(): + """ + User account fixture similar to e2e pattern for consistency. + + Creates user account data compatible with existing framework patterns. + """ + from models.user_model import User, UserProfile + from datetime import datetime + + # Create consistent user account similar to e2e pattern + profile = UserProfile( + display_name=f"E2EUser_{datetime.now().strftime('%H%M%S')}", + bio="E2E test user created by fixture", + ) + + return User(profile=profile, password="TestPassword123!", environment="e2e_test") + + +@pytest.fixture(scope="function") +def onboarded_app(request, test_environment): + """ + Execute onboarding and return a MainAppPage ready for UI interactions. + + Returns: + MainAppPage: initialized on the main app UI after onboarding. The page + object exposes: + - onboarding_result (dict): same structure as returned by onboarded_user + - user_data (dict): convenience alias for onboarding_result['user_data'] + + Default behavior: + - skip_analytics=True unless overridden with @pytest.mark.onboarding_config + + Usage: + def test_feature(onboarded_app): + app = onboarded_app + assert app.is_main_app_loaded() + """ + from core import SessionManager + from pages.onboarding import MainAppPage + + # Get driver from test instance if available + if hasattr(request.instance, "driver"): + driver = request.instance.driver + logger = getattr(request.instance, "logger", get_logger("onboarded_app")) + else: + session_manager = SessionManager(test_environment) + driver = session_manager.get_driver() + logger = get_logger("onboarded_app") + + # Get configuration from markers + config = OnboardingConfig() + for marker in request.node.iter_markers(): + if marker.name == "onboarding_config": + config = OnboardingConfig(**marker.kwargs) + break + + # Execute onboarding + onboarding_flow = OnboardingFlow(driver, config, logger) + result = onboarding_flow.execute_complete_flow() + + if not result["success"]: + raise OnboardingFlowError("Failed to prepare onboarded app", results=result) + + # Return main app page ready for testing + main_app = MainAppPage(driver) + + # Store onboarding result for access in tests + main_app.onboarding_result = result + main_app.user_data = result["user_data"] + + return main_app + + +@pytest.fixture(scope="function") +def multiple_onboarded_users(request, test_environment): + """ + Factory fixture for creating multiple onboarded users. + + Addresses the appium pattern of multiple device testing. + + Usage: + def test_multi_user(multiple_onboarded_users): + users = multiple_onboarded_users(count=2, config=OnboardingConfig(...)) + user1, user2 = users + """ + + def _create_multiple_users(count: int = 2, config: OnboardingConfig = None): + """ + Create multiple onboarded users for multi-device testing. + + Note: This is a simplified version. Real multi-device testing + would require separate driver instances and proper coordination. + """ + users = [] + base_config = config or OnboardingConfig() + + for i in range(count): + # Create unique config for each user + user_config = OnboardingConfig( + custom_display_name=f"{base_config.custom_display_name or 'MultiUser'}_{i + 1}", + skip_analytics=base_config.skip_analytics, + validate_each_step=base_config.validate_each_step, + test_metadata={**base_config.test_metadata, "user_index": i + 1}, + ) + + # This would need actual driver management for real multi-device + # For now, just return user data + flow = OnboardingFlow(None, user_config, get_logger(f"multi_user_{i + 1}")) + user_data = flow._create_test_user() + users.append(user_data.to_test_data()) + + return users + + return _create_multiple_users + + +# Seed Phrase Generation Fixtures + + +@pytest.fixture(scope="function") +def generated_seed_phrase(): + """Generate a random seed phrase for testing. + + Returns: + A valid BIP39 seed phrase (12, 18, or 24 words). + """ + return generate_seed_phrase() + + +@pytest.fixture(scope="function") +def generated_12_word_seed_phrase(): + """Generate a 12-word seed phrase for testing. + + Returns: + A valid 12-word BIP39 seed phrase. + """ + return generate_seed_phrase(12) + + +@pytest.fixture(scope="function") +def generated_24_word_seed_phrase(): + """Generate a 24-word seed phrase for testing. + + Returns: + A valid 24-word BIP39 seed phrase. + """ + return generate_seed_phrase(24) + + +@pytest.fixture(scope="function") +def onboarding_config_with_seed_phrase(generated_seed_phrase): + """Create onboarding config that uses a generated seed phrase. + + Args: + generated_seed_phrase: Automatically injected seed phrase fixture. + + Returns: + OnboardingConfig configured for seed phrase import. + """ + return OnboardingConfig( + use_seed_phrase=True, + seed_phrase=generated_seed_phrase, + seed_phrase_autocomplete=False, + validate_each_step=True, + take_screenshots=False, + ) diff --git a/test/e2e_appium/locators/__init__.py b/test/e2e_appium/locators/__init__.py new file mode 100644 index 0000000000..2bd997908e --- /dev/null +++ b/test/e2e_appium/locators/__init__.py @@ -0,0 +1,24 @@ +""" +Locators package for Status Desktop tablet E2E tests. +Contains all element locators organized by screen/feature. +""" + +from .base_locators import BaseLocators +from .onboarding.onboarding_locators import OnboardingLocators +from .onboarding.main_app_locators import MainAppLocators +from .onboarding.welcome_screen_locators import WelcomeScreenLocators +from .onboarding.analytics_screen_locators import AnalyticsScreenLocators +from .onboarding.create_profile_screen_locators import CreateProfileScreenLocators +from .onboarding.password_screen_locators import PasswordScreenLocators +from .onboarding.loading_screen_locators import LoadingScreenLocators + +__all__ = [ + "BaseLocators", + "OnboardingLocators", + "MainAppLocators", + "WelcomeScreenLocators", + "AnalyticsScreenLocators", + "CreateProfileScreenLocators", + "PasswordScreenLocators", + "LoadingScreenLocators", +] diff --git a/test/e2e_appium/locators/base_locators.py b/test/e2e_appium/locators/base_locators.py new file mode 100644 index 0000000000..986913c26f --- /dev/null +++ b/test/e2e_appium/locators/base_locators.py @@ -0,0 +1,68 @@ +from appium.webdriver.common.appiumby import AppiumBy + + +class BaseLocators: + BY_ACCESSIBILITY_ID = AppiumBy.ACCESSIBILITY_ID + BY_ID = AppiumBy.ID + BY_XPATH = AppiumBy.XPATH + BY_CLASS_NAME = AppiumBy.CLASS_NAME + BY_ANDROID_UIAUTOMATOR = AppiumBy.ANDROID_UIAUTOMATOR + + @staticmethod + def accessibility_id(value: str) -> tuple: + return (BaseLocators.BY_ACCESSIBILITY_ID, value) + + @staticmethod + def id(value: str) -> tuple: + return (BaseLocators.BY_ID, value) + + @staticmethod + def xpath(value: str) -> tuple: + return (BaseLocators.BY_XPATH, value) + + @staticmethod + def class_name(value: str) -> tuple: + return (BaseLocators.BY_CLASS_NAME, value) + + @staticmethod + def android_uiautomator(value: str) -> tuple: + return (BaseLocators.BY_ANDROID_UIAUTOMATOR, value) + + @staticmethod + def text_contains(text: str) -> tuple: + return (BaseLocators.BY_XPATH, f"//*[contains(@text, '{text}')]") + + @staticmethod + def text_exact(text: str) -> tuple: + return (BaseLocators.BY_XPATH, f"//*[@text='{text}']") + + @staticmethod + def content_desc_contains(desc: str) -> tuple: + return (BaseLocators.BY_XPATH, f"//*[contains(@content-desc, '{desc}')]") + + @staticmethod + def content_desc_exact(desc: str) -> tuple: + return (BaseLocators.BY_XPATH, f"//*[@content-desc='{desc}']") + + @staticmethod + def button_with_text(text: str) -> tuple: + return (BaseLocators.BY_XPATH, f"//android.widget.Button[@text='{text}']") + + @staticmethod + def text_view_with_text(text: str) -> tuple: + return (BaseLocators.BY_XPATH, f"//android.widget.TextView[@text='{text}']") + + @staticmethod + def edit_text_with_hint(hint: str) -> tuple: + return (BaseLocators.BY_XPATH, f"//android.widget.EditText[@hint='{hint}']") + + @staticmethod + def scrollable_with_text(text: str) -> tuple: + return ( + BaseLocators.BY_XPATH, + f"//android.widget.ScrollView//*[contains(@text, '{text}')]", + ) + + @staticmethod + def any_element_with_text(text: str) -> tuple: + return (BaseLocators.BY_XPATH, f"//*[@text='{text}' or @content-desc='{text}']") diff --git a/test/e2e_appium/locators/onboarding/analytics_screen_locators.py b/test/e2e_appium/locators/onboarding/analytics_screen_locators.py new file mode 100644 index 0000000000..3a228e0f52 --- /dev/null +++ b/test/e2e_appium/locators/onboarding/analytics_screen_locators.py @@ -0,0 +1,21 @@ +""" +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 + SHARE_USAGE_DATA_BUTTON = BaseLocators.accessibility_id("Share usage data") + NOT_NOW_BUTTON = BaseLocators.accessibility_id("Not now") + + # Container locator - stable without QMLTYPE + ONBOARDING_CONTAINER = BaseLocators.id("QGuiApplication.mainWindow.startupOnboardingLayout") diff --git a/test/e2e_appium/locators/onboarding/create_profile_screen_locators.py b/test/e2e_appium/locators/onboarding/create_profile_screen_locators.py new file mode 100644 index 0000000000..4eab5c355e --- /dev/null +++ b/test/e2e_appium/locators/onboarding/create_profile_screen_locators.py @@ -0,0 +1,28 @@ +""" +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") + + # 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')]") diff --git a/test/e2e_appium/locators/onboarding/loading_screen_locators.py b/test/e2e_appium/locators/onboarding/loading_screen_locators.py new file mode 100644 index 0000000000..095828ccac --- /dev/null +++ b/test/e2e_appium/locators/onboarding/loading_screen_locators.py @@ -0,0 +1,23 @@ +""" +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") + + # Alternative using partial ID (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')]") + + # Container locator - stable without QMLTYPE + ONBOARDING_CONTAINER = BaseLocators.id("QGuiApplication.mainWindow.startupOnboardingLayout") diff --git a/test/e2e_appium/locators/onboarding/main_app_locators.py b/test/e2e_appium/locators/onboarding/main_app_locators.py new file mode 100644 index 0000000000..a2d37302dc --- /dev/null +++ b/test/e2e_appium/locators/onboarding/main_app_locators.py @@ -0,0 +1,32 @@ +""" +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) + 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...") + + # 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 new file mode 100644 index 0000000000..cb3b3ff684 --- /dev/null +++ b/test/e2e_appium/locators/onboarding/onboarding_locators.py @@ -0,0 +1,79 @@ +""" +Onboarding screen locators for Status Desktop tablet E2E tests. +""" + +from ..base_locators import BaseLocators + + +class OnboardingLocators(BaseLocators): + # Welcome Screen Locators + WELCOME_TEXT = BaseLocators.accessibility_id("Welcome to Status") + WELCOME_TEXT_FALLBACK = BaseLocators.content_desc_contains("Welcome") + WELCOME_TEXT_TEXT = BaseLocators.text_contains("Welcome to Status") + + CREATE_PROFILE_BUTTON = BaseLocators.accessibility_id("Create profile") + CREATE_PROFILE_BUTTON_FALLBACK = BaseLocators.content_desc_exact("Create profile") + CREATE_PROFILE_BUTTON_TEXT = BaseLocators.button_with_text("Create profile") + + IMPORT_PROFILE_BUTTON = BaseLocators.accessibility_id("Import profile") + + # Create Profile Screen Locators + CREATE_PROFILE_SCREEN = BaseLocators.accessibility_id("Create profile screen") + + DISPLAY_NAME_INPUT = BaseLocators.accessibility_id("Display name input") + DISPLAY_NAME_ERROR = BaseLocators.accessibility_id("Display name error") + + NEXT_BUTTON = BaseLocators.accessibility_id("Next") + + # Help Improve Screen Locators + HELP_IMPROVE_SCREEN = BaseLocators.accessibility_id("Help us improve Status") + HELP_IMPROVE_TEXT_FALLBACK = BaseLocators.content_desc_contains("Help us improve") + + NOT_NOW_BUTTON = BaseLocators.accessibility_id("Not now") + HELP_IMPROVE_BUTTON = BaseLocators.accessibility_id("Help improve") + + # Password Setup Screen Locators + PASSWORD_SCREEN = BaseLocators.accessibility_id("Password setup screen") + PASSWORD_INPUT = BaseLocators.accessibility_id("Password input") + CONFIRM_PASSWORD_INPUT = BaseLocators.accessibility_id("Confirm password input") + PASSWORD_STRENGTH = BaseLocators.accessibility_id("Password strength") + PASSWORD_ERROR = BaseLocators.accessibility_id("Password error") + PASSWORD_CONTINUE_BUTTON = BaseLocators.accessibility_id("Continue") + + # Completion Screen Locators + COMPLETION_SCREEN = BaseLocators.accessibility_id("Onboarding completion") + COMPLETION_MESSAGE = BaseLocators.accessibility_id("Completion message") + GET_STARTED_BUTTON = BaseLocators.accessibility_id("Get started") + + # Main App Indicator + MAIN_APP_INDICATOR = BaseLocators.accessibility_id("Main app") + + # General Screen Elements + SCREEN_TITLE = BaseLocators.accessibility_id("Screen title") + LOADING_INDICATOR = BaseLocators.accessibility_id("Loading") + + # 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 new file mode 100644 index 0000000000..2ae7a9963a --- /dev/null +++ b/test/e2e_appium/locators/onboarding/password_screen_locators.py @@ -0,0 +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')]") + + # 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')]") + + # Container locator - stable without QMLTYPE + 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 new file mode 100644 index 0000000000..fc9a1b2559 --- /dev/null +++ b/test/e2e_appium/locators/onboarding/seed_phrase_input_locators.py @@ -0,0 +1,47 @@ +""" +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") + + # 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") + + 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") + + # 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}") diff --git a/test/e2e_appium/locators/onboarding/welcome_screen_locators.py b/test/e2e_appium/locators/onboarding/welcome_screen_locators.py new file mode 100644 index 0000000000..ab20d38a6f --- /dev/null +++ b/test/e2e_appium/locators/onboarding/welcome_screen_locators.py @@ -0,0 +1,21 @@ +""" +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 + 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") + LOGIN_BUTTON = BaseLocators.accessibility_id("Log in") + + # Container locators - stable without QMLTYPE + ONBOARDING_LAYOUT = BaseLocators.id("QGuiApplication.mainWindow.startupOnboardingLayout") diff --git a/test/e2e_appium/models/__init__.py b/test/e2e_appium/models/__init__.py new file mode 100644 index 0000000000..2e7a5d6d62 --- /dev/null +++ b/test/e2e_appium/models/__init__.py @@ -0,0 +1,15 @@ +""" +Models package for E2E test data structures. +""" + +from .user_model import User, UserProfile, CryptoWallet, WalletAddress +from .user_factory import UserFactory, UserType + +__all__ = [ + "User", + "UserProfile", + "CryptoWallet", + "WalletAddress", + "UserFactory", + "UserType", +] diff --git a/test/e2e_appium/models/user_factory.py b/test/e2e_appium/models/user_factory.py new file mode 100644 index 0000000000..e9ddc8b349 --- /dev/null +++ b/test/e2e_appium/models/user_factory.py @@ -0,0 +1,322 @@ +from typing import Dict, Any, List +from enum import Enum +import json +from pathlib import Path + +from .user_model import User, UserProfile, CryptoWallet, WalletAddress + + +class UserType(Enum): + BASIC = "basic" + OWNER = "owner" + ADMIN = "admin" + TOKEN_MASTER = "token_master" + + +class UserFactory: + def __init__(self): + self.reset() + + def reset(self) -> "UserFactory": + self._profile_data = { + "display_name": "Test User", + "bio": None, + "avatar_path": None, + "status_message": None, + "is_public": True, + } + self._auth_data = { + "password": "SecurePass123!", + "pin_code": None, + "biometric_enabled": False, + "recovery_phrase": None, + } + self._wallet_data = { + "name": "Default Wallet", + "primary_address": None, + "additional_addresses": [], + "is_backed_up": False, + } + self._user_data = { + "verified": False, + "ens_name": None, + "device_id": None, + "environment": "test", + "test_context": {}, + } + return self + + def with_display_name(self, name: str) -> "UserFactory": + self._profile_data["display_name"] = name + return self + + def with_bio(self, bio: str) -> "UserFactory": + self._profile_data["bio"] = bio + return self + + def with_avatar(self, avatar_path: str) -> "UserFactory": + self._profile_data["avatar_path"] = avatar_path + return self + + def with_status_message(self, message: str) -> "UserFactory": + self._profile_data["status_message"] = message + return self + + def with_public_profile(self, is_public: bool = True) -> "UserFactory": + self._profile_data["is_public"] = is_public + return self + + def with_password(self, password: str) -> "UserFactory": + self._auth_data["password"] = password + return self + + def with_pin_code(self, pin: str) -> "UserFactory": + self._auth_data["pin_code"] = pin + return self + + def with_biometric(self, enabled: bool = True) -> "UserFactory": + self._auth_data["biometric_enabled"] = enabled + return self + + def with_recovery_phrase(self, phrase: str) -> "UserFactory": + self._auth_data["recovery_phrase"] = phrase + return self + + def with_wallet_name(self, name: str) -> "UserFactory": + self._wallet_data["name"] = name + return self + + def with_primary_address( + self, address: str, network: str = "ethereum" + ) -> "UserFactory": + self._wallet_data["primary_address"] = { + "address": address, + "network": network, + "name": "Main Address", + } + return self + + def with_additional_address( + self, address: str, network: str, name: str = None + ) -> "UserFactory": + self._wallet_data["additional_addresses"].append( + { + "address": address, + "network": network, + "name": name or f"{network.title()} Address", + } + ) + return self + + def with_backed_up_wallet(self, backed_up: bool = True) -> "UserFactory": + self._wallet_data["is_backed_up"] = backed_up + return self + + def with_verified_status(self, verified: bool = True) -> "UserFactory": + self._user_data["verified"] = verified + return self + + def with_ens_name(self, ens_name: str) -> "UserFactory": + self._user_data["ens_name"] = ens_name + return self + + def with_device_id(self, device_id: str) -> "UserFactory": + self._user_data["device_id"] = device_id + return self + + def with_environment(self, environment: str) -> "UserFactory": + self._user_data["environment"] = environment + return self + + def with_test_context(self, context: Dict[str, Any]) -> "UserFactory": + self._user_data["test_context"].update(context) + return self + + def create_basic_user(self, name: str = "Basic User") -> "UserFactory": + return ( + self.reset() + .with_display_name(name) + .with_verified_status(False) + .with_backed_up_wallet(False) + ) + + def create_admin_user(self, name: str = "Admin User") -> "UserFactory": + return ( + self.reset() + .with_display_name(name) + .with_verified_status(True) + .with_backed_up_wallet(True) + .with_bio("Administrator user with full access") + ) + + def create_owner_user(self, name: str = "Owner User") -> "UserFactory": + return ( + self.reset() + .with_display_name(name) + .with_verified_status(True) + .with_backed_up_wallet(True) + .with_bio("Owner user with ownership privileges") + .with_test_context( + {"owner_permissions": True, "can_manage_community": True} + ) + ) + + def create_token_master_user( + self, name: str = "Token Master User" + ) -> "UserFactory": + return ( + self.reset() + .with_display_name(name) + .with_verified_status(True) + .with_backed_up_wallet(True) + .with_bio("Token master with token management privileges") + .with_additional_address( + "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh", + "bitcoin", + "Bitcoin Wallet", + ) + .with_additional_address( + "0x742d35CC6634C0532925a3b8D45B1BFA73651234", + "ethereum", + "Ethereum Wallet", + ) + .with_test_context( + { + "token_master_permissions": True, + "can_manage_tokens": True, + "multi_wallet_enabled": True, + } + ) + ) + + def build(self) -> User: + profile = UserProfile( + display_name=self._profile_data["display_name"], + bio=self._profile_data["bio"], + avatar_path=self._profile_data["avatar_path"], + status_message=self._profile_data["status_message"], + is_public=self._profile_data["is_public"], + ) + + wallet = CryptoWallet( + name=self._wallet_data["name"], + is_backed_up=self._wallet_data["is_backed_up"], + ) + + if self._wallet_data["primary_address"]: + primary_addr = WalletAddress( + address=self._wallet_data["primary_address"]["address"], + network=self._wallet_data["primary_address"]["network"], + name=self._wallet_data["primary_address"]["name"], + is_primary=True, + ) + wallet.addresses = [primary_addr] + + for addr_data in self._wallet_data["additional_addresses"]: + additional_addr = WalletAddress( + address=addr_data["address"], + network=addr_data["network"], + name=addr_data["name"], + is_primary=False, + ) + wallet.add_address(additional_addr) + + user = User( + profile=profile, + password=self._auth_data["password"], + crypto_wallet=wallet, + pin_code=self._auth_data["pin_code"], + biometric_enabled=self._auth_data["biometric_enabled"], + recovery_phrase=self._auth_data["recovery_phrase"], + is_verified=self._user_data["verified"], + ens_name=self._user_data["ens_name"], + device_id=self._user_data["device_id"], + environment=self._user_data["environment"], + test_context=self._user_data["test_context"].copy(), + ) + + return user + + def build_multiple(self, count: int) -> List[User]: + users = [] + base_name = self._profile_data["display_name"] + + for i in range(count): + self._profile_data["display_name"] = f"{base_name} {i + 1}" + user = self.build() + users.append(user) + + self._profile_data["display_name"] = base_name + return users + + @classmethod + def create_user_by_type(cls, user_type: UserType, name: str = None) -> User: + factory = cls() + + type_methods = { + UserType.BASIC: factory.create_basic_user, + UserType.OWNER: factory.create_owner_user, + UserType.ADMIN: factory.create_admin_user, + UserType.TOKEN_MASTER: factory.create_token_master_user, + } + + method = type_methods.get(user_type) + if method: + if name: + method(name) + else: + method() + else: + raise ValueError(f"Unknown user type: {user_type}") + + return factory.build() + + @classmethod + def load_from_file(cls, file_path: str) -> List[User]: + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"User data file not found: {file_path}") + + with open(path, "r") as f: + data = json.load(f) + + users = [] + for user_data in data: + user = User.from_test_data(user_data) + users.append(user) + + return users + + @classmethod + def save_to_file(cls, users: List[User], file_path: str) -> None: + path = Path(file_path) + path.parent.mkdir(parents=True, exist_ok=True) + + data = [] + for user in users: + data.append(user.to_test_data()) + + with open(path, "w") as f: + json.dump(data, f, indent=2, default=str) + + +def create_basic_user(name: str = "Basic User") -> User: + return UserFactory().create_basic_user(name).build() + + +def create_admin_user(name: str = "Admin User") -> User: + return UserFactory().create_admin_user(name).build() + + +def create_owner_user(name: str = "Owner User") -> User: + return UserFactory().create_owner_user(name).build() + + +def create_token_master_user(name: str = "Token Master User") -> User: + return UserFactory().create_token_master_user(name).build() + + +def create_multi_device_users( + count: int = 2, base_name: str = "Multi Device User" +) -> List[User]: + return UserFactory().create_basic_user(base_name).build_multiple(count) diff --git a/test/e2e_appium/models/user_model.py b/test/e2e_appium/models/user_model.py new file mode 100644 index 0000000000..506118323c --- /dev/null +++ b/test/e2e_appium/models/user_model.py @@ -0,0 +1,217 @@ +from dataclasses import dataclass, field +from typing import List, Optional, Dict, Any +from datetime import datetime +import uuid +import secrets +import hashlib + + +@dataclass +class WalletAddress: + address: str + network: str = "ethereum" + name: str = "Main Address" + balance: float = 0.0 + is_primary: bool = True + created_at: datetime = field(default_factory=datetime.now) + + def __post_init__(self): + if not self.address: + raise ValueError("Wallet address cannot be empty") + + if self.network.lower() == "ethereum": + if not (len(self.address) == 42 and self.address.startswith("0x")): + raise ValueError(f"Invalid Ethereum address format: {self.address}") + + @classmethod + def generate_ethereum_address(cls, name: str = "Main Address") -> "WalletAddress": + random_bytes = secrets.token_bytes(20) + address = "0x" + random_bytes.hex() + return cls( + address=address, network="ethereum", name=name, balance=0.0, is_primary=True + ) + + +@dataclass +class CryptoWallet: + wallet_id: str = field(default_factory=lambda: str(uuid.uuid4())) + name: str = "Default Wallet" + addresses: List[WalletAddress] = field(default_factory=list) + seed_phrase: Optional[str] = None + is_backed_up: bool = False + created_at: datetime = field(default_factory=datetime.now) + + def __post_init__(self): + if not self.addresses: + self.addresses.append( + WalletAddress.generate_ethereum_address("Main Address") + ) + + def add_address(self, address: WalletAddress) -> None: + if address.is_primary: + for addr in self.addresses: + addr.is_primary = False + self.addresses.append(address) + + def get_primary_address(self) -> Optional[WalletAddress]: + for address in self.addresses: + if address.is_primary: + return address + return self.addresses[0] if self.addresses else None + + def get_addresses_by_network(self, network: str) -> List[WalletAddress]: + return [ + addr for addr in self.addresses if addr.network.lower() == network.lower() + ] + + +@dataclass +class UserProfile: + display_name: str + bio: Optional[str] = None + avatar_path: Optional[str] = None + status_message: Optional[str] = None + is_public: bool = True + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) + + def __post_init__(self): + if not self.display_name or len(self.display_name.strip()) == 0: + raise ValueError("Display name cannot be empty") + + if len(self.display_name) > 50: + raise ValueError("Display name cannot exceed 50 characters") + + def update_display_name(self, new_name: str) -> None: + if not new_name or len(new_name.strip()) == 0: + raise ValueError("Display name cannot be empty") + + self.display_name = new_name.strip() + self.updated_at = datetime.now() + + +@dataclass +class User: + user_id: str = field(default_factory=lambda: str(uuid.uuid4())) + profile: UserProfile = field(default_factory=lambda: UserProfile("Test User")) + password: str = "SecurePass123!" + chat_key: str = field(default_factory=lambda: secrets.token_hex(32)) + crypto_wallet: CryptoWallet = field(default_factory=CryptoWallet) + + private_key: Optional[str] = field(default_factory=lambda: secrets.token_hex(32)) + recovery_phrase: Optional[str] = None + pin_code: Optional[str] = None + biometric_enabled: bool = False + + public_key: str = field(default_factory=lambda: secrets.token_hex(64)) + ens_name: Optional[str] = None + contact_code: str = field(default_factory=lambda: secrets.token_hex(16)) + + is_verified: bool = False + is_active: bool = True + account_created_at: datetime = field(default_factory=datetime.now) + last_login_at: Optional[datetime] = None + + test_context: Dict[str, Any] = field(default_factory=dict) + device_id: Optional[str] = None + environment: str = "test" + + def __post_init__(self): + self._validate_password() + self._generate_derived_keys() + + def _validate_password(self) -> None: + if len(self.password) < 8: + raise ValueError("Password must be at least 8 characters long") + + has_upper = any(c.isupper() for c in self.password) + has_lower = any(c.islower() for c in self.password) + has_digit = any(c.isdigit() for c in self.password) + + if not (has_upper and has_lower and has_digit): + raise ValueError("Password must contain uppercase, lowercase, and digit") + + def _generate_derived_keys(self) -> None: + chat_seed = f"{self.user_id}:chat_key" + self.chat_key = hashlib.sha256(chat_seed.encode()).hexdigest() + + contact_seed = f"{self.user_id}:contact_code" + self.contact_code = hashlib.sha256(contact_seed.encode()).hexdigest()[:16] + + def update_password(self, new_password: str) -> None: + old_password = self.password + self.password = new_password + try: + self._validate_password() + except ValueError: + self.password = old_password + raise + + def login(self) -> None: + self.last_login_at = datetime.now() + self.is_active = True + + def add_wallet_address(self, network: str, name: str = None) -> WalletAddress: + address_name = name or f"{network.title()} Address" + if network.lower() == "ethereum": + new_address = WalletAddress.generate_ethereum_address(address_name) + else: + random_bytes = secrets.token_bytes(20) + address = f"{network[:3]}:" + random_bytes.hex() + new_address = WalletAddress( + address=address, + network=network.lower(), + name=address_name, + is_primary=False, + ) + + self.crypto_wallet.add_address(new_address) + return new_address + + def get_primary_wallet_address(self) -> Optional[WalletAddress]: + return self.crypto_wallet.get_primary_address() + + def to_test_data(self) -> Dict[str, Any]: + primary_address = self.get_primary_wallet_address() + + return { + "user_id": self.user_id, + "display_name": self.profile.display_name, + "password": self.password, + "chat_key": self.chat_key, + "wallet_address": primary_address.address if primary_address else None, + "public_key": self.public_key, + "contact_code": self.contact_code, + "is_verified": self.is_verified, + "created_at": self.account_created_at.isoformat(), + "test_context": self.test_context, + } + + @classmethod + def from_test_data(cls, data: Dict[str, Any]) -> "User": + profile = UserProfile( + display_name=data.get("display_name", "Test User"), + bio=data.get("bio"), + avatar_path=data.get("avatar_path"), + ) + + wallet = CryptoWallet() + if data.get("wallet_address"): + primary_address = WalletAddress( + address=data["wallet_address"], + network=data.get("network", "ethereum"), + name="Main Address", + is_primary=True, + ) + wallet.addresses = [primary_address] + + return cls( + user_id=data.get("user_id", str(uuid.uuid4())), + profile=profile, + password=data.get("password", "SecurePass123!"), + chat_key=data.get("chat_key", secrets.token_hex(32)), + crypto_wallet=wallet, + public_key=data.get("public_key", secrets.token_hex(64)), + is_verified=data.get("is_verified", False), + test_context=data.get("test_context", {}), + ) diff --git a/test/e2e_appium/package.json b/test/e2e_appium/package.json new file mode 100644 index 0000000000..0fc3f3f075 --- /dev/null +++ b/test/e2e_appium/package.json @@ -0,0 +1,17 @@ +{ + "name": "status-e2e-appium", + "version": "1.0.0", + "description": "E2E testing framework for Status Desktop using Appium", + "private": true, + "scripts": { + "install-ci": "npm ci", + "install-deps": "npm install" + }, + "dependencies": { + "@octokit/rest": "^20.0.2" + }, + "devDependencies": {}, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/test/e2e_appium/pages/__init__.py b/test/e2e_appium/pages/__init__.py new file mode 100644 index 0000000000..5e43ba576e --- /dev/null +++ b/test/e2e_appium/pages/__init__.py @@ -0,0 +1,24 @@ +""" +Pages package for Status Desktop tablet E2E tests. +Contains Page Object Model classes for different screens. +""" + +from .base_page import BasePage +from .onboarding import ( + MainAppPage, + WelcomePage, + AnalyticsPage, + CreateProfilePage, + PasswordPage, + SplashScreen, +) + +__all__ = [ + "BasePage", + "MainAppPage", + "WelcomePage", + "AnalyticsPage", + "CreateProfilePage", + "PasswordPage", + "SplashScreen", +] diff --git a/test/e2e_appium/pages/base_page.py b/test/e2e_appium/pages/base_page.py new file mode 100644 index 0000000000..9822da60b2 --- /dev/null +++ b/test/e2e_appium/pages/base_page.py @@ -0,0 +1,357 @@ +import time +import os +import logging +from datetime import datetime +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 get_logger, log_element_action +from core import EnvironmentSwitcher + + +class BasePage: + def __init__(self, driver): + self.driver = 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"] + except Exception: + # Use default timeouts if config unavailable + self.timeouts = { + "element_wait": 30, + "element_click": 5, + "element_find": 10, + "default": 30, + } + element_wait_timeout = self.timeouts["element_wait"] + + self.wait = WebDriverWait(driver, element_wait_timeout) + + self.logger = logging.getLogger(self.__class__.__module__ + '.' + self.__class__.__name__) + + 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) + return WebDriverWait(self.driver, effective_timeout) + + def is_screen_displayed(self, timeout: Optional[int] = None): + return self.is_element_visible(self.IDENTITY_LOCATOR, timeout=timeout) + + 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 + """ + start_time = datetime.now() + locator_str = f"{locator[0]}: {locator[1]}" + + try: + wait = self._create_wait(timeout, "element_wait") + 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) + return element + except Exception: + duration_ms = int((datetime.now() - start_time).total_seconds() * 1000) + 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) + + if timeout is None: + timeout = self.timeouts.get("element_find", 15) + + for loc in locators_to_try: + try: + wait = self._create_wait(timeout, "element_wait") + wait.until(EC.visibility_of_element_located(loc)) + return True + except Exception: + continue + return False + + def safe_click( + self, + locator, + timeout: Optional[int] = None, + fallback_locators: Optional[List[tuple]] = None, + max_attempts: int = 3, + ) -> bool: + """Click an element with retries and optional fallback locators. + + Raises: + RuntimeError: if click fails after retries and fallbacks. + """ + locators_to_try: List[tuple] = [locator] + if fallback_locators: + locators_to_try.extend(fallback_locators) + + for loc in locators_to_try: + attempts = 0 + while attempts < max_attempts: + attempts += 1 + try: + wait = self._create_wait(timeout, "element_click") + element = wait.until(EC.element_to_be_clickable(loc)) + element.click() + log_element_action("click_element", f"{loc[0]}: {loc[1]}", True, 0) + return True + except Exception as e: + self.logger.debug(f"Click attempt {attempts} failed for {loc}: {e}") + if attempts >= max_attempts: + break + 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) + + def safe_input(self, locator, text: str, timeout: Optional[int] = None) -> bool: + """Qt-safe input by delegating to qt_safe_input with retries.""" + try: + return self.qt_safe_input(locator, text, timeout) + except Exception as e: + self.logger.error( + f"Failed to input text '{text}' to element {locator}: {e}" + ) + 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: + wait = self._create_wait(timeout, "element_find") + return wait.until(EC.presence_of_element_located(locator)) + except Exception: + 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 + + 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 + + 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) -> bool: + """Qt/QML-safe text input with proper waiting and retry logic""" + + for attempt in range(max_retries): + 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) + + actions = ActionChains(self.driver) + actions.send_keys(text).perform() + + # Verify input was successful + if self._verify_input_success(element, text): + self.logger.info(f"Qt input successful (attempt {attempt + 1})") + return True + + 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.logger.error(f"Qt input failed after {max_retries} 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 field_is_ready(driver): + try: + # Check if element is enabled and displayed + return element.is_enabled() and element.is_displayed() + except Exception: + return False + + try: + wait = WebDriverWait(self.driver, timeout) # Keep direct usage for custom condition + 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""" + 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 + + # For non-password fields, verify text content + actual_text = element.get_attribute( + "text" + ) # Android UIAutomator2 uses 'text' not 'value' + 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: + # For password fields, we can't check text, so use a minimal delay + # This is still better than a hardcoded sleep + if hasattr(element, "get_attribute"): + text = element.get_attribute( + "text" + ) # Android UIAutomator2 uses 'text' not 'value' + if text == "" or text is None: + return True + + # Small incremental wait + time.sleep(0.1) + except Exception: + # If we can't check, assume it's ready after minimal wait + time.sleep(0.2) + return True + + return True # Always return True to not block the flow diff --git a/test/e2e_appium/pages/onboarding/__init__.py b/test/e2e_appium/pages/onboarding/__init__.py new file mode 100644 index 0000000000..e29f5e0e7b --- /dev/null +++ b/test/e2e_appium/pages/onboarding/__init__.py @@ -0,0 +1,20 @@ +"""Onboarding page objects package (barrel module)""" + +# 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 .seed_phrase_input_page import SeedPhraseInputPage + +__all__ = [ + "WelcomePage", + "AnalyticsPage", + "CreateProfilePage", + "PasswordPage", + "SplashScreen", + "MainAppPage", + "SeedPhraseInputPage", +] diff --git a/test/e2e_appium/pages/onboarding/analytics_page.py b/test/e2e_appium/pages/onboarding/analytics_page.py new file mode 100644 index 0000000000..e2a059755e --- /dev/null +++ b/test/e2e_appium/pages/onboarding/analytics_page.py @@ -0,0 +1,32 @@ +""" +Analytics Page for Status Desktop E2E Testing + +Page object for the analytics consent screen during onboarding. +""" + +from ..base_page import BasePage +from locators.onboarding.analytics_screen_locators import AnalyticsScreenLocators + + +class AnalyticsPage(BasePage): + """Page object for the Help Us Improve Status screen (analytics consent)""" + + def __init__(self, driver): + super().__init__(driver) + + self.locators = AnalyticsScreenLocators() + self.IDENTITY_LOCATOR = self.locators.ANALYTICS_PAGE_BY_CONTENT_DESC + + def click_share_usage_data(self) -> bool: + self.logger.info("Clicking 'Share usage data' button") + return self.safe_click(self.locators.SHARE_USAGE_DATA_BUTTON) + + def click_not_now(self) -> bool: + self.logger.info("Clicking 'Not now' button") + return self.safe_click(self.locators.NOT_NOW_BUTTON) + + def skip_analytics_sharing(self) -> bool: + return self.click_not_now() + + def accept_analytics_sharing(self) -> bool: + return self.click_share_usage_data() diff --git a/test/e2e_appium/pages/onboarding/create_profile_page.py b/test/e2e_appium/pages/onboarding/create_profile_page.py new file mode 100644 index 0000000000..64b78fabc5 --- /dev/null +++ b/test/e2e_appium/pages/onboarding/create_profile_page.py @@ -0,0 +1,34 @@ +""" +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 + + +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) + + 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 diff --git a/test/e2e_appium/pages/onboarding/loading_page.py b/test/e2e_appium/pages/onboarding/loading_page.py new file mode 100644 index 0000000000..d7ccf54fc2 --- /dev/null +++ b/test/e2e_appium/pages/onboarding/loading_page.py @@ -0,0 +1,37 @@ +""" +Loading Page for Status Desktop E2E Testing + +Page object for splash during onboarding. +""" + +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + +from ..base_page import BasePage +from locators.onboarding.loading_screen_locators import LoadingScreenLocators + + +class SplashScreen(BasePage): + """Page object for the Loading/Splash screen during onboarding""" + + def __init__(self, driver): + super().__init__(driver) + self.locators = LoadingScreenLocators() + self.IDENTITY_LOCATOR = self.locators.SPLASH_SCREEN_PARTIAL + + def is_progress_bar_visible(self) -> bool: + return self.is_element_visible(self.locators.PROGRESS_BAR) + + def wait_for_loading_completion(self, timeout: int = 60) -> bool: + """Wait for loading to complete using explicit invisibility wait""" + self.logger.info(f"Waiting for loading completion (timeout: {timeout}s)") + try: + wait = WebDriverWait(self.driver, timeout) + wait.until( + EC.invisibility_of_element_located(self.locators.SPLASH_SCREEN_PARTIAL) + ) + self.logger.info("Loading completed - screen disappeared") + return True + except Exception: + self.logger.warning(f"Loading did not complete within {timeout} seconds") + return False diff --git a/test/e2e_appium/pages/onboarding/main_app_page.py b/test/e2e_appium/pages/onboarding/main_app_page.py new file mode 100644 index 0000000000..3f79fa71aa --- /dev/null +++ b/test/e2e_appium/pages/onboarding/main_app_page.py @@ -0,0 +1,41 @@ +""" +Main App Page for Status Desktop E2E Testing + +Page object for the main application interface after successful onboarding, Shell container. +""" + +from ..base_page import BasePage +from locators.onboarding.main_app_locators import MainAppLocators + + +class MainAppPage(BasePage): + """Page object for the main Status Desktop application after onboarding""" + + def __init__(self, driver): + super().__init__(driver) + self.locators = MainAppLocators() + + def is_main_app_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 is_search_field_visible(self) -> bool: + return self.is_element_visible(self.locators.SEARCH_FIELD) + + def click_wallet_button(self) -> bool: + self.logger.info("Clicking Wallet button") + return self.safe_click(self.locators.WALLET_BUTTON) + + def click_messages_button(self) -> bool: + self.logger.info("Clicking Messages button") + return self.safe_click(self.locators.MESSAGES_BUTTON) + + def click_communities_button(self) -> bool: + self.logger.info("Clicking Communities Portal button") + return self.safe_click(self.locators.COMMUNITIES_BUTTON) + + def click_settings_button(self) -> bool: + self.logger.info("Clicking Settings button") + return self.safe_click(self.locators.SETTINGS_BUTTON) diff --git a/test/e2e_appium/pages/onboarding/password_page.py b/test/e2e_appium/pages/onboarding/password_page.py new file mode 100644 index 0000000000..176164f6b7 --- /dev/null +++ b/test/e2e_appium/pages/onboarding/password_page.py @@ -0,0 +1,52 @@ +""" +Password Page for Status Desktop E2E Testing + +Page object for password creation and confirmation during profile setup. +""" + +import time + +from ..base_page import BasePage +from locators.onboarding.password_screen_locators import PasswordScreenLocators + + +class PasswordPage(BasePage): + """Page object for the Password Creation screen""" + + def __init__(self, driver): + super().__init__(driver) + self.locators = PasswordScreenLocators() + self.IDENTITY_LOCATOR = self.locators.PASSWORD_SCREEN + + def enter_password(self, password: str) -> bool: + self.logger.info("Entering password") + + # Use the new Qt-safe input method from base page + return self.qt_safe_input(self.locators.PASSWORD_INPUT, password) + + def confirm_password(self, password: str) -> bool: + self.logger.info("Confirming password") + + # Use the new Qt-safe input method from base page + return self.qt_safe_input(self.locators.PASSWORD_CONFIRM_INPUT, password) + + def click_confirm_password_button(self) -> bool: + self.logger.info("Clicking confirm password button") + + self.hide_keyboard() + + try: + return self.safe_click(self.locators.CONFIRM_PASSWORD_BUTTON_BY_ID) + except RuntimeError: + self.logger.info("Trying fallback locator for confirm button") + return self.safe_click(self.locators.CONFIRM_PASSWORD_BUTTON) + + def create_password(self, password: str) -> bool: + self.logger.info("Creating password") + + self.enter_password(password) + self.confirm_password(password) + + time.sleep(1) # Brief wait for validation + + return self.click_confirm_password_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 new file mode 100644 index 0000000000..cc1675954a --- /dev/null +++ b/test/e2e_appium/pages/onboarding/seed_phrase_input_page.py @@ -0,0 +1,274 @@ +""" +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): + 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() + else: + words = seed_phrase + + word_count = len(words) + self.logger.info(f"Entering {word_count}-word seed phrase") + + # 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 + + # Select appropriate tab + if not self.select_word_count_tab(word_count): + return False + + # 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}'") + 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}'") + + # 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) + + # 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 + + # 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}'") + + return True + + except Exception as e: + self.logger.error(f"Error entering word {position}: {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, + ] + + for locator in continue_locators: + if self.safe_click(locator): + self.logger.info("โœ… Continue button clicked successfully") + return True + + 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 + + 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 + """ + 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 + + # 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" + ) + return False + + # Click continue to import + if not self.click_continue(): + return False + + self.logger.info("โœ… Seed phrase import completed successfully") + return True diff --git a/test/e2e_appium/pages/onboarding/welcome_page.py b/test/e2e_appium/pages/onboarding/welcome_page.py new file mode 100644 index 0000000000..0307c66e5a --- /dev/null +++ b/test/e2e_appium/pages/onboarding/welcome_page.py @@ -0,0 +1,25 @@ +""" +Welcome Page for Status Desktop E2E Testing + +Page object for the initial welcome screen in the onboarding flow. +""" + +from ..base_page import BasePage +from locators.onboarding.welcome_screen_locators import WelcomeScreenLocators + + +class WelcomePage(BasePage): + """Page object for the Welcome screen""" + + def __init__(self, driver): + super().__init__(driver) + self.locators = WelcomeScreenLocators() + self.IDENTITY_LOCATOR = self.locators.WELCOME_PAGE + + def click_create_profile(self) -> bool: + self.logger.info("Clicking 'Create profile' button") + return self.safe_click(self.locators.CREATE_PROFILE_BUTTON) + + def click_login(self) -> bool: + self.logger.info("Clicking 'Log in' button") + return self.safe_click(self.locators.LOGIN_BUTTON) diff --git a/test/e2e_appium/pytest.ini b/test/e2e_appium/pytest.ini new file mode 100644 index 0000000000..cfc38f80e8 --- /dev/null +++ b/test/e2e_appium/pytest.ini @@ -0,0 +1,13 @@ +[pytest] +addopts = -v --tb=short --strict-markers +markers = + smoke: Quick critical tests for PR validation + onboarding: User onboarding flow tests + e2e: End-to-end integration tests + component: Component-level validation tests + ui_validation: UI element validation tests + tablet: Tablet-specific functionality tests + 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 diff --git a/test/e2e_appium/requirements.txt b/test/e2e_appium/requirements.txt new file mode 100644 index 0000000000..26c9e220e3 --- /dev/null +++ b/test/e2e_appium/requirements.txt @@ -0,0 +1,7 @@ +Appium-Python-Client==5.1.1 +pytest==7.4.3 +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 diff --git a/test/e2e_appium/scripts/commit_status_manager.js b/test/e2e_appium/scripts/commit_status_manager.js new file mode 100644 index 0000000000..4ae0e8a31d --- /dev/null +++ b/test/e2e_appium/scripts/commit_status_manager.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +const { Octokit } = require('@octokit/rest'); + +class CommitStatusManager { + constructor(octokit, context) { + this.octokit = octokit; + this.context = context; + } + + async determineTargetCommit(apkSourceType, buildRunId) { + if (apkSourceType === 'github_artifact' && buildRunId) { + return this.getCommitFromRun(buildRunId); + } + + if (this.isExternalApk(apkSourceType)) { + return null; // Skip status for external APKs + } + + return this.context.sha; // Use current commit + } + + async getCommitFromRun(runId) { + try { + const response = await this.octokit.rest.actions.getWorkflowRun({ + owner: this.context.repo.owner, + repo: this.context.repo.repo, + run_id: runId + }); + return response.data.head_sha; + } catch (error) { + console.warn(`Failed to get commit from run ${runId}: ${error.message}`); + return this.context.sha; + } + } + + isExternalApk(sourceType) { + return ['direct_url', 'lambdatest_app_id'].includes(sourceType); + } + + async setCommitStatus(targetSha, testStatus, runId) { + if (!targetSha) { + console.log('Skipping commit status for external APK'); + return false; + } + + const state = testStatus === 'success' ? 'success' : 'failure'; + const description = `E2E tests ${state}`; + + await this.octokit.rest.repos.createCommitStatus({ + owner: this.context.repo.owner, + repo: this.context.repo.repo, + sha: targetSha, + state, + target_url: `${this.context.serverUrl}/${this.context.repo.owner}/${this.context.repo.repo}/actions/runs/${runId}`, + description, + context: 'e2e/appium-android' + }); + + return true; + } +} + +// CLI usage +if (require.main === module) { + const [testStatus, apkSourceType, buildRunId] = process.argv.slice(2); + + // GitHub Actions provides context via environment + const context = { + repo: { owner: process.env.GITHUB_REPOSITORY_OWNER, repo: process.env.GITHUB_REPOSITORY.split('/')[1] }, + sha: process.env.GITHUB_SHA, + serverUrl: process.env.GITHUB_SERVER_URL, + runId: process.env.GITHUB_RUN_ID + }; + + const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); + const manager = new CommitStatusManager(octokit, context); + + manager.determineTargetCommit(apkSourceType, buildRunId) + .then(targetSha => manager.setCommitStatus(targetSha, testStatus, context.runId)) + .then(success => { + console.log(success ? 'Commit status set successfully' : 'Commit status skipped'); + process.exit(0); + }) + .catch(error => { + console.error('Failed to set commit status:', error.message); + process.exit(1); + }); +} + +module.exports = CommitStatusManager; \ No newline at end of file diff --git a/test/e2e_appium/scripts/generate_test_summary.py b/test/e2e_appium/scripts/generate_test_summary.py new file mode 100644 index 0000000000..e1785021ab --- /dev/null +++ b/test/e2e_appium/scripts/generate_test_summary.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 + +import xml.etree.ElementTree as ET +import argparse +import sys +from pathlib import Path + + +def parse_junit_xml(junit_path): + """Parse JUnit XML file and extract test statistics.""" + try: + tree = ET.parse(junit_path) + root = tree.getroot() + + total = int(root.get("tests", 0)) + failures = int(root.get("failures", 0)) + errors = int(root.get("errors", 0)) + skipped = int(root.get("skipped", 0)) + passed = total - failures - errors - skipped + duration = float(root.get("time", 0)) + + return { + "total": total, + "passed": passed, + "failed": failures + errors, + "skipped": skipped, + "duration": duration, + } + except Exception as e: + print(f"Error parsing JUnit XML: {e}", file=sys.stderr) + return None + + +def generate_summary(results, config): + """Generate markdown summary from test results and configuration.""" + if not results: + return "Test results unavailable" + + status_icon = "โœ…" if results["failed"] == 0 else "โŒ" + + summary = f"""{status_icon} **Test Results Summary** + +**Configuration:** +- APK Source: {config["apk_source_type"]} (`{config["apk_source"]}`) +- Test Selection: {config["test_selection_type"]} (`{config["test_target"]}`) +- Environment: {config["test_environment"]} +- Device: {config["device_name"]} +- Parallel: {config["parallel_execution"]} + +**Results:** +- Total: {results["total"]} +- Passed: {results["passed"]} +- Failed: {results["failed"]} +- Skipped: {results["skipped"]} +- Duration: {results["duration"]:.2f}s + +**Command:** `pytest {config["pytest_args"]}`""" + + if config.get("build_run_id"): + summary += f""" + +**Source Build:** [Run #{config["build_run_id"]}]({config["repo_url"]}/actions/runs/{config["build_run_id"]})""" + + return summary + + +def main(): + parser = argparse.ArgumentParser(description="Generate test summary from JUnit XML") + parser.add_argument("--junit-xml", required=True, help="Path to JUnit XML file") + parser.add_argument("--output", help="Output markdown file path") + parser.add_argument("--apk-source-type", help="APK source type") + parser.add_argument("--apk-source", help="APK source") + parser.add_argument("--test-selection-type", help="Test selection type") + parser.add_argument("--test-target", help="Test target") + parser.add_argument("--test-environment", help="Test environment") + parser.add_argument("--device-name", help="Device name") + parser.add_argument("--parallel-execution", help="Parallel execution enabled") + parser.add_argument("--pytest-args", help="Pytest arguments") + parser.add_argument("--build-run-id", help="Build run ID") + parser.add_argument("--repo-url", help="Repository URL") + + args = parser.parse_args() + + results = parse_junit_xml(args.junit_xml) + + config = { + "apk_source_type": args.apk_source_type or "unknown", + "apk_source": args.apk_source or "unknown", + "test_selection_type": args.test_selection_type or "unknown", + "test_target": args.test_target or "unknown", + "test_environment": args.test_environment or "unknown", + "device_name": args.device_name or "unknown", + "parallel_execution": args.parallel_execution or "false", + "pytest_args": args.pytest_args or "unknown", + "build_run_id": args.build_run_id, + "repo_url": args.repo_url or "https://github.com/status-im/status-desktop", + } + + summary = generate_summary(results, config) + + if args.output: + Path(args.output).write_text(summary) + print(f"Summary written to {args.output}") + else: + print(summary) + + +if __name__ == "__main__": + main() diff --git a/test/e2e_appium/scripts/run_tests.py b/test/e2e_appium/scripts/run_tests.py new file mode 100755 index 0000000000..2b5abfa5c2 --- /dev/null +++ b/test/e2e_appium/scripts/run_tests.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 + +import argparse +import subprocess +import sys +import os +from datetime import datetime +from pathlib import Path +from config import get_config + +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + + + + +def run_command(cmd, description): + print(f"\n๐Ÿš€ {description}") + print(f"Command: {' '.join(cmd)}") + print("-" * 60) + + try: + _ = subprocess.run(cmd, check=True, capture_output=False) + print(f"โœ… {description} completed successfully") + return True + except subprocess.CalledProcessError as e: + print(f"โŒ {description} failed with exit code {e.returncode}") + return False + + +def validate_environment(): + print("\n๐Ÿ” Validating environment configuration") + + try: + config = get_config() + print("โœ… Configuration loaded successfully") + print( + f"Device: {config.device_name} ({config.platform_name} {config.platform_version})" + ) + print(f"LambdaTest User: {config.lt_username}") + print(f"App URL: {config.status_app_url}") + return True + except Exception as e: + print(f"โŒ Configuration validation failed: {e}") + return False + + +def main(): + parser = argparse.ArgumentParser(description="Test Runner with XML/HTML Reports") + parser.add_argument( + "--category", + "-c", + choices=["smoke", "tablet", "critical", "all"], + default="smoke", + help="Test category to run (default: smoke)", + ) + parser.add_argument( + "--parallel", + "-n", + type=int, + default=None, + help="Number of parallel processes (default: from config)", + ) + parser.add_argument( + "--env", + "-e", + choices=["local", "lambdatest", "template", "lt"], + default=None, + help="Environment to run tests in (default: auto-detect)", + ) + parser.add_argument("--config", "-f", help="Custom configuration file path") + parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") + parser.add_argument( + "--retry", "-r", action="store_true", help="Enable retry for flaky tests" + ) + parser.add_argument( + "--test", + "-t", + help="Run specific test (e.g., test_click_create_profile_button)", + ) + parser.add_argument( + "--validate-only", + action="store_true", + help="Only validate configuration, don't run tests", + ) + parser.add_argument( + "--no-xml", action="store_true", help="Disable XML report generation" + ) + parser.add_argument( + "--no-html", action="store_true", help="Disable HTML report generation" + ) + parser.add_argument( + "--reports-dir", help="Custom reports directory (overrides config)" + ) + + args = parser.parse_args() + + if args.env == "lt": + args.env = "lambdatest" + + if args.env: + os.environ["TEST_ENVIRONMENT"] = args.env + + try: + config = get_config() + + if args.reports_dir: + config.reports_dir = args.reports_dir + + if args.no_xml: + config.enable_xml_report = False + if args.no_html: + config.enable_html_report = False + + if not validate_environment(): + print("\n๐Ÿ’ฅ Environment validation failed!") + return 1 + if args.validate_only: + print("\nโœ… Configuration validation completed successfully!") + return 0 + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + reports_dir = Path(config.reports_dir) + reports_dir.mkdir(exist_ok=True) + + cmd = ["python", "-m", "pytest"] + cmd.extend(["--env", args.env or "lambdatest"]) + + if args.category != "all": + cmd.extend(["-m", args.category]) + + parallel_processes = args.parallel or 1 + if parallel_processes > 1: + cmd.extend(["-n", str(parallel_processes)]) + + if args.retry: + cmd.extend(["--reruns", "2", "--reruns-delay", "1"]) + + if args.verbose: + cmd.append("-v") + + if args.test: + cmd.extend(["-k", args.test]) + + xml_file = None + if config.enable_xml_report: + xml_file = reports_dir / f"pytest_results_{timestamp}.xml" + cmd.extend(["--junitxml", str(xml_file)]) + + html_file = None + if config.enable_html_report: + html_file = reports_dir / f"pytest_report_{timestamp}.html" + cmd.extend(["--html", str(html_file), "--self-contained-html"]) + + if "test/e2e_appium/tests" in cmd: + cmd.remove("test/e2e_appium/tests") + cmd.append("test/e2e_appium/tests") + + print("=" * 60) + print("๐ŸŽฏ E2E TEST RUNNER") + print("=" * 60) + print(f"Environment: {args.env or 'lambdatest'}") + print(f"Category: {args.category}") + print(f"Parallel: {parallel_processes} processes") + print(f"Retry: {'Enabled' if args.retry else 'Disabled'}") + print(f"Verbose: {'Yes' if args.verbose else 'No'}") + if args.test: + print(f"Specific Test: {args.test}") + print("Reports:") + if config.enable_xml_report: + print(f" ๐Ÿ“„ XML (JUnit): {xml_file}") + if config.enable_html_report: + print(f" ๐ŸŒ HTML: {html_file}") + print("=" * 60) + + success = run_command(cmd, f"Running {args.category} tests") + if success: + print("\n๐ŸŽ‰ All tests completed successfully!") + print("๐Ÿ“Š Generated Reports:") + if config.enable_xml_report and xml_file and xml_file.exists(): + print(f" โœ… XML Report: {xml_file}") + if config.enable_html_report and html_file and html_file.exists(): + print(f" โœ… HTML Report: {html_file}") + return 0 + else: + print("\n๐Ÿ’ฅ Some tests failed!") + print("๐Ÿ“Š Reports available for analysis:") + if config.enable_xml_report and xml_file and xml_file.exists(): + print(f" ๐Ÿ“„ XML Report: {xml_file}") + if config.enable_html_report and html_file and html_file.exists(): + print(f" ๐ŸŒ HTML Report: {html_file}") + return 1 + except ValueError as e: + print(f"\n๐Ÿ’ฅ Configuration error: {e}") + return 1 + except Exception as e: + print(f"\n๐Ÿ’ฅ Unexpected error: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/e2e_appium/scripts/upload_apk_to_lambdatest.py b/test/e2e_appium/scripts/upload_apk_to_lambdatest.py new file mode 100755 index 0000000000..634a3ecbd2 --- /dev/null +++ b/test/e2e_appium/scripts/upload_apk_to_lambdatest.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +Upload APK to LambdaTest and return the app URL. +Simple script for GitHub Actions integration. +""" + +import os +import sys +import argparse +import requests +from pathlib import Path + + +def upload_apk_to_lambdatest(apk_path, app_name, username, access_key): + """Upload APK to LambdaTest and return app URL.""" + + if not Path(apk_path).exists(): + raise FileNotFoundError(f"APK file not found: {apk_path}") + + url = "https://manual-api.lambdatest.com/app/upload/virtualDevice" + + print(f"๐Ÿš€ Uploading {apk_path} to LambdaTest...") + print(f" App name: {app_name}") + + with open(apk_path, "rb") as f: + files = { + "appFile": ( + Path(apk_path).name, + f, + "application/vnd.android.package-archive", + ) + } + + data = {"name": app_name, "type": "android"} + + response = requests.post( + url, + files=files, + data=data, + auth=(username, access_key), + timeout=300, # 5 minutes timeout + ) + + if response.status_code == 200: + result = response.json() + app_url = result.get("app_url") + if app_url: + print("โœ… Upload successful!") + print(f" App URL: {app_url}") + print(f" App ID: {result.get('app_id', 'N/A')}") + + # Output for GitHub Actions + if os.getenv("GITHUB_ACTIONS"): + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"app_url={app_url}\n") + f.write(f"app_id={result.get('app_id', '')}\n") + + return app_url + else: + raise Exception("Upload succeeded but no app_url in response") + else: + try: + error_details = response.json() + error_msg = error_details.get("message", response.text) + except Exception: + error_msg = response.text + raise Exception(f"Upload failed: {response.status_code} - {error_msg}") + + +def main(): + parser = argparse.ArgumentParser(description="Upload APK to LambdaTest") + parser.add_argument("--apk-path", required=True, help="Path to APK file") + parser.add_argument("--app-name", required=True, help="App name in LambdaTest") + + args = parser.parse_args() + + # Get credentials from environment + username = os.getenv("LT_USERNAME") + access_key = os.getenv("LT_ACCESS_KEY") + + if not username or not access_key: + print("โŒ Missing LambdaTest credentials (LT_USERNAME, LT_ACCESS_KEY)") + sys.exit(1) + + try: + app_url = upload_apk_to_lambdatest( + args.apk_path, args.app_name, username, access_key + ) + print(f"Success: {app_url}") + except Exception as e: + print(f"โŒ Failed: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/test/e2e_appium/tests/__init__.py b/test/e2e_appium/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/e2e_appium/tests/base_test.py b/test/e2e_appium/tests/base_test.py new file mode 100644 index 0000000000..5da870b350 --- /dev/null +++ b/test/e2e_appium/tests/base_test.py @@ -0,0 +1,163 @@ +import os +from functools import wraps + +from core import SessionManager +from config.logging_config import get_logger + +# Constants +CLOUD_ENVIRONMENTS = ["lt", "lambdatest"] + + +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'): + self.report_test_result(passed=True) + return result + except Exception as e: + if hasattr(self, 'report_test_result'): + error_msg = str(e) + self.report_test_result(passed=False, error_message=error_msg) + raise + + return wrapper + + + +class BaseTest: + def setup_method(self, method): + # Initialize logger for test instance + self.logger = get_logger("tests") + + # Initialize test tracking + self._result_reported = False + + # Get environment from pytest config or environment variable + test_env = os.getenv("CURRENT_TEST_ENVIRONMENT", "lambdatest") + + # Try to get environment from pytest if available + 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() + self.test_name = method.__name__ + + if not hasattr(self.__class__, "_active_drivers"): + self.__class__._active_drivers = {} + self.__class__._active_drivers[id(self)] = self.driver + + try: + self.session_id = self.driver.session_id + except Exception: + self.session_id = "unknown_session" + + def teardown_method(self, method): + if hasattr(self, "session_manager") and self.driver: + try: + # Validate explicit reporting was used for cloud tests + self._validate_result_reporting() + + except Exception as e: + logger = get_logger("session") + logger.error( + f"โš ๏ธ Error in teardown: {e}", + extra={"error": str(e), "test_name": self.test_name}, + ) + finally: + # Remove from active drivers dict before cleanup + if hasattr(self.__class__, "_active_drivers"): + self.__class__._active_drivers.pop(id(self), None) + + # Clean up driver + self.session_manager.cleanup_driver() + + 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): + """ + Report test result to LambdaTest. + + Args: + passed: Whether the test passed (for backward compatibility) + error_message: Optional error message for failed tests + status: Direct status override ("passed", "failed", "error", "unknown", "skipped", "ignored") + """ + self._result_reported = True + + if self.driver and self.session_manager.environment in CLOUD_ENVIRONMENTS: + try: + final_status = status or ("passed" if passed else "failed") + self._report_to_lambdatest( + 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()}") + except Exception as e: + logger = get_logger("session") + logger.error(f"โš ๏ธ Failed to report result to LambdaTest: {e}") + + @classmethod + def _report_to_lambdatest(cls, driver, test_name, status, error_message=None): + logger = get_logger("session") + + try: + driver.execute_script(f"lambda-status={status}") + except Exception: + pass + try: + driver.execute_script(f"lambda-name={test_name}") + except Exception: + pass + + # Optional description (not supported on all drivers) + is_passed = status == "passed" + if not is_passed and error_message: + try: + clean_error = error_message.replace('"', '\\"').replace("\n", "\\n")[ + :500 + ] + driver.execute_script(f"lambda-description=Test failed: {clean_error}") + except Exception: + pass + + log_data = { + "test_name": test_name, + "lambdatest_status": status, + "success": is_passed, + } + if error_message: + log_data["error_message"] = error_message[:200] + + if is_passed: + logger.info(f"โœ… LambdaTest Report: {test_name} = PASSED", extra=log_data) + else: + logger.warning( + f"โŒ LambdaTest Report: {test_name} = FAILED", extra=log_data + ) + if error_message: + logger.error( + f" Error Details: {error_message[:200]}...", + extra={"test_name": test_name, "full_error": error_message}, + ) + + @classmethod + 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 diff --git a/test/e2e_appium/tests/test_onboarding_flow.py b/test/e2e_appium/tests/test_onboarding_flow.py new file mode 100644 index 0000000000..5d6e08b44e --- /dev/null +++ b/test/e2e_appium/tests/test_onboarding_flow.py @@ -0,0 +1,63 @@ +""" +Status Desktop E2E Onboarding Flow Tests + +This module contains tests for the complete onboarding flow, including both +fixture-based tests and component validation tests. +""" + +import pytest +from tests.base_test import BaseTest, lambdatest_reporting + + +class TestOnboardingFlow(BaseTest): + """Test class for onboarding flow functionality""" + + @pytest.mark.smoke + @pytest.mark.onboarding + @pytest.mark.e2e + @lambdatest_reporting + @pytest.mark.onboarding_config( + custom_display_name="E2E_TestUser", + skip_analytics=True, + validate_each_step=True, + take_screenshots=False, + ) + def test_onboarding_new_password_skip_analytics(self, onboarded_user): + """ + Test the onboarding flow using the onboarding fixture. + + """ + + result = onboarded_user + + # Validate results + assert result["success"], "Onboarding flow should complete successfully" + assert "user_data" in result, "Result should contain user data" + assert result["user_data"]["display_name"] == "E2E_TestUser", ( + "Should use custom display name" + ) + + # Validate all expected steps were completed + expected_steps = [ + "welcome_screen", + "analytics_screen", + "password_screen", + "loading_screen", + "main_app_verification", + ] + completed_steps = result["steps_completed"] + + for step in expected_steps: + assert step in completed_steps, f"Step '{step}' should be completed" + # Validate analytics action matches config + assert result["step_results"]["analytics_screen"]["action"] == "skipped" + + self.logger.info("Complete onboarding flow test with fixture passed!") + + @pytest.mark.onboarding + @lambdatest_reporting + @pytest.mark.onboarding_config(custom_display_name="E2E_TestUser") + def test_onboarding_lands_on_main_app(self, onboarded_app): + app = onboarded_app + assert app.is_main_app_loaded() + assert app.user_data["display_name"] == "E2E_TestUser" diff --git a/test/e2e_appium/utils/__init__.py b/test/e2e_appium/utils/__init__.py new file mode 100644 index 0000000000..7b3735cad9 --- /dev/null +++ b/test/e2e_appium/utils/__init__.py @@ -0,0 +1,11 @@ +from .generators import ( + generate_seed_phrase, + generate_12_word_seed_phrase, + generate_24_word_seed_phrase, +) + +__all__ = [ + "generate_seed_phrase", + "generate_12_word_seed_phrase", + "generate_24_word_seed_phrase", +] diff --git a/test/e2e_appium/utils/generators.py b/test/e2e_appium/utils/generators.py new file mode 100644 index 0000000000..896d6008a8 --- /dev/null +++ b/test/e2e_appium/utils/generators.py @@ -0,0 +1,35 @@ +import random +from typing import Optional +from eth_account.hdaccount import generate_mnemonic, Mnemonic + + +def generate_seed_phrase(word_count: Optional[int] = None) -> str: + """Generate a valid BIP39 seed phrase. + + Args: + word_count: Number of words in the seed phrase (12, 18, or 24). + If None, randomly selects from [12, 18, 24]. + + Returns: + Valid BIP39 seed phrase as a string. + """ + if word_count is None: + word_count = random.choice([12, 18, 24]) + + if word_count not in [12, 18, 24]: + raise ValueError("word_count must be 12, 18, or 24") + + words = "" + while not Mnemonic().is_mnemonic_valid(mnemonic=words): + words = generate_mnemonic(num_words=word_count, lang="english") + return words + + +def generate_12_word_seed_phrase() -> str: + """Generate a 12-word seed phrase.""" + return generate_seed_phrase(12) + + +def generate_24_word_seed_phrase() -> str: + """Generate a 24-word seed phrase.""" + return generate_seed_phrase(24) diff --git a/test/e2e_appium/utils/lambdatest_reporter.py b/test/e2e_appium/utils/lambdatest_reporter.py new file mode 100644 index 0000000000..a43c2a21d8 --- /dev/null +++ b/test/e2e_appium/utils/lambdatest_reporter.py @@ -0,0 +1,80 @@ +""" +LambdaTest result reporting utilities. + +Dedicated module for handling LambdaTest test result reporting +to keep conftest.py focused on pytest configuration. +""" + +from config.logging_config import get_logger + + +class LambdaTestReporter: + """Handles LambdaTest test result reporting.""" + + @staticmethod + def report_test_result(item, test_report): + """ + Emergency backup LambdaTest result reporting via pytest hooks. + + Only reports if BaseTest teardown didn't handle it (e.g., test crashed). + Prevents double reporting by checking if reporting already occurred. + + Args: + item: pytest test item + test_report: pytest test report + """ + test_name = item.name + test_passed = test_report.passed + error_message = None + + if test_report.failed and hasattr(test_report, "longrepr"): + error_message = str(test_report.longrepr) + + try: + from ..tests.base_test import BaseTest + + test_instance_id = id(item.instance) if hasattr(item, "instance") else None + + if test_instance_id: + # Check if BaseTest already handled reporting + test_instance = item.instance if hasattr(item, "instance") else None + if ( + test_instance + and hasattr(test_instance, "_result_reported") + and test_instance._result_reported + ): + # BaseTest already handled reporting, skip + logger = get_logger("session") + logger.debug( + f"Skipping pytest hook reporting for {test_name} - already reported by BaseTest" + ) + return + + driver = BaseTest.get_active_driver_for_test(test_instance_id) + if driver: + BaseTest._report_to_lambdatest( + driver, test_name, test_passed, error_message + ) + logger = get_logger("session") + logger.info( + f"๐Ÿ“‹ Fallback result reporting: {test_name} = {'PASSED' if test_passed else 'FAILED'}" + ) + else: + # Only warn for failed tests - passed tests may have already cleaned up + if not test_passed: + logger = get_logger("session") + logger.warning( + f"โš ๏ธ No active driver found for failed test: {test_name}" + ) + else: + # Only warn for failed tests - passed tests may have already cleaned up + if not test_passed: + logger = get_logger("session") + logger.warning(f"โš ๏ธ No test instance found for failed test: {test_name}") + + except Exception as e: + logger = get_logger("session") + logger.error( + f"โš ๏ธ Error reporting to LambdaTest: {e}", + extra={"test_name": test_name, "error": str(e)}, + )