diff --git a/.github/workflows/smoke_tests.yaml b/.github/workflows/smoke_tests.yaml new file mode 100644 index 000000000..6c8c28dfe --- /dev/null +++ b/.github/workflows/smoke_tests.yaml @@ -0,0 +1,278 @@ +name: Core Extension E2E Smoke Tests + +on: + workflow_run: + workflows: ['CI for main'] + types: [completed] + branches: [main] + workflow_dispatch: + inputs: + test-run-type: + type: choice + description: 'Run smoke tests or full regression?' + options: + - smoke + - full + required: true + default: 'smoke' + push: + branches: + - main + pull_request: + branches: + - main + types: + - opened + - reopened + - synchronize + - ready_for_review + +permissions: + id-token: write + contents: read + +jobs: + build-extension: + name: Build Core Extension + runs-on: ubuntu-latest-16-cores-core-extension + if: '${{ !github.event.pull_request.draft }}' + environment: alpha + env: + RUNNER: CI + LOG_LEVEL: info + POST_TO_TESTRAIL: true + TESTRAIL_API_KEY: '${{ secrets.TESTRAIL_API_KEY }}' + outputs: + test_run_id: ${{ steps.trid.outputs.test_run_id }} + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.14.0 + check-latest: true + + - name: Enable corepack + run: corepack enable + + - name: Checkout extension + uses: actions/checkout@v4 + with: + ref: '${{ github.head_ref }}' + fetch-depth: 0 + + - name: Create .npmrc + run: echo '//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}' >> .npmrc + + - name: Create env file + run: | + touch .env.production + echo RELEASE=alpha >> .env.production + echo POSTHOG_KEY=${{ secrets.POSTHOG_KEY }} >> .env.production + echo POSTHOG_URL=${{ secrets.POSTHOG_URL }} >> .env.production + echo COVALENT_API_KEY=${{ secrets.COVALENT_API_KEY }} >> .env.production + echo GLACIER_URL=${{ secrets.GLACIER_URL }} >> .env.production + echo PROXY_URL=${{ secrets.PROXY_URL }} >> .env.production + echo CORE_EXTENSION_LANDING_URL=${{ secrets.CORE_EXTENSION_LANDING_URL }} >> .env.production + echo SENTRY_DSN=${{ secrets.SENTRY_DSN }} >> .env.production + echo COINBASE_APP_ID=${{ secrets.COINBASE_APP_ID }} >> .env.production + echo CORE_WEB_BASE_URL=${{ secrets.CORE_WEB_BASE_URL }} >> .env.production + echo GLACIER_API_KEY=${{ secrets.GLACIER_API_KEY }} >> .env.production + echo FIREBASE_CONFIG=${{ secrets.FIREBASE_CONFIG }} >> .env.production + echo ID_SERVICE_URL=${{ secrets.ID_SERVICE_URL }} >> .env.production + echo GASLESS_SERVICE_URL=${{ secrets.GASLESS_SERVICE_URL }} >> .env.production + echo EXTENSION_PUBLIC_KEY=${{ secrets.EXTENSION_PUBLIC_KEY_E2E }} >> .env.production + echo ID_SERVICE_API_KEY=${{ secrets.ID_SERVICE_API_KEY }} >> .env.production + echo NOTIFICATION_SENDER_SERVICE_URL=${{ secrets.NOTIFICATION_SENDER_SERVICE_URL }} >> .env.production + + - name: Install dependencies + run: | + yarn install + yarn allow-scripts + + - name: Build extension + run: yarn build:next:alpha + + - name: Inject extension key + run: | + echo $(cat ./dist-next/manifest.json | jq '.key = "${{ secrets.EXTENSION_PUBLIC_KEY_E2E }}"') > ./dist-next/manifest.json + + - name: Upload built extension + uses: actions/upload-artifact@v4 + with: + name: extension + path: ./dist-next + + - name: Install TestRail CLI + run: pip3 install trcli + + - name: Create test run in TestRail + id: trid + run: | + TS=$(date -u +'%Y-%m-%d-%H:%M:%S') + TEST_RUN_ID=`trcli -y -h https://avalabs.testrail.io --project "New Gen Core Extension" --username ${{ secrets.TESTRAIL_EMAIL }} --key ${{ secrets.TESTRAIL_API_KEY }} add_run --title "New Gen Core Extension - $TS" --run-include-all | grep "run_id:" | awk '{print $2}'` + echo "test_run_id=$TEST_RUN_ID" >> $GITHUB_OUTPUT + + download-snapshots: + name: Download Test Snapshots from S3 + needs: build-extension + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + env: + AWS_REGION: us-east-1 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1.7.0 + with: + role-to-assume: arn:aws:iam::975050371175:role/github-flow-sa-role + role-session-name: GitHub_to_AWS_via_FederatedOIDC + aws-region: ${{ env.AWS_REGION }} + + - name: Set Python for AWS CLI + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install AWS dependencies + shell: bash + run: | + python3 -m venv ~/py_env + source ~/py_env/bin/activate + pip3 install awscli + + - name: Download Extension snapshots from AWS S3 + shell: bash + run: | + mkdir -p snapshots + source ~/py_env/bin/activate + aws s3 sync s3://core-qa-automation-snapshots/ext/ ./snapshots/ || echo "No snapshots found in S3, continuing..." + ls -la ./snapshots/ + + - name: Upload snapshots as artifact + uses: actions/upload-artifact@v4 + with: + name: test-snapshots + path: ./snapshots/ + if-no-files-found: warn + + playwright-tests: + name: Run Extension E2E Tests - Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + needs: [build-extension, download-snapshots] + runs-on: ubuntu-latest-16-cores-core-extension + permissions: + id-token: write + contents: read + environment: alpha + strategy: + fail-fast: false + matrix: + # Parallel execution with 3 shards on 16-core runner + # Each shard runs with 4 workers for optimal performance + # Adjust shardTotal to increase/decrease parallelism + shardIndex: [1, 2, 3] + shardTotal: [3] + env: + AWS_REGION: us-east-1 + TEST_RUN_ID: ${{ needs.build-extension.outputs.test_run_id }} + RUNNER: CI + LOG_LEVEL: info + HEADLESS: 'true' + WORKERS: '4' # Number of parallel workers per shard (4 CPUs allocated per container) + WALLET_PASSWORD: '${{ secrets.WALLET_PASSWORD }}' + RECOVERY_PHRASE_12_WORDS: '${{ secrets.RECOVERY_PHRASE_12_WORDS }}' + RECOVERY_PHRASE_24_WORDS: '${{ secrets.RECOVERY_PHRASE_24_WORDS }}' + container: + image: mcr.microsoft.com/playwright:v1.52.0-noble + # Optimized for parallel execution: 4 CPUs + 4 workers per shard + options: --ipc=host --security-opt=seccomp=unconfined --cap-add=SYS_ADMIN --shm-size=2gb --memory=4g --cpus=4 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Chrome and Python + working-directory: e2e-playwright-tests + run: | + apt-get update + apt-get install -y wget gnupg python3-pip python3-venv + wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - + echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google.list + apt-get update + apt-get install -y google-chrome-stable + + - name: Download built extension + uses: actions/download-artifact@v4 + with: + name: extension + path: ./e2e-playwright-tests/dist + + - name: Download test snapshots + uses: actions/download-artifact@v4 + with: + name: test-snapshots + path: ./e2e-playwright-tests/helpers/storage-snapshots + + - name: Output Github runner + run: | + curl ifconfig.io + + - name: Create test environment file + run: | + cd e2e-playwright-tests + touch .env + echo "WALLET_PASSWORD=${{ secrets.WALLET_PASSWORD }}" >> .env + echo "RECOVERY_PHRASE_12_WORDS=${{ secrets.RECOVERY_PHRASE_12_WORDS }}" >> .env + echo "RECOVERY_PHRASE_24_WORDS=${{ secrets.RECOVERY_PHRASE_24_WORDS }}" >> .env + + - name: Install Playwright dependencies + working-directory: e2e-playwright-tests + run: | + npm install + npx playwright install --with-deps chromium + + - name: Run Playwright smoke tests + if: github.event.inputs.test-run-type == 'smoke' || github.event.inputs.test-run-type == '' || github.event_name == 'push' || github.event_name == 'pull_request' || github.event_name == 'workflow_run' + working-directory: e2e-playwright-tests + env: + CI: true + run: | + GREP_FILTER=$([ -z "${{ github.event.inputs.test-run-type }}" ] || [ "${{ github.event.inputs.test-run-type }}" = "smoke" ] && echo "--grep=@smoke" || echo "") + PLAYWRIGHT_SHARD=${{ matrix.shardIndex }} xvfb-run --auto-servernum --server-args='-screen 0 1280x1024x24' npx playwright test --config=config/base.config.ts --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} $GREP_FILTER + + - name: Run Playwright full regression tests + if: github.event.inputs.test-run-type == 'full' + working-directory: e2e-playwright-tests + env: + CI: true + run: | + PLAYWRIGHT_SHARD=${{ matrix.shardIndex }} xvfb-run --auto-servernum --server-args='-screen 0 1280x1024x24' npx playwright test --config=config/base.config.ts --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + + - name: Upload results to TestRail + if: always() + shell: bash + working-directory: e2e-playwright-tests + run: | + python3 -m venv ~/py_env + source ~/py_env/bin/activate + pip3 install trcli + trcli -y -h https://avalabs.testrail.io --project "New Gen Core Extension" --username ${{ secrets.TESTRAIL_EMAIL }} --key ${{ secrets.TESTRAIL_API_KEY }} parse_junit -f ./tests/test-results/junit-report-${{ matrix.shardIndex }}.xml --run-id=${{ env.TEST_RUN_ID }} --case-matcher property + + - name: Output TestRail run link + if: always() + run: echo https://avalabs.testrail.io/index.php?/runs/view/${{ env.TEST_RUN_ID }} + + - name: Upload files as artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.shardIndex }} + path: | + e2e-playwright-tests/tests/test-results + e2e-playwright-tests/playwright-report + retention-days: 30 diff --git a/.gitignore b/.gitignore index 8acca9389..906ea3dce 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,8 @@ dist-next # CLI .sentryclirc + +# Python +.venv/ +__pycache__/ +*.pyc diff --git a/e2e-playwright-tests/.env.example b/e2e-playwright-tests/.env.example new file mode 100644 index 000000000..9f7eb9b98 --- /dev/null +++ b/e2e-playwright-tests/.env.example @@ -0,0 +1,16 @@ +# E2E Test Configuration +# Copy this file to .env and update with your actual values + +# Wallet Configuration +WALLET_PASSWORD="your-wallet-password" + +# Valid recovery phrases for successful onboarding tests +# These should be valid BIP39 mnemonics +RECOVERY_PHRASE_12_WORDS="your 12 word recovery phrase here" +RECOVERY_PHRASE_24_WORDS="your 24 word recovery phrase here" + +# Test Execution +HEADLESS=false +CI=false +EXTENSION_ID= +EXTENSION_PATH= \ No newline at end of file diff --git a/e2e-playwright-tests/.gitignore b/e2e-playwright-tests/.gitignore new file mode 100644 index 000000000..69824f834 --- /dev/null +++ b/e2e-playwright-tests/.gitignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules/ +package-lock.json + +# Test results +test-results/ +playwright-report/ +playwright/.cache/ + +# Screenshots +test-results/screenshots/ + +# User data +user-data-dir/ + +# Wallet snapshots (stored in S3, not in repo) +helpers/storage-snapshots/*.ts + +# Environment +.env +.env.local + +# Build output +dist/ +tsconfig.tsbuildinfo + +# OS +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*.mdc + +# Cursor IDE +.cursor/ +.cursor/** + diff --git a/e2e-playwright-tests/.prettierrc.json b/e2e-playwright-tests/.prettierrc.json new file mode 100644 index 000000000..e807a228e --- /dev/null +++ b/e2e-playwright-tests/.prettierrc.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "bracketSpacing": true, + "bracketSameLine": true, + "arrowParens": "always", + "endOfLine": "lf", + "proseWrap": "preserve", + "overrides": [ + { + "files": ["*.md"], + "options": { + "printWidth": 80 + } + }, + { + "files": ["*.yml", "*.yaml", "*.json", "*.json5", "package.json"], + "options": { + "tabWidth": 2, + "singleQuote": false + } + }, + { + "files": ["*.ts", "*.tsx"], + "options": { + "singleQuote": true + } + } + ] +} diff --git a/e2e-playwright-tests/README.md b/e2e-playwright-tests/README.md new file mode 100644 index 000000000..8391a1829 --- /dev/null +++ b/e2e-playwright-tests/README.md @@ -0,0 +1,284 @@ +# E2E Playwright Tests - Core Extension + +Complete end-to-end testing framework for the Core Extension using Playwright. + +#### 1. Install Dependencies + +```bash +cd e2e-playwright-tests +npm install +npx playwright install chromium +``` + +#### 2. Verify Extension Location + +The extension must be in `e2e-playwright-tests/dist/`: + +````bash +# Verify manifest exists +ls -la dist/manifest.json + +#### 3. Environment Variables (Required) + +Create `.env` file from the template: + +```bash +cp .env.example .env +```` + +Then edit `.env` and add your sensitive data: + +````bash +# .env +# REQUIRED: Wallet password for tests +WALLET_PASSWORD="#######" + +# REQUIRED: Valid BIP39 recovery phrases for onboarding tests +RECOVERY_PHRASE_12_WORDS="your 12 word recovery phrase here" +RECOVERY_PHRASE_24_WORDS="your 24 word recovery phrase here" + + +### Verification + +Verify everything is set up correctly: + +```bash +# Check dependencies installed +ls node_modules/@playwright && echo "✓ Playwright installed" + +# Check no workspace references +grep "workspace:" package.json || echo "✓ No workspace dependencies" + +# Check tests are discoverable +npx playwright test --list + +# Should show: +# Total: 1 test in 1 file +```` + +## Framework Structure + +``` +e2e-playwright-tests/ +├── node_modules/ # Local dependencies (npm) +├── package.json # Independent package file +├── tsconfig.json # Standalone TypeScript config +├── playwright.config.ts # Playwright configuration +├── .npmrc # npm settings +├── .gitignore # Git ignore rules +│ +├── dist/ # Extension to test +│ └── manifest.json # Extension manifest +│ +├── tests/ # Test files +│ └── basic-launch.spec.ts # Basic launch test +│ +├── pages/ # Page Object Models +│ └── extension/ +│ ├── BasePage.ts # Base page class +│ └── OnboardingPage.ts # Onboarding page +│ +├── fixtures/ # Playwright fixtures +│ └── extension.fixture.ts # Extension fixtures +│ +├── helpers/ # Helper utilities +│ ├── extensionHelpers.ts # Extension operations +│ ├── walletHelpers.ts # Wallet operations +│ ├── waits.ts # Wait utilities +│ └── storage-snapshots/ # Wallet snapshots +│ +├── config/ # Test configurations +│ ├── base.config.ts # Base config +│ ├── local.config.ts # Local config +│ ├── staging.config.ts # Staging config +│ ├── develop.config.ts # Develop config +│ └── global-setup.ts # Global setup +│ +└── test-results/ # Test output + └── screenshots/ # Failure screenshots +``` + +--- + +## Running Tests + +### Quick Start + +```bash +# Run all tests (parallel, default configuration) +npm test + +# Run smoke tests only +npm run test:smoke + +# Run with UI mode (interactive) +npm run test:ui + +# Run in headed mode (see browser) +npm run test:headed + +# Debug a specific test +npm run test:debug +``` + +### Parallel Testing + +The test suite supports parallel execution for faster results. See [PARALLEL_TESTING.md](./PARALLEL_TESTING.md) for detailed documentation. + +**Local Development:** + +```bash +# Run with all available cores (default) +npm test + +# Run with 4 parallel workers +npm run test:parallel + +# Run with maximum parallelization +npm run test:parallel:max + +# Run sequentially (no parallelization) +npm run test:sequential + +# Simulate CI shard locally +npm run test:shard +``` + +**Performance:** + +- **Sequential**: ~15-20 minutes for smoke tests +- **Parallel (4 workers)**: ~5-7 minutes for smoke tests +- **CI (3 shards × 4 workers)**: ~4-6 minutes for smoke tests + +**CI Configuration:** + +- 3 parallel shards distribute test files +- 4 workers per shard for parallel execution +- Total: up to 12 tests running simultaneously +- Optimized for 16-core GitHub Actions runner + +### Advanced Test Execution + +```bash +# Run specific test file +npx playwright test tests/onboarding.spec.ts + +# Run tests matching pattern +npx playwright test --grep="@smoke" + +# Run with specific browser +npx playwright test --project=chromium + +# Run with custom workers +npx playwright test --workers=8 + +# Run specific shard (1 of 3) +npx playwright test --shard=1/3 + +# Generate HTML report +npm run report +``` + +--- + +## Writing Tests + +### Basic Test Template + +```typescript +import { test, expect } from '../fixtures/extension.fixture'; +import { OnboardingPage } from '../pages/extension/OnboardingPage'; + +test.describe('My Feature', () => { + test('should do something', async ({ extensionPage }) => { + // Extension starts fresh by default + const onboardingPage = new OnboardingPage(extensionPage); + + // Your test logic + await expect(onboardingPage.welcomeTitle).toBeVisible(); + }); +}); +``` + +### Fresh Extension (Default Behavior) + +All tests start with a **fresh extension** by default - no wallet loaded: + +```typescript +test('test onboarding', async ({ extensionPage }) => { + // Extension is fresh - perfect for onboarding tests + const onboardingPage = new OnboardingPage(extensionPage); + await onboardingPage.isOnWelcomeScreen(); // true +}); +``` + +### Using Wallet Snapshots (Optional) + +If you need a pre-configured wallet: + +```typescript +test('test with wallet', async ({ unlockedExtensionPage }, testInfo) => { + // Load a wallet snapshot + testInfo.annotations.push({ type: 'snapshot', description: 'example' }); + + // Wallet is already set up + const homePage = new HomePage(unlockedExtensionPage); + await expect(homePage.totalBalance).toBeVisible(); +}); +``` + +### Available Fixtures + +- **`extensionPage`** - Fresh extension, no wallet +- **`unlockedExtensionPage`** - Extension with wallet (requires snapshot) +- **`popupPage`** - Extension popup view +- **`context`** - Browser context with extension +- **`extensionId`** - Auto-detected extension ID + +--- + +## Configuration + +### Extension Path + +The extension is loaded from `e2e-playwright-tests/dist/`: + +```typescript +// constants.ts +export const TEST_CONFIG = { + extension: { + path: './dist', // Relative to e2e-playwright-tests/ + }, +}; +``` + +### Timeouts + +Configure timeouts in `playwright.config.ts`: + +```typescript +{ + timeout: 120000, // Test timeout + expect: { timeout: 10000 }, // Assertion timeout + use: { + actionTimeout: 45000, // Action timeout + navigationTimeout: 45000 // Navigation timeout + } +} +``` + +### Browser Settings + +Tests run in Chromium by default: + +```typescript +{ + use: { + headless: false, // Show browser + viewport: { width: 1920, height: 1080 }, + screenshot: 'only-on-failure', + video: 'on-first-retry', + trace: 'on-first-retry' + } +} +``` diff --git a/e2e-playwright-tests/config/base.config.ts b/e2e-playwright-tests/config/base.config.ts new file mode 100644 index 000000000..bd5b57a88 --- /dev/null +++ b/e2e-playwright-tests/config/base.config.ts @@ -0,0 +1,91 @@ +import { defineConfig, devices } from '@playwright/test'; +import * as path from 'node:path'; +import * as dotenv from 'dotenv'; + +// Load environment variables +dotenv.config({ path: path.resolve(__dirname, '..', '.env') }); + +const shardId = process.env.PLAYWRIGHT_SHARD || 'default'; + +const testRailOptions = { + embedAnnotationsAsProperties: true, + outputFile: `../tests/test-results/junit-report-${shardId}.xml`, +}; + +/** + * Worker configuration: + * - Local: Uses all available CPU cores (undefined = 100% parallelization) + * - CI: Configurable via WORKERS env var, defaults to 4 for optimal performance + * + * The CI runner has 16 cores with 4 CPUs allocated per container. + * Using 4 workers allows parallel test execution within each shard. + */ +const getWorkers = () => { + if (!process.env.CI) { + // Local: use all available cores for maximum speed + return undefined; // Playwright will use 50% of CPU cores + } + + // CI: use configured workers or default to 4 + const workers = parseInt(process.env.WORKERS || '4', 10); + console.log(`Running with ${workers} parallel workers in CI`); + return workers; +}; + +/** + * Fully parallel mode allows tests within a single file to run in parallel. + * Disabled in CI to ensure more stable test execution with extension. + */ +const fullyParallel = !process.env.CI; + +export default defineConfig({ + globalSetup: require.resolve('./global-setup.ts'), + testDir: '../tests', + testMatch: '**/*.spec.ts', + outputDir: '../tests/test-results', + timeout: 120000, + expect: { timeout: 10000 }, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: getWorkers(), + fullyParallel: fullyParallel, + reporter: [process.env.CI ? ['junit', testRailOptions] : ['html'], ['list']], + + // Shared settings for all projects + use: { + trace: 'on-first-retry', + video: 'on-first-retry', + headless: process.env.HEADLESS === 'true', + bypassCSP: true, + navigationTimeout: 30000, + actionTimeout: 30000, + ignoreHTTPSErrors: true, + screenshot: 'only-on-failure', + viewport: { width: 1920, height: 1080 }, + + // Slow down operations (useful for debugging) + // launchOptions: { + // slowMo: 100, + // }, + }, + + projects: [ + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + channel: 'chromium', + // Chrome-specific args for extension testing + launchOptions: { + args: [ + '--no-sandbox', + '--disable-dev-shm-usage', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + ], + }, + }, + }, + ], +}); diff --git a/e2e-playwright-tests/config/global-setup.ts b/e2e-playwright-tests/config/global-setup.ts new file mode 100644 index 000000000..b3130159b --- /dev/null +++ b/e2e-playwright-tests/config/global-setup.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { FullConfig } from '@playwright/test'; + +async function globalSetup(_config: FullConfig) { + console.log('Starting global setup...'); + + // Clean up user data directory from previous runs + const userDataDir = path.resolve(__dirname, '..', 'user-data-dir'); + if (fs.existsSync(userDataDir)) { + console.log('Cleaning up user data directory...'); + fs.rmSync(userDataDir, { recursive: true, force: true }); + console.log('User data directory cleaned'); + } + + // Clean up old screenshots + const screenshotsDir = path.resolve(__dirname, '..', 'test-results', 'screenshots'); + if (fs.existsSync(screenshotsDir)) { + console.log('Cleaning up old screenshots...'); + const files = fs.readdirSync(screenshotsDir); + files.forEach((file) => { + fs.unlinkSync(path.join(screenshotsDir, file)); + }); + console.log('Old screenshots cleaned'); + } + + // Verify extension build exists + const extensionPath = path.resolve(__dirname, '..', 'dist'); + if (!fs.existsSync(extensionPath)) { + console.warn('Warning: Extension build not found at', extensionPath); + console.warn('Please ensure the extension is copied to e2e-playwright-tests/dist'); + } else { + console.log('Extension build found at', extensionPath); + } + + // Verify manifest exists + const manifestPath = path.join(extensionPath, 'manifest.json'); + if (fs.existsSync(manifestPath)) { + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + console.log(`Extension manifest found - Name: ${manifest.name}, Version: ${manifest.version}`); + } + + console.log('Global setup completed\n'); +} + +export default globalSetup; diff --git a/e2e-playwright-tests/config/local.config.ts b/e2e-playwright-tests/config/local.config.ts new file mode 100644 index 000000000..3bded3eec --- /dev/null +++ b/e2e-playwright-tests/config/local.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test'; +import { baseConfig } from './base.config'; + +export default defineConfig({ + ...baseConfig, + + // Local-specific overrides + use: { + ...baseConfig.use, + // Local testing can be slower for debugging + actionTimeout: 45000, + navigationTimeout: 45000, + }, + + retries: 1, + timeout: 180000, +}); diff --git a/e2e-playwright-tests/constants.ts b/e2e-playwright-tests/constants.ts new file mode 100644 index 000000000..022e2eb9a --- /dev/null +++ b/e2e-playwright-tests/constants.ts @@ -0,0 +1,110 @@ +export const TEST_CONFIG = { + extension: { + // Path to built extension (relative to this directory) + path: process.env.EXTENSION_PATH || './dist', + // User data directory for browser profile + userDataDir: './user-data-dir', + // Extension ID (will be auto-detected if not provided) + id: process.env.EXTENSION_ID || '', + }, + browser: { + // Run in headless mode (defaults to false for local dev, true in CI) + headless: process.env.HEADLESS === 'true' || process.env.CI === 'true', + }, + wallet: { + password: (() => { + if (!process.env.WALLET_PASSWORD) { + throw new Error('WALLET_PASSWORD must be set in .env file. See .env.example for template.'); + } + return process.env.WALLET_PASSWORD; + })(), + recoveryPhrase: process.env.WALLET_RECOVERY_PHRASE || 'test test test test test test test test test test test junk', + recoveryPhrase12Words: (() => { + if (!process.env.RECOVERY_PHRASE_12_WORDS) { + throw new Error('RECOVERY_PHRASE_12_WORDS must be set in .env file. See .env.example for template.'); + } + return process.env.RECOVERY_PHRASE_12_WORDS; + })(), + recoveryPhrase24Words: (() => { + if (!process.env.RECOVERY_PHRASE_24_WORDS) { + throw new Error('RECOVERY_PHRASE_24_WORDS must be set in .env file. See .env.example for template.'); + } + return process.env.RECOVERY_PHRASE_24_WORDS; + })(), + // Timeouts for wallet operations + timeouts: { + connect: 15000, + action: 15000, + navigation: 15000, + unlock: 10000, + }, + }, + timeouts: { + default: 30000, + extended: 60000, + short: 5000, + }, + testData: { + // Test wallet addresses (C-Chain format) + addresses: { + primary: '0x0000000000000000000000000000000000000000', + secondary: '0x1111111111111111111111111111111111111111', + }, + // Test contact information + contacts: { + testContact: { + name: 'Test Contact', + address: '0x2222222222222222222222222222222222222222', + }, + contact1: { + name: 'Alice Crypto', + avalancheCChain: '0xf5d2d2f8e3703C928c08af3F1f9C4692D7ab98C2', + avalancheXP: 'avax1p9d7lu6xw27ld20pj9ru8a8233a8radu4ad4tk', + bitcoin: 'bc1qgu8k0cs0jc928e39qgj87guk6ql3ahssap24fs', + solana: '3yTtm6Cp5x7ppKrXtjt19fxpT4Qj1f18R25eYWjaP6GH', + }, + contact2: { + name: 'Bob Blockchain', + avalancheCChain: '0x5D2aeFc98240C42456B4C237713AdA32E51bcba7', + avalancheXP: 'avax184g8ffhl0a9dej4wjjpz8dw8x977f9slg9uknd', + bitcoin: 'bc1q9pxl5qagt0glrgvuajjvu8vczky3ttxcv5nz4w', + solana: '73SxxYPdyw1qAMDq8gZmxEku17JAmQEJd17xr3ij8eh1', + }, + }, + // Test amounts + amounts: { + small: '0.001', + medium: '0.1', + large: '1', + }, + }, +}; + +export const NETWORK_CONFIG = { + avalanche: { + mainnet: { + chainId: 43114, + rpcUrl: 'https://api.avax.network/ext/bc/C/rpc', + explorerUrl: 'https://snowtrace.io', + }, + testnet: { + chainId: 43113, + rpcUrl: 'https://api.avax-test.network/ext/bc/C/rpc', + explorerUrl: 'https://testnet.snowtrace.io', + }, + }, +}; + +export const ELEMENT_TIMEOUTS = { + // How long to wait for elements to appear + visible: 10000, + // How long to wait for elements to disappear + hidden: 5000, + // How long to wait for navigation + navigation: 30000, +}; + +export const TEST_TAGS = { + SMOKE: '@smoke', + REGRESSION: '@regression', +}; diff --git a/e2e-playwright-tests/fixtures/extension.fixture.ts b/e2e-playwright-tests/fixtures/extension.fixture.ts new file mode 100644 index 000000000..96affa84e --- /dev/null +++ b/e2e-playwright-tests/fixtures/extension.fixture.ts @@ -0,0 +1,362 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import fs from 'node:fs'; +import path from 'node:path'; +import { test as base, chromium, BrowserContext, Page } from '@playwright/test'; +import { TEST_CONFIG } from '../constants'; +import { getExtensionId, waitForExtensionLoad, openExtensionPopup } from '../helpers/extensionHelpers'; +import { unlockWallet } from '../helpers/walletHelpers'; +import { loadWalletSnapshot } from '../helpers/loadWalletSnapshot'; + +// Define custom fixtures +export type ExtensionFixtures = { + context: BrowserContext; + extensionId: string; + extensionPage: Page; + unlockedExtensionPage: Page; + popupPage: Page; +}; + +export const test = base.extend({ + /** + * Browser context with extension loaded + * This is the foundation fixture that loads the extension + * + * By default, starts with a fresh extension (no snapshot). + * To use a wallet snapshot, add annotation: { type: 'snapshot', description: 'snapshotName' } + */ + // eslint-disable-next-line no-empty-pattern + context: async ({}, use, testInfo) => { + console.log('\nSetting up browser context with extension...'); + + const extensionPath = path.resolve(__dirname, '..', TEST_CONFIG.extension.path); + const userDataDir = path.resolve(__dirname, '..', TEST_CONFIG.extension.userDataDir); + + // Check if extension exists + if (!fs.existsSync(extensionPath)) { + throw new Error(`Extension not found at: ${extensionPath}. Please build the extension first.`); + } + + // Get snapshot name from test annotation (if provided) + // Default is 'none' for fresh extension launch + const snapshotAnnotation = testInfo.annotations.find((a) => a.type === 'snapshot'); + const snapshotName = snapshotAnnotation?.description || 'none'; + + if (snapshotName === 'none') { + console.log('Starting with fresh extension (no snapshot)'); + } else { + console.log(`Using snapshot: ${snapshotName}`); + } + + // Launch browser with extension in persistent context + const context = await chromium.launchPersistentContext(userDataDir, { + headless: TEST_CONFIG.browser.headless, + channel: 'chromium', + permissions: ['clipboard-read', 'clipboard-write'], + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + '--no-sandbox', + '--start-maximized', + '--disable-dev-shm-usage', + '--disable-background-timer-throttling', + '--disable-backgrounding-occluded-windows', + '--disable-renderer-backgrounding', + ], + }); + + console.log('Browser context created'); + + // Wait for extension to load + await waitForExtensionLoad(context); + console.log('Extension loaded'); + + // Load wallet snapshot if specified + if (snapshotName !== 'none') { + console.log(`Loading snapshot: ${snapshotName}`); + await loadWalletSnapshot(context, snapshotName, TEST_CONFIG.wallet.password); + console.log(`Snapshot loaded: ${snapshotName}`); + + // Wait a bit for the extension to process the snapshot data + await context + .pages()[0] + ?.waitForTimeout(500) + .catch(() => {}); + } + + // Close any about:blank pages that might have opened + for (const page of context.pages()) { + if (page.url() === 'about:blank') { + await page.close().catch(() => {}); + } + } + + // Use the context + await use(context); + + // Cleanup: close context and remove user data + console.log('Cleaning up context...'); + await context.close(); + + if (fs.existsSync(userDataDir)) { + fs.rmSync(userDataDir, { recursive: true, force: true }); + } + + console.log('Context cleaned up\n'); + }, + + extensionId: async ({ context }, use) => { + const extensionId = await getExtensionId(context); + await use(extensionId); + }, + + /** + * Extension page - basic page without wallet unlocked + * Useful for testing onboarding, login flows, etc. + * Starts fresh by default (no wallet snapshot loaded). + */ + extensionPage: async ({ context, extensionId }, use, testInfo) => { + console.log('Creating extension page (for tests WITHOUT wallet unlock)...'); + + // Check if a snapshot is being used + const snapshotAnnotation = testInfo.annotations.find((a) => a.type === 'snapshot'); + const hasSnapshot = snapshotAnnotation && snapshotAnnotation.description !== 'none'; + + if (hasSnapshot) { + console.warn( + 'WARNING: extensionPage fixture is being used with a snapshot. Consider using unlockedExtensionPage instead.', + ); + } + + // Wait for extension to auto-open its page + await context.waitForEvent('page', { timeout: 5000 }).catch(() => {}); + + // Find the extension page that was auto-opened + let page = context.pages().find((p) => p.url().startsWith(`chrome-extension://${extensionId}`)); + + // If no auto-opened page found, create one and let extension redirect naturally + if (!page) { + console.log('No auto-opened page found, creating new page...'); + page = await context.newPage(); + await page.goto(`chrome-extension://${extensionId}/home.html`); + await page.waitForLoadState('domcontentloaded'); + } else { + console.log(`Using auto-opened extension page: ${page.url()}`); + await page.waitForLoadState('domcontentloaded'); + } + + // Wait for potential redirects (e.g., fresh extension redirects to onboarding) + await page.waitForTimeout(2000); + + // Close any extra extension pages + for (const p of context.pages()) { + if (p !== page && p.url().startsWith(`chrome-extension://${extensionId}`)) { + console.log('Closing extra extension page:', p.url()); + await p.close().catch(() => {}); + } + } + + // Close any about:blank pages + for (const p of context.pages()) { + if (p.url() === 'about:blank') { + await p.close().catch(() => {}); + } + } + + const finalUrl = page.url(); + console.log(`Extension page ready at: ${finalUrl}`); + + // Log the type of page we ended up on + if (finalUrl.includes('onboard')) { + console.log('→ On onboarding page (fresh extension - no wallet)'); + } else if (finalUrl.includes('lock') || finalUrl.includes('login')) { + console.log('→ On lock/login page (wallet exists but locked)'); + } else if (finalUrl.includes('home')) { + console.log('→ On home page (wallet may be unlocked)'); + } + + await use(page); + + // Cleanup page storage + try { + await page.evaluate(() => { + // @ts-expect-error - window is available in browser context + window.localStorage.clear(); + }); + await page.evaluate(() => { + // @ts-expect-error - window is available in browser context + window.sessionStorage.clear(); + }); + } catch { + // Page might be closed already + } + }, + + /** + * Unlocked extension page - wallet is already unlocked + * This is the most commonly used fixture for testing wallet features + * + * Note: Requires a wallet snapshot to be loaded. Add annotation: + * testInfo.annotations.push({ type: 'snapshot', description: 'example' }); + */ + unlockedExtensionPage: async ({ context, extensionId }, use, testInfo) => { + console.log('Creating unlocked extension page...'); + + // Check if a snapshot is being used + const snapshotAnnotation = testInfo.annotations.find((a) => a.type === 'snapshot'); + const hasSnapshot = snapshotAnnotation && snapshotAnnotation.description !== 'none'; + + if (!hasSnapshot) { + throw new Error( + 'unlockedExtensionPage fixture requires a wallet snapshot. Add annotation: { type: "snapshot", description: "snapshotName" }', + ); + } + + // Always create a new page for the test (don't reuse auto-opened extension pages) + const page = await context.newPage(); + console.log(`Navigating to popup.html#/home...`); + await page.goto(`chrome-extension://${extensionId}/popup.html#/home`); + await page.waitForLoadState('domcontentloaded'); + + console.log(`Current page URL after navigation: ${page.url()}`); + + // Wait for snapshot data to be loaded and extension to render lock screen + await page.waitForTimeout(2000); + + // Close any extra extension pages that auto-opened + for (const p of context.pages()) { + if (p !== page && p.url().startsWith(`chrome-extension://${extensionId}`)) { + console.log('Closing auto-opened extension page:', p.url()); + await p.close().catch(() => {}); + } + } + + // Close any about:blank pages + for (const p of context.pages()) { + if (p.url() === 'about:blank') { + await p.close().catch(() => {}); + } + } + + // Now unlock the wallet with the password + console.log('Attempting to unlock wallet with password...'); + await unlockWallet(page, TEST_CONFIG.wallet.password); + console.log('Wallet unlocked successfully'); + + // Wait for unlock to complete and page to load + await page.waitForTimeout(2000); + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { + console.log('Network not idle after unlock, continuing...'); + }); + + // Additional wait for UI to render + await page.waitForTimeout(2000); + + console.log(`Final URL after unlock: ${page.url()}`); + console.log('Extension page unlocked and ready'); + + await use(page); + + // Cleanup page storage + try { + await page.evaluate(() => { + // @ts-expect-error - window is available in browser context + window.localStorage.clear(); + }); + await page.evaluate(() => { + // @ts-expect-error - window is available in browser context + window.sessionStorage.clear(); + }); + } catch { + // Page might be closed already + } + }, + + /** + * Extension popup page - simulates clicking the extension icon + * Useful for testing popup-specific features + */ + popupPage: async ({ context, extensionId }, use, testInfo) => { + console.log('Creating popup page...'); + + const page = await openExtensionPopup(context, extensionId); + + // Check if a snapshot is being used + const snapshotAnnotation = testInfo.annotations.find((a) => a.type === 'snapshot'); + const hasSnapshot = snapshotAnnotation && snapshotAnnotation.description !== 'none'; + + // Unlock wallet if snapshot is loaded + if (hasSnapshot) { + try { + await unlockWallet(page, TEST_CONFIG.wallet.password); + console.log('Popup wallet unlocked'); + } catch (error) { + console.warn('Could not unlock popup wallet:', error); + } + } + + await use(page); + + // Cleanup page storage + try { + await page.evaluate(() => { + // @ts-expect-error - window is available in browser context + window.localStorage.clear(); + }); + await page.evaluate(() => { + // @ts-expect-error - window is available in browser context + window.sessionStorage.clear(); + }); + } catch { + // Page might be closed already + } + }, +}); + +test.afterEach(async ({ context }, testInfo) => { + if (testInfo.status !== testInfo.expectedStatus) { + console.log('Test failed, capturing screenshot...'); + + const screenshotDir = path.resolve(__dirname, '..', 'test-results', 'screenshots'); + const filename = sanitizeFilename(`${testInfo.title}.png`); + const filepath = path.join(screenshotDir, filename); + + // Create directory if it doesn't exist + if (!fs.existsSync(screenshotDir)) { + fs.mkdirSync(screenshotDir, { recursive: true }); + } + + // Take screenshot of the first available page + const pages = context.pages(); + if (pages.length > 0 && pages[0]) { + try { + await pages[0].screenshot({ + path: filepath, + fullPage: true, + }); + + console.log('Screenshot saved:', filename); + + // Add screenshot as test annotation for test management systems + testInfo.annotations.push({ + type: 'testrail_attachment', + description: `./e2e-playwright-tests/test-results/screenshots/${filename}`, + }); + } catch (error) { + console.error('Failed to capture screenshot:', error); + } + } + } +}); + +/** + * Helper function to sanitize filenames + */ +function sanitizeFilename(name: string): string { + return name + .replace(/[<>:"/\\|?*]+/g, '-') + .replace(/\s+/g, '_') + .slice(0, 200); +} + +// Re-export expect for convenience +export { expect } from '@playwright/test'; diff --git a/e2e-playwright-tests/helpers/extensionHelpers.ts b/e2e-playwright-tests/helpers/extensionHelpers.ts new file mode 100644 index 000000000..5f7c6d003 --- /dev/null +++ b/e2e-playwright-tests/helpers/extensionHelpers.ts @@ -0,0 +1,156 @@ +import { BrowserContext, Page } from '@playwright/test'; +import { delay } from './waits'; + +export async function getExtensionId(context: BrowserContext): Promise { + console.log('Getting extension ID...'); + + // Wait for service worker to be available + let serviceWorker = context.serviceWorkers()[0]; + + if (!serviceWorker) { + console.log('Waiting for service worker...'); + serviceWorker = await context.waitForEvent('serviceworker', { + timeout: 30000, + }); + } + + const url = serviceWorker.url(); + const extensionId = url.split('/')[2]; + + if (!extensionId) { + throw new Error('Failed to extract extension ID from service worker URL'); + } + + console.log('Extension ID:', extensionId); + return extensionId; +} + +export async function getExtensionPage(context: BrowserContext, urlPattern: string, timeout = 10000): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + const pages = context.pages(); + const extensionPage = pages.find((p) => p.url().includes(urlPattern)); + + if (extensionPage) { + return extensionPage; + } + + await delay(100); + } + + throw new Error(`Extension page with pattern "${urlPattern}" not found within ${timeout}ms`); +} + +export async function waitForExtensionLoad(context: BrowserContext, timeout = 30000): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + const pages = context.pages(); + const extensionPage = pages.find((p) => p.url().startsWith('chrome-extension://')); + + if (extensionPage) { + await extensionPage.waitForLoadState('domcontentloaded'); + console.log('Extension loaded'); + return; + } + + await delay(500); + } + + throw new Error(`Extension did not load within ${timeout}ms`); +} + +export async function closeOnboardingPages(context: BrowserContext): Promise { + const pages = context.pages(); + + for (const page of pages) { + const url = page.url(); + if (url.includes('/onboarding') || url.includes('/welcome') || url.includes('getting-started')) { + console.log('Closing onboarding page:', url); + await page.close(); + } + } +} + +export async function setExtensionStorage(page: Page, key: string, value: any): Promise { + await page.evaluate( + async ([storageKey, storageValue]) => { + return new Promise((resolve, reject) => { + chrome.storage.local.set({ [storageKey]: storageValue }, () => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(); + } + }); + }); + }, + [key, value], + ); +} + +export async function getExtensionStorage(page: Page, key: string): Promise { + return await page.evaluate(async (storageKey) => { + return new Promise((resolve) => { + chrome.storage.local.get(storageKey, (result) => { + resolve(result[storageKey]); + }); + }); + }, key); +} + +export async function clearExtensionStorage(page: Page): Promise { + await page.evaluate(async () => { + return new Promise((resolve, reject) => { + chrome.storage.local.clear(() => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(); + } + }); + }); + }); +} + +export async function loadExtensionStorage(page: Page, data: Record): Promise { + console.log('Loading extension storage...'); + + for (const [key, value] of Object.entries(data)) { + await setExtensionStorage(page, key, value); + console.log(` Stored: ${key}`); + } + + console.log('Extension storage loaded'); +} + +export async function openExtensionPopup(context: BrowserContext, extensionId: string): Promise { + const popupUrl = `chrome-extension://${extensionId}/popup.html#/home`; + const page = await context.newPage(); + await page.goto(popupUrl); + await page.waitForLoadState('domcontentloaded'); + return page; +} + +export async function takeScreenshot(page: Page, name: string, path = './test-results/screenshots'): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `${name}-${timestamp}.png`; + await page.screenshot({ + path: `${path}/${filename}`, + fullPage: true, + }); + console.log('Screenshot saved:', filename); +} + +export async function switchToTab(context: BrowserContext, urlPattern: string): Promise { + const pages = context.pages(); + const targetPage = pages.find((p) => p.url().includes(urlPattern)); + + if (!targetPage) { + throw new Error(`No tab found with URL pattern: ${urlPattern}`); + } + + await targetPage.bringToFront(); + return targetPage; +} diff --git a/e2e-playwright-tests/helpers/loadWalletSnapshot.ts b/e2e-playwright-tests/helpers/loadWalletSnapshot.ts new file mode 100644 index 000000000..c420182fc --- /dev/null +++ b/e2e-playwright-tests/helpers/loadWalletSnapshot.ts @@ -0,0 +1,133 @@ +import type { BrowserContext, Page } from '@playwright/test'; + +// Dynamically load snapshots that exist +const SNAPSHOTS: Record = {}; + +// Try to load mainnetPrimaryExtWallet +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { mainnetPrimaryExtWallet } = require('./storage-snapshots/mainnetPrimaryExtWallet'); + SNAPSHOTS.mainnetPrimaryExtWallet = mainnetPrimaryExtWallet; +} catch (_e) { + console.warn('mainnetPrimaryExtWallet snapshot not available'); +} + +// Try to load testnetPrimaryExtWallet +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { testnetPrimaryExtWallet } = require('./storage-snapshots/testnetPrimaryExtWallet'); + SNAPSHOTS.testnetPrimaryExtWallet = testnetPrimaryExtWallet; +} catch (_e) { + console.warn('testnetPrimaryExtWallet snapshot not available'); +} + +export const loadWalletSnapshot = async ( + context: BrowserContext, + snapshotName: string, + _password: string, +): Promise => { + try { + console.log(`Loading wallet snapshot: ${snapshotName}`); + + // Get the snapshot data + const snapshot = SNAPSHOTS[snapshotName]; + + if (!snapshot) { + throw new Error( + `Snapshot "${snapshotName}" not found. Available snapshots: ${Object.keys(SNAPSHOTS).join(', ')}`, + ); + } + + // Parse the snapshot data if it's a string + const parsedSnapshot = typeof snapshot === 'string' ? JSON.parse(snapshot) : snapshot; + + // Find the extension page + let extensionPage: Page | undefined; + const loopEnd = Date.now() + 10000; // 10 seconds timeout + + // Loop until finding the extension url page (meaning it's fully loaded in the browser) or timeout after 10 seconds + while (!extensionPage && Date.now() < loopEnd) { + const pages = context.pages(); + for (const p of pages) { + if (p.url().startsWith('chrome-extension://')) { + extensionPage = p; + break; + } + } + // Wait 500ms before checking again + if (!extensionPage) { + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + + if (!extensionPage) { + throw new Error('Extension page not found. Extension may not be loaded correctly.'); + } + + console.log('Extension page found, loading snapshot data...'); + + // Verify required keys exist (if applicable) + const requiredKeys = ['WALLET_STORAGE_ENCRYPTION_KEY', 'accounts', 'settings', 'wallet']; + const missingKeys = requiredKeys.filter((key) => !parsedSnapshot[key]); + if (missingKeys.length > 0) { + console.warn(`Warning: Missing potentially required keys: ${missingKeys.join(', ')}`); + } + + // Store each key in chrome.storage.local and verify immediately + for (const [key, value] of Object.entries(parsedSnapshot)) { + await extensionPage.evaluate( + async ([k, v]) => { + return new Promise((resolve, reject) => { + const keyString = k as string; + const data = { [keyString]: v }; + // @ts-expect-error - chrome is available in extension context + chrome.storage.local.set(data, () => { + // @ts-expect-error - chrome is available in extension context + if (chrome.runtime.lastError) { + // @ts-expect-error - chrome is available in extension context + console.error('Error setting storage:', chrome.runtime.lastError); + // @ts-expect-error - chrome is available in extension context + reject(chrome.runtime.lastError); + } else { + // Verify the data was stored correctly + // @ts-expect-error - chrome is available in extension context + chrome.storage.local.get(keyString, (result) => { + console.log(`✓ Loaded ${keyString}:`, result[keyString] ? 'stored' : 'missing'); + resolve(); + }); + } + }); + }); + }, + [key, value], + ); + } + + console.log(`✓ Wallet snapshot "${snapshotName}" loaded successfully`); + + // Optional: Log storage summary for debugging + const storageSummary = await extensionPage.evaluate(async () => { + return new Promise>((resolve) => { + // @ts-expect-error - chrome is available in extension context + chrome.storage.local.get(null, (items) => { + const summary: Record = {}; + for (const [key, value] of Object.entries(items)) { + summary[key] = typeof value; + } + resolve(summary); + }); + }); + }); + console.log('Storage summary:', storageSummary); + } catch (error) { + console.error('Error in loadWalletSnapshot:', error); + throw error; + } +}; + +/** + * Get list of available snapshots + */ +export const getAvailableSnapshots = (): string[] => { + return Object.keys(SNAPSHOTS); +}; diff --git a/e2e-playwright-tests/helpers/waits.ts b/e2e-playwright-tests/helpers/waits.ts new file mode 100644 index 000000000..ac5c9cea7 --- /dev/null +++ b/e2e-playwright-tests/helpers/waits.ts @@ -0,0 +1,122 @@ +import { Locator, Page } from '@playwright/test'; + +export function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function waitForText(locator: Locator, expectedText: string, timeout = 5000): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + try { + const text = await locator.textContent(); + if (text && text.includes(expectedText)) { + return; + } + } catch { + // Element might not be available yet, continue waiting + } + await delay(100); + } + + throw new Error(`Timeout: Expected text "${expectedText}" did not appear within ${timeout}ms`); +} + +export async function waitForAttribute( + locator: Locator, + attribute: string, + expectedValue: string, + timeout = 5000, +): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + try { + const value = await locator.getAttribute(attribute); + if (value === expectedValue) { + return; + } + } catch { + // Element might not be available yet, continue waiting + } + await delay(100); + } + + throw new Error(`Timeout: Attribute "${attribute}" did not have value "${expectedValue}" within ${timeout}ms`); +} + +export async function waitForUrl(page: Page, urlPart: string, timeout = 5000): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + if (page.url().includes(urlPart)) { + return; + } + await delay(100); + } + + throw new Error(`Timeout: URL did not contain "${urlPart}" within ${timeout}ms`); +} + +export async function waitForCount(locator: Locator, expectedCount: number, timeout = 5000): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + try { + const count = await locator.count(); + if (count === expectedCount) { + return; + } + } catch { + // Continue waiting + } + await delay(100); + } + + throw new Error(`Timeout: Element count did not reach ${expectedCount} within ${timeout}ms`); +} + +export async function waitForNetworkIdle(page: Page, idleTime = 500, timeout = 10000): Promise { + let lastRequestTime = Date.now(); + const startTime = Date.now(); + + const requestListener = () => { + lastRequestTime = Date.now(); + }; + + page.on('request', requestListener); + + try { + while (Date.now() - startTime < timeout) { + if (Date.now() - lastRequestTime >= idleTime) { + page.off('request', requestListener); + return; + } + await delay(100); + } + throw new Error(`Timeout: Network did not become idle within ${timeout}ms`); + } finally { + page.off('request', requestListener); + } +} + +export async function waitForExtensionStorage(page: Page, key: string, timeout = 5000): Promise { + const start = Date.now(); + + while (Date.now() - start < timeout) { + const hasKey = await page.evaluate(async (storageKey) => { + return new Promise((resolve) => { + chrome.storage.local.get(storageKey, (result) => { + resolve(storageKey in result); + }); + }); + }, key); + + if (hasKey) { + return; + } + await delay(100); + } + + throw new Error(`Timeout: Storage key "${key}" not found within ${timeout}ms`); +} diff --git a/e2e-playwright-tests/helpers/walletHelpers.ts b/e2e-playwright-tests/helpers/walletHelpers.ts new file mode 100644 index 000000000..87fcb453b --- /dev/null +++ b/e2e-playwright-tests/helpers/walletHelpers.ts @@ -0,0 +1,144 @@ +import { Page } from '@playwright/test'; + +export async function importWalletWithRecovery(page: Page, recoveryPhrase: string, password: string): Promise { + console.log('Importing wallet with recovery phrase...'); + + // These selectors should match your extension's UI + // Update them based on your actual implementation + await page.getByRole('button', { name: /import/i }).click(); + await page.fill('[data-testid="recovery-phrase-input"]', recoveryPhrase); + await page.fill('[data-testid="password-input"]', password); + await page.fill('[data-testid="confirm-password-input"]', password); + await page.getByRole('button', { name: /continue|import/i }).click(); + + // Wait for wallet to be imported + await page.waitForURL(/.*home.*/); + + console.log('Wallet imported'); +} + +export async function createNewWallet(page: Page, password: string): Promise { + console.log('Creating new wallet...'); + + // These selectors should match your extension's UI + // Update them based on your actual implementation + await page.getByRole('button', { name: /create.*wallet/i }).click(); + await page.fill('[data-testid="password-input"]', password); + await page.fill('[data-testid="confirm-password-input"]', password); + await page.getByRole('button', { name: /continue|create/i }).click(); + + // Get recovery phrase + const recoveryPhraseElement = page.locator('[data-testid="recovery-phrase"]'); + const recoveryPhrase = (await recoveryPhraseElement.textContent()) || ''; + + // Confirm recovery phrase + await page.getByRole('button', { name: /continue|next/i }).click(); + + console.log('Wallet created'); + return recoveryPhrase.trim(); +} + +export async function unlockWallet(page: Page, password: string): Promise { + console.log('Unlocking wallet...'); + + // Wait for password input to appear + const passwordInput = page.locator('input[type="password"]'); + + try { + await passwordInput.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Password input found'); + } catch (_error) { + console.log('No password input found - checking if wallet is already unlocked...'); + + // Check if we're on a page that indicates the wallet is already unlocked + const currentUrl = page.url(); + if (currentUrl.includes('home') && !currentUrl.includes('onboard')) { + console.log('Already on home page - wallet appears to be unlocked'); + return; + } + + // Otherwise, this is an error + throw new Error(`Password input not found. Current URL: ${currentUrl}`); + } + + // Enter password + await passwordInput.fill(password); + console.log('Password entered'); + + // Click unlock/login button + const unlockButton = page.getByRole('button', { name: /unlock|log.*in|continue/i }); + await unlockButton.click(); + console.log('Unlock button clicked'); + + // Wait for unlock to complete (password input should disappear) + await passwordInput.waitFor({ state: 'hidden', timeout: 15000 }); + console.log('Password input hidden - wallet unlocked'); + + // Wait for the Portfolio/Home page to load + // This can be identified by the presence of certain elements on the main page + try { + // Wait for URL to change from login/lock screen + await page.waitForTimeout(1000); + console.log('Waiting for Portfolio page to load...'); + + // Wait for network load to be idle + await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { + console.log('Network not idle, continuing anyway'); + }); + + console.log('Wallet unlocked and Portfolio page loaded'); + } catch (error) { + console.warn('Portfolio page may not have fully loaded:', error); + } +} + +export async function lockWallet(page: Page): Promise { + console.log('Locking wallet...'); + + // Open menu/settings + await page.getByRole('button', { name: /menu|settings/i }).click(); + + // Click lock/sign out + await page.getByRole('button', { name: /lock|sign.*out|log.*out/i }).click(); + + // Wait for lock screen to appear + await page.locator('input[type="password"]').waitFor({ state: 'visible', timeout: 5000 }); + + console.log('Wallet locked'); +} + +export async function getTokenBalance(page: Page, tokenSymbol: string): Promise { + // This selector should match your extension's UI + const balanceElement = page.locator(`[data-testid="balance-${tokenSymbol}"]`); + await balanceElement.waitFor({ state: 'visible', timeout: 10000 }); + + const balance = await balanceElement.textContent(); + return balance?.trim() || '0'; +} + +export async function switchNetwork(page: Page, network: string): Promise { + console.log(`Switching to ${network}...`); + + // Open network selector + await page.getByRole('button', { name: /network/i }).click(); + + // Select network + await page.getByRole('button', { name: new RegExp(network, 'i') }).click(); + + // Wait for network switch to complete + await page.waitForTimeout(2000); + + console.log(`Switched to ${network}`); +} + +export async function getCurrentAccountAddress(page: Page): Promise { + // This should be adjusted based on your extension's storage structure + return await page.evaluate(async () => { + return new Promise((resolve) => { + chrome.storage.local.get(['wallet'], (result) => { + const address = result.wallet?.accounts?.[result.wallet?.activeAccountIndex]?.addressC || ''; + resolve(address); + }); + }); + }); +} diff --git a/e2e-playwright-tests/package.json b/e2e-playwright-tests/package.json new file mode 100644 index 000000000..3bcb8b38c --- /dev/null +++ b/e2e-playwright-tests/package.json @@ -0,0 +1,32 @@ +{ + "name": "@core-ext/e2e-playwright", + "version": "0.0.0", + "private": true, + "description": "Playwright E2E tests for Core Extension", + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", + "test:local": "playwright test --config=config/local.config.ts", + "test:smoke": "playwright test --grep=@smoke", + "test:sequential": "playwright test --workers=1", + "test:parallel": "playwright test --workers=4", + "test:parallel:max": "playwright test", + "test:shard": "playwright test --shard=1/4", + "test:ci": "CI=true playwright test --workers=4", + "report": "playwright show-report", + "codegen": "playwright codegen" + }, + "devDependencies": { + "@playwright/test": "1.52.0", + "@types/node": "20.17.42", + "@types/chrome": "0.0.277", + "dotenv": "16.4.1", + "typescript": "5.8.2" + }, + "dependencies": { + "playwright-extra": "4.3.6", + "puppeteer-extra-plugin-stealth": "2.11.2" + } +} diff --git a/e2e-playwright-tests/pages/extension/BasePage.ts b/e2e-playwright-tests/pages/extension/BasePage.ts new file mode 100644 index 000000000..3855b88ec --- /dev/null +++ b/e2e-playwright-tests/pages/extension/BasePage.ts @@ -0,0 +1,106 @@ +import { Page, Locator } from '@playwright/test'; +import { TEST_CONFIG } from '../../constants'; + +export abstract class BasePage { + readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async goto(path: string): Promise { + const extensionId = await this.getExtensionId(); + const url = `chrome-extension://${extensionId}/${path}`; + await this.page.goto(url); + await this.page.waitForLoadState('domcontentloaded'); + } + + async getExtensionId(): Promise { + if (TEST_CONFIG.extension.id) { + return TEST_CONFIG.extension.id; + } + + // Extract from current URL if we're on an extension page + const url = this.page.url(); + if (url.startsWith('chrome-extension://')) { + return url.split('/')[2]; + } + + throw new Error('Could not determine extension ID'); + } + + async waitForVisible(locator: Locator, timeout = TEST_CONFIG.timeouts.default): Promise { + await locator.waitFor({ state: 'visible', timeout }); + } + + async waitForHidden(locator: Locator, timeout = TEST_CONFIG.timeouts.default): Promise { + await locator.waitFor({ state: 'hidden', timeout }); + } + + async clickElement(locator: Locator): Promise { + await this.waitForVisible(locator); + await locator.click(); + } + + async fillInput(locator: Locator, value: string): Promise { + await this.waitForVisible(locator); + await locator.fill(value); + } + + async getText(locator: Locator): Promise { + await this.waitForVisible(locator); + return (await locator.textContent()) || ''; + } + + async isVisible(locator: Locator): Promise { + try { + await locator.waitFor({ state: 'visible', timeout: 5000 }); + return true; + } catch { + return false; + } + } + + async waitForNavigation(): Promise { + await this.page.waitForLoadState('domcontentloaded'); + } + + async screenshot(name: string): Promise { + await this.page.screenshot({ + path: `./test-results/screenshots/${name}.png`, + fullPage: true, + }); + } + + async reload(): Promise { + await this.page.reload(); + await this.waitForNavigation(); + } + + async getStorageValue(key: string): Promise { + return await this.page.evaluate(async (storageKey) => { + return new Promise((resolve) => { + chrome.storage.local.get(storageKey, (result) => { + resolve(result[storageKey]); + }); + }); + }, key); + } + + async setStorageValue(key: string, value: any): Promise { + await this.page.evaluate( + async ([storageKey, storageValue]) => { + return new Promise((resolve, reject) => { + chrome.storage.local.set({ [storageKey]: storageValue }, () => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(); + } + }); + }); + }, + [key, value], + ); + } +} diff --git a/e2e-playwright-tests/pages/extension/ContactsPage.ts b/e2e-playwright-tests/pages/extension/ContactsPage.ts new file mode 100644 index 000000000..1ef2f1350 --- /dev/null +++ b/e2e-playwright-tests/pages/extension/ContactsPage.ts @@ -0,0 +1,723 @@ +/** + * Contacts Page - Manage wallet contacts + */ +import { Page, Locator, expect } from '@playwright/test'; +import { BasePage } from './BasePage'; + +export class ContactsPage extends BasePage { + // Locators + readonly contactsPageTitle: Locator; + readonly addContactButton: Locator; + readonly searchContactInput: Locator; + readonly emptyStateMessage: Locator; + readonly noSearchResultsMessage: Locator; + readonly contactsList: Locator; + readonly contactListItem: Locator; + + // Add/Edit Contact Modal + readonly contactModal: Locator; + readonly contactNameInput: Locator; + readonly avalancheCChainInput: Locator; + readonly avalancheXPInput: Locator; + readonly bitcoinAddressInput: Locator; + readonly solanaAddressInput: Locator; + readonly saveContactButton: Locator; + readonly cancelButton: Locator; + readonly deleteContactButton: Locator; + + // Contact Details View + readonly contactDetailsModal: Locator; + readonly contactDetailsName: Locator; + readonly contactDetailsAvalancheCChain: Locator; + readonly contactDetailsAvalancheXP: Locator; + readonly contactDetailsBitcoin: Locator; + readonly contactDetailsSolana: Locator; + readonly copyAddressButton: Locator; + readonly editContactButton: Locator; + readonly closeDetailsButton: Locator; + + constructor(page: Page) { + super(page); + // Main page elements + this.contactsPageTitle = page.getByRole('heading', { name: /contacts/i }); + this.addContactButton = page.getByRole('button', { name: /add an address|add contact|new contact|\+/i }); + this.searchContactInput = page.locator('[data-testid="contact-search-input"], input[placeholder*="Search" i]'); + this.emptyStateMessage = page.locator('[data-testid="contacts-empty-state"], text=/no saved addresses/i'); + this.noSearchResultsMessage = page.locator( + '[data-testid="no-search-results"], text=/no contacts match your search/i', + ); + this.contactsList = page.locator('[data-testid="contacts-list"]'); + this.contactListItem = page.locator('[data-testid="contact-item"]'); + + // Add/Edit Contact Modal + this.contactModal = page.locator('[data-testid="contact-modal"], [role="dialog"]'); + this.contactNameInput = page.locator('[data-testid="contact-name-input"], input[name="name"]'); + this.avalancheCChainInput = page.locator( + '[data-testid="avalanche-c-chain-input"], input[name*="avalanche" i][name*="c" i], input[placeholder*="avalanche" i][placeholder*="c-chain" i]', + ); + this.avalancheXPInput = page.locator( + '[data-testid="avalanche-xp-input"], input[name*="avalanche" i][name*="xp" i], input[placeholder*="avalanche" i][placeholder*="x/p" i]', + ); + this.bitcoinAddressInput = page.locator( + '[data-testid="bitcoin-address-input"], input[name*="bitcoin" i], input[placeholder*="bitcoin" i]', + ); + this.solanaAddressInput = page.locator( + '[data-testid="solana-address-input"], input[name*="solana" i], input[placeholder*="solana" i]', + ); + this.saveContactButton = page.getByRole('button', { name: /save|add contact/i }); + this.cancelButton = page.getByRole('button', { name: /cancel/i }); + this.deleteContactButton = page.getByRole('button', { name: /delete/i }); + + // Contact Details View + this.contactDetailsModal = page.locator('[data-testid="contact-details-modal"]'); + this.contactDetailsName = page.locator('[data-testid="contact-details-name"]'); + // Contact details addresses - find by text content or data-testid + this.contactDetailsAvalancheCChain = page.locator( + '[data-testid="contact-details-avalanche-c"], div:has-text("0x"), div:has-text(/Avalanche C-Chain/i)', + ); + this.contactDetailsAvalancheXP = page.locator( + '[data-testid="contact-details-avalanche-xp"], div:has-text("avax"), div:has-text(/Avalanche X/P-Chain/i)', + ); + this.contactDetailsBitcoin = page.locator( + '[data-testid="contact-details-bitcoin"], div:has-text("bc1"), div:has-text(/Bitcoin/i)', + ); + this.contactDetailsSolana = page.locator('[data-testid="contact-details-solana"], div:has-text(/Solana/i)'); + this.copyAddressButton = page.getByRole('button', { name: /copy/i }); + this.editContactButton = page.getByRole('button', { name: /edit/i }); + this.closeDetailsButton = page.getByRole('button', { name: /close|back/i }); + } + + /** + * Navigate to contacts page + * Steps: Portfolio page → Settings button → Saved addresses option + */ + async navigateToContacts(): Promise { + // Fast URL check - if already on contacts page, return immediately + const currentUrl = this.page.url(); + if (currentUrl.includes('/contacts/list')) { + return; + } + + const settingsButton = this.page.locator('[data-testid="settings-button"]'); + + console.log(`ContactsPage: Current URL before navigation: ${currentUrl}`); + + // Quick check if Settings button is already visible (1 second max) + const isVisible = await settingsButton.isVisible({ timeout: 1000 }).catch(() => false); + console.log(`ContactsPage: Settings button visible: ${isVisible}`); + + if (!isVisible) { + // Settings button not visible, check current page state + console.log(`ContactsPage: Settings button not found, checking page state...`); + + // Take a snapshot of what's actually on the page + const pageTitle = await this.page.title().catch(() => 'unknown'); + console.log(`ContactsPage: Page title: ${pageTitle}`); + + // Check if we need to navigate to home + const needsNavigation = + !currentUrl.includes('popup.html#/home') && + !currentUrl.includes('home.html#/home') && + !currentUrl.includes('home.html#/'); + + if (needsNavigation) { + console.log(`ContactsPage: Navigating to home page...`); + await this.goto('home.html#/home'); + await this.page.waitForLoadState('domcontentloaded'); + await this.page.waitForTimeout(2000); + console.log(`ContactsPage: Navigation complete, current URL: ${this.page.url()}`); + } else { + console.log(`ContactsPage: Already on home page, waiting for UI to load...`); + await this.page.waitForTimeout(3000); + } + } + + // Wait for Settings button to be visible and clickable + console.log(`ContactsPage: Waiting for settings button...`); + await settingsButton.waitFor({ state: 'visible', timeout: 15000 }); + await settingsButton.click({ timeout: 10000 }); + + // Wait for settings menu and click "Saved addresses" + const savedAddressesOption = this.page.getByText('Saved addresses', { exact: false }); + await savedAddressesOption.waitFor({ state: 'visible', timeout: 10000 }); + await savedAddressesOption.scrollIntoViewIfNeeded(); + await savedAddressesOption.click(); + + // Wait for contacts page to load + await Promise.race([ + this.contactsPageTitle.waitFor({ state: 'visible', timeout: 10000 }), + this.page.waitForURL('**/contacts/list', { timeout: 10000 }), + ]); + } + + /** + * Check if we're on the contacts page + */ + async isOnContactsPage(): Promise { + // Check URL first (most reliable) + const currentUrl = this.page.url(); + if (currentUrl.includes('/contacts/list')) { + return true; + } + + // Fallback: check for contacts page title or "Contacts" text + const hasTitle = await this.isVisible(this.contactsPageTitle).catch(() => false); + if (hasTitle) { + return true; + } + + // Also check for "Contacts" text on the page + const contactsText = this.page.locator('text=/contacts/i').first(); + return await contactsText.isVisible({ timeout: 2000 }).catch(() => false); + } + + /** + * Check if empty state is displayed + */ + async isEmptyStateVisible(): Promise { + const emptyState = this.page.locator('[data-testid="contacts-empty-state"]'); + return await emptyState.isVisible({ timeout: 2000 }).catch(() => false); + } + + /** + * Add a new contact with all address types + */ + async addContact(contactData: { + name: string; + avalancheCChain?: string; + avalancheXP?: string; + bitcoin?: string; + solana?: string; + }): Promise { + // Click "Add an address" button + await this.clickElement(this.addContactButton); + + // Wait for the add contact page to load - wait for "Name this contact" button + const nameContactButton = this.page.getByRole('button', { name: /name this contact/i }); + await nameContactButton.waitFor({ state: 'visible', timeout: 10000 }); + await nameContactButton.click(); + + // Wait for name input to appear and fill it + const nameInput = this.page.locator('input[type="text"], input[name="name"], input').first(); + await nameInput.waitFor({ state: 'visible', timeout: 10000 }); + await nameInput.fill(contactData.name); + + // Add Avalanche C-Chain address if provided + if (contactData.avalancheCChain) { + const addCChainButton = this.page.getByRole('button', { name: /add avalanche c-chain address/i }); + await addCChainButton.waitFor({ state: 'visible', timeout: 5000 }); + await addCChainButton.click(); + // Wait for input to appear - try multiple selectors + const cChainInput = this.page.locator('input[type="text"]').last(); + await cChainInput.waitFor({ state: 'visible', timeout: 5000 }); + await cChainInput.fill(contactData.avalancheCChain); + } + + // Add Avalanche X/P-Chain address if provided + if (contactData.avalancheXP) { + const addXPButton = this.page.getByRole('button', { name: /add avalanche x\/p-chain address/i }); + await addXPButton.waitFor({ state: 'visible', timeout: 5000 }); + await addXPButton.click(); + // Wait for input to appear + const xpInput = this.page.locator('input[type="text"]').last(); + await xpInput.waitFor({ state: 'visible', timeout: 5000 }); + await xpInput.fill(contactData.avalancheXP); + } + + // Add Bitcoin address if provided + if (contactData.bitcoin) { + const addBitcoinButton = this.page.getByRole('button', { name: /add bitcoin address/i }); + await addBitcoinButton.waitFor({ state: 'visible', timeout: 5000 }); + await addBitcoinButton.click(); + // Wait for input to appear + const bitcoinInput = this.page.locator('input[type="text"]').last(); + await bitcoinInput.waitFor({ state: 'visible', timeout: 5000 }); + await bitcoinInput.fill(contactData.bitcoin); + } + + // Add Solana address if provided + if (contactData.solana) { + const addSolanaButton = this.page.getByRole('button', { name: /add solana address/i }); + await addSolanaButton.waitFor({ state: 'visible', timeout: 5000 }); + await addSolanaButton.click(); + // Wait for input to appear + const solanaInput = this.page.locator('input[type="text"]').last(); + await solanaInput.waitFor({ state: 'visible', timeout: 5000 }); + await solanaInput.fill(contactData.solana); + } + + // Click save button + await this.clickElement(this.saveContactButton); + + // Wait for "Contact created" message to appear + const successMessage = this.page.locator('text=/contact created/i'); + await successMessage.waitFor({ state: 'visible', timeout: 10000 }); + + // Navigate back to contacts list using back button + await this.navigateBackToContactsList(); + } + + /** + * Search for a contact + */ + async searchContact(searchTerm: string): Promise { + await this.searchContactInput.fill(searchTerm); + // Wait for search results to update - wait for contact list or empty state to update + await Promise.race([ + this.page + .locator('div[role="button"]') + .first() + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(() => {}), + this.page + .locator('text=/no saved addresses|no contacts match/i') + .first() + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(() => {}), + this.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {}), + ]); + } + + /** + * Clear search input + */ + async clearSearch(): Promise { + await this.searchContactInput.clear(); + // Wait for search to clear - wait for contact list to update + await Promise.race([ + this.page + .locator('div[role="button"]') + .first() + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(() => {}), + this.page.waitForLoadState('networkidle', { timeout: 1000 }).catch(() => {}), + ]); + } + + /** + * Get list of visible contacts + */ + async getVisibleContacts(): Promise { + await this.contactListItem + .first() + .waitFor({ state: 'visible', timeout: 5000 }) + .catch(() => {}); + return await this.contactListItem.all(); + } + + /** + * Get contact count + */ + async getContactCount(): Promise { + // Check if empty state is visible + if (await this.isEmptyStateVisible()) { + return 0; + } + + // Get search term if present + const searchInputValue = await this.searchContactInput.inputValue().catch(() => ''); + const searchTermLower = searchInputValue.trim().toLowerCase(); + const hasSearchTerm = searchTermLower.length > 0; + + // Count visible contact items (div[role="button"] elements containing addresses) + const contactItems = this.page.locator('div[role="button"]'); + const allItems = await contactItems.all(); + + let count = 0; + for (const item of allItems) { + const isVisible = await item.isVisible().catch(() => false); + if (!isVisible) continue; + + const text = await item.textContent().catch(() => ''); + if (!text) continue; + + // Only count items that contain crypto addresses + const hasAddress = text.includes('0x') || text.includes('avax') || text.includes('bc1'); + if (!hasAddress) continue; + + // If there's a search term, only count matching contacts + if (hasSearchTerm) { + if (text.toLowerCase().includes(searchTermLower)) { + count++; + } + } else { + count++; + } + } + + return count; + } + + /** + * Click on a contact by name + */ + async clickContactByName(contactName: string): Promise { + // Contact items are div[role="button"] elements that contain the contact name + const contactItem = this.page.locator(`div[role="button"]:has-text("${contactName}")`); + await this.clickElement(contactItem); + } + + /** + * View contact details + */ + async viewContactDetails(contactName: string): Promise { + await this.clickContactByName(contactName); + + // Check if we navigated to a details page + const currentUrl = this.page.url(); + if (currentUrl.includes('/contacts/details')) { + // Navigated to details page, wait for it to load + await this.page.waitForLoadState('domcontentloaded'); + // Wait for contact name to be visible + await this.contactDetailsName.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}); + } else { + // Try waiting for modal + await this.contactDetailsModal.waitFor({ state: 'visible', timeout: 5000 }).catch(() => { + // If modal not found, wait for contact name instead + return this.contactDetailsName.waitFor({ state: 'visible', timeout: 5000 }); + }); + } + } + + /** + * Edit an existing contact + */ + async editContact( + currentName: string, + updatedData: { + name?: string; + avalancheCChain?: string; + avalancheXP?: string; + bitcoin?: string; + solana?: string; + }, + ): Promise { + await this.viewContactDetails(currentName); + + // Wait for details page to load - wait for contact name to be visible + await this.contactDetailsName.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}); + + // Click on each chain label to expand addresses if needed + const chainLabels = ['Avalanche C-Chain', 'Avalanche X/P-Chain', 'Bitcoin', 'Solana']; + for (let i = 0; i < chainLabels.length; i++) { + const label = chainLabels[i]; + const labelElement = this.page.getByText(label, { exact: false }).first(); + await labelElement.waitFor({ state: 'visible', timeout: 5000 }); + await labelElement.click(); + + // After clicking the last label, click elsewhere to validate addresses + if (i === chainLabels.length - 1) { + const contactNameElement = this.page.getByText(currentName, { exact: false }).first(); + const nameVisible = await contactNameElement.isVisible({ timeout: 500 }).catch(() => false); + if (nameVisible) { + await contactNameElement.click(); + } else { + await this.page.click('body', { position: { x: 10, y: 10 } }); + } + // Wait for input fields to appear after clicking + await this.page + .locator('input') + .first() + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(() => {}); + } + } + + // Wait for input fields to appear (page is already in edit mode) + await this.page.locator('input').first().waitFor({ state: 'visible', timeout: 3000 }); + + // Step 1: Edit contact name first + if (updatedData.name !== undefined) { + const nameInput = this.page.locator('input').nth(0); + await nameInput.waitFor({ state: 'visible', timeout: 3000 }); + await nameInput.click(); + await nameInput.clear(); // Remove existing value + await nameInput.fill(updatedData.name); // Add new value + } + + // Step 2: Edit token addresses (remove existing value and add new one) + if (updatedData.avalancheCChain !== undefined) { + const cChainInput = this.page.locator('input').nth(1); + await cChainInput.waitFor({ state: 'visible', timeout: 3000 }); + await cChainInput.click(); + await cChainInput.clear(); // Remove existing value + await cChainInput.fill(updatedData.avalancheCChain); // Add new value + } + + if (updatedData.avalancheXP !== undefined) { + const xpInput = this.page.locator('input').nth(2); + await xpInput.waitFor({ state: 'visible', timeout: 3000 }); + await xpInput.click(); + await xpInput.clear(); // Remove existing value + await xpInput.fill(updatedData.avalancheXP); // Add new value + } + + if (updatedData.bitcoin !== undefined) { + const bitcoinInput = this.page.locator('input').nth(3); + await bitcoinInput.waitFor({ state: 'visible', timeout: 3000 }); + await bitcoinInput.click(); + await bitcoinInput.clear(); // Remove existing value + await bitcoinInput.fill(updatedData.bitcoin); // Add new value + } + + if (updatedData.solana !== undefined) { + const solanaInput = this.page.locator('input').nth(4); + await solanaInput.waitFor({ state: 'visible', timeout: 3000 }); + await solanaInput.click(); + await solanaInput.clear(); // Remove existing value + await solanaInput.fill(updatedData.solana); // Add new value + } + + // Click save button + await this.clickElement(this.saveContactButton); + + // Wait for "Contact updated" message to appear + const successMessage = this.page.locator('text=/contact updated/i'); + await successMessage.waitFor({ state: 'visible', timeout: 10000 }); + + // Navigate back to contacts list using back button + await this.navigateBackToContactsList(); + } + + /** + * Delete a contact + */ + async deleteContact(contactName: string): Promise { + await this.viewContactDetails(contactName); + + // Click delete button on contact details page + await this.deleteContactButton.waitFor({ state: 'visible', timeout: 5000 }); + await this.deleteContactButton.click(); + + // Wait for and click confirm delete button on confirmation page + const confirmDeleteButton = this.page.locator('[data-testid="confirm-delete-contact-button"]'); + await confirmDeleteButton.waitFor({ state: 'visible', timeout: 10000 }); + await confirmDeleteButton.click(); + + // Verify navigation back to contacts list using URL (delete automatically navigates back) + await this.page.waitForURL('**/contacts/list', { timeout: 10000 }).catch(() => { + // If URL check fails, verify by checking for contacts list page element + return this.page.locator('[data-testid="contacts-list-page"]').waitFor({ state: 'visible', timeout: 5000 }); + }); + } + + /** + * Copy address from contact details + */ + async copyAddressFromDetails(addressType: 'avalancheCChain' | 'avalancheXP' | 'bitcoin' | 'solana'): Promise { + let addressLocator: Locator; + + switch (addressType) { + case 'avalancheCChain': + addressLocator = this.contactDetailsAvalancheCChain; + break; + case 'avalancheXP': + addressLocator = this.contactDetailsAvalancheXP; + break; + case 'bitcoin': + addressLocator = this.contactDetailsBitcoin; + break; + case 'solana': + addressLocator = this.contactDetailsSolana; + break; + } + + // Find the copy button associated with this address + const copyButton = addressLocator.locator('..').getByRole('button', { name: /copy/i }); + await this.clickElement(copyButton); + } + + /** + * Get contact details from the details modal + */ + async getContactDetailsFromModal(): Promise<{ + name: string; + avalancheCChain: string; + avalancheXP: string; + bitcoin: string; + solana: string; + }> { + return { + name: await this.getText(this.contactDetailsName), + avalancheCChain: await this.getText(this.contactDetailsAvalancheCChain), + avalancheXP: await this.getText(this.contactDetailsAvalancheXP), + bitcoin: await this.getText(this.contactDetailsBitcoin), + solana: await this.getText(this.contactDetailsSolana), + }; + } + + /** + * Check if no search results message is displayed + */ + async isNoSearchResultsVisible(): Promise { + // Check if there's an active search term + const searchInputValue = await this.searchContactInput.inputValue().catch(() => ''); + const hasSearchTerm = searchInputValue.trim().length > 0; + + if (!hasSearchTerm) { + return false; + } + + // If there's a search term and no contacts found, that means search returned no results + const contactCount = await this.getContactCount(); + return contactCount === 0; + } + + /** + * Close contact details modal + */ + async closeContactDetails(): Promise { + await this.clickElement(this.closeDetailsButton); + await this.contactDetailsModal.waitFor({ state: 'hidden', timeout: 5000 }); + } + + /** + * Get locator for Avalanche C-Chain address field + */ + getAvalancheCChainAddressLocator(): Locator { + return this.page.locator('[data-testid="contact-address-c-chain"]'); + } + + /** + * Get locator for Avalanche X/P-Chain address field + */ + getAvalancheXPAddressLocator(): Locator { + return this.page.locator('[data-testid="contact-address-xp-chain"]'); + } + + /** + * Get locator for Bitcoin address field + */ + getBitcoinAddressLocator(): Locator { + return this.page.locator('[data-testid="contact-address-bitcoin"]'); + } + + /** + * Get locator for Solana address field + */ + getSolanaAddressLocator(): Locator { + return this.page.locator('[data-testid="contact-address-solana"]'); + } + + /** + * Ensure a contact exists, creating it if it doesn't + */ + async ensureContactExists(contactData: { + name: string; + avalancheCChain?: string; + avalancheXP?: string; + bitcoin?: string; + solana?: string; + }): Promise { + const contactCount = await this.getContactCount(); + if (contactCount === 0) { + await this.addContact(contactData); + } else { + // Check if contact with this name exists + const contactItem = this.page.locator(`text="${contactData.name}"`); + const exists = await contactItem.isVisible({ timeout: 2000 }).catch(() => false); + if (!exists) { + await this.addContact(contactData); + } + } + } + + /** + * Expand addresses in contact details by clicking chain labels + */ + async expandAddressesInDetails(contactName: string): Promise { + // Wait for contact name to be visible first + await this.contactDetailsName.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}); + const chainLabels = ['Avalanche C-Chain', 'Avalanche X/P-Chain', 'Bitcoin', 'Solana']; + + for (let i = 0; i < chainLabels.length; i++) { + const label = chainLabels[i]; + const labelElement = this.page.getByText(label, { exact: false }).first(); + await labelElement.waitFor({ state: 'visible', timeout: 5000 }); + await labelElement.click(); + + // After clicking the last label, click elsewhere to validate addresses + if (i === chainLabels.length - 1) { + const contactNameElement = this.page.getByText(contactName, { exact: false }).first(); + const nameVisible = await contactNameElement.isVisible({ timeout: 500 }).catch(() => false); + if (nameVisible) { + await contactNameElement.click(); + } else { + await this.page.click('body', { position: { x: 10, y: 10 } }); + } + // Wait for input fields to appear + await this.page + .locator('input') + .first() + .waitFor({ state: 'visible', timeout: 3000 }) + .catch(() => {}); + } + } + // Wait for all input fields to be ready + await this.page.locator('input').first().waitFor({ state: 'visible', timeout: 3000 }); + } + + /** + * Hover over chain labels to reveal copy buttons + */ + async hoverOverChainLabels(): Promise { + const chainLabels = ['Avalanche C-Chain', 'Avalanche X/P-Chain', 'Bitcoin', 'Solana']; + for (const label of chainLabels) { + const labelElement = this.page.getByText(label, { exact: false }).first(); + await labelElement.waitFor({ state: 'visible', timeout: 5000 }); + await labelElement.hover(); + // Wait for copy button to appear after hover + await this.page + .getByRole('button', { name: /copy/i }) + .first() + .waitFor({ state: 'visible', timeout: 2000 }) + .catch(() => {}); + } + } + + /** + * Verify a contact is visible in the list + */ + async verifyContactVisible(contactName: string, timeout = 10000): Promise { + const contactItem = this.page.locator(`text="${contactName}"`); + await contactItem.waitFor({ state: 'visible', timeout }); + } + + /** + * Verify a contact is deleted (not visible in the list) + */ + async verifyContactDeleted(contactName: string, timeout = 5000): Promise { + const contactItem = this.page.locator(`text="${contactName}"`).first(); + await expect(contactItem).not.toBeVisible({ timeout }); + } + + /** + * Search for a contact and verify it's visible + */ + async searchAndVerifyContact(searchTerm: string, expectedContactName: string): Promise { + await this.searchContact(searchTerm); + await this.verifyContactVisible(expectedContactName); + } + + /** + * Navigate back to contacts list, handling page closing + */ + async navigateBackToContactsList(): Promise { + // Check if already on contacts list page + const currentUrl = this.page.url(); + if (currentUrl.includes('/contacts/list')) { + return; + } + + // Click back button to navigate back + const backBtn = this.page.locator('[data-testid="page-back-button"]'); + await backBtn.waitFor({ state: 'visible', timeout: 5000 }); + await backBtn.click(); + + // Verify navigation to contacts list page using URL + await this.page.waitForURL('**/contacts/list', { timeout: 10000 }).catch(() => { + // If URL check fails, verify by checking for contacts list page element + return this.page.locator('[data-testid="contacts-list-page"]').waitFor({ state: 'visible', timeout: 5000 }); + }); + } +} diff --git a/e2e-playwright-tests/pages/extension/OnboardingPage.ts b/e2e-playwright-tests/pages/extension/OnboardingPage.ts new file mode 100644 index 000000000..c18bf0a0d --- /dev/null +++ b/e2e-playwright-tests/pages/extension/OnboardingPage.ts @@ -0,0 +1,441 @@ +/** + * Onboarding Page - First-time setup and wallet creation/import + */ +import { Page, Locator } from '@playwright/test'; +import { BasePage } from './BasePage'; + +export class OnboardingPage extends BasePage { + // Locators + readonly coreLogo: Locator; + readonly continueWithGoogleButton: Locator; + readonly continueWithAppleButton: Locator; + readonly createWalletButton: Locator; + readonly importWalletButton: Locator; + readonly passwordInput: Locator; + readonly confirmPasswordInput: Locator; + readonly termsCheckbox: Locator; + readonly continueButton: Locator; + readonly backButton: Locator; + readonly recoveryPhraseDisplay: Locator; + readonly recoveryPhraseInput: Locator; + readonly confirmRecoveryButton: Locator; + readonly finishButton: Locator; + // Import wallet method options + readonly recoveryPhraseOption: Locator; + readonly ledgerOption: Locator; + readonly keystoneOption: Locator; + // Recovery phrase form elements + readonly phraseLengthSelectorButton: Locator; + readonly wordCount12Option: Locator; + readonly wordCount24Option: Locator; + readonly clearAllButton: Locator; + readonly nextButton: Locator; + readonly recoveryPhraseErrorMessage: Locator; + readonly recoveryPhraseWordInputs: Locator; + // Wallet details page elements + readonly walletNameInput: Locator; + readonly unlockAirdropsToggle: Locator; + readonly enterPasswordInput: Locator; + readonly confirmPasswordInputField: Locator; + readonly passwordStrengthMessage: Locator; + readonly passwordLengthError: Locator; + readonly weakPasswordMessage: Locator; + readonly newsletterCheckbox: Locator; + readonly newsletterEmailInput: Locator; + readonly newsletterEmailError: Locator; + readonly privacyPolicyLink: Locator; + readonly termsOfUseCheckbox: Locator; + readonly termsOfUseLink: Locator; + // Customize Core page elements + readonly customizeCoreTitle: Locator; + readonly floatingViewOption: Locator; + readonly sidebarViewOption: Locator; + // Select Avatar page elements + readonly selectAvatarTitle: Locator; + readonly avatarOptions: Locator; + // Enjoy Your Wallet page elements + readonly enjoyWalletTitle: Locator; + readonly letsGoButton: Locator; + // Create new wallet flow elements + readonly newSeedphraseTitle: Locator; + readonly seedphraseWords: Locator; + readonly copyPhraseButton: Locator; + readonly createWalletTermsCheckbox: Locator; + readonly verifySeedphraseTitle: Locator; + readonly seedphraseVerificationButtons: Locator; + + // prettier-ignore + constructor(page: Page) { + super(page); + // Onboarding screen elements + this.coreLogo = page.locator('[data-testid="core-logo"]'); + this.continueWithGoogleButton = page.getByRole('button', { name: /continue with google/i }); + this.continueWithAppleButton = page.getByRole('button', { name: /continue with apple/i }); + this.createWalletButton = page.getByRole('button', { name: /manually create new wallet/i }); + this.importWalletButton = page.getByRole('button', { name: /access existing wallet/i }); + // Wallet setup elements + this.passwordInput = page.locator('[data-testid="password-input"]'); + this.confirmPasswordInput = page.locator('[data-testid="confirm-password-input"]'); + this.termsCheckbox = page.locator('[data-testid="terms-checkbox"]'); + this.continueButton = page.getByRole('button', { name: /continue|next/i }); + this.backButton = page.getByRole('button', { name: /back/i }); + this.recoveryPhraseDisplay = page.locator('[data-testid="recovery-phrase"]'); + this.recoveryPhraseInput = page.locator('[data-testid="recovery-phrase-input"]'); + this.confirmRecoveryButton = page.getByRole('button', { name: /confirm|verify/i }); + this.finishButton = page.getByRole('button', { name: /finish|done/i }); + // Import wallet method options + this.recoveryPhraseOption = page.locator('[data-testid="import-recovery-phrase-option"]'); + this.ledgerOption = page.locator('[data-testid="import-ledger-option"]'); + this.keystoneOption = page.locator('[data-testid="import-keystone-option"]'); + // Recovery phrase form elements + this.phraseLengthSelectorButton = page.locator('[data-testid="onboarding-phrase-length-selector"]'); + // Use role-based selectors for popover menu items + this.wordCount12Option = page.getByRole('menuitem', { name: '12-word phrase' }); + this.wordCount24Option = page.getByRole('menuitem', { name: '24-word phrase' }); + this.clearAllButton = page.getByRole('button', { name: /clear all/i }); + this.nextButton = page.getByRole('button', { name: /next/i }); + this.recoveryPhraseErrorMessage = page.locator('[data-testid="recovery-phrase-error-message"]'); + this.recoveryPhraseWordInputs = page.locator('input[type="text"], input[type="password"]'); + // Wallet details page elements + this.walletNameInput = page.locator('[data-testid="wallet-name-input"]'); + this.unlockAirdropsToggle = page.getByRole('checkbox', { name: /unlock airdrops/i }); + this.enterPasswordInput = page.locator('[data-testid="enter-password-input"]'); + this.confirmPasswordInputField = page.locator('[data-testid="confirm-password-input"]'); + this.passwordStrengthMessage = page.locator('[data-testid="password-strength-message"]'); + this.passwordLengthError = page.locator('[data-testid="password-length-error"]'); + this.weakPasswordMessage = page.locator('[data-testid="weak-password-message"]'); + this.newsletterCheckbox = page.getByRole('checkbox', { name: /stay updated/i }); + this.newsletterEmailInput = page.locator('[data-testid="newsletter-email-input"]'); + this.newsletterEmailError = page.locator('[data-testid="newsletter-email-error"]'); + this.privacyPolicyLink = page.getByRole('link', { name: /privacy policy/i }); + this.termsOfUseCheckbox = page.getByRole('checkbox', { name: /i have read and agree/i }); + this.termsOfUseLink = page.getByRole('link', { name: /terms of use/i }); + // Customize Core page elements + this.customizeCoreTitle = page.getByRole('heading', { name: /customize core to your liking/i }); + this.floatingViewOption = page.locator('[data-testid="floating-view-option"]'); + this.sidebarViewOption = page.locator('[data-testid="sidebar-view-option"]'); + // Select Avatar page elements + this.selectAvatarTitle = page.getByRole('heading', { name: /select your personal avatar/i }); + this.avatarOptions = page.locator('[data-testid="avatar-option"]'); + // Enjoy Your Wallet page elements + this.enjoyWalletTitle = page.locator('[data-testid="enjoy-wallet-title"]'); + this.letsGoButton = page.getByRole('button', { name: /let's go/i }); + // Create new wallet flow elements + this.newSeedphraseTitle = page.getByRole('heading', { name: /here is your wallet's recovery phrase/i }); + this.seedphraseWords = page.locator('[data-testid="seedphrase-word"]'); + this.copyPhraseButton = page.getByRole('button', { name: /copy phrase/i }); + this.createWalletTermsCheckbox = page.locator('input[type="checkbox"]').last(); + this.verifySeedphraseTitle = page.getByRole('heading', { name: /verify your recovery phrase/i }); + this.seedphraseVerificationButtons = page.getByRole('button', { name: /^[a-z]+$/i }); + } + + /** + * Check if we're on the onboarding page + */ + async isOnOnboardingPage(): Promise { + // Check if Core logo and all onboarding screen buttons are visible + const isCoreLogoVisible = await this.isVisible(this.coreLogo); + const isImportButtonVisible = await this.isVisible(this.importWalletButton); + const isCreateButtonVisible = await this.isVisible(this.createWalletButton); + + return isCoreLogoVisible && isImportButtonVisible && isCreateButtonVisible; + } + + /** + * Start create wallet flow + */ + async startCreateWallet(): Promise { + await this.clickElement(this.createWalletButton); + } + + /** + * Start import wallet flow + */ + async startImportWallet(): Promise { + await this.clickElement(this.importWalletButton); + } + + /** + * Set password + */ + async setPassword(password: string): Promise { + await this.fillInput(this.passwordInput, password); + await this.fillInput(this.confirmPasswordInput, password); + } + + /** + * Accept terms and conditions + */ + async acceptTerms(): Promise { + await this.clickElement(this.termsCheckbox); + } + + /** + * Click continue button + */ + async clickContinue(): Promise { + await this.clickElement(this.continueButton); + } + + /** + * Get recovery phrase displayed during wallet creation + */ + async getRecoveryPhrase(): Promise { + await this.waitForVisible(this.recoveryPhraseDisplay); + return await this.getText(this.recoveryPhraseDisplay); + } + + /** + * Enter recovery phrase for import or confirmation + */ + async enterRecoveryPhrase(phrase: string): Promise { + await this.fillInput(this.recoveryPhraseInput, phrase); + } + + /** + * Confirm recovery phrase + */ + async confirmRecoveryPhrase(): Promise { + await this.clickElement(this.confirmRecoveryButton); + } + + /** + * Finish onboarding + */ + async finish(): Promise { + await this.clickElement(this.finishButton); + } + + /** + * Complete create wallet flow + * @param password - Password for the wallet + * @returns Recovery phrase + */ + async createWallet(password: string): Promise { + console.log('Creating new wallet...'); + + await this.startCreateWallet(); + await this.setPassword(password); + await this.acceptTerms(); + await this.clickContinue(); + + // Get recovery phrase + const recoveryPhrase = await this.getRecoveryPhrase(); + console.log('Recovery phrase obtained'); + + await this.clickContinue(); + + // Confirm recovery phrase (some wallets require re-entry) + // This might need adjustment based on your actual flow + await this.enterRecoveryPhrase(recoveryPhrase); + await this.confirmRecoveryPhrase(); + + await this.finish(); + + console.log('Wallet created'); + return recoveryPhrase; + } + + /** + * Complete import wallet flow + * @param recoveryPhrase - Recovery phrase to import + * @param password - Password for the wallet + */ + async importWallet(recoveryPhrase: string, password: string): Promise { + console.log('Importing wallet...'); + + await this.startImportWallet(); + await this.enterRecoveryPhrase(recoveryPhrase); + await this.clickContinue(); + + await this.setPassword(password); + await this.acceptTerms(); + await this.clickContinue(); + + await this.finish(); + + console.log('Wallet imported'); + } + + /** + * Navigate to recovery phrase import screen + */ + async navigateToRecoveryPhraseScreen(): Promise { + await this.clickElement(this.importWalletButton); + await this.recoveryPhraseOption.waitFor({ state: 'visible' }); + await this.clickElement(this.recoveryPhraseOption); + await this.phraseLengthSelectorButton.waitFor({ state: 'visible' }); + } + + /** + * Select word count (12 or 24) + * @param wordCount - Number of words (12 or 24) + */ + async selectWordCount(wordCount: 12 | 24): Promise { + await this.phraseLengthSelectorButton.click(); + const option = wordCount === 12 ? this.wordCount12Option : this.wordCount24Option; + await option.waitFor({ state: 'visible', timeout: 10000 }); + await option.click(); + console.log(`${wordCount}-word option selected from dropdown`); + } + + /** + * Wait for and get recovery phrase word inputs + * @param expectedCount - Expected number of input fields + */ + async getRecoveryPhraseInputs(expectedCount: number) { + await this.recoveryPhraseWordInputs.first().waitFor({ state: 'visible' }); + await this.page + .locator('input[type="text"], input[type="password"]') + .nth(expectedCount - 1) + .waitFor({ state: 'visible', timeout: 5000 }); + return await this.recoveryPhraseWordInputs.all(); + } + + /** + * Fill recovery phrase words + * @param words - Array of words to fill + */ + async fillRecoveryPhrase(words: string[]): Promise { + const inputs = await this.getRecoveryPhraseInputs(words.length); + for (let i = 0; i < words.length; i++) { + await inputs[i].fill(words[i]); + } + await inputs[words.length - 1].blur(); + console.log(`Typed ${words.length}-word recovery phrase`); + } + + /** + * Test password validation scenarios + * @param walletPassword - Final password to use + */ + async testPasswordValidation(walletPassword: string): Promise { + await this.enterPasswordInput.fill('weak'); + await this.confirmPasswordInputField.fill('weak'); + + await this.page.waitForSelector('text=/password must be at least 8 characters/i', { + state: 'visible', + }); + + await this.enterPasswordInput.clear(); + await this.confirmPasswordInputField.clear(); + await this.enterPasswordInput.fill('weakpass'); + await this.confirmPasswordInputField.fill('weakpass'); + await this.confirmPasswordInputField.blur(); + + await this.page.waitForSelector('text=/weak password! try adding more characters/i', { + state: 'visible', + }); + + await this.enterPasswordInput.clear(); + await this.confirmPasswordInputField.clear(); + await this.enterPasswordInput.fill('Average123!@#'); + await this.confirmPasswordInputField.fill('Average123!@#'); + await this.confirmPasswordInputField.blur(); + + await this.page.waitForSelector('text=/weak password! try adding more characters/i', { + state: 'hidden', + }); + + await this.enterPasswordInput.clear(); + await this.confirmPasswordInputField.clear(); + await this.enterPasswordInput.fill(walletPassword); + await this.confirmPasswordInputField.fill(walletPassword); + } + + /** + * Verify and test wallet details page + * @param walletName - Name for the wallet + * @param password - Wallet password + */ + async fillWalletDetails(walletName: string, password: string): Promise { + await this.walletNameInput.waitFor({ state: 'visible', timeout: 10000 }); + await this.walletNameInput.fill(walletName); + + await this.testPasswordValidation(password); + + await this.enterPasswordInput.clear(); + await this.confirmPasswordInputField.clear(); + await this.enterPasswordInput.fill(password); + await this.confirmPasswordInputField.fill('differentPassword'); + await this.termsOfUseCheckbox.check(); + + await this.confirmPasswordInputField.clear(); + await this.confirmPasswordInputField.fill(password); + } + + /** + * Verify policy links navigation + */ + async verifyPolicyLinks(): Promise { + try { + const [privacyPolicyPage] = await Promise.all([ + this.page.context().waitForEvent('page', { timeout: 10000 }), + this.privacyPolicyLink.click(), + ]); + await privacyPolicyPage.waitForLoadState('domcontentloaded'); + await privacyPolicyPage.close(); + } catch (_error) { + console.log('Privacy policy link verification skipped (page did not open)'); + } + + try { + const [termsOfUsePage] = await Promise.all([ + this.page.context().waitForEvent('page', { timeout: 10000 }), + this.termsOfUseLink.click(), + ]); + await termsOfUsePage.waitForLoadState('domcontentloaded'); + await termsOfUsePage.close(); + } catch (_error) { + console.log('Terms of use link verification skipped (page did not open)'); + } + } + + /** + * Test email validation if newsletter is visible + */ + async testNewsletterEmail(): Promise { + await this.unlockAirdropsToggle.click(); + await this.page.waitForTimeout(500); // Wait for toggle animation + + const isNewsletterVisible = await this.newsletterCheckbox.isVisible({ timeout: 5000 }).catch(() => false); + + if (isNewsletterVisible) { + await this.newsletterCheckbox.check(); + await this.newsletterEmailInput.waitFor({ state: 'visible', timeout: 5000 }); + + await this.newsletterEmailInput.fill('invalidemail'); + await this.newsletterEmailInput.blur(); + await this.newsletterEmailError.waitFor({ state: 'visible', timeout: 5000 }).catch(() => { + console.log('Newsletter email error not shown'); + }); + + await this.newsletterEmailInput.clear(); + await this.newsletterEmailInput.fill('test@example.com'); + await this.newsletterEmailInput.blur(); + } else { + console.log('Newsletter checkbox not visible, skipping email validation test'); + } + } + + /** + * Complete post-wallet setup pages (Customize, Avatar, Enjoy) + */ + async completePostWalletSetup(): Promise { + await this.nextButton.click(); + + await this.customizeCoreTitle.waitFor({ state: 'visible', timeout: 10000 }); + await this.floatingViewOption.click(); + await this.nextButton.click(); + + await this.selectAvatarTitle.waitFor({ state: 'visible', timeout: 10000 }); + await this.avatarOptions.first().waitFor({ state: 'visible', timeout: 10000 }); + const avatars = await this.avatarOptions.all(); + await avatars[0].click(); + await this.nextButton.click(); + + await this.enjoyWalletTitle.waitFor({ state: 'visible', timeout: 60000 }); + console.log('Wallet creation completed - enjoy wallet screen shown'); + await this.letsGoButton.click(); + } +} diff --git a/e2e-playwright-tests/playwright.config.ts b/e2e-playwright-tests/playwright.config.ts new file mode 100644 index 000000000..3d4a7a583 --- /dev/null +++ b/e2e-playwright-tests/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from '@playwright/test'; +import * as path from 'node:path'; +import * as dotenv from 'dotenv'; + +// Load environment variables +dotenv.config({ path: path.resolve(__dirname, '.env') }); + +export default defineConfig({ + globalSetup: require.resolve('./config/global-setup.ts'), + testDir: './tests', + testMatch: '**/*.spec.ts', + outputDir: './test-results', + timeout: 120000, + expect: { timeout: 10000 }, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 1, + workers: process.env.CI ? 1 : undefined, + reporter: [process.env.CI ? ['junit', { outputFile: './test-results/junit-report.xml' }] : ['html'], ['list']], + + // Shared settings for all projects + use: { + trace: 'on-first-retry', + video: 'on-first-retry', + headless: process.env.HEADLESS === 'true', + bypassCSP: true, + navigationTimeout: 45000, + actionTimeout: 45000, + ignoreHTTPSErrors: true, + screenshot: 'only-on-failure', + viewport: { width: 1920, height: 1080 }, + }, + + projects: [ + { + name: 'chromium', + use: { + channel: 'chromium', + }, + }, + ], +}); diff --git a/e2e-playwright-tests/tests/contacts.spec.ts b/e2e-playwright-tests/tests/contacts.spec.ts new file mode 100644 index 000000000..9be5df8ce --- /dev/null +++ b/e2e-playwright-tests/tests/contacts.spec.ts @@ -0,0 +1,367 @@ +import { test, expect } from '../fixtures/extension.fixture'; +import { ContactsPage } from '../pages/extension/ContactsPage'; +import { TEST_CONFIG } from '../constants'; + +test.describe('Contacts', () => { + test( + 'As a CORE ext user, when I have no contacts I see an empty state', + { + tag: '@smoke', + annotation: [ + { type: 'snapshot', description: 'mainnetPrimaryExtWallet' }, + { type: 'testrail_case_field', description: 'custom_automation_id:EXT-CONTACTS-001' }, + ], + }, + async ({ unlockedExtensionPage }) => { + const contactsPage = new ContactsPage(unlockedExtensionPage); + await contactsPage.navigateToContacts(); + + expect(await contactsPage.isOnContactsPage()).toBe(true); + expect(await contactsPage.isEmptyStateVisible()).toBe(true); + expect(await contactsPage.getContactCount()).toBe(0); + }, + ); + + test( + 'As a CORE ext user, I can add a new contact', + { + tag: '@smoke', + annotation: [ + { type: 'snapshot', description: 'mainnetPrimaryExtWallet' }, + { type: 'testrail_case_field', description: 'custom_automation_id:EXT-CONTACTS-002' }, + ], + }, + async ({ unlockedExtensionPage }) => { + const contactsPage = new ContactsPage(unlockedExtensionPage); + const contact1 = TEST_CONFIG.testData.contacts.contact1; + + await contactsPage.navigateToContacts(); + await contactsPage.addContact({ + name: contact1.name, + avalancheCChain: contact1.avalancheCChain, + avalancheXP: contact1.avalancheXP, + bitcoin: contact1.bitcoin, + solana: contact1.solana, + }); + + await contactsPage.navigateBackToContactsList(); + await contactsPage.verifyContactVisible(contact1.name); + expect(await contactsPage.getContactCount()).toBeGreaterThanOrEqual(1); + }, + ); + + test( + 'As a CORE ext user, I can see Avalanche CXP Chain, BTC, and Solana addresses for contacts', + { + tag: '@smoke', + annotation: [ + { type: 'snapshot', description: 'mainnetPrimaryExtWallet' }, + { type: 'testrail_case_field', description: 'custom_automation_id:EXT-CONTACTS-003' }, + ], + }, + async ({ unlockedExtensionPage }) => { + const contactsPage = new ContactsPage(unlockedExtensionPage); + const contact1 = TEST_CONFIG.testData.contacts.contact1; + + await contactsPage.navigateToContacts(); + await contactsPage.ensureContactExists({ + name: contact1.name, + avalancheCChain: contact1.avalancheCChain, + avalancheXP: contact1.avalancheXP, + bitcoin: contact1.bitcoin, + solana: contact1.solana, + }); + + await contactsPage.viewContactDetails(contact1.name); + await contactsPage.expandAddressesInDetails(contact1.name); + + // Verify all addresses are visible in input fields + const cChainInput = unlockedExtensionPage.locator('input').nth(1); + const xpInput = unlockedExtensionPage.locator('input').nth(2); + const bitcoinInput = unlockedExtensionPage.locator('input').nth(3); + const solanaInput = unlockedExtensionPage.locator('input').nth(4); + + await expect(cChainInput).toHaveValue(contact1.avalancheCChain); + await expect(xpInput).toHaveValue(contact1.avalancheXP); + await expect(bitcoinInput).toHaveValue(contact1.bitcoin); + await expect(solanaInput).toHaveValue(contact1.solana); + }, + ); + + test( + 'As a CORE ext user, I can copy an address from the contact details', + { + tag: '@smoke', + annotation: [ + { type: 'snapshot', description: 'mainnetPrimaryExtWallet' }, + { type: 'testrail_case_field', description: 'custom_automation_id:EXT-CONTACTS-004' }, + ], + }, + async ({ unlockedExtensionPage }) => { + const contactsPage = new ContactsPage(unlockedExtensionPage); + const contact1 = TEST_CONFIG.testData.contacts.contact1; + + await contactsPage.navigateToContacts(); + await contactsPage.ensureContactExists({ + name: contact1.name, + avalancheCChain: contact1.avalancheCChain, + avalancheXP: contact1.avalancheXP, + bitcoin: contact1.bitcoin, + solana: contact1.solana, + }); + + await contactsPage.viewContactDetails(contact1.name); + await contactsPage.hoverOverChainLabels(); + + const copyButtons = unlockedExtensionPage.getByRole('button', { name: /copy/i }); + expect(await copyButtons.count()).toBeGreaterThan(0); + + await copyButtons.first().click(); + + const clipboardContent: string = await unlockedExtensionPage.evaluate(async () => { + // @ts-expect-error - navigator is available in browser context + return await navigator.clipboard.readText(); + }); + + const isValidAddress = + clipboardContent === contact1.avalancheCChain || + clipboardContent === contact1.avalancheXP || + clipboardContent === contact1.bitcoin || + clipboardContent === contact1.solana; + expect(isValidAddress).toBe(true); + }, + ); + + test( + 'As a CORE ext user, I can edit an existing contact', + { + tag: '@smoke', + annotation: [ + { type: 'snapshot', description: 'mainnetPrimaryExtWallet' }, + { type: 'testrail_case_field', description: 'custom_automation_id:EXT-CONTACTS-005' }, + ], + }, + async ({ unlockedExtensionPage }) => { + const contactsPage = new ContactsPage(unlockedExtensionPage); + const contact1 = TEST_CONFIG.testData.contacts.contact1; + const contact2 = TEST_CONFIG.testData.contacts.contact2; + + await contactsPage.navigateToContacts(); + await contactsPage.ensureContactExists({ + name: contact1.name, + avalancheCChain: contact1.avalancheCChain, + avalancheXP: contact1.avalancheXP, + bitcoin: contact1.bitcoin, + solana: contact1.solana, + }); + + await contactsPage.editContact(contact1.name, { + name: contact2.name, + avalancheCChain: contact2.avalancheCChain, + avalancheXP: contact2.avalancheXP, + bitcoin: contact2.bitcoin, + solana: contact2.solana, + }); + + await contactsPage.navigateBackToContactsList(); + await contactsPage.verifyContactVisible(contact2.name); + + const oldContactItem = unlockedExtensionPage.locator(`text="${contact1.name}"`).first(); + await expect(oldContactItem) + .not.toBeVisible({ timeout: 5000 }) + .catch(() => {}); + }, + ); + + test( + 'As a CORE ext user, I can search for a contact', + { + tag: '@smoke', + annotation: [ + { type: 'snapshot', description: 'mainnetPrimaryExtWallet' }, + { type: 'testrail_case_field', description: 'custom_automation_id:EXT-CONTACTS-006' }, + ], + }, + async ({ unlockedExtensionPage }) => { + const contactsPage = new ContactsPage(unlockedExtensionPage); + const contact1 = TEST_CONFIG.testData.contacts.contact1; + const contact2 = TEST_CONFIG.testData.contacts.contact2; + + await contactsPage.navigateToContacts(); + + // Ensure both contacts exist + await contactsPage.ensureContactExists({ + name: contact1.name, + avalancheCChain: contact1.avalancheCChain, + avalancheXP: contact1.avalancheXP, + bitcoin: contact1.bitcoin, + solana: contact1.solana, + }); + await contactsPage.ensureContactExists({ + name: contact2.name, + avalancheCChain: contact2.avalancheCChain, + avalancheXP: contact2.avalancheXP, + bitcoin: contact2.bitcoin, + solana: contact2.solana, + }); + + const initialContactCount = await contactsPage.getContactCount(); + expect(initialContactCount).toBeGreaterThanOrEqual(2); + + // Search by name + await contactsPage.searchAndVerifyContact(contact1.name, contact1.name); + await contactsPage.clearSearch(); + expect(await contactsPage.getContactCount()).toBe(initialContactCount); + + // Search by partial name + await contactsPage.searchAndVerifyContact('Alice', contact1.name); + await contactsPage.clearSearch(); + + // Search by partial addresses + const addressTests = [ + { type: 'C-Chain', address: contact1.avalancheCChain, contact: contact1 }, + { type: 'X/P-Chain', address: contact1.avalancheXP, contact: contact1 }, + { type: 'Bitcoin', address: contact1.bitcoin, contact: contact1 }, + { type: 'Solana', address: contact1.solana, contact: contact1 }, + ]; + + for (const addressTest of addressTests) { + await contactsPage.searchAndVerifyContact(addressTest.address.substring(0, 10), addressTest.contact.name); + await contactsPage.clearSearch(); + } + + // Search by contact2's address to verify filtering + await contactsPage.searchAndVerifyContact(contact2.avalancheCChain.substring(0, 10), contact2.name); + await contactsPage.clearSearch(); + + // Search by full addresses + for (const addressTest of addressTests) { + await contactsPage.searchAndVerifyContact(addressTest.address, addressTest.contact.name); + await contactsPage.clearSearch(); + } + + // Search by contact2's full address + await contactsPage.searchAndVerifyContact(contact2.avalancheCChain, contact2.name); + }, + ); + + test( + 'As a CORE ext user, when I search for a non existent contact I see No contacts match your search state', + { + tag: '@smoke', + annotation: [ + { type: 'snapshot', description: 'mainnetPrimaryExtWallet' }, + { type: 'testrail_case_field', description: 'custom_automation_id:EXT-CONTACTS-007' }, + ], + }, + async ({ unlockedExtensionPage }) => { + const contactsPage = new ContactsPage(unlockedExtensionPage); + const contact1 = TEST_CONFIG.testData.contacts.contact1; + + await contactsPage.navigateToContacts(); + await contactsPage.ensureContactExists({ + name: contact1.name, + avalancheCChain: contact1.avalancheCChain, + avalancheXP: contact1.avalancheXP, + bitcoin: contact1.bitcoin, + solana: contact1.solana, + }); + + await contactsPage.searchContact('NonExistentContact12345'); + expect(await contactsPage.getContactCount()).toBe(0); + expect(await contactsPage.isNoSearchResultsVisible()).toBe(true); + + await contactsPage.clearSearch(); + expect(await contactsPage.getContactCount()).toBeGreaterThan(0); + }, + ); + + test( + 'As a CORE ext user, I can delete a contact', + { + tag: '@smoke', + annotation: [ + { type: 'snapshot', description: 'mainnetPrimaryExtWallet' }, + { type: 'testrail_case_field', description: 'custom_automation_id:EXT-CONTACTS-008' }, + ], + }, + async ({ unlockedExtensionPage }) => { + const contactsPage = new ContactsPage(unlockedExtensionPage); + const contact1 = TEST_CONFIG.testData.contacts.contact1; + + await contactsPage.navigateToContacts(); + + // Create a contact first + await contactsPage.addContact({ + name: contact1.name, + avalancheCChain: contact1.avalancheCChain, + avalancheXP: contact1.avalancheXP, + bitcoin: contact1.bitcoin, + solana: contact1.solana, + }); + await contactsPage.navigateBackToContactsList(); + + // Verify contact exists before deletion + await contactsPage.verifyContactVisible(contact1.name); + + // Delete the contact + await contactsPage.deleteContact(contact1.name); + + // Verify contact is deleted - check count is 0 (instant, no waiting) + const contactCount = await unlockedExtensionPage.locator(`text="${contact1.name}"`).count(); + expect(contactCount).toBe(0); + }, + ); + + test( + 'As a CORE ext user, I can delete one contact when multiple contacts exist', + { + tag: '@smoke', + annotation: [ + { type: 'snapshot', description: 'mainnetPrimaryExtWallet' }, + { type: 'testrail_case_field', description: 'custom_automation_id:EXT-CONTACTS-009' }, + ], + }, + async ({ unlockedExtensionPage }) => { + const contactsPage = new ContactsPage(unlockedExtensionPage); + const contact1 = TEST_CONFIG.testData.contacts.contact1; + const contact2 = TEST_CONFIG.testData.contacts.contact2; + + await contactsPage.navigateToContacts(); + + // Create first contact + await contactsPage.addContact({ + name: contact1.name, + avalancheCChain: contact1.avalancheCChain, + avalancheXP: contact1.avalancheXP, + bitcoin: contact1.bitcoin, + solana: contact1.solana, + }); + await contactsPage.navigateBackToContactsList(); + + // Create second contact + await contactsPage.addContact({ + name: contact2.name, + avalancheCChain: contact2.avalancheCChain, + avalancheXP: contact2.avalancheXP, + bitcoin: contact2.bitcoin, + solana: contact2.solana, + }); + await contactsPage.navigateBackToContactsList(); + + // Verify both contacts exist + await contactsPage.verifyContactVisible(contact1.name); + await contactsPage.verifyContactVisible(contact2.name); + + // Delete the first contact + await contactsPage.deleteContact(contact1.name); + + // Verify deleted contact is gone + const deletedContactCount = await unlockedExtensionPage.locator(`text="${contact1.name}"`).count(); + expect(deletedContactCount).toBe(0); + + // Verify the other contact still exists + await contactsPage.verifyContactVisible(contact2.name); + }, + ); +}); diff --git a/e2e-playwright-tests/tests/onboarding.spec.ts b/e2e-playwright-tests/tests/onboarding.spec.ts new file mode 100644 index 000000000..8ca295372 --- /dev/null +++ b/e2e-playwright-tests/tests/onboarding.spec.ts @@ -0,0 +1,442 @@ +/** + * Onboarding Tests + * Tests for the extension onboarding flow and wallet options + */ +import { test, expect } from '../fixtures/extension.fixture'; +import { OnboardingPage } from '../pages/extension/OnboardingPage'; +import { TEST_CONFIG } from '../constants'; + +test.describe('Onboarding', () => { + test( + 'As a CORE ext user, I can see Google, Apple, Manually create wallet, and Access existing wallet options', + { + tag: '@smoke', + annotation: [{ type: 'testrail_case_field', description: 'custom_automation_id:EXT-ONBOARDING-001' }], + }, + async ({ extensionPage }) => { + console.log('Verifying onboarding options...'); + + const onboardingPage = new OnboardingPage(extensionPage); + + const isOnOnboarding = await onboardingPage.isOnOnboardingPage(); + expect(isOnOnboarding).toBe(true); + console.log('Onboarding page is visible'); + + await expect(onboardingPage.continueWithGoogleButton).toBeVisible(); + console.log('"Continue with Google" button is visible'); + + await expect(onboardingPage.continueWithAppleButton).toBeVisible(); + console.log('"Continue with Apple" button is visible'); + + await expect(onboardingPage.createWalletButton).toBeVisible(); + console.log('"Manually create new wallet" button is visible'); + + await expect(onboardingPage.importWalletButton).toBeVisible(); + console.log('"Access existing wallet" button is visible'); + + console.log('All onboarding options are visible'); + }, + ); + + test( + 'As a CORE ext user, on the onboarding page, I can check the language dropdown box and verify all languages are selectable', + { + tag: '@smoke', + annotation: [{ type: 'testrail_case_field', description: 'custom_automation_id:EXT-ONBOARDING-002' }], + }, + async ({ extensionPage }) => { + console.log('Verifying language dropdown functionality...'); + + const languageSelector = extensionPage.locator('[data-testid="onboarding-language-selector"]'); + await expect(languageSelector).toBeVisible(); + console.log('Language selector is visible'); + + await languageSelector.click(); + console.log('Clicked language selector'); + + const expectedLanguages = [ + { name: 'English (English)', originalName: 'English' }, + { name: 'Chinese - Simplified (简体中文)', originalName: '简体中文' }, + { name: 'Chinese - Traditional (繁體中文)', originalName: '繁體中文' }, + { name: 'German (Deutsch)', originalName: 'Deutsch' }, + { name: 'French (Français)', originalName: 'Français' }, + { name: 'Hindi (हिन्दी)', originalName: 'हिन्दी' }, + { name: 'Japanese (日本語)', originalName: '日本語' }, + { name: 'Korean (한국인)', originalName: '한국인' }, + { name: 'Russian (Русский)', originalName: 'Русский' }, + { name: 'Spanish (Español)', originalName: 'Español' }, + { name: 'Turkish (Türkçe)', originalName: 'Türkçe' }, + ]; + + const firstLanguageOption = extensionPage.getByText('English (English)', { exact: true }); + await firstLanguageOption.waitFor({ state: 'visible', timeout: 5000 }); + console.log('Dropdown menu is visible'); + + for (const lang of expectedLanguages) { + const langOption = extensionPage.getByText(lang.name, { exact: true }); + await expect(langOption).toBeVisible(); + console.log(`Verified: ${lang.originalName} option is visible`); + } + + const germanOption = extensionPage.getByText('German (Deutsch)', { exact: true }); + await germanOption.click(); + console.log('Selected German language'); + + await expect(languageSelector).toContainText('German'); + console.log('Verified: Language changed to German'); + + await languageSelector.click(); + await firstLanguageOption.waitFor({ state: 'visible', timeout: 5000 }); + const englishOption = extensionPage.getByText('English (English)', { exact: true }); + await englishOption.click(); + console.log('Changed back to English'); + + await expect(languageSelector).toContainText('English'); + console.log('Verified: Language changed back to English'); + + console.log('All languages are selectable and functional'); + }, + ); + + test( + 'As a CORE ext user, when I select the Access existing wallet option, I can see Recovery Phrase, Ledger and Keystone options', + { + tag: '@smoke', + annotation: [{ type: 'testrail_case_field', description: 'custom_automation_id:EXT-ONBOARDING-003' }], + }, + async ({ extensionPage }) => { + console.log('Verifying import wallet options...'); + + const onboardingPage = new OnboardingPage(extensionPage); + + await onboardingPage.clickElement(onboardingPage.importWalletButton); + await onboardingPage.recoveryPhraseOption.waitFor({ state: 'visible' }); + + await expect(onboardingPage.recoveryPhraseOption).toBeVisible(); + console.log('"Manually enter a recovery phrase" option is visible'); + + await expect(onboardingPage.ledgerOption).toBeVisible(); + console.log('"Add using Ledger" option is visible'); + + await expect(onboardingPage.keystoneOption).toBeVisible(); + console.log('"Add using Keystone" option is visible'); + + console.log('All import wallet options are visible'); + }, + ); + + test( + 'As a CORE ext user, for the Access Recovery Phrase option, 12-24 words can be selectable, Clear All and Next buttons can be functional', + { + tag: '@smoke', + annotation: [{ type: 'testrail_case_field', description: 'custom_automation_id:EXT-ONBOARDING-004' }], + }, + async ({ extensionPage }) => { + console.log('Verifying recovery phrase form functionality...'); + + const onboardingPage = new OnboardingPage(extensionPage); + + await onboardingPage.navigateToRecoveryPhraseScreen(); + console.log('Recovery phrase form loaded'); + + await onboardingPage.selectWordCount(12); + await expect(onboardingPage.phraseLengthSelectorButton).toContainText('12-word phrase'); + console.log('Verified: Dropdown displays "12-word phrase"'); + + const wordInputs = await onboardingPage.getRecoveryPhraseInputs(12); + expect(wordInputs.length).toBe(12); + console.log('Verified: 12 input fields are displayed'); + + await wordInputs[0].fill('test'); + await onboardingPage.clickElement(onboardingPage.clearAllButton); + await expect(wordInputs[0]).toHaveValue(''); + console.log('Verified: Clear All button clears input fields'); + + await onboardingPage.selectWordCount(24); + await expect(onboardingPage.phraseLengthSelectorButton).toContainText('24-word phrase'); + console.log('Verified: Dropdown displays "24-word phrase"'); + + const wordInputs24 = await onboardingPage.getRecoveryPhraseInputs(24); + expect(wordInputs24.length).toBe(24); + console.log('Verified: 24 input fields are displayed'); + + await wordInputs24[0].fill('test'); + await onboardingPage.clickElement(onboardingPage.clearAllButton); + await expect(wordInputs24[0]).toHaveValue(''); + console.log('Verified: Clear All button clears 24-word input fields'); + + console.log('Recovery phrase form functionality verified'); + }, + ); + + test( + 'As a CORE ext user, for the Access Recovery Phrase option, an Invalid Phrase error can be displayed if the user types the wrong one', + { + tag: '@smoke', + annotation: [{ type: 'testrail_case_field', description: 'custom_automation_id:EXT-ONBOARDING-005' }], + }, + async ({ extensionPage }) => { + console.log('Verifying invalid recovery phrase error...'); + + const onboardingPage = new OnboardingPage(extensionPage); + const invalidWords12 = [ + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', + ]; + + await onboardingPage.navigateToRecoveryPhraseScreen(); + console.log('Recovery phrase form loaded'); + + await onboardingPage.selectWordCount(12); + await onboardingPage.fillRecoveryPhrase(invalidWords12); + console.log('Typed 12 valid BIP39 words that form an invalid recovery phrase'); + + await expect(onboardingPage.recoveryPhraseErrorMessage).toBeVisible({ timeout: 10000 }); + console.log('Error message appeared for 12-word invalid phrase'); + + await expect(onboardingPage.nextButton).toBeDisabled(); + console.log('Verified: Next button is disabled due to invalid 12-word phrase'); + + await onboardingPage.clickElement(onboardingPage.clearAllButton); + console.log('Clicked Clear All button'); + + await onboardingPage.selectWordCount(24); + const invalidWords24 = [...invalidWords12, ...invalidWords12]; + await onboardingPage.fillRecoveryPhrase(invalidWords24); + console.log('Typed 24 valid BIP39 words that form an invalid recovery phrase'); + + await expect(onboardingPage.recoveryPhraseErrorMessage).toBeVisible({ timeout: 10000 }); + console.log('Error message appeared for 24-word invalid phrase'); + + await expect(onboardingPage.nextButton).toBeDisabled(); + console.log('Verified: Next button is disabled due to invalid 24-word phrase'); + + console.log( + 'Invalid recovery phrase error validation completed: Error messages displayed correctly for both 12 and 24-word invalid phrases', + ); + }, + ); + + test( + 'As a CORE ext user, for the Access Recovery Phrase option with 12 words, I can complete the full onboarding flow including wallet details, policy links verification, newsletter validation, customize core view selection, avatar selection, and wallet completion', + { + annotation: [{ type: 'testrail_case_field', description: 'custom_automation_id:EXT-ONBOARDING-006' }], + }, + async ({ extensionPage }) => { + console.log('Verifying successful onboarding with valid 12-word recovery phrase...'); + + const onboardingPage = new OnboardingPage(extensionPage); + const validWords12 = TEST_CONFIG.wallet.recoveryPhrase12Words.split(' '); + const walletPassword = TEST_CONFIG.wallet.password; + + await onboardingPage.navigateToRecoveryPhraseScreen(); + await onboardingPage.selectWordCount(12); + await expect(onboardingPage.phraseLengthSelectorButton).toContainText('12-word phrase'); + + await onboardingPage.fillRecoveryPhrase(validWords12); + + await expect(onboardingPage.nextButton).toBeEnabled({ timeout: 10000 }); + console.log('Verified: Next button is enabled with valid 12-word phrase'); + + await onboardingPage.nextButton.click(); + console.log('Clicked Next button - navigating to wallet details page'); + + await onboardingPage.fillWalletDetails('Wallet 12-word', walletPassword); + console.log('Wallet details filled with password validation'); + + await expect(onboardingPage.nextButton).toBeEnabled({ timeout: 10000 }); + console.log('Verified: Next button is enabled with all mandatory fields filled'); + + await onboardingPage.verifyPolicyLinks(); + console.log('Verified: Policy links navigate correctly'); + + await onboardingPage.testNewsletterEmail(); + console.log('Verified: Newsletter email validation'); + + await expect(onboardingPage.nextButton).toBeEnabled(); + await onboardingPage.completePostWalletSetup(); + + console.log('Successful end-to-end onboarding with 12-word recovery phrase completed'); + }, + ); + + test( + 'As a CORE ext user, for the Access Recovery Phrase option with 24 words, I can complete the full onboarding flow including wallet details, policy links verification, newsletter validation,core view selection, avatar selection, and wallet completion', + { + annotation: [{ type: 'testrail_case_field', description: 'custom_automation_id:EXT-ONBOARDING-007' }], + }, + async ({ extensionPage }) => { + console.log('Verifying successful onboarding with valid 24-word recovery phrase...'); + + const onboardingPage = new OnboardingPage(extensionPage); + const validWords24 = TEST_CONFIG.wallet.recoveryPhrase24Words.split(' '); + const walletPassword = TEST_CONFIG.wallet.password; + + await onboardingPage.navigateToRecoveryPhraseScreen(); + await onboardingPage.selectWordCount(24); + await expect(onboardingPage.phraseLengthSelectorButton).toContainText('24-word phrase'); + + await onboardingPage.fillRecoveryPhrase(validWords24); + + await expect(onboardingPage.nextButton).toBeEnabled({ timeout: 10000 }); + console.log('Verified: Next button is enabled with valid 24-word phrase'); + + await onboardingPage.nextButton.click(); + console.log('Clicked Next button - navigating to wallet details page'); + + await onboardingPage.fillWalletDetails('Wallet 24-word', walletPassword); + console.log('Wallet details filled with password validation'); + + await expect(onboardingPage.nextButton).toBeEnabled({ timeout: 10000 }); + console.log('Verified: Next button is enabled with all mandatory fields filled'); + + await onboardingPage.verifyPolicyLinks(); + console.log('Verified: Policy links navigate correctly'); + + await onboardingPage.testNewsletterEmail(); + console.log('Verified: Newsletter email validation'); + + await expect(onboardingPage.nextButton).toBeEnabled(); + await onboardingPage.completePostWalletSetup(); + + console.log('Successful end-to-end onboarding with 24-word recovery phrase completed'); + }, + ); + + test( + 'As a CORE ext user, I can manually create a new wallet and complete the full onboarding flow', + { + annotation: [{ type: 'testrail_case_field', description: 'custom_automation_id:EXT-ONBOARDING-008' }], + }, + async ({ extensionPage }) => { + console.log('Verifying manual wallet creation flow...'); + + const onboardingPage = new OnboardingPage(extensionPage); + const walletPassword = TEST_CONFIG.wallet.password; + + await onboardingPage.clickElement(onboardingPage.createWalletButton); + console.log('Clicked "Manually create new wallet" button'); + + await onboardingPage.newSeedphraseTitle.waitFor({ state: 'visible', timeout: 10000 }); + console.log('New seedphrase screen loaded'); + + await expect(onboardingPage.newSeedphraseTitle).toBeVisible(); + console.log('Verified: Recovery phrase title is visible'); + + const listItems = extensionPage.locator('ol li'); + await listItems.first().waitFor({ state: 'visible', timeout: 5000 }); + + const seedphraseWordsArray: string[] = []; + const count = await listItems.count(); + for (let i = 0; i < count; i++) { + const itemText = await listItems.nth(i).locator('p').first().textContent(); + if (itemText && itemText.trim()) { + seedphraseWordsArray.push(itemText.trim()); + } + } + + expect(seedphraseWordsArray.length).toBeGreaterThan(0); + console.log(`Verified: ${seedphraseWordsArray.length} seedphrase words are displayed`); + + await expect(onboardingPage.copyPhraseButton).toBeVisible(); + console.log('Verified: Copy phrase button is visible'); + + await expect(onboardingPage.nextButton).toBeDisabled(); + console.log('Verified: Next button is disabled initially'); + + await onboardingPage.createWalletTermsCheckbox.check(); + console.log('Checked terms checkbox'); + + await expect(onboardingPage.nextButton).toBeEnabled({ timeout: 5000 }); + console.log('Verified: Next button is enabled after accepting terms'); + + await onboardingPage.nextButton.click(); + console.log('Clicked Next button - navigating to verify seedphrase page'); + + await onboardingPage.verifySeedphraseTitle.waitFor({ state: 'visible', timeout: 10000 }); + console.log('Verify seedphrase screen loaded'); + + await expect(onboardingPage.verifySeedphraseTitle).toBeVisible(); + console.log('Verified: Verify recovery phrase title is visible'); + + // Wait for all verification buttons to be fully loaded + await extensionPage.waitForTimeout(2000); + await extensionPage.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => { + console.log('Network not idle, continuing with verification...'); + }); + + const verificationButtons = await onboardingPage.seedphraseVerificationButtons.all(); + expect(verificationButtons.length).toBeGreaterThan(0); + console.log(`Verified: ${verificationButtons.length} verification buttons are available`); + + const verificationQuestions = extensionPage.locator('p:has-text("Select the")'); + const questionCount = await verificationQuestions.count(); + console.log(`Answering ${questionCount} seedphrase verification questions`); + + for (let i = 0; i < questionCount; i++) { + const questionText = await verificationQuestions.nth(i).textContent(); + + if (questionText?.includes('first word')) { + const firstWord = seedphraseWordsArray[0]; + const firstWordButton = extensionPage.getByRole('button', { name: firstWord, exact: true }).first(); + await firstWordButton.waitFor({ state: 'visible', timeout: 10000 }); + await firstWordButton.click(); + console.log(`Selected first word: ${firstWord}`); + } else if (questionText?.includes('last word')) { + const lastWord = seedphraseWordsArray[seedphraseWordsArray.length - 1]; + const lastWordButton = extensionPage.getByRole('button', { name: lastWord, exact: true }).first(); + await lastWordButton.waitFor({ state: 'visible', timeout: 10000 }); + await lastWordButton.click(); + console.log(`Selected last word: ${lastWord}`); + } else if (questionText?.includes('comes after')) { + const wordMatch = questionText.match(/comes after.*?([a-z]+)/i); + if (wordMatch) { + const afterWord = wordMatch[1].toLowerCase(); + const afterWordIndex = seedphraseWordsArray.indexOf(afterWord); + if (afterWordIndex !== -1 && afterWordIndex < seedphraseWordsArray.length - 1) { + const nextWord = seedphraseWordsArray[afterWordIndex + 1]; + console.log(`Looking for word "${nextWord}" that comes after "${afterWord}"`); + const nextWordButton = extensionPage.getByRole('button', { name: nextWord, exact: true }).first(); + await nextWordButton.waitFor({ state: 'visible', timeout: 10000 }); + await nextWordButton.click(); + console.log(`Selected word after "${afterWord}": ${nextWord}`); + } + } + } + } + + await expect(onboardingPage.nextButton).toBeEnabled({ timeout: 5000 }); + console.log('Verified: Next button is enabled after seedphrase verification'); + + await onboardingPage.nextButton.click(); + console.log('Clicked Next button - navigating to wallet details page'); + + await onboardingPage.fillWalletDetails('My New Wallet', walletPassword); + console.log('Wallet details filled with password validation'); + + await expect(onboardingPage.nextButton).toBeEnabled({ timeout: 10000 }); + console.log('Verified: Next button is enabled with all mandatory fields filled'); + + await onboardingPage.verifyPolicyLinks(); + console.log('Verified: Policy links navigate correctly'); + + await onboardingPage.testNewsletterEmail(); + console.log('Verified: Newsletter email validation'); + + await expect(onboardingPage.nextButton).toBeEnabled(); + await onboardingPage.completePostWalletSetup(); + + console.log('Successful end-to-end wallet creation completed'); + }, + ); +}); diff --git a/e2e-playwright-tests/tsconfig.json b/e2e-playwright-tests/tsconfig.json new file mode 100644 index 000000000..be3501679 --- /dev/null +++ b/e2e-playwright-tests/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "resolveJsonModule": true, + "types": ["node", "@playwright/test", "chrome"], + "outDir": "./dist", + "rootDir": "." + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist", "test-results"] +}