mirror of
https://github.com/status-im/status-app.git
synced 2026-08-27 07:01:14 +00:00
test(e2e_appium): add e2e_appium (#18366)
initial setup of test framework for new tablet and mobile builds
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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
|
||||
Executable
+109
@@ -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()
|
||||
@@ -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())
|
||||
+229
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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}']")
|
||||
@@ -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")
|
||||
@@ -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')]")
|
||||
@@ -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")
|
||||
@@ -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')]")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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}")
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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", {}),
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
Executable
+203
@@ -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())
|
||||
+96
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)},
|
||||
)
|
||||
Reference in New Issue
Block a user