test(e2e_appium): add e2e_appium (#18366)

initial setup of test framework for new tablet and mobile builds
This commit is contained in:
Mag.
2025-08-19 14:50:36 +01:00
committed by GitHub
parent fbd08b565c
commit e94c79dc0a
59 changed files with 6416 additions and 0 deletions
+253
View File
@@ -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
+23
View File
@@ -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
+115
View File
@@ -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/
+173
View File
@@ -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
View File
+109
View File
@@ -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()
+177
View File
@@ -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
View File
@@ -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()
+24
View File
@@ -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
+387
View File
@@ -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
}
}
}
}
}
+269
View File
@@ -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()
+244
View File
@@ -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)
+11
View File
@@ -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",
]
+121
View File
@@ -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"
+133
View File
@@ -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
+185
View File
@@ -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}
+40
View File
@@ -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,
)
+24
View File
@@ -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",
]
+68
View File
@@ -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")
+15
View File
@@ -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",
]
+322
View File
@@ -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)
+217
View File
@@ -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", {}),
)
+17
View File
@@ -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"
}
}
+24
View File
@@ -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",
]
+357
View File
@@ -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)
+13
View File
@@ -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
+7
View File
@@ -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()
+203
View File
@@ -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
View File
@@ -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()
View File
+163
View File
@@ -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"
+11
View File
@@ -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",
]
+35
View File
@@ -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)},
)